冪等性で挑む、非同期処理のパフォーマンスチューニング
前回好評だった「冪等性と非同期実行」の続編にあたる記事です。
私たちが提供している Game Server Services はバックエンドに Lambda + DynamoDB といったフルマネージドサービスを活用した、いわゆるサーバーレスアーキテクチャで実装されています。
前回の記事はデータの整合性を保ちつつ、いかに処理をするかに焦点を当ててお話ししました。
ゲームはデータの整合性に対する要件は金融システムほどではないものの、高い水準で求められます。
あわせて、ゲームは体験に対する要件の水準が高く、レスポンスタイムへの要件にも厳しいのが特徴となっています。
前回の記事でざっくりとGS2における消費処理と入手処理を軸とした、トランザクションアーキテクチャのお話をしました。
今回の記事では、このトランザクションの実行についてのパフォーマンスチューニングを行った内容のお話をしたいと思います。
処理内容の例
まず、GS2を使用してゲームが「ガチャ」を実行する流れを整理します。
今回の例のガチャは「課金通貨を消費」して「ガチャを引いて」「アイテムを入手する」という流れとなります。
1. 「ゲーム」が商品販売マイクロサービスで「商品を購入」する
2. 商品販売マイクロサービスは「課金通貨を消費」して「ガチャを引く」トランザクションAを発行する
3. トランザクションAの非同期処理を開始する
4. 課金通貨の残高管理マイクロサービスで「課金通貨を消費」する
5. 抽選マイクロサービスでガチャの「抽選を実行」する
6. トランザクションAの非同期処理完了を通知する
7. 「ゲーム」が抽選結果を取得し、ガチャ演出を開始する
8. 抽選マイクロサービスは抽選結果の「アイテムを入手」するトランザクションBを発行する
9. トランザクションBの非同期実行を開始する
10. アイテム管理マイクロサービスで「アイテムを入手」する
11. トランザクションBの非同期処理完了を通知する
12. 「ゲーム」がガチャ演出から抜けられる状態にする
このような流れに整理することができます。
この中で非同期処理が関わる部分が何箇所かあります。ここが今回お話しするチューニングの対象となる部分だと理解してください。
1. 「ゲーム」が商品販売マイクロサービスで「商品を購入」する
2. 商品販売マイクロサービスは「課金通貨を消費」して「ガチャを引く」トランザクションAを発行する
3. 《非同期処理》トランザクションAの非同期処理を開始する
4. 課金通貨の残高管理マイクロサービスで「課金通貨を消費」する
5. 抽選マイクロサービスでガチャの「抽選を実行」する
6. 《非同期処理》トランザクションAの非同期処理完了を通知する
7. 「ゲーム」が抽選結果を取得し、ガチャ演出を開始する
8. 抽選マイクロサービスは抽選結果の「アイテムを入手」するトランザクションBを発行する
9. 《非同期処理》トランザクションBの非同期実行を開始する
10. アイテム管理マイクロサービスで「アイテムを入手」する
11. 《非同期処理》トランザクションBの非同期処理完了を通知する
12. 「ゲーム」がガチャ演出から抜けられる状態にする
頭に《非同期処理》とつけた4つのプロセスが非同期処理を開始する部分となります。
つまり、今回の例では4回 非同期処理が開始されていることになります。
GS2においては、非同期処理というのはスレッドのようなものではなく、キューに処理を積んでキューから処理を開始するような流れで実行されます。
なぜキューを挟むのかというと、処理が終了するまでリトライすることを保証するためです。
サーバーレスアーキテクチャでキューを実現する方法としてはいくつかの方法があるのですが、GS2では AWS Lambda の非同期実行(Event実行)または Simple Queue Service(SQS) を経由して Lambda を実行するような処理が一般的です。
非同期処理にはオーバーヘッドが生じる
さて、この両方の方式で課題があります。それが今回取り上げるパフォーマンスの問題です。
SQS 経由で Lambda を実行する場合はイメージしやすいのですが、キューに入れてから実際に Lambda が実行されるまでにオーバーヘッドが発生します。
実はこれは Lambda の非同期実行でも同様に、リクエストから実際にLambdaが実行されるまでにレイテンシーが発生します。
dwell time と呼ばれる時間なのですが、非同期実行の Lambda は、関数実行時にエラーが発生した時に最大2回自動的にリトライされ、これを実現するために一旦サービスキューというキューを経由して処理されており、ここのオーバーヘッドが生じます。
では、実際にどの程度のレイテンシーが生じるのか?というと、どちらのケースもキューにジョブを積んでから、ジョブが実行されるまで 50ms ほどのレイテンシーが発生することを観測しています。
これは通常の用途では、決して大きくはないレイテンシーなのですが、GS2のような使い方をしていると「商品を購入」してから「抽選を実行」まで、または「アイテムの入手」が実行されるまでの待ち時間が増えることに繋がってしまいます。
リトライを自動的にすることは、開発者の開発体験を最適化するという観点では素晴らしいアプローチですが、処理の99%以上はリトライを必要としません。
しかし、リトライのために非同期処理化した結果、99%以上の正常系の処理で処理時間が長くなるというデメリットを受けてしまうことを忘れてはなりません。
非同期処理のレイテンシーに冪等性で対峙する
では、非同期処理のレイテンシーを最小化するにはどうすればいいのか?
GS2 でとっているアプローチは同期実行も同時に実行しています。
「非同期処理がオーバーヘッドがあるなら、オーバーヘッドのない同期実行も一緒にすればいいじゃない」という脳筋アプローチです。
非同期処理にはSQSの遅延実行機能を使用して、1秒後にジョブが実行されるようにジョブを登録し、ジョブの登録に成功した段階で、同期処理も呼び出しています。そして同期処理の完了は待たずに処理を継続しています。
すると何が起こるのかというと、同期処理の実行が直ちに開始され、およそ1秒後に非同期処理実行が実行されるような形で最低2回処理が実行されるようになります。
前回の記事でも説明してきたように、GS2のAPIには冪等性がありますので、非同期処理実行時にすでに同期実行によって処理が終わっていれば何も処理せずに処理を終えることになります。
どのくらい速くなるのか
GS2のAPIは単体であれば50ms以下で応答しています。
最初に示した例で、これらの要素も含めてどのくらい時間がかかっていたのかを考えてみます。
1. 「ゲーム」が商品販売マイクロサービスで「商品を購入」する(50ms)
2. 商品販売マイクロサービスは「課金通貨を消費」して「ガチャを引く」トランザクションAを発行する
3. 《非同期処理》トランザクションAの非同期処理を開始する(50ms)
4. 課金通貨の残高管理マイクロサービスで「課金通貨を消費」する(50ms)
5. 抽選マイクロサービスでガチャの「抽選を実行」する(50ms)
6. 《非同期処理》トランザクションAの非同期処理完了を通知する(50ms)
7. 「ゲーム」が抽選結果を取得し、ガチャ演出を開始する
8. 抽選マイクロサービスは抽選結果の「アイテムを入手」するトランザクションBを発行する
9. 《非同期処理》トランザクションBの非同期実行を開始する(50ms)
10. アイテム管理マイクロサービスで「アイテムを入手」する(50ms)
11. 《非同期処理》トランザクションBの非同期処理完了を通知する(50ms)
12. 「ゲーム」がガチャ演出から抜けられる状態にする
トータル 400ms かかっていることになります。 非同期処理のレイテンシー50msがなくなるとどのようになるのかというと、《非同期処理》という部分のコストがほとんどなくなりますので、以下のようになります。
1. 「ゲーム」が商品販売マイクロサービスで「商品を購入」する(50ms)
2. 商品販売マイクロサービスは「課金通貨を消費」して「ガチャを引く」トランザクションAを発行する
3. 《非同期処理》トランザクションAの非同期処理を開始する
4. 課金通貨の残高管理マイクロサービスで「課金通貨を消費」する(50ms)
5. 抽選マイクロサービスでガチャの「抽選を実行」する(50ms)
6. 《非同期処理》トランザクションAの非同期処理完了を通知する
7. 「ゲーム」が抽選結果を取得し、ガチャ演出を開始する
8. 抽選マイクロサービスは抽選結果の「アイテムを入手」するトランザクションBを発行する
9. 《非同期処理》トランザクションBの非同期実行を開始する
10. アイテム管理マイクロサービスで「アイテムを入手」する(50ms)
11. 《非同期処理》トランザクションBの非同期処理完了を通知する
12. 「ゲーム」がガチャ演出から抜けられる状態にする
というわけでトータル200msになります。なんと半減です。実際にはAPIの処理時間は50ms以下ですので、もっと効果は大きくなります。
まとめ
非同期処理と冪等性でリトライを扱いやすくすることも大切ですが、それによって正常系の体験を損なうことをは避ける必要があります。
残念ながら非同期処理でリトライを行うということは、一旦キューを経由することは避けられませんので、その分レイテンシーが発生し、トータルの処理時間では増加傾向になります。
特に AWS が提供してくれている非同期処理の仕組みでは 50ms ほどのレイテンシーは許容する必要があります。
ここに同期実行と冪等性を組み合わせて対峙することで、正常系のトータルの処理時間を短縮しつつ、非同期処理によるリトライの恩恵を得ることも可能です。
ただし、冪等性の実現にコストがかかる上に、同期処理と非同期処理で最低2回処理を実行することになるため更にコストは上乗せされてしまいます。
ユースケースに合わせつつ、トータルの処理時間を短くすることにコストとを割くべきだという判断になった際に、こういった手法を活用してみるのもいいのではないでしょうか。