こんにちは、SRE部の廣瀬です。
本記事では、ZOZOTOWNでカートに商品を入れる際に使われているデータベース群の内、SQL Server(以降、カートDBと呼ぶ)にフォーカスします。ZOZOTOWNでは数年前から、人気の商品(以降、加熱商品と呼ぶ)が発売された際、カートDBがボトルネックとなる問題を抱えています。様々な負荷軽減の取り組みを通じて状況は劇的に改善されていますが、未だに完璧な課題解決には至っていません。
そこで今回は、加熱商品の発売イベントにおける負荷軽減の取り組みを振り返ります。また、直近の取り組みとして、SQL ServerのCDCを用いた新たな負荷軽減の検証内容をご紹介します。
背景 - カートDBのボトルネックについて
加熱商品の発売イベントに関する対策について、最初に言及した記事としては以下が挙げられます。この記事では人気の福袋商品を加熱商品として紹介していますが、それ以外にも加熱商品は様々な種類のものが存在します。

ZOZOTOWNで最大級のトラフィックを記録する福袋発売イベントで実施した負荷対策 - ZOZO TECH BLOG
上記記事を参考に、加熱商品の発売イベントにおけるカートDBのボトルネックについてご説明します。
ZOZOTOWNのカート投入の仕様
ZOZOTOWNでは、「カートに入れる」ボタンを押したタイミングで在庫が確保されます。つまり、カートに入った商品はそのまま注文完了まで進めば、確実に購入できます。ECサイトによっては、「カートに入れる」ボタンを押したタイミングでは在庫が確保されません。代わりに、注文完了後に順次在庫の確保処理を実行して、在庫が確保できない場合はキャンセルのお詫びメールを送信する仕様になっています。ZOZOTOWNのカート投入の仕様は現実世界でのショッピングの体験を再現しており、個人的に好きな仕様の1つです。本仕様により、「カートに入れる」ボタンを押したタイミングで以下のようなクエリ(以降、在庫更新クエリと呼ぶ)がカートDBに対して実行されます。
update 在庫テーブル set 在庫数 = 在庫数 - 1 where PK = ***SQL Serverの論理リソース競合について
SQL Serverでは、データを更新する際に様々なリソースに対して排他制御をかけます。様々な排他制御の内、本記事で言及する「行ロック」と「ページラッチ」について簡単に説明します。
「行ロック」は、行(レコード)に対して読み書きする際に獲得する必要のある論理的なリソースです。この仕組みによって「1つのレコードを一度に更新できるのは、1つのクエリだけ」といったルールを実現できます。
「ページラッチ」は、複数のレコードを格納している8KBの物理領域に対して読み書きする際に獲得する必要のある論理的なリソースです。基本的には「行ロック」も「ページラッチ」も、同一リソースへの書き込み(以降、writeと呼ぶ)は競合し、同一リソースへの読み取り(以降、readと呼ぶ)は競合しません。
表にまとめると以下の通りです。
write/write read/write read/read
行ロック 競合する 競合する 競合しない
ページラッチ 競合する 競合する 競合しない
競合が発生すると、片方のクエリはもう片方のクエリが「行ロック」や「ページラッチ」を解放するまで待たされることになります。つまり、論理リソースの競合が発生するということは該当クエリの実行時間の遅延につながるということです。
なお、SQL Serverのロックについては以下の記事で詳しくまとめていますので、良かったらご覧ください。

SQL Serverのロックについて出来る限り分かりやすく解説 - Qiita
加熱商品の発売イベントにおけるDB論理リソース競合
通常時は、様々な商品がカートに投入されている状況のため、複数の在庫更新クエリが同時に同一リソースへ更新要求を出すことはほとんどありません。したがって、「行ロック」も「ページラッチ」も大幅なクエリ遅延につながるような競合は発生しません。
しかし、加熱商品の発売イベントでは、特定の人気商品に対して在庫更新クエリが集中します。このような状況下では大量の「行ロック」および「ページラッチ」競合が発生し、クエリの実行時間の大幅な遅延やクエリタイムアウトエラー多発に繋がってしまいます。ワーストケースでは、クエリの遅延によりワーカースレッドが枯渇して、カートDB全体のスループットが著しく下がるという障害が発生することもあります。
このリソース競合を図示すると以下のようになります。
カートDBのボトルネックまとめ
ここまでの内容をあらためてまとめます。
- ZOZOTOWNでは「カートに入れる」ボタンを押したタイミングで在庫が確保され、内部的には在庫更新クエリが発行されている
- 在庫更新クエリを実行するためには「行ロック」および「ページラッチ」という論理リソースを獲得する必要がある
- 加熱商品の発売イベントでは、特定の商品にカート投入要求が集中し、DB内部で「行ロック」「ページラッチ」関連の論理リソース競合が多発する
- 論理リソース競合多発によってクエリの処理時間が遅延しタイムアウトエラー多発や、ワーカースレッド枯渇によるDB全体のスループット激減という障害につながることもある
このように、CPU負荷やディスク負荷の高騰といった物理リソース起因ではなく、SQL Server内部で獲得する必要のある論理リソース競合がボトルネックである点が特徴的となっています。
次は、カートDBのボトルネックに対するこれまでの対応策を振り返っていきます。
カートDBボトルネック対策の歴史
1.在庫分割による排他制御の分散
こちらの記事で紹介しているように、加熱商品の論理在庫を分割することで、在庫更新クエリによる排他制御を分散させる案です。
この対応のイメージ図は以下の通りです。
この案を2018年に実装して以降、2015年から3年連続で障害が発生していた福袋発売イベントを無障害で乗り切れています。一方で、以下のような課題も抱えていました。
- 運用負荷が大きく、年に1回の福袋発売イベント時だけ発動していた
- 効果は限定的で、分割するメリットが無いほどの少ない在庫数に対しては適用できない
他にもクエリチューニングを実施する等の様々な対策を施してきましたが、限界を迎えていました。具体的には、対策を入れて上昇していくDBの処理能力を、加熱商品の発売イベント時のトラフィックがさらに上回るようになっていきました。
そこでSQL Serverのレイヤだけで対応するのではなく、ワークロードを加味した別DBの選定等、課題の根本的な解決を目指すことになりました。成果の第一弾として、2021年にカート決済機能リプレイスのPhase1がリリースされましたので、そちらをご紹介します。
2.キューイングシステムの導入によるキャパシティコントロール
これまでのカートDBでは、在庫更新クエリ数が増えれば増えるほど、カートDBへのリクエスト数も増える状況になっていました。そこで、カート決済機能リプレイスのPhase1という位置づけで、カートDBの前段にキューイングシステムを設置しました。これにより、在庫更新クエリが増えても、キューイングシステムの後段に位置するカートDBへの更新リクエスト数を一定に保つことが可能となりました。
キューイングシステムの概略図は以下の通りです。
より詳しい情報は下記のテックブログ達にまとまっております。よろしければご覧ください。

ZOZOTOWN カート決済機能リプレイス Phase1 〜 キャパシティコントロールの実現 - ZOZO TECH BLOG

ZOZOTOWN カート投入の分散キューイングシステム 〜 プロダクションレディまでの歩み - ZOZO TECH BLOG

ZOZOTOWNカート機能のリプレイスPhase1裏側を大公開 - ZOZO TECH BLOG

How Amazon DynamoDB supported ZOZOTOWN's shopping cart migration project | Amazon Web Services
この対応を入れたことで、下図のようにレイテンシ、エラー率の両面で劇的な効果を上げることができました。
ここまで、カートDBボトルネック対策の歴史を振り返りました。ワークアラウンドな対応にとどまっていた「在庫分割による排他制御の分散」と比べて、「キューイングシステムの導入によるキャパシティコントロール」は、あらゆる加熱商品に有効な負荷軽減の取り組みとなりました。しかし、ここまでの対策を実施しても論理リソース競合によるクエリタイムアウトの多発という障害の発生を完全に無くすことはできませんでした。
次項では、現状のシステム構成でも障害が発生してしまう要因を説明します。
現在のカートDBが抱えるボトルネック
現在のカートDBのボトルネックに関するワークロードの概略図を以下に示します。
在庫テーブルに対しては、常にwriteとreadの両方のリクエストが発生しています。writeは基本的に在庫更新クエリのみであり、キューイングシステムの導入によってキャパシティをコントロール可能な状態になっています。一方で、readは様々なリクエストで発生し、各リクエスト毎に同時実行数も異なります。例えば秒間10000リクエストのreadクエリもあれば、秒間10リクエストのreadクエリもあります。また、大半のreadはキューを経由しないため、アクセス増加に伴って秒間リクエスト数も増加していきます。
readとwriteはページラッチの競合が発生するため、writeのリクエスト数を一定に保ったとしてもreadの数が増えるほどページラッチ競合の発生リスクは増加していきます。したがって、readの増加によるreadとwriteのページラッチ競合が現在のカートDBのボトルネックということになります。なお、上述の内容は以下の記事に記載されている方法で調査を行い特定しました。よろしければご覧ください。

SQL Serverの障害調査フローと事例のご紹介~原因不明な障害の調査から解決まで~ - ZOZO TECH BLOG

トラブルの原因特定率を劇的に向上させるSQL Serverロギングの仕組み作り - ZOZO TECH BLOG
次項では、現状のボトルネックを踏まえた負荷軽減の取り組みについてご紹介します。
直近でのカートDB負荷軽減の取り組み
現状のボトルネックを踏まえて、システムを以下の構成に変更することで論理リソース競合を軽減できるのではと考えました。
続きはこちら