Lumenを実用的に使うあれやこれや(シャーディング、Writeの遅延対策)

　本記事は、[サムザップ Advent Calendar 2019 #1](https://qiita.com/advent-calendar/2019/sumzap1) の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にしてしまえば解消しますが、やはりパフォーマンスに問題があります。またクエリキャッシュと組み合わせるとさらに複雑になります。そこで以下のようにしてみました。

1. 値の読み込みはキャッシュを対象とし、そこになければDBから読み込む
2. 値の書き込みはキャッシュに行い、書き込みリストに入れておいて、この時点では書き込まない
3. Controllerの最後でDB書き込みを実行する

図1
![fig1.fw.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270481/b09de458-2cae-a2e8-d0ef-4d94df6f8fd2.png)


　この仕組みを「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 さんの記事です。