1
ソーシャルゲーム案件における
DB 分割の PHP 実装
~とにかく分割ですよ。 10 回じゃ足りない。 20 回くらい分割。~
株式会社インフィニットループ
佐々木 亨基
自己紹介
・佐々木 亨基
・ゆきこ yukicon
・ Twitter:@yukiconEx
・株式会社インフィニットループ所属
・札幌 MySQL 勉強会代表
・ PHP 歴は 4 年くらい
・現在仕事では PHP オンリー
・いい加減な人間なので PHP の緩さは好き
インフィニットループについて
・北海道札幌市にあるシステム開発会社
 約 90 名(契約スタッフ・アルバイト含む)で活動中
 社長も含め、ほぼ全員がエンジニア
・主な開発実績(主にサーバサイドを担当)
 ブラウザ三国志 (2009)
 英雄クエスト (2010)
  Lord of Knights(2012)
 フォトバトラー (2012)
  Vim 検定 (2012)
  PHP 検定 (2013)
 その他いろいろ
お題目
■ はじめに
・ DB 分割とは
・どうして DB 分割なんかするの?
・ユーザ単位による水平分割
・ DB 分割のデメリット
お題目
■ 実装のお話
・クラス設計
・使用例
・エイリアス
・水平分割
・水平分割された DB への問い合わせ
・トランザクション
・トランザクションの開始
・コミット
・まとめ
DB 分割とは
分割していない DB
A テーブル
B テーブル
C テーブル
D テーブル
E テーブル
F テーブル
DB 分割とは
分割した DB
A テーブル
B テーブル
E テーブル
F テーブル
E テーブル
F テーブル
E テーブル
F テーブル …
垂直分割
( テーブル単位での分割 )
水平分割
( 同テーブルの ID 単位による分割 )
C テーブル
D テーブル
DB 分割とは
更にそれぞれがマスタスレーブ構成を取る
Master
Master
…
垂直分割
( テーブル単位での分割 )
水平分割
( 同テーブルの ID 単位による分割 )
Slave Slave Slave
Master
Slave Slave Slave
Slave Slave Slave
Master
Slave Slave Slave
Master
Slave Slave Slave
どうして DB 分割なんかするの?
■ 要件
数万~十数万の同時接続数に耐えられるシステム
■ 案件の特徴
ソーシャルゲーム案件の規模は予測が難しい
突然跳ねる事もあり、正確な規模が見積もれない
どうして DB 分割なんかするの?
・同時接続数万という要求は高い
・さらに予想を上回る可能性もある
・しかしその実さっぱり流行らない可能性もある
・スモールスタート可能
・かつ困った時にサーバ追加で解決できる構成
つまり
スケールアウト可能なシステム構成
どうして DB 分割なんかするの?
Web サーバは単純なサーバ追加で対応可能
しかし DB は簡単にはいかない
・・・ ×nWeb Web Web Web
DB × ?
どうして DB 分割なんかするの?
DB のスケールアウトと言えばマスタスレーブ構成
Master
Slave Slave Slave ・・・ ×n
SELECT
UPDATE
SELECT SELECT
どうして DB 分割なんかするの?
しかしマスタスレーブ構成のマスタサーバは 1 台
マスタサーバの更新性能がネックとなり
いずれ限界が来る
Master
Slave Slave Slave ・・・ ×n
SELECT
UPDATE
SELECT SELECT
どうして DB 分割なんかするの?
マスタ 1 台で数万の同時接続数を捌くのは不可能
要件的にマスタスレーブ構成では破綻する
どうして DB 分割なんかするの?
ではどうする?
・ Fusion-io のような超高速ストレージを使う?
 →用意できるとは限らない
 →導入しても解決しなかった場合に詰む
・ MySQL Cluster を使う?
 →まだ枯れていない技術という印象
 →制約も多く、導入は怖い
どうして DB 分割なんかするの?
マスタスレーブのセットを増やそう !
       |
   \   __   /
    _ (m) _ピコーン
       | ミ |
    /  ` ´   \
      ('A`)
     ノヽノヽ
       くく
ユーザ単位による DB 分割
ユーザ数が増えたなら UserDB を追加
Global が苦しくなったら更に垂直分割をする事で
スケールアウト可能
User1 User2 User3 …
水平分割された UserDB
ユーザに紐付くデータを一定のルールで振り分けて格納
Global
GlobalDB
ユーザに紐付かない共通のデータを格納
DB 分割のデメリット
■ 設計でカバーする
・ DB 間を跨いだ JOIN ができない
→ 冗長なデータの持ち方をしてしまう
→ マスタデータは全 DB に持つなどして対策
・水平分割すればするほどパフォーマンスが下がる
 ※とにかく分割とかいうタイトルになってますが
  あんまり分割しちゃダメです、最低限にしましょう
DB 分割のデメリット
■ できるだけライブラリ側で吸収する
・水平分割された DB の串刺し検索が大変
→ ユーザ一覧を持ってくるのですら一苦労
・複数 DB のトランザクション管理が煩雑
ここからは実装のお話
クラス設計
3 つのクラスから成っている
・ DatabaseAccess
 全 DB 、トランザクションを統括するクラス
・ DatabaseAccessNode
 単一 DB にアクセスするクラス
 マスタスレーブの切り替えも行う
 分割なしならこのクラスのみで完結
・ DatabaseAccessMultiNode
 水平分割された DB にまとめてアクセスするクラス
クラス設計
図にすると
Master
Master
…
DatabaseAccess
Slave Slave Slave
Master
Slave Slave Slave
Slave Slave Slave
Master
Slave Slave Slave
Master
Slave Slave Slave
DatabaseAccessNode
DatabaseAccessMultiNode
使用例
// singleton なインスタンスを取得
$dba = DatabaseAccess::getInstance();
// グローバル DB から SELECT
$dba->getDBAN('global')->select('user_id_tbl');
// 対象ユーザ ID のデータがある DB から SELECT
$dba->getDBAN('user', $user_id)->select('user_tbl');
// 複数の対象ユーザ DB から SELECT
$dban_arr = $dba->getDBANArr('user', $user_id_arr);
$dbam = new DatabaseAccessMultiNode($dban_arr);
$dbam->select('user_tbl');
使用例
// singleton なインスタンスを取得
$dba = DatabaseAccess::getInstance();
// グローバル DB から SELECT
$dba->getDBAN('global')->select('user_id_tbl');
// 対象ユーザ ID のデータがある DB から SELECT
$dba->getDBAN('user', $user_id)->select('user_tbl');
// 複数の対象ユーザ DB から SELECT
$dban_arr = $dba->getDBANArr('user', $user_id_arr);
$dbam = new DatabaseAccessMultiNode($dban_arr);
$dbam->select('user_tbl');
なんか難しい!
エイリアス
エイリアスをつくって抽象化
class DatabaseAccess
{
function gb()
{
// global の DatabaseAccessNode オブジェクトを返す
return $this->getDBAN('global');
}
function user($user_id)
{
// user の DatabaseAccessNode オブジェクトを返す
return $this->getDBAN('user', $user_id);
}
:
:
エイリアス
// singleton なインスタンスを取得
$dba = DatabaseAccess::getInstance();
// グローバル DB から SELECT
$dba->gb()->select('user_id_tbl');
// 対象ユーザ ID のデータがある DB から SELECT
$dba->user($user_id)->select('user_tbl');
// 複数のユーザ DB から SELECT
$dba->user_multi($user_id_arr)->select('user_tbl');
だいぶすっきり
エイリアス
// singleton なインスタンスを取得
$dba = DatabaseAccess::getInstance();
// グローバル DB から SELECT
$dba->gb()->select('user_id_tbl');
// 対象ユーザ ID のデータがある DB から SELECT
$dba->user($user_id)->select('user_tbl');
// 複数のユーザ DB から SELECT
$dba->user_multi($user_id_arr)->select('user_tbl');
// 全ユーザ DB から SELECT
$dba->user_all()->select('user_tbl');
全ユーザ DB 用のエイリアスも用意すると便利
水平分割
ID とサーバ ID をマッピングするテーブルを
グローバル DB に作成して管理
CREATE TABLE `id_partition_tbl` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`server_id` tinyint(4) unsigned NOT NULL,
PRIMARY KEY (`id`),
KEY `server_id` (`id`,`server_id`)
)
id server_id
1000 1
1001 2
1002 3
水平分割
ID の発行 ( 仮に server_id を 1 で指定 )
INSERT INTO id_partition_tbl (server_id) VALUE (1);
SELECT LAST_INSERT_ID();
 ↓
分割ルールに従ってサーバの割り当て
 ↓
テーブルへの登録
UPDATE id_partition_tbl SET server_id = 3 WHERE id = 1000;
水平分割
分割ルール
・テーブルによる管理
必ずテーブルを参照する必要がある
server_id をキャッシュするなどの工夫が必要
登録数の少ないサーバに振り分け
各サーバに重みをつけて振り分け
既存データを意図通りに再配置
など柔軟な対応が可能
$server_id = getServerId('id_partition_tbl', $id);
水平分割
分割ルール
・剰余やハッシュによる振り分け
均等にバランシングされる
サーバ追加時に既存データの再配置が必要
$server_id = ($id % $server_cnt) + 1;
水平分割
分割ルール
・範囲による振り分け
ある程度意図を持ってバランシングできる
既存データに手をいれずサーバ追加が可能
ただし小回りは効かない
foreach ($range_arr as $range) {
if ($range['min'] <= $id and $id <= $range_info['max']) {
$server_id = $range['server_id'];
break;
}
}
水平分割された DB への問い合わせ
DatabaseAccessMultiNode クラスにより実現
複数 DB に同じ SQL を投げ、結果をマージ
使用者は複数 DB への問い合わせである事を意識
せず、単一 DB を扱うのと同様に記述する事がで
きる
// 単一 DB への問い合わせ
$dba->user($user_id)->select('user_tbl');
$dba->user($user_id)->update('user_tbl');
// 複数 DB への問い合わせ
$dba->user_multi($user_id_arr)->select('user_tbl');
$dba->user_multi($user_id_arr)->update('user_tbl');
水平分割された DB への問い合わせ
__call と call_user_func_array によって実装
DatabaseAccessNode クラスに単一 DB へ問い合
わせる処理を追加すると、
DatabaseAccessMultiNode クラスを経由して複
数 DB に問い合わせもする事ができる
class DatabaseAccessMultiNode
{
function __call($func_name, $args = array())
{
// 各 DB に対して実行
foreach ($this->dban_arr as $key => $dban) {
$tmp_data_arr[] =
call_user_func_array(array($dban, $func_name), $args);
}
水平分割された DB への問い合わせ
レスポンスは型によって処理を振り分ける
$tmp_data = reset($tmp_data_arr);
if (is_numeric($tmp_data)) {
// 数値の場合は和を返す
$sum = 0;
foreach ($tmp_data_arr as $tmp_data) {
$sum += $tmp_data;
}
return $sum;
} else if (is_array($tmp_data)) {
// 配列の場合はマージして返す
$data = array();
foreach ($tmp_data_arr as $tmp_data) {
$data = array_merge($data, $tmp_data);
}
return $data;
:
:
水平分割された DB への問い合わせ
user_id をキーにした場合
UPDATE は対象レコードが存在しないため問題無いが
User1
(1000-1999)
User2
(2000-2999)
User3
(3000-3999)
UPDATE t SET a = a + 100 WHERE
user_id IN (1000, 2000, 3000);
UPDATE t SET a = a + 100 WHERE
user_id IN (1000, 2000, 3000);
UPDATE t SET a = a + 100 WHERE
user_id IN (1000, 2000, 3000);
水平分割された DB への問い合わせ
INSERT は気をつける必要がある
User1
(1000-1999)
User2
(2000-2999)
User3
(3000-3999)
INSERT INTO t (user_id, a) VALUES
(1000, 0), (2000, 0), (3000, 0);
INSERT INTO t (user_id, a) VALUES
(1000, 0), (2000, 0), (3000, 0);
INSERT INTO t (user_id, a) VALUES
(1000, 0), (2000, 0), (3000, 0);
水平分割された DB への問い合わせ
INSERT は気をつける必要がある
User1
(1000-1999)
User2
(2000-2999)
User3
(3000-3999)
INSERT INTO t (user_id, a) VALUES
(1000, 0), (2000, 0), (3000, 0);
INSERT INTO t (user_id, a) VALUES
(1000, 0), (2000, 0), (3000, 0);
INSERT INTO t (user_id, a) VALUES
(1000, 0), (2000, 0), (3000, 0);
不要なレコードまで INSERT されてしまう
水平分割された DB への問い合わせ
__call による実装は、あくまでも全 DB に同じ
クエリを投げているだけ
レスポンスも型によって機械的に対応している
INSERT のようにそれでは問題がある場合は、
専用メソッドを立てて対応する
トランザクション
トランザクションは DB 単位でかかるため
管理に気を使わなくてはいけない
// 対象のユーザ DB にトランザクション開始
$dba->beginTransactionToUser($user_id);
// この更新はグローバル DB への更新のため
// トランザクションの対象とならない
$dba->gb()->update();
XA トランザクション…
うっ…頭が…
( 分離レベルが SERIALIZABLE に限られる、
 挙動が怪しいという事で、ミドルウェアに頼らず
 アプリによる実装としました )
トランザクションの開始
複数 DB へのトランザクション開始方法は 2 通り
・最初にまとめて開始してしまう
・必要になった時点で開始する
トランザクションが必要な事がわかりきっている場合は最
初にまとめてしまう方が管理が楽かつ簡単
どれか 1 つでもトランザクションがかかっていれば他の DB
も更新処理時に自動でトランザクション状態となるオート
モードも用意したが、管理できなくなる懸念があったため
使っていない
トランザクションの開始
■ 最初にまとめて開始してしまう場合
トランザクションは短い方が良い
コネクションのコストによって無用にトランザクションが
長くならないようにマスタサーバへのコネクションを行
なってからまとめてトランザクションを開始する
// 対象の DB をマスタに接続
$dba->gb()->useMaster();
$dba->user($user_id)->useMaster();
// マスタに接続した DB を一斉にトランザクション開始
$dba->myBeginTransactionToConnectionMaster();
Global User
コネクション
BEGIN
コネクション
BEGIN
Global User
コネクション
コネクション
BEGIN
BEGIN
トランザクションの開始
■ 必要になった時点で開始する場合
ある DB に対してトランザクションをかけるが、
ある DB に対しては 10 回に 1 回くらいしかトランザクショ
ンが必要が無い処理の場合、必要になった時にトランザク
ションを開始する
別々のユーザ ID が対象になった際に両方のユーザ ID が同
じ DB に所属している場合など、既にトランザクションが開
始されている事もある
// 対象のユーザ DB にトランザクション開始
$dba->beginTransactionToUser($user_id);
// たまにしかここに来ないので、ここでトランザクション開始
// 既にトランザクション開始されているならスルーする
If ($dba->user($other_user_id)->isTransaction()) {
// 同じ DB の場合はここにはこない
$dba->beginTransactionToUser($other_user_id);
}
コミット
各 DB に対してバラバラのタイミングでコミットを行うとつ
くりが複雑になり、データ不整合となるバグを引き起こす
可能性が高くなる
悪い例
// グローバル DB をアップデート
$dba->gb()->update();
// グローバル DB をコミット
$dba->commitToGlobal();
// ユーザ DB をアップデート
$dba->user($user_id)->update();
// ユーザ DB をコミット
$dba->commitToUser($user_id);
ここでエラーが起こるとユーザ DB のみ更新されず
データ不整合状態となる
Global User
UPDATE
COMMIT
UPDATE
COMMIT
コミット
コミットは必ず処理の最後にまとめて行うようにする
処理の順番によるデータ不整合に気を配る必要が無くなり
コーディングの難易度も下がる
// グローバル DB をアップデート
$dba->gb()->update();
// ユーザ DB をアップデート
$dba->user($user_id)->update();
// まとめてコミット
$dba->allCommit();
Global User
UPDATE
UPDATE
COMMIT
COMMIT
コミット
まとめてコミットと言っても順番にコミットするだけ
いわゆる 2 相コミットではないため、一部がコミットされ
てしまうと全体のロールバックは不可能
やはり途中でエラーとなった場合はデータ不整合となる
$commited_arr = array();
foreach ($dban_arr as $dban) {
$dban->commit();
$commited_arr[] = $dban->database_name;
}
コミット
途中でコミットがエラーとなった場合は、どの DB がコミッ
トされ、どの DB がコミットされていないのかをログに残す
} catch (Exception $e) {
if (0 < count($commited_arr)) {
// 1 度以上コミットしたということはデータ不整合
$uncommited_arr = array();
foreach ($dban_arr as $dban) {
if ($dban->isTransaction()) {
// トランザクション中なら配列に含める
$uncommited_arr[] = $dban->database_name;
}
}
// エラーとなった DB 情報のログを残す
logging(sprintf('commit error commited[%s]
uncommited[%s]', implode(',', $commited_arr),
implode(',', $uncommited_arr)));
}
}
コミット
ログを頼りに手で対応する事になってしまうが
実際データ不整合はほとんど起こらず
1 年で 1 回や 2 回という低い頻度のため
ログによる対応で問題となった事はない
まとめ
・エイリアスを用意
・複数 DB を束ねて管理するクラスを用意
抽象化は重要
抽象化する事で経験の浅いエンジニアでも扱える
・分割ルールは設計段階で破綻の無いように決めておく
必要であれば独自のルールによる振り分けを実装する
・トランザクションの管理もできるだけ簡単にする
・特にコミットはデータ不整合の起こりやすいポイント
・ 2 相コミットではない
・一部コミットされると全体ロールバックは不可
・データ不整合はコミット失敗のログを残す事で対応する
DB 分割意外となんとかなる
でも気をつけるところはちゃんと気をつけないとダメ
求人募集
インフィニットループでは、エンジニアを募集しています
・社長も含めほぼ全員がプログラマで技術者に優しい環境
・勤務地は北海道札幌市、他社出向などは無し
・おいしい食べ物、自然いっぱい、花粉少ない、涼しい
・短い通勤時間、徒歩や自転車で通勤が可能
・ U ターン、 I ターン大歓迎
・ PHP 開発エンジニア
・スマホ開発エンジニア
・ MySQL エンジニア
・インフラエンジニア
ご清聴ありがとうございました

ソーシャルゲーム案件におけるDB分割のPHP実装