一張訂單的狀態,最容易的起手式是幾個布林欄位:is_paid、is_shipped、is_cancelled。能動——直到有人「付了款、出貨中、又申請退貨、退到一半改主意取消」,你的布林湊出一個現實裡不存在的組合,客服打電話來問「這張到底什麼狀態」,你自己也答不出來。
訂單狀態不是幾個開關,是一台有明確轉移規則的狀態機——而且真實電商裡它不是一台,是五台一起跳舞。這篇拆一個實際跑過的電商平台的訂單狀態設計。
我是撞牆才想清楚要做狀態機
老實說,這套設計不是我一開始就想好的。是撞牆撞出來的。
當時要做一件很單純的事:確認一張訂單「現在到底到哪了」。結果一查就傻眼——訂單系統、金流系統、物流系統、發票系統,各自都有自己新增的狀態,而且是別人陸續加上去的,沒人知道全貌;狀態之間怎麼轉、什麼條件才能轉,也沒有人講得清楚。四個系統各長各的,湊起來就是一團「不知道合不合法」的組合。
那次之後我才確定一件事:這種東西一定要有一台明確定義的狀態機——把合法狀態、合法轉移寫死,其餘一律擋掉。不是為了漂亮,是為了「任何人都能回答這張單現在合不合法、能不能往下走」。
為什麼布林欄位撐不住
布林欄位的問題不是不夠用,是它允許非法狀態存在。is_paid=true 且 is_cancelled=true 在資料庫裡完全合法,業務上卻沒有意義。每多一個布林,非法組合翻倍——四個布林 16 種組合,真正合法的可能只有 6 種,剩下 10 種是等著咬你的地雷。
狀態機把它反過來:先定義合法狀態、合法轉移,其餘一律拒絕。訂單只能處在一個明確狀態,只能照規則轉移。
一張訂單是五個狀態機
這是最反直覺、也最關鍵的一點:真實電商的「一張訂單」不是一台狀態機,是五台各管各節奏的狀態機。背後的原理,電商業界有個老說法——四流分離:金流、物流、資訊流、商流是四條平行的流,一張訂單同時活在這幾條流上,每條流有自己的狀態。硬把它們壓成一個「訂單狀態」欄位,就是災難的開始。
| 狀態機 | 態數 | 管哪條流 | 誰驅動 |
|---|---|---|---|
| Order(訂單) | 6 | 訂單主流程 | 業務事件 |
| Payment(付款) | 8 | 金流 | 金流商 callback |
| Refund(退款) | 4 | 金流(逆向) | 後台審核 + 金流商 |
| CheckoutSession(結帳) | 6 | 下單前程序 | 使用者操作 + 逾時 |
| DeliveryGroup(出貨) | 7 | 物流 | 物流商貨態 |
(第六台 ReturnOrder 退貨單 7 態,走逆向流程時登場。)
為什麼不塞成一台大狀態機? 組合會爆炸。光 Order 6 態 × Payment 8 態 × DeliveryGroup 7 態就是 336 種組合,其中絕大多數(「已取消但配送中」「未付款但已退款」)根本不該存在。硬塞一台的下場,是你要手寫幾百條 if 去擋非法組合——回到布林地獄,只是更大。
拆成五台,每台各自單純(Order 只管 6 態),代價是查「這張單完整狀況」時要 join 五台。這個 join 成本,是換取每台狀態機都乾淨可驗證的合理代價。
Order 的真實轉移表
五台裡最核心的 Order,合法轉移長這樣:
PENDING → PAID / CANCELLED
PAYMENT_FAILED → PENDING(重新付款)/ CANCELLED
PAID → SHIPPING / CANCELLED
SHIPPING → COMPLETED ← 配送中不可取消,只能走退貨
COMPLETED → (終態)
CANCELLED → (終態)三個設計重點藏在這張表裡:
- 轉移守衛集中在一處:所有轉移都經過一個
can_transition檢查,非法轉移(例如COMPLETED → PENDING)直接擋下拋錯,而不是散在各 service 裡各自判斷。改規則只改一個地方——這正是我當年撞牆缺的東西。 - 每次轉移都留痕:轉移同時寫 audit log + 發通知。狀態變化本身就是業務事件,值得被記錄和廣播。
- 配送中不可取消:
SHIPPING沒有回到CANCELLED的路。東西已經在路上,要「取消」只能走退貨這條完全不同的路徑。
兩個沒人特別設計、卻很值得講的地方
真實系統一定有「照理說該這樣、實際卻那樣」的角落。這套有兩個,而且我很誠實:它們不是我精心設計的,是長出來的——但長成這樣本身就有意思。
一、PAYMENT_FAILED 沒有任何「轉入」來源。 看轉移表你會發現,沒有任何狀態能轉「進」PAYMENT_FAILED——它不是從 PENDING 失敗掉進來的。它只在「先付款後建單」流程裡,作為建單時的初始狀態直接出生。這件事我當初沒特別想,是回頭看轉移表才發現「咦它沒有入口」。而這恰好揭示一個道理:枚舉裡開好的狀態,跟真的有人會轉進去,是兩件事——很多狀態是「開好跑道但沒接線」,讀系統要驗後者。
二、退貨不改 Order 狀態。 COMPLETED 是終態,退貨不會把它轉回任何狀態。「這張單退過貨」表達在另外兩台狀態機上:ReturnOrder 走完 7 態 + Payment 轉成 REFUNDED/PARTIAL_REFUNDED。為什麼?回到四流分離——退貨活在物流流和金流流,不在訂單流。訂單這條流對它來說已經走到終點了。所以要查「一張已完成的單退到什麼程度」,你得同時看三台機。這是「拆多台」的 join 成本最具體的體現,也是四流分離最誠實的代價。
收:狀態機設計的三個判斷
- 拆幾台:單台的組合爆炸 vs 多台的 join 成本——驅動節奏不同的(業務事件 vs 金流 callback vs 物流貨態)就該拆開,這也是四流分離的落地。
- 終態怎麼設計:
COMPLETED不可逆、退貨走側路,vs 有些系統給 Order 直接加一個REFUNDED態——前者狀態機乾淨但查詢要 join,後者查詢方便但終態不再是終態。 - 什麼進狀態機:狀態要少而明確;能用事實欄位記錄的(收貨時間、來源)就別塞成狀態,否則狀態爆炸。
接下來往哪走
- 付款流程:八種付款方式怎麼分流 — Payment 那台狀態機的入口,八種付款方式各走各的路
- 付款 callback:驗簽與冪等為什麼是生死線 — 驅動 Payment 狀態機的金流 callback,本身是一篇大文章
- 狀態機設計 pattern — 這篇是電商實例,那篇講通用的狀態機設計原則