トランザクション

回 5-3 道 5 データベースと分散

トランザクションは、データベースへの複数の変更を「全部やるか、何もしないか」の 1 まとまりにして扱う仕組みです。A から 300 円を引いて B に足す振込が途中で止まると、まとめていなければ合計 1000 円が 700 円に減ります。控え (ログ) を先に書き、コミットの印で確定する形なら、どこで止まっても 1000 円のままに戻せます。

振込が途中で止まっても壊れない理由と、2 人が同時に書くと更新が消える理由を、コミットと分離レベルで切り分けられるようになります。

振込が途中で止まったら、お金はどこへ行くのか

トランザクションは、データベースへの複数の変更を「全部やるか、何もしないか」の 1 まとまりにする仕組みです。この語はビジネスの取引や、日々の取引の記録 (トランザクションデータ) を指すこともありますが、この回で扱うのはデータベースの意味です。

A さんの口座から B さんの口座へ 300 円を移すとき、データベースでは「A から 300 円を引く」と「B に 300 円を足す」の 2 つを行います。1 つ目の直後に電源が落ちたら、A からは 300 円が消え、B には届いていません。お金がどこかへ消えたことになります。

トランザクションは、2 つの変更を 1 つの封筒に入れ、封をした (確定した) ときだけ両方を残します。この回では、お金が消える様子を Python で起こし、控え (ログ) を先に書けば消えないことを確かめます。

A から B へ 1,000 円を送る振込で、伝票を別々に出すと片方だけ変わり、BEGIN と COMMIT で 1 通の封筒にすると失敗しても ROLLBACK で両方が元に戻る図

トランザクションはどう動くのか

トランザクションは、始まりの印から確定の印までの変更をひとかたまりで扱います。確定をコミット、取り消しをロールバックと呼びます。

BEGIN から COMMIT までが 1 つ

SQL (データベースに命令する言葉) では、BEGIN で始め、COMMIT で確定します。途中でやめるときは COMMIT の代わりに ROLLBACK を出すと、それまでの変更がすべて取り消されます。

振込を 1 つにまとめる

BEGIN;
UPDATE accounts
  SET balance = balance - 300
  WHERE id = 'A';
UPDATE accounts
  SET balance = balance + 300
  WHERE id = 'B';
COMMIT;

PostgreSQL の公式の手引きは、トランザクションの要点を「複数の手順を、全部か無かの 1 つの操作に束ねること」と書いています。途中の状態はほかから見えず、途中で失敗すれば、どの手順もデータベースに残りません。

BEGIN を書かない文も、1 文ずつが小さなトランザクションとして動き、成功すれば確定します。これを自動コミットと呼びます。1 文ずつは守られますが、2 文の間で止まると 1 文目だけが残ります。

控えを先に書く

止まっても壊れないのは、データベースが本体を書き換える前に、何をするかの控え (ログ) を先に残すからです。

PostgreSQL はこの控えを WAL (Write-Ahead Logging、先に書く記録) と呼びます。公式文書は、データのファイルを書き換えてよいのは、その変更を書いた WAL の記録がディスクに書かれたあとだけだと定めています。止まったあとは、控えを読み直して次のように整えます。

止まった時点起き直したとき
コミットの印より前途中までの変更を無かったことにする
コミットの印より後全部の変更が残る形にそろえる

この頁の Python は、取り消し用の「直す前の値」も控えに書く、いちばん単純な形にしています。PostgreSQL は書き換える前の行を残しておき、コミットの印が無いトランザクションが作った行を見せないことで、同じ「無かったこと」を実現します。

ACID の 4 つ

トランザクションが守る性質は、頭文字を取って ACID と呼ばれます。

性質守ること振込で言うと
A 原子性 (Atomicity)全部やるか、何もしないか引いたのに足さない、が残らない
C 一貫性 (Consistency)決めた決まりを破らない残高が 0 円未満にならない
I 分離性 (Isolation)途中の状態をほかから見せない引いた直後の残高を他人が読まない
D 永続性 (Durability)確定したら停電でも消えないCOMMIT のあとの 700 円は残る

A と D を支えるのが前の節の控えです。I は、同時に動くトランザクションどうしをどこまで切り離すかで、あとの節の分離レベルがそれです。

銀行の振込を例に、A は全部か無か、C は決まりを守る、I は途中が見えない、D は確定したら停電でも 700 円のまま残る、の 4 つを並べた図

動かして確かめる

1 本目は、トランザクションを使わずに振込を書きます。A は 1000 円、B は 0 円から始め、300 円を移します。止まる側は、A から引いた直後に例外 (停電の代わり) を投げます。

動かす まとめずに振り込む

python実行可能
出力

▶ 実行ボタンを押すと結果がここに表示されます

実行すると、次の通りに出ます

{'A': 700, 'B': 300} 合計 1000
止まった 停電
{'A': 700, 'B': 0} 合計 700

止まらなければ合計は 1000 円です。引いた直後に止まると、A は 700 円、B は 0 円で、合計は 700 円になりました。300 円がどこにも無い状態が残っています。

2 本目は、控えを先に書く形です。振込を、A の控え、A を書く、B の控え、B を書く、コミットの印の 5 手に分け、0 手から 5 手までのそれぞれで止めます。起き直したら、控えにコミットの印があれば新しい値でそろえ (やり直し)、無ければ控えを後ろから読んで古い値に戻します (取り消し)。

動かす 控えを先に書いて止める

python実行可能
出力

▶ 実行ボタンを押すと結果がここに表示されます

実行すると、次の通りに出ます

手 直後 -> 戻した後
0 1000+0 -> 1000+0 取り消し
1 1000+0 -> 1000+0 取り消し
2 700+0 -> 1000+0 取り消し
3 700+0 -> 1000+0 取り消し
4 700+300 -> 1000+0 取り消し
5 700+300 -> 700+300 やり直し

2 手や 3 手で止まった直後は、1 本目と同じ 700+0 です。それでも控えに A の古い値 1000 があるので、起き直すと 1000+0 に戻せました。

4 手の行では、A も B も書き終えて 700+300 なのに、コミットの印が無いので取り消されました。確定を決めるのは「本体を書き終えたか」ではなく「コミットの印が控えに残ったか」です。5 手まで進んだときだけ 700+300 が残りました。

どの行も、戻した後の合計は 1000 円です。止まる位置がどこでも、全部やったか何もしなかったかのどちらかになります。これが、残高の移し替えが途中で止まっても壊れない理由です。

途中で落ちたら、明細と total はそろうか

上の図は注文の明細と合計を 2 文で直す例で、落ちる位置を動かすと、自動コミットのときだけ両者がずれます。

同時に書くと、なぜ更新が消えるのか

トランザクションで包んでも、2 人が同じ残高を同時に読んで書くと、片方の更新が消えることがあります。これを lost update (更新の消失) と呼びます。

3 本目は、A の 1000 円から、田中さんが 300 円、鈴木さんが 200 円をほぼ同時に引き出す場面です。2 人とも先に残高を読み、手元で引き算をしてから書きます。

動かす 2 人が同じ残高を読む

python実行可能
出力

▶ 実行ボタンを押すと結果がここに表示されます

実行すると、次の通りに出ます

2 人とも読んだ 1000 1000
田中が書いた 700
鈴木が書いた 800
正しくは 500

正しくは 500 円のはずが、800 円になりました。鈴木さんが古い 1000 円から計算した値で上書きし、田中さんの 300 円が消えています。2 人の書き込みは 1 つずつなら正しく確定しているので、原子性と永続性ではこれを防げません。防ぐのは分離性の側です。

分離レベルで何が防げるか

分離レベルは、同時に動くトランザクションどうしを、どこまで切り離すかの度合いです。SQL の標準は 4 段を決めていて、PostgreSQL の公式文書は、段ごとに起こり得る現象を次の表にしています (表 13.1 を訳した物)。

分離レベルダーティリード反復不能読み取りファントム直列化の異常
READ UNCOMMITTED標準は許すが PostgreSQL では起きない起こり得る起こり得る起こり得る
READ COMMITTED起きない起こり得る起こり得る起こり得る
REPEATABLE READ起きない起きない標準は許すが PostgreSQL では起きない起こり得る
SERIALIZABLE起きない起きない起きない起きない
  • ダーティリードは、まだコミットしていない他人の変更を読むこと
  • 反復不能読み取りは、同じ行を 2 回読むと、間に他人がコミットした変更で値が変わっていること
  • ファントムは、同じ条件で 2 回探すと、当たる行の集まりが変わっていること
  • 直列化の異常は、結果が 1 つずつ順に流したどの順番とも合わないこと

PostgreSQL の既定は READ COMMITTED で、READ UNCOMMITTED を頼んでも中では READ COMMITTED と同じに動きます。

lost update はこの表の列にありません。PostgreSQL では、書き方と分離レベルで次のように分かれます。

書き方と分離レベル2 人目の書き込み更新
READ COMMITTED で、読んだ値から計算して書くそのまま上書きする消える
READ COMMITTED で、balance = balance - 200 と 1 文で書く1 人目のコミットを待ち、直ったあとの行で計算し直す消えない
REPEATABLE READ か SERIALIZABLE先を越されたと分かると失敗する消えない (やり直しが要る)

公式文書によると、REPEATABLE READ では、自分が始まったあとに他人が書き換えた行を書き換えられず、could not serialize access due to concurrent update の失敗が返ります。アプリはトランザクションを始めからやり直し、新しい値を読み直して計算します。

4 本目は、この「先を越されたら失敗し、読み直してやり直す」形をまねます。行に版の番号 (書かれた回数) を持たせ、読んだときの版と今の版が違えば書き込みを断ります。

動かす 版を確かめてやり直す

python実行可能
出力

▶ 実行ボタンを押すと結果がここに表示されます

実行すると、次の通りに出ます

  田中 1000 -> 700
  鈴木 1000 -> 800
確かめない 800
  田中 1000 -> 700
  鈴木 衝突
  鈴木 700 -> 500
確かめる 500

版を確かめないと、3 本目と同じく 800 円です。確かめると、鈴木さんは 1 度「衝突」で断られ、700 円を読み直して 500 円を書きました。

崩れる前提は「読んでから書くまで値は変わらない」です。また段を上げると失敗がふつうに起きるので、やり直しを書いていないアプリは守れません。

トランザクションは仕事でどう確かめるのか

仕事では、どこまでを 1 つのトランザクションにしたかと、同時に来たときの動きを、試験とコードの読み合わせで確かめます。

  1. 2 つ以上の値を一緒に直す処理 (振込、注文と在庫) が、1 つの BEGIN から COMMIT の中にあるかを見る。道具が自動で BEGIN を出すこともあるので、道具の文書も確かめる
  2. 試験で、2 文の間にわざと例外を投げ、合計が始める前と同じかを見る (1 本目と 2 本目と同じ確かめ方)
  3. 「読んでから書く」所を探し、1 文で書くか、分離レベルを上げて失敗したらやり直すコードにする
  4. トランザクションを開いたまま、人の操作や外の API を待たない

PostgreSQL では、古い行を片付ける掃除 (VACUUM) が裏で動いています。これが追いつかないと何が起きるかが、次の実例です。

関わる実例

トランザクション ID の周回で書き込みが止まった Sentry (2015年7月20日)

エラーを集めるサービス Sentry は、2015年7月20日の月曜日、米国の仕事の時間のほとんどで止まりました。2015年7月23日に David Cramer が公開した事後報告によると、原因は PostgreSQL のトランザクション ID (XID) でした。

PostgreSQL は、途中の状態をほかから見せない仕組みを、トランザクションごとの番号で作っています。行には作ったトランザクションの番号が付き、自分より新しい番号の行は「未来の行」として見せません。公式文書によると、この番号は 32 ビットで、40 億を超えるトランザクションを流すと 1 周します。比べは周回を前提にした計算で、どの番号から見ても 20 億が古い側、20 億が新しい側です。そこで掃除が、十分に古い行に「凍結」の印を付けて回ります。

掃除が追いつかないと、周回まで残り 4000 万で警告が出て、残り 300 万を切ると新しい番号を配らなくなります。データが消えるのを防ぐため、書き込みを断る守りです。

Sentry は書き込みがとても多く、自動の掃除の設定を何度も見直していましたが、この守りが働いて書き込みが止まりました。自動の掃除を待って 3 時間近くを費やしても書き込みは戻らず、調べると 1 つの巨大な表だけ掃除が終わっていませんでした。使い道の限られた表だったので空にする (TRUNCATE) と決め、5 分後に全体が戻ったと書いています。その後、掃除を早く始める設定 (autovacuum_freeze_max_age を 500000000) と、掃除の担当を 6 つにする設定に変えました。

崩れた前提は「トランザクションの番号はいくらでも使える」です。分離性の土台の番号は有限で、掃除が追いつくことが前提でした。

出典は Transaction ID Wraparound in Postgres (Sentry Blog) と Routine Vacuuming (PostgreSQL 公式文書) です。

よくある問い

BEGIN を書かないと、トランザクションは無いのですか

あります。PostgreSQL は BEGIN の無い文を 1 文ずつ小さなトランザクションとして包み、成功すれば確定します (自動コミット)。ただし 2 文の間で止まると 1 文目だけが残るので、まとめたい変更は BEGIN と COMMIT で囲みます。

トランザクションの一部だけを取り消せますか

セーブポイントを使えばできます。SAVEPOINT で印を置き、ROLLBACK TO でその印まで戻すと、印より前の変更を残したまま後ろだけを取り消せます。外側を COMMIT するまで、どの変更もほかの接続には見えません。

トランザクションデータやトランザクションファイルとは何ですか

注文や入出金のように、取引ごとに増えていく記録を指す言葉で、この回のトランザクションとは別の意味です。商品や顧客の一覧のようにあまり変わらない情報はマスターデータと呼んで分けます。トランザクションデータを書き込むときに、この回のトランザクションを使う関係です。

@Transactional とは何ですか

Java の Spring などで、関数に付けると、その中の処理を 1 つのトランザクションで包む印です。自分で BEGIN と COMMIT を書く代わりに、道具が関数の始まりと終わりで出します。

確認クイズ

答えだと思う選択肢を押すと、その場で正誤と理由が開きます。4 問すべてに正しく答えると、この回は修了で、石が緑に変わります (この端末のブラウザに残ります)。間違えた問いは、あとから解き直せます。

1 問目

トランザクションの途中で止まったとき、変更を始める前の状態に戻すことを何と呼びますか。

2 問目

このコードを動かすと何が出ますか。

2 人が読んでから書く

bal = 500
a = bal  # 1 人目が読む
b = bal  # 2 人目が読む
bal = a + 100
bal = b - 50
print(bal)

3 問目

この頁の 2 本目のコードで、4 手目 (A も B も書き終え、コミットの印はまだ) で止まったとき、起き直した後の残高はどうなりましたか。

4 問目

2015年7月の Sentry の障害で、PostgreSQL が書き込みを受け付けなくなった直接の理由はどれですか。

説明課題

A の口座から B の口座へ 300 円を移す途中で電源が落ちても、お金が消えない理由を、「控え (ログ)」「コミットの印」「取り消し」という言葉を使って、3〜4 文で説明してください。書いたら、下の模範解答を開いて比べてください。

模範解答

自分の説明を書いてから開いて、比べてください。

この回の先の一歩

注文と在庫、明細と合計のように、2 つ以上の値を一緒に直す処理は仕事のあちこちにあります。どこまでを 1 つのトランザクションにするか、同時に来たら何が起きるかを言えると、設計の見直しや障害の調べで役に立ちます。

もっと深く

この回の続きを、ゆめさくのコースの章や道具で深められます。頁の中の確認クイズに答えたあと、手を動かす量を増やしたいときに使ってください。

一次資料

どれも 2026年10月27日に開いて、題と中身を確かめました。

次へ

道 5 データベースと分散の回

  1. 5-1正規化重なりを無くす頁で読む
  2. 5-2インデックスと B-tree索引が速い理由頁で読む
  3. 5-3トランザクションACID、分離の度合いこの回
  4. 5-4分けて置くスケーリング、複製、CAP頁で読む

道 5 の頁へ戻るほかの道を見る