Gaiax Engineers' Blog

Gaiaxのエンジニアブログです。 社内のエンジニア様子、イベントレポート等を発信していきます。

FuelPHPとパフォーマンスチューニング

こんにちは!Gaiax Advent Calendarの3日目を担当します、2年目エンジニアの@shirakiya831です!
昨日は1年目の新人が9ヶ月を振り返っていましたが、今日は9ヶ月間ほどずっと取り組んでいるFuelPHPのパフォーマンスチューニングの話をしようと思います。

(※FuelPHPを触ったことがある人向けの記事になっておりますので、あらかじめご了承を。)

なんでFuelPHP

そもそもGaiaxはPerlに注力している会社です。YAPCのスポンサーにもなっているほどで、Perl製のアプリケーションが多く動いています。(*「YAPC」とはPerlの日本最大のカンファレンスです。)

とは言っても全てPerlで動いているのかと言われるとそうではなく、私が所属しているエアリー事業部のエアリーフレッシャーズPHP製のFuelPHPをWAFとして採用しています。
採用した理由は当時所属していたメンバーがPHPerが多かったというのもあり、その開発スピードを優先したそうです。
日本ではユーザーが比較的多いCakePHP等、他のWAFも検討したそうですが、2013年時点でのPHPの対応バージョン、機能面や書きやすさ等を考慮してFuelPHPが採用されたという歴史があるようです。

PHPのWAFのそれぞれの特徴などは他の比較記事に譲りますが、ガチガチに規約があるわけでもなくかと言ってセキュリティなどの全ての機能を自分でモリモリ準備しないといけないというのがFuelPHPのざっくりした個人的な感想です。

FuelPHPのパフォーマンスで気にしないといけないこと

2013年からFuelPHPで開発が続けられてきましたが、以前より CRUD操作全般で処理が遅い という問題がありました。

その原因は

  • Modelオブジェクトの生成が遅い
  • ORMでのデータベース(DB)操作は罠がいっぱいある

の2点が影響度としては大きいです。それらについて説明していきます。

FuelPHPのModelオブジェクトは生成が遅い

FuelPHPは標準で独自ORMを提供しています。が、このORMを用いて生成されるオブジェクト(Modelオブジェクト)が重たいです。具体的にはこのオブジェクトの生成時のパフォーマンスが悪いです。

ORMを用いてModelインスタンスを生成する場合と、DB::select()(DBにSQLを直接投げるFuelPHP組み込みの関数)を使い、同じオブジェクトを生成するためにstdClassのインスタンスを生成する場合を比較してみました。

<?php
...

/* ORMを使ってModelインスタンスを生成する場合 */
$mem_usage = memory_get_peak_usage();
$start = microtime(true);

// 1000件の投稿データを取得する
$posts = Model_Post::find('all');

// データをDBから取り出し、インスタンス生成にかかった時間
echo microtime(true) - $start;             //=> 0.38421 sec
// 使用したメモリ
echo memory_get_peak_usage() - $mem_usage; //=> 10,318,096 B
<?php
...

/* ORMを使わずstdClassのインスタンスを生成する場合 */
$mem_usage = memory_get_peak_usage();
$start = microtime(true);

// 1000件の投稿データを取得する
$posts = DB::select()->from('posts')->execute()->as_array();
$posts = array_map(function($post) {
    return (object) $post;
}, $posts);

// データをDBから取り出し、インスタンス生成にかかった時間
echo microtime(true) - $start;             //=> 0.02893 sec
// 使用したメモリ
echo memory_get_peak_usage() - $mem_usage; //=> 6,626,800 B

※ PHP5.4.42、FuelPHP1.7、デフォルトの設定で検証。秒数は環境で変化するので比較的な視点で見ていただけると。

一目瞭然ですね。 今回、投稿データPostsにはテストとして簡便なデータしか入れていませんが、実運用しているものならば様々な設定やリレーションが定義されていたりして、よりModelオブジェクトを生成した場合は時間もメモリも大きくなることは必至です。

なので、ただデータを取り出すだけならばDB::select()等を使うなど、Modelオブジェクトを使うのかどうかは注意を払うようにした方が良さそうです。

FuelPHPのORMを使ったデータベース操作は罠がいっぱい

端的に言うと、FuelPHPのORMは知らない間に大量にクエリを発行していたりするので、これがパフォーマンス低下につながります。

実際にORMでよく使われるであろうメソッドと発行されたクエリ、そしてデータを取り出すのに要した時間を先ほどのCRUD観点で見ていきます。 (以下、引き続き1000件の投稿データPostsを使っていきます)

READ(SELECT)での罠

ORMのSELECT文を発行するfindメソッドは、1テーブルだけからデータを取得して使うというケースだと問題ないのですが、リレーションを定義している時に罠があります。

<?php
...

$posts = Model_Post::find('all')

//=> query: SELECT * FROM `posts` (※これ以降も含めて多少単純化してます)
//=> time: 0.38421 sec

ごく普通ですが、投稿データPostsに投稿者を表すUsersをリレーションを定義していた場合、以下のような書き方ができます。そしてこれは多用されていると思います。

<?php
...

$posts = Model_Post::find('all');
foreach ($posts as $post) {
    $user_names[] = $post->user->name;
}
//=> query:
//    1. SELECT * FROM `posts`;
//    2. SELECT * FROM `users` WHERE (`id` = '1') LIMIT 1;
//    3. SELECT * FROM `users` WHERE (`id` = '1') LIMIT 1;
//    ...
//    1001. SELECT * FROM `users` WHERE (`id` = '1') LIMIT 1;
//=> time: 2.99296 sec (マジか!!)

出ましたね、化けの皮。
FuelPHPのORMは遅延読み込みと呼ばれる機能があり、予め明示的にリレーション先のテーブルをJOINしてデータを取るように書かなくても、必要なときにリレーション先のデータをModelオブジェクトとして取得することができます。 ですが、この機能を使うと恐ろしく遅くなります。DB::select()を使うことをオススメしますが、Modelオブジェクトを使いたいときは極力以下のように先にJOINしてデータを取得するようにした方が良いです。

<?php
...

$posts = Model_Post('all', array(
    'releted' => array('user'),
));
foreach ($posts as $post) {
    $user_names[] = $post->user->name;
}
//=> query: SELECT * FROM `posts` AS `t0` LEFT JOIN `users` AS `t1` ON (`t0`.`user_id` = `t1`.`id`);
//=> time: 0.58088 sec

UPDATE(UPDATE)、DELETE(DELETE)の罠

ORMのUPDATE文を発行するsaveメソッドは1オブジェクトにつきsaveメソッド1コールという使い方をしないといけないので、大量データの更新の時にパフォーマンスが落ちてしまいます。完全にN+1状態です。

<?php
...

$posts = Model_Post::find('all');

foreach ($posts as $post) {
    $post->is_edited = 1;
    $post->save();
}

//=> query:
//    1. SELECT * FROM `posts`;
//    2. UPDATE `posts` SET `is_edited` = 1 WHERE `id` = '1'
//    3. UPDATE `posts` SET `is_edited` = 1 WHERE `id` = '2'
//    ...
//    1001. UPDATE `posts` SET `is_edited` = 1 WHERE `id` = '1000'
//=> time: 4.9598 sec

このようにクエリが大量に発行されるので、なるべく少ないクエリ数で一気に更新をかけた方が圧倒的に高速です。(そもそも取得とModelインスタンスの生成すらしなくていいですからね!)
そこでDB::update()を代わりに使ってみます。

<?php
...

DB::update('posts')
    ->set(array('is_edited' => 1))
    ->where('id', '<=', 1000)
    ->execute();

//=> query: UPDATE `posts` SET `is_edited` = 1
//=> time: 0.015708 sec

削除時も同様で、DELETE文を発行するdelete()メソッドも同じことが言えます。
(削除時はDB::update()の代わりにDB::delete()を使います。)

CREATE(INSERT)

ORMのINSERT文を発行するのもUPDATE時と同じくsave()メソッドを使います。
UPDATEの時と同じように、Modelオブジェクトを生成して、1つ1つのオブジェクトに対してsave()大量のクエリが発行されることになります。

<?php
...

for ($i=0; $i<1000; $i++) {
    $post = Model_Post::forge(array(
        'body'      => "test $i"
        'is_edited' => 0,
    ));
    $post->save();
}

//=> query:
//    1. INSERT INTO `posts` (`body`, `is_edited`) VALUES ('test 0', '0')
//    2. INSERT INTO `posts` (`body`, `is_edited`) VALUES ('test 1', '0')
//    ...
//    1000. INSERT INTO `posts` (`body`, `is_edited`) VALUES ('test 999', '0')
//=> time: 4.5239 sec

こちらもDB::insert()という関数が存在するのですが、以下のようにして、1クエリで大量レコードに対してINSERTを実行することができます。(いわゆるBULK INSERT)

<?php
...

DB::insert('posts')
    ->columns(array('body','is_edited'))
    ->values(array('test 0', 2))
    ->values(array('test 1', 2))
    ...
    ->values(array('test 999', 2))
    ->execute();

//=> query: INSERT INTO `posts` (`body`, `is_edited`) VALUES ('test 0', '0'), ('test 1', '0'), ('test 2', '0'), ..., ('test 999', '0')
//=> time: 0.07471 sec

(実際には1000レコード分データを挿入したいときは->values()を1000行書くのは目も当てられないので、->values()が生成する文字列を連結させてDatabase_Query_Builderクラスに食わせるように独自に拡張した関数を作りました。)

これらでDB操作の全てにおいてパフォーマンスを向上することができると思います。

FuelPHPに限った話ではないと思いますが、ORMはそれ自身が発行するクエリをよく意識して使わないと今回のようなケースになっていたりするので、ちゃんとクエリログを出力することなどがとても重要で、気をつけていきましょう!

さいごに

長くなりましたが、今回はFuelPHPのデータベース操作の罠とその解決方法を紹介しました。
以上のことを意識して開発していくと、サーバーサイドの処理がとても遅く、後で全体を通してコードを修正しないといけないという悲劇は起こりにくくなるのではと思います。(…言っていて悲しい)

明日は私の兄貴に激似だと私の中で話題の先輩エンジニア、kyuu1999さんのGROUP_CONCAT()がアツい」というお話です。
実際私も長らく続けていたパフォーマンスチューニングの中でGROUP_CONCATを使ってデータの取得スピードを上げることにも使っていたので、どのようなお話になるのか楽しみです!

では!