目次
こんにちは、元気よく挨拶しようとすると「挨拶だけテンション高いのやめろ」と上司から言われるリスペクトのプログラマー、@sji_chです!
FuelPHPのOrmはデフォルトでオブジェクトキャッシュが有効となっていますが、イマイチいつどこで役立っているかはっきりしない部分があったり、キャッシュが有効なせいで思わぬ不具合を引き起こす場合があったりします。
この記事ではキャッシュが自動的に使われる場合と使われない場合について、キャッシュを破棄する方法、キャッシュをデータの取得時に無視する方法、そしてOrmを拡張してselectの条件を考慮したものにする方法について述べます。
Fuel v2では新たなOrmが開発されているところですが、今回取り上げるのはv1(1.7.2)のOrmです。
対象読者は以下のような人です。
- PHPの基本的な構文を把握している人
- FuelPHPのOrmについて、「もっとうまく付き合いたい」とか「キャッシュってどこで使われてるんだ?」とか「キャッシュうぜえ何だこれ」とか思うものの、自分でフレームワークのソースを読むのはかったるい人
- とても暇な人
なおOrmの話題ということで、画像はWikipediaからオウム目のアオボウシインコのものをお借りしています(By mauroguanandi CC BY 2.0, via Wikimedia Commons)。
キャッシュとは?
「キャッシュはアーキテクチャではない、ただの最適化だ」(*)
— Rob Pike
何らかのデータにアクセスした際、2度目以降の参照でより高速にアクセスできるような方法でそのデータを複製しておく仕組みを、キャッシュと呼びます。
先に前提知識の確認のため、PHPでの簡単なキャッシュの実装を示します。
皆さんは、同じ挙動を示す以下の(1)と(2)のコードのうち、どちらが高速だと思いますか?
$array = ['a','b','c',...,'abc','abd',...'zzz']; // (1) if (in_array('abc', $array)) { // なんらかの処理 } // ['a' => 0, 'b' => 1, 'c' => 2,...] $assoc = array_flip($array); // (2) if (isset($assoc ['abc'])){ // なんらかの処理 }
配列がある程度の要素数を持つ場合、後者がより高速です。
前者は内部的に線形探索を行うため(*)、時間効率はO(N)で、データ量に比例して処理時間がかかる可能性があります。
後者はハッシュテーブルを引くだけですので*1、衝突があまり起きなければ時間効率はO(1)程度で、ほぼ一定の速度でデータを取り出すことができます。
配列からのデータ取得が速いということは、例えばDBへの問い合わせのような重い処理で手に入れたデータを複数回利用する場合、一度取得した結果は配列に格納することで、2度目以降は実際のDBへのアクセスを行わず、高速にそのデータを参照することができそうです。
class Model_Test extends \Orm\Model { private static $cache = []; public function get_by_id($id) { if (!isset(static::$cache[$id])) { static::$cache[$id] = static::query() ->where('id', $id) ->get_one(); } return static::$cache[$id]; } }
これがPHPでのよくある単純なキャッシュの実装です。
FuelPHPのOrmが持つオブジェクトキャッシュも、おおむねこれに近い仕組みで実装されています。
有効期間はPHPプロセスの実行中となるため、Webシステムで使われる場合にHTTPリクエストをまたいでキャッシュを持ち続けることはできませんが、1つのリクエスト内で何度も参照されるデータに使えば高速化が期待できます。
- *1PHPの連想配列はハッシュテーブルに毛が生えたようなもの(順序が付いている)
OrmのキャッシュについてのTips
キャッシュが自動的に使われる状況を有効利用する
OrmのオブジェクトキャッシュはModelに設定されたプライマリキーの値*1をキー、クエリに伴って構築したOrmオブジェクトを値とする連想配列で保持されています。
Ormオブジェクトは
- find()やquery()で最初にレコードを取り出した際
- save()でDBへのINSERTが行われる際
にキャッシュへ登録され、2度目以降に同じレコードのオブジェクトをプライマリキーのみでfind()して取得する際、すなわちfind($pk)はほぼ配列からデータを取り出すだけの処理となり、非常に高速に動作します。しかし、純粋に速度的な面でキャッシュが有効に使われ、まともな効果が出るのはこのケースだけです。
$test = Model_Test::find(1); // 1回目はDBにクエリを飛ばす $test2 = Model_Test::find(1); // 2回目以降はキャッシュから取り出すだけで超速い
オブジェクトキャッシュはOrmオブジェクトをクエリの結果から構築するhydrate()*2の際にも参照され、すでに構築済のレコードのオブジェクトについては新しく生成(new)せず、キャッシュにあるデータを使うようになっています。
注意しなければならないのは、ここでのキャッシュの参照は実際にDBへのクエリを飛ばしデータを取得した後、メモリのどこへ取得したデータを放り込むかを決めるものであって、DBアクセスはバイパスされず、もっぱらオブジェクトの使い回しによるメモリ使用量削減とオブジェクト生成処理省略の効果しか出ないということです*3。
可能性としての話であれば、参照局所性が上がることでハードウェアのキャッシュが効きやすくなったり、GCへの負担が減って速度的にも有利になる、ということはあるかもしれません。しかし基本的に、この際のキャッシュ参照は速度的な面において、プライマリキーでのfind()のような劇的な効果をもたらすものではありません。
- *1複合プライマリキーの場合もあるため、正確にはOrmが独自の方法でプライマリキーをシリアライズした文字列
- *2由来は不明ですが、レコードのデータをオブジェクトのプロパティとして埋める処理はnHibernateなど他の多くのORMでもhydrationと呼ばれているようです
- *3これは性能面での話で、hydrate()の際のキャッシュ参照の本来の目的は、同じレコードに対するOrmオブジェクトの実体をプロセス全体で1つに統一し、複数箇所での更新/参照時の一貫性を保つことです(*)
キャッシュが自動的には使われない状況とその対処
その他の状況において、組み込みのオブジェクトキャッシュは使われません。
find(‘first’, $conditions)やfind(‘all’)、query()の使用時、またリレーションに設定したModelのオブジェクトをlazy loadingする際は、内部で行われるhydrate()の過程でオブジェクトキャッシュが参照されはします。しかしすでに述べたように、これは実際にDBへクエリを飛ばして結果を取得した後の話です。
プライマリキー以外を条件にデータを取得する際はもちろん、たとえ結果的にプライマリーキーのみを条件にデータを取得する場合であっても、find($pk)の形でただ1つの引数のみを与える使い方以外では、高速なキャッシュの参照は行われません。
$test = Model_Test::find(1); // クエリが飛ぶ $test = Model_Test::find('first', [ // クエリが飛ぶ 'where' => [ ['id' => 1] ] ]); $test = Model_Test::query() // 何かしらのユニークキーで取得 ->where('unique_key', 1) ->get_one(); $test = Model_Test::query() ->where('unique_key', 1) // 同じユニークキーで取得しても当然クエリが飛ぶ ->get_one();
プライマリキーだけではなく他のユニークキーを使ってキャッシュの恩恵を得たい場合や、ある程度複雑なクエリを飛ばす回数をキャッシュで減らしたい場合は、自前で取得結果のキャッシュを行う必要があります。
キャッシュをクリアする方法
FuelPHPのOrmでは、デフォルトの機能としてはキャッシュをクリアする方法が提供されていません。
このため、たとえばバッチ処理にOrmが使用され、数十万ものOrmオブジェクトを生成するようなことがあった場合、少し悲しいことが起きるかもしれません。Ormの利用コード側が各オブジェクトの参照を手放した後も、キャッシュが参照を握り続けることによって、GCによるメモリ領域の回収が行われず、PHPの実行環境で許容されるメモリ容量を食い潰してしまう可能性があります。
foreach ($超たくさんのid as $id) { // キャッシュにOrmオブジェクトが登録される $test = Model_Test::find($id); // キャッシュが参照を握ってるので、unsetや次のループで$testを上書きしてもGCされない unset($test); } // 実行しおわる前にPHPがメモリ容量不足で死ぬ
そもそも、バッチ処理にOrmのような重い方法を使おうとするのは賢い選択とは言えません。が、たとえ性能が犠牲になったとしても、なるべくクエリビルダや生SQLといった他の手段の利用を避けたいケースはあり得ます。たとえば、システムの他の部分での利便性のためにOrmを採用していて、Model側の処理の多くがOrm特有の機能(Observerやリレーション定義)に依存している場合などです。
現状の内部実装に依存した方法であってもよいので、とにかくキャッシュをクリアしながらOrmを利用したいという場合、オブジェクトキャッシュはOrm\Modelのprotectedメンバ$_cached_objectsに連想配列として保持されているため、継承でこれを空にするメソッドを加えることで、キャッシュをクリアすることができます(*)。
class Model_Test extends \Orm\Model { public static clear_cache() { static::$_cached_objects[get_called_class()] = []; } }
データの取得時にキャッシュを無視する(無効化する)方法
データの取得時に、一旦オブジェクトキャッシュを無視したいという場合があります。
例えばFuelPHPのOrmはfind()やquery()の際にselectを使うことができ、一部のカラムについてのみデータを取得することで、DBからフェッチするデータの量を減らして最適化することができます。
$test = Model_Test::query() ->select('id') // 「id」カラムのみフェッチ、他のプロパティはnullになる ->where('id', 1) ->get_one();
問題は、この一部のカラムについてのみデータを取得し構築したOrmオブジェクトが、やはりキャッシュに登録されてしまうということです。
処理のはじめの方でselectによってカラムを絞り、半端に構築したOrmオブジェクトが、後でカラムを絞らずにプライマリキーで()find()しようとした際にもキャッシュから取得されるため、本来有効な値の入っているべきプロパティからnullが取得されるというような、予想外のバグを引き起こす可能性があります*1。
他にもアプリケーション側の何らかの都合によって、Ormを通したデータ取得とOrmを通さないデータ更新が交互に行われるようなことがあった場合、実際のDBデータとキャッシュで内容にズレが生じてしまうことになるため、この場合もキャッシュから取得されるデータを使うのはバグの元になります。
また当然、Ormによるデータ更新をDB::rollback_transaction()で巻き戻した場合も、オブジェクトキャッシュの内容は巻き戻らず、DBデータとのズレが発生します。
最初にselectでカラムを絞って取得した後、次にfind($pk)の形で別のselect条件で同じレコードのOrmオブジェクトを取得する際は、先に述べた方法でキャッシュをまるごと破棄してしまうのも1つの対処法です。ただそこまでしなくとも、find()やquery()に与える設定でfrom_cache(false)を使うことで、データ取得の際にクエリ単位でキャッシュを無視させることもできます*2。
$partial = Model_Test::query() // 「id」カラムのみフェッチ ->select('id') ->where('id', 1) ->get_one(); $complete = Model_Test::query() // キャッシュを無視して取得 ->where('id', 1) ->from_cache(false) ->get_one();
- *1プライマリキーのみでのfind()以外の場合については特別に対策されている筈なのですが(*)、実際にはやはりキャッシュされた内容だけが取得されます。なんとなくバグな気がします。
- *2from_cache()は、こういったキャッシュ問題へのworkaroundとしてOrmへ後付けされたものです(*)
キャッシュ機構に手を入れてselectを考慮したものにする
「そもそも取得条件変えたのにキャッシュからデータ引っ張ってくるなんてバグじゃねーか!」と考える方もいらっしゃるかと思います。わりとその通りだと思います。
フレームワークの現状の内部実装に依存したダーティな、しかも制限付きの方法ですが、参考までにキャッシュ機構に手を入れてselectを考慮したものにする方法の例も書いておきます。
要するにキャッシュがselectの取得条件という利用時の文脈を考慮せず、プライマリキーの値だけを見て働くのがまずいわけなので、キャッシュのキーにその時の文脈、つまりselectの条件を含めてしまえばよいということです。複合プライマリキーの場合の実装を参考に、テキトーにシリアライズしてプライマリキーにつなげてしまいます。
元の\Orm\Model、\Orm\Queryからの変更部分を抜き出すと、以下のようなコードとなります。
class Model_Test extends \Orm\Model { public static function implode_pk($data) { $result = parent::implode_pk($data); if (static::$cache_context) { $result .= '['.static::$cache_context.']'; } return $result; } public static function cached_object($obj, $class = null) { $class = $class ?: get_called_class(); $id = (is_int($obj) or is_string($obj)) ? ((string) $obj).'['.static::$cache_context.']' : $class::implode_pk($obj); $result = ( ! empty(static::$_cached_objects[$class][$id])) ? static::$_cached_objects[$class][$id] : false; return $result; } static protected $cache_context = null; public static function set_cache_context($cache_context) { static::$cache_context = $cache_context; } protected static function reset_cache_context() { static::set_cache_context(join('|', array_keys(static::properties()))); } public static function _init() { static::reset_cache_context(); } public static function query($options = array()) { $result = Query_ContextAwareCache::forge(get_called_class(), array(static::connection(), static::connection(true)), $options); return $result; } public static function find($id = null, array $options = array()) { static::reset_cache_context(); return parent::find($id, $options); } } class Query_ContextAwareCache extends \Orm\Query { public function hydrate(&$row, $models, &$result, $model = null, $select = null, $primary_key = null) { if ($model) { $context = is_array($selects = $this->select()) ? join('|', str_replace($this->alias.'.', '', Arr::pluck($selects, 0))) : $selects; $model::set_cache_context($context); } // 中略 // 元コードがModel_Base::cached_object()を利用してるので置き換え $obj = $this->from_cache ? $model::cached_object($pk, $model) : false; // 以下略 } }
元コードでは\Orm\Query::hydrate()内で\Orm\Modelへの参照がハードコードされているため*1、この部分の書き換えのためにhydrate()をまるごと置き換えています。
また実際には、\Orm\Model::create()にあるDBへのINSERT時のキャッシュ更新処理も置き換える必要があります。
省略抜きの実装はGistに上げておきます。
注意点ですが、この方法ではselect条件ごとにOrmオブジェクトの実体が生成されるため、プロセス内に1つのDBレコードへ対応するOrmオブジェクトが複数生成されることになります。あるOrmオブジェクトを通してのデータ更新は、別のキャッシュ内のOrmオブジェクトには反映されません。これを避ける場合はキャッシュを分けるのではなく、前回取得時のselect条件にないものを取得しようとする際はクエリを飛ばしてキャッシュを更新する、という実装へ変えないとダメそうです。また、Model_SoftやModel_Temporal、Model_Nestedsetの利用時は別途オーバーライドする必要があります。
本来このような変更が必要な場合は、PRを出して本家への取り込みを目指す方がよいのかもしれません(手抜き工事で周りくどい方法を使っている部分は、素直で地道な実装に変えた上で)。
まとめなど
以上、FuelPHPのOrmとキャッシュの扱い方について述べてきました。
ORMは便利なツールである一方、使ってよいケースとダメなケースとがあり、ダメなケースの多くは性能的な問題の解決が困難となるケースです。導入した時点ではとても温厚で便利そうに見えたOrmが、ある日当然牙をむき問題の原因となる現象は、俗に「Ormの目が攻撃色になる」と呼ばれています(*)。
Ormによる性能劣化を避ける単純な方法はOrmを捨てることですが、この際にある程度は置き換えのための工数がかかります。内部実装の理解を深めキャッシュを有効に活用することで、Ormを捨てずに済むケースが増え、より適切な選択が可能になる場合もあることでしょう。
最初にRob Pikeの言葉を引用しましたが、彼は他にキャッシュについて次のようにも語っています。
いわく、 「単純なキャッシュバグなんてものは存在しない」(*)
いわく、 「キャッシュとは発生待ちのバグだ」(*)
FuelPHP v1のOrmオブジェクトキャッシュには、この記事ではカバーしきれていない他の側面、Rob Pikeが言うところの発生待ちのバグがあります。例えば、リレーションから引っ張るオブジェクトはfrom_cache(false)で参照を制御するオブジェクトキャッシュとは別の形でキャッシュされるため、あわせて使ってもうまくいかない場合があります。この問題についてはまた別の機会に書こうかと思います。
0から作り直されているv2のOrmについては、この記事の内容は殆ど当てはまらなくなるかもしれません。現在FuelPHP v2のOrmを手がけているUruは、かつてStackOverflowでのOrmについての質問への回答で、v2ではキャッシュをoffにする機能をデフォルトで提供するという話をしていたことがあります(*)。
現在v2のOrmは基本的なCRUDやリレーションの仕組みが実装されたという段階ですが、今のところまだオブジェクトキャッシュの機構は実装されていないようです。
また部分的なカラムのselectについては、v2のOrmでは実装されない可能性もあります(*)。
追記(2015/3/11 23:41)
最初「データの取得時にキャッシュを無視する(無効化する)方法」のところで「プライマリキーのみでのfind()以外の場合については特別に対策されているのでダイジョブ」というむねの記述をしていましたが、よくよく実験結果見たらダメでした。Ormのコードに入ってる対策部分が効いてないようなので、記述を修正しています