世界を動かす技術を、日本語で。

Shopifyは在庫予約のためにRedisをMySQLに置き換え、スケールアップしました

概要

Shopifyの 在庫予約システム は、Black Friday 2025のピーク時に $5.1M/分の売上 を処理。 RedisからMySQLへの移行で ACIDトランザクション による一貫性とスケーラビリティを実現。 SKIP LOCKED を活用した一行一単位の設計と、1,000行上限のプール方式を導入。 ボトルネックはCPUではなく DB接続数 であると判明し、接続管理の可視化で解決。 段階的な切り替えと徹底した検証で、信頼性とパフォーマンスを両立。

Shopifyの在庫オーバーセル保護システムの進化

  • オーバーセル保護 は、購入完了時に商品がまだ購入可能かを保証する仕組み
  • 誤判定による 二重販売販売機会損失 を防止
  • Black Friday 2025では 過去最高のトランザクション数 を記録
  • すべての取引で 在庫管理システム が関与

オーバーセル保護の主な処理

  • Reserve :支払い開始時に在庫を一時的に予約
  • Claim :支払い成功時に在庫台帳から数量を恒久的に差し引き
  • 予約の 速度と正確性 が購入体験と売上に直結

システム要件

  • 高スループット対応 :ピークトラフィックでもリクエストを落とさない
  • 複数ロケーション対応 :発送可能な拠点からのみ予約
  • ACID保証 :予約と台帳間で一貫性維持
  • 正確性最優先 :オーバーセル・アンダーセルの排除

Redisモデルの限界とMySQL移行

  • 旧システムは Redis を利用し、DECR/INCRで在庫を管理
  • 予約と台帳が分離 し、原子的な同時更新が困難
  • オーバーセルやアンダーセル を引き起こすリスク
  • マルチロケーション非対応、Redis運用コストも課題
  • MySQL統合 でACIDトランザクション・シングルシステム運用へ

MySQLでのSKIP LOCKED設計

  • 一商品一行 から 一単位一行 へ設計変更
    • 例:在庫10個なら10行で管理
  • SKIP LOCKED で同時予約時の競合を回避しスケーラブルに
  • 1,000行上限のプール 方式を採用
    • プール枯渇時はインラインで補充
    • 補充時はロック でスロットル、スローレスポンスだが正確性を担保

主要な技術的工夫

  • 複合主キー でロック数削減
    • shop_id, inventory_item_id, inventory_group_id, id の組み合わせ
  • READ COMMITTED でギャップロック回避
    • REPEATABLE READから変更し、デッドロック防止
  • ロック取得順序統一 でデッドロック解消
    • 予約・確定処理でテーブル操作順を標準化
  • UNION ALLによるバッチ処理 でDBラウンドトリップ削減

真のボトルネック:DB接続数

  • CPUやクエリ最適化 後もスループットが頭打ち
  • 接続数枯渇 が主因と判明
  • 各SQLに コメントタグ でビジネスプロセスを明示
  • ProxySQL でタグごとに接続保持時間を可視化
  • 接続時間の多いプロセス を特定し最適化
  • checkout pathの見直し でDB負荷半減
  • InnoDBスレッド並列度 も現状に合わせて最適化

安全な切り替えと検証

  • RedisとMySQLの並行運用 (シャドウモード)で安全性担保
  • 双方に書き込みつつRedisを正として比較検証
  • 問題発生時は キルスイッチ で即時リバート可能
  • 段階的ロールアウト でリスク最小化

得られた教訓

  • 過去の設計判断の見直し の重要性
  • ボトルネックの真因 を正しく特定・可視化することの価値
  • DB設計・運用・監視 の細部が大規模ECの信頼性を左右

まとめ

  • 在庫予約の正確性とスケーラビリティ は、ECプラットフォームの根幹
  • MySQLの新機能と設計工夫 でRedis時代を超える性能・一貫性を実現
  • 運用・監視の改善 がシステム進化の鍵

Hackerたちの意見

「でも、一番難しい教訓はデータベース設計についてじゃなかった。実際のボトルネックは、私たちが観察して測定していたものではなかったんだ。」

「でも、それは負荷を支えているの?」

「正直、クロードがこの言語に収束するのは変だ。すごく冗長で解析しづらいから。セマンティック密度がトレーニングで勝つと思うんだけど。」

「アイテムごとに数量カラムを持つ1行ではなく、販売可能なユニットごとに1行を使う。10ユニットのアイテムは10行になる。」 > 「でも、全在庫に対してユニットごとに1行だと、スケールすると崩壊する。10箇所に50,000ユニットあるアイテムは500,000行になるし、リザーブクエリはそれをスキャンすることで遅くなる。だから、アイテム/ロケーションの組み合わせごとに1,000行に制限された利用可能な行のプールを維持している。このプールからリザベーションが行を消費し、補充プロセスが在庫台帳から補充する。こんなアプローチで不安を感じるべきじゃないのかな?同期の問題が起こる可能性を下げるためのバックオフ(プール)を作っているように見える。」

「それは補充プロセスがどう機能するかによると思う。もしアイテムを1,000個以上注文しないなら、問題にはならないと思う。」

物の種類によるね。物理的な在庫があると、数が限られてるから管理しやすいし、面白いことにこの問題は物理的な在庫にだけ関係するんだよね。同期の問題を避けるために、ちょっとディスクスペースを使うだけだし。パフォーマンスのための非正規化は結構一般的なパターンだけど、最初からそれを考える人はあんまりいないよね。

スケールによるね。ほとんどの会社は、これが重要になるスケールには達してないよ。

そうだね、デザインはあんまり良くないよね。「SKUごとに1行」と「SKUごとに1000行」の間には妥協点があるはずだよ。ショッピングカート*SKUの組み合わせごとに1行っていうのがいいと思う。もし2人が同じSKUをそれぞれ100個と500個注文したら、テーブルには2行だけでいいはずだよ:order1とorder2のためにね。600行なんていらない。

これを「契約タイプごとの1行」って呼ぶかな。これは問題に対する最も一般的なモデルだと思う(例えば、モデルをさらに細かく分解できないからね)。だから、ストレージがめっちゃ安いなら、最もスケーラブルなモデルだよ。

予約行アプローチには慣れてるよ(SELECT FOR UPDATE SKIP LOCKEDを使ってる)けど、この補充アイデアは怖いな。

「もっとシンプルな解決策があるかもしれない。1. ユーザーが注文を開始した時に在庫からリザベーションを差し引くが、同じトランザクション内で進行中の注文フローのために別の行を維持する。2. 注文フローが中止されたりタイムアウトした場合、これを在庫に戻すバックグラウンドプロセスを用意する。これだとこのアプローチよりシンプルで、ロックも必要ない。彼らの提案されたアプローチも合理的だけど、シンプルなフローを選ばない理由があるはず。スケールするgcサービスを持つのはそれほど難しくないけど、彼らはそれを分けたくなかったのかもしれない。」

「今、あなたには2つの問題がある。リザベーションシステムがバックアップしたらどうなる?」

Hacker Newsで議論の続きを見る