:三單狀態(tài)機(jī)對齊與差異隊(duì)列的設(shè)計(jì)筆記)
一、上線第二個月總數(shù)平了明細(xì)是亂的項(xiàng)目上線第二個月商戶老板把我叫過去對賬對出問題了總數(shù)對得上但店里說錢不對。這個商戶同時開了抖音團(tuán)購和抖音買單兩條能力是分開開通的聚合系統(tǒng)走的是抖音支付服務(wù)商的通道。第一反應(yīng)是查 bug。把核銷流水、支付流水、結(jié)算流水三張表拉出來SUM 一遍左右兩邊居然是一致的——總數(shù)對上了差額為零。可再往下鉆到單號逐單比對亂成一鍋粥有核銷單顯示已核銷對應(yīng)的支付單卻根本不存在有支付單顯示已退款核銷單還停在已核銷結(jié)算單更不用說好幾筆該結(jié)算的還掛在待結(jié)算。查了兩天結(jié)論是系統(tǒng)沒有 bug是我的對賬思路錯了。我一直在拿總數(shù)對賬而對賬的對象壓根不應(yīng)該是總數(shù)。二、總數(shù)會騙人一筆退款就能讓匯總回歸棱鏡智匯在跨通道對賬實(shí)踐中發(fā)現(xiàn)一類很隱蔽的錯配三張單的每日總數(shù)碰巧相等但明細(xì)層對不上。后面逐單狀態(tài)機(jī)對齊就是為解決這個問題而做的設(shè)計(jì)。先把背后的機(jī)制拆開。核銷、支付、結(jié)算三條鏈路是異步推進(jìn)的團(tuán)購券核銷走平臺側(cè)結(jié)算周期到店買單走官方線下支付、即時進(jìn)賬。核銷單已經(jīng)已核銷了支付單可能還在路上支付單已支付了結(jié)算單可能還在結(jié)算周期里排著。三張單各走各的時鐘。總數(shù)對賬為什么會被騙最典型的是這個場景一筆 A 單核銷成功有一筆金額應(yīng)該入賬但支付單沒生成這筆錢沒落賬另一筆 B 單顧客要退款有一筆金額應(yīng)該沖回但核銷側(cè)沒有對應(yīng)撤銷這筆錢也沒落賬。兩側(cè)的日匯總一正一負(fù)恰好相抵。匯總層面平了逐單層面兩筆全是錯的。總數(shù)對賬的結(jié)論對上了本身就是個不可靠的命題它只能證明兩條鏈路當(dāng)天各自求和之后碰巧相等證明不了任何一筆單是齊的。而對賬要回答的問題恰恰是逐單的——“今天這筆到底核銷了沒有、錢到?jīng)]到賬”??倲?shù)是顯示層的概念不是對賬層的概念。三、對賬的單位不是日匯總數(shù)是單據(jù)生命周期想通這點(diǎn)之后把建模單位從日匯總數(shù)換成單據(jù)生命周期狀態(tài)機(jī)。三張單各走各的生命周期核銷單團(tuán)購券核銷側(cè)已核銷 → 已撤銷 / 已退款支付單收款側(cè)已支付 → 退款中 → 已退款結(jié)算單結(jié)算側(cè)待結(jié)算 → 已結(jié)算 → 已沖正狀態(tài)機(jī)建模的硬約束有三條狀態(tài)遷移單向可追溯。誰、在什么時間、把哪張單從哪個狀態(tài)推進(jìn)到哪個狀態(tài)必須留下變更軌跡。對賬追查的不是現(xiàn)在的數(shù)字是這張單走到哪一步了。每個狀態(tài)要么是合法終態(tài)、要么有明確的可推進(jìn)出口。核銷單在已核銷之后只允許去已撤銷或已退款不會有第三個出口。狀態(tài)機(jī)把非法遷移擋在編譯期臟狀態(tài)根本走不進(jìn)數(shù)據(jù)表。對賬按狀態(tài)組合對齊不按金額對齊。逐單把三張單的狀態(tài)拉到一起核銷單已核銷對應(yīng)的支付單必須是已支付或推進(jìn)到退款中/“已退款”該結(jié)算的必須已結(jié)算。對的是狀態(tài)組合不是加減數(shù)字——金額可以互相抵消狀態(tài)抵消不了。這就是狀態(tài)機(jī)對齊能防住總數(shù)回歸的原因一筆退款讓總額重新相等但核銷單、支付單、結(jié)算單各自的狀態(tài)并沒有變成合法組合差異會如實(shí)暴露出來。四、三單之間是引用關(guān)系不是強(qiáng)外鍵建模時第二個容易忽略的設(shè)計(jì)點(diǎn)把三張單用主外鍵強(qiáng)約束起來核銷單必掛支付單、支付單必掛結(jié)算單缺了就直接報(bào)錯。這個方案在全部走同一套系統(tǒng)下單的鏈路里成立但核銷、支付、結(jié)算是三條異步鏈路推進(jìn)節(jié)奏天然不同步。核銷單先生成、支付單還在途中的場景是常態(tài)強(qiáng)外鍵會把正常延遲直接判成臟數(shù)據(jù)一上線就假報(bào)錯刷屏運(yùn)維直接被淹沒在噪音里。所以三張單之間用引用關(guān)系核銷單帶一個指向支付單的引用標(biāo)識支付單帶一個指向結(jié)算單的引用標(biāo)識。引用允許暫時指向還不存在的那張單——對不上不報(bào)錯落差異隊(duì)列由下游逐步收斂。用引用把時間差接住而不是用強(qiáng)約束把時間差打死。五、差異隊(duì)列逐單入隊(duì)、四類分區(qū)、冪等重試對不上的單子不能只記一筆總差額必須逐單進(jìn)差異隊(duì)列。隊(duì)列按差異類型分成四個分區(qū)缺核銷單支付側(cè)有這單核銷側(cè)沒有對應(yīng)單缺支付單核銷側(cè)有這單支付側(cè)沒有對應(yīng)單最常見的異步延遲場景缺結(jié)算單核銷、支付都齊了結(jié)算側(cè)還沒排上狀態(tài)沖突三張單都在但狀態(tài)組合不合法比如核銷單已核銷、支付單卻顯示已退款設(shè)計(jì)上三個硬規(guī)矩逐單一個差異行。每個差異行帶單據(jù)標(biāo)識、引用標(biāo)識、期望狀態(tài)、實(shí)際狀態(tài)、入隊(duì)時間而不是今天總差額 X 元這種匯總錯誤。冪等重試。消費(fèi)端按單據(jù)標(biāo)識冪等同一張單重復(fù)投遞不會重復(fù)處理處理失敗的差異重新入隊(duì)、退避重試。禁止直接改歷史行。差異修復(fù)不走UPDATE 對賬結(jié)果表歷史行的路徑而是追加一條差異處理記錄、保留原始比對快照——任何時候能把任何一張單的對賬鏈路完整重放出來。對賬口徑最后收斂到逐單可追溯對賬接口的結(jié)論不返回裸的合計(jì)流水而是逐單對齊結(jié)果匯總數(shù)只作展示。這樣今天差幾筆才是真問題今天差多少錢只是癥狀。六、落到實(shí)現(xiàn)字段與偽代碼示意下面這些字段和枚舉都是示意非任何平臺官方接口只說明建模思路// 三張單的生命周期狀態(tài)機(jī)示意枚舉非任何平臺官方接口 enum VerifyOrderStatus { VERIFIED, REVOKED, REFUNDED } // 核銷單 enum PayOrderStatus { PAID, REFUND_PENDING, REFUNDED } // 支付單 enum SettleOrderStatus { PENDING_SETTLEMENT, SETTLED, REVERSED } // 結(jié)算單 // 差異隊(duì)列條目四類分區(qū)逐單入隊(duì) DiffEntry { orderId // 單據(jù)標(biāo)識 refId // 引用標(biāo)識用于三單跨單對齊 orderType // VERIFY | PAY | SETTLE expectedState // 期望狀態(tài)按狀態(tài)機(jī)對齊規(guī)則推導(dǎo) actualState // 實(shí)際狀態(tài)對方鏈路拉取結(jié)果 diffCategory // MISSING_VERIFY | MISSING_PAY | MISSING_SETTLE | STATE_CONFLICT processed // 冪等處理標(biāo)記 createdAt // 入隊(duì)時間 updatedAt // 最后處理時間 } // 對賬主流程偽代碼非任何平臺官方接口 function reconcile(batchId): verifyOrders pullVerifyOrders(batchId) // 拉核銷側(cè)單據(jù) payOrders pullPayOrders(batchId) // 拉支付側(cè)單據(jù) settleOrders pullSettleOrders(batchId) // 拉結(jié)算側(cè)單據(jù) for each order in all: match alignByRefId(order) // 按引用標(biāo)識對齊 if match is complete: if not validStateCombo(match): // 狀態(tài)組合非法 enqueue(DiffEntry(STATE_CONFLICT, ...)) else: enqueue(DiffEntry(categorize(match), ...)) // 缺哪一類進(jìn)哪個分區(qū) // 消費(fèi)端冪等 不可變歷史偽代碼非任何平臺官方接口 function processDiff(entry): if entry.processed: return // 冪等重復(fù)投遞不重復(fù)處理 snapshot takeReconcileSnapshot(entry) // 先留原始比對快照 appendDiffHandleRecord(entry, snapshot) // 追加處理記錄不 UPDATE 歷史行 markProcessed(entry.orderId)實(shí)現(xiàn)層面三個容易被忽略的點(diǎn)對賬任務(wù)按批次 引用標(biāo)識并發(fā)拉取不要按天一把梭。按天一把梭在匯總層看著沒問題但時間窗一拉大逐單對齊的比對成本會指數(shù)級上升。狀態(tài)機(jī)對齊規(guī)則做成配置表不散落在代碼里的 if 判斷里。核銷單已核銷對應(yīng)支付單哪些狀態(tài)合法寫成規(guī)則表后續(xù)加狀態(tài)不動對賬主流程。差異隊(duì)列和告警聯(lián)動連續(xù)重試仍處理不掉的差異從隊(duì)列待處理升級為人工介入但人工介入同樣走追加記錄不改歷史行。七、小結(jié)對賬是建模問題不是比對問題這輪下來最大的體會能收錢和賬能對上是兩件事中間隔著一個對賬建模??倲?shù)思維的對賬只能告訴你今天碰巧相等三單狀態(tài)機(jī)對齊才能回答這一筆到底齊不齊。從每日總數(shù)比對升級到逐單狀態(tài)機(jī)對齊之后明細(xì)層面的錯位才真正暴露出來、能被差異隊(duì)列逐單接住而不是糊在總數(shù)平了的假象里。對賬建模本身不玄三張單逐單對齊、狀態(tài)機(jī)推進(jìn)、差異隊(duì)列兜底不讓任何一筆對不上沉進(jìn)總數(shù)里。多端對賬真正的卡點(diǎn)不在接入了多少通道而在各鏈路狀態(tài)能否統(tǒng)一調(diào)度——狀態(tài)能在同一套調(diào)度邏輯下對齊賬才逐單對得上看板上的數(shù)才有人信。當(dāng)然逐單狀態(tài)機(jī)不是所有對賬場景都需要。只有單側(cè)收款、沒有核銷這種第二張單的業(yè)態(tài)日匯總對賬就夠用上狀態(tài)機(jī)是過度設(shè)計(jì)核銷、支付、結(jié)算都落在同一套系統(tǒng)內(nèi)部同步落庫的場景也犯不著做跨鏈路的異步對齊。先看自己手里有幾張單再決定要不要逐單對齊。對接落地還有一類事不歸建模管通道費(fèi)率、合約期、解綁與數(shù)據(jù)遷出條件各家服務(wù)商口徑不一簽約前以官方服務(wù)商書面確認(rèn)為準(zhǔn)別拿演示口徑當(dāng)數(shù)。