HN 日本語サマリー

← 一覧へ戻る
インフラ・DevOps

Shopify、在庫予約にRedisからMySQLへ移行し、スケーリングに成功

Shopify replaced Redis with MySQL for inventory reservations–and it scaled (shopify.engineering)

310 pointsby adletbalzhanov207 コメント

要約

Shopifyは、在庫予約システムで長年使用していたRedisをMySQLに置き換え、スケーリング目標を達成しました。この移行では、SKIP LOCKED機能、複合主キー、接続可視性を活用し、特にピーク時の高スループット要件を満たしました。以前のRedisモデルではアトミックなトランザクション処理が困難でしたが、MySQLへの移行によりACIDトランザクションが可能になり、在庫の二重販売や販売機会損失のリスクを排除しました。

全文翻訳

ブログ|インフラストラクチャ在庫予約のためにRedisをMySQLに置き換え、スケーリングに成功しましたSKIP LOCKED、複合主キー、接続可視性をどのように使用してスケーリング目標を達成したか2026年5月12日公開チェックアウト時、購入者が「購入を完了する」をクリックすると、購入しようとしている商品がまだ利用可能であることを保証する必要があります。この判断を誤ると、2人の購入者が同じ最後の1点を購入してしまう可能性があります。その場合、マーチャントは注文をキャンセルし、謝罪メールを送り、サポートコストを負担しなければなりません。逆に誤ると、商品が売り切れていると購入者に伝えてしまい、本来ならできたはずの販売機会を失うことになります。Shopifyの規模では、これらの失敗は急速に増幅します。2025年のブラックフライデーには、Shopifyプラットフォーム上のマーチャントは、ピーク時に毎分510万ドルの記録的な売上を達成しました。これらの取引のすべてが在庫に影響します。当社の過剰販売保護システムは、支払い処理中に在庫を予約することでこれを処理します。これは、2つの同時チェックアウトが同じ商品を確保するのを防ぐための短い保留です。長年、このシステムはRedisで稼働していました。統一データベース戦略に向けて動く中で、私たちは困難な問いに答えなければなりませんでした。MySQLは同じ規模に対応できるのか?以前の試みは失敗していました。数量カラムを持つ単一の行では、競合に対処できませんでした。MySQL 8のSKIP LOCKED機能は、異なる設計を導入しました。アイテムごとに1行ではなく、在庫ユニットごとに1行です。37signalsのデータベースベースの負荷分散アプローチに触発され、私たちはMySQL上で予約を再構築し、2025年のピークトラフィック中に高スループット目標を達成しました。しかし、最も困難な教訓はデータベース設計に関するものではありませんでした。実際の問題のボトルネックは、私たちが観察し測定していたものではなかったことを発見したことです。この記事では、その解決策と、その過程で発見したことについて説明します。課題過剰販売保護とは何か?過剰販売保護には、主に2つの操作があります。予約:支払いが開始されたときに、商品を予約済みとしてマークします(数分程度の短い保留)。確定:支払いが成功したときに、在庫台帳(信頼できる情報源)から数量を永久に差し引きます。チェックアウトの完了は、これが高速かつ正確であるかに依存します。予約が遅いと、スロットリングが発生し、購入者体験が悪化します。間違いは、過剰販売(顧客の不満)または過剰販売不足(収益損失)を意味します。規模と正確性の要件規模は抽象的なものではありません。Shopifyは米国eコマースの14%以上を支えており、2025年のブラックフライデーには、ピーク時の毎分売上が前年比11%増加しました。予約は在庫に触れるすべてのチェックアウトで実行されるため、システムはリクエストをドロップしたり一貫性を損なったりすることなく、その急増に対応する必要があります。私たちは以下を行う必要がありました。ピークトラフィック中のプラットフォームの高性能スループット目標をサポートするマルチロケーション在庫を尊重する(履行可能なロケーションからのみ予約する)予約と在庫台帳間のACID保証を維持する正確性を優先する:過剰販売も予約損失もないRedisモデルとその限界以前のシステムでは、予約はRedisに保存されていました。各アイテムには数量キーがあり、予約はDECR、解放はINCRでした。Redisは競合にうまく対応しましたが、予約と在庫台帳は2つの異なるシステムに存在していました。確定ステップ(支払い処理済み、在庫を永久に差し引く)では、MySQLを更新してRedisをクリーンアップする必要がありましたが、これら2つの操作を単一の原子ステップでラップすることはできませんでした。順序によっては、過剰販売(商品が販売されたが台帳から差し引かれなかった)または過剰販売不足(商品が差し引かれたが予約済みとしてマークされたまま)が発生する可能性がありました。さらに、Redisモデルにはマルチロケーションの認識がなく、維持するための別のクラスターの運用コストがかかりました。予約を台帳と同じMySQLデータベースに移行することで、すべてをACIDトランザクションでラップし、これらの障害モードを完全に排除することができました。解決策:SKIP LOCKEDコアアイデア:設計により制限された、ユニットごとの1行数量カラムを持つアイテムごとの1行の代わりに、販売可能なユニットごとに1行を使用します。10ユニットを持つアイテムは10行になります。3ユニットを予約するということは、単一のトランザクションで3行を選択して移動することを意味します。予約と在庫台帳を同じデータベースに保持することで、予約と確定の間でACIDを取得し、Redisで発生する可能性のあるバグ(例:支払いは成功したが在庫が確定されない、またはその逆)を修正します。簡略化された予約フローは次のようになります。SKIP LOCKEDがスケーラビリティを可能にします。他のトランザクションが一部の行をロックしている場合、MySQLはそれらをスキップし、他の利用可能な行を返します。同じ行での待機がなくなり、競合が減少します。しかし、すべての在庫に対してユニットごとに1行では、規模が大きくなると破綻します。10ロケーションにわたる50,000ユニットのアイテムは500,000行になり、予約クエリはそれらをスキャンするにつれて遅くなります。代わりに、アイテム/ロケーションの組み合わせごとに最大1,000行に制限された利用可能な行のプールを維持します。予約はこのプールから行を消費します。補充プロセスが在庫台帳からそれを補充します。なぜ1,000なのか?キャップは、枯渇することなくバーストを吸収するのに十分な大きさでありながら、テーブルをコンパクトに保ち、SKIP LOCKEDスキャンを高速にするのに十分小さい必要があります。フラッシュセール中にアイテム/ロケーションあたりの観測されたピーク予約率に基づいてサイズを決定しました。1,000は、補充が持続的な負荷の下で追いつくのに十分なヘッドルームを提供し、テーブルがクエリパフォーマンスが低下するほど大きくなるのを防ぎます。プールが空になった場合はどうなるか?極端なフラッシュセール中、人気アイテムのプールは一時的に枯渇する可能性があります。その場合、予約パスはインラインで補充をトリガーします。ロックにより、一度に補充できるトランザクションは1つだけになります。同じアイテムに対する他の同時予約は、行の挿入を競うのではなく、完了を待ちます。これにより、サンダーハード(thundering herd)を防ぎます。補充が完了すると、待機中のトランザクションは満杯のプールで続行されます。購入者は、商品が実際には利用できない場合を除き、商品が利用できないとは決して見なしません。これにより、特定の予約のレイテンシは増加しますが、正確性は維持されます。利用可能な在庫を持つ購入者が拒否されることはありません。主要な技術的決定1.複合主キー:行あたりのロック数を削減最初のプロトタイプでは、自動インクリメントIDを主キーとして使用しました。ロックの動作(例:SHOW ENGINE INNODB STATUSを使用)を観察したところ、予約あたり1つではなく2つの行ロックが発生していることがわかりました。自動インクリメント主キーでは、InnoDBはWHERE句で使用されるセカンダリインデックスとクラスタ化インデックス(主キー)の両方をロックしていました。フィルタリングするカラムが主キーの一部となるように、複合主キー(shop_id、inventory_item_id、inventory_group_id、id)に切り替えました。これにより、行あたりのロックが1つに減り、秒間多数の予約を実行する際に重要でした。教訓:この規模では、インデックスと主キーの設計がロック数とスループットに直接影響します。2.READ COMMITTED:ギャップ(上限)ロックの回避テーブルが空で補充が必要な状態でSELECT ... FOR UPDATE SKIP LOCKEDを実行した際、ギャップロック(「上限」疑似レコードにも)が発生しました。これらのロックは、補充トランザクションが新しい行を挿入するのをブロックし、デッドロックにつながる可能性がありました。これらのトランザクションのトランザクション分離レベルをREPEATABLE READ(MySQLのデフォルト)からREAD COMMITTEDに変更しました。READ COMMITTEDでは、InnoDBは同じ方法でギャップロックを取得しないため、補充を続行できました。Jahfer HusainのInnoDBロックに関するガイドは、これを理解するのに非常に役立ちました。これは、このコードベースでデフォルト以外の分離レベルを初めて使用した例でした。トランザクションごとに分離を設定するための小さなフレームワークサポートが必要でした。3.一貫したロック順序:デッドロックの回避予約と確定が異なる順序で2つのテーブルにアクセスしたときにデッドロックが発生しました。予約はreserved_quantitiesにINSERTしてからreservation_unitsをDELETEしていましたが、確定はreserved_quantitiesをDELETEしていました。異なるトランザクションが異なる順序で2つのテーブルをロックし、サイクルを形成する可能性がありました。修正は順序を標準化することでした。予約は常にunitsテーブルからDELETEしてからreserved_quantitiesにINSERTします。確定は単に...