針對有些耗時比較長的任務,我們一般會想到使用非同步化的方式來進行優化邏輯。即客戶端先發起一次任務請求並攜帶回撥地址callbackUrl,然後服務端收到請求後立即返回成功,然後在後臺處理具體事務,等任務完成後再回撥客戶端,通知完成。
首先這個方案是值得肯定的,但是我們得注意幾點:1. 客戶端回撥是否可靠?2. 是否接受客戶端的主動查詢,從而從另一角度彌補各種環境的不確定性?
實際上,要提供一個狀態查詢的服務很簡單,只需查詢具體狀態值返回即可。但要實現一個可靠的回撥卻是有點難度的,今天我們就來提供一個實現思路和實現,希望能幫助到需要的同學。
1. 要實現的目標
需要先給自己定個小目標,否則就沒了方向。總體上是:要求穩定、可靠、不積壓。細化如下:
1. 正常情況下能夠及時通知到客戶端結果狀態;
2. 客戶端服務短暫異常的情況下,仍然能夠接到通知;
3. 服務端服務短暫異常的情況下,仍然能推送結果到客戶端;
4. 網路環境異常時,仍然能儘可能通知到客戶端;
5. 服務端回撥儘量不要積壓太多;
2. 實現思路
要達到以上目標,我們主要做的事也就相應出來了:
1. 使用重試機制保證儘量通知;
2. 使用次數限制保證積壓不會太嚴重;
儘管看起來只是一個重試而已,但如果控制不好,要麼給自己帶來巨大的服務壓力,要麼就是進行無效地重試。所以,我們使用一種退避演算法,提供一些重試測試,保證重試的合理性。
具體點說就是,回撥失敗後會進行重試,但每次重試都會有一定的延時控制,越往後延時越大,直到達到最大重試次數後結束。比如:
1. 第1次回撥失敗後,設定下一個回撥時間間隔為30秒;
2. 第2次回撥也失敗後,設定下一個回撥時間間隔為1分鐘;
3. 第3次回撥也失敗後,設定下一個回撥時間間隔為3分鐘;
...
那麼退避策略配置就為 30/60/180...
另外,我們需要藉助於db的持久化,保證回撥的可靠性,不至於因為機器當機而丟失回撥資訊。
3. 具體程式碼實現
我們將此實現全部封裝到一個類中,對外僅暴露一個 submitNewJobCallbackTask() 方法。如下:
import com.alibaba.fastjson.JSONObject; import com.ctrip.framework.apollo.Config; import com.ctrip.framework.apollo.ConfigService; import com.github.pagehelper.PageHelper; import com.my.common.util.HttpUtils; import com.my.common.util.SleepUtil; import com.my.enums.CallbackStatusEnum; import com.my.dao.entity.DistributeLock; import com.my.dao.entity.JobDataCallbackInfo; import com.my.dao.mapper.JobDataCallbackInfoMapper; import com.my.model.enums.DataJobStatus; import lombok.extern.log4j.Log4j2; import org.apache.lucene.util.NamedThreadFactory; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import javax.annotation.Resource; import java.util.*; import java.util.concurrent.*; /** * 功能描述: 查詢結果成功執行回撥處理任務 * */ @Log4j2 @Component public class ResultCallbackWorker implements Runnable { @Resource private JobDataCallbackInfoMapper jobDataCallbackInfoMapper; @Resource private LockService lockService; /** * 正在執行的回撥任務容器,方便進行close */ private CallbackTaskWrapperContainer runningCallbacksContainer = new CallbackTaskWrapperContainer(); /** * 執行回撥的執行緒池(佇列無限,需外部限制) */ private ScheduledExecutorService executorService = new ScheduledThreadPoolExecutor(4, new NamedThreadFactory("JobCallbackWorker"), new ThreadPoolExecutor.CallerRunsPolicy()); /** * 自動執行該任務 */ @PostConstruct public void init() { new Thread(this, "ResultCallbackWorker") .start(); } @Override public void run() { Random random = new Random(); int baseSleepSec = 50; int maxRangeSec = 120; while (!Thread.currentThread().isInterrupted()) { // dataNumLevel 代表任務飽和度,該值越大,說明等待任務越多,需要更頻繁執行 int dataNumLevel = 0; try { if(!tryCallbackTaskLock()) { dataNumLevel = random.nextInt(30); continue; } try { dataNumLevel = pullCallbackInfoFromDb(); } finally { unlock(); } } catch (Throwable e) { log.error("【任務回撥】執行資料查詢時發生了異常", e); } finally { // 新增一個隨機10s, 避免叢集機器同時請求,從而導致分配不均衡 int rndSleepSec = random.nextInt(10); int realSleepSec = baseSleepSec + rndSleepSec + maxRangeSec * (100 - dataNumLevel) * 100 / 100; SleepUtil.sleepSecs(realSleepSec); } } log.warn("【任務回撥】任務結束"); } /** * 獲取回撥執行分散式鎖() * * @return true: 成功, false: 未獲取,不執行後續邏輯 */ private boolean tryCallbackTaskLock() { String methodName = "ResultCallbackWorker"; // 悲觀鎖實現, 樂觀鎖實現 return lockService.lock(methodName); } /** * 釋放分散式鎖 * * @return true:成功 */ private boolean unlock() { String methodName = "ResultCallbackWorker"; return lockService.unlock(methodName); } /** * 從db中拉取待回撥列表並處理 * * @return 更新資料的飽和度: 滿分100, 用於後續更新拉取速率 */ private Integer pullCallbackInfoFromDb() { Integer dealNums = getNoHandlerTaskAndUpdate(10); log.info("【任務回撥】本次處理無handler的任務數:{}", dealNums); dealNums = getCallbackStatusTimeoutTaskAndUpdate(10); log.info("【任務回撥】本次處理回撥超時的任務數:{}", dealNums); return dealNums * 100 / 20; } /** * 獲取未被任何機器處理的回撥任務 * * @return 處理行數 */ private Integer getNoHandlerTaskAndUpdate(int limit) { PageHelper.startPage(1, limit, false); String[] statusEnums = { CallbackStatusEnum.WAIT_HANDLER.name() }; Map<String, Object> cond = new HashMap<>(); cond.put("statusList", statusEnums); // 拉取 5小時 ~ 1分鐘 前應該回撥的資料, 進行重試 cond.put("nextRetryTimeGt", new Date(System.currentTimeMillis() - 5 * 3600_000)); cond.put("nextRetryTimeLt", new Date(System.currentTimeMillis() - 60_000)); List<JobDataCallbackInfo> waitingCallbackInfos = jobDataCallbackInfoMapper.getExpiredCallbackTaskInfo(cond); return addRequeueCallbackTaskFromDb(waitingCallbackInfos); } /** * 獲取未被任何機器處理的回撥任務 * * @return 處理行數 */ private Integer getCallbackStatusTimeoutTaskAndUpdate(int limit) { PageHelper.startPage(1, limit, false); String[] statusEnums = { CallbackStatusEnum.HANDLER_RETRYING.name() }; Map<String, Object> cond = new HashMap<>(); cond.put("statusList", statusEnums); // 只處理6小時前的資料 cond.put("updateTimeGt", new Date(System.currentTimeMillis() - 6 * 3600_000L)); // 5小時 ~ 1分鐘 前應該回撥的資料 cond.put("nextRetryTimeGt", new Date(System.currentTimeMillis() - 5 * 3600_000)); cond.put("nextRetryTimeLt", new Date(System.currentTimeMillis() - 60_000)); cond.put("nowMinusUpdateTimeGt", 600); List<JobDataCallbackInfo> waitingCallbackInfos = jobDataCallbackInfoMapper.getExpiredCallbackTaskInfo(cond); return addRequeueCallbackTaskFromDb(waitingCallbackInfos); } /** * 將從db撈取出的待回撥的任務,放入本地佇列進行回撥 * * @param waitingCallbackInfos 待處理的任務(from db) */ private Integer addRequeueCallbackTaskFromDb(List<JobDataCallbackInfo> waitingCallbackInfos) { int submittedTaskNum = 0; for (JobDataCallbackInfo callbackInfo : waitingCallbackInfos) { // 佇列已滿,不再新增資料 if(!submitTaskImmediately(callbackInfo)) { return submittedTaskNum; } submittedTaskNum++; updateCallbackFinalStatus(callbackInfo, CallbackStatusEnum.HANDLER_RETRYING, false); } return submittedTaskNum; } /** * 提交一個新的job回撥任務 * * @param jobId 非同步任務id * @param jobStatus 任務狀態 * @param callbackUrl 回撥地址 * @param bizId 業務id */ public void submitNewJobCallbackTask(String jobId, DataJobStatus jobStatus, String callbackUrl, String bizId) { JobDataCallbackInfo callbackInfo = new JobDataCallbackInfo(); callbackInfo.setJobId(jobId); callbackInfo.setBizId(bizId); callbackInfo.setBizType("offline_pull_data_job"); callbackInfo.setJobStatus(jobStatus.name()); callbackInfo.setCallbackStatus(CallbackStatusEnum.HANDLER_RETRYING); int retryTimes = 0; callbackInfo.setNextRetryTime( getNextRetryTimeWithPolicy(retryTimes)); callbackInfo.setRetryTimes(retryTimes); callbackInfo.setCallbackUrl(callbackUrl); jobDataCallbackInfoMapper.insert(callbackInfo); if(!submitTaskImmediately(callbackInfo)) { updateCallbackFinalStatus(callbackInfo, CallbackStatusEnum.WAIT_HANDLER); } } /** * 立即提交一個任務到 * * @param callbackInfo 回撥任務資訊 * @return true: 提交成功, false: 提交失敗 */ private boolean submitTaskImmediately(JobDataCallbackInfo callbackInfo) { if(runningCallbacksContainer.reachMaxQueue()) { return true; } Future<?> taskFuture = executorService.submit(() -> callback(callbackInfo)); boolean addSuccess = runningCallbacksContainer.addTask(callbackInfo, taskFuture); assert addSuccess; return true; } /** * 執行某個回撥任務的處理邏輯 * * @param callbackInfo 回撥引數資訊 */ private void callback(JobDataCallbackInfo callbackInfo) { boolean callSuccess = false; try { callSuccess = doCallback(callbackInfo.getCallbackUrl(), callbackInfo.getJobId(), callbackInfo.getBizId(), DataJobStatus.valueOf(callbackInfo.getJobStatus())); } catch (Throwable e) { log.error("【回撥任務】回撥呼叫方失敗,稍後將進行重試, jobId:{}", callbackInfo.getBizId(), e); } finally { log.info("【回撥任務】回撥完成:{}, jobId:{}", callbackInfo.getCallbackUrl(), callbackInfo.getJobId()); if(callSuccess) { updateCallbackFinalStatus(callbackInfo, CallbackStatusEnum.SUCCESS); } else { requeueFailedCallbackTaskIfNecessary(callbackInfo); } } } /** * 關機時,儲存當前任務狀態 */ public void shutdown() { runningCallbacksContainer.cancelAllTask(); } /** * 重新入隊回撥失敗的佇列(延時自行斷定) * * @param callbackInfo 上一次回撥資訊 */ private void requeueFailedCallbackTaskIfNecessary(JobDataCallbackInfo callbackInfo) { Config config = ConfigService.getAppConfig(); Integer maxRetryTimes = config.getIntProperty( "_job_finish_callback_retry_max_times", 7); if(callbackInfo.getRetryTimes() >= maxRetryTimes) { updateCallbackFinalStatus(callbackInfo, CallbackStatusEnum.FAILED); return; } nextRetryCallback(callbackInfo); } /** * 進入下一次回撥重試操作 * * @param callbackInfo 回撥任務資訊 */ private void nextRetryCallback(JobDataCallbackInfo callbackInfo) { int retryTimes = callbackInfo.getRetryTimes() + 1; callbackInfo.setRetryTimes(retryTimes); Date nextRetryTime = getNextRetryTimeWithPolicy(retryTimes); callbackInfo.setNextRetryTime(nextRetryTime); jobDataCallbackInfoMapper.update(callbackInfo); // 延時排程 Future<?> taskFuture = executorService.schedule(() -> callback(callbackInfo), nextRetryTime.getTime() - System.currentTimeMillis(), TimeUnit.MILLISECONDS); boolean addSuccess = runningCallbacksContainer.addTask(callbackInfo, taskFuture); assert !addSuccess; } /** * 回撥任務終態更新(SUCCESS, FAILED, CANCELED) * * 或者不再被本次呼叫的任務,都會更新當前狀態 * * @param callbackInfo 回撥任務基本資訊 * @param callbackStatus 當次回撥結果 */ private void updateCallbackFinalStatus(JobDataCallbackInfo callbackInfo, CallbackStatusEnum callbackStatus) { updateCallbackFinalStatus(callbackInfo, callbackStatus, true); } /** * 更新db狀態,同時處理本地佇列 * * @param removeRunningTask 是否移除本地佇列 * @see #updateCallbackFinalStatus(JobDataCallbackInfo, CallbackStatusEnum) */ private void updateCallbackFinalStatus(JobDataCallbackInfo callbackInfo, CallbackStatusEnum callbackStatus, boolean removeRunningTask) { callbackInfo.setCallbackStatus(callbackStatus); jobDataCallbackInfoMapper.update(callbackInfo); if(removeRunningTask) { runningCallbacksContainer.taskFinish(callbackInfo); } } /** * 回撥客戶端,通知任務結果 * * @param jobId 任務jobId * @param jobStatus 執行狀態 * @return true: 成功 */ private boolean doCallback(String callbackUrl, String jobId, String bizId, DataJobStatus jobStatus) throws Exception { log.info("【回撥任務】回撥客戶端:{} jobId:{}, jobStatus:{}", callbackUrl, jobId, jobStatus); Map<String, Object> params = new HashMap<>(); params.put("jobId", jobId); params.put("jobStatus", jobStatus); params.put("bizId", bizId); String response = HttpUtils.post(callbackUrl, JSONObject.toJSONString(params)); log.info("【回撥任務】回撥成功:{}, response:{}", callbackUrl, response); // 業務收到請求,應儘快響應成功結果, 響應 success 則成功 return "success".equals(response); } /** * 根據重試次數,獲取相應的延時策略生成下一次重試時間 * * 退避演算法實現1 * * @param retryTimes 重試次數, 0, 1, 2... * @return 下一次重試時間 */ private Date getNextRetryTimeWithPolicy(int retryTimes) { if(retryTimes < 1) { retryTimes = 1; } Config config = ConfigService.getAppConfig(); String retryIntervalPolicy = config.getProperty( "job_finish_callback_retry_policy", "30/60/180/1800/1800/1800/3600"); String[] retryIntervalArr = retryIntervalPolicy.split("/"); if(retryTimes > retryIntervalArr.length) { retryTimes = retryIntervalArr.length; } String hitPolicy = retryIntervalArr[retryTimes - 1]; return new Date(System.currentTimeMillis() + Integer.valueOf(hitPolicy) * 1000L); } /** * 回撥任務管理容器 */ private class CallbackTaskWrapperContainer { /** * 正在執行的回撥任務容器,方便進行close */ private Map<Long, CallbackTaskWrapper> runningCallbacksContainer = new ConcurrentHashMap<>(); /** * 新增一個回撥任務(正在執行) * * @param callbackInfo 回撥資訊 * @param taskFuture 非同步任務例項 */ boolean addTask(JobDataCallbackInfo callbackInfo, Future<?> taskFuture) { CallbackTaskWrapper oldTaskWrapper = runningCallbacksContainer.put(callbackInfo.getId(), new CallbackTaskWrapper(callbackInfo, taskFuture)); return oldTaskWrapper == null; } /** * 某任務完成處理 */ void taskFinish(JobDataCallbackInfo callbackInfo) { runningCallbacksContainer.remove(callbackInfo.getId()); } /** * 某任務取消處理 */ void cancelTask(JobDataCallbackInfo callbackInfo) { taskWrapper.cancel(); updateCallbackFinalStatus(callbackInfo, CallbackStatusEnum.CANCELED); taskFinish(callbackInfo); } /** * 取消所有記憶體任務, 重新放入等待佇列 */ void cancelAllTask() { // 遍歷 running task, 更新為 WAIT_HANDLER for (CallbackTaskWrapper taskWrapper : runningCallbacksContainer.values()) { taskWrapper.cancel(); updateCallbackFinalStatus(taskWrapper.getCallbackInfo(), CallbackStatusEnum.WAIT_HANDLER); taskFinish(taskWrapper.getCallbackInfo()); } } /** * 檢查回撥任務佇列是否達到最大值 * * @return true:已到最大值, false:還可以接收新資料 */ boolean reachMaxQueue() { int retryQueueMaxSize = 4096; return runningCallbacksContainer.size() > retryQueueMaxSize; } } /** * 回撥任務包裝器 */ private class CallbackTaskWrapper { /** * 任務資訊實體 */ private JobDataCallbackInfo callbackInfo; /** * 非同步任務控制 */ private Future<?> taskFuture; CallbackTaskWrapper(JobDataCallbackInfo callbackInfo, Future<?> taskFuture) { this.callbackInfo = callbackInfo; this.taskFuture = taskFuture; } void rolloverFuture(Future<?> taskFuture) { this.taskFuture = taskFuture; } JobDataCallbackInfo getCallbackInfo() { return callbackInfo; } Future<?> getTaskFuture() { return taskFuture; } void cancel() { taskFuture.cancel(true); callbackInfo = null; } } }
其中,有一個重要的回撥任務資訊的資料結構參考如下:
CREATE TABLE `t_job_data_callback_info` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵id', `job_id` varchar(64) NOT NULL COMMENT '任務id', `callback_status` varchar(30) NOT NULL DEFAULT 'WAIT_HANDLER' COMMENT '回撥狀態,SUCCESS:回撥成功,HANDLER_RETRYING:被執行回撥中, WAIT_HANDLER:回撥任務等待被接收處理, FAILED:回撥最終失敗, CANCELED:主動取消', `callback_url` varchar(300) DEFAULT '' COMMENT '回撥地址', `job_status` varchar(30) DEFAULT NULL COMMENT '任務執行狀態,冗餘欄位,回撥時使用', `retry_times` int(6) NOT NULL DEFAULT '0' COMMENT '已重試次數', `biz_id` varchar(200) DEFAULT '' COMMENT '業務id, 看業務作用', `next_retry_time` datetime NOT NULL COMMENT '下一次執行回撥重試的時間', `err_msg` varchar(3000) DEFAULT '' COMMENT '錯誤資訊描述', `server_ip` varchar(32) NOT NULL DEFAULT '' COMMENT '執行任務的機器', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新時間', PRIMARY KEY (`id`), KEY `job_id` (`job_id`), KEY `next_retry_time` (`next_retry_time`,`callback_status`), KEY `update_time` (`update_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='非同步任務回撥客戶端資訊表';
以上基本就是整個的可靠優雅的回撥實現了,其中一基礎的db操作,列舉類之類的就不用細化了。
核心大部分可以簡單描述為前面所說的重試機制. 但還有一點值得說明的是, 為了避免任務在叢集環境中分佈不均勻, 所以使用了一個飽和度+隨機值延時的方式, 讓每個機器都有差不多的機會執行回撥任務.(不過具體的分佈均勻性, 還需要實踐去驗證才行, 可以通過統計server_ip檢視)
4. 時序圖
下面以一個時序圖, 展示整體工作流程的全貌: