iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
Software Development

Re:從零開始做直播代購電商平台系列 第 7 篇

Day 7|庫存(上):不能超賣,是唯一的鐵律

  • 分享至 

  • xImage
  •  

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

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

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

帳本三個數字:上限只被追加貨調整、兩個消耗各自累積;「剩餘」永遠用算的,不用存的。

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

  • 熱冷分離。 商品資訊直播中會一直變——主播現場喊價、現場跟廠商追加到貨——所以 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 在補的洞。

反思

偏差比故障可怕

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

不過,這個系統真的超賣過一次——而兇手不是併發。明天講那次事故。


本文改寫自我的部落格系列《Re:從零開始做直播代購電商平台》,本篇完整版:https://blog.aidan.tw/blog/rezero-inventory/


上一篇
Day 6|身分與帳號(下):FB 的牆,與三層綁定漏斗
下一篇
Day 8|庫存(下):那次真的超賣,兇手是一次 migration
系列文
Re:從零開始做直播代購電商平台 共 11 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言