超賣大概是電商後端第一名的經典題:限量 100 件的商品一上架,同一秒湧進 500 個人搶,怎麼保證最後不會賣出 101 件?這篇拆一個真實電商怎麼設計庫存,以及一個我覺得很值得攤開的現場——這套系統有三條扣庫存的路徑並存,是新舊過渡沒收乾淨的活教材。
庫存不是一個數字,是三個
第一個關鍵認知:一個商品的「庫存」不是一個數字,至少是三個:
- quantity(實際庫存):倉庫裡真的有幾件。
- reserved(已預佔):有人正在結帳、還沒付錢,但先幫他保留的。
- safety(安全庫存):刻意留的緩衝,不賣到見底。
真正能賣的是 available = quantity − reserved。這個「可賣量」才是防超賣要守住的數字。而且真實電商是多倉的——同一個商品在好幾個倉都有貨,扣的時候還要決定從哪個倉扣、能不能跨倉。
預佔:為什麼「加車」不能佔庫存
第二個關鍵:什麼時候該保留庫存?
答案不是「加購物車」。如果加車就佔庫存,那一堆人把限量品加進車放著不買,庫存就被卡死、真正想買的人看到「缺貨」——這是早期一些電商犯過的錯。
正確的時機是進入結帳(建立 CheckoutSession)的時候才預佔。而且預佔本身是一個小狀態機:RESERVED(保留中)→ CONFIRMED(付款成功、正式扣)/ RELEASED(放棄結帳、釋放)/ EXPIRED(超時沒付、自動釋放)。限量品的預佔還會設一個短時效(例如 10 分鐘),時間到沒付款就由背景工作(Celery)自動釋放,把庫存還給下一個人。
這個「預佔 + 短時效自動釋放」的機制,才是防止「庫存被卡死」和「超賣」之間的平衡點。
防超賣的核心:行鎖
真正扣庫存的那一下,防超賣靠的是行鎖(select_for_update)。
想像 500 個請求同時要扣同一個商品的庫存。如果每個都「先讀 available、判斷夠不夠、再扣」,就會出現經典的 race condition:500 個都讀到「還有 1 件」,500 個都判斷「夠」,然後 500 個都扣——超賣。
行鎖把這件事序列化:扣庫存前先鎖住那一列,同一時間只有一個請求能動它,扣完釋放鎖、下一個才進來。500 個請求排隊一個一個扣,扣到 0 的時候後面的自然失敗。這跟 Redis INCR 原子計數 是同一個道理——把「讀-改-寫」交給資料庫的鎖,而不是在應用層自己判斷。
誠實的現場:三條扣庫存路徑並存
這一段是這套系統最真實、也最值得引以為戒的地方。
實際去追 code 會發現,這套系統扣庫存的路徑不只一條,是三條並存:
- 舊的下單流程(place_order)直接扣
- 新的 CheckoutSession 確認時扣
- 出庫單確認出貨時扣
三條路徑各自都對,問題是它們並存——如果一張訂單不小心走了其中兩條,庫存就會被重複扣。這是「新舊流程過渡」的典型病:新的結帳流程加上去了,但舊的 place_order 沒拔乾淨;出庫單那條又是另一個階段補的。每一條當初都有它的理由,湊在一起就是一個「同一件事有三個地方在做」的地雷。
這跟出貨那篇的「Order 進 SHIPPING 有兩條驅動路徑」、「Shipment↔DeliveryGroup 雙軌」是同一種病——重構做到一半,新路徑上線了,舊路徑忘了拔。 它不是「寫得爛」,是真實系統演化的常態;但它必須被標記出來,不然下一個人會照著三條路各加各的,越滾越亂。收斂這種過渡帶,本身就是一門功課(strangler fig 那套)。
收:庫存設計的三個要點
- 庫存是三個數字:quantity − reserved = available,safety 留緩衝,可賣量才是防超賣要守的。
- 結帳才預佔、加車不佔:預佔配短時效自動釋放,平衡「卡死」和「超賣」。
- 扣庫存用行鎖、且只留一條路徑:行鎖防 race;三條路徑並存是過渡病,要收斂成一條。