庫存:不能超賣,是這個系統唯一的鐵律

· tech

#war-story#live-commerce#inventory

📑 目錄

留言解析完、身分掛好單,主線走到心臟:庫存。這個系統的功能清單可以慢慢長,但只有一條規則從第一天就是鐵律——不能超賣。賣掉不存在的貨,是要一個一個跟客人道歉退款的。這章講當年怎麼守這條不變量、它被什麼打破過(答案會出乎意料),以及重來會怎麼守。

帳本的形狀:不存「剩餘」,存上限與消耗

最直覺的庫存設計,是存一個「剩餘庫存」欄位,賣一件減一、退一件加一。當年沒有這樣做,而是把帳本拆成一個上限、兩個消耗:

product 價格、商品資訊 直播中會一直變 1:1 庫存表(熱資料,獨立一張) 庫存上限 —— 主播追加貨=只調這裡 購物車數量(預留) 訂單數量(成交) 付款:購物車 → 訂單,同一筆交易內轉移 不變量:購物車 + 訂單 ≤ 上限(可賣=導出值,不存「剩餘」) FSM batch 下單・LWW 改單 客人 自行調整數量 客服 清除・調整購物車 助理/營運 追加貨・結束檔期 寫入者有四方——但每小時會從 cart/order items 全量重算兩個計數(派生值的自我修復)
帳本三個數字:上限只被追加貨調整、兩個消耗各自累積;「剩餘」永遠用算的,不用存的。

這個形狀有三個當年就做對的判斷:

  • 熱冷分離。 商品資訊直播中會一直變——主播現場喊價、現場跟廠商追加到貨——所以 product 表是「會被營運頻繁編輯的冷資料」,庫存表是「被下單交易高頻打的熱資料」,拆開,鎖的範圍就只罩熱列。
  • 不存「剩餘」。 剩餘=上限−購物車−訂單,永遠用算的。存剩餘的問題是語意混濁:每一次補償、改單、追加貨都在同一個數字上疊,錯一次就永久漂移,而且你永遠不知道它「本來應該是多少」。分開存,每個數字語意單純:上限只被追加貨動、購物車只進不出(付款轉出、改單修正)、訂單只在付款時加——歪掉時,每個數字都有自己的對帳對象。
  • 預留與成交分開數。 購物車數量(佔著但還沒付)與訂單數量(付了)分開,超賣公式是兩者之和對上限;主播看的「賣了多少」是總和,一樣即時,而「預留→成交」的轉化率順便成了免費的營運指標——棄單率,風控那章會再見到它。

扣庫存:當年的三板斧,與它的偏差

併發扣庫存的標準選項有三條路:資料庫鎖、Redis 原子操作、單一寫入者排隊。當年的組合是第一條的重裝版:ORM + transaction、先查再扣、Serializable 隔離、噴錯就重試Serializable 保證「先查再扣」的間隙不會被人插隊——擠進來的交易會直接失敗,重試,重試再失敗⋯⋯然後,那位顧客就被略過了

先講公道話:這套當年沒有超賣過(超賣另有兇手,下一節)。單一 batch 消費者本來就把大部分的寫入天然序列化了,Serializable 是對付其餘寫入者(客人調數量、客服調整、助理追加貨)的保險帶,邏輯上無懈可擊。

問題出在失敗的分佈。重試耗盡的顧客不是隨機掉的:衝突集中在哪裡,犧牲就集中在哪裡——衝突永遠集中在最搶手的商品。也就是說:你賣得越好的商品,無聲消失的客人越多。 這個偏差安靜、不報錯、不進任何儀表板,是我現在回看最想修的一刀。

重來的修法出奇地便宜——把「先查再扣」壓成一句條件更新:

UPDATE inventory
   SET cart_qty = cart_qty + 2
 WHERE product_id = :pid
   AND stock_cap - cart_qty - order_qty >= 2;
-- 影響 0 列=沒搶到,直接回「完售」;
-- 查與扣之間沒有間隙,也就沒有 Serializable、沒有重試風暴

當年主力是 ORM,而 Django 寫得出一模一樣的東西——filter() 就是 WHERE,F() 讓運算下推到資料庫、不把值撈回 Python:

from django.db.models import F

updated = Inventory.objects.filter(
    product_id=pid,
    stock_cap__gte=F("cart_qty") + F("order_qty") + n,  # 不變量寫在 WHERE 裡
).update(cart_qty=F("cart_qty") + n)                    # 一句 UPDATE,原子完成

if updated == 0:
    ...  # 沒搶到:乾淨地回「完售」,沒有例外、沒有重試

檢查和扣減在同一個原子動作裡,不變量由 WHERE 子句守著:搶不到就是乾淨的 0 列,不用隔離級別撐腰、不用重試、也就沒有重試耗盡的偏差。單列的原子條件更新,是關聯式資料庫最被低估的併發原語——而且它跟 ORM 毫無衝突,差別只在你有沒有意識到 filter().update() 是一句話,而「先 get() 再改欄位再 save()」是兩句話,中間的空隙就是當年 Serializable 在補的洞。

那次真的超賣:兇手不是併發,是一次 migration

這個系統真的超賣過一次。所有人的直覺都會猜是鎖沒上好——不是。公式從頭到尾沒破,破的是公式裡一個詞的定義。

原設計裡,只有直播下單的購物車賣出數量;商城(平台)加入的購物車是意向,不佔。後來需求方希望商城購物車也算進賣出數量——改起來不難,改完跑一次 migration,把歷史的商城購物車全部納入計數。然後:賣出數量瞬間暴增,直接衝破庫存上限。

改動前:意向不佔庫存 直播購物車 60(佔) 餘 40 上限 100 商城購物車 50(意向) 60 ≤ 100 ✓ 不變量成立 需求變更 + migration 之後 直播 60(佔) 商城 50(佔) 上限 100,佔用 110——溢出的 10 件,就是超賣 110 > 100 ✗ 歷史意向被追溯升格成佔用 公式沒變、鎖也沒失守——是「賣出」兩個字的定義變了,而且套用到了歷史資料上
殺死不變量的不是併發:migration 把過去的「意向」一夜之間全部升格成「佔用」,歷史不會替新定義騰出空間。

當年的收拾很務實:新語意保留下來——業務要的就是它;溢出的量兩條路消化——跟廠商追貨、把上限抬上去,追不到的由客服清購物車、逐一跟客人說明。一個從上限端修、一個從佔用端修,把帳擠回不變量裡。代購的地形這裡幫了忙:上限本來就不是倉庫裡的死數字,是「跟廠商還要得到多少」的承諾——有得談。

事後拆解,這裡有兩個獨立的錯誤疊在一起:

  1. 語意錯誤:商城購物車是「還沒承諾的意向」,把它算進佔用,等於把庫存借給不一定會來的人——原設計的「直播佔、商城不佔」區分,其實正是預留(reservation)與意向(intent)的正確分界。
  2. 追溯錯誤:就算業務真的要改,migration 把新語意套到歷史資料上才是引爆點。歷史資料是在舊規則下形成的,新規則生效的那一刻,世界不會重新排隊給你。

我重來的立場很簡單:這個需求我會踩死。 如果真的頂不住,也只有兩個安全檔位——新語意只對新資料生效(不 migrate);或商城佔用另設配額與時效,並且上線前先 dry-run 算出每個商品的計數會變成多少。「直接改+migrate」是唯一絕對錯誤的選項,而它恰恰是最順手的那個。這條教訓值得放大成一句話:不變量不是程式碼,是語意契約——改公式裡任何一個詞的定義,都是在重寫歷史資料的意義。

守住之後:釋放、重算,與大掃除

佔了就要放,不然庫存會慢性失血。當年的釋放不是 TTL 倒數,是結算日:直播以一週一個檔期為購買週期,檔期結束打一支「結束檔期 API」——清掉檔期下的購物車、未付款的訂單,並依條件把不付款的人加入黑名單(懲罰在週期邊界執行,是下一檔期的門票管制)。這支一次清一整檔的 API,當年跑起來意外地順——回頭看是紀律的回報:除了那次量測後的精準反正規化(上一章的 fb_user_id),整個 schema 乖乖遵守 3NF,大掃除時沒有散落各處的派生資料要跟著擦。正規化平常看不出好處,在大規模刪除和結算的那一刻連本帶利還你。

另外兩道防線:

  • 每小時全量重算。 兩個計數的真相是 cart items 和 order items,計數只是它們的快取;每小時從真相重建一次,漂移的上界被壓在一小時內——最終一致性的自我修復,也是三本帳那章的迷你預告。重來只補一件事:重算出的差異要記錄、要告警——差異不為零代表某條增量路徑有 bug,默默蓋掉等於把 bug 的訊號一起擦掉。
  • 追加貨改走調整帳。 當年直播中追加庫存的 API 會打失敗——開賣瞬間幾百個下單交易在同一列上排隊,助理的 UPDATE 擠不進去,現場只能人工重打,等於讓直播助理當了重試機制。重來把追加貨改成 append 的調整帳(adjustments ledger):上限 = 初始 + SUM(調整),追加寫入永遠是 insert 新列、不碰熱列,而且自帶「誰、何時、加了多少」的 audit——後台章要的操作留痕,順便解決。

反思

不變量是語意契約,寫在程式碼之前

這章最重要的教訓,是超賣事故給的:守住不變量的最大威脅不是併發、不是 bug,是一個聽起來很合理的需求變更。「商城購物車也算賣出」在會議室裡毫無殺氣,沒有人覺得自己正在動一條鐵律的定義。工程師在這種時刻的職責,不是評估「改起來多難」——是認出這個需求在改語意,不是在加功能,然後把後果攤開來:要嘛不改,要嘛只對新資料生效,要嘛先 dry-run 給大家看數字。當年我們把它當一般需求做了;重來,這是我會用力說「不」的少數時刻。資深與否的差別,常常不在會多少技術,在認得出哪些字不能隨便改

誠實的帳本,自己會長成 event sourcing 的形狀

把這個系統的資料模型排開:留言先落地再消費、上限與消耗分開存、付款是新增 order item 而不是改 cart item、重來版的追加貨是 append 調整帳、每小時從事實重算派生值——事實只增不改,派生隨時可重建。當年沒有人說過「我們來做 event sourcing」,這些設計是被一個個具體的痛(帳對不上、熱列打架、除錯沒線索)逼出來的。DDIA 第三部分講的那套「事實與派生」,在一個六人團隊的電商系統裡自己長了出來——好的架構模式不是拿來套的,是誠實面對資料語意之後,自然收斂到的形狀。

偏差比故障可怕

故障會叫:告警響、圖表掉、所有人衝進來。偏差不會——Serializable 重試耗盡的顧客,一個一個安靜地消失,而且集中在你最熱賣的商品上,系統毫無感覺。這是我帶 SRE 之後回頭看最有感的一刀:平均成功率 99% 的系統,可能在最重要的那 1% 流量上是 90%——平均值會說謊,失敗的分佈才說真話。重來版用一句條件更新把這個偏差從根拔掉,但更通用的功課是:每次設計「失敗就略過」的路徑時,先問一句——被略過的,會不會恰好都是同一群人?