AWS for Games Advent Calendar 2022 9日目の記事です。
Game Server Services(GS2) ではゲームに必要となるサーバー機能をマイクロサービス化し、皆さんに提供しています。
マイクロサービスには所持品の管理や、ゲーム内ストア、課金通貨の残高管理など30を超える機能を用意しており、これらを組み合わせながらゲーム内の仕様を実現できるようにしています。
さて、マイクロサービスの最も難しい課題はトランザクションにあると私は考えています。
今回は Game Server Services がどのようにこの課題に立ち向かい、そして問題を解決しているかお話ししたいと思います。
マイクロサービスとトランザクションの両立がなぜ難しいのか
モノリシックなサーバーシステムは、大体の場合「所持品の所持数量」と「課金通貨の残高」は同じRDBに保存しています。
そして、「課金通貨を消費して所持品を増加させる」という処理があった場合に、1つのトランザクションで処理を行い、どちらかの処理が失敗したらロールバックする仕組みで完全性を担保しています。
しかし、マイクロサービス化すると「所持品の所持数量を保存するデータベース」と「課金通貨の残高を保存するデータベース」が分離することとなり、これまでのようにRDBのトランザクションで処理することができなくなり、別のアプローチで完全性を実現しなければならなくなる。というのが難しさの根源です。
スタンプシート
GS2 では独自のトランザクションシステムを用意しています。
これはゲームのユースケースを前提とした仕組みのため、あらゆるシステムにそのまま応用できるとは思いませんが、参考にはなるかもしれません。
スタンプシートを説明するために、「対価」と「報酬」について説明する必要があります。
対価と報酬
ゲーム内の処理で「所持品の増減」といったユーザーデータの書き換えは、大体の場合ユーザーにとって「メリット」のある操作か「デメリット」のある操作かに分類することができます。
いずれにも該当しないものは、もしかすると必ずしもサーバーでデータを管理する必要がないデータかもしれません。
GS2 では「メリットのある操作」を「報酬」と呼び、「デメリットのある操作」を「対価」と呼んでいます。
対価の例
- 所持品を減らす
- スタミナを減らす
- 進行中のクエスト情報を削除する
報酬の例
- 所持品を増やす
- スタミナを増やす
- 進行中のクエスト情報を作成する
概ね「対価」と「報酬」は対となります。
GS2 ではトランザクション処理を開始する際に、スタンプシートというエンティティを作成しますが、これには「複数の対価」と「1つの報酬」を関連づけることができます。
スタンプシートのプロセス
なぜスタンプシートというか、というと これが「稟議書」を模したデータ構造だからです。
現実でも「稟議書」を書くことがあると思いますが、これには「複数の上長」の確認印を得た後で「何らかの処理を履行する」ことができる 一種のトランザクションだからです。
このときに肝となるのが「何らかの処理を履行」するにあたって、確認印を押した上長すべてに本当にいいのか確認をすることなく、ハンコが押されていることで承認を得ていると判断し履行することができている点です。
さて、ゲーム内ストアで1回だけ半額で購入できるガチャを課金通貨を消費して引くプロセスを例に話をします。
登場人物として4つのマイクロサービスが登場します。
- ゲーム内ストア
- 回数制限
- 課金通貨残高管理
- ガチャ
ゲーム内ストア
ゲーム内ストアは販売中の商品の管理と、その購入処理を実現するマイクロサービスです。
ゲーム内ストアは実際は販売期間を管理するスケジュール管理マイクロサービスと連携したりしますが、本題ではないので割愛します。
ゲーム内ストアは購入リクエストを受け付けると、「最大値1の購入回数のカウンターを1上昇させる」「課金通貨を通常の半額消費する」という2つの対価と「ガチャを引ける」報酬を設定したスタンプシートを発行します。
回数制限
回数制限は文字通りゲーム内で取れる行動回数を制限する仕組みです。
用途ごとにカウンターを持ち、カウンター操作を受け付けるAPIがあります。
カウンターにはリセット間隔を設定できるため、1日1回や1週間に3回のような要件も満たせるようになっています。
当然ですが、リセットを実行する時間や曜日を設定できます。
最大値を超えるカウンター上昇が発生した場合はエラー応答を返します。
課金通貨残高管理
課金通貨残高管理は文字通り、現金で購入したゲーム内通貨の残高を管理する仕組みです。
資金決済法によってこのような情報は厳密に管理することが求められ、それを実現するための仕組みを有しています。
また、有償価値のある課金通貨は同じユーザーアカウントでも配信プラットフォームごとにウォレットを分けて管理する必要があるなど、プラットフォーマーによる規約を実現するための機能も備わっています。
ガチャ
排出確率を設定すると、抽選APIを呼び出すだけで確率に基づいたアイテムを排出してくれる仕組みです。
さて、マイクロサービスの解説がおわったので、ゲーム内ストアが払い出したスタンプシートの話に戻りましょう。
ゲーム内ストアで購入処理を行うと、以下のスタンプシートが発行されるという話をしました。
- 対価
- 最大値1の購入回数のカウンターを1上昇させる
- 課金通貨を通常の半額消費する
- 報酬
- ガチャを引ける
スタンプシートを実行すると、まずは「対価」に関する処理を行います。
各マイクロサービスにスタンプシートを持っていくと、処理を実行して実行を保証する署名情報を発行してくれます。
2つの「対価」を払い終えたら、「スタンプシート」と「2つの実行を保証する署名情報」をもってガチャのマイクロサービスに「報酬」を受け取りに行きます。
ガチャはスタンプシートに記録された「対価」の内容と「実行を保証する署名情報」が一致することを確認し、報酬を付与します。
ちなみに、ガチャは排出したアイテムを付与するために更に新しいスタンプシートを発行します。これも同じように実行することで報酬が手に入ります。
これで一連の処理を実行する流れができました。
スタンプシートで完全性を実現する
しかし、これだけでは不十分です。まだスタンプシートは購入処理の一連の作業をまとめた存在にしかなっていません。
たとえば、ガチャのマイクロサービスが障害により停止していた場合、「対価」だけ消費して「報酬」が得られていないような状況を作ってしまったり、リトライすることで「対価」を消費し過ぎてしまう問題が発生し得ます。
この問題を解決するために、GS2のAPIでは冪等性を持たせることをできるようにしています。
冪等性
冪等性 はマイクロサービスの文脈ではよく登場するワードですが、繰り返し処理を実行しても複数回処理されることなく一定の状態を保つ仕組みのことです。
GS2 における冪等性
GS2 にはユーザーデータの操作に関わるAPIには Duplication Avoider というパラメーターが必ずあります。
ここにパラメーターを指定することで、冪等性が有効になります。
同一の Duplication Avoider パラメーターでAPIが呼び出された場合、それが複数回呼び出されたとしても処理は1度しか実行しないように設計されています。
具体的な処理としては
- 同じ Duplication Avoider の値のリクエストが複数同時に処理されないようロックされる
- 正常に処理が完了した場合 Duplication Avoider とリクエストパラメーターのハッシュ値をキーとしてレスポンスを保存する
- Duplication Avoider とリクエストパラメーターのハッシュ値ですでにレスポンスデータがある場合は、処理をせずにレスポンスデータをそのまま応答する
これで、API の冪等性を担保しています。
スタンプシートと冪等性
スタンプシート関連のリクエストには必ず Duplication Avoider をつけて処理されています。
つまり、同じスタンプシートを繰り返し実行したとしても、何度も「対価」を払うことは起こり得ませんし、最初に成功した結果が返ってくるだけで複数回「報酬」を受け取ることができないことになります。
スタンプシートは成功するまで繰り返し実行することで、結果整合的ではありますが、完全性を保証することができます。
この時に気をつけないといけないのが、「回数制限のカウンターが最大値に達している」「課金通貨の残高が不足している」というような明らかにエラーになるケースではリトライを続行しないようにしないと無限にリトライしてしまうという点です。
GS2 ではこのあたりのややこしい処理は全てサーバー側で請け負う仕組みがあり、スタンプシートの発行とともにGS2側で完了するか、継続不能なエラーが発生するまでリトライしてくれる仕組みも提供しています。
そのため、GS2 の利用者からするとスタンプシートの存在に気づかずに使い続けることもあると思います。
ロールバック
「複数の対価」 を払っている途中で継続不能なエラーが発生した場合についてです。
今回のケースであれば「回数制限のカウンターを1上昇」には成功したが「課金通貨の残高を減らすには残高が不足していた」というものです。
この場合、回数制限のカウンターは上昇してしまっていますが、継続不可能なエラーでスタンプシートの実行が止まってしまっており、不整合が発生します。
理想的には、ここから回数制限のカウンターの値を元に戻す処理を動かすことですが、GS2では割り切っておりここは途中で実行が止まってしまったスタンプシートを列挙する機能を提供するにとどめ、個別対応していただいています。
なぜなら、この問題の発生条件にユーザーの悪意が介在する可能性が高いからです。
正しく、購入処理の前に残高やカウンターの値を確認した上でスタンプシートを発行していればこのような問題は発生せず、発生するとしたら並列実行するなどの行為によって残高チェックのタイミングと購入処理のタイミングで状態が変化している状況を作り出さなければ発生しないからです。
ゲームにおいては「報酬」を払い出しすぎることはゲーム全体に大きなダメージを与えますが、「対価」の払い過ぎは一般的なケースで発生しないケースであれば大きなダメージとはならない。という事業特性からこのような制約を受け入れています。
技術的な制約というよりは、実装コスト面の制約によるものですので、将来的には自動的にロールバックできるようにしていきたいところです。
複数の報酬
最後に、複数の報酬の話をします。
スタンプシートには1つの報酬しかつけられないという話をしました。
これは設計上、安全に報酬を付与できるのは1つだけという制約から来ています。
しかし、ゲームではクエストクリア時に「経験値」と「ゲーム内通貨」を手にいれるというような複数の報酬は一般的に存在します。
GS2 では、ここをジョブキューを挟むことで完全性を両立しています。
具体的には「経験値を入手する処理」と「ゲーム内通貨を入手する」という2つのジョブをジョブキューに登録する という1つの報酬にしてしまいます。
これによって、スタンプシートが実行できたタイミングではジョブキューにジョブが登録された状態になり、その後ジョブキューが報酬付与の処理を順番に処理し、成功するか回復不能なエラーが発生するまでリトライを続けることで完全性を保証します。
この時も Duplication Avoider を指定して報酬付与を行うことで、ジョブキュー内のジョブが複数回実行されるようなことがあっても、報酬が付与されすぎることがないようになっています。
最後に
マイクロサービスは疎結合に設計することが最も重要となり、マイクロサービス間の連携がエコシステムとして機能しはじめたときにマイクロサービス化が成功したという状況と言われています。
そのため、マイクロサービス間の連携を「スタンプシート」という形で抽象化し、マイクロサービス間はスタンプシートを経由して連携することで、疎結合かつ完全性を担保しながら連携させているという事例でした。
この仕組みは非常によく機能しており、GS2 におけるマイクロサービスの独立性の実現に大きく貢献してくれています。
みなさんもマイクロサービス化に取り組む際にはぜひご参考ください。