DBIxでN+1問題に直面した・・其の壱
いにしえのコードを修正していたらDBIxでN+1問題に直面している部分を発見した!!
なので解決策をサクッと書こうと思います(/・ω・)/
初めにN+1問題とは
PerlのDBIxのようなORMは最適化のために余分なカラムは読み込まないようになっていることが多いです。
そのため、読み込まれていないカラムを表示しようとすると、
本来のSQL(1)に加えて、各レコードごとに1回のSQL(N)が実行されてしまいます/(^o^)\ナンテコッタイ
つまり、無駄にSQLが発行され、表示に時間がかかります。
Rubyあまり触ったことないんですが、参考にした記事がRubyなのでRubyにもいえる話なんでしょうね・・('A`)
たとえば以下のようなn_plus_one_problem
データベースがあって
author
テーブル
author_id | name |
---|---|
1 | hoge |
2 | huga |
book
テーブル
book_id | title | author_id |
---|---|---|
1 | aaaa | 1 |
2 | bbbb | 1 |
3 | cccc | 2 |
4 | dddd | 2 |
author
テーブルとbook
テーブルに1対多のリレーションが貼られているとします。
本の情報を表示したいとき、
Controller側は
sub index : Path :Args( 0 ) { my ( $self, $c ) = @_; my $book_rs = $c->model( 'Master::Book' ); $c->stash( book_rs => $book_rs ); return; }
View側は
<table border="1"> <tr> <th>Book ID</th> <th>Book Title</th> </tr> [% WHILE ( book = book_rs.next ) %] <tr> <td>[% book.book_id %]</td> <td>[% book.title %]</td> <tr> [% END %] </table>
とすればいいですね。
すると、クエリログはこうなります(DBIx::QueryLogモジュール)
[2015-06-13T06:57:56] [DBIx::Class::Storage::DBI] [0.000502] SELECT COUNT( * ) FROM book me JOIN author author ON author.author_id = me.author_id at.. [2015-06-13T06:57:56] [DBIx::Class::Storage::DBI] [0.000452] SELECT me.book_id, me.title, me.author_id FROM book me JOIN author author ON author.author_id = me.author_id at..
While文を回すためのカウントSQLは例外として、本体のSQLで1回のクエリが走っているのが分かります。
そこに著者の情報も表示したくなったとき、
Controller側は
sub index : Path :Args( 0 ) { my ( $self, $c ) = @_; my $book_rs = $c->model( 'Master::Book' )->search( undef, { join => 'author' } ); $c->stash( book_rs => $book_rs ); return; }
View側は
<table border="1"> <tr> <th>Book ID</th> <th>Book Title</th> <th>Author ID</th> <th>Author Name</th> </tr> [% WHILE ( book = book_rs.next ) %] <tr> <td>[% book.book_id %]</td> <td>[% book.title %]</td> <td>[% book.author.author_id %]</td> <td>[% book.author.name %]</td> <tr> [% END %] </table>
とすればいいですね。
すると、クエリログはこうなります。
[2015-06-13T07:00:28] [DBIx::Class::Storage::DBI] [0.000439] SELECT COUNT( * ) FROM book me JOIN author author ON author.author_id = me.author_id at.. [2015-06-13T07:00:28] [DBIx::Class::Storage::DBI] [0.000330] SELECT me.book_id, me.title, me.author_id FROM book me JOIN author author ON author.author_id = me.author_id at.. [2015-06-13T07:00:28] [DBIx::Class::Storage::DBI] [0.000434] SELECT me.author_id, me.name FROM author me WHERE ( me.author_id = '1' ) at.. [2015-06-13T07:00:28] [DBIx::Class::Storage::DBI] [0.000213] SELECT me.author_id, me.name FROM author me WHERE ( me.author_id = '1' ) at.. [2015-06-13T07:00:28] [DBIx::Class::Storage::DBI] [0.000601] SELECT me.author_id, me.name FROM author me WHERE ( me.author_id = '2' ) at.. [2015-06-13T07:00:28] [DBIx::Class::Storage::DBI] [0.000196] SELECT me.author_id, me.name FROM author me WHERE ( me.author_id = '2' ) at..
わ~~お!!なんかたくさん走ってますね!!
さらに、SELECT句をダンプするとこうなります。(Data::Printerモジュール)
select [ [0] "me.book_id", [1] "me.title", [2] "me.author_id" ]
JOINはしたものの結合先のテーブル情報はロードされていないことが分かります。
しかし、Viewでauthor
テーブルの情報を表示しようとしているため、
リレーションを便りにクエリが実行され、テーブル情報を取ってこようとします。
結果、N+1回クエリが実行されてしまいます。
少し古い情報ですが、Catalystのオレンジ本ではN+1問題を解決するために、別テーブルのカラムを参照する際にはJOINではなくPREFETCHを推奨しています。
実際、JOINではなくPREFETCHを用いてみると、
[2015-06-16T06:16:31] [DBIx::Class::Storage::DBI] [0.000633] SELECT COUNT( * ) FROM book me JOIN author author ON author.author_id = me.author_id at.. [2015-06-16T06:16:31] [DBIx::Class::Storage::DBI] [0.000285] SELECT me.book_id, me.title, me.author_id, author.author_id, author.name FROM book me JOIN author author ON author.author_id = me.author_id at..
なんかいけましたね(。-`ω´-)ンー.
select [ [0] "me.book_id", [1] "me.title", [2] "me.author_id", [3] "author.author_id", [4] "author.name" ]
このようにauthor
テーブルの情報がすでに読み込まれていることが分かります。
PREFETCHは結合したテーブルのカラムを自動的に読み込んでくれます。
デメリットとして無駄なデータの読み込みは発生しますが、このほうがミスは少なそうですね。
もしRubyのORMにPREFETCHに相当するものがあるのであれば、それを使えばいいかもしれません(調べてないけどねw)
しかし、DBIxのPREFETCHはクセの強いやつで、そういう訳にもいきません。
少なくとも僕の使ったバージョンでは・・
その辺の話は次回にさせていただく予定です(まとめるのつらい・・)
参考URL
スタブでオリジナルのメソッドを呼び出す
Test::Mock::Guardモジュール便利ですよね。シンプルで使いやすいので、よくこれでメソッドの動作を変えたりしてます。
しかしこの前、かなりシビアな条件でのみ起こり得るバグに遭遇してしまい、
Forkしたりスリープ埋め込んだりして、
なんとか再現しようとしたら見事に再帰りました(¨)( :)(..)(: )ゴ-ロゴロ
#!/usr/bin/env perl use strict; use warnings; use utf8; use Test::More; use Test::Mock::Guard 'mock_guard'; run_test() if $ENV{ HARNESS_ACTIVE }; { package Test; sub add { return $_[0] + $_[1]; } } sub run_test { my $guard = mock_guard( 'Test', +{ add => sub { print "hoge\n"; sleep 1; shift->add( 1, 2 ); } } ); # オリジナルのTest->addメソッドを期待していた is( Test->add( 1, 2 ), 3 ); done_testing; return; }
これを実行するとこうなる。
akata:^_^[~/perl_test/mock_test]$ prove -v main.pl main.pl .. hoge hoge . .
( ゚д゚)ポカーン
モックされたメソッドが永遠に呼び出され続けています。
仕方がないのでTest::MockModuleモジュールを使って、内部からオリジナルのメソッドを呼び出すことで解決出来ました。
sub run_test { my $module = Test::MockModule->new( 'Test' ); $module->mock( 'add', sub { shift; print "hoge\n"; sleep 1; $module->original( 'add' )->( @_ ) } ); is( Test->add( 1, 2 ), 3 ); done_testing; return; }
akata:^_^[~/perl_test/mock_test]$ prove -v main.pl main.pl .. hoge ok 1 1..1 ok All tests successful. Files=1, Tests=1, 2 wallclock secs ( 0.02 usr 0.00 sys + 0.03 cusr 0.00 csys = 0.05 CPU) Result: PASS
うーん。いけたけどこれしかないのかな(。-`ω´-)ウーム
ScalaをインストールするためのAnsible-Roleを書いた
ちゃんとgit pull
した後にpullされているか確認しましょう。
ひどい目にあいますよ\(^o^)/
はい、今回はScala(ついでにsbtも)です。結論:めっちゃ簡単だった
第1弾Git
: GitをインストールするためのAnsible-Roleを書いた - Akata Works第2弾Vim
: VimをインストールするためのAnsible-Roleを書いた - Akata Works第3弾Zsh
: ZshをインストールするためのAnsible-Roleを書いた - Akata Works
Yumリポジトリの追加をget_urlモジュールでやりました。
あと、yumモジュールにURLを指定するとrpmファイルをlocal installできます。
localhostオプションは非推奨らしいのでこっちを使いましょー!!
とまあ、このへんがポイントでしょうか??
短いからソースコードも貼っとこう(これで安心だ)
--- - name: Install dependent repositories get_url: url=https://bintray.com/sbt/rpm/rpm dest=/etc/yum.repos.d/bintray-sbt-rpm.repo mode=0644 - name: Install dependent libraries yum: name={{ item }} state=present with_items: - java7 - http://downloads.typesafe.com/scala/2.11.4/scala-2.11.4.rpm - sbt
local installだとyumモジュールの冪等性チェックに時間がかかるような気がします。
ダウンロードとインストールを分けたほうがいいかもしれませんね。
おまけ
git pull
されてなかったのはこれと同じっぽい
submoduleの闇にはまりつつあるな・・
参考URL
PerlとDBIxでトランザクションのテストをするときに注意したいこと
複数のテーブルを操作するときトランザクションを貼る必要がありますよね??
そこでよくTest::Mock::Guardなどのモジュールを使って
擬似的にエラーを吐かせたりするんですが、ちょっと詰まったとこがあったのでここにメモしておきます。
下の関数はとあるスキーマのレコードを2つ渡すとそのname属性を"c"と"d"に変更してくれるなんともクソみたいな関数です。
面倒臭かったので引数でエラーの制御をしちゃってます(ちゃんとTest::Mock::Guardモジュールを使いましょう!!)
#!/usr/bin/env perl use strict; use warnings; use utf8; use MySchema; use Test::More; run_test() if $ENV{ HARNESS_ACTIVE }; sub update_a_and_b { my ( $schema, $a, $b, $is_occurred ) = @_; $schema->txn_do( sub { $a->update( { name => 'c' } ); die if $is_occurred; $b->update( { name => 'd' } ); } ); return; } sub run_test { my $schema = MySchema->connect( 'DBI:mysql:database=my_schema;host=localhost;', 'username', 'password' ) or die 'Can not connect database $!'; # Create tables with Result classes. # "add_drop_table" option is executing "drop table" at beforehand. $schema->deploy( { add_drop_table => 1, } ); my $my_table_rs = $schema->resultset( 'MyTable' ); note( 'Not occurred error in transction.' ); { my $a = $my_table_rs->find_or_create( { name => 'a' } ); my $b = $my_table_rs->find_or_create( { name => 'b' } ); eval{ update_a_and_b( $schema, $a, $b, 0 ) }; note( "Error: $@" ) if $@; is( $a->name, 'c' ); is( $b->name, 'd' ); } note( 'Not occurred error in transction.' ); { my $a = $my_table_rs->find_or_create( { name => 'a' } ); my $b = $my_table_rs->find_or_create( { name => 'b' } ); eval{ update_a_and_b( $schema, $a, $b, 1 ) }; note( "Error: $@" ) if $@; is( $a->name, 'a' ); is( $b->name, 'b' ); } done_testing; return; }
一見、2回目のupdate_a_and_b関数でエラーが発生し、更新されていたaのname属性がロールバックされそうです。
それではテストを実行してみましょう。
akata:^_^[~/perl/dbix-transaction]$ prove -Ilib -v main.pl main.pl .. # Not occurred error in transction. ok 1 ok 2 # Not occurred error in transction. # Error: DBIx::Class::Schema::txn_do(): Died at main.pl line 21. not ok 3 # Failed test at main.pl line 64. # got: 'c' # expected: 'a' ok 4 1..4 # Looks like you failed 1 test of 4. Dubious, test returned 1 (wstat 256, 0x100) Failed 1/4 subtests Test Summary Report ------------------- main.pl (Wstat: 256 Tests: 4 Failed: 1) Failed test: 3 Non-zero exit status: 1 Files=1, Tests=4, 0 wallclock secs ( 0.02 usr 0.01 sys + 0.15 cusr 0.05 csys = 0.23 CPU) Result: FAIL
はい、エラー
実はDBのデータはちゃんとロールバックされるのですが、Resultオブジェクトのカラムは元に戻らないみたいなんですよね・・
なので、テストを行う前にきちんとデータをDBから引っ張ってきましょう。
データの取得はdiscard_changesメソッドで簡単にできます。
こうかな??
note( 'Not occurred error in transction.' ); { my $a = $my_table_rs->find_or_create( { name => 'a' } ); my $b = $my_table_rs->find_or_create( { name => 'b' } ); eval{ update_a_and_b( $schema, $a, $b, 1 ) }; note( "Error: $@" ) if $@; $a->discard_changes; is( $a->name, 'a' ); is( $b->name, 'b' ); }
こっちのほうがいいかな??
sub update_a_and_b { my ( $schema, $a, $b, $is_occurred ) = @_; # use Try::Tinyすること try { $schema->txn_do( sub { $a->update( { name => 'c' } ); die if $is_occurred; $b->update( { name => 'd' } ); } ); } catch { $a->discard_changes; $b->discard_changes; }; return; }
テスト以前に、後々処理が続く機能(そもそもそういうのはいいのか??)のことを考えたら、
個人的には下かな〜と思うのですが、一応どちらでもテストは通りますよ。
akata:^_^[~]$ prove -Ilib -v main.pl main.pl .. # Not occurred error in transction. ok 1 ok 2 # Not occurred error in transction. ok 3 ok 4 1..4 ok All tests successful. Files=1, Tests=4, 1 wallclock secs ( 0.02 usr 0.00 sys + 0.18 cusr 0.04 csys = 0.24 CPU) Result: PASS
あと、たまにロールバックされる前にテストが呼ばれることもあったので、
きちんとロールバックされているか確認したほうがいいかもしれません。
ZshでChange DirectoryしたときにList Segmentsする
Zshでcdしたときにlsされると便利かな~って思って適当に実装してみた。
手っ取り早く考えたのがこれ
alias cd="cd $1 ls"
まあコレはちゃんと動かないんですけどね。
エイリアスは引数を取れないので、問答無用でホームディレクトリ帰され、lsに引数が渡されたことになります。
$ cd hoge/
これが
$ cd && ls hoge/
こうなる(┐「ε:)ズコー====
次に考えたのがこれ
alias cd="cdls" function cdls() { cd $1 && ls }
いけそうな気がする・・
cdls:1: maximum nested function level reached
が……駄目っ……!
コレはcdのエイリアスが有効であるため、再帰的にcdが呼ばれ続けてエラーを吐いている。
と言う訳でbuiltinを使いましょう。
builtinはユーザ実装の関数やエイリアスよりシェル・コマンドを優先的に実行するためのコマンドです。
alias cd="cdls" function cdls() { builtin cd $1 && ls }
これでエイリアスのcdではなく、シェル・コマンドのcdが呼ばれるようになります。
akata:^_^[~]$ cd hoge bar foo hoge huga akata:^_^[~/hoge]$
(∩´∀`)∩ワーイいけました。
ちなみにこれだとlsコマンドの方はエイリアスが有効になっているので、
既存のエイリアスを使い回せます。
後、確認していませんが多分Bashでもちゃんと動くと思います(タブンネ..)
参考URL
Linuxコマンド集 - 【 builtin 】 シェル・コマンドを優先して実行する:ITpro
ここに知りたいことは全部書いていました。
ZshをインストールするためのAnsible-Roleを書いた
AnsibleのRoleシリーズの第3弾はシェルスクリプトのZshです。
akatakun/ansible-role-zsh · GitHub
第1弾Git
: GitをインストールするためのAnsible-Roleを書いた - Akata Works第2弾Vim
: VimをインストールするためのAnsible-Roleを書いた - Akata Works第4弾Scala
: ScalaをインストールするためのAnsible-Roleを書いた - Akata Works
そして、設定がめんどくさい基本ツールのRoleは全部書き終わったような気がします。
やはりほとんど第1弾と第2弾の使い回しです・・
でも、使い回しが容易であることもAnsibleの良いところだと思います(学習コストも低いですし・・)
話は逸れますが、
僕はこれらのリポジトリをPrivateリポジトリのSubModuleとして持っており、
ansible-playbook
コマンド一発で環境構築ができるようにしています。
イメージとしてはこんな感じ??
* ansible/ # Privateリポジトリ * initialization.yml # Root Playbook * roles/ * git/ # 第1弾 * vim/ # 第2弾 * zsh/ # 第3弾
まじsubmodule便利っす
と、まあ、
最初はログインシェルの変更までやるつもりでしたが、飽きちゃったんで設定ファイルのシンクまでです。
気が向いたら更新とTmuxとか他のツールのRoleも書いていこうかな(*´ڡ`●)
というかさっさとScalaやらねば・・
perl: warning: Setting locale failed.
MacからSSHでCentOSに接続してPerlを実行すると以下のエラーが出た。
perl: warning: Setting locale failed. perl: warning: Please check that your locale settings: LANGUAGE = (unset), LC_ALL = (unset), LANG = "ja_JP.UTF-8" are supported and installed on your system. perl: warning: Falling back to the standard locale ("C").
現在のロケールの設定の中にインストールされていないロケールがあることが原因らしい。
現在のロケールの設定はlocale
コマンドで確認できるので確認するといい。
インストールされていなければついでにインストールしてしまおう。
localedef -f UTF-8 -i ja_JP ja_JP.UTF-8
根本的なことをいうと、単純にMacの設定を引き継いでいるからってこともある。
"/etc/ssh_config"ファイルから以下の行をコメントアウトするといい。
Host * SendEnv LANG LC_*
ロケールの問題なので他のコマンドでも発生するかも・・