Akata Works

東京エンジニア。主にRuby,Go,たまにAWSとiOS。ゲーム音楽が好きです。連絡はTwitterかakata.onen@gmail.comまで

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も)です。結論:めっちゃ簡単だった

akatakun/scala · GitHub

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

そして、設定がめんどくさい基本ツールの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からSSHCentOSに接続して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_*

ロケールの問題なので他のコマンドでも発生するかも・・

参考URL

centos に gitolite 導入, あとロケール問題 - Please Sleep

IBM Knowledge Center