たまに以下のようなロジックで値の一意性を保証しようとしているコードを見かけます。
if ( 既に値が存在するか ) { ...(1) print "別の値にしてください" } else { 値をどっかに保存する処理 ...(2) print "保存しました" }
一見うまく動きそうなんですけど、これは複数プロセスや複数スレッドが同時に実行されるような環境ではうまく動きません。
例えばwebアプリでDBに値を保存する場合などは、こういうことが考えられます。
- ユーザーAが nyan という値を送ってきます
- まだ DB には nyanという値が存在しないので、 (1) で else 節に入ります
- ここでユーザーBも偶然 nyan という値を送ってきました
- まだ DB には nyan という値が存在しないので、 (1) で else 節に入ります
- else 節に入ったユーザーAのリクエストは(2)の行を処理し、正常に終了します。
- else 節に入ったユーザーBのリクエストは(2)の行を処理しようとします。
このとき、DBにuniq制約が貼ってあればユーザーBには想定していないエラーを返すことになりますし、uniq制約が貼っていない場合、重複してはいけないはずのデータなのに重複を許してしまうという最悪の事態になります。
こうならないように、値の一意性を確保したい場合は、より低く確実な層でチェックするべきでしょう。今回の場合なら、DBにuniq制約を貼った上で以下のようなロジックにするべきです。
try { 値をどっかに保存する処理 print "保存しました" } catch (ユニーク制約に引っかかったよ例外) { print "別の値にしてください" } // あるいは error = 値をどっかに保存する処理 if (error_code == NULL) { print "保存しました" } elseif (error_code == ユニーク制約に引っかかったよエラー) { print "別の値にしてください" } else { print "予期しないエラー" }
DBへの保存の他にも、例えば「もし存在してないなら、新しくディレクトリを作る」みたいなやつに関しても気をつけなければいけません。これも、「先にチェックして、無かったら作る」みたいにしちゃうと同じようなパターンで予期しないエラーを引き起こす可能性があります。それを防ぐためには、
- まずmkdir してしまって
- エラーが出なければ新規作成されたので正常系
- EEXIST 以外のエラーならば予期していないエラー
- EEXIST なエラーである場合
- path がディレクトリであれば作成済みであったので正常系
- path がディレクトリでなければ、「同名のファイルがあるよ」というエラー
といった形にしなければなりません。
要するに、重複が許されないようなものに関しては、自分でアプリケーションのレイヤーでそのチェックを組むと並行性に問題が出てくるので、アプリケーションのレイヤーより下に任せた上で、その結果をアプリケーション側でハンドルしてあげるようにしましょうね、という話でした。
もちろんどこまで真面目にやるべきかは案件によるとしか言えないし、多くの場合、データの整合性が守られてるならそれで問題なしとしていいことのほうが多いとは思う。でもこういうの知っておかないと困ることもあるので一応知っておいたうえで「そこまで真面目にやる必要なし」という判断ができるようになっていたほうがよいだろうという話。
以下余談。
Rails の ActiveRecord の uniqueness validation が、まさに上のような問題を持っている。これはまあいろんなDBに対応しないといけない以上しょうがない部分があるし、どちらにせよDBにuniq制約貼っておけばデータの整合性が破壊されることはないので、そこまで大きな問題ではないんだけど、上記のようなパターンで ActiveRecord::RecordNotUnique 吐かれた場合に真面目にユーザーに「別の値使ってください」って通知しようとするときにはどうするべきなんだろうなぁというのを迷っている。ActiveRecord::RecordNotUnique 例外を catch して record.errors に add するようなやつを ActiveRecord::Validations::UniquenessValidator に prepend しちゃう?それはちょっと暴力的すぎるなぁ、などなど悩みは尽きない。だいたい毎回「レアケースだし、データが守られてればそこまで真面目にやらんでええやろ」に落ち着く気がする。