如何做一個實時的業務統計的監控?比如分鐘級?也就是每分鐘可以快速看到業務的變化趨勢,及可以做一些簡單的分組查詢?
哎,你可能說很簡單了,直接從資料庫 count 就可以了! 你是對的。
但如果不允許你使用db進行count呢?因為線上資料庫資源可是很寶貴的哦,你這一count可能會給db帶來災難了。
那不然咋整?
沒有db,我們還有其他資料來源嘛,比如: 訊息佇列?埋點資料? 本文將是基於該前提而行。
做監控,儘量不要侵入業務太多!所以有一個訊息中介軟體是至關重要的。針對大資料系統,一般是: kafka 或者 類kafka. (如本文基礎 loghub)
有了訊息中介軟體,如何進行分鐘級監控? 這個應該就很簡單了吧。不過如果要自己實現,其實坑也不少的!
如果自己實現計數,那麼你可能需要做以下幾件事:
1. 每消費一個訊息,你需要一個累加器;
2. 每隔一個週期,你可能需要一個歸檔操作;
3. 你可能需要考慮各種併發安全問題;
4. 你可能需要考慮種效能問題;
5. 你可能需要考慮各種機器故障問題;
6. 你可能需要考慮各種邊界值問題;
哎,其實沒那麼難。時間序列資料庫,就專門為這類事情而生!如OpenTSDB: http://opentsdb.net/overview.html
可以說,TSDB 是這類應用場景的殺手鐗。或者基於流計算框架: 如flink, 也是很輕鬆完成的事。但是不是本文的方向,略過!
本文是基於 loghub 的現有資料,進行分鐘級統計後,入庫 mysql 中,從而支援隨時查詢。(因loghub每次查詢都是要錢的,所以,不可能直接查詢)
loghub 資料結構如: 2019-07-10 10:01:11,billNo,userId,productCode,...
由於loghub提供了很多強大的查詢統計功能,所以我們可以直接使用了。
核心功能就是一個統計sql,還是比較簡單的。但是需要考慮的點也不少,接下來,將為看官們奉上一個完整的解決方案!
擼程式碼去!
1. 核心統計任務實現類 MinuteBizDataCounterTask
import com.aliyun.openservices.log.Client; import com.aliyun.openservices.log.common.LogContent; import com.aliyun.openservices.log.common.LogItem; import com.aliyun.openservices.log.common.QueriedLog; import com.aliyun.openservices.log.exception.LogException; import com.aliyun.openservices.log.response.GetLogsResponse; import com.my.service.statistics.StatisticsService; import com.my.entity.BizDataStatisticsMin; import com.my.model.LoghubQueryCounterOffsetModel; import com.my.util.loghub.LogHubProperties; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.annotation.Resource; import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; /** * 基於loghub 的分鐘級 統計任務 */ @Component @Slf4j public class MinuteBizDataCounterTask implements Runnable { @Resource private LogHubProperties logHubProperties; @Resource private StatisticsService statisticsService; @Resource(name = "defaultOffsetQueryTaskCallback") private DefaultOffsetQueryTaskCallbackImpl defaultOffsetQueryTaskCallback; /** * loghub 客戶端 */ private volatile Client mClient; /** * 過濾的topic */ private static final String LOGHUB_TOPIC = "topic_test"; /** * 單次掃描loghub最大時間 間隔分鐘數 */ @Value("${loghub.offset.counter.perScanMaxMinutesGap}") private Integer perScanMaxMinutesGap; /** * 單次迴圈最大數 */ @Value("${loghub.offset.counter.perScanMaxRecordsLimit}") private Integer perScanMaxRecordsLimit; /** * 構造必要例項資訊 */ public ProposalPolicyBizDataCounterTask() { } @Override public void run() { if(mClient == null) { this.mClient = new Client(logHubProperties.getEndpoint(), logHubProperties.getAccessKeyId(), logHubProperties.getAccessKey()); } while (!Thread.interrupted()) { try { updateLastMinutePolicyNoCounter(); Thread.sleep(60000); } catch (InterruptedException e) { log.error("【分鐘級統計task】, sleep 中斷", e); Thread.currentThread().interrupt(); } catch (Exception e) { // 注意此處可能有風險,發生異常後將快速死迴圈 log.error("【分鐘級統計task】更新異常", e); try { Thread.sleep(10000); } catch (InterruptedException ex) { log.error("【分鐘級統計task】異常,且sleep異常", ex); Thread.currentThread().interrupt(); } } } } /** * 更新最近的資料 (分鐘級) * * @throws LogException loghub查詢異常時丟擲 */ private void updateLastMinutePolicyNoCounter() throws LogException { updateMinutePolicyNoCounter(null); } /** * 更新最近的資料 */ public Integer updateMinutePolicyNoCounter(LoghubQueryCounterOffsetModel specifyOffset) throws LogException { // 1. 獲取偏移量 // 2. 根據偏移量,判定是否可以一次性取完,或者多次獲取更新 // 3. 從loghub中設定偏移量,獲取統計資料,更新 // 4. 更新db資料統計值 // 5. 更新偏移量 // 6. 等待下一次更新 // 指定offset時,可能為補資料 final LoghubQueryCounterOffsetModel destOffset = enhanceQueryOffset(specifyOffset); initSharedQueryOffset(destOffset, destOffset == specifyOffset); Integer totalAffectNum = 0; while (!isScanFinishOnDestination(destOffset)) { // 完整掃描一次時間週期 calcNextSharedQueryOffset(destOffset); while (true) { calcNextInnerQueryOffset(); ArrayList<QueriedLog> logs = queryPerMinuteStatisticFromLoghubOnCurrentOffset(); Integer affectNum = handleMiniOffsetBatchCounter(logs); totalAffectNum += affectNum; log.info("【分鐘級統計task】本次更新資料:{}, offset:{}", affectNum, getCurrentSharedQueryOffset()); if(!hasMoreDataOffset(logs.size())) { rolloverOffsetAndCommit(); break; } } } log.info("【分鐘級統計task】本次更新資料,總共:{}, destOffset:{}, curOffset:{}", totalAffectNum, destOffset, getCurrentSharedQueryOffset()); rolloverOffsetAndCommit(); return totalAffectNum; } /** * 處理一小批的統計資料 * * @param logs 小批統計loghub資料 * @return 影響行數 */ private Integer handleMiniOffsetBatchCounter(ArrayList<QueriedLog> logs) { if (logs == null || logs.isEmpty()) { return 0; } List<BizDataStatisticsMin> statisticsMinList = new ArrayList<>(); for (QueriedLog log1 : logs) { LogItem getLogItem = log1.GetLogItem(); BizDataStatisticsMin statisticsMin1 = adaptStatisticsMinDbData(getLogItem); statisticsMin1.setEventCode(PROPOSAL_FOUR_IN_ONE_TOPIC); statisticsMin1.setEtlVersion(getCurrentScanTimeDuring() + ":" + statisticsMin1.getStatisticsCount()); statisticsMinList.add(statisticsMin1); } return statisticsService.batchUpsertPremiumStatistics(statisticsMinList, getCurrentOffsetCallback()); } /** * 獲取共享偏移資訊 * * @return 偏移 */ private LoghubQueryCounterOffsetModel getCurrentSharedQueryOffset() { return defaultOffsetQueryTaskCallback.getCurrentOffset(); } /** * 判斷本次是否掃描完成 * * @param destOffset 目標偏移 * @return true:掃描完成, false: 未完成 */ private boolean isScanFinishOnDestination(LoghubQueryCounterOffsetModel destOffset) { return defaultOffsetQueryTaskCallback.getEndTime() >= destOffset.getEndTime(); } /** * 獲取偏移提交回撥器 * * @return 回撥例項 */ private OffsetQueryTaskCallback getCurrentOffsetCallback() { return defaultOffsetQueryTaskCallback; } /** * 初始化共享的查詢偏移變數 * * @param destOffset 目標偏移 * @param isSpecifyOffset 是否是手動指定的偏移 */ private void initSharedQueryOffset(LoghubQueryCounterOffsetModel destOffset, boolean isSpecifyOffset) { // 整分花時間資料 Integer queryStartTime = destOffset.getStartTime(); if(queryStartTime % 60 != 0) { queryStartTime = queryStartTime / 60 * 60; } // 將目標掃描時間終點 設定為起點,以備後續迭代 defaultOffsetQueryTaskCallback.initCurrentOffset(queryStartTime, queryStartTime, destOffset.getOffsetStart(), destOffset.getLimit(), destOffset.getIsNewStep(), isSpecifyOffset); if(defaultOffsetQueryTaskCallback.getIsNewStep()) { resetOffsetDefaultSettings(); } } /** * 計算下一次統計偏移時間 * * @param destOffset 目標偏移值 */ private void calcNextSharedQueryOffset(LoghubQueryCounterOffsetModel destOffset) { int perScanMaxSecondsGap = perScanMaxMinutesGap * 60; if(destOffset.getEndTime() - defaultOffsetQueryTaskCallback.getStartTime() > perScanMaxSecondsGap) { defaultOffsetQueryTaskCallback.setStartTime(defaultOffsetQueryTaskCallback.getEndTime()); int nextExpectEndTime = defaultOffsetQueryTaskCallback.getStartTime() + perScanMaxSecondsGap; if(nextExpectEndTime > destOffset.getEndTime()) { nextExpectEndTime = destOffset.getEndTime(); } defaultOffsetQueryTaskCallback.setEndTime(nextExpectEndTime); } else { defaultOffsetQueryTaskCallback.setStartTime(defaultOffsetQueryTaskCallback.getEndTime()); defaultOffsetQueryTaskCallback.setEndTime(destOffset.getEndTime()); } resetOffsetDefaultSettings(); } /** * 重置偏移預設配置 */ private void resetOffsetDefaultSettings() { defaultOffsetQueryTaskCallback.setIsNewStep(true); defaultOffsetQueryTaskCallback.setOffsetStart(0); defaultOffsetQueryTaskCallback.setLimit(0); } /** * 計算下一次小偏移,此種情況應對 一次外部偏移未查詢完成的情況 */ private void calcNextInnerQueryOffset() { defaultOffsetQueryTaskCallback.setIsNewStep(false); // 第一次計算時,limit 為0, 所以得出的 offsetStart 也是0 defaultOffsetQueryTaskCallback.setOffsetStart( defaultOffsetQueryTaskCallback.getOffsetStart() + defaultOffsetQueryTaskCallback.getLimit()); defaultOffsetQueryTaskCallback.setLimit(perScanMaxRecordsLimit); } /** * 獲取當前迴圈的掃描區間 * * @return 15567563433-1635345099 區間 */ private String getCurrentScanTimeDuring() { return defaultOffsetQueryTaskCallback.getStartTime() + "-" + defaultOffsetQueryTaskCallback.getEndTime(); } /** * 從loghub查詢每分鐘的統計資訊 * * @return 查詢到的統計資訊 * @throws LogException loghub 異常時丟擲 */ private ArrayList<QueriedLog> queryPerMinuteStatisticFromLoghubOnCurrentOffset() throws LogException { // 先按保單號去重,再進行計數統計 String countSql = "* | split(bizData, ',')[5] policyNo, bizData GROUP by split(bizData, ',')[5] " + " | select count(1) as totalCountMin, " + "split(bizData, ',')[2] as productCode," + "split(bizData, ',')[3] as schemaCode," + "split(bizData, ',')[4] as channelCode," + "substr(split(bizData, ',')[1], 1, 16) as myDateTimeMinute " + "group by substr(split(bizData, ',')[1], 1, 16), split(bizData, ',')[2],split(bizData, ',')[3], split(bizData, ',')[4],split(bizData, ',')[7], split(bizData, ',')[8]"; countSql += " limit " + defaultOffsetQueryTaskCallback.getOffsetStart() + "," + defaultOffsetQueryTaskCallback.getLimit(); GetLogsResponse countResponse = mClient.GetLogs(logHubProperties.getProjectName(), logHubProperties.getBizCoreDataLogStore(), defaultOffsetQueryTaskCallback.getStartTime(), defaultOffsetQueryTaskCallback.getEndTime(), LOGHUB_TOPIC, countSql); if(!countResponse.IsCompleted()) { log.error("【分鐘級統計task】掃描獲取到未完整的資料,請速檢查原因,offSet:{}", getCurrentSharedQueryOffset()); } return countResponse.GetLogs() == null ? new ArrayList<>() : countResponse.GetLogs(); } /** * 根據上一次返回的記錄數量,判斷是否還有更多資料 * * @param lastGotRecordsCount 上次返回的記錄數 (資料量大於最大數說明還有未取完資料) * @return true: 是還有更多資料應該再迴圈獲取, false: 無更多資料結束本期任務 */ private boolean hasMoreDataOffset(int lastGotRecordsCount) { return lastGotRecordsCount >= perScanMaxRecordsLimit; } /** * 加強版的 offset 優先順序: 指定偏移 -> 基於快取的偏移 -> 新生成偏移標識 * * @param specifyOffset 指定偏移(如有) * @return 偏移標識 */ private LoghubQueryCounterOffsetModel enhanceQueryOffset(LoghubQueryCounterOffsetModel specifyOffset) { if(specifyOffset != null) { return specifyOffset; } LoghubQueryCounterOffsetModel offsetBaseOnCache = getNextOffsetBaseOnCache(); if(offsetBaseOnCache != null) { return offsetBaseOnCache; } return generateNewOffset(); } /** * 基於快取獲取一下偏移標識 * * @return 偏移 */ private LoghubQueryCounterOffsetModel getNextOffsetBaseOnCache() { LoghubQueryCounterOffsetModel offsetFromCache = defaultOffsetQueryTaskCallback.getCurrentOffsetFromCache(); if(offsetFromCache == null) { return null; } LocalDateTime now = LocalDateTime.now(); LocalDateTime nowMinTime = LocalDateTime.of(now.getYear(), now.getMonth(), now.getDayOfMonth(), now.getHour(), now.getMinute()); // 如果上次仍未內部迴圈完成,則使用原來的 if(offsetFromCache.getIsNewStep()) { offsetFromCache.setStartTime(offsetFromCache.getEndTime()); long endTime = nowMinTime.toEpochSecond(ZoneOffset.of("+8")); offsetFromCache.setEndTime((int) endTime); } return offsetFromCache; } /** * 生成新的完整的 偏移標識 * * @return 新偏移 */ private LoghubQueryCounterOffsetModel generateNewOffset() { LoghubQueryCounterOffsetModel offsetNew = new LoghubQueryCounterOffsetModel(); LocalDateTime now = LocalDateTime.now(); LocalDateTime nowMinTime = LocalDateTime.of(now.getYear(), now.getMonth(), now.getDayOfMonth(), now.getHour(), now.getMinute()); long startTime = nowMinTime.minusDays(1).toEpochSecond(ZoneOffset.of("+8")); long endTime = nowMinTime.toEpochSecond(ZoneOffset.of("+8")); offsetNew.setStartTime((int) startTime); offsetNew.setEndTime((int) endTime); return offsetNew; } /** * 將日誌返回資料 適配到資料庫記錄中 * * @param logItem 日誌詳情 * @return db資料結構對應 */ private BizDataStatisticsMin adaptStatisticsMinDbData(LogItem logItem) { ArrayList<LogContent> logContents = logItem.GetLogContents(); BizDataStatisticsMin statisticsMin1 = new BizDataStatisticsMin(); for (LogContent logContent : logContents) { switch (logContent.GetKey()) { case "totalCountMin": statisticsMin1.setStatisticsCount(Integer.valueOf(logContent.GetValue())); break; case "productCode": statisticsMin1.setProductCode(logContent.GetValue()); break; case "myDateTimeMinute": String signDtMinStr = logContent.GetValue(); String[] dateTimeArr = signDtMinStr.split(" "); String countDate = dateTimeArr[0]; String[] timeArr = dateTimeArr[1].split(":"); String countHour = timeArr[0]; String countMin = timeArr[1]; statisticsMin1.setCountDate(countDate); statisticsMin1.setCountHour(countHour); statisticsMin1.setCountMin(countMin); break; default: break; } } return statisticsMin1; } /** * 重置預設值,同時提交當前 (滾動到下一個偏移點) */ private void rolloverOffsetAndCommit() { resetOffsetDefaultSettings(); commitOffsetSync(); } /** * 提交偏移量 * */ private void commitOffsetSync() { defaultOffsetQueryTaskCallback.commit(); } }
主要實現邏輯如下:
1. 每隔一分鐘進行一個查詢;
2. 發生異常後,容錯繼續查詢;
3. 對於一個新統計,預設倒推一天範圍進行統計;
4. 統計時間範圍間隔可設定,避免一次查詢數量太大,費用太高且查詢返回數量有限;
5. 對於每次小批量查詢,支援分佈操作,直到取完資料;
6. 小批量資料完成後,自動提交查詢偏移;
7. 後續查詢將基礎提交的偏移進行;
8. 支援斷點查詢;
2. 偏移提交管理器 OffsetQueryTaskCallback
主任務中,只管進行資料統計查詢,提交偏移操作由其他類進行;
/** * 普通任務回撥介面定義, 考慮到多種型別的統計任務偏移操作方式可能不一,定義一個通用型偏移介面 * */ public interface OffsetQueryTaskCallback { /** * 回撥方法入口, 提交偏移 */ public void commit(); /** * 設定初始化繫結當前偏移(期間不得改變) * * @param startTime 偏移開始時間 * @param endTime 偏移結束時間 * @param offsetStart 偏移開始值(分頁) * @param limit 單次取值最大數(分頁) * @param isNewStep 是否是新的查詢 * @param isSpecifyOffset 是否是指定的偏移 */ public void initCurrentOffset(Integer startTime, Integer endTime, Integer offsetStart, Integer limit, Boolean isNewStep, Boolean isSpecifyOffset); /** * 從當前環境中獲取當前偏移資訊 * * @return 偏移變數例項 */ public LoghubQueryCounterOffsetModel getCurrentOffset(); } import com.alibaba.fastjson.JSONObject; import com.my.util.constants.RedisKeysConstantEnum; import com.my.util.redis.RedisPoolUtil; import com.my.model.LoghubQueryCounterOffsetModel; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import javax.annotation.Resource; /** * 預設偏移回撥實現 * */ @Component("defaultOffsetQueryTaskCallback") @Slf4j public class DefaultOffsetQueryTaskCallbackImpl implements OffsetQueryTaskCallback { @Resource private RedisPoolUtil redisPoolUtil; /** * 當前偏移資訊 */ private ThreadLocal<LoghubQueryCounterOffsetModel> currentOffsetHolder = new ThreadLocal<>(); @Override public void commit() { if(!currentOffsetHolder.get().getIsSpecifyOffset()) { redisPoolUtil.set(RedisKeysConstantEnum.STATISTICS_COUNTER_OFFSET_CACHE_KEY.getRedisKey(), JSONObject.toJSONString(currentOffsetHolder.get())); } } @Override public void initCurrentOffset(Integer startTime, Integer endTime, Integer offsetStart, Integer limit, Boolean isNewStep, Boolean isSpecifyOffset) { LoghubQueryCounterOffsetModel currentOffset = new LoghubQueryCounterOffsetModel(); currentOffset.setStartTime(startTime); currentOffset.setEndTime(endTime); currentOffset.setOffsetStart(offsetStart); currentOffset.setIsNewStep(isNewStep); currentOffset.setIsSpecifyOffset(isSpecifyOffset); currentOffsetHolder.set(currentOffset); } @Override public LoghubQueryCounterOffsetModel getCurrentOffset() { return currentOffsetHolder.get(); } /** * 從快取中獲取當前偏移資訊 * * @return 快取偏移或者 null */ public LoghubQueryCounterOffsetModel getCurrentOffsetFromCache() { String offsetCacheValue = redisPoolUtil.get(RedisKeysConstantEnum.STATISTICS_COUNTER_OFFSET_CACHE_KEY.getRedisKey()); if (StringUtils.isBlank(offsetCacheValue)) { return null; } return JSONObject.parseObject(offsetCacheValue, LoghubQueryCounterOffsetModel.class); } public Integer getStartTime() { return currentOffsetHolder.get().getStartTime(); } public void setStartTime(Integer startTime) { currentOffsetHolder.get().setStartTime(startTime); } public Integer getEndTime() { return currentOffsetHolder.get().getEndTime(); } public void setEndTime(Integer endTime) { currentOffsetHolder.get().setEndTime(endTime); } public Integer getOffsetStart() { return currentOffsetHolder.get().getOffsetStart(); } public void setOffsetStart(Integer offsetStart) { currentOffsetHolder.get().setOffsetStart(offsetStart); } public Integer getLimit() { return currentOffsetHolder.get().getLimit(); } public void setLimit(Integer limit) { currentOffsetHolder.get().setLimit(limit); } public Boolean getIsNewStep() { return currentOffsetHolder.get().getIsNewStep(); } public void setIsNewStep(Boolean isNewStep) { currentOffsetHolder.get().setIsNewStep(isNewStep); } } /** * loghub 查詢偏移量 資料容器 * */ @Data public class LoghubQueryCounterOffsetModel implements Serializable { private static final long serialVersionUID = -3749552331349228045L; /** * 開始時間 */ private Integer startTime; /** * 結束時間 */ private Integer endTime; /** * 起始偏移 */ private Integer offsetStart = 0; /** * 每次查詢的 條數限制, 都需要進行設定後才可用, 否則查無資料 */ private Integer limit = 0; /** * 是否新的偏移迴圈,如未完成,應繼續子迴圈 limit * * true: 是, offsetStart,limit 失效, false: 否, 需藉助 offsetStart,limit 進行limit相加 */ private Boolean isNewStep = true; /** * 是否是手動指定的偏移,如果是說明是在手動被資料,偏移量將不會被更新 * * 此變數是瞬時值,將不會被持久化到偏移標識中 */ private transient Boolean isSpecifyOffset; }
3. 批量更新統計結果資料庫的實現
因每次統計的資料量是不確定的,因儘可能早的提交一次統計結果,防止一次提交太多,或者 機器故障時所有統計白費,所以需要分小事務進行。
@Service public class StatisticsServiceImpl implements StatisticsService { /** * 批量更新統計分鐘級資料 (事務型提交) * * @param statisticsMinList 新統計資料 * @return 影響行數 */ @Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = Throwable.class) public Integer batchUpsertPremiumStatistics(List<BizProposalPolicyStatisticsMin> statisticsMinList, OffsetQueryTaskCallback callback) { AtomicInteger updateCount = new AtomicInteger(0); statisticsMinList.forEach(item -> { int affectNum = 0; BizProposalPolicyStatisticsMin oldStatistics = bizProposalPolicyStasticsMinMapper.selectOneByCond(item); if (oldStatistics == null) { item.setEtlVersion(item.getEtlVersion() + ":0"); affectNum = bizProposalPolicyStasticsMinMapper.insert(item); } else { oldStatistics.setStatisticsCount(oldStatistics.getStatisticsCount() + item.getStatisticsCount()); String versionFull = versionKeeperFilter(oldStatistics.getEtlVersion(), item.getEtlVersion()); oldStatistics.setEtlVersion(versionFull + ":" + oldStatistics.getStatisticsCount()); // todo: 優化更新版本號問題 affectNum = bizProposalPolicyStasticsMinMapper.updateByPrimaryKey(oldStatistics); } updateCount.addAndGet(affectNum); }); callback.commit(); return updateCount.get(); } /** * 版本號過濾器(組裝版本資訊) * * @param oldVersion 老版本資訊 * @param currentVersion 當前版本號 * @return 可用的版本資訊 */ private String versionKeeperFilter(String oldVersion, String currentVersion) { String versionFull = oldVersion + "," + currentVersion; if (versionFull.length() >= 500) { // 從150以後,第一版本號開始保留 versionFull = versionFull.substring(versionFull.indexOf(',', 150)); } return versionFull; } }
4. 你需要一個啟動任務的地方
/** * 啟動時執行的任務排程服務 * */ @Service @Slf4j public class TaskAutoRunScheduleService { @Resource private MinuteBizDataCounterTask minuteBizDataCounterTask; @PostConstruct public void bizDataAutoRun() { log.info("============= bizDataAutoRun start ================="); ExecutorService executorService = Executors.newSingleThreadExecutor(new NamedThreadFactory("Biz-data-counter-%d")); executorService.submit(minuteBizDataCounterTask); } }
5. 將每分鐘的資料從db查詢出來展示到頁面
以上將資料統計後以分鐘級彙總到資料,接下來,監控頁面就只需從db中進行簡單聚合就可以了,我們們就不費那精力去展示了。
6. 待完善的地方
1. 叢集環境的任務執行將會出問題,解決辦法是:加一個分散式鎖即可。 你可以的!
2. 針對重試執行統計問題,還得再考慮考慮。(冪等性)
嘮叨: 踩坑不一定是壞事!