突然の決済スパイクに悩まされていた「BOOTH」

金川祐太郎氏(以下、金川):「オンライン即売会を支えた技術」。BOOTH部の金川がお話します。よろしくお願いします。

「BOOTH」では、突然の決済スパイクに頭を抱えていました。決済スパイクは、例えば有名なクリエイターの期間限定販売や、「YouTube」などVTuberの配信中に商品が公開された時、あとは最近行われるエアコミケといったオンライン即売会などで発生します。

決済スパイクが起きる時、BOOTHでは同じ商品に注文が集中します。この時に何に困るかというと、これはBOOTHで以前行われていた処理なのですが、同じトランザクションの中で在庫処理と決済処理を同時に行っています。まず商品で悲観的ロックを取り、在庫の減算を行い、注文の確定を行ってから決済確定APIを呼び出します。この時、同じ商品で注文が殺到してしまうと、注文を1つずつしか捌けないため、時間のかかる処理が行われていた場合、トランザクションが詰まってしまいます。

実際にこれは同じ商品に注文が殺到したケースで、ピクシブ株式会社では「DataDog」というツールを使って処理のかかっている時間を見られるのですが、この決済処理には約13秒ほどの時間がかかっていて、そのほとんどが在庫ロック待ちで使われていました。

決済スパイクを解決するための3つのアプローチ

そこでスパイクを捌くためのアプローチとして、3つの方法を考えました。まず1つ目が、在庫1個ごとにレコードを用意する方法。2つ目が、決済されるごとに「Sidekiq」にジョブを積んで非同期で処理を行う方法。3つ目が、トランザクションを分割するという方法です。

まず、在庫1個ごとにレコードを用意する方法です。これは最初に考えた方法で、「在庫 ロック」などで検索を行うと出てくる事例です。在庫ごとにレコードを作って、個別にロックを取ればいいという方法なのですが、BOOTHの在庫数をsumで取ってみたところ数億になったので、さすがにこれは現実的ではないと断念しました。

2つ目は、決済されたらSidekiqにジョブを積んで非同期で決済を行う方法です。決済が完了したらメールやポーリングなどを使ってユーザーに通知します。しかしこれを行うと、決済を確定するボタンを押してからその結果を知るまで、その決済の注文の状態がわからなくなる時間が増えてしまいます。

例えば決済に2秒かかるとして、一気に1,000個注文が入った場合、注文が1個ずつしか処理されないので、1,000個注文目の人は30分近く結果を待たされてしまいます。なので、これも断念しました。

採用した方法は、トランザクションの分割です。在庫の減算と決済の2つにトランザクションを分割します。在庫の減算は悲観的ロックを取りながら在庫を減らすというトランザクションで、これは数百ミリ秒ぐらいの処理時間です。もう1つが悲観的ロックを取らずに決済確定APIを叩くトランザクションです。これはロックを取らないので並列に実行できますが、約2秒ほど長い時間がかかります。

この2つのトランザクションに分けることで、体験を変えずに決済処理を並列で行えるようになりました。

トランザクション分割後の具体的な流れ

この処理の詳細な流れを説明します。1つ目が在庫管理トランザクションです。2つ目が決済確定トランザクションです。1つ目の在庫管理トランザクションでは、商品を管理している棚をイメージしてください。棚から商品を取り出せるのは1人ずつです。その棚からレジの横に商品を確保しておくというイメージで、在庫確保テーブルに商品情報を挿入します。

そのあと在庫減算を行って、いったんその処理をコミットします。次に在庫確保レコードを削除して、注文確定状態にして、決済確定APIを呼び出します。ここは、レジの横にどけておいた商品を実際にレジで決済を行うというイメージです。なので、ここは悲観的ロックが必要ありません。

これは正常系の流れです。ここでエラーが起きた時の流れをちょっと考えてみましょう。マイクロサービスパターンのSagaパターンの中に補償トランザクションがあるのですが、それと同じイメージで、決済トランザクションで失敗した時に、在庫トランザクションはすでにコミットされているので、それを手動で取り消す必要があります。

例えば決済トランザクションの最後で、決済APIが失敗したというケースを考えます。この場合、決算トランザクションは自動でロールバックされるのですが、在庫管理トランザクションはロールバックできないので、在庫管理トランザクションで行った処理の逆順の処理をすればOKです。なので、まず在庫で悲観的ロックを取ってから商品情報を削除して、在庫を加算します。

ここでもう1つ考えないといけないのは、この補償トランザクションで失敗した時のケースです。例えばRDBの障害などが考えられます。この場合、補償トランザクションが失敗してしまうので、べき等な操作にしておいて再実行可能にしておく必要があります。今回の場合、バッチで長時間残っている在庫を確保した商品については、もう一度同じ補償トランザクションを再実行するという方法で解決しています。

改善を入れる前は、最大分間注文数がだいたい400件ぐらいしか捌けなかったのですが、この改善によって分間2,000注文を捌けるようになりました。めでたしめでたしですね。

DOMの組み立てはフロントに寄せていく予定

これは後日談なのですが、ある日突然BOOTH全体が重くなりました。注文自体は捌けていたのですが、CPUが100パーセント張り付くという障害が起きてしまいました。slimのレンダリングに時間がかかっていたことが原因でした。今まで注文が捌ききれていなかったので、ボトルネックになるところが移動したという感じです。

ここの処理を追いかけたところ、slimが遅いことがわかりました。ユーザーの設定によって表示する内容が変わるため部分的なキャッシュも難しく、今後はフロントで組み立てる方向に寄せていく予定です。

最後にまとめです。悲観的ロックを取るトランザクションの中で、時間のかかる処理をしないのが最善です。ただ、どうしても必要な場合があるので、その場合はトランザクションの分割を検討します。最後にDOMの組み立てはけっこう大変なので、今後はフロントにDOMの組み立ての処理を寄せていくのがよいかなと思っています。以上です。ありがとうございました。

司会者:金川さんの発表は以上です。ではこれから質疑応答に入っていきたいと思います。「決済と在庫の最終的な結果整合性はどうやって確認できているんですか?」とという質問です。

金川:整合性は、問い合わせベースがあるのと、もう1つ、スライドの中でも語っていたんですが、きちんとロールバックが行われているかを定期的なジョブで確認しているので、そこで「おかしいぞ?」となったらアラートが上がるようにしています。

司会者:ありがとうございます。次「使っているRDBは何ですか?」という質問がありました。

金川:弊社は基本的にMySQLを使っています。バージョンとしては5.6だったかな?

司会者:ありがとうございます。ではこちらの質問もお願いします。「整合性が取れなくなってしまう頻度はどの程度なのでしょうか?」

金川:整合性を2つの方法で確認していると話したと思うんですが、まず問い合わせベースはほぼ来たことがないです。トランザクションが失敗したケースも今まで1回起きたぐらいで、あとは自然に解消されています。

司会者:ありがとうございます。「ダミーのカードデータで大量に購入して、決済確定が失敗しまくった場合、在庫をゼロにできてしまう攻撃ができそうな気がしたのですが、在庫側のロールバックはどの程度の時間で完了するのでしょうか?」という質問が来ています。

金川:まず失敗した段階で、レスポンスを返すのと同じリクエストの中で一緒にロールバックをやっちゃおうという方針にしていて、そこで失敗しなかったケアとして、非同期でもう一度補償トランザクションを走らせるというかたちになっているので、その攻撃はたぶん成り立たないはずです。

司会者:なるほど。そのリクエスト中で失敗する確率はそこまで高くないだろうという感じですね。

金川:そうですね。DBとの接続が切れない限りという感じですね(笑)。

司会者:どうもありがとうございます。ここで金川さんの発表は終わりにしたいと思います。