大型電商系統架構實戰系列(三):購物車、庫存預留、下單流程與超賣治理
前言
如果說首頁、搜尋、商品頁代表的是電商平台的流量壓力,那麼從加入購物車到正式下單,代表的就是平台最敏感的交易壓力。因為這條鏈路不只影響使用者體驗,還直接牽動庫存是否正確、訂單是否重複、支付是否能接上、後續履約是否有依據。
很多電商系統一開始都把這件事想得太簡單:使用者按下結帳,系統檢查庫存、建立訂單、扣掉庫存,然後等待付款。這條描述在白板上看起來很直覺,但一旦碰到高併發、大促、秒殺、重試請求、支付逾時、跨倉庫存與後續取消退款,問題就會快速失控。
所以這篇要處理的核心問題是:購物車、庫存與訂單到底應該怎麼拆? 以及在超大型電商場景下,為什麼「先查庫存再扣庫存」這種直覺流程往往不夠用,甚至會直接把系統帶進超賣、錯單與補償地獄。
系列文章導航
- 從商品瀏覽到訂單履約,超大型電商平台到底怎麼拆
- 商品目錄、搜尋、推薦、快取與 CDN,為什麼電商本質上是讀流量主導的系統
- 購物車、庫存預留、下單流程與超賣治理(本篇)
- 支付、退款、帳務、對帳與交易一致性
- MySQL、PostgreSQL、DynamoDB、Redis、OpenSearch 與 S3,資料到底該放哪裡
- CloudFront、WAF、ALB、EKS、Aurora 與 Multi-Region,AWS 基礎設施如何規劃
- 黑五與大促場景下的限流、降級、觀測、災難復原與成本控制
購物車是使用者意圖,不是正式訂單
電商系統最常見的第一個混淆,是把購物車當成「還沒付款的訂單」。
這種想法在業務上看似合理,但在系統上很危險。因為購物車通常只是:
- 使用者暫時感興趣的商品集合
- 可能跨裝置同步的狀態
- 可能長時間不結帳的暫存資料
- 可能被刪除、替換、合併或失效的意圖資訊
換句話說,購物車的核心責任是幫使用者記住「我想買什麼」,而不是代表平台已經承諾「你一定買得到」。
這個界線很重要,因為它直接影響後面的庫存策略。如果你在使用者把商品加進購物車的瞬間就永久扣減庫存,平台很快就會被大量棄單與囤貨行為拖垮。反過來說,如果你完全不做預留,高峰時又很容易在結帳瞬間大量超賣。
庫存不是一個欄位,而是一組狀態
中小型系統常見的商品表設計是這樣:
stock_totalstock_available
但大型電商的庫存通常至少要拆成幾種不同視角:
- 總庫存:物理上存在的數量。
- 可售庫存:目前可對外販售的數量。
- 預留庫存:已被結帳流程占住,但尚未完成付款或確認的數量。
- 在途庫存:採購、調撥或退貨回倉途中。
- 倉庫維度庫存:不同倉、不同地區、不同配送時效的可售量。
只要平台開始有跨倉、預購、第三方賣家、門市取貨或活動保留量,庫存就已經不是一個欄位能解釋清楚的東西。
為什麼「先查庫存再扣庫存」不夠用
最直覺的流程通常是:
- 使用者點結帳。
- 系統查庫存夠不夠。
- 如果夠,就建立訂單。
- 然後把庫存減掉。
這在低流量情境可能還能運作,但在大促或熱門商品情境下,很容易出現經典 race condition:
- 很多請求同時查到同一批剩餘庫存。
- 每個請求都認為自己可以下單。
- 後面才發現總和超過實際可售量。
如果你完全依賴資料庫列鎖硬扛,確實有機會保住正確性,但你會立刻面對:
- 熱門 SKU 成為單點寫入瓶頸
- 大量交易等待鎖
- p99 延遲飆高
- 整條下單鏈路在活動期間變得不可預測
所以對超大型電商來說,關鍵不是只有「正確」,而是要在正確的前提下,讓熱點也能被有秩序地承接。
Reservation 是電商高併發庫存治理的常見解法
很多成熟平台不會在下單最前面就做最終扣減,而是先做一層庫存預留(reservation)。
它的邏輯通常是:
- 使用者進入結帳流程。
- 系統針對商品或 SKU 嘗試建立短生命週期 reservation。
- reservation 成功後,才允許進入訂單建立與支付流程。
- 若支付成功或訂單正式確認,reservation 轉為實際扣減。
- 若逾時未付款或流程失敗,reservation 自動釋放。
這種做法的價值在於:
- 把「短暫占住庫存」與「最終成交」切開。
- 可以吸收支付等待時間,而不必立刻做永久扣減。
- 對熱門商品可以加上更明確的容量控管。
但 reservation 不是魔法,它會帶來新的責任:
- 逾時回收機制
- 重試與冪等控制
- reservation 漏釋放的補償任務
- 對帳與修復流程
也就是說,它不是讓問題消失,而是把問題從同步鎖衝突,轉移到狀態治理與補償治理。
一條比較合理的下單鏈路會長什麼樣子
sequenceDiagram
participant User as 使用者
participant Cart as Cart Service
participant Order as Order Service
participant Inventory as Inventory Service
participant Payment as Payment Service
User->>Cart: 確認購物車內容
User->>Order: 建立訂單請求
Order->>Inventory: 嘗試建立 reservation
Inventory-->>Order: reservation 成功 / 失敗
Order-->>User: 建立訂單與支付意圖
User->>Payment: 完成付款
Payment-->>Order: 支付成功通知
Order->>Inventory: reservation 轉正式扣減
Order-->>User: 訂單確認成功
這條流程的重點不是 sequence diagram 本身,而是它反映了兩個核心觀念:
- 訂單建立與最終庫存扣減之間,通常存在一段待確認狀態。
- 支付、庫存與訂單不是同一個欄位就能表達清楚的狀態機。
訂單其實是一個狀態機,而不是一筆靜態資料
大型電商的訂單很少只是 created、paid、shipped 這幾個簡單狀態。更常見的真實情況是:
OrderCreatedInventoryReservationPendingInventoryReservedPaymentPendingPaidPickingPackedShippedDeliveredCancelledRefundPendingRefunded
一旦你接受訂單是狀態機,而不是資料表的一行記錄,很多實作上的判斷就會更清楚:
- API 重試時應該檢查狀態與 idempotency key,而不是盲目重做。
- 補償任務要知道自己正在補哪一段狀態轉移。
- 後台查詢與客服工具要能解釋「卡在哪一個步驟」。
秒殺、大促與熱門 SKU 為什麼最容易超賣
對超大型電商來說,平時能跑,不代表活動時能活下來。熱門活動會把大量流量壓到少數商品、少數價格與少數分區,這時候最常見的問題就是:
- 同一個 SKU 成為熱點
- 同一段庫存邏輯反覆被打
- 大量請求同時進來,造成 reservation 與訂單建立暴增
- 支付前後狀態分叉,造成待清理資料急遽增加
所以成熟的系統通常不只靠資料庫交易來保護,而會疊加:
- 入口排隊或漏斗式接受機制
- 對熱門商品獨立限流
- 單人限購
- reservation TTL 與過期清理
- 後台補償與對帳任務
本質上,這是一個系統容量治理問題,不只是 SQL 寫法問題。
Idempotency 是高併發下單流程的基本配備
在大型電商中,你幾乎不能假設使用者只會按一次按鈕,也不能假設 App、瀏覽器、API gateway、支付方都不會重送。
所以至少以下幾個動作通常都需要 idempotency 設計:
- 建立訂單
- 建立 reservation
- 建立支付意圖
- 接收支付 callback
- 取消訂單
- 退款請求
這裡的關鍵不是單純「防止重複 insert」,而是要保證:同一個業務意圖被重送時,系統能回到同一個業務結果,而不是重新走一次完整副作用。
Saga 與補償不是選配,而是現實
一筆電商交易實際上會經過多個系統:購物車、庫存、訂單、支付、履約、通知。只要跨過服務邊界,你就很難再用單一資料庫 transaction 包住所有事情。
這時候比較務實的做法通常不是硬追求分散式兩階段提交,而是:
- 讓每一段局部交易各自成功提交
- 透過事件驅動串起後續流程
- 若中途失敗,執行補償動作
例如:
- 訂單建好,但 reservation 失敗,訂單應取消或標記失敗。
- reservation 成功,但支付失敗,應釋放 reservation。
- 支付成功,但履約異常,後續要進入客服或退款處理。
這也是為什麼大型電商不能只設計 happy path,而必須把 failure path 當作主線一起設計。
總結
從購物車到正式下單,超大型電商真正要處理的不是一條簡單 CRUD 流程,而是一條高併發、跨狀態、跨系統、需要補償與對帳的交易鏈路。
購物車代表的是使用者意圖,reservation 代表的是短暫資源占用,訂單代表的是正式業務承諾,支付代表的是外部金流結果,而庫存扣減則是整條鏈路最不能出錯的核心約束之一。只要這些責任沒有被清楚切開,系統就會在高峰時段迅速進入混亂。
下一篇我們會接著往下走,處理電商最容易被寫得過於簡化、但實際上極其關鍵的一段:支付、退款、帳務、對帳與交易一致性。







