トランザクション — 表にない列が実務の大半だ
一言でいうと
教科書の分離レベルの表は、3つの異常現象しか扱いませんが、実務の並行性バグの大半は、その表にない4つ目の現象であるライトスキューです。
なぜ必要なのか
並行性のバグに出会うと、よく出てくる処方があります。「分離レベルをリピータブルリードに上げろ」です。そして、かなりの場合、その処方は問題を直せません。残高がマイナスになり、在庫が超過販売され、二重予約が続けて生まれます。
理由は2つです。1つ目は、表で学んだ定義と実際の実装が違うことです。2つ目は、4つのレベルのどれも検知できない異常現象が1つあることです。
どう動くのか
ACIDから整理すると、次のとおりです。原子性は、すべて反映されるか、すべて取り消されるという性質、一貫性は、制約を守る性質、分離性は、同時実行が逐次実行のように見える性質、永続性は、コミットされた結果が消えない性質です。永続性は、たいてい先行書き込みログ(WAL)で保証します。データページを書き換える前に、変更内容をログに先に記録し、そのログをディスクに書き出します。クラッシュ後は、ログを再生して復旧します。
標準が定義した分離レベルと異常現象は、次のとおりです。
| 分離レベル | dirty read | non-repeatable read | phantom read | write skew |
|---|---|---|---|---|
| Read Uncommitted | 標準上は許可 | 許可 | 許可 | 許可 |
| Read Committed | 不可 | 許可 | 許可 | 許可 |
| Repeatable Read | 不可 | 不可 | 標準上は許可 | 許可 |
| Serializable | 不可 | 不可 | 不可 | 不可 |
右端の列が核心です。Serializableを除くすべてのレベルがライトスキューを許可し、ほとんどのサービスはRead Committedで動いています。
実装の違いも、知っておく価値があります。PostgreSQLはMVCCで分離を実装しているので、Read Uncommittedを要求してもRead Committedとして動作します。構文は受け付けますが、dirty readは構造的に不可能です。「分離レベルを下げて性能を上げよう」という提案が、PostgreSQLではまったく効果がない理由です。逆に、PostgreSQLのRepeatable Readはスナップショット分離なので、標準より強く、ファントムも見えません。代償は、書き込みが衝突したときにトランザクションがエラーで死ぬことです。分離レベルを上げると、アプリケーションがリトライする責任を負うことになります。リトライのロジックなしにレベルだけを上げるのは、ユーザーにエラーをより頻繁に見せる変更にすぎません。
現場での姿
ライトスキューの古典的な例が、当直医の問題です。ルールは「当直者が最低1人は残らなければならない」です。2人の医師が同時にトランザクションを開いて、それぞれcount(*) = 2を読んで条件を通過した後、それぞれ別々の行を更新します。書き込みが重ならないので、スナップショット分離は何の衝突も検知できません。両方とも成功し、当直者は0人になります。
同じ構造のバグが繰り返されます。在庫確認後の差し引き、座席の二重予約の検査、ユニーク制約のない重複登録の検査は、すべてライトスキューです。分離レベルを1段階上げても、1つも直りません。
選択肢は3つです。ロック対象の行が明確なら、SELECT ... FOR UPDATEのような悲観的ロックが最も単純で予測可能です。衝突がまれで、ユーザーの操作が途中に挟まるなら、バージョンカラムを使う楽観的ロックのほうがよいです。不変条件が複数の行や複数の表にまたがり、ロック対象を特定できないなら、Serializableが答えです。
そして可能なら、分離レベルで守っていた不変条件を制約に移すのが最も堅牢です。ユニークインデックスや排他制約は、分離レベルと無関係に常に成立し、リトライも不要で、後から加わったチームメンバーが誤って迂回することもできません。
リトライをどこに置くのか
分離レベルを上げることにしたなら、リトライは選択ではなく、セットです。ところが、リトライをどこにでも置くと、エラーよりさらに悪い結果になります。3つだけ守れば十分です。
リトライのブロックの中には、データベースの作業だけを入れます。トランザクションの中で決済リクエストを送ったり、メールを送信したりしたなら、トランザクションがロールバックされても、そのリクエストは元に戻りません。2回目の試行で同じことがまた起こり、顧客は2回決済されます。外部呼び出しは、トランザクションの外に出すか、少なくともコミットが終わった後に回します。
回数と間隔に上限を設けます。直列化失敗は、競合が激しいほど頻繁に起こるので、失敗したトランザクションをすぐにもう一度投げると、競合がさらに激しくなります。短く休んでから再試行し、何回か失敗したら諦めてユーザーに知らせます。
何が原因で失敗したのかは、SQLSTATEで区別します。直列化失敗は40001、デッドロックは40P01です。エラーメッセージの文言で分岐すると、バージョンアップのときに静かに壊れます。この2つのコードは再試行する価値がありますが、制約違反のように、やり直しても同じ結果になるエラーは、リトライの対象ではありません。
次のラボですること
本物のPostgreSQLで、2つのセッションを重ねて動かします。更新の消失をまず作ってみて、行ロックとリピータブルリードでそれぞれ防いだ後、リピータブルリードでも防げないライトスキューを、当直表で再現します。最後に、直列化可能レベルでそれを捕まえ、スキップロックでジョブキューを分けて処理します。