Shopify、在庫予約システムをRedisからMySQLに置き換え
(shopify.engineering)- 在庫予約システムは、決済処理中に同一商品が二重販売されるオーバーセルを防ぐ中核インフラであり、Shopifyは長年Redisベースで運用してきた
- MySQL 8の**
SKIP LOCKED**機能を活用し、アイテムごとの数量カラムではなく販売単位ごとに1行を持つ構造へ再設計することで、Redisなしでも高性能な処理を実現 - 複合主キー、
READ COMMITTED分離レベル、一貫したロック順序、UNION ALLによるバッチ処理などのMySQL最適化手法を組み合わせ、ロック競合とデッドロックを解消 - 実際のボトルネックは予約クエリではなくコネクション占有にあり、チェックアウト経路全体を計測してDB読み取り50%、トランザクション33%削減を達成
- 2025年のブラックフライデーピーク基準で毎分510万ドルの売上を処理しながら、writer CPU 50%未満、reader CPU 16%未満を維持し、目標スループットを上回って達成
背景: オーバーセル防止システムの要件
- チェックアウト完了時点で在庫が実際に残っていることを保証する**オーバーセル防止(Oversell Protection)**システムが必要
- Reserve: 決済開始時に数分間そのアイテムを一時的にロック
- Claim: 決済完了時に在庫元帳から数量を恒久的に減算
- どちらの方向でもエラーは許容できない
- 誤ると同一商品を二人が購入したり、在庫があるにもかかわらず売り切れ扱いになって売上損失が発生したりする
- 規模要件: Shopifyは米国Eコマースの14%以上を担っており、2025年のブラックフライデーには前年比11%増の毎分510万ドルの売上を記録
- マルチロケーション在庫(Multi-location inventory)、ACID保証、高性能スループット、正確性優先が中核要件
既存のRedisモデルの限界
- Redisでは各アイテムが数量キーを持ち、予約は
DECR、解除はINCRで処理 - 中核的な問題: 予約データ(Redis)と在庫元帳(MySQL)が別々のシステムに存在していた
- Claim段階でMySQLの更新とRedisのクリーンアップを単一の原子的トランザクションとしてまとめられなかった
- 実行順序によって、オーバーセル(商品は販売されたが元帳が未減算)またはアンダーセル(元帳は減算されたが依然として予約状態)が発生しうる
- マルチロケーション在庫認識機能がなく、別途Redisクラスタを運用するコスト負担もあった
中核的な解決策: SKIP LOCKEDベースのMySQL再設計
基本構造: 1販売単位あたり1行(One Row Per Unit)
- アイテムごとの数量カラムの代わりに、販売可能な単位ごとに1行を持つ構造を採用
- 在庫10個のアイテム → 10行; 3個予約時は単一トランザクションで3行を選択・移動
- 予約と在庫元帳を同じMySQL DBに置くことで、reserveとclaimをACIDトランザクションとして処理し、Redisで発生していた種類のバグを排除
SKIP LOCKED: 他トランザクションがロックした行を飛ばして利用可能な行を即時返す → 同一行待機なしで競合を低減
プールサイズ制限: ロケーションごとに最大1,000行
- アイテム/ロケーションの組み合わせごとに利用可能な行を最大1,000件に制限し、テーブルサイズとスキャン性能を維持
- 例: 在庫50,000個 × 10ロケーション = 500,000行になる状況を防止
- プール枯渇時はインライン補充(replenishment)をトリガーし、単一トランザクションのみが補充するようロックをかけて、多数のトランザクションが同時に行を挿入するthundering herdを防止
- プールが完全に空になった場合、その予約にのみ遅延が発生し、実在庫がある購入者が売り切れ扱いされることはない
4つの重要な技術的判断
1. 複合主キーでロック数を削減
- 初期プロトタイプでオートインクリメントIDを主キーに使うと、InnoDBがセカンダリインデックスとクラスタ化インデックスの両方をロックし、予約ごとに2つの行ロックが発生
shop_id, inventory_item_id, inventory_group_id, idで構成される複合主キーを適用 → フィルタカラムが主キーに含まれるためロックが1つに減少- 秒間数千件の予約が発生する環境では、インデックス・主キー設計がロック数とスループットに直接影響する
2. READ COMMITTEDでギャップロックを除去
- 空テーブルに
SELECT ... FOR UPDATE SKIP LOCKEDを実行すると**ギャップロック(gap lock、supremumを含む)**が発生し、補充トランザクションのINSERTをブロックしてデッドロックを誘発 - 分離レベルをMySQLデフォルトの
REPEATABLE READから**READ COMMITTED**へ変更 → ギャップロックの発生方式が変わり、補充トランザクションが正常に進行 - このコードベースで初めてデフォルト以外の分離レベルを採用したため、トランザクション単位で分離レベルを設定するための小規模なフレームワーク対応が必要だった
3. 一貫したロック順序でデッドロックを防止
- reserveとclaimが2つのテーブルに異なる順序でアクセスしてデッドロックが発生
- reserve:
reserved_quantitiesINSERT →reservation_unitsDELETE - claim:
reserved_quantitiesDELETE
- reserve:
- 解決策: reserveが常にunitsテーブルのDELETEを先に、
reserved_quantitiesのINSERTを後に行うよう順序を標準化 → 循環待機(circular wait)を排除
4. UNION ALLバッチでラウンドトリップを削減
- カートに複数のラインアイテムがある場合、
UNION ALLで予約クエリを単一ラウンドトリップにバッチ処理 - 総ラウンドトリップ数の削減により、高負荷時のレイテンシを改善
実際のボトルネック: クエリではなくコネクション占有
問題発見の過程
- 本番環境で目標スループット未満の段階で頭打ちとなり、P90レイテンシは良好、CPUも上限未満、クエリも最適化済みの状態だった
- 負荷テストで観測された症状:
- MySQL内でのスレッドキューイング
- キューに積まれた処理が実行される際のCPU急上昇
- ProxySQLレイヤーでのMySQLバックエンドコネクション枯渇
コネクション可視性の確保
- アプリケーションレイヤー: すべてのSQL文に**
/* conn_tag:checkout_completion */**形式のビジネスプロセス識別コメントを追加 - ProxySQLレイヤー: タグの解析と呼び出し元ごとのコネクション占有時間集計を追加
- 結果: どのプロセスがどれだけ長くコネクションを占有しているかを即座に把握可能に
判明した内容と解決
- 予約以外のチェックアウト経路の別コードが、必要以上に長くコネクションを占有していた
- それらは先に限界に達していなかったため、最適化対象から漏れていたコードだった
- チェックアウト経路の整理の結果: プライマリDB読み取り50%減、トランザクション33%減
- 数年前に保守的に設定されたまま見直されていなかったInnoDBスレッド並行性設定を調整し、追加のボトルネックを除去
- 改善後は高ボリュームのフラッシュセール基準で、writer CPU 50%未満、reader CPU 16%未満を維持
移行方法: Shadow Mode
- RedisからMySQLへ即時切り替えはせず、Shadow Mode方式で両システムを並行運用
- すべての予約をRedisとMySQLの両方に同時書き込みし、Redisをsource of truthとして維持
- 実運用トラフィックでMySQLの正確性と性能を並行検証
- インフライト中の予約を移行することなく切り替え可能(両システムが同時に稼働していたため)
- MySQLをsource of truthに切り替えた後もキルスイッチを維持し、二重書き込み経路によりRedisが常に最新状態を保持
- ロールアウトは低トラフィックのPodから最大ボリュームのマーチャントまで、Pod単位で段階的に実施
教訓
1. 古い判断を再検討すること
- 5年前には不可能だったMySQLの活用が、
SKIP LOCKEDのような新機能によって今では可能になった - スレッド上限などの「経験則」設定は、ワークロードやハードウェアが変化したら見直しが必要
- CPUが低いのにキューイングが発生するなら、必ず原因を掘り下げるべき
2. 小さく始めて観察すること
- フルRailsフレームワークなしに、小規模なRubyスクリプトとMySQLで最小プロトタイプを構築
- 2つ目のターミナルでロックの動作を直接観察するやり方は、理論以上に多くを教えてくれる
- コネクション占有計測パターン(アプリレイヤーのタグ + プロキシ集計)は実装が簡単で、すぐに実行できる
1件のコメント
久しぶりに、本当に開発らしい記事が上がってきましたね