http://bakery.cakephp.org/articles/markstory/2014/07/21/cakephp_2_5_3_and_1_3_20_released
7/21に久々に1.3系を含むCakeのアップデートがリリースされました。
1.3系も更新されるやつは大体やばいやつ、ということで今回も何やらやばそうな事が書いてあります。
The 1.3.20 release contains an important fix to address a potential race condition in Model::save() that can cause data loss when records are deleted during concurrent updates.
data lossとはどういうことか、実際に試して確認してみました。
結論
save(update)処理の最中に対象レコードが削除されると、〜where1=1というえらいクエリが発行されて更新しようとした内容で全件updateが発生します。
速やかにバージョンアップしましょう。1.3系は他に変更点はなさそうです。
環境
PHP5.4.31
CakePHP1.3.19(再現環境)
試してみる
system_logsというテーブルに対してID指定で更新をかける下記のようなコードを実行。
$this->SystemLog->id = 100; $updatedata = []; $updatedata['SystemLog']['user_id'] = 1; $this->SystemLog->save($updatedata);
id=100のレコードが存在する場合
下記のようなクエリが実行される。
SELECT COUNT(*) AS `count` FROM `system_logs` AS `SystemLog` WHERE `SystemLog`.`id` = 100 SELECT COUNT(*) AS `count` FROM `system_logs` AS `SystemLog` WHERE `SystemLog`.`id` = 100 UPDATE `system_logs` SET `user_id` = 1 WHERE `system_logs`.`id` = 100
カウントが二回実行されてますがどちらも存在チェック(model::exits())で呼ばれてます。
普通にアップデートされてますね。
id=100のレコードが存在しない場合
SELECT COUNT(*) AS `count` FROM `system_logs` AS `SystemLog` WHERE `SystemLog`.`id` = 100 INSERT INTO `system_logs` (`user_id`, `created`) VALUES (1, '2014-08-07 19:00:29')
最初の存在チェックでレコードが存在しないため、insertが実行されました。
id=100のレコードが更新途中で削除された場合
初回の存在チェックと、二回目の存在チェック(DboSource::defaultConditions)より前にsleepを入れてレコード削除。
SELECT COUNT(*) AS `count` FROM `system_logs` AS `SystemLog` WHERE `SystemLog`.`id` = 100 SELECT COUNT(*) AS `count` FROM `system_logs` AS `SystemLog` WHERE `SystemLog`.`id` = 100 UPDATE `system_logs` SET `user_id` = 1 WHERE 1 = 1
WHERE1=1というやばそうなUPDATEが実行されました。
何が起きているのか?
ID指定ありで、Modell::save内の初回の存在チェックが通るとupdateとして処理が行われるのですが、その際のcondition(WHERE句)作成ロジック内で再度対象レコードの存在チェックが行われ、対象レコードが存在しないとdefaultCondition=nullとして扱われ、WHERE 1 = 1というSQLが発行されるようです。
dbo_source.phpの1877行あたり
function defaultConditions(&$model, $conditions, $useAlias = true) { if (!empty($conditions)) { return $conditions; } $exists = $model->exists(); if (!$exists && $conditions !== null) { return false; } elseif (!$exists) { return null; // 存在チェックに引っかかるとnullが返り、結果的にwhere1=1となる } $alias = $model->alias; if (!$useAlias) { $alias = $this->fullTableName($model, false); } return array("{$alias}.{$model->primaryKey}" => $model->getID()); //通常はこっち }
アップデート版の挙動
以下のように修正されてます。
$model->__safeUpdateModeはsaveによる更新時はtrueとなります。
function defaultConditions(&$model, $conditions, $useAlias = true) { if (!empty($conditions)) { return $conditions; } $exists = $model->exists(); if (!$exists && ($conditions !== null || !empty($model->__safeUpdateMode))) { return false; } elseif (!$exists) { return null; } $alias = $model->alias; if (!$useAlias) { $alias = $this->fullTableName($model, false); } return array("{$alias}.{$model->primaryKey}" => $model->getID()); }
存在チェックに引っかかった場合はnullではなくfalseが返るようになります。
defaultConditionsでfalseが返された場合、実行されるSQLは以下のようになります。
UPDATE `system_logs` SET `user_id` = 1 WHERE 0 = 1
お、おう、という感じはしますが結果的に更新はかからなくなります。
影響は?
同一レコードに対する更新、削除が頻繁に行われるようなシステムでなければ(例えば特定のユーザーに紐付いたデータ等であれば)そんなに起こることはなさそうですが、バッチによる一斉更新とユーザー操作がかぶったりした場合に運悪いと遭遇しそうです。
速やかにアップデートしましょう。
最後に
nullとかfalseとかarrayとかが混在して戻ってくるやつをアレコレするの見てて辛い。