COMMIT が返った瞬間、データはまだメモリにしかないのに、なぜ停電しても消えないのか?
Rails で order.save! が成功したら、その注文は「保存された」と信じてユーザーに完了画面を出しているはずだ。ところが PostgreSQL は、更新したテーブルの中身をその時点ではほとんどディスクに書いていない。では COMMIT 直後にサーバーの電源が落ちたら、その注文はどこから戻ってくるのだろうか。
- PostgreSQL は COMMIT 時にテーブル本体ではなく、変更内容を追記した WAL(先行書き込みログ)だけをディスクに確定させてから成功を返す。
- テーブル本体の書き戻しはチェックポイントでまとめて行われ、クラッシュ後は最後のチェックポイント以降の WAL を再生して変更を復元する。
synchronous_commit = offは直近のコミットを失う代わりに速くなる設定で、データ破損の危険があるfsync = offとは別物である。
テーブルを書き換えるたびにディスクへ書くと遅すぎる
PostgreSQL のテーブルやインデックスは、8KB 程度の固定長ブロックであるページの集まりとしてデータファイルに格納されている。UPDATE を実行すると、DB はまずメモリ上の共有バッファにあるページを書き換える。ディスク上のファイルはまだ古いままだ。
COMMIT のたびに書き換えたページを全部ディスクへ書けば安全ではある。しかし 1 つのトランザクションは、テーブルの行・複数のインデックス・別テーブルなど、ファイル上で離れた位置のページを触る。離れた位置への書き込みはランダム I/O になり、さらに OS のページキャッシュを越えて物理ディスクまで届いたことを確認する fsync(ファイルの変更を記憶装置に確実に書き出させるシステムコール)をファイルごとに待つ必要がある。小さなトランザクションが毎秒何百件も来る Web アプリでは、この方式はすぐに詰まる。
かといってメモリ上の変更だけで「成功」を返せば、電源断で注文は消える。速さと耐久性(コミットしたデータは失われないという性質。ACID の D)を両立させるために使われているのが、WAL という仕組みである。
先にログを書く — WAL のたった一つのルール
WAL(Write-Ahead Logging、先行書き込みログ)のルールは「データファイルの変更は、その変更を記述したログがディスクに確定した後でなければ書き出してはならない」という一点だけだ。ログは「どのページのどこを何に変えたか」という変更記録の列で、pg_wal ディレクトリに、通常 16MB ずつのセグメントファイルとして追記されていく。
このルールの効果は、COMMIT 時に fsync すべきものが WAL ファイルだけになることだ。WAL は末尾に追記するだけなので書き込みはシーケンシャルで、散らばったデータページを書き出すより大幅に安い。さらに同時に多数のトランザクションがコミットする状況では、1 回の WAL の fsync で複数のトランザクションをまとめて確定できる。これをグループコミットと呼ぶ。
UPDATE … ; COMMIT; の流れ アプリ PostgreSQL ディスク │ UPDATE │ │ │──────────▶│ 共有バッファの │ │ │ ページを書き換え │ │ │ WAL レコード生成 │ │ COMMIT │ │ │──────────▶│ WAL を追記+fsync │ │ │──────────────────▶│pg_wal │◀──────────│ 成功を返す │ │ │ │ │ │ (数分後) │ │ │ チェックポイント │ │ │ ダーティページ │ │ │──────────────────▶│table │ │ 書き出し │
COMMIT の時点でデータファイルが古いままでも困らないのは、クラッシュ後の起動時に WAL を読み直し、データページに反映されていない変更をやり直せるからだ。これをロールフォワードリカバリ、または REDO と呼ぶ。冒頭の問いの答えはここにある。注文はテーブルからではなく、WAL から戻ってくる。
倉庫の在庫管理に近い。作業員は入出庫のたびに、手元の作業ノートの末尾へ「A 棚から 3 個出庫」と時系列で書くだけで、棚ごとの在庫台帳は夕方にまとめて書き換える。台帳の更新中に停電しても、最後に台帳を締めた時刻以降のノートを頭から読み直せば、台帳は正しい状態に戻せる。ノートが WAL、台帳がデータファイル、「台帳を締める」作業がチェックポイントにあたる。
チェックポイント — WAL を無限に溜めないための区切り
WAL だけで復元できるなら、データファイルへの書き出しはいつまでも後回しにできそうに思える。しかし実際はそうはいかない。後回しにするほど、クラッシュ後に再生すべき WAL が長くなり、ディスク上に保持すべき WAL も増え続けるからだ。そこで PostgreSQL は定期的にチェックポイントを実行する。チェックポイントでは、書き換え済みでまだディスクに書かれていないダーティページをすべて書き出し、WAL に「ここまでは反映済み」という印を残す。クラッシュリカバリはこの印から REDO を始め、それより前の WAL セグメントは再利用・削除できるようになる。
チェックポイントの間隔は 2 つの設定で決まる。checkpoint_timeout(デフォルト 5 分)が経過するか、WAL の量が max_wal_size(デフォルト 1GB)を超えそうになるか、どちらか早いほうで始まる。
# postgresql.conf checkpoint_timeout = 5min # 時間による発火 max_wal_size = 1GB # WAL 量による発火 checkpoint_completion_target = 0.9
間隔を短くすると、再生すべき WAL が減るのでクラッシュ後の復旧は速くなる。代わりにダーティページの書き出しが頻繁になり、I/O が増える。加えてデフォルトで有効な full_page_writes により、チェックポイント後に初めて変更されるページはページ全体が WAL に書かれる。これは OS クラッシュで書きかけのページ(新旧が混ざった壊れたページ)が残っても、行単位の変更記録だけでは直せないためだ。結果としてチェックポイントを増やすと WAL の量も増える。checkpoint_completion_target(デフォルト 0.9)は、書き出しを次のチェックポイントまでの時間の約 9 割に分散させ、I/O の山を作らないための設定である。
大量の COPY やバッチ更新で WAL が急増すると、max_wal_size による発火が続いてチェックポイントが詰まる。間隔が checkpoint_warning より短くなるとサーバーログに max_wal_size を増やすよう促すメッセージが出るので、時々なら気にせず、頻発するなら max_wal_size を上げるのが定石だ。
「COMMIT を待たない」設定と「fsync をやめる」設定は別物
synchronous_commit(デフォルト on)を off にすると、PostgreSQL は WAL のディスク確定を待たずに COMMIT の成功を返す。WAL はバックグラウンドの WAL writer が wal_writer_delay(デフォルト 200ms)ごとに書き出すので、成功を返してから本当に確定するまでに隙間ができる。WAL writer は忙しい時にページ単位でまとめて書くよう設計されているため、この隙間の最大は wal_writer_delay の 3 倍、デフォルトなら約 600ms になる。
この隙間でクラッシュすると、成功を返したトランザクションが消える。ただし失われるのは「最後に確定した WAL より後のコミット」だけで、DB はその地点までコミット順に再生されるため、整合性は壊れない。そのため、トランザクション単位で選んで使える。
BEGIN;
SET LOCAL synchronous_commit = off;
INSERT INTO page_views (path) VALUES ('/top');
COMMIT;
一方 fsync = off は、WAL を含むすべての書き込みでディスク確定を待たなくなるサーバー全体の設定だ。WAL ルールの前提である「ログが先にディスクにある」が崩れるため、OS クラッシュや電源断でデータベースが任意に壊れうる。名前が似ていても、失うものが「直近のコミット」なのか「DB 全体の整合性」なのかで、重みはまったく違う。なお synchronous_commit には remote_write や remote_apply といった値もあり、レプリカに WAL が届くまで待つかどうかも同じ設定で制御する。レプリケーションもまた、この WAL を送り届ける仕組みの上に作られている。
アクセスログやイベントトラッキングのように、数百 ms 分が消えても困らない大量 INSERT が DB を圧迫しているなら、そのトランザクションだけ SET LOCAL synchronous_commit = off にすると COMMIT 待ちを減らせることがある。決済や在庫のように失ってはいけない書き込みには使わない。また、バッチ処理中にディスク使用量が急に増えたり、サーバーログにチェックポイントが頻繁すぎるという警告が出たりした場合は、pg_wal の膨張と max_wal_size を疑うとよい。
Docker で PostgreSQL を起動し、書き込みで WAL の位置(LSN)が進むことと、synchronous_commit を切り替えたときのスループットの差を確かめる。
docker run -d --name pgwal -e POSTGRES_PASSWORD=pw postgres:17 sleep 5 docker exec pgwal psql -U postgres -c "SELECT pg_current_wal_lsn(), pg_walfile_name(pg_current_wal_lsn());" docker exec pgwal pgbench -U postgres -i postgres docker exec pgwal psql -U postgres -c "SELECT pg_current_wal_lsn();" docker exec pgwal ls -lh /var/lib/postgresql/data/pg_wal docker exec pgwal pgbench -U postgres -c 4 -T 10 postgres docker exec pgwal psql -U postgres -c "ALTER SYSTEM SET synchronous_commit = off;" -c "SELECT pg_reload_conf();" docker exec pgwal pgbench -U postgres -c 4 -T 10 postgres docker rm -f pgwal
LSN(WAL 内のバイト位置を表す番号)が pgbench -i の前後で大きく進み、pg_wal に 16MB のセグメントファイルが並んでいるのが見えるはずだ。2 回の pgbench の tps を比べると、off のほうが高く出ることが多い。差の大きさはディスクの fsync の速さに左右されるので、環境によっては小さい。
- COMMIT が成功したら、テーブルのファイルに書き込まれている — COMMIT 時に確定しているのは WAL だけで、テーブルのページはチェックポイントなどで後から書き出される。耐久性は WAL の再生によって保証されている。
synchronous_commit = offとfsync = offはどちらも「少し危ないが速くなる」同類の設定だ — 前者は直近のコミットを失うだけで整合性は保たれるが、後者はクラッシュ時に DB 全体が壊れうる。本番でfsync = offにしてよい場面はほぼない。- チェックポイントは頻繁なほど安全で良い — 復旧は速くなるが、ページ書き出しと全ページ書き込みによる WAL が増え、平常時の I/O 負荷が上がる。
- WAL
- Write-Ahead Logging。データファイルより先に変更記録をディスクへ確定させる追記型ログ。
- fsync
- ファイルの変更を OS のキャッシュから記憶装置へ確実に書き出させるシステムコール。
- チェックポイント
- ダーティページをすべて書き出し、WAL 再生の開始点を進める処理。
- REDO
- クラッシュ後に WAL を読み直し、データページへの未反映の変更をやり直す復旧処理。
- LSN
- Log Sequence Number。WAL 内のバイト位置を表す単調増加の番号。
- グループコミット
- 複数トランザクションのコミットを 1 回の WAL の fsync でまとめて確定させること。
- PostgreSQL Docs: Write-Ahead Logging (WAL) — WAL のルールと、なぜ WAL だけ fsync すれば足りるのかを 1 ページで説明している。
- PostgreSQL Docs: WAL Configuration — チェックポイントの発火条件、full_page_writes との関係、間隔のトレードオフの一次情報。
- PostgreSQL Docs: Asynchronous Commit — synchronous_commit = off の失われる範囲と、fsync = off との違いが明記されている。
- PostgreSQL Docs: WAL Internals — pg_wal のセグメントファイルと LSN の正体を確認できる。