SQSメッセージ処理の高速化によるクレジットカード決済の待ち時間改善(PCリプレイス編)

SQSメッセージ処理の高速化によるクレジットカード決済の待ち時間改善(PCリプレイス編)

はじめに

こんにちは、カート決済部カート決済基盤ブロックの多田とSRE部カート決済SREブロックの伊藤(@_itito_)です。普段はZOZOTOWN内のカート機能や決済機能の開発、保守運用、リプレイスを担当しています。

以前の記事で、クレジットカード決済処理をSQSで非同期化し、キャパシティコントロールを実現した取り組みをご紹介しました。

techblog.zozo.com

非同期化によって決済処理の安定性は大幅に向上しました。しかし、セールの終了時刻となる日曜から月曜にかけての日跨ぎ時に注文のピークを迎える時間帯では、依然としてユーザーの待ち時間が課題として残っていました。本記事では、SQSメッセージ処理を高速化することで待ち時間を改善した取り組みについてご紹介します。

目次

クレジットカード決済の非同期処理の仕組み

前回の記事でご紹介した非同期処理の仕組みを簡単に振り返ります。

クレジットカード決済の非同期処理の仕組み

ZOZOTOWNのクレジットカード決済では、注文確定ボタンの押下後、仮注文の作成まで同期処理で行います。クレジットカードの与信確保から本注文作成までは非同期処理で行われます。与信の確保が成功するとCartDBにある仮注文をFrontDBの本注文に変換し、注文を確定させます。フロントエンドはポーリングで本注文の完了を監視し、完了次第ユーザーに結果を表示します。

この非同期化により、注文数のスパイクをキューで吸収し、与信処理のキャパシティコントロールを実現できました。ただし、注文の完了画面が表示されるまでのユーザーの待ち時間は非同期化により短縮されたわけではありません。

課題:ピーク時のSQS滞留による注文完了までの待ち時間

ZOZOTOWNでは、セールの終了時刻となる日曜から月曜にかけての日跨ぎ時に注文のピークを迎えます。このピーク時に、注文確定のリクエスト受信から注文が確定するまでのP99レイテンシを分析しました。その結果、ピーク時間帯では注文確定までのP99レイテンシが大幅に増加し、最大で約26秒まで悪化していました。

注文ピーク時における注文リクエストのレイテンシ推移

問題の本質:エンキュー速度 > デキュー速度

ピーク時の待ち時間が増加する原因を深掘りしたところ、個々の処理速度ではなくエンキューとデキューの速度差に問題がありました。

指標
エンキューのスループット 109 req/s
ワーカーの並列数 186
メッセージ処理時間(平均) 2.1s
デキューのスループット 186 ÷ 2.1s ≒ 89 req/s

エンキューのreq/sは注文確定のリクエスト数に相当します。デキューのスループットはワーカーの並列数 ÷ メッセージ処理時間から算出しています。

エンキュー速度とデキュー速度の比較

上記グラフの通り、エンキュー速度がデキュー速度を上回っているため、SQSにメッセージが滞留し、待ち時間が増加していました。つまり、メッセージ処理は2.1秒で終わっているが、キューが詰まり26秒も待たされている状態です。

メッセージ処理をどれぐらい短縮すれば解消できるか

メッセージ処理時間を改善することで、単位時間あたりの処理数を増やし、エンキュー速度に追いつくことができます。現在の並列数からメッセージ処理時間を改善することでの、デキューのスループットと待ち時間の見込みは以下の通りです。

デキュー (req/s) メッセージ処理時間 (平均) 短縮量 不足分(エンキュー - デキュー)
89(現状) 2.1s 0s +20 req/s
93 2.0s 0.1s +16 req/s
98 1.9s 0.2s +11 req/s
103 1.8s 0.3s +6 req/s
109 1.7s 0.4s 0

平均メッセージ処理時間を2.1秒から1.7秒程度まで0.4秒短縮できれば、エンキューとデキューのスループットが均衡し、キュー滞留による待ち時間を大幅に改善できることが分かりました。

改善箇所の特定

目標が定まったところで、与信処理後に実行される注文の作成処理フローを整理し、改善できそうな箇所を探しました。大きく2つの改善ポイントを見つけました。

  1. 仮注文から本注文作成のストアドプロシージャ改善:リンクサーバー経由のアクセスが多く、ネットワークオーバーヘッドが大きい。アクセスを削減すれば大幅な改善が見込める。
  2. 本注文の作成以降の後処理の並列化:メール送信やクレジットカード登録などが直列実行されており、並列化すれば合計時間を圧縮できる。

以降、それぞれの改善内容を詳しくご紹介します。

改善1:仮注文から本注文作成のストアドプロシージャ改善

仮注文から本注文の作成処理とは

仮注文から本注文を作成するストアドプロシージャでは、CartDB上の仮注文テーブル群に保存されたデータをFrontDBの本注文テーブル群にINSERTします。与信の確保が成功した後に実行され、この処理が完了することで注文データが作成されます。

注文フローにおけるストアドプロシージャによる本注文作成の位置づけ

参照する仮注文側のテーブルは10種類以上に分かれており、ストアド内ではCartDBに対するSELECT・INSERT・UPDATEが合計で約20回実行されます。

ボトルネック:リンクサーバー経由の参照コスト

ストアドプロシージャはFrontDB側で動作しますが、参照する仮注文テーブルはCartDB側にあります。FrontDBからCartDBへの参照にはSQL Serverの機能であるリンクサーバーが使われており、クエリ実行のたびにDBサーバー間で通信が発生します。

learn.microsoft.com

ネットワーク往復のオーバーヘッドにより、本来1ms未満で完了する軽量なクエリでも数msのコストがかかります。

実測では、IF EXISTS程度の軽量クエリ1回でも約3.5msかかっていました。同様のリンクサーバー経由クエリがストアド内で約20回繰り返されることで、ストアド全体で大きなオーバーヘッドになっていました。

改修前:リンクサーバー経由でCartDBを参照するストアド呼び出しフロー

改善アプローチ:OPENJSONでリンクサーバー経由の参照を不要にする

改善後のストアドプロシージャでは、リンクサーバー経由のクエリを排除するために以下のアプローチを取りました。

  1. CartDBから仮注文データをJava側で事前に取得する
  2. 取得したデータをJSON形式に変換し、ストアドプロシージャのパラメーターとして渡す
  3. ストアド内ではOPENJSONを使ってJSONをテーブルのように扱い、リンクサーバー経由の参照を置き換える

OPENJSONはSQL Serverの組み込み関数で、JSON文字列をテーブル形式に変換できます。リンクサーバー経由のクエリを、メモリ上のJSONに対するクエリに置き換えることで、サーバー間通信を不要にしました。

以下は代表的な変換パターンです。仮注文関連テーブルへの存在チェックを例にすると、改修前後で次のように書き換わります。

-- 改修前: リンクサーバー経由でCartDBの仮注文関連テーブルを参照
IF EXISTS (
    SELECT *
    FROM [接続先].[DB名].[スキーマ名].[テーブル名]
    WHERE ID = @ID
)
-- 改修後: パラメーターで受け取ったJSONをOPENJSONで参照
IF EXISTS (
    SELECT *
    FROM OPENJSON(@Json, '$.table_foo_bar') WITH (
        ID INT '$.table_foo_bar_id'
    )
    WHERE ID = @ID
)

同じパターンで、リンクサーバー経由で実行していたCartDBへのアクセスのうち、参照系の処理を中心に約20か所を置き換えました。

Java側の事前取得とクエリ最適化

ストアドへ渡すJSONを生成するためには、改修前はストアド内からリンクサーバーを介して取得していた仮注文データを、Java側で事前に取得する必要があります。事前取得は直列でも実行できますが、少しでも処理時間を短縮するため、CompletableFutureを使って並列実行しました。(CompletableFutureの詳細は改善2で後述します)

さらに、改修前は仮注文テーブルごとに個別のSELECTを発行していましたが、関連テーブルをJOINで集約し、実行するクエリ数を削減しました。事前取得そのものを軽量化することで、JSONパラメーター化のオーバーヘッドを最小限に抑えています。

修正後の処理フローは以下のとおりです。

改修後:JSONパラメーター化によるストアド呼び出しフロー

改善結果

STG環境で負荷試験シナリオを実行し、改善効果を ミクロ(個別クエリ)→ミドル(ストアド全体)→マクロ(エンドポイント全体) の3つの粒度で計測しました。クエリはストアドに含まれ、ストアドはエンドポイント処理の一部であるという包含関係になっています。

計測には、ミクロ・ミドルの粒度ではSQL ServerのDMV(dm_exec_query_statsdm_exec_procedure_stats)を用いています。マクロの粒度ではエンドポイント全体のレイテンシ計測を用いています。

クエリ個別での計測(代表例)
リンクサーバー経由のクエリは、置き換えにより1クエリあたりの平均実行時間が1〜2桁減少しました。

クエリ種別 改修前 改修後 改善率
仮注文関連テーブルへの存在チェック(IF EXISTS 3.764ms 0.049ms 98.7%
仮注文テーブルからのINSERT 6.424ms 0.984ms 84.7%
仮注文テーブルからのSELECT 2.655ms 0.210ms 92.1%

ストアド単体での計測
ストアド内で繰り返されていたリンクサーバー経由クエリの実行時間の短縮が積み重なり、ストアド全体としても75%超の改善につながりました。

改修前 改修後 改善率
平均CPU時間 81.7ms 15.6ms 80.9%
平均実行時間 146.3ms 36.1ms 75.3%

本注文作成の処理全体での計測
エンドポイント全体のレイテンシを計測した結果、平均で約100msの改善を確認できました。内訳としては、ストアド単体で約110msの短縮が得られた一方、JSON生成のためにJava側で事前取得する処理が追加された分(約10ms)があり、合計で約100msの改善となっています。

改善2:本注文の作成後の後処理の並列化

既存の課題:直列実行される後処理

本注文の作成後には、以下のような後処理が必要です。

  • 注文完了メール送信
  • クレジットカード登録
  • お気に入りブランド登録
  • メールマガジン登録
  • 買い替え割(割引サービス)の適用
  • 不正検知
  • 在庫の更新

従来の実装では、これらの処理が直列に実行されていたため、個々の処理は数十〜数百ミリ秒程度であっても、全体の実行時間が膨らんでいました。

後処理の分類:待つべきか、投げっぱなしでよいか

並列化を進めるにあたり、まず各処理を一覧化し、完了を待つ必要があるか(join)、投げっぱなしでよいか(fire-and-forget)を判断しました。

判断基準は、注文の完了画面の表示に直接影響する処理かという点です。失敗時にエラーメッセージを表示する必要がある処理は完了を待ち、画面表示に影響しない処理は投げっぱなしにします。

なお、ここでのfire-and-forgetは、注文の完了画面の表示を待たないという意味です。処理に失敗した場合は、ログやメトリクスで検知できるようにし、必要に応じてリトライや補完処理を行えるようにしています。

この基準で各処理を分類した結果は以下の通りです。

処理 分類 理由
注文完了メール送信 join(待つ) 失敗時に注文の完了画面でエラーメッセージを表示するため
クレジットカードの登録 join(待つ) 失敗時に注文の完了画面でエラーメッセージを表示するため
お気に入りブランドの登録 join(待つ) 失敗時に注文の完了画面でエラーメッセージを表示するため
メールマガジンの登録 join(待つ) 失敗時に注文の完了画面でエラーメッセージを表示するため
買い替え割の適用 fire-and-forget 画面表示に影響しない非同期処理
不正検知 fire-and-forget 画面表示に影響しない非同期処理
在庫の更新 fire-and-forget 画面表示に影響しない非同期処理

この整理により、joinで待つ処理同士はCompletableFutureで並列実行し、fire-and-forgetの処理は完了を待たず投げっぱなしにするという方針が定まりました。

CompletableFutureとは

CompletableFutureは、非同期処理の結果を表すJavaのクラスです。Springの@AsyncやExecutorと組み合わせることで、複数の処理を並列に実行し、すべての完了を待ち合わせることができます。

例えば、メール送信(200ms)とクレジットカード登録(500ms)を直列実行すると合計700msかかりますが、CompletableFutureで並列実行すれば最も遅い処理の500msで完了します。

// joinパターン:@Asyncメソッドが返すCompletableFutureをallOfで待ち合わせ
CompletableFuture<ResultA> futureA = task.processA();
CompletableFuture<ResultB> futureB = task.processB();
CompletableFuture<ResultC> futureC = task.processC();
CompletableFuture<ResultD> futureD = task.processD();
CompletableFuture.allOf(futureA, futureB, futureC, futureD).join();

// fire-and-forgetパターン:@Asyncメソッドの戻り値を受け取らず呼び出すだけ
task.processE();
task.processF();

改善アプローチ:@AsyncCompletableFutureによる並列化

リプレイス後のJava実装では、Springの@AsyncアノテーションとCompletableFutureを活用して後処理を並列化しました。

処理の流れは以下の通りです。

  1. 仮注文から本注文作成:仮注文データを取得し、JSON形式に変換しストアドプロシージャを呼び出し本注文を作成
  2. 後処理を順次発火:join対象・fire-and-forget問わず、すべての後処理を@Asyncメソッドとして順次発火する。各処理は非同期で並列に実行される
  3. join対象のみ待ち合わせCompletableFutureを返す4つの処理だけをallOf().join()で待ち合わせ、結果を取得する

改善の全体像と効果

これらの改善は、ASPからJavaへのリプレイスに合わせて実施しました。現時点ではPCサイトのリプレイスのみ完了しており、SPサイト(スマートフォンサイト)は今後リプレイスを実施する予定です。

改善サマリー

改善内容 短縮効果
改善1 本注文の作成処理のJSONパラメーター化
(リンクサーバー削減・クエリ最適化・並列データ取得)
約0.1s
改善2 後処理の並列化・fire-and-forget化
(CompletableFutureによる並列実行と不要な待機の排除)
約0.3〜0.4s
合計 約0.4〜0.5s

PCリプレイス済み範囲では、目標としていた0.4秒の短縮を確認できました。

実測値による効果確認

PCのリプレイスが完了した時点で、リプレイス済みのPCとリプレイス未完了のSPのレイテンシを比較しました。

対象 ASP(SP) Java(PC) 改善幅
メッセージの処理時間(平均) 1.74s 1.34s 約0.4s改善

なお、前述の2.1秒はピーク時分析に用いた期間の平均値であり、上表の1.74秒はPCリプレイス後の効果確認の時点におけるSP側の実測値です。目標の0.4秒と実測の改善幅0.4秒は、計測期間・比較対象が異なるため直接比較できるものではありませんが、目標と同等の短縮効果が得られていることを確認できました。

メッセージ処理時間のリプレイス前後の比較

グラフからも、リプレイス後はピーク時に限らず全体的にメッセージ処理時間が改善していることが確認できます。当初の分析で目標としていた0.4秒の短縮が、実測値でも確認できました。過去に計測したピーク時のリクエスト量であれば、SQSの滞留が解消される見込みです。なお、現時点ではPCのリクエスト数はSPと比較して大幅に少なく、同等のリクエスト数での比較はできていません。あくまで現時点で確認できた速報値としてご理解ください。

今後の展望

注文数は年々増加しており、現在の改善だけでは将来的に再びSQSの滞留が起こりえます。

また、今回の効果測定はPCとSPのリクエスト数が大きく異なる状況での比較でした。SPのN%リリース中に同等のリクエスト数で比較し、正式な効果測定を実施する予定です。

今後は以下の取り組みを検討しています。

  • SPリリース時の同等リクエスト数での正式な効果測定
  • さらなるストアドプロシージャの最適化
  • 注文フロー全体のリプレイス完遂

まとめ

本記事では、クレジットカード決済の非同期処理におけるSQSメッセージ処理の高速化についてご紹介しました。

  • 注文リクエストのピーク時のデータ分析で、ボトルネックがエンキュー速度とデキュー速度の差にあることを特定
  • 本注文の作成処理のストアドプロシージャのJSONパラメーター化でリンクサーバー経由アクセスを削減し、CPU時間を80.9%改善
  • 後処理の並列化でCompletableFutureを活用し、メール送信・クレジットカード登録などの処理を並列実行
  • これらの組み合わせで約0.4秒の短縮を達成し、ピーク時の待ち時間を大幅に改善

ストアドプロシージャのリンクサーバー削減と、JavaのCompletableFutureによる並列化を組み合わせることで、0.4秒という目標を達成できました。決済処理のような高信頼性が求められるシステムでも、ms単位の地道な改善の積み重ねが大きな効果を生むことを実感しました。同様の課題を抱えている方の参考になれば幸いです。

ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

corp.zozo.com

カテゴリー