LoginSignup
7
1

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-12-10

 本記事は、サムザップ 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にしてしまえば解消しますが、やはりパフォーマンスに問題があります。またクエリキャッシュと組み合わせるとさらに複雑になります。そこで以下のようにしてみました。

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

図1
fig1.fw.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 さんの記事です。

7
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
1