なぜLaravel×水平分散は難しいのか? 水平分散を導入するための仕組みと実装

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

Laravelで水平分散への対応

木村竜氏:それでは第2章としまして、「Laravelで水平分散への対応」を始めていきます。

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

はじめに水平分散する際の実装方針なんですが、Laravelで水平分散がなぜ困難なのかを確認していきます。おそらく、ここに来て下さった方々の多くはEloquentで水平分散をどうやって実装したらいいのかに興味を持っているのではないかと考えています。

このような疑問は、それが困難だと感じているから来られていると思います。その困難さについては、この後の議論のために整理していきます。

次に、「困難を解決するための実装方針」と題していますが、今こちらで出した困難に対し、何を考えどう対応してきたかの実装方針をお見せいたします。

最後にその他課題と解決ですが、こちらは文字通りですね。その他に、水平分散で一般的に問題になるところをどのように対応してきたかをお話をさせていただきます。

まずは、「Laravelで水平分散する際の困難さ」を始めていきます。

なぜLaravelでの水平分散は困難に感じるのかという点なんですが、その理由は水平分散自体の問題とEloquent特有の問題に分けられるかなと考えています。

水平分散自体の問題に関しては、言語に関係なく、水平分散するといろいろな制約がどうしても出てきてしまいます。考えないといけないことが多いところです。

Eloquent特有の問題については、Eloquentを無理なく拡張して水平分散を実装するのはきつそうだなと。あとはpackagistを探しても、Laravelの水平分散をサポートしているパッケージがないなとか、そういった意味合いになります。もちろん、Eloquentを使わないでスクラッチで書く話になれば、「Eloquentだから」という問題については回避できるんですが、Laravelを使う以上はEloquentを使ったほうがいいなと考えました。

この難しさの原因なんですが、情報が足りていないことと、あとは実装の難易度が実際に高かったことが挙げれると思います。

まず情報が足りないところについて確認していきます。Googleでは検索件数を比較してみました。

「MySQLと水平分散」で検索をかけた結果なんですが、こちらで25万件ほどあります。水平分散の話題自体はそこそこありまして、どういうときに何を気を付けなければならないのかという情報は、けっこう手に入りやすい環境かなと思います。

ただ、その情報をどうやってLaravelにソースコードとして落とし込むかという話になると、なかなか情報が見つけられないところがあります。試しにこちらですね。「Laravel 水平分散」というかたちの検索をすると、1万件ほどに落ち込んでしまいます。

(スライドには映っていませんが)4件目にすでに今回の講演のページがヒットしてしまう状況になっていて、少なくとも日本語だとそれぐらい情報が足りていない状況になっています。

実装方針の肝は、仮想ビルダーの存在

次に、実際に難しいという点についてです。Eloquentで水平分散を自然に実装する方法を探したんですけど、これにけっこう苦戦させられました。その難しさの一番の原因が、Eloquentとクエリビルダーが本質的に1コネクションの中の1テーブルとしか表現できない点にあります。

水平分散のモデルは、複数コネクションに分散配置されたテーブルの合算ですね。例えばuser_cardsというテーブルがあったとして、それをDB1、DB2、DB3に分散配置しておいて、それらすべてを合わせたものを1つのテーブルとしてみなすものになります。

コネクション数固定のEloquentとクエリビルダーは、ここらへんが根本的な構造で合致しない問題がありました。そのため、既存の構造に手を加えるだけでは不十分という状態です。自ら新たな構造を作り出していく必要があり、その際にLaravelの中の実装についても熟知していく必要があります。

実際にLaravelのEloquentに関する多くのクラスを……弊社ではオーバーライドして新たなクラスを作り出したり、機能拡張する必要性に迫られました。

この困難は実際に弊社で立てた方針について、「困難を解決するための実装方針」と題して始めていきます。

まず、コネクションの概念を拡張しました。水平分散したデータベースの集合全体への接続を表現する仮想コネクションと、単一DBへの接続を表現した物理コネクションで概念を分けました。そして、ビルダーも同様に概念を拡張して、仮想ビルダーと物理ビルダーを分けて考えるようにしました。

こちらもイメージを図にしてみました。

まず物理コネクションは、このように各水平分散された各DBへの接続情報の一つひとつを表現しています。

そして仮想コネクションは、これらの複数のコネクションを抽象的に集合させただけの意味になります。仮想ビルダーは、これをテーブル単位に置き換えたものです。

この実装方針の肝は、仮想ビルダーの存在です。これにより、ひとまず複数コネクションに分散配置された同じテーブルのモデルを仮想ビルダーで表現できるようになりました。この方針であれば、ひとまずソースコードと実態のモデルが乖離して崩壊しないのかなと考えたので、これを起点に実装を考えることにしました。

クエリ解決機能の是非

次に仮想ビルダーのユースケースの責任範囲を考えます。これからお話することは、のちに間違いだと判明するのですが、自然に考えて既存のEloquentと同等のインターフェースを備えるべきかなと。物理ビルダーに切り出しましたが、仮想ビルダーもそれと同じだけのインターフェースを備えるべきだと。となれば、クエリの発行は当然できるべきと考えました。

その後、このクエリの発行を行うためには分散DBで……それぞれ操作を分解して発行する必要があるなと、すぐに気が付きました。

例えば、ユーザID2、3、4のレコードを取得してくるクエリがあったときに、ユーザID4はDB1に問い合わせ、ユーザID2と3はDB2に問い合わせる操作の分解が発生して、さらにそれらの結果をマージするかたちです。水平分散したテーブルに対して何らかのクエリを投げようとすると、操作の分解とそれらの操作を実施するのと、それらを実施したあとにマージするような3つの操作が必要になってきます。

こうしたもともとのクエリを分解、実行、マージする一連の操作を「クエリ解決機能」と呼ぶことにしました。

先ほどの仮想ビルダーの話に戻して言い方を変えると、クエリ解決の機能は仮想ビルダーに持たせるべきかという命題になります。素直に考えると、そうあるべきとも感じるんですが、物理的な制約からlimit-offsetなどの機能はどうあがいても使えません。

また、バッチ処理では対象のデータベースを分解して選択する工程のあと、分解したデータベースにクエリを実行する工程に関しては、並列に処理をしたくなるはずです。その場合は、クエリ解決の中で行われる分解、操作、マージを1つの関数の中でアトミックに処理をしようと考えるのが、そもそも無理があるんじゃないかなと考えました。

したがって、こちら簡易的なクエリビルダーにそのようなインターフェースを持たせるのではなくて、ユースケースとして別のクラスに切り出したほうがいいと考えました。というわけで、仮想ビルダーにクエリ解決の機能を付けるのは間違いだと気が付きました。

「クエリポリシー」の実装

それでは、仮想ビルダーのモデルを立てたけど、これは結局ユースケースとしてどこまで使えるのかに注目しました。結論から申し上げますと、仮想ビルダーは、配下の物理ビルダーを目的に応じて取得させる。「番号1番のデータベースのビルダーはどれですか?」「すべての階下の物理ビルダーをください」などの関数を定義するに留めました。

一方でクエリ解決に関してはユースケースを表現するサービスとして、「クエリポリシー」と命名し実装することにしました。

このクエリポリシークラスは、ビルダーをラッピングしてクエリ解決をしながらCRUDを実行してくれるものになっています。これに関しても、どのような動作を行うか、selectに関してのみですが簡単に説明します。

まずクエリポリシーのオブジェクトを生成し、そこに引数でビルダーを渡します。これによって、まずクエリポリシーというオブジェクトが取得操作の準備段階が完了します。

次にこのクエリポリシーに対してselect系の命令を実行すると、内部で採番番号ごとにキーがグルーピングされていきます。採番番号とは、DB1やDB2といった接続先を示す番号のことです。採番キーとは、ここではユーザIDのことですね。分散の起点となるIDのことです。

その後、分散された採番ごとにクエリを実行し、最後にそれぞれのクエリ結果をマージして返却。このような機能となっています。

うれしいことに、このような実装にすると引数に渡すビルダーが、物理ビルダーであっても、仮想ビルダーであっても、うまく動くように中を実装することができます。

この部分で採番キーのグルーピングを何気なく行っているんですけど、採番キーの発行も少し深いお話になると思うので、こちらに関しては採番サービスという機能を別途作成し、それを利用しています。その採番サービスについて、これから説明します。

まず採番サービスの実装方針ですが、ユーザIDごとに分散番号を記録する方針にしました。よくある実装としては、ユーザIDの剰余を使って分散番号を決定する、というものがありますが、このやり方をしてしまうとデータベースの追加が柔軟に行えないので、不採用としています。

採番サービスに求められる役割

採番サービスには、次のような機能が求められます。

まず1つ目は普通の機能なんですが、与えられたキーに基づいて採番番号を発行する機能ですね。これはユーザID4などを引数にすると「あなたは採番2ですよ」と記録して、そのことを返す機能です。これはユーザの作成時に利用します。

次に「与えられた分散キー一覧に基づいて、採番ごとに分割する」と書いてあるんですが、例えばユーザID1、2、3、4を引数にすると、ユーザID1が採番1、採番2はユーザID2、3、4だといったかたちで、採番ごとに分解してくれる機能です。

これが先ほどのデータベースの選択というところで用いられているものでして、ほぼすべてのAPIで毎回使うことになるため、ここの機能に関してはDBへのアクセス負荷を減らすことが求められます。Memcached::getMultiなどを駆使して、各キーを一度に取ってこれるようにして、極力キャッシュ解決できるようにしておくべきです。

といったことで、クエリポリシーという機能も作ってクエリ解決は簡単にできるようになりました。ただ、実際のプロダクトではこのクエリ解決機能、毎回ビルダーを突っ込んむ使い方をするのは面倒くさいので、もう1段踏み込んでリポジトリにこのクエリポリシーを利用する共通の関数を用意しました。そちらの具体例はこちらになります。

このコードはあんまり現実的な内容ではないんですけど、複数のユーザから何かお気に入り系などのロック済みのカード一覧を得る内容になっています。こちらで第1引数で絞り込む対象のユーザIDの一覧を設定して、第2引数がクロージャーで、各データベースで発行するクエリ内容を記述しています。このようにすることで、ビルダーの柔軟さと、水平分散の恩恵を同時に得ることができます。

先ほども申し上げましたが、内部で動いているクエリポリシーは、仮想ビルダーであっても物理ビルダーであっても動くようになっています。なので、こちらLaravelのEloquentモデルクラスのコネクションという設定項目がありますけど、あちらのコネクションの設定項目は水平分散しているデータベースのものであろうと、水平分散していないデータベースのものであろうとも、このままのソースコードで動くようになっています。

といったところで、着地点としては、このようにプロダクトコード側で水平分散があるかどうかを意識せずに、統一的に記述できるところまで持っていくことができました。

といったところで、「困難を解決するための実装方針」については終了いたします。

IDの一意性を担保する必要性

最後に「その他課題と解決」を始めていきます。ひとまずリストにすると、これだけの課題を解決する必要に直面しました。

これらの課題の内容を1つずつ説明していきます。

まずIDの一意性の担保というところです。この課題はどういうことかと言うと、すでにサービスの中ではエンティティの同一性チェックを走らせていて、これはIDでチェックする前提でロジックを書いていました。異なるコネクション同士であっても、IDがバッティングするのは非常に問題がありました。

これはauto_increment_incrementとauto_increment_offsetというMySQL、ひいてはAuroraなんですけど、設定項目がありまして、こちらをうまく設定することでバッティングしないようにする ことができました。

例えば、auto_increment_incrementというのを100、auto_increment_offsetの1から100というかたちで各データベースに設定していくと、イメージ的にはIDにサフィックスの0から99がつくようになるというかたちです。

このようなかたちでデータベースが違ければ、IDが異なるということをこのサフィックスを付けることで解決することができるので、難を逃れることができました。

XAトランザクションのコミット

続いて「XAトランザクション、トランザクション発行」と題しています。

まずXAトランザクションという言葉について、念のため定義を確認しておきます。

こちらは、分散したデータベースに、アトミックにトランザクションの変更を反映させるための機能です。どういうことかと言うと、逆にXAトランザクションがない世界を考えると、複数のデータベースに対して同時にトランザクションを走らせて。

それらを順番にコミットしていくときに、あっちのデータベースにはコミットしたけど、こっちのデータベースにコミットしていない瞬間が存在します。この瞬間に何かしらデータベースに不都合が起きれば、そのままデータ不整合に直結してしまう可能性があります。

というわけでデータベースを分割する以上、この問題は絶対に考えなければなりません。

MySQLはこのXAトランザクションの仕組みが備わっているのですが、分離レベルがREPEATABLE-READ、もしくはSERIALIZABLEでないと利用できない制約があります。

弊社はすべて原則としてREAD-COMMITEDを使っていまして、したがってMySQLのXAトランザクションに頼ることができませんでした。なので、原理的にはコミットの処理には問題があって、どうしても部分コミットが発生してしまう状況になってしまいました。

また、コミットだけでなくトランザクションの発行にも問題があります。

それまで弊社ではトランザクションの発行はアプリケーションレイヤーでこのデータベースを更新するかたちで宣言してきたのですが、水平分散をしている場合は、これから具体的にどのデータベースに更新をかけるかが予測が困難です。

もちろん、予測できないからといって、「じゃあ全データベースにトランザクションをとりあえず貼っておこう」というのは当然遅いですし、せっかく分散して減らしたはずのデータベースのコネクションの数がまた増えてしまいます。ここでは、弊社ではこの思想で対応しました。

まず物理コネクションのトランザクションを改善しました。物理コネクションにトランザクションを貼る命令を流した際、実際にはその場ではアクセスをせずに、このあとデータベースに何かしらのselectやupdate文などのデータベースアクセスが走ったときに、初めてそのデータベースへのトランザクションを走らせる遅延解決を実装しています。

次に仮想コネクションにもトランザクションの発行命令を投げられるようにしましたが、これは単に配下の物理コネクションにトランザクションの命令を投げるだけです。先ほど申し上げたように、物理コネクション側は遅延解決をするようになっているので、実際に物理コネクションは待機状態のようなものになります。

このような対策を行うことによって、水平分散がどのテーブルに更新が必要かということをアプリケーションレイヤーで意識する必要はなくなりました。

その後、コミットについてです。こちらはデータベース全体でコミットを行うcommitAll()という関数を用意しました。基本的にはデータベースのコミット前にやるべき作業をすべて完了してから、commitAll()を順番にたたくことで限りなく不具合が発生しにくいかたちにしました。

流れとしては、トランザクション発行中のデータベース1をバルクアップデート、データベース2をバルクアップデート、データベース3をバルクアップデートというかたちで流していって、すべてが終わったらデータベース1をコミット、データベース2をコミット、データベース3をコミット、という流れで実装されています。

ただこちらでは、不意のデータベース接続不良などで部分コミットが発生してしまう恐れがどうしてもあります。残念なんですが、この問題については完全な解決は結局できませんでした。こういった場合にはログを残すようなかたちで対応をして、いざ本当にそういった現象が起きてしまった時には、手動で対応を行うかたちにしました。

ただこちらは、実際に運用しているアプリケーションでまだ一度も観測したことがなくて、発生頻度はかなり低いと考えてもよいんじゃないかと思います。

バッチ処理における分散処理

続いて、「バッチ処理における分散処理」を始めていきたいと思います。

バッチ処理では、機械的にデータベースの値を書き換えるために、DBの負荷による操作が大きいです。その際に、水平分散したDB1に変更を適用してからDB2に変更を適用して……と、直列に処理をしていくのは非効率です。こちらは並列で実装したいはずです。幸いなことに分散処理を行うライブラリ自体はpackagistにたくさん公開されていますので、それらのどれかを使って実装すると良いです。

弊社ではこちらのライブラリを用いて開発しました。

ここからは、弊社で実装した分散コマンドクラスの規定実装を一部お見せしますので、実装の参考にしていただければなと思います。

まずメインとなるハンドル関数なんですが、このような作りになっています。この辺の使い方に関しては、Worker Poolライブラリの使い方の範疇になるので、スライドは後々公開予定ですので、後ほどゆっくりご確認いただければなと思います。特質点もないので、これらの中をもう少し深堀します。

こちらのコメント順に確認していきます。

こちらはgetWorkerPoolSize()と書いてあるんですけど、継承先でこのコマンドクラスは何プロセス同時に横に動けるかというところを指定しています。次にmakeWorker()関数ですが、並列処理を用いるWorkerオブジェクトをここで指定しています。Workerオブジェクトは何かという話なんですけど、1プロセスあたりに動くクラスだと考えていただければと思います。

次にtaskGenerator()についてなんですけども、このコマンドで実装するすべてのタスクの生成を行います。タスクというのは1Wokerあたりの引数と思っていただければと思います。Wokerは基本的に1プロセス立ち上がるんですけども、そのプロセスが何度も何度も使い回されて、タスクを順次処理していくといった仕組みになっています。

このタスクに物理ビルダーを引数にして渡してあげれば、データベースの水平分散の分散処理、並列処理が実行できると考えました。その具体例がこちらになります。

まずタスク生成のためにiteratorクラスを用意しました。分散の種類に応じて適切なiteratorクラスを用意してあげると、なかなか便利です。今回はビルダーごとに発生させるためにuser_presentboxesと指定しているんですけど、これの意味合いとしては、user_presentboxesは水平分散されているので、その分散された配下の物理ビルダーというのを順次イテレーションすると、そういった実装になっています。

あと分散処理をする際の注意点になるんですけど、PDOなどのリソース型を内部に持つクラスについては、シリアライズ可能なかたちでプロセス間を受け渡す必要があることだけは気を付けてください。これをうっかり忘れるとSegmentation faultが返ってくることになります。コマンドの分散処理については以上となります。

マイグレーション処理の実行

最後にマイグレーションについてですね。まずLaravelに実装されている通常のマイグレーションで、水平分散されたテーブルを用意する方法を考えます。

例えば最初から4台、分散されたデータベースでマイグレーションするには、このような実装を考えることができます。分散しているすべてのデータベースに、マイグレーションをループして流すというものですね。

ただ、この方法だといくつか問題点が出てきてしまいます。まず水平分散DB追加時のマイグレーション問題ですね。先ほど4台のマイグレーションを実施しました。その本番環境に対して、水平分散台数をやはり8台にするとき、どうなるでしょうかと。

インフラでデータベースを用意したのはいいんですけど、初期状態となるテーブルを追加するのは、通常のマイグレーションではもうできません。マイグレーションは、マイグレーションファイル単位で実行が記録されてしまっているために、すでに実行済みのマイグレーションデータはもう起動しません。

結局、手動で空のcreate文の実行を行ったり、あるいはインフラの仕組み次第ですが、何かしらコピーして中のレコードを消すなどの対処を行う必要が出てきてしまいます。

次にalterテーブルの直列実行問題ですね。水平分散をしていないにしろ、Laravelのマイグレーションというのは、時間の掛かるalterテーブルをもし複数テーブルで実行をしてしまった場合、結局Laravelのマイグレーションはクエリが直列に実行されるために、それぞれの総実行時間が加算されて本当に全部が終わるのに時間がかかってしまう問題点を抱えています。

水平分散を始めると、この問題はさらに顕著になりまして、例えば100テーブル分散しているテーブルにalterテーブルをかけるとなると、その100テーブルをすべて直列に実行してすべて待つ状態になってしまいます。

この問題に対処するために、このような機能を用意いたしました。

これは自作のマイグレーションになります。まず、マイグレーションのバージョン管理を各DBごとに行うようにしました。このかたちであれば、後からDBが追加されても、そのDBのバージョン管理は独立しているので、問題なく1からマイグレーションしてくれるはずだという話です。

次に、並列処理も実装しました。これにより、メンテナンスはありきなんですけど、現実的な時間でalterテーブルを完了できるようになったテーブルが多くなりました。これも少しだけ、具体的な実装をお見せします。

まず、マイグレーションフォルダの構成ですが、通常のLaravelのものとは独立させます。弊社では、このような構成にしました。肝心なのは、各マイグレーションファイルがどのテーブルに関するものなのかと、どの順序で実施すべきなのかが、ファイル名やファイルのアドレスから導けることです。

次に、各マイグレーションファイルの中身はこのようなかたちになっているとします。

このBlueprintのところに関しては、通常のLaravelのものを使い回しています。私が実装したのは、この外側の部分でして、こちらは右の画像で用いているgetSchemaBuilder()やetConnection()というような関数がthisで定義されているんですけど、この関数は、どのデータベースに接続するかまで決定しきった物理ビルダー、物理コネクションが返ってきます。

なので、マイグレーションファイル中は、これはDB1を今やっているんだなとか、DB2をすでにやっているんだなとか、といったことを気にする必要は一切ないです。

次に、マイグレーションコマンドの分散部分の実装です。1コネクション1テーブルを1プロセスとして起動しています。プロセス内で、先ほどのリビジョン番号09から始まって10、11という順番でマイグレーションを実行していっています。

当然10まで実行済みであれば、11だけ実行する。そういったような判断をして、各DBごとにマイグレーションを行っていっています。これで無事に分散することができました。解決してきた課題については、以上になります。

Laravelはたいていのことは対処できる

第2章の内容についてまとめていきます。Laravelで水平分散を行うために満たしたい要件と、それに伴って発生するさまざまな問題を解決してきました。残念ながら今回の対応方法だと、Eloquentの利用方法が変わってきてしまうため、すでに運用中だった、開発後期に入ってきているプロジェクトでは、このような移行作業を行うのはもう困難です。

最低限、リポジトリにあたるような「クエリビルダーの直接利用をしないで、どこかで制限しているよ」という形になっていないと厳しいかなと考えています。なので、これからLaravelを使って開発する方は、ぜひあらかじめ水平分散のことも考えて対応することを検討していただければなと考えてます。

今回の講演は以上なんですが、最後にいくつか述べさせていただきます。

今回懸念事項の払拭で、さまざまな問題点への対処事例というのを紹介させていただきました。Laravelはたいていのことは対処できます。Eloquentに水平分散を実施するところもできました。Laravelは自分でレールを考えて行く必要がある分、自由でなんでも作れるところが良いのかなと考えます。

自由でなんでも書けるんですけど、メインで扱わなかったんですがDIや各種サービス、SQS、fluentdやSentryなど、そういったコードのインフラとなる部分の機能については、すでに数多く用意されています。

なので、今回はEloquentとDBの最適化やアクセスの拡張のお話ばかりになってしまったんですけど、逆にこの部分さえ乗り切れれば、その豊富な機能性で開発が加速することは間違いないと思います!

なのでLaravelを使って開発する方は、ぜひそこを意識していただければと思います。以上になります。ご清聴ありがとうございました。

(会場拍手)