退避演算法實現之客戶端優雅回撥

等你歸去來發表於2020-09-05

  針對有些耗時比較長的任務,我們一般會想到使用非同步化的方式來進行優化邏輯。即客戶端先發起一次任務請求並攜帶回撥地址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. 時序圖

  下面以一個時序圖, 展示整體工作流程的全貌:

 

 

相關文章