從業務切到系統#

電商系統設計常被誤導往「先選資料庫」這條路 ── 其實應該先想清楚業務流,再回頭看每段流程需要什麼儲存特性。

核心業務流(用戶角度):

瀏覽商品 → 加購物車 → 下訂單 → 支付 → 配送 → 收貨 → 評價 → 退換貨

每個動作牽動的子系統:

子系統主要負責
商品SKU 資料、庫存、分類、搜尋
購物車用戶選品集合、會話狀態
訂單下單、狀態流轉、訂單明細
支付對接支付閘道、流水
庫存占用、扣減、歸還
用戶認證、地址、會員等級
賬戶餘額、積分、優惠券
物流配送單、追蹤
評價商品、賣家、物流評價
售後退換貨、糾紛

業務系統的儲存特性差異#

不同系統的儲存需求大相逕庭 ── 套用同一資料庫是常見錯誤:

系統主要儲存關鍵指標
商品MySQL + ES + 物件儲存 + CDN讀多寫少,圖片影片量大
購物車Redis + MySQL 混合高頻寫入、可丟(暫存)/必存(已登入)
訂單MySQL(強一致性)強事務、可分片、長尾查詢
庫存Redis + MySQL高並發扣減、絕對不能超賣
賬戶MySQL + 嚴格事務對賬、不能丟錢
評價MySQL + ES寫一次讀多次、需要全文檢索
行為日誌Kafka → HDFS寫吞吐極高、批次分析

「電商不是一個系統,是一群系統」── 把它們混在一個資料庫是擴容噩夢的開始。

訂單系統的核心:必須準確#

訂單最重要的特性:準確性 > 一切。可以慢、可以暫時不可用,不能算錯錢

訂單系統的核心資料:

CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    total_amount DECIMAL(15, 2) NOT NULL,
    status VARCHAR(20),         -- 'created', 'paid', 'shipped', 'completed', 'cancelled'
    idempotency_key VARCHAR(64) UNIQUE,
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

CREATE TABLE order_items (
    id BIGINT PRIMARY KEY,
    order_id BIGINT NOT NULL,
    sku_id BIGINT NOT NULL,
    quantity INT NOT NULL,
    unit_price DECIMAL(15, 2) NOT NULL
);

兩個關鍵設計:

  1. idempotency_key UNIQUE:防止重複下單
  2. status + 狀態機:保證合法的狀態轉移

重複下單的真實場景#

問題不只「用戶手抖點兩下」,更常見的是:

  • 網路抖動導致前端 retry
  • 後端服務超時,網關 retry
  • 訊息佇列重投
  • 用戶刷新頁面後再點

不做防護就會:

  • 用戶被扣兩次款
  • 庫存被佔走兩份
  • 商家收到兩單但只有一個地址
  • 客訴爆炸

防重複下單:冪等性設計#

冪等(idempotent)= 同一個請求重複執行,結果與執行一次相同。

方案 1:唯一索引 + INSERT IGNORE#

下單時帶一個 idempotency_key(前端生成的 UUID 或基於用戶+購物車快照的 hash):

INSERT INTO orders (id, user_id, idempotency_key, ...)
VALUES (..., ..., 'abc123', ...);

idempotency_key 上有 unique 約束。重複請求 → 主鍵衝突 → 回傳已有訂單。

def create_order(user_id, items, idempotency_key):
    try:
        order = insert_order(user_id, items, idempotency_key)
        return order
    except UniqueViolation:
        # 重複請求,回傳既有訂單
        return get_order_by_idempotency_key(idempotency_key)

優點:靠資料庫 + 一個欄位搞定,極可靠。 缺點:失敗 path 要小心 ── 必須能正確回傳上次的結果。

方案 2:Token / Pre-order#

下單前先 POST /orders/token 拿一個 token,下單時帶上:

1. POST /orders/token  →  返回 token T1
2. POST /orders + token=T1  →  創建訂單,標記 T1 已用
3. POST /orders + token=T1  →  T1 已用,回傳第 2 步的結果

token 存 Redis(有 TTL)或 DB。

優點:能在更早期擋掉重複請求,少做一次 INSERT 嘗試。 缺點:多一次 RTT。

方案 3:用戶端去重 + 服務端確認#

前端 disable 按鈕、後端做最終 check。只靠前端不夠 ── 用戶可以打開兩個 tab、可以用 Postman、可以攻擊。所以前端只是優化 UX,後端必須是 source of truth。

實務建議#

  • 對外 API(特別是支付相關)必須要求 client 帶 idempotency_key
  • 不接受 client 帶 → 服務端用 (user_id, request_hash) 構造一個
  • key 要存夠久(至少幾天)── 重複可能來自重試、補償流程

ABA 問題#

並發場景下另一類錯誤。場景:

T0: 訂單狀態 = "created"
T1: 用戶 A 取消訂單 → status 改為 "cancelled"
T2: 系統自動 retry 邏輯 → 重置為 "created"
T3: 訂單員看到 "created",去發貨

從 T3 角度看「狀態 = created」── 跟一開始一樣,但經歷了不同。如果只用 WHERE status = 'created' 判斷,無法察覺中間發生過取消。

解:版本號 / CAS#

每次更新帶版本號:

UPDATE orders SET status = 'paid', version = version + 1
WHERE id = ? AND version = ?

如果中間有其他 update(version 已增),這條會 affect 0 rows → 偵測到衝突。

def update_order(order_id, expected_version, new_status):
    rows = db.execute("""
        UPDATE orders SET status = %s, version = version + 1
        WHERE id = %s AND version = %s
    """, [new_status, order_id, expected_version])
    if rows == 0:
        raise OptimisticLockError("order has been modified")

解:狀態機 + 嚴格轉移#

訂單狀態圖明確定義「哪些狀態可以轉到哪些」:

created ──pay──→ paid ──ship──→ shipped ──confirm──→ completed
   │
   └─cancel─→ cancelled
            ↑
   paid ────┘ (退款後)

UPDATE 永遠帶上「當前期望的源狀態」:

UPDATE orders SET status = 'shipped'
WHERE id = ? AND status = 'paid'

只有 paid → shipped 合法時才會更新。從 ABA 過程「created → cancelled → created」回來,第二次的 created 跟第一次的 created 不應有「被發貨」資格 ── 但若狀態機只看當前狀態而沒有歷史,仍會被矇騙。

解:完整事件流(Event Sourcing 思路)#

把訂單視為「事件序列」而非「狀態快照」。每個動作都 append 一條事件:

events: [created, cancelled, recreated, paid, shipped]

當前狀態 = 重放事件結果。但更重要的是 所有歷史都在,業務查詢能看到 ABA 軌跡。

對訂單系統,純 ES(事件溯源)較重;常見折衷是 DB 存當前狀態 + 事件流(log/Kafka)存歷史

庫存扣減:另一個冪等戰場#

下單流程必涉及庫存扣減。核心要避免:

  1. 超賣:庫存不足卻扣減成功
  2. 重複扣減:同一訂單扣兩次

避超賣的核心:

UPDATE inventory SET stock = stock - ?
WHERE sku_id = ? AND stock >= ?

WHERE 帶 stock >= ? 是關鍵 ── 由 DB 的原子性保證。affect rows = 0 即庫存不足。

避重複扣減:扣減操作帶 order_id,記錄扣減事件:

INSERT INTO inventory_log (order_id, sku_id, delta) VALUES (?, ?, -?)

(order_id, sku_id) 上有 unique 約束。同一訂單對同一 SKU 重複扣會 conflict → 跳過。

狀態機要落到資料庫設計#

狀態機紙上畫得清楚不夠,要落到:

  1. 應用層:每個轉移函式驗證源狀態
  2. 資料庫:UPDATE 永遠帶 source state
  3. 監控:非法轉移觸發報警
-- 反面教材
UPDATE orders SET status = 'completed' WHERE id = ?

-- 正面範本
UPDATE orders SET status = 'completed' WHERE id = ? AND status = 'shipped'

如果某個狀態轉移路徑罕見、但你看到 affect = 0 ── 可能:

  1. 真實的並發衝突(正常)
  2. 上游發了錯誤的訊號(要查)
  3. 訂單被外部直接修改(資料異常)

訂單號的設計#

不要直接用 DB 自增 ID 暴露給用戶 ── 暴露訂單量是商業敏感。

常見方案:

方案結構缺點
Snowflake-liketimestamp + machine + seq機器 ID 設定
雪花變體 + 用戶 ID 後綴+ user_id 末 4 位對齊用戶分片
UUID隨機索引差,全表掃描慢
Sequence + 加密內部 ID + 加密混淆解密複雜

電商常見:yyyyMMddHHmmss + machine + 隨機序列 ── 既可讀又能對齊分片。

小結#

訂單系統的設計要點:

  1. 冪等是設計時就要的,不是事後補的 ── idempotency_key + unique index
  2. 狀態轉移要嚴格 ── UPDATE 永遠帶源狀態
  3. ABA 用版本號或事件流避免
  4. 庫存扣減靠 DB 原子性stock >= ?
  5. 訂單號設計不暴露業務

下章看商品與購物車這兩個與訂單性質截然不同的系統。