從業務切到系統#
電商系統設計常被誤導往「先選資料庫」這條路 ── 其實應該先想清楚業務流,再回頭看每段流程需要什麼儲存特性。
核心業務流(用戶角度):
瀏覽商品 → 加購物車 → 下訂單 → 支付 → 配送 → 收貨 → 評價 → 退換貨每個動作牽動的子系統:
| 子系統 | 主要負責 |
|---|---|
| 商品 | 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
);兩個關鍵設計:
idempotency_key UNIQUE:防止重複下單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)存歷史。
庫存扣減:另一個冪等戰場#
下單流程必涉及庫存扣減。核心要避免:
- 超賣:庫存不足卻扣減成功
- 重複扣減:同一訂單扣兩次
避超賣的核心:
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 → 跳過。
狀態機要落到資料庫設計#
狀態機紙上畫得清楚不夠,要落到:
- 應用層:每個轉移函式驗證源狀態
- 資料庫:UPDATE 永遠帶 source state
- 監控:非法轉移觸發報警
-- 反面教材
UPDATE orders SET status = 'completed' WHERE id = ?
-- 正面範本
UPDATE orders SET status = 'completed' WHERE id = ? AND status = 'shipped'如果某個狀態轉移路徑罕見、但你看到 affect = 0 ── 可能:
- 真實的並發衝突(正常)
- 上游發了錯誤的訊號(要查)
- 訂單被外部直接修改(資料異常)
訂單號的設計#
不要直接用 DB 自增 ID 暴露給用戶 ── 暴露訂單量是商業敏感。
常見方案:
| 方案 | 結構 | 缺點 |
|---|---|---|
| Snowflake-like | timestamp + machine + seq | 機器 ID 設定 |
| 雪花變體 + 用戶 ID 後綴 | + user_id 末 4 位 | 對齊用戶分片 |
| UUID | 隨機 | 索引差,全表掃描慢 |
| Sequence + 加密 | 內部 ID + 加密混淆 | 解密複雜 |
電商常見:yyyyMMddHHmmss + machine + 隨機序列 ── 既可讀又能對齊分片。
小結#
訂單系統的設計要點:
- 冪等是設計時就要的,不是事後補的 ── idempotency_key + unique index
- 狀態轉移要嚴格 ── UPDATE 永遠帶源狀態
- ABA 用版本號或事件流避免
- 庫存扣減靠 DB 原子性:
stock >= ? - 訂單號設計不暴露業務
下章看商品與購物車這兩個與訂單性質截然不同的系統。