PHP
laravel
Database
Sharding

laravelでシャーディング(sharding)したデータベースに対するアクセスする方法

確認環境

  • PHP 5.4
  • laravel 5.0
  • mariadb 5.5

最初に: データベースのシャーディングとは何を意味しているのか?

この記事では, 同一DB設計の複数DBサーバ群からなるmaster(write)-slave(read)構成のDBネットワーク全体を
さらに複数用意し, 何らかのルールをもとにアクセスユーザを振り分ける手法を, DBのシャーディングと呼びます.

一つのテーブルをレコードの属性ごとに複数テーブルに分割する手法(パーティショニング)や
テーブルをカラムごとに分割して複数DBに配置する手法のことではないのであしからず.

今回想定している構成

以下の図ような構成のDBを扱うことを想定しています.

database.png

ユーザテーブル等, 共通に使いたいものはcommon系のDBに格納し, それ以外のデータは
shard0~shardNのDBに格納します.
アプリケーションにアクセス時にどのshardを使用するかは, ユーザのIDをshardの数で割った余りをもとに
決定するとします.
write-readの間レプリケーションは, 各shard系の中でのみ行うものとします.

実装

laravelを普段使っている人にとっては今更な話かもしれませんが, EloquentModel経由でDBにアクセスする場合は
Modelの$connection属性に指定した接続設定が使用されます.

Illuminate\Database\Eloquent\Model.php
class Model
{
    protected $connection;

    // ...

    public function getConnection() {
        return $this->connection;
    }

    public function setConnection($connnection) {
        $this->connection = $connection;
    }

    // ...
]

ですので, あらかじめすべてのDBの設定を設定ファイルに記述しておき, DBへのアクセス前に$connectuonの
値を変更するだけで接続先を切り替えることは可能です.

app/database.php
'connections' => [
    'common' => [
        'read' => [
            'host' => env(COMMON_READ_DB_HOST),
        ],
        'write' => [
            'host' => env(COMMON_WRITE_DB_HOST),
        ],
        'driver' => 'mysql',
        'database' => env(COMMON_DB_NAME),
        'username' => env(COMMON_DB_USER),
        'password' => env(COMMON_DB_PASSWORD),
    ],
    'shard0' => [
        'read' => [
            'host' => env(SHARD0_READ_DB_HOST),
        ],
        'write' => [
            'host' => env(SHARD0_WRITE_DB_HOST),
        ],
        'driver' => 'mysql',
        'database' => env(SHARD0_DB_NAME),
        'username' => env(SHARD0_DB_USER),
        'password' => env(SHARD0_DB_PASSWORD),
    ],
    'shard1' => [
        'read' => [
            'host' => env(SHARD1_READ_DB_HOST),
        ],
        'write' => [
            'host' => env(SHARD1_WRITE_DB_HOST),
        ],
        'driver' => 'mysql',
        'database' => env(SHARD1_DB_NAME),
        'username' => env(SHARD1_DB_USER),
        'password' => env(SHARD1_DB_PASSWORD),
    ],
    'shard2' => [
        'read' => [
            'host' => env(SHARD2_READ_DB_HOST),
        ],
        'write' => [
            'host' => env(SHARD2_WRITE_DB_HOST),
        ],
        'driver' => 'mysql',
        'database' => env(SHARD2_DB_NAME),
        'username' => env(SHARD2_DB_USER),
        'password' => env(SHARD2_DB_PASSWORD),
    ]
]
app/Http/Controllers/HogeController.php
use Auth;
use app/Models/Foo;
use app/Models/Baz;

class HogeController 
{
    public function index() {
        $search = new Foo();
        $search->setConnection('shard'.(Auth::user()->id % 3));
        $foo = $search->find(1);

     $search2 = new Baz();
        $search2->setConnection('shard'.(Auth::user()->id % 3));
     $baz = $search2->find(1);

       //...

        return view('hoge/index');

    }

}

しかし、このままではすべてのモデルに対して, アクセス前に同じ処理を書かなければならなくなり,
またDB分割のルールの変更が起きた場合などに影響範囲が大きすぎるなどで, とても使い物になりません.

そこで, 以下のようなサービスを用意してすこし使いやすくすることにします.

app/Services/ShardSelector.php
class ShardSelector
{
    /** 現在使用しているシャード */
    private $shard;

    public function _construct() {
        $this->shard = 'shard0';
    }

    public function setShard($shard) {
        $this->shard = $shard;
    }

    public function getShard() {
        return $this->shard;
    }

    /**
     * ユーザIDを元に使用するDB設定の名前を取得する
     *
     * @param int $userId ユーザID
     * @return string DB設定の名前
     */
    public function getShardName($userId) {
        return 'shard'.($userId % 3)
    }
}

次に, このサービスに対するFacadeも用意して, 各モデルのコンストラクタを以下のように変更します.
(Facadeについての説明は省略)

app/Models/Foo.php
use ShardSelector;

class Foo extends Model
{
    public function __construct(array $attaribute = []) {
        parent::__construct($attribute);

        $this->connection = \ShardSelector::getShard();
    }

    // ...
}

最後に, シャーディングされたDBへアクセスする必要がある処理の先頭で, 使用するシャードDBの設定を行います.

app/Http/Controllers/HogeController.php
use Auth
use ShardSelector;
use app/Models/Foo;
use app/Models/Baz;

class HogeController 
{
    public function index() {

        ShardSelector::setShard(ShardSelector::getShardName(Auth::user()->id));

        $foo = Foo::find(1);
        $baz = Baz::find(1);

        //...

        return view('hoge/index');

    }

}

コントローラの各メソッドでいちいち指定するのが面倒でしたら, 以下のようなミドルウェアを作成し,
routeなりコントローラのコンストラクタの中で指定するのもよいでしょう.

app/Http/Middleware/SelectShard.php
use Auth;
use Closure;
use ShardSelector;

class SelectShard
{
    public function handle($request, Closure $next) {
        if (!Auth::guest()) {
            ShardSelector::setShard(ShardSelector::getShardName(Auth::user()->id));
        }

        return $next($request);
    }
}

これで, 各ロジック内で使用しているDBのことを気にすることなく, 処理を書いていくことができます.

問題点: DBをまたいだテーブル検索をしたい場合どうするのか

これまでは, 一度の処理の中で必ずひとつのシャードのみを使用する場合のみの話をしてきましたので
話は比較的単純でした.
これが, 一度に複数のシャードを使用する状況を考え出すと, 途端に複雑になってきます.

例えば, 売上の確認のために, 指定日以降の販売履歴一覧が欲しいという要望が上がったとします.
この販売履歴テーブルが各シャードに分散して存在していた場合, 取得するためのロジックは以下のように
なるかと思います.
(コードはイメージです)

$list = collect([]);
foreach (ShardSelector->getAllShards() as $shard) {
    ShardSelector::setShard($shard);
    $list->push(SalesHistory::where('sales_date', '>', $date)->get());
}

return $list->flattern();

一見簡単です.
しかしここから,

  • 売上履歴は一ページ20件ずつ表示してほしい
  • いくつかの項目でソートできるようにしてほしい

という当然の要望が追加されたらどうなるでしょうか.
DBが一つであったならば, これらの要望は単純にSQLにLimit句を付けたりsort句を付けたりするだけで
おしまいの話なのですが,
複数のDBに分散している場合は, どのレコードが全体で何件目に相当するかはすべてのDBのレコード足してみないとわからない以上
DBサーバ上ではなくアプリサーバ上で全件取得し, ソートし, 必要分だけ抜き出すという処理をしないといけなくなります.
この処理は単純に書くことが面倒なだけでなく, データ量によってはメモリか処理時間に重大な問題を引き起こすことも考えられます.

さらに面倒な話は, 複数シャードDBに対してトランザクションを利用した書き込みを行う場合です.
複数トランザクションのコミット最中に一つだけコミットに失敗し, ロールバックする必要が出た場合でも
他のトランザクションはすでにコミット済みのためロールバックすることはできず、データは不整合な状態になってしまう
恐れがあります.
トランザクションのコミットだけが失敗するなんてことはめったに起こることではないのだから, ちゃんとログを出力しておいて
問題が起きてしまったらログを頼りにデータを修正する, という割り切りが許されるならそれでももんだいないのかもしれませんが
そうでないならば2フェイズコミット的な仕組みが必要になってしまうことになります.

最後に: 蛇足的な話

ここまで, LaravelでのシャーディングされたDBの扱いについて書いてきましたが,
根本的な問題としてDBのシャーディングという手法は, 本当に必要な手法なのでしょうか?

DBの処理能力が足りない場合の対処として, もっとも一般的な方法はDBサーバをread系とWrite系にわけ
read系DBサーバを増やしていく(スケールアウト)手法だと思います.
シャーディングはこの手法で対処できないからこそ採用される手法だとおもうのですが, 実際にスケールアウトが有効でない状況とはどういう状態なのでしょうか?
おそらくそれは, スケールアウトできないサーバ, つまりwrite系サーバが性能不足に陥った状態でしょう.
wrtite系DBサーバが性能不足に対する対処法はシャーディングだけでしょうか?
ひょっとすると, その問題は単純にwrite系サーバのマシン(インスタンス)のスペックを上げる(スケールアップ)だけで対処できてしまうかもしれません.
あるいはテーブル設計を見直したり, テーブル単位のパーティショニングを導入することで対処できることかもしれません.
少なくとも一つのDBを分割されていない一つの系だけにとどめておけば, 上で書いたような面倒さを抱える必要はなくなります.

シャーディングに対応する方法を書いた記事の最後に書くのはなんですが,
DBの性能不足に対応する手法としてシャーディングを採用する前に, もっと別のよりシンプルな手法を検討してみてもいいのではないでしょうか.