本記事は、サムザップ Advent Calendar 2019 #1 の12/10の記事です。
私のチームでは、サーバ側のフレームワークとして「Lumen」を使用しています。私は3年ぐらい前から「LumenかわいいよLumen」とずっと愛でてたのですが、いざ実用にしようとするといろいろハードルがありました。それらを紹介できればと思います。
■Lumenとは?
Lumenは、Laravelという現在人気のフレームワークからいろいろそぎ落として作られたマイクロフレームワークです。
PHPで出来ていて、Laravelより速度は速いぶん、Laravelの機能も一部はあとから足せたり、DBのORMとしてEloquantも使えるという、マイクロフレームワークにしてはなかなか便利なものに仕上がっています。
Laravel直系ということもあり、ドキュメントも豊富なLaravelのを読めば、ある程度理解できるのもよいとこかなと思っています。
■Lumenにデフォルトであるものとないもの
弊社のゲームはなかなか高負荷になりがちで、負荷対策を施すのが必須になっています。また、チーム開発であり、コンテナでの開発も進めています。
その視点ではLumenは「持ってるもの」「持ってないもの」があります。
・持っているもの
データベースのRead/Writeの参照先を分ける機能
マイグレーション
各種キャッシュ
・持ってないもの
データベースの水平分割(シャーディング)
データベースのRead/Writeを分けることによる書き込み遅延対策
速いコレクション(Ver. 6.0以降の遅延評価がついて良くなりました)
■Depotによる遅延書き込みとRead/Writeの書き込み遅延対応
Lumenはデフォルトで、Read/WriteのDBアクセスを分ける機能があります。ただ、書き込みにはやや時間がかかり、素のままでは、すぐ読み込みを走らせると、更新されたデータになりません。いくら高性能なAuroraでもこの現象はおきます。
読み書きを同じDBにしてしまえば解消しますが、やはりパフォーマンスに問題があります。またクエリキャッシュと組み合わせるとさらに複雑になります。そこで以下のようにしてみました。
- 値の読み込みはキャッシュを対象とし、そこになければDBから読み込む
- 値の書き込みはキャッシュに行い、書き込みリストに入れておいて、この時点では書き込まない
- Controllerの最後でDB書き込みを実行する
この仕組みを「Depot」と呼ぶことにしました。リクエストが来たら「Controller」「Library」「Depot」「Model」という順に呼ばれてデータをもらえます。このとき「Library」から「Model」を呼び出すのを原則禁止しています。
キャッシュについてはapcを使い、格納するリストには素直にLarabelのCollectionクラスを使いました。ただやはり重いので、whereとかは使わず、検索はforeachで済ませるようにしました。
いろいろやってこういう形に落ち着いたのですが、それでもアクセスが連続するAPIがあるときは、やはり古いものが読まれる現象がありました。そのときはトランザクションをかけて、読み書きを同一のDBにしたり、APIをまとめたりしました。
この状態で負荷試験をかけたところ、かなりよい成績が出せてよかったです。またコード的にはモデルにいろいろ書かずに済み、すっきりした感じはします。
■Lumenでの水平分割
あまり資料がない部分で、フレームワークにないところです。みんなどうしているんでしょうね?
いくつかの実装が公開されているのですが、以下の点でどうしたものかなと思い、独自に実装しました。
・カードや武器は、強化などでユーザーが指定して使うため、ユニークなIDを持たせたい。とするとこのIDにシャード情報がないと、DBアクセスに困る
・ユーザーIDでシャーディングしたい。このユーザーIDはアプリでユニークにしたい
・ユーザーIDは通信に乗せない。シャーディングは内部で完結しているので、アプリからのアクセスで特定する何かしらが必要
この処理もDepotで吸収しようかと思ってましたが、結局モデルでの分割となりました。
まずユーザーIDとは「ユニークID」+「シャーディング情報(テーブルやDBの番号)」の2つの組み合わせで実現しています。これはLumenのアクセサを使って、呼び出し側から見るとユニークなIDが割り当てられているように見せかけています。
クライアントからはUserKeyと呼ばれるワンタイムパスワード的なものが送られてくるので、それとユーザーIDを紐づいたものをRedisのキャッシュに乗せておきます。
ユーザーIDがわかったところで、ベースモデルクラスでは、地道に呼ばれるメソッド(saveやwhere)ごとに、このIDからシャーディング先を得て処理しています。
saveの場合はこんな感じです。
public function save(array $options = [])
{
foreach ($this->sharding_type_list as $type) {
$key_name = $type . '_shard_key';
if ($this->$key_name === null) {
continue;
}
foreach ($this->$key_name as $column => $method) {
$shard_key = $this->getAttributeFromArray($column) ?? null;
if ($shard_key === null) {
continue;
}
$this->setShardedParameter($type, $method, $shard_key);
break;
}
}
return parent::save($options);
}
protected function setShardedParameter(string $type, string $method, $key)
{
$shard_id = $this->$method($key);
if ($shard_id === null) {
throw new Exception('Error!! Invalid Query!!');
}
switch ($type) {
case 'dns':
$this->setConnection(static::RAW_CONNECTION . '_' . $shard_id);
break;
case 'table':
$this->setTable(static::RAW_TABLE . '_' . $shard_id);
break;
default:
throw new Exception('Error!! Invalid Sharding Type!!');
}
}
指定されるcolumn(attribute)にuser_idが入っている想定になっており、setShardedParameter()で、それをもとにシャーディング先を決定しています。
configにもDBとテーブルぶんの設定を入れていて、これは各クラスのconstructorで取得し、そのあとの処理ですぐ使えるようにしています。
constructorでシャーディング先を決めているのではなく、各処理のメソッドでシャーディング先を決定しているのがミソかと思います。この場合、つなげて書けない(where()->where()->…みたいな)のですが、そこは一律ナシってルールにして割り切っています(もしかしたら解消される見込み)。
■水平分割したときのマイグレーション
コンテナ化しているので、マイグレーションも水平分割に対応する必要があります。
Lumenにはマイグレーションの仕組みがあるので、それを利用しています。
UserBase.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
abstract class UsersBase extends Migration
{
protected $operation = 'create';
protected $table_name = null;
protected $shard_count = 0;
public function __construct()
{
$config = config('database.db_shard');
$this->shard_count = $config['table_shard_count'];
}
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$count = 0;
while ($count < $this->shard_count) {
Schema::{$this->operation}(
$this->table_name . '_' . $count,
function (Blueprint $table) {
$this->setSchema($table);
}
);
++$count;
}
}
abstract protected function setSchema(Blueprint $table);
}
2019_10_17_162227_create_user_profile_table.php
use Illuminate\Database\Schema\Blueprint;
class CreateUserProfileTable extends UsersBase
{
protected $table_name = 'user_profile';
protected function setSchema(Blueprint $table)
{
$table->bigInteger('id', true);
$table->bigInteger('user_id')->index('idx_user_id');
$table->string('profile', 256);
$table->timestamps();
$table->softDeletes();
}
}
UsersBaseというベースクラスを用意してやり、そこでsetSchema()というテーブルを作るメソッドをテーブル分割数分呼び出している感じです。
デプロイのスクリプトでは、これを各DBに対して行うようにしています。
■まとめ
いろいろ実用面で手を加えることになったLumenですが、今後は使用するプロダクトが増えていくかと思います。
本記事がその一助となり、これを機に「LumenかわいいよLumen」と皆さんも愛でてもらえたらうれしいです。
明日は @kida_hironari さんの記事です。