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に準じた設定になっていて、この辺もおいおい変更されていく可能性がありそうだなと思う。