調(diào)度平臺的設(shè)計實踐:從任務(wù)模型到高可用架構(gòu))
1. 項目概述ax調(diào)度到底在解決什么問題說“ax”之前先聊個背景。我們當(dāng)時業(yè)務(wù)線里有一堆“掛了就要人工介入”的臟活累活用戶上傳文件后的轉(zhuǎn)碼、定時報表推送、庫存同步、售后狀態(tài)自動流轉(zhuǎn)……早期全靠服務(wù)內(nèi)部起的定時器加一些邊寫邊補的Job代碼在硬扛。看起來好像也沒什么問題但等業(yè)務(wù)量上來、節(jié)點一多問題就全露餡了同一臺機器上不同任務(wù)互相搶CPU一個Job卡死連帶整個服務(wù)心跳異常新增一個定時任務(wù)要先找代碼、再發(fā)版、再看運氣。后來我們統(tǒng)一做了一次技術(shù)梳理結(jié)論很明確——需要一個獨立的、能統(tǒng)一管理異步任務(wù)和定時任務(wù)的調(diào)度中心把“任務(wù)怎么定義、怎么分派、怎么執(zhí)行、怎么重試、怎么監(jiān)控”這些事全部收口。ax調(diào)度就是這么來的。項目代號“ax”取自“async executor”的縮寫我們內(nèi)部一直沿用到今天。ax調(diào)度本質(zhì)上是一個輕量級的分布式異步任務(wù)調(diào)度平臺核心能力可以概括成三句話任務(wù)配置化、調(diào)度集中化、執(zhí)行分布式化。業(yè)務(wù)方只需要把任務(wù)邏輯封裝成一個接口實現(xiàn)然后在ax控制臺上注冊任務(wù)、配置觸發(fā)規(guī)則剩下的分派、排隊、失敗重試、執(zhí)行日志全部由平臺接管。對我們這種偏業(yè)務(wù)中臺的技術(shù)團隊來說它解決的不只是“任務(wù)跑起來”的問題更重要的是把任務(wù)治理從“靠約定、靠人盯”變成了“靠平臺、靠制度”。這篇東西適合誰看如果你正在用Quartz、xxl-job、Celery之類的方案或者團隊的任務(wù)調(diào)度還停留在“代碼里new一個ScheduledThreadPool”的階段那ax的設(shè)計思路和落地過程中的坑多少能給你一些參考。尤其是調(diào)度器選型、任務(wù)分派策略、重試與冪等設(shè)計這幾個環(huán)節(jié)我盡量把當(dāng)時踩過的坑和翻車現(xiàn)場都講清楚。2. 整體設(shè)計思路拆解為什么沒直接選開源方案2.1 當(dāng)時擺在我們面前的三條路開始動工之前我們先做了一輪方案評審基本是三選一直接用現(xiàn)成的xxl-job、引入消息隊列做異步解耦、自研一套調(diào)度平臺。xxl-job確實是當(dāng)時比較成熟的選擇功能覆蓋度也高文檔中文友好社區(qū)也很活躍。但我們有幾個實際顧慮第一業(yè)務(wù)方對任務(wù)的管理訴求不只是“觸發(fā)一下”還希望有更細(xì)粒度的分片策略、優(yōu)先級搶占、任務(wù)間依賴編排xxl-job雖然在分片上做得不錯但依賴編排這塊還是偏弱第二我們有一部分任務(wù)是長耗時任務(wù)比如大文件轉(zhuǎn)碼可能跑十幾分鐘甚至更久xxl-job內(nèi)置的調(diào)度線程模型在這種場景下容易出現(xiàn)任務(wù)堆積和誤判超時第三公司內(nèi)部有比較強的統(tǒng)一監(jiān)控和權(quán)限體系要求開源項目直接接進(jìn)來改動成本和后續(xù)升級成本都不小。消息隊列那條路我們也認(rèn)真想過。MQ做異步化確實很合適但它本質(zhì)上解決的是“削峰填谷、流量緩沖”的問題不是“任務(wù)什么時候該跑”的問題。如果用MQ做定時調(diào)度延遲消息在數(shù)量大時會有精度損耗而且“任務(wù)的優(yōu)先級、依賴、分片、回執(zhí)”這些概念MQ并不原生支持要靠業(yè)務(wù)代碼做大量補償。所以MQ可以作為執(zhí)行通道的補充但不能作為調(diào)度內(nèi)核。最后我們定下的思路是調(diào)度內(nèi)核自研執(zhí)行通道可以對接MQ也可以走HTTP回調(diào)兩邊解耦。ax調(diào)度的定位是“調(diào)度大腦”不是“執(zhí)行引擎”。2.2 架構(gòu)分層和模塊邊界定了自研之后我們把ax拆成了幾個清晰的模塊每個模塊的職責(zé)邊界在設(shè)計階段就卡得很死調(diào)度中心scheduler負(fù)責(zé)任務(wù)注冊、觸發(fā)時間計算、任務(wù)分派、狀態(tài)流轉(zhuǎn)是整個平臺的大腦。執(zhí)行器executor業(yè)務(wù)方接入的SDK接收調(diào)度指令執(zhí)行真正的業(yè)務(wù)邏輯然后把執(zhí)行結(jié)果回傳給調(diào)度中心??刂婆_console管理后臺負(fù)責(zé)任務(wù)配置、權(quán)限管理、日志查詢、告警配置。存儲層store元數(shù)據(jù)存MySQL實時狀態(tài)和隊列數(shù)據(jù)存Redis執(zhí)行日志走ES。這個分層的核心考慮是讓調(diào)度中心保持無狀態(tài)。調(diào)度中心本身不執(zhí)行任何業(yè)務(wù)任務(wù)它只負(fù)責(zé)“算時間、發(fā)指令、收結(jié)果”所以可以任意水平擴展。執(zhí)行器才是真正干活的地方由業(yè)務(wù)方自己部署個數(shù)和位置都不受限制??刂婆_更簡單就是個標(biāo)準(zhǔn)的Web應(yīng)用。設(shè)計中最重要的一條邊界是調(diào)度鏈路和執(zhí)行鏈路必須分離。調(diào)度鏈路走HTTP長輪詢或者Netty長連接執(zhí)行鏈路一般走本地線程池如果執(zhí)行邏輯特別重還可以把任務(wù)丟給MQ由下游消費者異步處理。這樣即使執(zhí)行器上某個任務(wù)搞崩了線程池也不會影響調(diào)度指令的接收。2.3 為什么任務(wù)模型要自研而不是套用cron很多團隊做調(diào)度系統(tǒng)第一反應(yīng)就是用cron表達(dá)式定時任務(wù)嘛cron夠了。ax里cron只是觸發(fā)方式之一我們的任務(wù)模型是“事件驅(qū)動 時間驅(qū)動”雙模的。時間驅(qū)動就是大家熟悉的cron、固定間隔、延遲執(zhí)行事件驅(qū)動則是“當(dāng)某個事件發(fā)生后觸發(fā)下游任務(wù)”比如文件上傳完成、訂單狀態(tài)變更、外部回調(diào)到達(dá)。這兩種模式在業(yè)務(wù)里的使用頻率差不多高如果只支持cron那些需要“上游完成后跑下游”的場景就得靠業(yè)務(wù)方自己硬編碼回調(diào)調(diào)度平臺就退化成一個定時器了。另外任務(wù)模型上我們還加了優(yōu)先級和分組的概念。優(yōu)先級解決的是“任務(wù)都在排隊誰先跑”的問題分組解決的是“哪些任務(wù)必須在同一臺機器上串行執(zhí)行”的問題。比如同一個用戶的多筆訂單狀態(tài)流轉(zhuǎn)任務(wù)如果被分到不同機器并發(fā)執(zhí)行就會出現(xiàn)狀態(tài)覆蓋這種任務(wù)必須按用戶維度哈希到同一個執(zhí)行器上。這些設(shè)計都不是憑空拍腦袋想出來的而是業(yè)務(wù)方在評審會上一條一條提出來的硬需求。3. 核心細(xì)節(jié)解析與實操要點3.1 任務(wù)狀態(tài)機一張圖想清楚所有流轉(zhuǎn)做調(diào)度系統(tǒng)最容易翻車的地方就是一上來就寫代碼寫完發(fā)現(xiàn)狀態(tài)流轉(zhuǎn)全是洞。我們的做法是先畫狀態(tài)機把每個狀態(tài)的定義和合法流轉(zhuǎn)路徑全部定死再動手寫代碼。ax里任務(wù)實例JobInstance的狀態(tài)定義是這樣的待運行PENDING任務(wù)已創(chuàng)建等待調(diào)度器分發(fā)。分發(fā)中DISPATCHING調(diào)度器已下發(fā)指令等待執(zhí)行器確認(rèn)。運行中RUNNING執(zhí)行器已接收并開始執(zhí)行。成功SUCCEEDED執(zhí)行完成且回執(zhí)成功。失敗FAILED業(yè)務(wù)執(zhí)行異?;虺瑫r等待重試或進(jìn)入死信。取消CANCELED被人為取消或依賴的上游任務(wù)失敗。其中最容易出問題的是PENDING到DISPATCHING這一步。如果調(diào)度中心下發(fā)指令后執(zhí)行器沒收到或者收到了但來不及回執(zhí)任務(wù)就會卡在DISPATCHING。我們的處理是給這個狀態(tài)加一個超時閾值超過30秒沒有進(jìn)入RUNNING就重新分發(fā)同時把分發(fā)次數(shù)記錄下來超過3次直接標(biāo)記為FAILED并由告警通知介入。這里有個細(xì)節(jié)經(jīng)驗狀態(tài)的回推一定要帶“輪次”信息。也就是說執(zhí)行器在回執(zhí)結(jié)果時除了任務(wù)ID還要帶上本次調(diào)度的分派次數(shù)調(diào)度中心只接受最新輪次的回執(zhí)過期輪次的回執(zhí)直接丟棄。否則網(wǎng)絡(luò)抖動導(dǎo)致舊回執(zhí)后到會把一個新分派的任務(wù)誤判成成功或失敗。3.2 任務(wù)分派策略一致性哈希與分組串行的取舍任務(wù)分派是調(diào)度系統(tǒng)最核心的環(huán)節(jié)它決定了“任務(wù)發(fā)給誰”。ax早期版本用的是簡單輪詢遇到執(zhí)行器負(fù)載不均。后來改成一致性哈希按任務(wù)ID哈希到固定執(zhí)行器好處是同一個任務(wù)每次都在同一臺機器上跑本地緩存友好也方便排查問題。但一致性哈希有個副作用如果執(zhí)行器數(shù)量變化擴縮容哈希環(huán)會重排正在運行的長任務(wù)可能會被重新分派。我們的規(guī)避方案是分派時帶一個“親和性”參數(shù)調(diào)度中心優(yōu)先把任務(wù)分給上次執(zhí)行成功的機器只有當(dāng)該機器失聯(lián)或負(fù)載過高時才重新哈希。這個策略在長任務(wù)場景下特別管用實測任務(wù)重復(fù)執(zhí)行率下降了60%以上。分組串行又是一個單獨的話題。對于需要嚴(yán)格串行的任務(wù)組我們在Redis里維護(hù)了一把組鎖key的格式是group:{groupId}:lock拿到鎖的執(zhí)行器才能處理該組的下一個任務(wù)。鎖的持有時間不固定因為任務(wù)時長不可控我們用的是“續(xù)約”模式執(zhí)行器在執(zhí)行期間周期性續(xù)約任務(wù)結(jié)束立即釋放。這個設(shè)計很像分布式鎖的看門狗機制實現(xiàn)起來不難但必須注意續(xù)約線程不能和業(yè)務(wù)線程共用線程池否則業(yè)務(wù)線程池被打滿時續(xù)約也停了鎖被誤釋放串行就變成并發(fā)。3.3 延遲任務(wù)與定時任務(wù)時間輪還是掃表調(diào)度系統(tǒng)躲不開的問題到了該觸發(fā)的時間任務(wù)在哪里等著兩種主流方案我們都深挖過。第一種是時間輪Timing Wheel方案純內(nèi)存精度高適合短延遲、高頻觸發(fā)的任務(wù)。但時間輪的問題在于任務(wù)量大時內(nèi)存占用高而且進(jìn)程重啟后所有未觸發(fā)的任務(wù)全部丟失必須依賴外部存儲做恢復(fù)復(fù)雜度一下就上來了。第二種是掃表方案用一個線程每隔幾秒掃描任務(wù)表中到達(dá)觸發(fā)時間的記錄扔到分發(fā)隊列。實現(xiàn)簡單可靠性高但掃描頻率和DB壓力是矛盾的。我們最終用的是掃表方案的優(yōu)化版所有任務(wù)按下次觸發(fā)時間建立索引調(diào)度器每5秒掃一次掃到的任務(wù)進(jìn)入Redis延遲隊列延遲隊列的消費者再以毫秒級精度把任務(wù)投遞給執(zhí)行器。這樣既保證了數(shù)據(jù)庫層面不會頻繁掃描又讓實際觸發(fā)精度控制在幾百毫秒內(nèi)。這里建議設(shè)置一個合理的“每次掃描上限”。我們是每次最多撈200條到期任務(wù)如果超過200條說明積壓了觸發(fā)告警而不是全量撈出來。原因是防止一次掃描過多導(dǎo)致DB慢查詢進(jìn)而拖垮整個調(diào)度中心。3.4 觸發(fā)規(guī)則的動態(tài)更新業(yè)務(wù)方改任務(wù)配置是常態(tài)比如“這個報表從每天早上8點改成9點”。ax里任務(wù)配置的每一次變更都生成一個新版本調(diào)度器比較“當(dāng)前生效版本”和“內(nèi)存中緩存版本”不一致就重新加載。變更校驗的坑在于cron表達(dá)式的合法性校驗不能只靠cron庫自身的parse還要做一次“近100次觸發(fā)時間計算”防止出現(xiàn)閏年、月底這種極端時間點上cron行為不符合預(yù)期的情況。改配置還有個聯(lián)動問題如果任務(wù)當(dāng)前正在運行中配置變更是否中斷當(dāng)前實例我們的策略是“運行中實例不受影響下一次觸發(fā)采用新配置”。這樣設(shè)計的好處是業(yè)務(wù)不會因為改配置而丟數(shù)據(jù)壞處是如果新配置需要立即生效且任務(wù)是個長跑任務(wù)可能要等很久。所以我們在控制臺上加了一個“強制終止當(dāng)前實例”的操作按鈕帶有二次確認(rèn)和審計日志。4. 實操過程與關(guān)鍵環(huán)節(jié)實現(xiàn)4.1 執(zhí)行器接入SDK的設(shè)計這一塊是業(yè)務(wù)方感知最強的部分接入復(fù)雜度直接決定平臺能不能推廣開。ax的SDK設(shè)計目標(biāo)很明確業(yè)務(wù)方只寫一個實現(xiàn)類其他全部框架搞定。核心接口簡單到夸張public interface AxJobHandler { /** * 執(zhí)行任務(wù) * param context 任務(wù)上下文包含任務(wù)ID、分片參數(shù)、業(yè)務(wù)參數(shù) * return 執(zhí)行結(jié)果true為成功 */ boolean execute(AxJobContext context); }業(yè)務(wù)方在控制臺上注冊任務(wù)時只需要填上Handler名稱、觸發(fā)規(guī)則和業(yè)務(wù)參數(shù)。啟動時SDK會自動掃描并注冊本機可執(zhí)行的Handler列表調(diào)度中心分派任務(wù)時根據(jù)Handler名稱把請求路由到對應(yīng)執(zhí)行器。這里最大的注意點是參數(shù)傳遞的序列化格式。早期我們用的Java原生序列化后來發(fā)現(xiàn)跨語言客戶端接入Python腳本、Node服務(wù)根本沒法反序列化于是全部改為JSON。代價是性能略降但換來的是通用性值得。所有業(yè)務(wù)參數(shù)在控制臺配置時都以JSON字符串形式存儲執(zhí)行時SDK把它注入上下文對象。4.2 回調(diào)鏈路執(zhí)行狀態(tài)怎么回到調(diào)度中心執(zhí)行器執(zhí)行完任務(wù)之后要把結(jié)果回傳給調(diào)度中心。回傳方式有三條鏈路同步HTTP回傳默認(rèn)方式執(zhí)行完成后立即POST結(jié)果到調(diào)度中心。異步批回傳針對高頻短任務(wù)SDK本地攢一批結(jié)果每2秒批量上報一次減少調(diào)度中心壓力。狀態(tài)查詢補償如果調(diào)度中心發(fā)現(xiàn)某個任務(wù)長時間沒收到回執(zhí)會主動向執(zhí)行器發(fā)起狀態(tài)查詢請求執(zhí)行器根據(jù)本地執(zhí)行記錄返回結(jié)果。第三條鏈路非常重要。因為總會有網(wǎng)絡(luò)抖動導(dǎo)致回調(diào)丟失的情況光靠調(diào)度中心單方面重發(fā)會造成任務(wù)重復(fù)執(zhí)行必須先查詢執(zhí)行器當(dāng)前狀態(tài)再做決策。這也是我們常跟業(yè)務(wù)方強調(diào)的調(diào)度系統(tǒng)永遠(yuǎn)不要假設(shè)網(wǎng)絡(luò)是可靠的回調(diào)丟失是常態(tài)不是異常。實操中回執(zhí)消息里我們會帶上“執(zhí)行器IP 進(jìn)程ID 執(zhí)行開始時間”三個追蹤字段。一旦任務(wù)出問題可以通過這些字段快速定位到具體某臺機器上的某個進(jìn)程然后拉對應(yīng)時間段的日志不用再去幾十臺機器里盲猜。4.3 分布式一致性調(diào)度中心多節(jié)點下的去重保障調(diào)度中心是集群部署的兩個節(jié)點可能同時掃到同一個到期任務(wù)如何保證這個任務(wù)只被分派一次我們用了MySQL行鎖加Redis分布式鎖雙層保障。第一層掃描任務(wù)時使用SELECT ... FOR UPDATE SKIP LOCKEDMySQL 8.0支持不需要額外中間件就能解決“多節(jié)點搶同一批任務(wù)”的問題第二層拿到任務(wù)后需要在Redis里通過SETNX搶占一個分派鎖key的過期時間設(shè)為10秒確保即使數(shù)據(jù)庫鎖意外釋放也不能重復(fù)分派。這里有個經(jīng)驗教訓(xùn)分派鎖的過期時間不能設(shè)太短。早期設(shè)的是3秒結(jié)果某次GC停頓超過3秒任務(wù)被重復(fù)分派業(yè)務(wù)方收到了兩條重復(fù)通知。后來改成持有鎖期間主動續(xù)約并把最大持有時間放寬到30秒線上再沒因為這個出過問題。如果任務(wù)數(shù)量巨大可以考慮用Lua腳本原子地完成“搶鎖更新任務(wù)狀態(tài)”兩個動作避免兩步操作的間隙。4.4 動態(tài)線程池控制執(zhí)行器的并發(fā)水位執(zhí)行器收到分派指令后不是馬上丟進(jìn)線程池就完了還要看這個執(zhí)行器當(dāng)前能扛多少并發(fā)。ax的SDK內(nèi)置了一個動態(tài)線程池核心線程數(shù)和最大線程數(shù)都可以通過控制臺動態(tài)調(diào)整無需重啟。調(diào)整時采用“先降后升”策略先降低任務(wù)拉取頻率讓運行中的任務(wù)自然結(jié)束再調(diào)整線程數(shù)避免線程數(shù)突降導(dǎo)致已提交任務(wù)排隊過久。線程池的任務(wù)隊列我們用的是有界隊列默認(rèn)容量1000。超過容量之后新任務(wù)直接走拒絕策略返回“忙”的狀態(tài)調(diào)度中心收到忙狀態(tài)后會把任務(wù)重新放回待運行隊列延遲幾秒再分發(fā)。這比“無限排隊導(dǎo)致執(zhí)行器OOM”健康得多。配置線程池大小要根據(jù)任務(wù)類型拆開一個執(zhí)行器實例上跑多種任務(wù)時我們按任務(wù)分組配置多個線程池避免“轉(zhuǎn)碼任務(wù)把線程吃滿狀態(tài)流轉(zhuǎn)任務(wù)餓死”的問題。5. 部署落地與參數(shù)選型參考5.1 調(diào)度中心的部署形態(tài)調(diào)度中心本身要求不高但為了高可用我們至少部署兩個節(jié)點。MySQL用現(xiàn)有主從Redis用哨兵集群ES用于日志檢索。調(diào)度中心節(jié)點之間通過DB和Redis協(xié)調(diào)狀態(tài)不需要額外引入ZooKeeper這樣運維側(cè)少維護(hù)一個組件壓力小很多。一個值得注意的部署參數(shù)調(diào)度掃描線程的間隔不要小于任務(wù)執(zhí)行的平均耗時。假如任務(wù)平均要10秒掃描間隔設(shè)為5秒就會導(dǎo)致同一個任務(wù)經(jīng)常還在跑就被又掃到一次雖然我們有狀態(tài)去重但DB查詢壓力會變大。我們最后把掃描間隔定在任務(wù)平均耗時的1/3到1/2之間本身上限不超過5秒。5.2 執(zhí)行器資源建議執(zhí)行器建議獨立部署不要和業(yè)務(wù)Web服務(wù)混在一起。混在一起的問題是Web服務(wù)的流量洪峰會影響任務(wù)執(zhí)行的穩(wěn)定性任務(wù)執(zhí)行占滿CPU又會拖垮Web接口響應(yīng)。如果實在要復(fù)用進(jìn)程那就必須做線程池隔離并且給執(zhí)行器設(shè)置“最大占用CPU比例”的熔斷參數(shù)。我們還做了“執(zhí)行器心跳失聯(lián)熔斷”。每個執(zhí)行器每15秒上報一次心跳如果調(diào)度中心連續(xù)3個心跳周期沒收到某個執(zhí)行器的消息就標(biāo)記該執(zhí)行器失聯(lián)不再向其分派新任務(wù)。失聯(lián)恢復(fù)后需要手動執(zhí)行器重新注冊避免半死不活的節(jié)點被重新分派任務(wù)后反復(fù)失敗。5.3 參數(shù)速查表參數(shù)項推薦值說明數(shù)據(jù)庫到期掃描間隔5秒兼顧實時性和DB壓力單次掃描上限200條防慢查詢積壓自動告警分派鎖過期時間30秒持有期間主動續(xù)約執(zhí)行器心跳周期15秒失聯(lián)判定為3個周期任務(wù)超時默認(rèn)值300秒可按任務(wù)單獨配置回調(diào)丟失查詢間隔10秒調(diào)度中心主動補償查詢線程池有界隊列容量1000超出走拒絕策略返回忙表格里的值都是我們在生產(chǎn)環(huán)境長期運行后調(diào)出來的參考值不代表所有場景適配。任務(wù)類型偏短平快的掃描間隔可以調(diào)到2秒任務(wù)偏重的可以放寬到10秒。原則只有一個不要讓調(diào)度中心成為任務(wù)執(zhí)行的瓶頸也不要讓DB因為頻繁掃描而成為瓶頸。6. 常見問題與排查技巧實錄6.1 任務(wù)一直處于“待運行”狀態(tài)這是上線初期被問得最多的一個問題。排查順序通常是先看調(diào)度中心是否有到期掃描日志確認(rèn)任務(wù)是否進(jìn)入了掃描范圍。再看數(shù)據(jù)庫里任務(wù)的next_trigger_time是否正確有時候是配置的cron表達(dá)式有問題。確認(rèn)掃描線程是否卡死。老版本的JDBC連接池若配置不當(dāng)空閑連接會被MySQL服務(wù)端斷開掃描線程拿到失效連接后反復(fù)報錯看起來就是“啥也沒發(fā)生”。我們后來在掃描線程上加了一個埋點計數(shù)器每次掃描無論有沒有掃到任務(wù)都會自增并記錄耗時一旦耗時超過閾值直接告警。這樣任務(wù)積壓的原因是不是出在掃描層一眼就能看出來。6.2 任務(wù)“跑成功了”但調(diào)度中心沒收到回執(zhí)這種問題大多出在回傳鏈路上。我們先看執(zhí)行器日志里有沒有“回執(zhí)發(fā)送失敗”的日志再看調(diào)度中心有沒有收到狀態(tài)查詢的請求。如果執(zhí)行器發(fā)了、調(diào)度中心沒收到大概率是網(wǎng)絡(luò)層有攔截或者負(fù)載均衡把請求路由到了錯誤的節(jié)點。還有一種容易忽略的情況執(zhí)行器本地執(zhí)行成功返回值true但業(yè)務(wù)方法內(nèi)部開了子線程子線程拋異常并沒有冒泡到主線程。這種“假成功”沒法完全靠調(diào)度系統(tǒng)識別只能要求業(yè)務(wù)方在執(zhí)行邏輯里盡量捕獲所有異常并顯式設(shè)置結(jié)果狀態(tài)。6.3 重復(fù)執(zhí)行問題冪等是最后一道防線調(diào)度系統(tǒng)無論設(shè)計得多嚴(yán)謹(jǐn)重復(fù)執(zhí)行在某些極端場景下都是難免的調(diào)度中心GC停頓、網(wǎng)絡(luò)重傳、回調(diào)丟失觸發(fā)補償查詢……所以我們對所有接入ax的任務(wù)有一個硬性要求必須保證接口冪等。平臺會在任務(wù)上下文中注入一個executionId每次觸發(fā)都不同業(yè)務(wù)方在執(zhí)行邏輯開頭以這個ID做去重判斷。具體做法有兩種一種是用Redis的SETNXkey是biz:{bizType}:{executionId}能設(shè)置成功說明是首次執(zhí)行另一種是把executionId作為業(yè)務(wù)表唯一鍵插入沖突說明已執(zhí)行過。前一種簡單后一種在強一致場景下更保險。我們對涉及資金和庫存的任務(wù)強制要求用唯一鍵方案不允許偷懶。6.4 執(zhí)行器負(fù)載不均問題出現(xiàn)負(fù)載不均時先看是不是哈希環(huán)偏斜。節(jié)點數(shù)少時一致性哈希本身容易出現(xiàn)數(shù)據(jù)傾斜解決辦法是引入虛擬節(jié)點每個物理節(jié)點映射150個虛擬節(jié)點實測傾斜率從30%降到8%以內(nèi)。還要看是不是某些任務(wù)的耗時異常膨脹導(dǎo)致機器假負(fù)載。我們加了一個“慢任務(wù)統(tǒng)計”執(zhí)行器中單個任務(wù)執(zhí)行超過平均耗時3倍時自動上報一個慢任務(wù)事件控制臺上能看到Top10慢任務(wù)列表。很多調(diào)度問題其實不是調(diào)度系統(tǒng)的鍋而是業(yè)務(wù)任務(wù)本身變慢了得先把業(yè)務(wù)側(cè)的慢任務(wù)揪出來。6.5 線上事故復(fù)盤一次回調(diào)風(fēng)暴有一次我們調(diào)整了一個大任務(wù)組的并發(fā)上限結(jié)果觸發(fā)了回調(diào)風(fēng)暴。任務(wù)本身是一個查詢類任務(wù)單次執(zhí)行秒級完成之前并發(fā)不高一切正常。調(diào)整并發(fā)上限后同一時間上千個任務(wù)一起執(zhí)行執(zhí)行器回調(diào)線程瞬間打滿大量回調(diào)超時調(diào)度中心觸發(fā)補償查詢查詢又加重執(zhí)行器壓力形成惡性循環(huán)。后來在SDK里加了一個“回調(diào)熔斷”機制本地回調(diào)隊列積壓超過閾值時新任務(wù)直接拒絕執(zhí)行返回“忙”狀態(tài)等下一輪分派。同時在調(diào)度中心側(cè)增加了回調(diào)處理線程池的隊列告警。生產(chǎn)環(huán)境上線后類似事故再沒發(fā)生過。7. 最后分享兩個實操心得第一個心得是關(guān)于任務(wù)日志的。調(diào)度系統(tǒng)本身再完善如果排查問題不方便業(yè)務(wù)方照樣不愛你用。ax里我們做了一個特別“土”但特別好用的功能把每個任務(wù)實例的全鏈路日志從觸發(fā)、分發(fā)、接收、回執(zhí)到重試以時間線形式聚合在控制臺上業(yè)務(wù)排查問題不用再各種系統(tǒng)來回跳。這個功能投入產(chǎn)出比極高強烈建議自研調(diào)度平臺的團隊優(yōu)先做。第二個心得是平臺上線前一定要做“混沌測試”。我們當(dāng)時專門模擬了執(zhí)行器宕機、調(diào)度中心節(jié)點重啟、MySQL主從切換、Redis緩存清空四類故障場景提前暴露了十幾個問題包括狀態(tài)丟失、分派鎖死、回調(diào)丟失等。這些故障如果等到線上自然發(fā)生再處理代價完全不可控。ax調(diào)度這套東西說復(fù)雜也復(fù)雜說簡單也簡單。復(fù)雜在分布式場景下的各種異常情況永遠(yuǎn)超乎想象簡單在只要把任務(wù)模型定義清楚、把狀態(tài)流轉(zhuǎn)規(guī)則定死、把回執(zhí)鏈路補全一個調(diào)度平臺的核心骨架就立住了。如果你正準(zhǔn)備搞類似的系統(tǒng)希望這篇經(jīng)驗?zāi)軒湍闵俨葞讉€坑。