昨天留了個懸念:教科書的三個坑(重複、亂序、失蹤),當年一個都沒踩。今天把原因攤開。
當年的系統,這三個坑的體感是:「好像沒遇到什麼問題」。把昨天的架構代入,原因一目瞭然:

SUM 是讀的時候算的。三套防禦的成本歸零,因為沒有可以被打壞的狀態。這是整個系列「事實 append、狀態派生」主旋律的最終回收:同一個原則,在留言層教過你事實要接上路徑才算數(raw 躺在庫裡,重放從沒發動)、在庫存層給了你對帳、在金流層直接讓最兇的一類 bug 絕種。
對帳也順著這個結構分了級:某家銀行的付款記錄會帶 orders payment id,自動對回;現金靠營運標記——又是一個「自動收大宗、人工收殘量」的漏斗,跟身分章的綁定漏斗同構。
這個「狀態讀時派生」的設計,不是全隊拍手通過的——它是被擋出來的。當年 CTO 和幾位同仁不只一次想加一顆訂單狀態欄位:一個直接可查、可篩、放在列表頁很方便的 status。我一意孤行地反對,理由每次都一樣:這個狀態本質上就是各種欄位與事實的聚合——做這顆欄位,就是做一份 cache。 cache 一旦存在,就要回答所有 cache 都要回答的問題:誰負責更新它、哪些寫入路徑要記得同步它、它歪掉的時候以誰為準。而我們當時根本沒有遇到效能問題。在真的遇到效能問題之前,不要做 cache。
這個立場不是教條地反固化——庫存章的賣出計數就是固化的派生值,因為它躺在下單的熱路徑上(每則留言都要檢查不變量),而且配了每小時重算的自我修復。兩個決定用的是同一把尺:派生值要不要固化,唯一正當的理由是量測到的讀取成本;固化了,就要同時承認它是 cache、配上重算與對帳。「查起來方便」不在正當理由清單上——方便的代價,是從此多一個會說謊的欄位。
退款的故事是狀態機教訓的姊妹篇。當年認真做了退款 API——串好銀行、包好流程——結果營運人員不愛用。他們習慣直接開銀行後台按退款,快、熟、看得到餘額。
掙扎過後的決定:放手。營運去銀行按他們的,按完把訂單狀態標成「退款中」——然後排程接手,sync 退款的實際進度。系統從「執行者」退到「觀察者」:人做動作,系統對帳。
這個退位其實跟整章的架構一脈相承:退款進度也只是另一組事實,誰觸發的不重要,系統的職責是把事實追回來、把狀態派生對。硬要營運走你的 API,本質上是把自尊建在「別人要不要用你的介面」上;讓系統有能力追上現實,才是把工程花在對的地方。
這章有兩次「退讓」:狀態不做轉移限制(跟著事實走)、退款不走自家 API(讓營運去銀行按)。兩次都是系統對現實低頭——而回頭看,兩次都對。錢的流動牽著銀行、營運、客人三方,你的系統永遠只是其中一個參與者;把自己定位成「所有金錢事實的忠實帳本」,而不是「金錢流程的唯一入口」,反而讓正確性更容易守住。帳本不需要控制世界,只需要如實記下世界。
金流照理是整個系統最容易出事的地方——外部依賴最多、不可靠網路、錢的違約成本最高——但它是這個系列寫到現在最平靜的一章。連著兩章「無聊」(Day 9 的結帳也是),而且無聊的原因相同:前面把事實與派生的關係擺對了,後面的複雜度就長不出來。這也是工程裡最不公平的一件事:好架構的回報,以「沒有故事」的形式發放——出事救火的人人看得見,讓火燒不起來的人沒有戰功。我後來當 EM,很大一部分工作就是學會看見、並獎勵這種「沒有故事」的人。
明天講錢收完之後的事:出貨——賣的是承諾,出的是現實。
本文改寫自我的部落格系列《Re:從零開始做直播代購電商平台》,本篇完整版:https://blog.aidan.tw/blog/rezero-payment/