Symfony2+Doctrine2.3でSharding(水平分割)を実現する

Symfony2 + Doctrine2.3の環境でデータベースのSharding(水平分割)を行う際の実装方法など。ここで言うShardingは、例えば10台データベースを利用するとしてユーザーIDなどを基準に利用するデータベースを各10台のどれかに振り分けるような場合(参考: 分割 (データベース) - Wikipedia

確認環境

下記手順でSymfonyを展開。DocumentRootがSymfony/web。

% wget "http://symfony.com/download?v=Symfony_Standard_Vendors_2.1.1.tgz" .
% tar zxvf Symfony_Standard_Vendors_2.1.1.tgz
% chmod 777 Symfony/app/cache Symfony/app/logs

データベースはMySQL 5.5を利用。mysqld_multiを使ってlocalhostに3インスタンス起動(ポートは3306、3307、3308)。
3つのインスタンス全てに下記SQLを実行しておく。今回はuserテーブルをそれぞれのデータベースで振り分けるという例。

CREATE DATABASE `sharding` DEFAULT CHARSET=utf8;
GRANT ALL PRIVILEGES ON `sharding`.* TO 'sharding'@'127.0.0.1' IDENTIFIED BY 'sharding'

USE sharding;
CREATE TABLE `user` (
  `id` int(11) unsigned NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

Bundle作成手順を省略するために確認はAcmeDemoBundleを変更して行う。下記URLにアクセスしているイメージ。

http://localhost/app_dev.php/demo/

自前でConnectionを切り替える

app/config/config.ymlに下記のようなDoctrineの設定を行う(内容をわかりやすくするためにparameters.ymlの値を使ってません)

doctrine:
    dbal:
        connections:
            db1:
                driver:   pdo_mysql
                host:     127.0.0.1
                port:     3306
                dbname:   sharding
                user:     sharding
                password: sharding
                charset:  UTF8
            db2:
                driver:   pdo_mysql
                host:     127.0.0.1
                port:     3307
                dbname:   sharding
                user:     sharding
                password: sharding
                charset:  UTF8
            db3:
                driver:   pdo_mysql
                host:     127.0.0.1
                port:     3308
                dbname:   sharding
                user:     sharding
                password: sharding
                charset:  UTF8

同じホストだけどそれぞれポートが3306、3307、3308と分かれて設定されている。この状態でコントローラに下記のようなコードを書くと各MySQLにそれぞれデータが挿入される。

<?php
class DemoController extends Controller
{
    public function indexAction()
    {
        $conn = $this->get('doctrine')->getConnection('db1');
        $conn->insert('user', array('id' => 1));

        $conn = $this->get('doctrine')->getConnection('db2');
        $conn->insert('user', array('id' => 2));

        $conn = $this->get('doctrine')->getConnection('db3');
        $conn->insert('user', array('id' => 3));

3306ポートにはuserテーブルにid:1のデータ。3307にはid:2、3308にはid:3。これで一番基本的なコネクションの切り替えは出来るのだけど、当然のことながらDoctrineを使っているのにこのようなコードは書きたくない。

EntityManagerで切り替える

Symfonyのドキュメントにあるようなデータの取得方法をしようとする場合は素のConnectionを扱わずにEntityManagerを使いたい。
下記コマンドでEntityクラスを作成する(src/Acme/DemoBundle/Entity/User.phpが作られる)。

% php app/console doctrine:mapping:import AcmeDemoBundle annotation
% php app/console doctrine:generate:entities AcmeDemoBundle

※今回はプライマリキーに値を自分で設定するので、上記コマンドでUser.phpを生成したあとにidに対してのsetterを追加している(idがstrategy="IDENTITY"なので上記コマンドだけだとidに対するsetterが生成されない)。

<?php
use Acme\DemoBundle\Entity\User;

class DemoController extends Controller
{
    public function indexAction()
    {
        $user = new User();
        $user->setId(1);
        $em = $this->get('doctrine')->getEntityManager();
        $em->persist($user);
        $em->flush();

ドキュメントにあるように普通にこのようにすると3306ポートのMySQLにデータを保存してしまう。そこでapp/config/config.ymlに下記のようなORMの設定を追加する。

doctrine:
    dbal:
        ~上に同じなので省略~
    orm:
        auto_generate_proxy_classes: %kernel.debug%
        entity_managers:
            db1:
                connection: db1
                mappings:
                    AcmeDemoBundle: ~
            db2:
                connection: db2
                mappings:
                    AcmeDemoBundle: ~
            db3:
                connection: db3
                mappings:
                    AcmeDemoBundle: ~

PHP側のコードはこんな感じ。

<?php
use Acme\DemoBundle\Entity\User;

class DemoController extends Controller
{
    public function indexAction()
    {
        $user = new User();
        $user->setId(5);
        $em = $this->get('doctrine')->getEntityManager('db2');
        $em->persist($user);
        $em->flush();

これで3307にid:5が挿入される。データを取得する場合も同じようにEntityManagerを指定して取得すれば該当のMySQLに接続してデータを取得できる。

<?php
use Acme\DemoBundle\Entity\User;

class DemoController extends Controller
{
    public function indexAction()
    {
        $em = $this->get('doctrine')->getEntityManager('db2');
        $repos = $em->getRepository('AcmeDemoBundle:User');
        $user = $repos->find(5);

ShardManagerを使う

ここまでの情報をうまく使って実装してもSharding自体は可能だけど、Shardingのためのオリジナリティあふれる自前コードを書かなければならないし、Doctrineのコード全体に渡ってコネクション名を管理するのは避けたい。
そこで、Doctrine2.3で提供されているShardManagerインタフェースを使ってShardingを実現するというのがこのエントリの本旨。ここまでに書いたデータベースの切り替え方法は他でもよく書かれていることなのだけど、土台から書かないとよくわからない感じになりそうだったので書いてみた。

13. Sharding — Doctrine DBAL 2.1.0 documentation

このページは英語だけどDoctrineに限らないShardingをする上での検討事項がわかりやすく書いてあるので原文を参照するのがおすすめ。主にShardingすることによる制約、Sharding対象の全データベースにまたいで一意になるプライマリキーをどのように生成するか、ShardManagerインタフェースの使い方が書かれている。

データベースにまたがって一意になるプライマリキーの生成

13.1.2. Table Generatorの方法をここではやってみる。IDをインクリメントするだけの管理テーブルを作成して採番する方法。Single point of failureなどのこの方法に起因する欠点も原文に書かれているので参照のこと。

下記のようにテーブルを作成する。ここでは3306ポートのMySQLに下記SQLを実行(Doctrine\DBAL\Id\TableGeneratorのdocコメントにこのテーブル定義が書いてある)。

CREATE TABLE `sequences` (
  `sequence_name` varchar(255) NOT NULL,
  `sequence_value` int(11) NOT NULL DEFAULT '1',
  `sequence_increment_by` int(11) NOT NULL DEFAULT '1',
  PRIMARY KEY (`sequence_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

あとはPHPでTableGeneratorを利用すればすぐに採番を開始できる。

<?php
use Doctrine\DBAL\Id\TableGenerator;

class DemoController extends Controller
{
    public function indexAction()
    {
        $conn = $this->get('doctrine')->getConnection('db1');
        $tableGenerator = new TableGenerator($conn, 'sequences');
        $id = $tableGenerator->nextValue('user');

sequencesテーブルは下記のようになる。nextValueする度にsequence_valueがsequence_increment_by分だけインクリメントする。

mysql> select * from sequences;
+---------------+----------------+-----------------------+
| sequence_name | sequence_value | sequence_increment_by |
+---------------+----------------+-----------------------+
| user          |              1 |                     1 |
+---------------+----------------+-----------------------+
1 row in set (0.00 sec)

ShardManagerを使ってShardingする

プライマリキーの生成ができるようになったので、いよいよShardManagerを使ってShardingをしてみる。ShardManagerはインタフェースなので今回はDoctrine\DBAL\Sharding\PoolingShardManagerクラスを使って実装する。実装結果を先に見た方がわかりやすいのでまずはPHPから。

<?php
use Doctrine\DBAL\Id\TableGenerator;
use Doctrine\DBAL\Sharding\PoolingShardManager;
use Acme\DemoBundle\Entity\User;

class DemoController extends Controller
{
    public function indexAction()
    {
        $conn = $this->get('doctrine')->getConnection();
        $shardManager = new PoolingShardManager($conn);

        // globalとして設定しているデフォルトの接続先に接続
        $shardManager->selectGlobal();

        $tableGenerator = new TableGenerator($conn, 'sequences');
        $id = $tableGenerator->nextValue('user');

        // $idを基準にして接続すべきデータベースへ接続
        $shardManager->selectShard($id);

        $user = new User();
        $user->setId($id);
        $em = $this->get('doctrine')->getEntityManager();
        $em->persist($user);
        $em->flush();

大体こんな感じになる。selectGlobal、もしくはselectShardするとその後のクエリは全て同じデータベースに流される。つまり、適切にselectShardさえできれば個々のクエリの接続先は意識しなくても大丈夫。データベースをまたがってのトランザクションは制約として使えないので、1トランザクションで1shardということを意識してShardManagerを扱う。

selectGlobalはデフォルトとして設定されている接続先(後述)に接続する。selectShardは引数を基準にしてSharding先に接続する。
データを取得するときも同じで、事前にselectShardさえしていれば特定のEntityManagerを呼び出したりしなくても大丈夫。

<?php
use Doctrine\DBAL\Id\TableGenerator;
use Doctrine\DBAL\Sharding\PoolingShardManager;
use Acme\DemoBundle\Entity\User;

class DemoController extends Controller
{
    public function indexAction()
    {
        $conn = $this->get('doctrine')->getConnection();
        $shardManager = new PoolingShardManager($conn);

        $shardManager->selectShard($id); // $idはリクエストなどから取得した仮定
        $em = $this->get('doctrine')->getEntityManager();
        $repos = $em->getRepository('AcmeDemoBundle:User');
        $user = $repos->find($id);

ShardManager#selectShardしたときの接続先を決定する

selectShardに渡される基準値を使って接続先を決める必要がある。これはDoctrine\DBAL\Sharding\ShardChoser\ShardChoserインタフェースを実装したクラスを作成して行う。selectShardしたときに内部ではShardChoserが呼び出される。

<?php
namespace Acme\DemoBundle;

use Doctrine\DBAL\Sharding\PoolingShardConnection;
use Doctrine\DBAL\Sharding\ShardChoser\ShardChoser;

class SimpleShardChoser implements ShardChoser
{
    public function pickShard($distributionValue, PoolingShardConnection $conn)
    {
        return ($distributionValue % 2) + 1;
    }
}

ここではあくまでも例なので偶数だったら1、奇数だったら2を返す単純なもの。このpickShardの戻り値が各shardのid(後述)になる。つまりselectShardの引数を使ってshardのidを返すように実装する。

ShardManagerが動作するように設定を行う

ここが難所で、今のところ正攻法な解決ができていない(というかわからない)。普通にapp/config/config.ymlに設定しようとするとShardManagerの設定をDoctrine\Bundle\DoctrineBundle\DependencyInjection\Configurationが想定していないので、設定エラーとみなされてしまう。この辺はおいおい対応されるのかなという感じ。今のところは13.8. Generic SQL Sharding Supportにあるように直接PHPコードで設定してしまうしかないのかもしれない。
よろしくないけど、一応こういうことをすると設定が書けるようになるっちゃなる(真似しないようにしましょう)。

--- Configuration.php    2012-09-11 08:24:24.000000000 +0000
+++ vendor/doctrine/doctrine-bundle/Doctrine/Bundle/DoctrineBundle/DependencyInjection/Configuration.php    2012-09-19 14:25:52.615927030 +0000
@@ -139,6 +139,17 @@
                 ->booleanNode('profiling')->defaultValue($this->debug)->end()
                 ->scalarNode('driver_class')->end()
                 ->scalarNode('wrapper_class')->end()
+                ->scalarNode('shardChoser')->end()
+                ->arrayNode('global')
+                    ->useAttributeAsKey('key')
+                    ->prototype('scalar')->end()
+                ->end()
+                ->arrayNode('shards')
+                    ->prototype('array')
+                        ->useAttributeAsKey('key')
+                        ->prototype('scalar')->end()
+                    ->end()
+                ->end()
                 ->booleanNode('keep_slave')->end()
                 ->arrayNode('options')
                     ->useAttributeAsKey('key')

この変更を行った上でのapp/config/config.ymlはこんな感じ。

doctrine:
    dbal:
        connections:
            default:
                wrapper_class: Doctrine\DBAL\Sharding\PoolingShardConnection
                shardChoser:   Acme\DemoBundle\SimpleShardChoser
                global:
                    driver:   pdo_mysql
                    host:     127.0.0.1
                    port:     3306
                    dbname:   sharding
                    user:     sharding
                    password: sharding
                    charset:  UTF8
                shards:
                    -
                        id:       1
                        driver:   pdo_mysql
                        host:     127.0.0.1
                        port:     3307
                        dbname:   sharding
                        user:     sharding
                        password: sharding
                        charset:  UTF8
                    -
                        id:       2
                        driver:   pdo_mysql
                        host:     127.0.0.1
                        port:     3308
                        dbname:   sharding
                        user:     sharding
                        password: sharding
                        charset:  UTF8
    orm:
        auto_generate_proxy_classes: %kernel.debug%
        auto_mapping: true

PHPで設定するにしてもYamlで設定するにしても、ポイントはwrapper_classでConnectionクラスをラッパークラスに切り替えること。上記設定はあくまでもDoctrine\DBAL\Sharding\PoolingShardConnectionに準じた設定になっていて、この辺もおいおい変更されていく可能性がありそうだなと思う。