COMMIT した瞬間、PostgreSQL は本体に書いていない
PostgreSQL を運用していると、少し不思議に思う場面があります。大量の書き込みが走っているのに、一件の COMMIT はほとんど待たされずに返ってきます。それでいて、サーバーの電源が突然落ちても、再起動すればコミット済みのデータはちゃんと残っています。速さと壊れにくさは、普通なら両立しにくいものです。それでもデータベースは、平然と両方をこなしているように見えます。
この不思議は、COMMIT を「変更をデータファイルへ書き終えた合図」だと思っていると説明できなくなります。実際には、PostgreSQL は COMMIT の時点でテーブルの本体(データファイル)を書き換えていません。先にすることは別にあります。入口で図にすると、素朴なイメージと実際の動きはこう違います。
PostgreSQL は、データ本体を書き換えるより先に、その変更内容をまず追記専用の日誌へ書き終えます。この日誌が WAL(Write-Ahead Logging、先行書き込みログ)です。日誌への追記が終わった時点で COMMIT は完了として返り、本体のデータファイルへの反映はあとでまとめて行われます。
順序を言葉にすると「ログが先、本体はあと」です。たったこれだけの取り決めが、クラッシュ耐性・レプリケーション・任意時点への復元(PITR)という、一見別々に見える機能をまとめて支えています。この記事では、その日誌が実際に伸びていく様子を psql で覗き、kill -9 でサーバーを殺してから自動で復旧するところまでを、使い捨ての PostgreSQL で目の前で起こしてみます。
先にログ、あとで本体
なぜ「先にログ」だと速くて壊れにくいのか、先に理屈を押さえておきます。
テーブルの本体は、行がどのページに入るかがばらばらに決まります。あちこちのページを更新するたびにディスクの離れた場所へ書きにいくのは、順番の定まらない書き込みで、ディスクにとっては不得意な動きです。一方 WAL は、変更内容を起きた順に末尾へ足していくだけの一本のログです。同じ場所へ続けて書き足していく動きはディスクが最も得意とするもので、しかも「末尾まで書き終えた」ことさえ保証できれば、その時点でコミットを確定してよくなります。だから COMMIT は本体の更新を待たずに素早く返せます。
壊れにくさも同じ仕組みから来ます。WAL には「どの行をどう変えたか」が起きた順に残っています。もし本体への反映が途中で電源断に巻き込まれても、再起動後に、直近のチェックポイント以降の WAL を読み直して同じ変更をなぞり直せば、落ちる直前のコミット済みの状態へたどり着けます。復旧は、いまディスクに残っている本体のデータファイルへ、まだ反映しきれていない分の WAL を継ぎ足し、最新の状態へ組み立て直す形で進みます。
ここで見落としやすい点があります。COMMIT が返っても、本体のデータファイルはその場では書き換わりません。変更が当たったページはいったんメモリ上(共有バッファ)に汚れたページ(dirty page)として残り、ディスクへ確実に書き切られるのは WAL のほうだけです。本体ファイルへの反映は、あとから動くチェックポイントやバックグラウンドの書き出しに任され、状況によっては数分遅れて行われます。コミットのたびに本体のあちこちへ書きにいく遅い動きを避けるための、意図的な後回しです。
図にすると、コミットの瞬間とチェックポイントの瞬間とで、三つの置き場所(メモリ・WAL・本体)の状態はこう食い違います。
この「コミット済みだが本体はまだ古い」という状態は一瞬ではありません。次のチェックポイントまで、ふつうは数秒から数分のあいだ続きます。ただし本当の勘所は「窓が広いから狙わなくてよい」ではなく、そもそも復旧が「どこまで本体へ書けていたか」に左右されない作りだ、という点です。redo は各ページに刻まれた適用済みの位置を見て、まだ当たっていない変更だけを足し直します。だから本体がどれだけ古くても、あるいは一部が先に書けていても、行き着く先は同じコミット済みの状態になります。あとで kill -9 の実験がタイミング合わせ抜きで成立するのは、このためです。
ここで出てくる用語を先に一つだけ紹介しておきます。WAL の中の位置を指す番地を LSN(Log Sequence Number)と呼びます。0/1514378 のように書かれる、ログの先頭からの通し番号だと思ってください。書き込みが進むと LSN は増えていきます。以降の実験は、この LSN が動くのを見るところから始めます。
実験環境を使い捨てで用意する
手を動かせるように、Docker で使い捨ての PostgreSQL を立てます。何度でも作り直せる環境なので、kill -9 のような乱暴な操作も気軽に試せます。
docker run -d --name pg-wal-demo -e POSTGRES_PASSWORD=pass postgres:17
以降、psql には次のように入ります。
docker exec -it pg-wal-demo psql -U postgres
まず、この先ずっと使うテーブルを作っておきます。
CREATE TABLE accounts (id int primary key, name text, balance int);
実験1:自分の INSERT が WAL になるのを見る
まず、INSERT の前後で WAL の位置(LSN)がどれだけ進むかを見ます。現在の書き込み位置は pg_current_wal_lsn() で取れます。
SELECT pg_current_wal_lsn();
pg_current_wal_lsn
--------------------
0/1514378
一件だけ INSERT して、もう一度同じ関数を呼びます。
INSERT INTO accounts VALUES (1, 'alice', 1000);
SELECT pg_current_wal_lsn();
pg_current_wal_lsn
--------------------
0/1514488
位置が 0/1514378 から 0/1514488 へ進みました。差は 272 バイトです。この一件の INSERT のために、それだけの記録が WAL の末尾へ書き足されたということです。書き込み位置がどの WAL ファイルに対応するかは pg_walfile_name() で分かります。
SELECT pg_walfile_name('0/1514488');
pg_walfile_name
--------------------------
000000010000000000000001
では、その 272 バイトの中身を覗いてみます。WAL は pg_waldump という付属コマンドで人間が読める形に展開できます。さきほどの二つの LSN を範囲に指定して、コンテナの中で実行します。
docker exec pg-wal-demo bash -c \
'cd "$PGDATA/pg_wal" && pg_waldump -s 0/1514378 -e 0/1514488 000000010000000000000001'
rmgr: Heap len (rec/tot): 71/ 71, tx: 740, lsn: 0/01514378, desc: INSERT+INIT off: 1, blkref #0: rel 1663/5/16384 blk 0
rmgr: Btree len (rec/tot): 90/ 90, tx: 740, lsn: 0/015143C0, desc: NEWROOT level: 0, blkref #0: rel 1663/5/16389 blk 1
rmgr: Btree len (rec/tot): 64/ 64, tx: 740, lsn: 0/01514420, desc: INSERT_LEAF off: 1, blkref #0: rel 1663/5/16389 blk 1
rmgr: Transaction len (rec/tot): 34/ 34, tx: 740, lsn: 0/01514460, desc: COMMIT 2026-07-15 23:57:34 UTC
たった一件の INSERT が、四つの記録に分かれて WAL に残っています。一つ目の Heap ... INSERT+INIT が、テーブル本体へ「この行を足した」という変更そのものです。続く二つの Btree は、主キー id のインデックスにも同じ行の場所を書き足した記録です。インデックスもまた WAL で保護されているので、クラッシュしても本体とインデックスが食い違わずに戻せます。そして最後の Transaction ... COMMIT が、このトランザクションを確定した印です。読者が同じことを試すと LSN の値は違いますが、この四段の並びは同じように現れます。
大事なのは、COMMIT が返った時点でこれらが WAL に書き終わっているという事実です。本体のデータファイルがまだ更新されていなくても、変更の記録は日誌に残っています。次の実験で、その記録だけを頼りにデータが生き返るところを見ます。
実験2:kill -9 してから、自動で復旧する
ここがこの記事の山場です。コミット済みのデータと、コミットしていないデータを両方用意しておいて、サーバーを電源断のように殺し、再起動して、生き残るデータと消えるデータを見比べます。
まず、二枚の psql を開きます。一枚目(セッションA)はそのまま置いておき、二枚目(セッションB)で、コミットしないトランザクションをわざと開いたままにします。
セッションB:
BEGIN;
INSERT INTO accounts VALUES (2, 'bob', 500);
-- ここで COMMIT せず、開いたまま放置する
セッションA:こちらは普通にコミットします。
INSERT INTO accounts VALUES (3, 'carol', 300);
いまテーブルには、コミット済みの alice(実験1で入れた分)と carol、それにセッションB がコミットせず抱えている bob があります。ほかのセッションからは、コミットされていない bob は見えません。
この状態で、サーバーを穏やかに止めるのではなく、SIGKILL で即死させます。停止処理や後片付けをいっさい挟まない、電源プラグを抜くのに近い止め方です。
docker kill --signal=KILL pg-wal-demo
docker start pg-wal-demo
再起動したサーバーのログを見ると、正常に止まらなかったことを検知して、自動リカバリが走っています。
docker logs pg-wal-demo
LOG: database system was not properly shut down; automatic recovery in progress
LOG: redo starts at 0/14ED840
LOG: invalid record length at 0/1552518: expected at least 24, got 0
LOG: redo done at 0/15524E0
LOG: database system is ready to accept connections
redo starts at から redo done までが、WAL を読み直して変更をなぞり直した区間です。途中の invalid record length はエラーではなく、「ここで WAL が途切れている=落ちた瞬間」を見つけた合図で、そこまでを再生して復旧を終えています。テーブルを確認します。
SELECT id, name, balance FROM accounts ORDER BY id;
id | name | balance
----+-------+---------
1 | alice | 1000
3 | carol | 300
コミット済みの alice と carol はそのまま生きています。誰かが停止処理でていねいに保存してくれたわけではありません。落ちる前に WAL へ書き終えていた記録を、再起動時になぞり直しただけです。
一方、コミットしていなかった bob は現れません。ここは少していねいに見ておく価値があります。再起動時の redo は、コミットしたかどうかで選り分けたりはせず、WAL に残っている変更をとにかく順に再生します。ですから bob の INSERT が WAL に残っていれば、その行は物理的にはテーブルへ書き戻されることもあります。それでも SELECT に出てこないのは、bob のトランザクションに COMMIT レコードが無く、その行を作ったトランザクション ID が「確定しなかった(アボートした)」ものとして扱われるからです。前回の MVCC の記事で見た、行バージョンに付く死亡印とちょうど裏返しの関係で、確定しなかった行は誰の目にも映らず、あとで VACUUM が回収します。redo がその行を消したのではなく、確定の印が無い行は最初から見えない、という線引きです。
これが「先にログ」の効き目です。本体への反映が間に合っていなくても、日誌さえ残っていれば、コミット済みは必ず戻り、未コミットは決して残らない、という線引きが保たれます。
念のため付け加えると、この実験はキルのタイミングを狙って合わせたものではありません。carol をコミットしてから docker kill まで数秒あり、この間にチェックポイントは挟んでいません。さきに触れたとおり、復旧はどこまで本体へ書けていたかに左右されないので、いつ落としても行き着く先は同じです。今回は redo starts at … redo done と出ているとおり、再生すべき WAL がちゃんと残っていました。逆に、キルの直前へ CHECKPOINT を差し込んで本体を追いつかせておけば、再生する分はほとんど残りません。
再生する範囲がどこからどこまでになるかは、次の図の関係で決まります。
実験3:なぜ COMMIT は速いのか、そして synchronous_commit
「先にログ」が速さの源だと述べましたが、その速さには一つ条件が付いています。コミットを確定するには、WAL がディスクへ確実に書き終わったこと(fsync)を待つ必要があります。メモリに書いただけでは、電源断でその記録ごと消えてしまうからです。この待ちが一件ごとに入ると、コミットの回数が多いほど積み重なります。
どのくらい効くのかを測ってみます。よけいな要素を省くため、インデックスを持たない単純なテーブルを用意します。
CREATE TABLE bench (v int);
五千件を一件ずつ、別々のトランザクションでコミットして所要時間を比べます。psql は文ごとに自動でコミットするので、次のように一行 INSERT を五千個並べれば、それぞれが別々のコミットになります。
docker exec pg-wal-demo bash -c \
'seq 5000 | sed "s/.*/INSERT INTO bench VALUES (1);/" > /tmp/ins.sql'
まず既定の synchronous_commit = on。コミットごとに WAL の書き込み完了を待ちます。
docker exec pg-wal-demo bash -c \
'time ( ( echo "SET synchronous_commit=on;"; cat /tmp/ins.sql ) | psql -U postgres -q )'
real 3.173
テーブルを空にして、同じ五千件を synchronous_commit = off で流します。こちらは WAL の書き込み完了を待たずにコミットを返します。
docker exec pg-wal-demo bash -c \
'psql -U postgres -qc "TRUNCATE bench;";
time ( ( echo "SET synchronous_commit=off;"; cat /tmp/ins.sql ) | psql -U postgres -q )'
real 0.048
同じ五千件のコミットが、3.173 秒から 0.048 秒へ、六十倍以上ちぢみました。待ちを外すとこれだけ速くなるという事実は、裏を返せば on のときの時間の大半が「ディスクへ確実に書き終わるのを待つ時間」だったことを意味します。
ではいつも off にすればよいかというと、そうはいきません。off は「WAL がまだディスクへ届かないうちにコミットを返す」設定です。その直後に電源が落ちると、返したはずの直近のコミットを数百ミリ秒ぶん取りこぼすことがあります。ここで大切なのは、失われるのは直近の一部だけで、半端に壊れるわけではない点です。WAL の順序は保たれるので、残った分までは一貫した状態に戻ります。壊れるか否かではなく、直近の取りこぼしをどこまで許すか。速さと引き換えの割り切りだと捉えると、設定を選びやすくなります。
実験4:チェックポイントが再生範囲を区切る
最後に、実験2で出てきた「どこから再生するか」を決めているチェックポイントを見ておきます。
WAL は放っておくと延々と伸びます。もし復旧のたびに一番最初から全部を再生していたら、動かし続けたサーバーほど再起動に時間がかかってしまいます。そこで PostgreSQL は折々にチェックポイントを実行します。これは「ここまでの WAL の内容は本体のデータファイルへ確実に反映した」という区切りを打つ処理です。区切りより前はもう本体に入っているので、次にクラッシュしても再生しなくてよくなります。この区切りの位置が、実験2のログに出ていた再生開始点(redo LSN)です。
いまの再生開始点を確認します。
SELECT redo_lsn FROM pg_control_checkpoint();
redo_lsn
-----------
0/1552518
ここでまとまった量の書き込みをして、WAL を先へ進めます。
INSERT INTO accounts SELECT g, 'user' || g, g * 10 FROM generate_series(100, 1100) g;
SELECT pg_current_wal_lsn();
pg_current_wal_lsn
--------------------
0/1749B10
書き込み位置は 0/1749B10 まで進みました。それでも再生開始点はまだ動いていません。
SELECT redo_lsn FROM pg_control_checkpoint();
redo_lsn
-----------
0/1552518
いまクラッシュすれば、0/1552518 から 0/1749B10 までの WAL を全部再生することになります。ここでチェックポイントを手で打ちます。
CHECKPOINT;
SELECT redo_lsn FROM pg_control_checkpoint();
redo_lsn
-----------
0/1749B10
再生開始点が現在位置まで前進しました。チェックポイントがここまでの変更を本体へ流し込んだので、この時点でクラッシュしても再生すべき WAL はほとんど残っていません。ふだんはこのチェックポイントが自動で定期的に走り、復旧にかかる時間が一定の範囲を超えないように保たれています。速く書きたい気持ち(本体への反映は後回しにしたい)と、早く復旧したい気持ち(本体への反映は進めておきたい)の折り合いを、チェックポイントの間隔が調整している、と見ることもできます。
同じ仕組みから、レプリケーションと復元が出てくる
ここまで見てきたのは、「変更を本体へ書く前に、まず日誌へ書き終える」という一つの取り決めでした。この一手は、クラッシュからの復旧だけでなく、もっと広い機能の土台になっています。
WAL は「どの変更が、どの順で起きたか」を完全に記録した再生可能なログです。ということは、この WAL を別のサーバーへ送り続けて同じように再生させれば、もう一台がほぼ同じ状態を追いかけ続けます。これが物理レプリケーションで、待機系やレプリカはこうして作られます。さらに、ある時点で取ったバックアップに、そこから先の WAL を必要な分だけ継ぎ足して再生すれば、「昨日の午後三時ちょうどの状態」といった任意の時点を復元できます。これが PITR(Point-In-Time Recovery)です。どちらも新しい別の仕組みではなく、「日誌を順に再生すれば状態を作り直せる」という同じ性質を、別の相手や別の時点に向けているだけです。
PostgreSQL の永続性や可用性の話で迷ったら、まず「本体より先に、追記専用の日誌へ書き終える」という一枚目の図に戻ると、多くの機能が同じ言葉で説明できます。使い捨てのコンテナはいつでも壊せるので、kill -9 からの復旧を何度か自分の手で起こしてみると、この線引きが腑に落ちると思います。
同じシリーズで、PostgreSQL の UPDATE が行を上書きせずに新しいバージョンを追記する仕組みを PostgreSQLのUPDATEは上書きしない:MVCCと行バージョンをpsqlで見る にまとめています。あわせてどうぞ。