Open Matchを参考にマッチメイキング・フレームワークを作った

ゲームサーバー上でのマッチメイキングを想定したフレームワーク minimatch を開発した。

開発に至った理由

すでにこの分野ではOpen Matchというオープンな実装があり、活用例も世に出てきている。 そんな中でなぜ今新しく minimatch を作ったのか。 一番大きい理由は Open Matchを使ったローカル開発を考えるに書いた通りだ。

Open Matchはゲームのマッチングを作るための仕組みなので、ゲーム開発者はまずローカル環境で試しながら開発をしたくなる。 では早速と、Getting Startedを読むと いきなりKubernetesクラスターが必要になり、kubectlのコマンドが多数登場し、ゲームのロジックとは無関係のものまで準備しないといけない。 Kubernetesの扱いに慣れているインフラ寄りの開発者であればともかく、普段ゲームを作っている開発者からするとハードルが高い。

上記の記事では便利ツールを使ってKubernetesへのデプロイを楽にする方法を紹介したが、やはり小さい環境でのKubernetesは(メンタル的にも)重く、もっと気軽にデプロイできたらいいのにと思っていた。

また、Kubernetesの有無にかかわらずOpen Matchの分散コンポーネント設計は全体の処理の流れを追いづらくしているとも感じていた。 負荷に応じてスケールしやすい利点があると当初は思ったが、整合性を満たすためにSynchronizerは単一であることが求められ、結局そこがボトルネックになっていた。これではコンポーネント分割の利点があまりないのでは?という疑問も湧いてきた。

Open Matchの開発はオープンに行われているため改善の提案やパッチを送ることも考えたが、私が理想とする設計を目指すとすべてを作り直すような形に行き着いてしまったため、minimatchという新規プロジェクトでやることにした。

minimatchの特徴

minimatchは上で述べた難しさを解消すべく、シングルプロセスで動く。 状態を保存するRedisですら1プロセスの中に収まっている(miniredisを使っている)。 よってGoさえ入っていればすぐに実行できる。 Match FunctionやDirectorといったユーザー定義部分はGoの特定interfaceを満たした構造体をフレームワークに渡す形になっている。

その上でOpen Match FrontendとAPI互換性を保っているため、Open Matchを利用するゲームサーバーを一部の環境のみminimatchに差し替えることも可能だ。 より詳しい解説やexamplesはminimatchのREADMEに書いたので気になる方はぜひ。

minimatchのアーキテクチャ

minimatchはAPIこそOpen Match互換だが、内部アーキテクチャはかなり異なっている。

Open Matchでは複数のMatch Function同士が同じチケットを取り、重複したマッチが生じた場合EvaluatorとSynchronizerというコンポーネントを連携させて不整合を解消するが、ここの処理が非常に複雑化している。Sychronizerのコードも読んだが理解が難しかった。

そこで、minimatchでは整合性の取り方をシンプルにした。 Match Functionがチケットを取る前にロックをかけて1つのチケットを同時に1つのFunctionしか取得できないようにした。 ごく普通の悲観ロックである。 早めにロックを取るので、シンプルな分パフォーマンスは悪くなると予想したが、負荷試験を行ったところ意外と問題はなかった(詳細は後述)。

ロックの手法としてはDistributed Locks with Redisを元に実装されたrueidislockを採用した。

miniだけどスケーラブル!?

minimatchは当初の予定ではローカルや小規模な環境に特化した実装だったが、理想を言えば本番環境レベルの負荷でも動いてほしい。 果たしてどれくらいまで耐えられるのかは予想はつかなかったが、ひとまず通常時は1プロセスで動く状態を保ちつつ、Frontend, Backend, Redisを分割可能な設計にした。

1プロセスですべてを賄えることがminimatchの利点のはずなのに、分割したら本末転倒では……とツッコまれそうだが Backend, Query Service, Match Function, Synchronizer, Evaluator, Directorといった機能群をminimatch Backendにまとめているため、本家のOpen Matchに比べればまだシンプルな構成だ。

また、負荷分散に寄与する大きな閃きがあった。 一般的に、典型的なマッチメイキングは次のような流れで進む。

  1. プレイヤーがチケットを作成 (CreateTicket API)
  2. プレイヤーは作成したチケットにマッチ成立情報: Assignmentが付くまで監視 (WatchAssignment API)

このとき、チケットを監視するプレイヤーは作ったチケットのIDに対してマッチ成立情報(Assignment)が付いたかどうかさえわかればよい。 よって TicketとAssignmentを異なるキーに配置することで、チケット作成とマッチ成立状況取得の間で負荷分散が可能 である。 minimatchではこのやり方を採用し、Redisを水平分割しやすくした。

この構成で割と大きめの負荷(1,000〜5,000 matchmaking request/s)を与えてみたところ、マッチング成立時間は中央値200ms未満で安定した。 ランダムに1vs1のチケットを作る単純なシナリオでの試験ではあるが、minimatchはminiなのに意外とスケール可能性も秘めていることが示せただろう。

このあたりの話は具体的な実装も添えてScalable minimatch にまとめている。

まとめ

本記事ではOpen Matchから感じた課題を解消したminimatchの開発の経緯と性能を解説した。 minimatchはまだ生まれたばかりで具体的なゲームタイトルでの採用実績はまだないため、バージョンも v1 に達していない。 しかし、Open Matchと比べてシンプルなアーキテクチャでコード量も少ないので、軽量なマッチメイキングのライブラリを探している方にはちょうどいいかもしれない。Open Matchに倣ってコードもApache License 2.0で公開しており、Issueやパッチも歓迎している。

また、Open Matchのダメ出しのような内容が多くなってしまったが、API DesignはOpen Matchを参考にしているし、 Open Matchがなければ作る発想にすら至らなかったと思うので大変感謝している。 性能や設計の良し悪しにかかわらず、こういった製品をApache Licenseで公開してくれたGoogle(とUnity?)の開発者にも多大なる感謝を。