Laravelを活用したゲームサーバーチューニング 通信時間100ms以内を実現するための工夫

Laravelを用いたゲームサーバーのチューニング #1/2 >> 2はこちら

Laravel開発における勘所

木村竜氏:ナウプロダクションの木村竜です。よろしくお願いいたします。

(会場拍手)

本日はLaravelの懸念事項の払拭をメインで話させていただきます。Laravelのどんな機能が便利か、などの側面についてのお話はあまりないんですけど、その点についてはご了承ください。

本日の話の流れなんですが、まず簡単に私やLaravelについての紹介をさせていただきます。その後、Laravelを高速で動作させるために行ってきた事例の紹介。Laravelで水平分散をさせるために行ってきた事例の紹介。このような順番で行わせていただきます。

それではさっそく始めさせていただきます。まず私自身は5年前に新卒としてナウプロダクションに入社いたしまして、それからずっとサーバサイドのPHPエンジニアとしてゲーム開発を行ってきています。ナウプロダクションは受託メインのゲーム開発会社です。

私は東京スタジオで働いておりまして、そこではスマートフォン向けゲームの開発を積極的に行っております。2年半ほど前から、Laravelに注目しまして、以降はずっとLaravelを使ってきています。社内では、このLaravelを用いた内製フレームワークの開発を行っておりまして、プロダクトに参加しつつ反省点を見つけては、日々フレームワークの改良を励んでおります。

自己紹介はそれぐらいにして、Laravelについての紹介です。Laravelについて簡単にどういったものかを紹介させていただくんですが、その前に、みなさんすでに業務でLaravelを利用している方はいらっしゃいますでしょうか?

(会場挙手)

6人くらいですね。ありがとうございます。意外に少ないかなと思ったんですけど、今回、もしフレームワークの選定で迷っている方であれば、今回の講演で選定の参考にしていただければと思います。

Laravelを用いられて、Laravelを使って開発されている方に関しては、Laravelを利用する際でつまづきがちなポイントを今回説明していきますので、ぜひノウハウを持ち帰っていただければと思います。

もっとも勢いのあるPHPフレームワーク

というわけでお話を戻しますが、LaravelはPHPフレームワークの一種で、Packagistで公開されています。Laravelだけの利用であれば、こちらはMITライセンスなので誰でも利用することができます。PHPフレームワークに「Symfony」がありますが、Laravelはこれをベースにさらに機能強化を行ったものとなります。

また、世間的にも今もっとも勢いのあるフレームワークとなっております。

スライドはGoogleトレンドにて、日本国内の検索状況を調査した図です。2016年11月頃からCakePHPを超えて、他のフレームワークをどんどん圧倒している状況です。みなさんから愛されている証拠なのかなと。

さて、Laravelについて私自身が感じている強みを挙げますと、この3点がございます。

1つは自由度の高さについてですが、Laravelのコードは、それ自体はDIを用いて開発されていて、実装の差し替えに寛容です。そのため、部分的に中の仕組みを差し替えることも可能です。また、コードディレクトリの構成にもとくに強い縛りがないので、プロダクト固有のルールや構成を導入することも可能です。

私は最近、ドメイン駆動開発がLaravelと相性が良いとうかがいまして、Laravelでドメイン駆動開発をどうやったら入れられるかを勉強中です。

次にDI機能の強さですが、LaravelではDIに関連する機能がたくさん用意されています。パフォーマンスを下げないための工夫もこなされています。

最後の洗練されたコード群についてなんですが、これはLaravelを使い始めた頃の自分にとって、とくに有意義なものでした。性能面では至らない機能もあるんですが、提供されているインターフェースは非常に使い勝手の良いものが多いです。プログラマとして経験が浅い方がおられるなら、こちらを手本に開発を進めるとコーディングのコツを学べると考えてます。

これらの強みを一言で言うのであったら、「簡単に書けてなんでもできる」ところです。ただ、なんでもできる分、敷かれているレールは弱めになっております。自分で工夫しなければ、簡単に性能が落ちてしまいます。よく「Laravelは、フルスタックフレームワークだ!」と言われているんですが、自分でレールを作らなくていいわけではないです。

ここからLaravelの誤解が生まれてきます。よくある誤解が、「ORMは遅いよね」というものです。ORMとうまく付き合うためのレールはLaravelには用意されていません。

公式ドキュメント通りにやれば、間違いないと考えがちなんですけど、これは誤りです。Laravelは自分でよく考えてレールを作っていく必要があります。

まとめると、このようになります。

Laravelは自由で書きやすい分、自分でレールを考えていく必要がある。そういったフレームワークと覚えていただければと思います。簡単ですが、Laravelの紹介は以上になります。このレールを考えていくところについては、この後お話いたします。

通信時間を100msec以内にするための工夫

ということで第1章。「Laravelの通信時間を100msec以内を達成するための工夫」を始めていきます。

こちらの章の流れについて説明します。

始めに、通信時間100msec以内の定義ですが、みなさんがもし今後Laravelの性能測定をする際に、できるだけ参考にできるよう、目標値をもう少し具体的に書かせていただきます。性能なども併せて書かせていただきます。

次に「Laravelの起動処理を早くする」についてなんですが、主にサービスプロバイダ、DI関連の話ですね。こちらの初期起動にまつわる問題について紹介いたします。最後に「DBへのアクセス回数を最小にする」ですが、これはEloquentの使い方を誤らないためのエッセンスです。

リポジトリパターンを導入したと記載しているんですが、できるだけみなさんに実践で活かしてもらえるよう、具体的にどのような機能をリポジトリに持たしたのかを、お話しさせていただきます。

ということでまずは、通信時間100msec以内の定義をしていきます。まず、100msec以内を目指すそもそもの動機についてです。これはユーザエクスペリエンスに基づいています。通信時間におけるユーザエクスペリエンスの基準を調べると、100msecが1つのボーダーとされている記事が多くて、ここを乗り切れば「ユーザにサクサク感を味わってもらえるのではないか」と考えて、弊社ではそこを1つの目標をしています。

次に、この数値を達成するための測定方法の定義を行います。今回は、みなさんが性能測定をする際に参考にしやすいよう、できるだけ再現が簡単な状況での目標に置き換えました。

その目標は、AWSのオールインワン環境で、単一APIの実行時間50msecとします。本番はだいたいオールインワンより遅くなるので、本番で100msec出す上での参考にすると良いと思います。

その他の細かい条件については、スライドの通りとします。ただし、これでもインフラ要件次第では、本番で100msecを超えてしまう点についてはご了承ください。AWSのマルチAZ構成を使うとAZ間のネットワークシーンで数十msecレベルでのオーダーの遅延がざらに発生します。

こちら数値なんですけど、この条件のもと、直近で開発中のとあるプロダクトでの性能試験を表しています。一部表現をぼかしているんですけど、スマートフォンゲームでありがちな通信の結果となっておりますね。だいたいのAPIが30〜50msec以内に収まっていることがこちらから確認できると思います。

初期起動を速くするには?

続きまして、「Laravelの起動処理を速くする」を始めていきます。まずこのお話の前提となる知識であるDIと、ServiceProvider(サービスプロバイダ)という言葉の説明をさせていただきます。

DIとは「Dependency Injection」の略語であり、ざっくり説明すると、クラス間およびオブジェクト間の依存関係を外側からコントロールする仕組みのことです。そして、ServiceProviderとは、DIのために用いられるLaravelの機能です。DIの外側から依存解決の部分を実装するために用いられます。

言葉だけだとどういうことかがピンとこないと思うので、ServiceProviderの動作をイメージ図にしました。

まずここのmakeなんですが、これは大雑把に言ってnewの拡張です。newといえば、クラス名を指定してそのクラスのオブジェクトを生成する言語構造です。

一方で、これをmakeに置き換えた場合にどうなるかと言うと、事前にServiceProviderに登録しておいたルールに基づいて、オブジェクトを生成してくれる仕組みとなっています。つまりmakeというのは、どこからnewを呼び出して、どういう拡張がされたのか、オブジェクトが生成されてから実際返されるまでの間に、プログラマが都合のいいシナリオを仕込んでおくことができる。そういった拡張になっています。

この強みとして、外から依存関係を操作して部分的にコード改修がしやすい点が挙げられます。Laravelが実装の差し替えに寛容なのは、この機能をうまく利用しているからです。

例えばこの図を見てください。

ClassA自体はフレームワークのコードなので、簡単に手出しができない状況とします。また、ClassBを拡張したClassB`をClassAに利用してもらいたい。そういった状況を考えます。

このような場合でもServiceProviderを用意して、依存関係の上書きをするだけで、簡単にClassB`を使わせることができるようになります。

他にもメリットがいくつかあります。

今回はDIがメインのお話じゃないので、ひとまずこのあたりで説明を切り上げますが、とにかくLaravelを使う場合は、このDIの利用が基本的なものになってきます。とはいえ、DIを使い過ぎて、あまりServiceProviderの仕事が増えても大変なことになってしまいます。

仕事量が増えたServiceProviderは、この場合ですとClass1だけがほしい状況なんですが、処理に関係のないClassA、B、C、Dといったものも読み込んでしまっています。この調子でどんどん読み込み数が多くなれば、どんどん処理が重くなっていってしまいます。

さらにServiceProviderの起動は、すべてのAPIの実行前に実施されるということなので、このままではすべてのAPIが遅くなってしまう。こういった問題を抱えています。

この問題を解決するための機能がLaravelに用意されていて、それが遅延ServiceProviderになります。

これはServiceProviderが行っている登録の処理そのものですね。必要になったときに遅延解決する仕組みになっています。

先ほどの例も、遅延ServiceProviderを利用したかたちに修正いたしました。このようなかたちでClass1を利用しても、ServiceProviderAは起動しません。このようにパッケージごとに切り分けますと、ServiceProviderの実行負荷は一定以下に抑えることが可能になります。

こちらの改善例なんですけど、弊社で開発しているフレームワークは、最初の1年半ほどは、このことを意識できておらず、すべてのサービスを愚直に読み込んでしまっていたんですね。

このことを知って、遅延ServiceProviderを用いて適切に切り分けることで、読み込みファイル数を平均100ファイルほど減らした上で、さらに中でいろいろな処理を行っていたので、そういう処理も省けた結果として、6msecほどの高速化に成功いたしました。

といったところで、「Laravelの初期起動を速くする」についての説明は以上になります。

DBへの問い合わせ回数を減らす

次です。次に「DBへのアクセス回数を最小にする」というところなんですが、その前にLaravelの前提知識として「Eloquent」について紹介いたします。

Eloquentは、データベースアクセスのORMで、1テーブルあたり1クラスずつ定義して使います。1クラスでありながら、クエリビルダーとしての振る舞いと、レコード。つまりエンティティとしての振る舞いを併せ持っています。

クエリビルダーとしての振る舞いと、レコードとしての振る舞いを書き出しましたが、つまり、MySQLのクエリ文を作り出す能力と、その結果をEloquentオブジェクトのコレクションで返すと。レコードとして振る舞うので、レコードの集合はEloquentオブジェクトのコレクションで表現される。そういったものになっています。

ここで触れているエンティティについてなんですけども、少し注釈をいたします。

エンティティはドメイン駆動開発的のところの、ドメイン層のエンティティのことではありません。テーブルと密結合するこれらのクラスは、インフラストラクチャ層のエンティティにあたります。

ドメイン駆動開発をよく知っていて、もし「あれ?」と感じる方がいらっしゃるかもしれないんですけど、今回のお話はあくまでインフラストラクチャ層のお話と考えていただければと思います。

さて、その中でもEloquentはこのような機能を持っているのですが、クエリビルダーとしての機能がむき出しなので、素直にこちらを使い始めてしまうと危険です。

例えば、ビジネスロジックのループ文でクエリビルダーを直接使うといったシナリオを考えてしまうと、当然すぐにクエリ回数が爆発してしまって、重たいアプリケーションの完成となってしまいます。

そこで、ドメイン駆動開発でいうところのリポジトリを導入いたしました。

ゲームのビジネスロジックのテーブル参照は、すべてリポジトリ経由と約束することで、データベースの操作をカプセル化しました。つまり、クエリ結果をキャッシュして、DBへの問い合わせ回数を減らすことを内部に仕込むようにしました。

念のため補足しますが、こちらはあくまでゲームの世界でのお話です。CMSからの複雑な問い合わせやバッチ処理においては、Eloquentの直接利用を認めています。

selectを最小限にする

リポジトリとは内部でキャッシュすることの説明です。具体的な役割がどういったものになるのかをスライドに書いています。

まず、「selectを最小限にする」についてです。リポジトリ内のライフサイクルをこちらに図式化いたしました。

まずこちらのfetch()関数なんですけど、これはデータベースからの取得と、取得したデータをメモリ内にマッピングする役割を持っています。このマッピングしたデータのことを、今はひとまずエンティティコレクションと呼ぶことにします。

こちらで一度マッピングした後、2度目以降の呼び出しに関しては、マッピング済みのエンティティコレクションを返すかたちで、2度目以降に関しては、再度DBへの問い合わせをしないと、そういった役割になっています。

次にget()やfind()、count()ですが、これはサービスにパブリックに公開される取得関数群のことです。内部的には、こちらはfetch()関数を用いて取得されていまして、2度目以降の問い合わせに関してはDB接続なしということが自動的に達成されるようになります。

また、こちらの取得に関しては、O(1)の負荷となるように設計されます。フェッチしてきたデータから、条件に見合うデータだけを絞り込んできて、エンティティの配列やエンティティ自身、件数というかたちで返しています。

先ほど、O(1)のお話をしましたが、この絞り込み自体に時間が掛かってしまうといけないので、エンティティコレクションにはインデックスの機能を付けています。例えば、所持している全カードをフェッチしてから、カードID101を取得するような例ですと、初回のエンティティコレクションへのカードID問い合わせのときに、内部的にはカードIDでGROUP BYしておきます。

そのGROUP BYした結果を内部的にキャッシュしておいて、そのあと201や301の問い合わせをO(1)で完了させられるようにしています。

次にinsertについて。

フェッチ済みのエンティティコレクションは、データの変化に合わせて当然更新する必要が出てきます。memchachedや外部ストレージによるキャッシングの場合は、普通はこのエンティティコレクションの塊そのものを消してしまう方法なんですが、それだと当然DBへの問い合わせ回数が増えてしまうので、このような場合にはがんばって内部のコレクションを更新する方針で対応を行います。

deleteについても同様です。データベース側の削除と、エンティティコレクション側の削除を同期させる必要があります。selectを最小にするところに関しては、このように実装しました。

コミット直前にまとめてアップデート

次に、「コミット直前にまとめてアップデートする」についてです。

アイディアとしては、コミット直前までアップデートを待つことで更新の効率が高まるんじゃないかと考えました。なのでアップデート直前まで、いくつかのモデルをローカルにため込んでおいて、それらをまとめてアップデート群に流したらいいんじゃないかと。

さらに、せっかく更新のタイミングを1箇所にまとめておいたので、バルクアップデートをしたらいいんじゃないかと思って、1テーブルあたりの更新回数を1回のみに抑えることができるんじゃないかなと考えました。

ただ、それを思ったんですが、いくら調べてもMySQLにはbulkupdate文なんて存在していなくて、アップデート文は基本的には対象レコードに、まったく同じ値を書き込むということしかできません。バラバラに違う値を入れる場合、結局1行ずつアップデートするしかないんじゃないかなと思ったんですが、こちらに関しては、ELT文とFIELD文を組合せればできます。こちらはバルクアップデートをなんとかしてできないかと調べている途中で、ちょくちょく見かけていたんです。

こういった記事に関しては、見かけている方もいらっしゃると思うんですが、ただ、これは重そうだと思って、避け続けてきました。ただ、こちらは最終手段だと思ってやってみると、けっこう性能はよかったです。このELT文とFIELD文を使うと、このようなbulkupdate文を作ることができます。

こちら、Laravelでは実装されていないので、自前で作る必要があります。重いんじゃないかと思っていたんですけど、数十レコードを更新するようなクエリでも、数ミリsecぐらいに収まります。

単純に1回のクエリで、往復だけでも1msec掛かっている世界であるならば、クエリを往復させるコストに比べれば、はるかに安いのではないかといえます。こちらに関してはLaravelに限らず、ぜひ試してみていただければと思います。

最後に注意点です。クエリ回数はこれで抑えることができるんですが、ビジネスロジック上で取得してから更新して、また取得して……みたいなロジックを発生させてしまうと、2回目以降の取得でデータベースにアクセスすることになった場合、古いデータが取得されてしまうことが実際起こり得ます。

想定していないデータが入り込んでしまったり、あるいは逆に入らないことが発生します。弊社では、安全のために何かしらのクエリをテーブルで実行する場合は、リポジトリに溜め込んでいる更新内容を反映させてから取得するロジックに書き換えました。といったかたちで、コミット直前に一斉反映といったロジックを作る時には、この点にも気を付けていただければと思います。

「DBへのアクセス回数を最小化するために対応してきたこと」については、こちらで以上になります。いったん第1章についての内容をまとめます。

Laravelで通信時間を短くするために、DIとEloquentの利用方法に工夫をこなしてきました。使い始めの頃は、クエリ結果のキャッシングなどをサービス単位で行っていたんですけど、それでも300msecから400msec掛かってしまう状況がざらでした。しかし現在では100msecが実現できるほど高速になりました。

とくにEloquentは初学者でも真っ先に触る部分だと考えられますので、Laravelで速度が出ないと嘆いている方は、まずはEloquentの利用方法を見直してみると良いのではないかなと考えます。