購物車到訂單:被主播拔掉的狀態機
· tech
#war-story#live-commerce#system-design
📑 目錄
交易主線的最後一站:單怎麼從購物車走到訂單。留言進了購物車、身分掛好了單、庫存卡住了不變量——這章把它們聚合成一筆可以付錢、可以出貨、可以開發票的東西。標題不是比喻:這個系統裡真的有一台狀態機,被主播們用行動拔掉了。
兩種購物車,一張表
全景說過購物車有兩種:直播下單的佔庫存、商城自己加的不佔。資料模型的答案在身分章亮過相:一張 cart item 表,用 content type + object id(泛型外鍵)標記來源。佔不佔、算直播價還是商城價,由來源決定;數量調整、結帳、清除,全走同一套邏輯。
調整數量的邊界也因此很乾淨:佔庫存的單要加量,就是再打一次庫存章那句條件更新——搶得到才加;減量就是釋放,把差額還給計數。留言的 LWW 改單、客人自己調、客服代調,三條路徑走的是同一組動作,只是觸發者不同。
重來要補的一張表:購物車的操作帳
上面數過購物車的寫入者:LWW 改單、客人自調、客服代調,加上庫存章的檔期結算清除與重喊重置——一張表,五路寫入。但五路的「留痕」嚴重不對稱:留言來的變更有完整溯源(msg → cart item 那條鏈,結構化、可 join);人來的變更,當年不是沒記——我們會手動把操作寫進 Django 內建的那張 log 表——問題是那張表是全系統的大雜燴:購物車調整、商品改動、各種雜項操作通通混在同一張表,欄位泛用到只剩「誰、動了哪個東西、一段文字」。要回答「這筆單怎麼變成這樣」,得在大雜燴裡撈文字:撈得到靠運氣,撈到了也沒有前後數量、沒有差額、跟重算對不起來。客服接到「我的購物車怎麼變這樣」,留言那半查得到,人那半只能考古。這件事的教訓很具體:「有記錄」和「查得動的記錄」是兩回事——泛用 log 表寫的時候人人安心,查的時候形同沒有。
重來我會為 cart item 專門開一張操作紀錄表——不是「開始記錄」,是把記錄從大雜燴裡搬出來、給它結構:每次變更,在同一筆交易內 append 一筆,觸發者(哪則留言、客人、哪位客服、檔期結算、重喊)、前後數量、差額,欄位打死、可以 join。三個講究:
- 它是變更帳,不是事實來源。 cart 表照舊是交易事實、照舊在同步交易裡守超賣——這不是把購物車改成 event sourcing。超賣是硬不變量,檢查必須發生在寫入之前;把「先查再扣」搬進 event store、再靠投影派生狀態,最難的序列化決策一點沒省,只是換了身衣服。
- 同一筆交易 append,成本趨近零,買到三件事:客訴可重建、客服操作有 audit(後台章的重來清單本來就有這條)、每小時重算抓到差異時終於有地方查案——差異不為零代表某條增量路徑有 bug,這張表就是查案的卷宗。
- 這不是新發明,是把「貨」的紀律補給「單」。 配貨系統當年就有配貨紀錄表、庫存變動可用事件全量重建(出貨章會講)——貨有的待遇,單也該有。哪天催付、風控、營運分析想吃購物車的變更流,這張表升級成 outbox 就行:是演進,不是重寫。
合併結帳:三層,各自對應一個現實
結帳有個當年就支援的狠需求:跨檔期合併結帳——不同檔期、不同實況主買的東西,一次付清。這逼出了三層結構:
三層各自的存在理由,比「正規化」更務實:
- payment 是付錢的單位。 客人不在乎你的檔期,他要一次付清——所以聚合層必須存在。它的狀態由第三方付款事實聚合而來(部分付款、多渠道,精彩留給下一章)。
- order 是履約的單位,按檔期切。 代購的貨跟著檔期到、出貨跟著檔期走,售後的節奏天生以檔期為界;檔期限定的優惠券也記在這層。
- order item 是會計的單位。 成交那一刻定格金額——發票、退款、對帳,全部站在這個不再變動的數字上。
而「購物車到訂單」這個動作本身的形狀,已經預告了下一節:結帳不是去改 cart item 的狀態——付款當下新增一筆 order item,和庫存帳本上「購物車數量轉訂單數量」在同一筆交易內完成。狀態轉移用新增記錄表達,溯源鏈因此自然多接一段:msg → cart item → order item,任何一筆成交都能一路回溯到當初那則留言。
訂單的「狀態」:五個欄位,零個狀態機
教科書會教你給訂單畫一張漂亮的狀態機:建立 → 待付款 → 已付款 → 備貨 → 出貨 → 完成。當年的系統不是這樣——訂單的「狀態」是五個各自獨立的欄位:付款狀態、開發票狀態、退款狀態、物流狀態、客服標記——每一個都跟著事實更新,沒有轉移限制。
先講數學再講人。數學:這五件事各自獨立演進——付款完成不影響發票開不開得出來,退款可以發生在物流的任何階段。塞進單一狀態機,狀態數是五個維度的笛卡兒積,幾百個組合裡大半沒有業務意義,但每個都要你回答「誰能轉到誰」。正交的東西就該正交地存。
人:這台狀態機不是紙上談兵——選標系統的同仁真的做過轉移限制,然後發現主播們想改就改。改狀態被擋下來的每一次,對他們都是阻力而不是保護,最後狀態機被拔掉了。這個故事我認為是健康的認輸,但值得收一個更精確的教訓:狀態機適合你能控制的流程,不適合你只是在記錄的現實。 系統裡有兩種東西——硬不變量(不能超賣、錢要對),用資料庫層的約束守死,誰來都不能繞;軟狀態(人的作業進度),只記錄、不強制,因為主播、廠商、客服的現實你本來就管不了。當年最後的形狀恰好正確:超賣守死、狀態自由。很多系統犯的是相反的錯——把人的流程綁死,把錢的約束放鬆。
價格的生命週期:承諾之前查事實,承諾之後不再變
價格在這個系統裡有一條清楚的生命線:
- 購物車階段:不存價,永遠查現價。 主播現場喊價、營運事後才補登金額——如果加入購物車時就把價格拷貝進 cart item,每次補登都要跑大量回填。查現價,零回填。正規化的優勢第三次出現(前兩次:身分章的反正規化判準、庫存章的檔期大掃除)。cart item 因為帶著來源,直播價和商城價自然分流——同一件商品,兩個通路兩個價,不用任何特殊處理。
- 成交那一刻:定格。 order item 把金額存死——不只是發票和退款需要一個不再變動的數字,更因為客人的心理契約:付過錢的東西,數字不能變。優惠也在這一刻算清:優惠券、免運,有的記在 order item、有的記在檔期層(檔期限定券),各自留下事實。
一句話收:snapshot 只發生在承諾點——承諾之前永遠查事實,承諾之後永遠不再變。 cart 是意向,order 是承諾,兩邊的價格策略相反,而且都是對的。
反思
狀態機輸給主播,是我看過最健康的一次認輸
工程師對狀態機有種天然的迷戀——它精確、可證明、畫在白板上很漂亮。但選標系統那台狀態機的下場提醒我:模型的職責是服務現實,不是糾正現實。 主播不是不守規矩,是他們的工作本來就充滿例外:貨臨時到、價格臨時改、客人臨時換——每一個例外都合理,合起來就是「想改就改」。在這種現實上蓋轉移規則,擋下的全是正當操作。正確的花費方式是把「強制」的預算全部投給錢和庫存,把「記錄」留給其他一切——約束是稀缺資源,要花在違約成本最高的地方。
三層結構不是潔癖,是三個現實各要一層
payment、order、order item 這三層,如果當年是為了「架構好看」而分,大概撐不過第一次需求變更。它們撐住了,是因為每層背後有一個獨立變動的現實:客人要一次付清(付錢的節奏)、貨按檔期到(履約的節奏)、帳要經得起發票與退款(會計的節奏)。後來跨檔期合併、部分付款、檔期限定券這些複雜需求一個個冒出來,結構都接得住——複雜需求殺不死職責單純的結構,殺得死聰明但混雜的結構。 判斷要不要多一層的標準,從來不是「教科書說要」,是「這層對應的現實,會不會獨立於其他層變動」。
這一章之所以「無聊」,是前面的章在還債
寫到這章我發現一件事:它沒有事故可講。沒有超賣等級的爆炸、沒有被打爆的 API——結帳這段當年就是穩穩地跑。回頭看不是運氣:購物車一張表多型,所以合併結帳不用縫合兩套邏輯;單掛在身分上,所以誰來結帳都不用搬資料;帳本雙計數,所以 cart→order 的轉移一筆交易就完成。結帳只是把前面章節做對的事實聚合起來而已。 系統設計裡最被低估的讚美就是「無聊」——會爆炸的章節好寫,無聊的章節難得。願你的結帳流程也無聊。