用 MySQL 取代 Redis 做庫存預留(Shopify)

Shopify Engineering|Emilie Noel|2026-05-12|9 分鐘 原題:〈We replaced Redis with MySQL for inventory reservations—and it scaled〉 副標:How we used SKIP LOCKED, composite primary keys, and connection visibility to hit our scale targets.

圖片與程式碼(依原始 source 匯入)

文中的兩段 SQL 是嵌入的 GitHub gist,我把原始內容取回並寫進對應段落(gist 1、gist 2)。 文章本體有 2 張圖,都用原始 URL 嵌在下面對應位置:一張是頁首主圖,一張是結尾的梗圖。沒有影片。

統整

問題:超賣與少賣,兩邊都會痛

結帳時買家按下「完成購買」,必須保證他要買的東西還在。兩個方向都會出事:

方向後果
超賣(oversell)兩個買家買到同一件最後庫存 → 商家要取消訂單、寄道歉信、吃掉客服成本
少賣(undersell)明明還有貨卻告訴買家售完 → 商家損失一筆本來做得到的生意

規模讓兩者都會快速複利:2025 黑五當天,平台商家的尖峰銷售額達到每分鐘 510 萬美元的紀錄,而每一筆交易都會碰到庫存。

系統要做的兩件事

操作時機做什麼
Reserve(預留)付款開始時把商品標記為已預留(短期持有,例如數分鐘)
Claim(認領)付款成功時從庫存帳本(source of truth)永久扣除數量

結帳完成依賴這件事又快又正確。預留太慢會觸發節流、讓買家體驗變差;做錯則是超賣(憤怒的客人)或少賣(流失的營收)。

規模與正確性的要求

  • Shopify 支撐超過 14% 的美國電商;2025 黑五尖峰每分鐘銷售額比前一年增加 11%
  • 預留發生在每一次碰到庫存的結帳上

需求清單:

  • 在尖峰流量下支撐平台的高吞吐目標
  • 尊重多地點庫存(只從能夠履約的地點預留)
  • 在預留與庫存帳本之間維持 ACID 保證
  • 正確性優先:不超賣、不遺失預留

舊的 Redis 模型與它的極限

做法:每個商品一個 quantity key,預留就是 DECR,釋放就是 INCR。

Redis 處理併發沒問題,但預留和庫存帳本活在兩個不同的系統裡。

致命的地方在 claim 這一步:付款處理完成後要永久扣庫存,必須同時更新 MySQL 和清理 Redis,而這兩個操作無法被包進單一的原子步驟。依照執行順序不同,會造成:

順序 A → 超賣:商品賣掉了,但從來沒有從帳本扣除
順序 B → 少賣:商品被扣除了,卻仍然標記為已預留

另外還有兩個問題:Redis 模型沒有多地點意識,以及多維護一個獨立叢集的營運成本。

把預留搬進和帳本同一個 MySQL 資料庫,意味著我們可以把所有事情包進 ACID 交易,徹底消除這些失敗模式。

解法:SKIP LOCKED

核心想法:一列一單位,而且刻意有上限

不是「一個商品一列,帶一個 quantity 欄位」,而是一個可售單位一列。

一個有 10 個單位的商品就有 10 列。預留 3 個單位=在單一交易裡選出並搬移 3 列。而且因為預留和庫存帳本在同一個資料庫,reserve 和 claim 之間有 ACID——修掉了 Redis 時代那一整類 bug。

簡化後的預留流程(原文嵌入的 gist):

-- 以 READ COMMITTED 隔離等級開始交易
BEGIN;
 
-- 選出 N 個單位,跳過任何被其他交易鎖住的列
SELECT id
FROM available_units
WHERE shop_id = ?
  AND inventory_item_id = ?
  AND inventory_group_id = ?
ORDER BY shop_id ASC, inventory_item_id ASC, inventory_group_id ASC, id ASC
LIMIT 3
FOR UPDATE SKIP LOCKED;
 
-- 把選中的單位搬到 reserved_units 表
INSERT INTO reserved_units (unit_id, cart_token, expires_at, ...)
 
-- 從可用池移除
DELETE FROM available_units
WHERE (shop_id, inventory_item_id, inventory_group_id, id) IN (?, ?, ?, ?);
 
COMMIT;

SKIP LOCKED 就是讓這件事可擴展的關鍵:如果另一個交易鎖住了某些列,MySQL 會跳過它們、回傳其他可用的列。不用在同一列上等待,競爭大幅降低。

為什麼要設上限:1,000

「一列一單位」如果套用到所有庫存會在規模下崩掉:

一個在 10 個地點有 50,000 個單位的商品會變成 500,000 列,而預留查詢會因為要掃過它們而變慢。

所以他們維持一個有上限的可用列池,每個「商品 × 地點」組合上限 1,000 列。預留從這個池子消耗列,一個補貨(replenishment)程序再從庫存帳本把它填回來。

為什麼是 1,000? 上限要大到足以吸收突發流量而不見底,同時小到讓表保持精簡、讓 SKIP LOCKED 的掃描夠快。

定這個數字的方法是經驗性的:觀察快閃特賣期間每個「商品 × 地點」的尖峰預留速率,1,000 給的餘裕足以讓補貨在持續負載下跟得上,同時表不會長到查詢效能開始退化。

池子空了會怎樣

極端快閃特賣時,熱門商品的池子可能暫時耗盡。此時:

預留路徑 → 就地觸發補貨(inline replenishment)
          → 用一把鎖確保「同一時間只有一個交易在補貨」
          → 其他同商品的併發預留「等它做完」,而不是全部搶著插入列
            ↑ 這就是避免 thundering herd
          → 補完之後,等待中的交易帶著滿的池子繼續

買家永遠不會看到商品不可用(除非它真的沒了)。這會為那一筆特定的預留增加延遲,但它保住了正確性:一個有庫存可買的買家永遠不會被拒絕。

四個關鍵技術決定

1. 複合主鍵:每列少一把鎖

第一版原型用 auto-increment ID 當主鍵。用 SHOW ENGINE INNODB STATUS 觀察鎖行為時,他們看到每次預留有兩把 row lock 而不是一把。

原因:有 auto-increment 主鍵時,InnoDB 同時鎖住了 WHERE 用到的次級索引,以及叢集索引(主鍵)。

修法是改成複合主鍵,讓拿來過濾的欄位本身就是主鍵的一部分:

PRIMARY KEY (shop_id, inventory_item_id, inventory_group_id, id)

結果:降到每列一把鎖,在每秒跑大量預留時這件事很重要。

Takeaway:在這個規模下,索引與主鍵設計直接影響鎖的數量與吞吐量。

2. READ COMMITTED:避開 gap(supremum)鎖

在一張空的、需要補貨的表上跑 SELECT ... FOR UPDATE SKIP LOCKED 時,他們看到 gap lock(包含在 supremum 偽紀錄上的鎖)。這些鎖擋住了補貨交易插入新列,而且可能導致死鎖。

修法:把這些交易的隔離等級從 MySQL 預設的 REPEATABLE READ 改成 READ COMMITTED。

在 READ COMMITTED 底下,InnoDB 不會用同樣的方式取得 gap lock,所以補貨得以進行。

兩個附帶細節:他們特別感謝 Jahfer Husain 的 InnoDB locking 指南;而且這是這個程式庫第一次使用非預設的隔離等級,需要框架小幅支援「逐交易設定隔離等級」。

3. 一致的上鎖順序:避開死鎖

reserve 和 claim 以不同順序碰兩張表時會死鎖:

原本:
  reserve → INSERT 進 reserved_quantities,然後 DELETE from reservation_units
  claim   → DELETE from reserved_quantities
  ⇒ 不同交易以不同順序鎖住兩張表 → 形成環 → 死鎖

修正後:
  reserve → 先 DELETE 單位表,再 INSERT 進 reserved_quantities
  claim   → 只碰 reserved_quantities
  ⇒ 兩條路徑以相同順序取得鎖 → 不可能循環等待

4. 用 UNION ALL 批次化

每一次往返資料庫都有成本。對於有多個品項的購物車,用 UNION ALL 把預留查詢批次化,一次往返取得所有需要的單位(原文嵌入的 gist):

(SELECT id, inventory_item_id, inventory_group_id
 FROM available_units
 WHERE shop_id = 1 AND inventory_item_id = 100 AND inventory_group_id = 1
 ORDER BY shop_id, inventory_item_id, inventory_group_id, id
 LIMIT 2 FOR UPDATE SKIP LOCKED)
UNION ALL
(SELECT id, inventory_item_id, inventory_group_id
 FROM available_units
 WHERE shop_id = 1 AND inventory_item_id = 200 AND inventory_group_id = 1
 ORDER BY shop_id, inventory_item_id, inventory_group_id, id
 LIMIT 5 FOR UPDATE SKIP LOCKED)

真正的瓶頸:連線,不是 CPU

這一節是整篇的重點,也是標題沒說的那一半。

在生產環境他們撞到一個遠低於目標的吞吐天花板,而且:

  • 預留延遲(P90)可以接受
  • CPU 沒有滿載
  • 查詢已經最佳化過了

所以我們往別的地方看。

試過的無效路徑:把多個結帳的預留批次到單一 SKIP LOCKED 查詢裡以少用連線(負載測試有幫助,但增加了複雜度);把部分讀取負載移到副本。「還是有東西對不起來。」

跟著症狀走

負載測試時看到的三個症狀:

  • MySQL 裡有 thread 在排隊
  • 排隊的工作跑起來時 CPU 飆高
  • ProxySQL 層對 MySQL 後端的連線耗盡

關鍵的一句:

知道連線被耗盡,並不告訴你是誰佔著它們。我們需要「逐呼叫者」的歸因。

他們的做法(兩層,很容易複製):

應用層  → 為每一條 SQL 語句加上標示業務流程的註解標籤
           例如  /* conn_tag:checkout_completion */

ProxySQL 層 → 加上追蹤,解析這個標籤,
              並量測「每個呼叫者持有連線多久」

結果 → 連線總持有時間,按業務流程拆解

這立刻顯示出哪些呼叫者消耗了最多的連線時間。不是哪些查詢慢,而是哪些流程在長交易中持有連線。 如果你正在撞連線上限卻說不出為什麼,這個模式(在應用層打標籤、在代理層聚合)實作起來很直接,而且立刻可行動。

看見之後發現了什麼

一旦我們能看見連線用量,我們才知道預留並不是唯一的大戶。

結帳路徑的其他部分持有連線的時間超過必要。而它們之所以沒被最佳化過,只是因為它們不是第一個撞到上限的。

連線是有限的:在高吞吐下我們需要每秒大量的短交易。當其他程式持有連線更久時,預留是壓垮駱駝的最後一根稻草——不是因為預留很慢,而是因為連線池早就接近枯竭了。

兩件事一起做完才拆掉天花板:

動作效果
清理結帳路徑主資料庫上少了 50% 的讀取、33% 的交易
重新檢視 MySQL 設定InnoDB thread concurrency 多年前被保守地設定過,之後從未重新評估。工作負載早就變了;在有餘裕的地方調高之後,移除了一個「在把連線與 CPU 指標並排看之前根本看不見」的瓶頸

結果:在高流量快閃特賣期間,writer CPU 維持在 50% 以下,reader CPU 在 16% 以下,還有餘裕。

切換:影子模式

他們沒有直接把開關從 Redis 扳到 MySQL。

影子模式(shadow mode)
  → 每一筆預留「同時」寫進 Redis 和 MySQL
  → Redis 維持 source of truth
  → 可以在真實生產流量上並排比較兩個系統,
    驗證 MySQL 產出正確的業務結果、且符合效能要求
  → 因為兩個系統都活著,「沒有進行中的預留需要遷移」
    Redis 的預留繼續被承認,MySQL 同時建立自己的狀態

確認正確性與效能之後才把 source of truth 切到 MySQL。保險機制:有 kill switch 可以切回 Redis,因為雙寫路徑仍然活著,Redis 隨時都有完整的預留視圖。推進方式是逐 pod 漸進,從低流量 pod 開始,一路做到最高流量的商家。

他們自己列的兩個收穫

1. 重新檢視舊決定

五年前做不到的事(例如用 MySQL 跑這個工作負載),今天可能因為 SKIP LOCKED 這類新功能而做得到。 設定也一樣:thread 上限和其他「經驗法則」設定,值得在工作負載與硬體演化之後重新檢查。 如果數字對不起來(例如 CPU 低但有排隊),就深入挖。

2. 從小處開始並觀察

我們從一個最小原型得到很大的價值:一個小的 ruby script 加 MySQL,沒有 Rails 這種完整框架。 觀察資料庫(例如在第二個終端機看鎖的行為)教會我們的,比純理論多。 探索的時候,簡單的工具加上緊密的回饋迴圈,勝過龐大而不透明的系統。

結論段的三句:

MySQL 現在能處理我們過去以為需要專門基礎設施的工作負載。如果你正在為高吞吐的互斥需求伸手去拿 Redis、Kafka 或自製的協調層,你現有的資料庫可能已經夠了。

瓶頸不在我們預期的地方。我們花了好幾週最佳化查詢和鎖;真正的極限是在我們根本沒在看的程式裡的連線用量。 如果數字對不起來——CPU 低但排隊高——就去檢測整條路徑。答案常常在管路裡,不在引擎裡。

關鍵是,這件事並不是要讓預留變快,而是要讓它成為「好鄰居」。 預留和購物車更新、付款處理、訂單建立共用一個資料庫。一個會塞爆連線或持鎖過久的系統,會危害到所有其他系統。真正的標準是:在不損害資料庫整體健康的前提下維持吞吐量。

Water line vs race meme: inventory|757x757

反覆出現的關鍵字

  • 正確性優先(correctness):需求清單第一條、池子見底的處理方式、影子模式的驗證標準,全部收束在同一句——寧可多付延遲,不可以拒絕一個有貨可買的買家。
  • contention(競爭)/鎖:從 Redis 的單一 quantity key,到「一列一單位」,到複合主鍵減少鎖數,到 gap lock,到上鎖順序——整篇的技術決定全都在回答同一個問題:怎麼讓兩個交易不要撞在一起。
  • 有界(bounded):1,000 列上限是唯一被展開解釋「為什麼是這個數字」的設計參數。「大到吸收突發、小到掃描夠快」是一個雙邊約束,而不是單邊最佳化。
  • 可見性(visibility)/歸因(attribution):副標題三個詞之一,也是真正解決問題的那一個。「知道連線被耗盡,並不告訴你是誰佔著它們」是全篇最可遷移的一句。
  • 數字對不起來(the numbers don’t add up):出現在兩個收穫裡,而且是他們決定往別處看的觸發條件。低 CPU + 高排隊 = 去檢測整條路徑。
  • 好鄰居(safe neighbors):最後一段才出現,但它重新定義了整個專案的目標——不是「讓預留變快」,是「讓預留不要害到共用資料庫的其他人」。

矛盾點

文章內部

  1. gist 裡的 SQL 和「一致上鎖順序」那節自相矛盾。 gist 的流程是先 INSERT 進 reserved_units、再 DELETE from available_units;但第 3 節明講修正後的順序是先 DELETE 單位表、再 INSERT。(推論)gist 應該是修正前的簡化版沒有同步更新,照它抄會重現那個死鎖。
  2. 表名在文章裡有三種寫法:gist 用 available_units / reserved_units,第 3 節用 reservation_units / reserved_quantities。(推論)讀的時候要自己對齊,否則會以為有四張表。
  3. 「這不是要讓預留變快」和整篇前三分之二的內容張力很大。(推論)前面大半篇在講 SKIP LOCKED、複合主鍵、批次化——全部都是在讓預留變快。這句結論是誠實的重新定位,但它也等於承認:他們花了好幾週最佳化的東西,並不是真正的瓶頸。(他們自己在下一段就講了。)
  4. 「MySQL 可能已經夠了」這個結論被自己的過程打臉一半。(推論)為了讓 MySQL 夠,他們動用了:SKIP LOCKED、非預設隔離等級(需要改框架)、複合主鍵重設計、有界池、inline 補貨加鎖、UNION ALL 批次、ProxySQL 層的自訂追蹤、InnoDB thread concurrency 調校、影子模式雙寫切換。「你現有的資料庫可能已經夠了」是對的,但「夠了」的門票不便宜。
  5. 1,000 這個上限的通用性沒有被討論。 作者說是從「快閃特賣時每個商品×地點的尖峰預留速率」觀察來的。(推論)那它綁定的是 Shopify 的流量形狀,不是一個可以照抄的常數——但文中的寫法容易讓人當成推薦值。

與 vault 既有筆記

  1. ACID 的隔離性是可調的,預設擋不住 write skew 在這裡拿到一個正面案例。 那篇講的是預設隔離等級太弱要往上調;這篇是反方向——他們刻意從 REPEATABLE READ 降到 READ COMMITTED,因為預設的 gap lock 反而擋住了補貨插入。(推論)合起來才是完整的:隔離等級是一個雙向的旋鈕,不是「越高越安全」的單調刻度。那篇可以補一句:往上調換正確性,往下調換併發,兩邊都要先知道自己在換什麼。
  2. 「資料庫撐不住」不是一個問題,是三個 少了第四種。 那篇列的是寫入吞吐/磁碟容量/分析查詢太慢。這篇的瓶頸三者都不是——是「連線池耗盡」,症狀是 CPU 低、延遲正常、但吞吐撞天花板。(推論)這是那篇最該補的一條,而且它的診斷路徑完全不同:不是看查詢,是看「誰持有連線多久」。
  3. 索引是拿寫入換查詢,寫多讀少的表加索引是純虧 得到一個新面向。 那篇算的是「寫入時要多維護幾棵樹」。這篇加上了鎖的維度——用 auto-increment 主鍵時,WHERE 打在次級索引上會同時鎖次級索引和叢集索引,每列兩把鎖。(推論)值得補進那篇:索引的成本不只是維護,還包括它在高併發下多產生的鎖。
  4. Key-Value 的限制是為了換分片而付的價 的反向案例。 那篇解釋 KV 為什麼要拿掉 JOIN 和跨 key 交易。這篇則是因為缺少跨系統交易而付出代價:Redis + MySQL 兩個系統,claim 那一步無法原子化,直接導致超賣/少賣。(推論)補充的判準是:當兩個資料必須一致時,把它們放在同一個交易邊界裡的價值,會超過 KV 帶來的吞吐優勢。
  5. WHERE 決定回傳幾列,索引才決定要看過幾列 的一個極端實作。 1,000 列上限的理由就是「讓 SKIP LOCKED 的掃描夠快」——(推論)他們不是靠索引壓低掃描量,而是直接讓表本身不可能變大。這是那篇的一個變體:控制掃描量的最後手段是控制表的大小。
  6. 改漏一列的代價不是工多,是資料安靜地不一致 的商業版本。 那篇講的是資料庫不會抗議;這篇的超賣/少賣正是它不抗議的後果最終由商家承擔(取消訂單、道歉信、客服成本、流失的營收)。(推論)可以互鏈。
  7. 同一個寫法在互動式列表沒事,在全量掃描必爆 的另一種形狀。 那篇說負載形狀決定慣例的適用範圍。這篇的「InnoDB thread concurrency 多年前保守設定、之後從未重新評估,而工作負載早就變了」是同一個主張在設定值上的版本。(推論)設定值也有適用範圍,而且它不會自己告訴你已經過期。

待確認

  • 預留的 TTL 到底多長? 文章只說「短期持有,例如數分鐘」。過期的預留怎麼回收——背景 job、還是下次 reserve 時順手清?沒講。
  • 補貨程序的觸發條件:除了「池子空了就地補」之外,有沒有背景的定期補貨?水位線在哪?
  • SKIP LOCKED 的公平性:跳過被鎖的列意味著沒有排隊順序。高併發下會不會有某些交易一直拿不到列?文章沒有討論飢餓問題。
  • 1,000 這個上限對多地點的影響:上限是「每個商品 × 地點」,那一個熱門商品在 10 個地點就是 10,000 列——和文中反對的「500,000 列」只差一個數量級,這個邊界在哪?
  • conn_tag 的成本:為每一條 SQL 加註解會不會影響 query cache/執行計畫快取?在 ProxySQL 解析註解的開銷多大?
  • 文中提到的 Jahfer Husain 的 InnoDB locking 指南值得單獨找來看。