sasaryo.dev  |  blog   about

Write-Ahead Logging(先行書き込みログ)という仕組みはなぜあるのか

#db

はじめに

こんなSQLを実行したとします。

BEGIN;

UPDATE inventories
SET stock = stock - 1
WHERE product_id = 100;

INSERT INTO orders (product_id, status)
VALUES (100, 'confirmed');

COMMIT;

アプリケーションにはCOMMIT成功が返った。その直後に、サーバーの電源が落ちたとします。

このとき、在庫の変更と注文の記録はどうなるのか。COMMITが返った時点でディスクへの書き込みは終わっているのか。もし書き込みの途中だったら、データは復元できるのか。

この記事では、RDBがこれらの問題をどう解決しているかを追いかけます。キーワードはWAL(Write-Ahead Logging)。説明はPostgreSQLに基づきますが、「データより先にログを永続化する」という考え方自体はMySQLなど他のRDBにも共通しています。

SQL実行とディスクへの書き込みは別の話

最初に押さえておきたいのは、SQLを実行したからといって、ディスク上のデータがすぐに書き換わるわけではないということです。

RDBはディスク上のデータを直接操作しません。テーブルやインデックスのデータはページと呼ばれるブロック単位で管理されていて、操作する前にまずメモリ上の領域に読み込みます。PostgreSQLではこの領域を共有バッファと呼び、ディスク上でこれらのページが格納されているファイルをデータファイルと呼びます。

UPDATEを実行すると、変更されるのはメモリ上のページです。変更されたページはダーティページと呼ばれますが、この時点ではまだデータファイルには反映されていません。ダーティページがデータファイルに書き出されるのは、後述するチェックポイントなどのタイミングです。

SQL実行

メモリ上のページを変更(ダーティページ)

データファイルへの反映は後で

つまり、COMMITが返った時点では、データファイルにはまだ何も書かれていない可能性があります。メモリ上にしか存在しない変更を、RDBはクラッシュ後にどうやって復元しているのでしょうか。

クラッシュは起きる。問題は「復元できるか」

メモリ上にしかない変更を壊さずにディスクに残す方法を考えてみます。

素朴な方法①:コミットのたびにデータファイルに書き出す

コミットのたびに、変更されたすべてのページをデータファイルに書き出してfsync(ディスクへの同期書き込み)する。これならコミット済みのデータは必ずディスク上にあります。ただし、書き込みの途中でクラッシュするとリカバリできません。

テーブルのページ10とインデックスのページ45を書き換える場合、2つのページを別々にディスクに書き出すことになります。1つ目のページの書き込みが終わり、2つ目を書いている途中でクラッシュしたら、テーブルは新しい値、インデックスは古い値という中途半端な状態になります。しかもデータファイルには「2つ書くつもりだった」という情報は残らないので、この不整合を検出して修復する手がかりがありません。

素朴な方法②:ディスクへの書き出しを後回しにする

もう1つの方向は、コミット時にはメモリ上で「コミット済み」とマークするだけにして、ディスクへの書き出しは後でまとめて行う方法です。データファイルに書き出される前にクラッシュしたら、コミットしたはずのデータはメモリごと消えます。復元しようにも、どこにも変更の記録が残っていません。

共通する問題

2つの方法に共通しているのは、クラッシュしたときに「何をしようとしていたか」がわからないということです。①は書き込み途中の不整合を修復できず、②はそもそもディスクに何も残らない。壊れたときに復元するには、「何をしようとしていたか」の記録が必要です。

WALの原則:変更の意図を先に記録する

WAL(Write-Ahead Logging)はこの問題に対する解決策です。

データファイルを直接書き換える前に、「何をどう変えたか」という変更内容をログファイルに記録する。

PostgreSQLの公式ドキュメントではこう説明されています。

WAL’s central concept is that changes to data files (where tables and indexes reside) must be written only after those changes have been logged, that is, after WAL records describing the changes have been flushed to permanent storage. If we follow this procedure, we do not need to flush data pages to disk on every transaction commit, because we know that in the event of a crash we will be able to recover the database using the log.

(WALの中心的な概念は、データファイルへの変更は、その変更を記述するWALレコードが永続ストレージにフラッシュされた後にのみ行われなければならない、ということだ。この手順に従えば、トランザクションのコミットごとにデータページをディスクにフラッシュする必要はない。クラッシュが起きても、ログを使ってデータベースを復元できるからだ。)

PostgreSQL Documentation: Write-Ahead Logging (WAL)

これがWAL、Write-Ahead Logging(先行書き込みログ)の基本原則です。「Ahead」は「データファイルへの書き込みより先に」という意味で、データファイルへの変更は、対応するWALレコードがディスクに書き出された後にしか行われないというルールを表しています。

WALがあれば、たとえデータファイルが中途半端な状態でも、ログに記録された変更の意図を順に再適用するだけで正しい状態を復元できます。

速さの副産物

WALの第一の目的はクラッシュリカバリですが、副産物として速さも手に入ります。

データファイルへの書き込みは、テーブルやインデックスの各ページがディスク上のあちこちに散らばっているため、飛び飛びの位置に書き込むランダムI/Oになります。一方、WALへの書き込みは1本のログファイルへの末尾追記なので、連続した位置に書き込むシーケンシャルI/Oです。同じディスク書き込みでも、シーケンシャルのほうが同期のコストは小さくなります。

The WAL file is written sequentially, and so the cost of syncing the WAL is much less than the cost of flushing the data pages.

(WALファイルはシーケンシャルに書き込まれるため、WALの同期コストはデータページのフラッシュに比べてずっと小さい。)

PostgreSQL Documentation: Write-Ahead Logging (WAL)

注文処理を例に、WALへの書き込みを追ってみる

冒頭のSQL(在庫を減らして注文を記録するトランザクション)を例に、コミットまでの流れを追ってみます。

1. 共有バッファ上のページを変更する

UPDATE inventoriesINSERT INTO ordersを実行すると、PostgreSQLは共有バッファ上の該当ページを変更します。この時点ではメモリ上の変更だけで、ディスクのデータファイルには何も起きていません。

2. WALレコードをWALバッファに書く

ページを変更すると同時に、PostgreSQLは変更内容をWALレコードとして記録します。WALレコードとは、「どのテーブルの、どのページの、どの位置を、どう変えたか」を表す小さなデータです。例えば今回の処理なら「inventoriesテーブルのページ10のオフセット200を書き換えた」「ordersテーブルのページ3にタプルを挿入した」といったレコードが生成されます。

生成されたWALレコードは、まずWALバッファに追加されます。WALバッファはメモリ上の領域で、WALレコードをディスクに書き出す前に一時的に溜めておく書き込みバッファです。コミット時やバッファが埋まったタイミングで、まとめてディスク上のWALファイルに書き出されます。

3. コミットレコードをWALバッファに追加する

WALは1本のログファイルに、サーバー上で同時に動いている複数のトランザクションの変更を時系列に記録していきます。そのため、WALの中身は「トランザクションAの変更、Bの変更、Aの変更、Aのコミット、Bの変更……」のように混在しています。

COMMITを実行すると、WALバッファにコミットレコードが追加されます。これは「このトランザクションの変更はここで確定した」という区切りの印です。クラッシュリカバリ時、PostgreSQLはコミットレコードが存在するトランザクションの変更だけを復元し、コミットレコードがないトランザクション、つまり未コミットのまま中断されたトランザクションの変更は破棄します。

4. WALバッファの内容をディスクにフラッシュする

WALバッファの内容を、ディスク上のWALファイルに書き出します。このとき使われるのがfsyncです。OSに対して「バッファに溜めずに本当にディスクに書き込め」と指示するシステムコールで、通常のwrite()だけではOSのページキャッシュに留まる可能性がありますが、fsyncはディスクへの物理的な書き込みを保証します。

ここがコミットの耐久性が確定する瞬間です。WALファイルへの書き込みは、ファイル末尾への追記なのでシーケンシャルI/O。データファイルのあちこちに書き込むのとは違い、1回の同期書き込みで済みます。

5. アプリケーションにCOMMIT成功を返す

WALのフラッシュが完了したら、アプリケーションにコミット成功を返します。

6. データファイルへの反映は後で行われる

ダーティページがデータファイルに書き出されるのは、後述するチェックポイントのタイミングです。コミット応答のレイテンシとは無関係です。

1. UPDATE・INSERTを実行

2. 共有バッファ上のページを変更

3. WALレコードをWALバッファに追加

4. COMMITレコードを追加

5. WALをディスクにフラッシュ(fsync)  ← 耐久性が確定

6. アプリケーションにCOMMIT成功を返す

7. データファイルへの反映はチェックポイントで後から

ポイントは、コミット時に同期的にディスクへ書き出すのはWALだけだということです。データファイルへの反映はコミットの外で非同期に行われる。WALが耐久性を保証してくれるからこそ、データファイルへの反映を後回しにできるわけです。

クラッシュしたらどうなるか

ここまでの説明で、コミット時にはWALだけがディスクに書き出され、データファイルへの反映は後回しにされることがわかりました。では、クラッシュが起きたときにWALはどう使われるのか。

COMMIT成功後、データファイルへの反映前にクラッシュした場合

これが冒頭で提示したシナリオです。アプリケーションにはCOMMIT成功が返っているが、データファイルにはまだ反映されていない。

この場合でも、変更内容はWALに残っています。PostgreSQLは再起動時にWALを読み、まだデータファイルに反映されていない変更を再適用(REDO)します。だからコミット済みのデータは失われません。

WALフラッシュ前にクラッシュした場合

WALがディスクに書き出される前にクラッシュした場合、そのトランザクションのWALレコードは失われます。ただし、同期コミット(デフォルト)であればWALのフラッシュが完了するまでクライアントにCOMMIT成功を返さないので、アプリケーションから見れば「COMMITが返ってこなかった」だけです。耐久性は壊れていません。

WALフラッシュ後、COMMIT応答前にクラッシュした場合

WALはディスクに書き出されたが、アプリケーションにCOMMIT成功を返す前にクラッシュした場合。この場合、WAL上ではコミット済みなのでデータは復旧されますが、アプリケーション側は成功応答を受け取っていません。「接続が切れた」としか見えない。

つまり、アプリケーションから見ると「コミットが成功したのか失敗したのかわからない」状態になりえます。だからこそ、重要な操作には冪等なリトライの仕組みが必要になるわけです。

チェックポイント:WALを永遠に溜め続けないための仕組み

WALがあれば、データファイルへの反映は後回しにできる。でも「後回し」はいつまでも続けられるわけではありません。

WALが際限なく溜まり続けたら、クラッシュリカバリのときに大量のWALを最初からリプレイしなければなりません。復旧に何時間もかかるかもしれない。ディスク容量も食い続けます。

この問題を解決するのがチェックポイントです。

チェックポイントは、「ここまでのダーティページはすべてデータファイルに書き出した」という地点を作る操作です。PostgreSQLではデフォルトで5分ごと、またはWALが一定量(デフォルト1GB)を超えたときに自動的に実行されます。

具体的な動作は次の通りです。

  1. REDOポイントの記録: チェックポイント開始時点のWALの位置(LSN)を記録する。このLSNがREDOポイント(クラッシュリカバリの開始地点)になる
  2. ダーティページの書き出し: 共有バッファ内のすべてのダーティページをデータファイルに書き出す
  3. チェックポイントレコードの書き込み: REDOポイント等のメタデータをWALに記録する
  4. 制御ファイルの更新: pg_controlファイルにチェックポイント情報を書き込む

チェックポイントが完了すれば、それ以前のWALはリカバリに不要になります。古いWALセグメントファイルは再利用または削除できます。

チェックポイント間隔のトレードオフ

チェックポイントの間隔には、次のようなトレードオフがあります。

クラッシュリカバリの流れ

チェックポイントの仕組みがわかったところで、クラッシュリカバリの全体像を見てみます。

PostgreSQLが起動すると、まず制御ファイルpg_control)を読みます。制御ファイルはディスク上に置かれた小さなファイルで、最新のチェックポイントの位置や、前回のシャットダウンが正常だったかどうかといったメタデータが記録されています。異常終了が検出されると、リカバリモードに入ります。

リカバリの流れは次の通りです。

  1. pg_controlから最新のチェックポイントの位置を取得する
  2. そのチェックポイントレコードからREDOポイントを取得する。REDOポイントとは、チェックポイント開始時点のWAL上の位置のことで、「ここから先のWALをリプレイすればよい」という開始地点を示す
  3. REDOポイントからWALの末尾まで、WALレコードを順番にリプレイする
  4. すべてのレコードを処理し終えたら、リカバリ完了

ステップ3で重要なのは、チェックポイント以降のWALだけをリプレイすればよいことです。チェックポイント以前のダーティページはすでにデータファイルに書き出されているので、それより前のWALは不要。これがチェックポイントの存在意義です。

リプレイ時には、WALレコードが「すでに適用済みか」を判定する仕組みがあります。WALレコードにはそれぞれ**LSN(Log Sequence Number)**という通し番号が振られていて、WAL上の位置を一意に示します。一方、データファイルの各ページにも「最後に適用されたWALレコードのLSN」が記録されています。

リプレイ時にこの2つを比較し、WALレコードのLSNがページのLSNより大きければ(=ページに未反映なら)適用し、小さければスキップします。これにより、チェックポイント後にデータファイルに反映済みの変更を二重に適用してしまうことを防いでいます。

おわりに

この記事では、COMMITしたデータがどうやって生き残るのかを追いかけてきました。

整理するとこうなります。RDBの変更はまずメモリ上のページに対して行われ、データファイルにはすぐには反映されない。コミット時にはWAL(変更内容を記録したログ)だけをディスクに書き出す。データファイルへの反映はチェックポイントで非同期に行い、クラッシュしたらWALからリカバリする。

中心にあるのは、「データファイルへの変更を直接保証しようとするのではなく、変更の意図を先に記録しておく」という考え方です。データファイルが壊れても、WALから復元できる。この仕組みがあるからこそ、RDBはデータファイルへの反映を後回しにしつつ耐久性を保てている。

WALはクラッシュリカバリのための仕組みですが、「すべての変更がシーケンシャルなログとして記録されている」という性質は、レプリケーションやポイントインタイムリカバリ(PITR)にも活用されています。WALの本来の目的から生まれた副産物ですが、それはまた別の話。

この記事ではWALを通じて「壊れても復元できる仕組み」を見てきました。 壊れること自体は防げなくても、復元さえできれば実用上は問題ない。この割り切りから、ここまで精巧な仕組みが組み上がっているのが面白かったです。