- ブラウザURL入力
- DNS名前→IP
- ルータ経路選択
- ロードバランサ振り分け
- Nginx受け口
- アプリ処理
- DB保存今ここ
前回までに、COMMIT が全部か無しかの境界を引き、成功を返せば残ることを見ました。今回は、その境界の内側に別のトランザクションが入り込んだときに何が起きるかを見ます。
まず結論
値を読み、アプリで足し、書き戻す。これを2つのトランザクションが同時に行うと、両方ともエラーなくコミットしたのに片方の更新が消えることがあります。どちらも「全部か無しか」は守られています。
原子性が約束するのは途中で割り込まれないことではありません。読んでから書くまでの間は、分離レベルや文の書き方で宣言したときだけ守られます。実測では同じ順序の更新が、DB・分離レベル・書き方によって 110・120・130 に分かれました。
PostgreSQL の既定の設定で、n = 100 の行を A が読みます。1秒後に B が同じ行を読み、読んだ値 + 20 を書いてコミットします。その後 A が、自分が読んだ値 + 10 を書いてコミットしました。最終的な n はいくつでしょうか。
当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。
全部か無しかは、途中に入られないことではない
BEGIN から COMMIT までを1つにまとめると、DB はその中身を全部反映するか、全く反映しないかのどちらかにします。これが原子性です。
原子性が決めるのは、終わったときに何が残るかです。途中に別のトランザクションが入り込まないことは約束していません。
1995年の論文「A Critique of ANSI SQL Isolation Levels」は、この隙間で起きる現象を名付けています。
P4 (Lost Update): The lost update anomaly occurs when transaction T1 reads a data item and then T2 updates the data item (possibly based on a previous read), then T1 (based on its earlier read value) updates the data item and commits.
例として挙がる履歴は H4: r1[x=100] r2[x=100] w2[x=120] c2 w1[x=130] c1 です。r は読む、w は書く、c はコミット、数字はトランザクションの番号です。T2 の +20 は確定したのに、T1 が古い読みから計算した 130 で上書きします。論文は T2's update will be lost と続けます。
- ・A も B も全部か無しかでコミットしている。消えたのは、A の読みと書きの間に B が入ったから
- ・順序は論文の H4 と同じ r1 r2 w2 c2 w1 c1。増分は H4 が T1 +30、実験が A +10
PostgreSQL の既定で、実際に消える
PostgreSQL 18.6 で、n = 100 の行を1つ作りました。セッション A は BEGIN して n を読み、psql の \gset で変数に取り、3秒待ってから「読んだ値 + 10」を書いてコミットします。セッション B は1秒後に始まり、読んで「読んだ値 + 20」を書いてすぐコミットします。分離レベルは PostgreSQL の既定である READ COMMITTED です。
--- session A (T1) ---BEGIN ISOLATION LEVEL READ COMMITTED;BEGINSELECT n AS cur FROM counter WHERE id = 1A read n = 100SELECT pg_sleep(3);pg_sleep----------(1 row)UPDATE counter SET n = 100 + 10 WHERE id = 1;UPDATE 1COMMIT;COMMIT--- session B (T2, started 1s later) ---BEGIN ISOLATION LEVEL READ COMMITTED;BEGINSELECT n AS cur FROM counter WHERE id = 1B read n = 100UPDATE counter SET n = 100 + 20 WHERE id = 1;UPDATE 1COMMIT;COMMIT--- final ---final n = 110
A の UPDATE 文には 100 + 10 と数字が入っています。A が送ったのは「読んだ 100 に 10 を足した値」で、その間に B が書いた 120 は計算に入っていません。
どちらの COMMIT もエラーなく COMMIT と返り、最終は 110 です。2回とも同じでした。エラーは1行も出ていません。
READ COMMITTED で最終 110 になった結果について、最も正確な説明はどれですか。
読んでから書く間は、宣言した分だけ守られる
同じ手順を、分離レベルと書き方を変えて繰り返しました(各2回、2回とも同じ結果)。
DB と分離レベル 書き方 A の UPDATE 最終 nPG18 READ COMMITTED(既定) 読んだ値 + 10 UPDATE 1 110PG18 REPEATABLE READ 読んだ値 + 10 serialization エラー 120PG18 READ COMMITTED(既定) n = n + 10 UPDATE 1 130PG18 REPEATABLE READ n = n + 10 serialization エラー 120MySQL REPEATABLE READ(既定) 読んだ値 + 10 エラーなし 110MySQL REPEATABLE READ(既定) n = n + 10 エラーなし 130MySQL READ COMMITTED 読んだ値 + 10 エラーなし 110
既定のままだと、PostgreSQL でも MySQL でも 110 でした。 MySQL の既定の REPEATABLE READ でも更新は消え、エラーも出ていません。MySQL の公式文書は、この形の弱さを次のように書いています。
If you query data and then insert or update related data within the same transaction, the regular SELECT statement does not give enough protection. Other transactions can update or delete the same rows you just queried.
ただし公式文書に「lost update」の語は見つかっていません。言えるのは「通常の SELECT では保護が不十分と公式が書き、実測では消えてエラーも出なかった」までです。
PostgreSQL の REPEATABLE READ では、A の UPDATE が失敗しました。
BEGIN ISOLATION LEVEL REPEATABLE READ;BEGINSELECT n AS cur FROM counter WHERE id = 1A read n = 100SELECT pg_sleep(3);pg_sleep----------(1 row)UPDATE counter SET n = 100 + 10 WHERE id = 1;psql:/tmp/a.sql:5: ERROR: could not serialize access due to concurrent updateCOMMIT;ROLLBACK
公式はこのエラーを、REPEATABLE READ のトランザクションは開始後に他が変更した行を変更できない(cannot modify or lock rows changed by other transactions after the repeatable read transaction began)ためだと説明し、retry the whole transaction from the beginning を求めます。B の +20 が残ったのは、DB が A を止めたからです。 A の +10 は、アプリが再試行しない限り失われます。
宣言の置き場所は3つあります。分離レベル、文の書き方、そしてロックです。読むときに行をロックする SELECT … FOR UPDATE もありますが、ここでは名前だけにします。1文で書いたとき書く瞬間に何が起きているのか、読んだ行がいつ待たされるのかは、次に読むコース『データベースの仕組み』で扱います。
対比例 — CPU の上でも、同じ隙間で消える
DB の外にも同じ形があります。プログラムの count += 1 は1行ですが、実行されるときは 読む・変える・書く(read-modify-write)の3段階に分かれます。2つのスレッドが同じ変数にこれを繰り返すと、一方が読んでから書くまでの間に他方が書き、その書き込みが上書きされます。これは別コース『OSとカーネルの土台』の「同じ値を2人が書く」で扱っています。
対応を並べると、DB の実験は同じ形をしています。
- 読む: SELECT で n を取り、アプリの変数に入れる
- 変える: アプリの中で +10 を計算する
- 書く: UPDATE で計算済みの数字を送る
スレッドの場合もトランザクションの場合も、1回ずつの書き込みは完全に行われています。半端な値が書かれたのではなく、古い読みから作った完全な値が、新しい値を上書きしています。 隙間を塞ぐには、読むと書くを分けない操作にするか、隙間に入られたことを検出して止めるか、先に押さえるかを宣言します。
「UPDATE を `n = n + 10` と1文で書けば、どの DB・どの分離レベルでも更新は消えない」という主張は、実測に照らして妥当ですか。
2つのトランザクションが同じ行を更新する
A が 100 を読み、B が 120 を書いてコミットした後、A が 100 + 10 を書いてコミットする。どちらも原子的に確定し、エラーも出ないが、最終は 110 で B の +20 が消えた。
A: SELECT n -> 100B: SELECT n -> 100B: UPDATE 120, COMMITA: UPDATE 110, COMMITfinal n = 110
2つのスレッドが同じ変数を増やす
count += 1 は読む・変える・書くに分かれる。一方が読んだ後に他方が書くと、一方は古い値に1を足した値を書き戻し、他方の加算が消える。どの書き込みも途中で壊れてはいない。
A: 読む -> 5B: 読む -> 5B: 書く 6A: 書く 6count = 6
DB のトランザクションと CPU のスレッド。道具は違いますが、更新が消える仕組みに共通するものは何でしょうか。
「書き込みが壊れたのか」「書き込みの材料が古かったのか」を、両方で区別してみてください。
この章のまとめ
- 原子性は 終わったときに全部か無しか を決める。途中に入られないこと は約束しない。
- Lost Update は Berenson ら(1995)が P4 として挙げた現象。SQL 標準の3現象には含まれない。
- 読んだ値から計算して書くと、PostgreSQL の既定(READ COMMITTED)でも MySQL の既定(REPEATABLE READ)でも 最終 110。エラーは出なかった。
- PostgreSQL の REPEATABLE READ では A が
could not serialize access due to concurrent updateで止まり 120。守ったのではなく止めた。再試行はアプリの仕事。 n = n + 10の1文は PostgreSQL の READ COMMITTED と MySQL の REPEATABLE READ で 130。PostgreSQL の REPEATABLE READ では1文でも 120。- 同じ名前の分離レベルでも、DB が違えば結果が違う。読んでから書く間は、宣言した分だけ 守られる。
自分の現場で確かめる
読んだだけでは身につきません。手元の環境で実際に見てみてください。
理解度チェック
読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。
「Lost Update(更新の消失)」という現象の出どころとして正確なのはどれですか。
用語集(4語)
- Lost Update(P4)
- T1 が読み、T2 が更新してコミットし、T1 が先に読んだ値を元に更新してコミットすると、T2 の更新が失われる現象。Berenson ら(1995)が P4 として挙げた。SQL 標準の3現象には含まれない。
- 分離レベル
- 同時に動くトランザクションどうしが、互いの変更をどこまで見えるかの取り決め。PostgreSQL の既定は READ COMMITTED、MySQL の InnoDB の既定は REPEATABLE READ。同じ名前でも挙動は DB ごとに違う。
- 直列化の失敗(serialization failure)
- PostgreSQL の REPEATABLE READ などで、並行する更新と両立できないときに返るエラー。公式はトランザクション全体を最初から再試行するよう求める。
- read-modify-write
- 値を読む・変える・書くの3段階で更新すること。段階の間に別の実行が入り込める。
出典(一次ソース)
- Database Language SQL (Proposed revised text of DIS 9075), July 1992 — SQL-92 草案 §4.28。ISO の最終版は未確認 ↗
- H. Berenson, P. Bernstein, J. Gray, J. Melton, E. O'Neil, P. O'Neil, A Critique of ANSI SQL Isolation Levels, Proc. ACM SIGMOD 95, pp. 1-10 (arXiv cs/0701157) §2.2, §4.1 ↗
- PostgreSQL 18 Documentation — 13.2. Transaction Isolation ↗
- MySQL 8.4 Reference Manual — 17.7.2.4 Locking Reads ↗
- MySQL 8.4 Reference Manual — 17.7.2.3 Consistent Nonlocking Reads ↗
- MySQL 8.4 Reference Manual — 17.7.2.1 Transaction Isolation Levels ↗