いまどきのカジュアルなデータベース関連開発
YAPC::Asia 2013
Sep 20th, 2013
profile
今年新たに上げたCPANモジュール
- Riji
- Puncheur
- DBIx::Schema::DSL
- Cache::Redis
- Config::PL
- Redis::LeaderBoard
- DBIx::FixtureLoader
- SQL::Maker::Plugin::JoinSelect
- Teng::Plugin::SearchJoined
- Git::Repository::FileHistory
- GitDDL::Migrator
- UnazuSan
- Plack::Middleware::Woothee
- Plack::Middleware::UnicodePictogramFallback::TypeCast
あと
をtypesterの代理でCPANに上げました
アジェンダ
- ORM
- MySQL以外のミドルウェア
- DB設計
- MVC
- Test
- Profiling
DB開発ってことですが
- 今年のYAPCはRDB寄りの話がほとんど無くて歴史の流れを感じる
- 廃れたんじゃなくて当たり前のように使われている
- おさらい的な感じ
- 長いセッションなので、随時質問・ツッコミください
- MySQL前提の話が多いです
ORM
Teng and DBIx::Class
typester likes DBIC.
社内では、去年Tengに傾いたものの、今年に入って揺り戻しがあって、
DBICが増えてる。新規でTeng使ってるの多分僕だけ><
僕もDBICきらいじゃないので、結構ゴツメの使い方でTengを使っている
スキーマ(DDL)管理 - use DBIx::Schema::DSL;
拙作。自分でSQLかけばええやんて感じだけど+αしてる
SQL::Translatorに対するDSL的な感じ。
定義
package MyApp::Schema;
use DBIx::Schema::DSL;
create_table player => columns {
primary_key 'id';
varchar 'name';
};
出力
% perl -ML -E 'say MyApp::Schema->output' > sql/myapp.sql
DBIx::Schema::DSL
メリット
- Perlの定数連携(Defualt値とか)
- DRYに書ける。余計な記述を減らせる
- Perlなのでシンタックスチェックが楽
- SQLのクオリティーが統一される
- 特にSQL::TranslatorフレンドリーなSQLが出力される
- GitDDLを使う上で重要
- 外部キー制約がついたDDLを出し分けできる
- MySQL Workbenchとかに食わせてER図書かせたりできる
デメリット
何故スキーマ管理にMySQL Workbenchとかを使わないか
- バイナリはツライ
- ブランチ毎にテーブルの齟齬があると無理ゲーになる
- テキスト最強
- GitDDLが神
Migration - use GitDDL::Migrator;
- typesterのGitDDLを運用向けに改良
- 以前のバージョンに戻すとか
- migrateの履歴を残すようにとか
- SQL::Translaterのdiffが壊れてる時用にSQL指定でバージョンあげられるようにとか
- drop & add columns じゃなくてchange columnsしたいときとか
- 特定のバージョン指定してそこに戻したいとか
- 実際のデータベースとの差分をとってDDLに不整合がないかとか
GitDDL(::Migrator)の仕組み
- 現在のDDLのバージョンをgitのハッシュを結びつけて管理する
- % git log -1 sql/myapp.sql を見る
- git_ddl_versionテーブルに、マイグレートした時点のgitのハッシュを保存しておく
- SQL::Translator使えば2つのDDLを比較してよしなに差分SQLを出力してくれる
- DEMO
Tengのスキーマ定義 - use Teng::Schema::Loader;
- せっかくDDL書いてるのに、Teng::Schema::Declare とか使うのめんどい
- プロダクションで使っても問題になってない
partition切ってて、pkが id, created_atの時にちょっとおかしくなった時があったので
無理やり定義書き換えてidのみをpkに定義しなおしたりとかしてる。詳しいことは忘れた
- SQL::Translator::Producer::Teng とか作ってもいいけど特に困ってないのでやってない
JOIN - use Teng::Plugin::SearchJoined;
TengはJOINサポートしてない割り切った設計になっている(agree)、が
joinしたSQL投げつつ、rowオブジェクトは個別に取るみたいなことはやりたい
my $itr = $teng->search_joined(
user_item => [
item => {'item.id' => 'user_item.item_id'},
], {
'user_item.user_id' => 10,
});
# $itr->suppress_object_creation(1);
while (my ($user_item, $item) = $itr->next) {
...
}
- search_by_sql みたいなのは諦めた
- DBICのprefetchとかと違って、発行されるSQLがイメージしやすくなっている。
ResultSet - use MyApp::DB::ResultSet;
DBICだと、Rowの集合みたいな概念があって結構便利。DBIC脳だと欲しくなる
- Teng::Iteratorを継承したMyApp::DB::ResultSetみたいなのを作る
- さらに、それを継承したMyApp::DB::ResultSet::Player クラスを作る
- $tengにresult_setってメソッド生やしてそのインスタンス返すようにする
こんな感じ
my $rs = $teng->result_set('player');
$rs->single({id => 1});
これは以下とほぼ等価
$teng->single(player, {id => 1});
Teng::Plugin::ResultSet
でも書こうかと思ってるけど保留中
例外処理 - use Exception::Tiny;
- Teng#handle_error をオーバーライドして例外投げるようにしてる
- 投げる例外を階層化しておくと便利
- MyApp::DB::Exception
- MyApp::DB::DuplicateException
- MyApp::DB::RecordNotFoundException
- MyApp::DB::Row::DeleteFailedException
トランザクションとの連携 - DBIx::TransactionManager::EndHook
use DBIx::TransactionManager::EndHook;
sub take_item {
my ($self, $item) = @_;
$self->items->add($item);
$teng->transaction_manager->add_end_hook(sub {
log('take item!');
});
}
- nested transactionで一番外側のコミットが走った時点で処理を走らせたい
- 逆にロールバック時は処理させたくない
- アイテム付与処理でちゃんとfluentdにログを投げたい
- 得点更新処理でちゃんとRedisに投げたい
- 作者がDBICでもやろうとしてたけど断念してた
MySQL以外のミドルウェア
Redis
- 便利。当たり前のように使っている
- memcachedが不要になった(あくまでゲームの場合)
- 色々な型を組み合わせるプログラミング的な面白さがある
- SortedSetでお手軽ランキング(フレンドランキングとか)
- Setでログインユーザーを残しておいたり
- Listをジョブキューとして使う
Redisの注意点
- Expire設定をしっかりやる
- LRUには期待できない。メモリ使用量を監視しながら調整が必要
- 冗長構成をとるのが結構難しい
- 永続的なデータは置かない
- 定期的にDBに保存
- もしくはDBに保存しつつ補助的な役割で使う(ランキングとか)
- 最悪作り直せるように
Redis.pm
- PurePerlであんま速くない
- 再接続のケアとかがちゃんとしてる
- fork safeじゃないのでp-r送ってるけど取り込んでもらってないので、そのパッチ当てたバージョンを使ってる
- Redis::hiredis はhiredis使ってて高速だけど再接続のケアとかに難があるし、コードが微妙で継承できなかったりする
- Redis::hiredisは普通に使えばRedis.pmの3倍は速くて、Cache::Memcached::Fastとかとそんなに変わらない
- ちゃんとしたhiredisバインディングが待ち望まれている!!!
Cache::Redis
拙作
- Cache::Cacheなインタフェース
- 一応動いてる
- Redis.pm依存なのでそんな速くない
- Redis::hiredisに差し替えられるようにはなっててそうすればC::M::Fの8割程度の速度は出るが…
Redis::LeaderBoard
拙作
- 同率問題解消のために書いた
- まだあまり使ってない
- Redis.pm依存なので(ry
- Redis本体のUniqu Ranking実装は2.8から3.0にマイルストーンがしれっと伸ばされていて悲しみ
Fluent::Logger
- fluentdが無いと生きていけない
- 例外とかも構造化ログで吐くようにしてる
- Mongoにcappedで入れたりして多少検索できるようにとかしてるが…
DB設計
テーブル定義
割と普通。
- AR-ishな id requiredなアンチパターン (hoge.id -> fuga.hoge_id)
- 単複変換とかは気持ち悪いのでやらない
- テーブル名は単数形
- 極力正規化する
インデックスは必要最低限
- 複合インデックス貼ってるのに、単一インデックスも貼っちゃうとかありがち
- user_id, item_id の順で複合インデックス貼ってるなら、user_id単体のインデックスは不要とか
外部キー張らない
- バッドノウハウだけどやってる
- 外部キー制約ついてると、リレーション先のレコードロックが発生したりする
- 不必要なインデックスの重複が起こる。
接続時の処理
on_connect_do => [
"SET NAMES utf8mb4",
"SET SESSION sql_mode='TRADITIONAL'",
],
- TRADITIONALが一番厳密
- SET NAMES使ってるとか無いわーと思われるかもしれませんが、
mysql_enable_utf8
している場合こうしないと、mb4範囲が文字化けるので致し方ない。
- DBD::mysqlがmysql_enable_utf8してると、charsetをutf8に決め打ってしまうので、接続時にSET NAMESしてやる必要がある。
たまに勘違いしてる人がいるけど(ぼくもしてた)、on_connect_do
はDBI標準の機能ではない。DBIで同様のことをしたい場合は、Callbacksを使う。
Unicode6とutf8mb4
- Unicode絵文字時代
DEFAULT CHARACTER SET utf8mb4
- 767byte問題。VARCHAR(191) にしないとuniqueインデックス張れない (255じゃない)
- もっと超大規模サービスだと「文字列にindex張るとかインデックスサイズ的に無いわー」とかなって、murmurhashの別カラムを定義してそっちにインデックス張ったりしてるらしいですよ
- アンドロイドとか結局フォント足りなくて見られなかったりするけど…
- 画像へのFallbackとかが必要になってくる
- Plack::Middleware::UnicodePictogramFallback::TypeCast ての書いてたけど使ってない
パーティショニング
MySQL5.1以降
- 時系列で切ることしかやってない
- デイリーで切るとか (id, created_at)
- イベント事に切るリストパーティション (id, event_id)
- とにかくDB一系統がメモリに乗り切るようにガツンガツン落とせるようにしておく
- pruning期待してどうこうみたいなのはやってない
マスターデータの考え方
- ゲームだと普通に1万行とか越える
- アプリケーションからは更新させない
- 更新させたい場合はリレーションした別テーブルを作る
- data/master 的な場所でリポジトリ管理して、データを投入したバージョンのコミットハッシュを保存しておく(GitDDLと同じ考え方)
- Google SpreadSheet -> csv|yaml -> MySQL
- マスタデータのテストをしっかり書く(リレーション先が揃ってるかとか)
DBIx::FixtureLoader
拙作。フィクスチャをよしなにDBに流しこんでくれる。[テーブル名].[拡張子]
[テーブル名]-1.[拡張子] のようにハイフン区切りでpostfixをつけておくことも可能。
論理削除を使わない
- 最近はストレージの性能も上がったしdeleteもそんなに遅くない
- 削除時に履歴テーブルにインサートするようにして、そっちはデイリーパーティションして定期的に落とすようにしてる
MVC
ディレクトリ構成
Model/ とは別に DB/ を用意。ORM関連は DB/ に置く。
lib/MyApp/
├── CLI
├── Controller
├── DB
├── Model
└── View
ORMの考え方
- Model層経由でDB/(ORM層)を呼ぶ
- ORMの生のメソッドはModel側(内部以外)からは極力呼ばない
- search, update, insert, delete
- これらはprivate的な感じ
- ORM側に適切なメソッドを用意する
- Web API叩くのと同じ感覚
Test
Test::mysqld
- プロセスを使い回すとかせずに、テストを並列で立ち上げた分Test::mysqldも並列で立ち上げてる
- copy_data_fromが入って便利になった
- マスタデータと初期データを一式入れたデータディレクトリを事前に用意して、テストは並列で走らせる
- sql/myapp.sql を使ってテーブル構築
- data/master 以下のデータは全部流し込む
- この状態のディレクトリを保存しておく
- sql/myapp.sqlやdata/masterが更新されていたら作りなおす
事前データ作る
use Path::Tiny qw/path/;
use Test::mysqld;
sub prepare_mysqld_copy_data {
my $datadir = 'tmp/test_mysqld_data';
path($datadir)->rmtree;
my $mysqld = Test::mysqld->new(
base_dir => 'tmp/test_mysqld',
my_cnf => {
datadir => $datadir, # $datadirを指定
'skip-networking' => '',
},
) or die $Test::mysqld::errstr;
# データ流し込み
deploy_test_database($mysqld->dsn);
}
データ流し込み
use DBI;
use DBIx::FixtureLoder;
sub deploy_test_database {
my $dsn = shift;
my $dbh = DBI->connect($dsn);
# スキーマ流し込み
my $source = path('sql/myapp_ddl.sql')->slurp;
for my $stmt (split /;/, $source) {
next unless $stmt =~ /\S/;
$dbh->do($stmt) or die $dbh->errstr;
}
# マスタデータ流し込み
my $loader = DBIx::FixtureLoder->new(dbh => $dbh);
for my $file (path('data/master')->children) {
$loader->load_fixture($file);
}
}
→ tmp/test_mysqld_data にデータディレクトリが作られる。
実際テストを走らせる
my $mysqld = Test::mysqld->new(
copy_data_from => 'tmp/test_mysqld_data',
my_cnf => { 'skip-networking' => ''},
) or die $Test::mysqld::errstr;
マスタデータとテスト
- マスタデータもコードの一部といえるので整合性のチェックもちゃんと書く
- マスタデータがちゃんと入った状態でテストを走らせないと意味が無い
Test::mysqlのテスト以外使い途
- 開発用のDB立てたり
- マスタデータを整形して、クライアント用のデータを出力したりする
(Tips)発行されているSQLをテストする
# こんなかんじのやつをt::Utilに定義
sub trace_sqls(&) {
my $code = shift;
my @sqls;
require DBIx::Tracer;
my $tracer = DBIx::Tracer->new(sub {
my %args = @_;
push @sqls, $args{sql};
});
$code->();
@sqls;
}
# こんなかんじで使う
my @sqls = trace_sqls {
$dbh->do('UPDATE ...');
...
};
Profiling
基本的には
去年のトークでfujiwaraさんが話していたようなことです。
アタリマエのことだけど、だがそれが難しい。
DBIx::QueryLog
- 便利。開発中に使う。
- 出力されているクエリとかその実行時間とかもろもろ出してくれる。
- explainを出してくれる
DBIx::QueryLog->explain(1);
が神っぽい。
- 他にも色々オプションがある
Devel::KYTProf
- ボトルネックがあるときに本番で流して使ってる
- アプリサーバー1台だけとか
- Hatenaでは開発で使っていて、本番で流すようなことはしていないらしい
can
でcoderef取ってフックしてってやってるので、AUTOLOAD使ってるRedisの場合だと事前に
eval {$redis->$_} for @commands;
とかやって登録しておかないとダメ。
IOネックなやつはだいたいこのへんで潰せる。
Devel::NYTProf
アプリネックな場合でにっちもさっちも行かなくなったら使う。
だいたいDBICとかDateTimeが原因でしょんぼりする。
なので僕はDBICとDateTimeは使っておらず、DBICの代わりにTeng。DateTimeの代わりにTime::Piece::Plus
最後に
もう一つ
We are hiring!
- カヤックではサーバーサイドエンジニアを絶賛募集中です
- 特にLobi(旧Nakamap)という(主にゲーム向けの)チャットサービス
- 中規模チャットサービスの運用事例 (@handlename)
- もはや中規模じゃない?(とか言いたい人もいるらしい)
- 09-21(土) 14:10:00
- 多目的教室3
以上
ありがとうございました。
質問おねがいします!