はじめに
技術評論社様より発刊されているSoftware Designの2024年5月号より「レガシーシステム攻略のプロセス」と題した全8回の連載が始まりました。
ZOZOTOWNリプレイスが順調に進む中始まった「カート決済機能」のリプレイス。第5回では、始動の経緯と、システムの安定稼働につながる大きな改善をもたらしたキューイングシステムの導入について解説します。
目次
ZOZOTOWNカート決済リプレイスの始動
体制変更
2021年4月、ZOZOTOWNカート決済リプレイスが始まりました。ZOZOTOWNリプレイス開始からこの時点で4年が経過しており、専属のSREやバックエンドチームのメンバーは複数のプロジェクトを経験して、新しい技術や環境に馴染みノウハウも溜まってきたころです。ここからさらにリプレイスを加速するため、リプレイス専属バックエンドチームから3名がカート決済チームへ異動しました。
カート決済チームはZOZOTOWNのカート投入処理から注文データ作成、決済処理を開発・運用しているバックエンドチームです。カート決済機能の既存コードは約11万ステップ、ストアドプロシージャが250本、画面は105画面あります。さらに長年積み上げてきた機能改修により、仕様はもちろんコードは一度や二度見ただけでは到底理解できないほど複雑になっていました。これをもともと4名のエンジニアで開発・運用していましたが、メンバーの入れ替わりなどで歴史や仕様をすべて把握しているメンバーはおらず、都度調査しながら追加改修をしている状況でした。
カート決済リプレイス開始前は、リプレイス専属チームで一部機能を実装したマイクロサービスを新規に作成し、実際に運用するチームへ移譲していく形で進めていました(図1左)。移譲後も技術サポートは続けますが、複数プロジェクトを同時にサポートしていく難しさや、チームが分かれていることによる連携の取りにくさ、ドメイン知識が浅く将来を見越した適切なアーキテクチャ設計が難しいことなどが課題でした。
前述のとおりカート決済機能をリプレイスするには膨大なドメイン知識や運用を考慮したうえでの設計、状況に応じた柔軟な対応が必要です。まずは組織変更し(図1右)、リプレイスメンバーがカート決済機能のドメイン知識を習得する必要がありました。
カート決済サービスの将来像
ZOZOTOWNリプレイスを開始する前のZOZOTOWNはオンプレミスで動く1つのモノリシックなシステムでした。使用していた言語はVBScriptでWebサーバはIIS、データベースはSQL Serverです。SQLを実行する際にはVBScriptからSQL Server上のストアドプロシージャを呼び出しており、ストアドプロシージャにも数千行レベルのビジネスロジックが多数存在しています。また複数のデータベース間をリンクサーバで接続し、ストアドプロシージャ内で分散トランザクションを使用している箇所もあります(図2左)。
これをカート・注文・決済という機能単位でマイクロサービスへ切り出し、Amazon Elastic Kubernetes Service(以下、EKS)上で動くJava Spring Bootのアプリケーションにリプレイスすることになりました(図2右)。また、データベースも各サービスに適したものを選定する想定です。
ジレンマと優先順位
将来的なアーキテクチャを思い描きつつも、2021年当時のカート決済機能はある致命的な問題を抱えていました。それは、人気商品販売などのイベント時にカート投入リクエストが爆発的に増加し、頻繁にデータベース障害が発生することです。ZOZOではこのようにアクセスがスパイクする商品を「過熱商品」と呼んでいます。多くは転売目的のBOTによるもので、メンバーは過熱商品の販売日時を日々調査し、負荷対策や障害発生時の対応のため休日もリアルタイムで監視を行っていました。このように日々の運用負荷が高く、事業案件はもちろんリプレイス案件にも集中できない状況が続いていました。
まずはこの問題を解決するため、直近7ヵ月後の大規模過熱イベントであった11月の福袋販売までにキャパシティコントロール可能なアーキテクチャに変更することを目標としました。
スケールアップ戦略だけでは防ぎきれなくなった障害
一般的に、ECサイトの多くは注文確定時に在庫を引き当てます。しかしこの仕様の場合、自分のカートに入れておいた商品がいつの間にか他の人に買われ、売り切れてしまうということがあり得ます。
一方ZOZOTOWNのカート投入機能には[カートに入れる]ボタンを押した時点で在庫を引き当て有効期限まで確保するという特徴があります。これは「ZOZOTOWNではお客様にリアル店舗のように安心してお買い物を楽しんでほしい」という思いから作られたためです。
ZOZOTOWNで[カートに入れる]ボタンを押すと、次の流れで処理が実行されます。
- 在庫テーブルの在庫数を減算する
- カートテーブルにレコードを登録する
在庫テーブルとカートテーブルは、SQL Serverのカート用データベース(以下、カートDB)にあります。障害の原因は1の在庫数の減算処理にありました。実際には次のようなクエリを実行しています。
UPDATE 在庫テーブル SET 在庫数 = 在庫数 - 1 WHERE PK = ?
過熱商品の販売開始直後には、在庫テーブルの同一レコードへ大量の更新リクエストが集中し、ロック競合が発生します。その結果、後続クエリの滞留によりクエリタイムアウトが多発します。最悪の場合はワーカースレッドが枯渇し、カートDBへ接続できない状況になります。カートDBに接続できなくなるとサイト全体がエラーになるため、状況によってはカートDBをフェイルオーバーする必要がありました。それまではカートDBの物理的なスケールアップを繰り返してしのいでいましたが、すでに性能の伸びが頭打ちになり限界が来ていました。
この問題を根本的に解決するためには、まずカートDBを柔軟にスケールアウト/スケールイン可能なAmazon DynamoDB(以下、DynamoDB)にリプレイスすることが有効だと考えました。しかしカートDBはZOZOTOWN以外のサービスや基幹システムからも接続があります。この時点でカート決済のリプレイスは開始からすでに2ヵ月が経過し、6月に差し掛かっていました。11月の福袋販売までにリリースし安定稼働まで持っていくには影響範囲が大き過ぎて現実的ではないというのが結論でした。
このような背景から、アーキテクチャの変更や影響範囲を最小限に留めつつ効果的な一手を打つ必要がありました。さまざまな案を検討した結果、ボトルネックとなっている更新処理の前にキューを挟むことでリクエストのキャパシティコントロールを実現する方向になりました。
キューイングシステムの導入
前述した背景により、Amazon Kinesis Data Streams(以下、KDS)を利用して次の処理を行うキューイングシステムを構築しました(図3)。
- queuing-apiが商品タイプを判別
- 商品タイプに応じて異なるKDSにレコード投入、以後ポーリング処理でカート状態管理用DynamoDBを確認
- Workerは定期的にKDSへポーリング、レコードが存在すれば後続処理を実施、カートDBのUpdateが完了した時点で状態管理用DynamoDBにその旨を記録
- queuing-apiが状態管理用DynamoDBから処理終了のStatusを見てClientにレスポンス
どのような経緯を経てこのようなシステムになったか、そして本システムのポイントを本節では解説していきます。
Kinesis Data Streamsの採用理由
ZOZOTOWNリプレイスは技術スタック選定でAWSを採用しています。本キューイングシステムもAmazon EKS上で稼働するアプリケーションを前提として利用サービスの検討を開始しました。キューイングシステムの候補として検討したサービスはAmazon Simple Queue Service(以下、SQS)とKDSですが、KDSを採用した理由は2つあります。
First In First Outでのキャパシティ
前述した「ZOZOTOWNではお客様にリアル店舗のように安心してお買い物を楽しんでほしい」という考えのもと、構築するキューイングシステムも「カート投入機能は同一商品に限り順序性を担保したい」というFirst In First Out(以下、FIFO)の要件がありました。いずれのサービスもFIFO機能が提供されているという面では要件を満たしていたのですが、検討を開始した2021年、SQSのFIFOキューにはAPIメソッドごとに300Req/secのAPIコール制限がありました。ZOZOTOWNのカート投入リクエスト数を考慮するとキャパシティ観点でSQSのAPIコール制限が懸念されました。これを防ぐためにサイトの成長に応じてSQSを増加させていく運用なども考えられましたが、この場合FIFOの要件を満たせなくなってしまいます。また、SQSの増加に追従するロジックをアプリケーション側で実装する必要があります。
一方KDSはシャードあたりのキャパシティという考え方になり、1シャードあたり1,000レコード/secまたは1MB/secという制限がありました。シャード数はKDSの設定変更のみで調整可能なため、キャパシティ変更のオペレーションと実装を考えた際にKDSにメリットがあると判断しました。
余談ですが、SQSは2021年5月末にFIFOキューの高スループットモードを提供しており、一度に複数のメッセージを発行する仕様にすれば300tpsを超える性能を発揮することも可能です。2023年10月には処理可能なトランザクション数が9,000tpsまで増加しています。コスト面ではSQSが優位になるため、何に優先度を置くかによっては選択が変わっていたかもしれません。
Kinesis Client Libraryを利用した開発工数の削減
もう1つの大きな決め手は、KDSとレコード処理ロジック(Worker)を仲介するKinesis Client Library*1(以下、KCL)が提供されていたことです。
KCLでは、何らかの事情により特定のWorkerのパフォーマンスが低下または停止してしまうような事態に陥った場合に異なるWorkerがそのレコードを処理するためのフェイルオーバー機能が提供されています。Workerの状態管理のためLeaseTableとしてのDynamoDBの構築・管理は必要になりますが、自身でKCLのフェイルオーバー機能を実装する時間をカットできるのは本質的なカート決済機能のリプレイスを進めるうえで大きなメリットと判断しました。
本システムのポイント
過熱商品専用のKDSを作成
KDSの採用理由にFIFO要件がある一方、KDSでシャード数を変更することによりキャパシティが変更可能になることを挙げました。しかし前述したような過熱商品の存在はここでも避けられない課題となりました。すべての商品ごとにシャードを用意できれば特定の商品によるカート投入リクエストのスパイクによる影響範囲はその商品だけになります。しかし、これはコストの関係から断念しセール時などでもユーザー体験を損なわないレイテンシで処理できる量のシャード数を用意しました。過熱商品については過去にアクセスがスパイクした商品や明らかにアクセス数の多い商品をDynamoDBに登録し、登録済みの商品であれば異なるKDSにレコードを登録する仕様にしました。
この仕様を入れることで過熱商品の販売に伴いその他多くの商品のカート投入に影響が出てしまうことを防げるようになりました。
既存システムへの改修は最小に
本来であればクライアントから定期的にポーリング処理を行う形が最も望ましいのですが、カート投入機能はブラウザのほか、ネイティブアプリにも存在しています。ZOZOTOWNのアプリは下位バージョンについても一定期間のサポート期間を設けており、直近のバージョンに対する強制アップデートはさまざまな部門との調整連携が必要になります。福袋販売時期を加味するとネイティブアプリのバージョンアップは現実的ではありませんでした。
そこでエンキュー後の状態管理(ポーリング)もqueuing-apiに持たせることでネイティブアプリ側の改修は必要とせず、VBScriptの改修も最小限に抑え速やかに本システムのリリースにつなげられました。前段となるVBScriptとqueuing-apiは結果的に後続処理完了まで待つという動きになりますが、本キューイングシステムの最大の目的はDBへの流量をコントロールすることであり、フロントエンド側の改修範囲を最小限に留めスピード感を持って開発を進めるためには、この形がベストと判断しました。
過熱商品への運用を補助するしくみ
前述のように過熱商品専用のKDSを作成したものの、事前にアクセススパイクが予測できる商品もあれば、予測できずスパイクする事態もかなりの頻度で発生することが考えられました。
そこで図4のような過熱商品運用を補助するしくみを構築しました。本システムが稼働するEKS上ではログ収集の機構としてfluentd-firehoseを採用しており、コンテナのアクセスログを収集してAmazon Simple Storage Service(以下、S3)にログとして保存するしくみがあらかじめ構築されていました。またそれをAWS Athena(以下、Athena)を使い分析するという運用を行っています。そのしくみを流用してCronなどの定期実行でAthenaから一定時間内に大量にカート投入されている商品がないかをログから判断し、あれば過熱商品判定用DynamoDBにItem登録するしくみを実装しました。当初はこの定期ジョブをGitHub Actionsを使い実現していたのですが、多くのアプリケーションのCI/CDにGitHub Actionsが使われていることからたびたび実行待機状態になってしまう問題があり、意図した実行間隔で動作できないという課題が生まれたことから現在では自社でコントロール可能なEKS基盤上で動作するArgo Workflows上での定期実行を採用しています。
導入効果
本システムを当初ターゲットとしていた福袋販売イベント前にリリースできたこともあり、無事にイベントを乗り越えられました。リリースまでは過熱商品販売のたびに更新の競合に伴うエラーが大量に発生していましたが、更新の競合を激減させることに成功し福袋販売時以外にも障害件数も減らせました。また運用を補助するしくみがあることで過熱商品販売に関する人的工数も大幅に抑えられています。
残った課題とその解決方法
本キューイングシステムだけでは対処しきれなくなった課題が2つがありました。本節では、それらの課題と解決方法を紹介します。
ページラッチ競合によるDB障害
SQL Serverにはデータ格納領域であるページの一貫性を保つための排他制御のしくみとしてページラッチという概念があります*2。キューイングシステムの導入により同一レコード更新の競合に伴う障害発生件数は低減できましたが、今度はページ単位の読み取りと書き込みの競合によるSQL Serverのワーカースレッド枯渇に伴う障害が課題となりました(図5)。
在庫情報のDynamoDB化
カート投入処理のボトルネックを根本解決するために実施したのが、キューイングシステム導入前に検討していたDynamoDBへの移行です。DynamoDB上に在庫情報を持ったテーブル(在庫数テーブル)を作成し、VBScriptからJava API経由で操作します(図6)。
DynamoDBはフルマネージドなサーバレスNoSQLデータベースです。データは常に3つのアベイラビリティゾーンに保持され、内部的には複数のパーティションに分散されています。RDBではないためJOINを使用するような複雑なクエリで大量データを分析するOLAP処理には向いていませんが、単純なクエリを用いた書き込みや読み込みを行うOLTP処理を数ミリ秒で安定して大量に処理できるのが特徴です。また、書き込みと読み込みのスループットが相互に依存しないため、SQL Serverで課題となっていたページラッチのような競合は発生しません。
課金体系では従量課金型のオンデマンドモードを使用することで、事前に負荷の予測が難しい場合や偏りがある場合でも負荷に合わせて柔軟にスループットをスケール可能です。また、特定のパーティションに負荷が集中した場合に自動でパーティションを分割して追加キャパシティを割り当てるAdaptive capacity機能がデフォルトで有効になっています。チーム内にも知見があることから、移行先はDynamoDBが最適と判断しました。
スコープは欲張らず最小限に
今回の移行対象は在庫テーブル内の在庫数のみです。在庫テーブルには金額や販売日時なども存在するため、多くのストアドプロシージャから参照・更新されます。また、ZOZOTOWN以外のサービスや基幹システムからも使用されているため、在庫テーブルをすべて移行すると影響範囲があまりにも大きく、リリースまで数年かかってしまいます。
カート決済リプレイスの方針として、リリーススパンは半年程度にすることが定められています。ビッグバンリリースによるさまざまなリスクを下げるほか、リリースまでの期間が長くなるとエンジニアが成果を感じにくく、モチベーション低下にもつながります。また、スパンを短くすることで世の中の技術動向や事業の成長に合わせて方針転換しやすいというのも大きな理由でした。
異種DB移行による脱トランザクション
既存のカート投入処理は、在庫テーブルの更新とカートテーブルの登録を1つのトランザクションで処理し整合性を担保していました。リプレイス後はSQL ServerとDynamoDBの異なるデータベースの更新となり、トランザクションは使用できません。そのため処理を分解して次のように変更しました。
- 在庫サービスのAPIを呼び出して在庫数を減算する
- 減算に成功した場合、カートテーブルにレコードを登録する
2の登録処理が失敗した場合、在庫数は減算されたままとなりデータの不整合が発生します。そのため、在庫数更新履歴を常にチェックして補正するバッチを作成し、結果整合性を担保しています。在庫数更新履歴はDynamoDBのKDS連携機能を使用しており、項目の変更前後のキャプチャが取得可能です。そのキャプチャを、KCLを使用したWorkerが取得し、Aurora MySQLの在庫数更新履歴テーブルへレコードを登録するしくみとなっています(図7)。
効果
在庫サービスへの移行後は、以前の5倍以上のカート投入リクエストを受けてもカートDB内で競合や負荷の上昇は見られていません。また、DynamoDBも適切なリトライ処理をアプリケーション側で実装していることもあり、とくに大きな問題もなく安定して稼働しています。
キューイングシステム自体のランニングコスト
前述したように本キューイングシステムではエンキュー後の状態管理をqueuing-apiが担っています。過熱商品の販売に伴い、デキュー待ちのレコードが増えると必然的にqueuing-api自体のリソース消費量も上昇します。負荷状況に応じてオートスケールするシステムではあるものの、突発的なスパイクには対応できないケースが多々見受けられます。いつ大量アクセスが発生するかわからない状況では、常にある程度潤沢なリソースを本システムで稼働させ続ける必要があり、ランニングコスト面で改善の余地がありました。
Rate Limitの導入
ランニングコストの問題は、Rate Limitを導入し、1商品あたりの秒間カート投入リクエスト数に上限値を定めることで解決しました。
ZOZOTOWNリプレイスではインフラ基盤としてEKSを活用し、EKS内でサービスメッシュとしてIstioを採用しています。IstioにはRate Limit機能が提供されており、特定のエンドポイントやHeaderのValue単位などさまざまな細かい条件でリクエスト数を制限できます。
今回のケースでは、カート投入リクエストにおいて商品単位でリクエスト数を制限する機能を次の流れで実現しました(図8)。
- 呼び出し元のVBScriptから商品情報を識別できる情報とともにカート投入リクエストが行われる
- Envoy Filter*3にて条件にマッチした場合、該当のマイクロサービスに到達する前にIstio Rate Limitにトラフィックをルーティング
- 制限値に達しているかを判定し、達していなければそのまま本来のリクエスト先となるマイクロサービスにトラフィックをルーティング
- 制限値に達している場合Istio Rate Limitは該当のトラフィックをマイクロサービスにルーティングすることなくStatus:429を応答
- 呼び出し元のVBScriptでは制限値に達したレスポンスを受けた場合は後続処理を行わずユーザーに適切な文言のみ返す
導入効果
Istio Rate Limitを活用することで過剰なアクセスはqueuing-apiに到達する前に遮断できる状態になり、キューイングシステム自体の負荷状況の改善とランニングコストの低下を実現しました。
Rate Limitは上限を超えたリクエストはすべて遮断する仕様です。そのため、あまりに厳しい制限値ではユーザー体験を低下させるリスクが存在します。前述したキューイングシステムやDynamoDBの導入により本質的にカート投入機能の性能限界が上がったことで、ユーザー体験を損なわない制限値でのRate Limit導入ができたと考えています。
まとめ
長いZOZOTOWNの歴史の中でもとくにトラブルの多かったカート投入機能ですが、クラウドネイティブな技術を活用していくことでキャパシティコントロール可能な状態を作り出し、システムの安定稼働率は大幅に向上しました。結果としてビジネスロジックやフロントエンド部分の脱VBScript化という本質的なリプレイスプロジェクトに注力できています。
今後のカート決済機能のリプレイスについてもぜひご期待いただければと思います。
本記事は、技術本部 カート決済部 カート決済技術戦略ブロック ブロック長の半澤 詩織と同 SRE部 カート決済SREブロック ブロック長の横田 工によって執筆されました。
本記事の初出は、Software Design 2024年9月号 連載「レガシーシステム攻略のプロセス」の第5回「キャパシティコントロール可能なカートシステム」です。
ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。
*1:https://docs.aws.amazon.com/ja_jp/streams/latest/dev/shared-throughput-kcl-consumers.html
*2:https://learn.microsoft.com/ja-jp/sql/relational-databases/diagnose-resolve-latch-contention?view=sql-server-ver16#what-is-sql-server-latch-contention
*3:https://istio.io/latest/docs/reference/config/networking/envoy-filter/