レコードがなかったらINSERTして返すみたいなのを確実にやる
find_or_create
的なやつは大体どんなORMでも
- レコードを探す
- 無かったらINSERT
みたいに実装することになると思う。ただこれだと、1と2の間でレースコンディションでエラー起きることがある。他のプロセスがINSERTしてしまうとかそういうやつ。
それを防ぎたい場合に、1の時点でFOR UPDATE
するのはすごくダメで、空行にFOR UPDATE
したりするとMySQLだと盛大に乙るのは有名な話。
エラーを起こさないで、確実にレコードを取りたい場合にはどうすればよいかというと、以下のようにするのが良いと思っている。UNIQUEキー制約なりがちゃんと付いている前提。サンプルコードはTengの場合。
sub find_or_create_surely {
my ($self, $table, $where, $opt) = @_;
my $row;
my $txn = $self->txn_scope; {
# データを取得
$row = $self->single($table, $where);
unless ($row) {
# 無かったらINSERTを試みる
eval {
$row = $self->insert($table, $where);
};
if (my $err = $@) {
if ($err =~ /DBD::mysql::st execute failed: Duplicate entry/) {
# エラーが発生していて重複エラーだったら取り直す
$row = $self->single($table, $where);
}
else {
# 不明なエラーなので、例外を投げる
$txn->rollback;
die $@;
}
}
}
# ロックかけたいときは取り直す(private interface使ってるのあんま良くない…)
$row = $self->single($table, $row->_where_cond, {for_update => 1}) if $opt->{for_update};
}
$txn->commit;
$row;
}
Duplicate entry
の部分はここだと正規表現だけど、最近はMyApp::DB::DuplicateException
とかをプロジェクトで用意しているってのは前に少し書いた。
常にこうしたほうが良いという話ではなくて、これはエラーを握りつぶしているので、そういうことが起こり得て許容するのであれば上記の実装でいいけど、他のプロセスが割り込むこと自体がまずい(連打アクセスなどで同じ処理が並列に続行されてしまうとか)のであればちゃんとエラーにしたほうが良い。
なので、最初に書いた「レコードを探して」「無かったらINSERT」という処理がデフォルトの挙動になっているのは正しい。ActiveRecordもそうで、レースコンディションをケアしたいのであればActiveRecord::RecordNotUnique
をトラップするとかそういう感じになるみたい。
常にINSERT ON DUPLICATE KEY UPDATEではダメか?
状況によっては構わない。ただ、INSERT ON DUPLICATE KEY UPDATE
だと以下の様な問題がある。
- テーブルのAUTO_INCREMENTの値が常に(UPDATEの場合でも)インクリメントされてしまうので場合によってはすごい勢いで増える
- 単純な処理だったら良いが、レコードの事前の値をとってそれを元に更新するとかができない
INSERTする所で例外をトラップするかわりにINSERT IGNOREではダメか?
INSERT IGNORE
は重複エラー以外もIGNOREしてしまうのが良くない。
追記
@songmu " # エラーが発生していて重複エラーだったら取り直す" の部分、トランザクション分離レベルによっては一貫性読み取りでレコード取得できない気がします。
— ご当地ふなっしー (@ryopeko) 2014, 3月 2
確かに他のトランザクション内でINSERTが成功してるけどcommitはされていないみたいな状態だとレコードが取れない可能性がありますね!めんどくさい…