CakePHP + MySQL アプリのテスト時間を 72% 削減した話

この記事は CakePHP Advent Calendar 2018 の23日目の記事です。

CakePHP アプリケーションのユニットテストでボトルネックになりがちなのがデータベースへのディスク I/O 時間ですが、そういったプロジェクトでは FriendsOfCake/fixturize プラグイン を導入すると大幅に高速化できる可能性があります。

実例として、テストに18分も掛かっていた MySQL を使ったプロジェクトありましたが、導入後なんと5分にまで短縮されました。(約 72% の高速化🎉)

結果として早いサイクルで CI を回せるようになり開発スピードも上がりました。

何がボトルネックになっていたか?

テストケースごとに実行されるテストデータのリロードクエリがボトルネックになっていました。リロードクエリとは TRUNCATE TABLE クエリと INSERT クエリのセットのことで、積み重なると高コストなクエリになります。

テストデータのリロード時間はテストケースが増えるほど支配的なものになる

CakePHP のユニットテストは、テストデータを Fixture クラスとして定義しておき、テストクラスの側で使用したい Fixture 名の一覧を記述します。

例として、テストクラスは以下のようになります。

<?php
namespace App\Test\TestCase;
use Cake\TestSuite\TestCase;

class ArticlesTest extends TestCase
{
    // 3テーブル分のテストデータの使用を宣言
    public $fixtures = [
        'app.articles',
        'app.comments',
        'app.users',
    ];

    public function testCase1() {
        // テストコード
    }

    public function testCase2() {
        // テストコード
    }
}

このテストを実行すると testCase1()testCase2() のテストケースの実行前に、それぞれ3テーブル分のリロードクエリが実行されます。同じように、このクラスに100個のテストケースがあったとしたらリロードクエリは最低で300回実行されます。

極端な例を挙げましたが、リロード回数はテスト数とフィクスチャ数の掛け算で増えていくため、簡単に無視できない I/O コストになることは想像に難くないと思います。

毎回テストデータをリロードする理由

無意味に何度もリロードしている訳ではありません。毎回リロードすることで、テストが書きやすくなるというメリットがあります。

テスト中にいくらテストデータを書き換えたとしても、次のテストでは元の状態にリセットされているので、テストケース間でテストデータの状態について副作用を気にする必要がなくなります。

この高速化プラグインがすること

FriendsOfCake/fixturize のソースを見ると分かりますが、このプラグインのすることは非常にシンプルです。上述の「毎回同じテストデータの状態でテストを開始したい!」という要求を満たしつつ、必要なときだけリロードするようにし、リロードクエリの実行回数を削減します。

具体的には、初回のフィクスチャロード後に CHECKSUM TABLE クエリ でテーブルのチェックサム値を取っておき、次以降のリロードは差分があったときだけ行うようにします。

チェックサム値に差分がなければテストデータが変更されていないということなので、リロードもスキップして問題ないということになります。

プラグインで高速化できる可能性のあるプロジェクト

ということで、以下に該当するプロジェクトであればプラグインを導入するだけで大幅にテスト時間を削減できる可能性があります。

  • CakePHP 3.x 製アプリ
  • MySQL / MariaDB / Percona のいずれかを使用している
  • テストケース数とテーブル数が多いプロジェクト
  • 複数テーブルを参照する結合的なテストをしているプロジェクト

また、もしこのプラグインが使えない環境のプロジェクトであっても、不要なロードを減らすというアプローチ自体は有効かもしれません。

おまけ: その他のアプローチ

他のアドベントカレンダーになりますが、素晴らしい記事を見かけましたのでそちらも紹介しておきます。

DB ストレージを tmpfs というオンメモリのファイルシステムに差し替え、高コストなディスク IO を無くすることで高速化するというアプローチです。私も手元のプロジェクトで試してみましたが、実際に 40% ほど高速化できました。FriendsOfCake/fixturize プラグイン と併用することで更に高速化が見込めます。

MySQLの複合主キーテーブル検索時の速度比較

MySQL の複合主キーテーブルのレコードを何件かピックアップして主キー検索したい機会があったので、いくつかのパターンの SQL で検索してパフォーマンス比較をしてみました。

実験の内容

  • それぞれ適当にピックアップした15レコードを1クエリで検索する
  • 実行時間は同じクエリを5回実行したときの最小と最大の時間を計測
  • レコードは適当に100,000件投入

実験環境

  • Ubuntu 13.10 (on Vagrant)
  • MySQL 5.5.37
  • InnoDB

使用したテーブル

CREATE TABLE `myTable` (
  `player_id` int(11) NOT NULL,
  `id` int(11) NOT NULL,
  `body` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`player_id`,`id`)
) ENGINE=InnoDB;

各検索方法の実行時間とインデックス使用状況

1. OR検索の場合

SQL例

SELECT * FROM `myTable`
WHERE  (`player_id` = 50 AND `id` = 4856)
  OR (`player_id` = 61 AND `id` = 4274);

※実験時は15個のORを繋げて検索しましたが長くなるのでここでは省略しています。

実行時間

1.2ms ~ 1.5ms

EXPLAIN結果

id select_type table type possible_keys key key_len ref rows Extra
1 SIMPLE myTable range PRIMARY PRIMARY 8 NULL 15 Using where

2. UNION ALL検索の場合

SQL例

SELECT * FROM `myTable` WHERE (`player_id` = 50 AND `id` = 4856)
UNION ALL
SELECT * FROM `myTable` WHERE (`player_id` = 61 AND `id` = 4274);

実行時間

1.5ms ~ 2.0ms

EXPLAIN結果

id select_type table type possible_keys key key_len ref rows Extra
1 PRIMARY myTable const PRIMARY PRIMARY 8 const,const 1
2 UNION myTable const PRIMARY PRIMARY 8 const,const 1
15 UNION myTable const PRIMARY PRIMARY 8 const,const 1
NULL UNION RESULT ALL NULL NULL NULL NULL NULL

3. WHERE IN の複合カラム指定による検索の場合

SQL例

SELECT * FROM `myTable`
WHERE (player_id, id) IN ((50, 4856),(61, 4274));

実行時間

40.9ms ~ 49.7ms

EXPLAIN結果

id select_type table type possible_keys key key_len ref rows Extra
1 SIMPLE myTable ALL NULL NULL NULL NULL 100714 Using where

コメント

最も高速なのは普通に OR で繋げる検索でした。range スキャンになるんですね。次点で UNION ALL 検索。ちなみに ALL 無しの UNION に変えてもほぼ同じ速度でした。

そして最も遅かったのは WHERE IN 検索でした。IN 句に指定する件数が多いとオプティマイザはフルスキャンを選択するため大幅に遅い結果になりました。

シェルからCakePHPを使うとAPC書き込みに失敗する問題

ウェブからアプリケーションにアクセスしてAPCを使うと普通に使えるのに、
コマンドラインから cake すると何故か使えなくて困っていました。

解決したので方法をメモしておきます。

環境

  • MAMP 2.1.3
  • PHP 5.4.10
  • CakePHP 2.4.0

事象

PHPUnitでテストしようと思ったらこんなエラーが出ていました。

$ ./lib/Cake/Console/cake test core AllTests
PHP Warning:  _cake_core_ cache was unable to write 'cake_dev_eng' to Apc cache in /var/www/line_street/lib/Cake/Cache/Cache.php on line 325
PHP Warning:  _cake_core_ cache was unable to write 'cake_console_eng' to Apc cache in /var/www/line_street/lib/Cake/Cache/Cache.php on line 325
Warning Error: include(PHPUnit/Autoload.php): failed to open stream: No such file or directory in [/var/www/line_street/lib/Cake/TestSuite/CakeTestSuiteDispatcher.php, line 150]

2013-11-24 15:36:39 Warning: include(PHPUnit/Autoload.php): failed to open stream: No such file or directory in [/var/www/line_street/lib/Cake/TestSuite/CakeTestSuiteDispatcher.php, line 150]
Warning Error: include(): Failed opening 'PHPUnit/Autoload.php' for inclusion (include_path='.:/usr/local/lib/php') in [/var/www/line_street/lib/Cake/TestSuite/CakeTestSuiteDispatcher.php, line 150]

2013-11-24 15:36:39 Warning: include(): Failed opening 'PHPUnit/Autoload.php' for inclusion (include_path='.:/usr/local/lib/php') in [/var/www/line_street/lib/Cake/TestSuite/CakeTestSuiteDispatcher.php, line 150]
Error: Please install PHPUnit framework (http://www.phpunit.de)
#0 /var/www/line_street/lib/Cake/Console/ShellDispatcher.php(208): TestShell->initialize()
#1 /var/www/line_street/lib/Cake/Console/ShellDispatcher.php(68): ShellDispatcher->dispatch()
#2 /var/www/line_street/lib/Cake/Console/cake.php(51): ShellDispatcher::run(Array)
#3 {main}
Warning Error: _cake_core_ cache was unable to write 'file_map' to Apc cache in [/var/www/line_street/lib/Cake/Cache/Cache.php, line 325]

2013-11-24 15:36:39 Warning: _cake_core_ cache was unable to write 'file_map' to Apc cache in [/var/www/line_street/lib/Cake/Cache/Cache.php, line 325]

ここで言われていることは二つ。

APC の書き込みが出来ないということと、PHPUnit をインストールしてくださいということです。

コマンドラインからの APC 有効化

app/Config/core.php のキャッシュエンジン設定はこうなってます。

$engine = 'Apc';

一応 php のパスを確認。

$ which php
/Applications/MAMP/bin/php/php5.4.10/bin/php

ちゃんと MAMP の PHP を読み込んでいるので大丈夫そうです。

次に apc_sma_info を実行してみて APC の動作確認。
今回は直接 php コマンドの -r オプションで実行して確認してみます。

$ /Applications/MAMP/bin/php/php5.4.10/bin/php -r 'var_dump(apc_sma_info());'
Warning: apc_sma_info(): No APC SMA info available.  Perhaps APC is disabled via apc.enabled? in Command line code on line 1

Call Stack:
    0.0005     226808   1. {main}() Command line code:0
    0.0005     227416   2. apc_sma_info() Command line code:1

bool(false)

false が返ってきていて、APC が無効だと言われています。
「php.ini の apc.enabled が有効になっていますか」という旨のことを言われているので確認します。

$ php -r 'phpinfo();' | grep apc.enable
apc.enable_cli => Off => Off
apc.enabled => On => On

apc.enabled はちゃんと有効でしたが、apc.enable_cliが無効になっています。
apc.enable_cli が無効だと、コマンドラインから APC を利用することが出来ません。

php.ini を編集します。

$ vi /Applications/MAMP/bin/php/php5.4.10/conf/php.ini

一番下に以下の設定を追記しました。

apc.enable_cli = 1

Apache を再起動して再度確認します。

$ php -r 'phpinfo();' | grep apc.enable
apc.enable_cli => On => On
apc.enabled => On => On

有効になりました!

PHPUnit のインストール

ついでにさっき怒られていた PHPUnit がインストールされていない件も対応します。

pear コマンドで PHPUnit のインストール先を確認しておきます。

$ /Applications/MAMP/bin/php/php5.4.10/bin/pear config-get php_dir
/Applications/MAMP/bin/php/php5.4.10/lib/php

インストールします。

$ /Applications/MAMP/bin/php/php5.4.10/lib/php upgrade-all
$ /Applications/MAMP/bin/php/php5.4.10/lib/php config-set auto_discover 1
$ /Applications/MAMP/bin/php/php5.4.10/lib/php install pear.phpunit.de/PHPUnit

エラーが出なければ OK です。
これで無事に CakePHP で PHPUnit が使えるようになりました。
今回は MAMP 環境でしたが、XAMPP でも似たような感じだと思います。

参考ドキュメント