GS2 Blog

Game Server Services(https://gs2.io/) の最新情報をお届けします

非同期処理とリトライと冪等性

今回の記事は普段の GS2 のアップデート告知とは少し毛色が異なり、技術的なトピックを扱うエントリーです。

gs2.hatenablog.com

こちらで告知した消費アクションの分岐処理を実装するにあたって、どのようなアプローチで課題に向き合ってきたのかを解説しようと思います。

非同期処理とリトライ

まずは、非同期処理とリトライについて考えてみましょう。

非同期処理とは?

API を呼び出すと、処理の結果が返ってくる。処理の途中でエラーが発生したらエラーが返ってくる」というのが同期処理です。
この場合、エラーハンドリングは呼び出し元に委ねられますので、比較的シンプルに処理を行うことができます。

一方で、非同期処理とはどういうものか?というと

API を呼び出すと、処理を動かし、処理IDを応答する」「処理IDを指定して完了を通知」「処理IDを指定して処理結果を取得」
というように呼び出し元へ処理を一旦返すような処理を行います。

「なんで実装が大変そうなのに、そんなややこしいことをするの?」というと、設計者のさまざまな思惑があるわけですが、
一番一般的なのは「処理にかかる時間が長くて、APIの応答をすぐに返せない」ケースでしょう。
近年LLMを使った画像生成や音楽生成、動画生成のような技術が出てきていますが、これらはまさに処理に時間がかかるのでAPIの応答まで数分握り続けるというのは現実的ではないケースがあり、非同期処理を導入するメリットがあります。

GS2 においては少し事情が違っていて、ゲームを複数のデバイスでプレイしていたとしても「操作しているデバイスに限らず、全てのログイン中のデバイスにも結果を反映できるように」という意図があります。
つまり「デバイスAでクエスト開始APIを呼び出す」「処理の完了通知はデバイスAだけでなく、デバイスBにも通知」「デバイスA,Bともにクエストが開始されたことを認識できる」という意図で非同期処理を軸とした設計をしています。

リトライ

さて、次にリトライについてです。
同期処理の場合、処理時間が長くなるとタイムアウトするかもしれないといった、イレギュラーなケースをなるべく無くすために、すぐに結果を返したくなるインセンティブが強いため、エラーが発生したらエラー内容をすぐに結果として返したくなります。
一方で、非同期処理の場合は(UXが許す範囲で)多少時間がかかっても実装上はそれほど困らないという特性が加わります。

結果として、非同期処理の中で自動的にリトライして、クライアントにエラー応答を返すケースを減らしてあげる試みがしやすくなります。
しかし、同期処理にしても、非同期処理にしてもリトライは真面目につくるのは結構難しいのです。

リトライの難しさとは?

GS2 はゲーム用途に特化したサーバーを提供する BaaS ですが、ゲーム開発者は普段ローカルリソースを触ることが多いため、エラーハンドリングがとにかく苦手です。いや、そもそもエラーハンドリングが得意な人なんていないです。
通信処理を隠蔽したり、リトライを隠蔽したりして、なるべく「開発者体験を良くしよう」というのもGS2の目指すところですので、リトライについてもなるべく私たちのレイヤーで処理したいと考えています。

エラーの種類

リトライを考えるにあたって、エラーには大きく2種類あることを認識する必要があります。
1つ目は「リトライする価値のあるエラー」2つ目は「リトライする価値のないエラー」です。

たとえば「アイテムの所持数量が足りなくてアイテムを消費できなかった」というエラーはリトライをする価値がありません。
しかし「データベースが100ms以上応答しなかった」というのはリトライする価値のあるエラーです。

リトライする価値のあるエラーが発生した時に、内部的にリトライをしてあげることでエラーハンドリングをする必要性が減って開発者体験がよくなります。

本当にその処理リトライして大丈夫?

世の中がこんなに簡単だったら楽なのですが、現実はそんなに甘くありません。
たとえば「アイテムの所持数量を+1」するというAPIを呼び出したとしましょう。
この処理を実行した時「APIが500ms以上応答しなかったためタイムアウトした」という状況が発生したとしましょう。

この時「アイテムは加算されているけど、応答が間に合わなかった」という場合を考慮しなければなりません。
何も考えずにリトライをするとアイテムが +2 されてしまう可能性が生まれてしまいます。

冪等性

リトライをする時の強い味方が「冪等性」です。
同じ処理を繰り返し実行しても結果が反映されないという特性です。

この同じ処理というのは「同じリクエスト内容」という意味ではなく「同じコンテキストで呼び出された処理」を指します。
リトライとは「同じコンテキストで処理を再度呼び出す」ということですので、この場合に多重に処理されないように設計する必要があります。

HTTPの標準に「Idempotency-Key」というリクエストヘッダを追加しようという動きがある程度には近年標準的に実装するべきだという認識が高まっているのがこの技術です。
意図するところは「Idempotency-Key」が同じ値を持つリクエストは同じコンテキストで呼び出されているので、多重処理しないようにしなければならない ということです。

冪等性の実現方法

まずロック機構が必要になります。同一 Idempotency-Key のリクエストは並列で動いてはいけません。
処理の結果「正常」「リトライをする価値のないエラー」の場合は Idempotency-Key とリクエストパラメーターのハッシュ値をキーとして結果を一定期間保存しておく必要があります。
処理の開始前に Idempotency-Key とリクエストパラメーターのハッシュ値で作ったキーの処理が過去に終わったことがないかを確認して、過去に実行した結果が保存されている場合はその結果を応答する必要があります。
処理の中で外部リソースを触る場合は、全て冪等性のある呼び出しを行う必要があります。

冪等性を実現するのは単純ではありません。ロック処理や結果の格納などコストもかかります。しかし、冪等性を担保することは非常に大切です。
このエントリーを最後まで読む頃には「実装は大変だったし、お金もかかるんだろうけど GS2 の API に冪等性があってよかったね」と思っていただけているんじゃないかと思います。

実際に 消費アクションの分岐処理を実装するにあたって ここまで説明してきたことがどのように役立ったのか

GS2 はマイクロサービスで設計しており、現在はプレゼントボックスや、アイテムボックス、ガチャといった機能を40を超えるマイクロサービスとして提供しています。

今回追加された機能がどういうものだったのかを説明します。

GS2では「課金通貨を300減らして」「ガチャを引く」というような関連あるプレイヤーのリソース操作を「トランザクション」と呼んでいます。
そして、これらの処理は一般的に複数のマイクロサービスに跨った処理を行うことになります。

「課金通貨の残高を管理するシステム」と「ガチャの抽選をするシステム」は全く別のシステムです。
そして「ガチャを引く」と「キャラクターを入手する」処理が連鎖して非同期処理で動きます。このことを念頭にこれからの説明を読んでください。

トランザクションとは「課金通貨の残高を管理するシステムに通貨の残高を減らす」「ガチャの抽選をするシステムに抽選させる」という処理をひとまとめにして処理します。
この処理がどこかで失敗したときに「トランザクション処理をリトライ」できるようにすることで、開発者はどのシステムを呼び出すといった複雑な思考から解放されます。

トランザクションをリトライする時の課題

一方で複数のシステムを整合性を維持したまま処理させるのはなかなか困難です。マイクロサービスが難しいと言われる理由の99%がここにあります。

「課金通貨の残高を管理するシステムに通貨の残高を減らす」「ガチャの抽選をするシステムに抽選させる」トランザクションのエラーパターンを考えてみましょう。

  • 課金通貨の残高を管理するシステムに通貨の残高を減らす 時に残高が足りない
  • 課金通貨の残高を管理するシステムに通貨の残高を減らす 処理がタイムアウト(すでに減らす処理は終わっていた)
  • 課金通貨の残高を管理するシステムに通貨の残高を減らす 処理がタイムアウト(まだ減らす処理は終わっていない)
  • ガチャの抽選をするシステムに抽選させる 処理がタイムアウト(すでに抽選処理は終わっていた)
  • ガチャの抽選をするシステムに抽選させる 処理がタイムアウト(まだ抽選処理は終わっていない)

ざっとこのくらいのパターンが出てきます。

「課金通貨の残高を管理するシステムに通貨の残高を減らす 時に残高が足りない」
トランザクションの実行前に弾いておくべきケースなので、横に置いておきます。

他のエラーの中で一番厄介なケースは

  • 課金通貨の残高を管理するシステムに通貨の残高を減らす 処理が正常に終わった
  • ガチャの抽選をするシステムに抽選させる 処理がタイムアウト(まだ抽選処理は終わっていない)

のようなケースです。
このトランザクションを正しくリトライするためには

「課金通貨の残高を管理するシステムに通貨の残高を減らす処理」はスキップして「ガチャの抽選をするシステムに抽選させる」部分だけリトライする必要があります。

普通に作っていたら頭が痛いです。
しかし、トランザクションの中で呼び出す処理に冪等性があればリトライ時は何も考えずにそれぞれの処理を呼び直して、両方成功するまでリトライすればよくなり、シンプルになります。

分岐と冪等性

さて、ここまではもともと GS2 では実現できていた処理でした。
今回実装した機能は、トランザクションの中で処理を分岐する仕組みです。

もう少し具体的にいうと「ガチャチケットを持っている場合」は「ガチャチケットを消費」して、持っていなければ「課金通貨を300消費」して「ガチャを引く」というトランザクションです。

この場合、トランザクションの中で分岐条件に影響を与える変化が生じます。
「ガチャチケットを1枚持っている」状況で、このトランザクションを実行したとして「ガチャチケットを消費」した後で「ガチャを引く」のに失敗したのでリトライするとします。
リトライをした時に「ガチャチケットを持っている場合」の条件は満たさなくなっています。それでも「ガチャチケットを持っている場合」の判定処理に冪等性があり「ガチャチケットを持っている」という結果を得ることができます。
続けて「ガチャチケットを消費」を実行しますが、こちらも冪等性によって再度消費されることはありません。
最後に「ガチャを引く」部分だけリトライすることができます。

まとめ

  • 非同期処理は自動的にリトライして開発者体験をあげやすい
  • リトライ処理は冪等性があると実装が簡単になる
  • 冪等性の実現はロックの取得や、結果の保存などデータベースの負担が増えてお金がかかるけどやる価値がある

自社のシステム以外と連携する必要がある時に、冪等性のないAPIを使う必要がある時にとにかく困ります。
マイクロサービス云々は置いておいて、世の中全てのシステムに冪等性が担保される時代になるよう強く望みます。

(C) Game Server Services, Inc.