CakePHPでUPDATEするときのクエリを(少しだけ)減らしてみた

この記事はだいぶ前に書かれたものなので情報が古いかもしれません
CakePHPでINSERTやUPDATEの処理を行うときって、どちらもsave()メソッドを使いますよね。

//INSERT時
$this->Model->create($data);
$this->Model->save($data);

//UPDATE時
$this->Model->set($data);
$this->Model->save($data);

あとはまあ、UPDATEのときにはsaveField()というメソッドもあるけど、あれは最終的にはsave()メソッド呼び出してます。

save()メソッドの便利なところは、入力するデータ(上の場合だと$data)にプライマリーキーの値がセットされていたら、自動的にUPDATE文にしてくれるってところですよね。

で、さらにこいつは、UPDATEのときはその値がキーになっているレコードが存在するかどうかを、SELECT文を投げて確かめてくれる。あったら何の遠慮もなくUPDATE文を発行し、もしなければINSERT文の発行に切り替えてくれる。

なるほど、素晴らしい。

ただまあ、INSERTのときは良いんですけど、UPDATEのときにはちょっとした問題……問題ってほとでもないかな、ちょっと気になる部分があって、個人的にはそれを何とかしたいな~ってずっと思ってたので、今回はそれをやってみることにしました。



気になる部分って?

結論から言うと、同じSELECT文を2回投げてやがるんですよ、Cakeさんが。

UPDATE文を発行するときにはまずSELECT文で更新しようとしているレコードが存在するか確かめてくれるとさっき言いましたね。

実際、SQLのデバッグを見ると分かるんですけど、UPDATE文が発行されている前には必ずSELECT文があります。

SELECT COUNT(*) AS `count` FROM `table` AS `Model` WHERE `Model`.`id` = 1
UPDATE `table` SET `id` = 1, `updated` = '2012-07-17 17:22:16' WHERE `Model`.`id` = 1

めんどくさいんでプライマリーキーの名前は『id』ってことにします。以下idでよろすく。

上記の場合だとidが1のレコードをUPDATEしているわけですね。すでにidが1のレコードが存在したので、save()メソッドが気を利かせてINSERTせずにUPDATEしてくれたわけです。

それ自体は良いことなんですけど、実際にはこの部分、こんな感じになってます。

SELECT COUNT(*) AS `count` FROM `table` AS `Model` WHERE `Model`.`id` = 1
SELECT COUNT(*) AS `count` FROM `table` AS `Model` WHERE `Model`.`id` = 1
UPDATE `table` SET `id` = 1, `updated` = '2012-07-17 17:22:16' WHERE `Model`.`id` = 1

全く同じSELECT分を二回投げておられるのです。一回目でレコードが存在したことは分かったはずなのに、右見て左見てもう一回右を見る道路横断時の安全確認のように、もっかいレコードの存在を確かめてます。

きっと疑り深いんですね、Cakeさんは。大好きなお兄ちゃんが浮気してないか心配で心配で、何度もケータイをチェックしてしまう性格ですね、きっと。

たいした問題じゃないといえば確かにたいした問題じゃないとは思うんですけど、でも単純計算してクエリの量が1.5倍に増えてますからね。UPDATE処理を1000回行おうと思ったら、本来なら2000個のクエリで間に合うところを3000回も投げちゃうわけですよ。10000回行おうと思ったら、20000回で良いところを30000回もクエリを投げてしまいます。1000000回行おうと思ったr

きっとヤンデレなんですね、Cakeさんは。お兄ちゃんのことが好きすぎて必要以上にメールを送ってしまうんですね、きっと。

「お兄ちゃん、元気してるかな?」「どうして返事くれないの、お兄ちゃん?」「ふふっ……そうだよね。あたしからのメールなんてうざいだけだよね……」「もしかして……誰かと一緒にいるの?」「そうか……あの女ね。あの女が……」「許さない、あの女……よくもあたしのお兄ちゃんを……」「ころすコロスころすコロスころすコロスころすコロスころすコロスころすコロスころすコロス」

ヤンデレってこんなイメージで合ってる?



何で2回投げているのでしょーか

ケータイの画面を覗い……じゃなくって、モデルのsave()メソッドを覗いてみると、こんな一文があります。

$this->exists();

//exist()メソッドの中身
function exists() {
    if ($this->getID() === false) {
        return false;
    }
		
    $conditions = array($this->alias . '.' . $this->primaryKey => $this->getID());
    $query = array('conditions' => $conditions, 'recursive' => -1, 'callbacks' => false);
    return ($this->find('count', $query) > 0);
}

save()メソッドの中でexists()というメソッドが呼ばれてます。上ではexists()メソッドの中身も一緒に書いちゃいましたが、このexists()がレコードが存在するかどうかを確かめるメソッドみたいですね。

で、save()メソッドの中身をもうちょい読み下っていくと、UPDATE時にはこんな処理をしてます。

$success = (bool)$db->update($this, $fields, $values);

ようはこれがUPDATE文を発行してSQLを実行するメソッドなんですけど、これも結論から言うと、このメソッドの中でもモデルのexists()メソッドを呼び出しているんですね。正確には、このupdate()メソッドの中身を辿った先にあるdefaultConditions()っていうメソッドで。

だから結果として2回同じSELECT文が発行されると、そういうわけです。



ちなみに、たぶんなんですけどMySQLを使っているとこうなるっぽい。

その辺りのメソッドを追いたい場合は、この辺↓のファイルを調べてみると良いっす。

//libs/model/model.php
function save($data = null, $validate = true, $fieldList = array()) {
    ~ 中略 ~

    $success = (bool)$db->update($this, $fields, $values);
}

//libs/model/datasource/dbo/dbo_mysql.php
//Cakephp2.0だとModel/Datasource/Database/Mysql.php
function update(&$model, $fields = array(), $values = null, $conditions = null) {
    ~ 中略 ~

    $conditions = $this->conditions($this->defaultConditions($model, $conditions, $alias), true, true, $model);
}

//libs/model/datasource/dbo_source.php
function defaultConditions(&$model, $conditions, $useAlias = true) {
    ~ 中略 ~

    $exists = $model->exists();
}



じゃあどうする?

そりゃやっぱり……exists()メソッドを書き換えてみるのが良いんじゃないでしょーか。

ってことで、実際にやってみました。

まあ、コアライブラリの中身を書き換えるのはよろしくないので、app_model.phpにコピーしてオーバーライドする感じですね。

//app_model.php
function exists() {
    if ($this->getID() === false) {
        unset($this->existID, $this->existFlg);
        return false;
    }
	
    //フラグが存在する状態で現在のレコードIDが等しかったらSELECT文は発行しない
    if(isset($this->existFlg)) {
        if(!empty($this->existID) && $this->existID == $this->getID()) {
            return $this->existFlg;
        }
    }
	
    $conditions = array($this->alias . '.' . $this->primaryKey => $this->getID());

    $query = array('conditions' => $conditions, 'fields' => $this->name.'.'.$this->primaryKey, 'recursive' => -1, 'callbacks' => false);

    //結果をメンバ変数に保持しておく
    $this->existFlg = ($this->find('count', $query) > 0);
    if($this->existFlg) {
        //レコードが存在するならそのIDも保持しておく
        $this->existID = $this->getID();
    }
    return $this->existFlg;
}

//実行結果
SELECT COUNT(`Model`.`id`) AS `count` FROM `table` AS `Model` WHERE `Model`.`id` = 1
UPDATE `table` SET `id` = 1, `updated` = '2012-07-17 17:22:16' WHERE `Model`.`id` = 1

正直、こんな書き方で大丈夫かっていう不安もなくはないんですけど、今のところ無事に動いてるっぽいので、たぶん大丈夫なんだと思います。

ようは一回目でレコードが存在するかどうかのSELECT文を投げたら、その結果を保持してるってことですね。保持している限りはもっかい同じ処理をやらなくて済むようにしていると、そういう感じです。

あとついでに、COUNTを使うときはワイルドカード(*)を使わない方が良いっていう話を聞いたことがあるので、発行するクエリの内容もちょっと変えておきました。



とりあえずはこれで、UPDATE時に必要以上のクエリは投げないようになりました。

どうでっしゃろ?

UPDATE文を一度にたくさん投げることがあるよーって場合には、検討してみるのも良いと思いますよ。

まあ、処理の書き方に関してはもっと別の良いやり方があるかもしれないけどね。今回は、どうして2回投げてんだろってところが主題なので、それが分かっただけでも儲けもんってことにしといてください。

何かもっと良いやり方を知っている方は、ぜひ一報欲しいですね。

よろしくお願いしますm(_ _)m

あとヤンデレの攻略法も知っている方も、ぜひ一報欲しいですね。

よろしくお願いしますm(_ _)m
 もしかしたら何か関連しているかも? 
 みんなからのコメント 
2015年10月05日 01:00:25
Stew Eucen
通りすがりの DB エンジニアです。

CakePHP のクエリは、次にそのテーブルが更新されるまでは、同一クエリに対してキャッシュを返すような仕様だったと思います。

同一クエリならその都度 DB へクエリを投げることなく同じ結果を返してくるので、ライブラリ内では exists() メソッドを何度も呼び出しているのだと思います。

CakePHP が「このクエリはキャッシュを使いました」とかの目印をログに出してくれたらいいのにと思いますよ。
2015年10月05日 12:15:17
まっち~(管理人)
>Stew Eucenさん
おお、なるほど。実際のところは、1回のUPDATE文に対して複数回DBにクエリ投げてたわけじゃなかったんですね。無理に減らさなくても良かったのか……。

これは知りませんでした。ありがとうございます。

でもそういうことなら確かに、ログでキャッシュ使ってるかどうか判別できると嬉しいですね。