
2025/12/12 10:16
Disk can lie to you when you write to it
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
本番向けの書き込み先行ログ(WAL)は、データがクラッシュや潜在的なセクタエラーに耐えるために、複数の保護機構を組み合わせる必要があります。
- チェックサム:各レコードヘッダーにはマジックナンバー、シーケンス番号、および CRC32C チェックサムが含まれ、
後にチェックサムが検証されて静かな破損を検出します。fsync()- 冗長性:2 つの WAL ファイルを別々のディスクに並列で書き込み、一方が LSE(潜在的セクタエラー)を起こしてももう一方は壊れません。
- Direct/Durable I/O:
/O_DIRECTを使用すると、カーネルにデータを安定したストレージへフラッシュさせるため、ページキャッシュ遅延を防止します。O_DSYNC- 順序付き操作:Linux では
とio_uringを組み合わせることで、各書き込みが直後にIOSQE_IO_LINKが実行され、追加のコンテキストスイッチを排除します。fsync()- 書き込み後検証:両方の
が完了した後、WAL を再読込しチェックサムを再確認します。失敗した場合はセカンダリ WAL を使用します。fsync()- 回復:クラッシュリカバリ中にシステムは両方の WAL ファイルをスキャンし、重複レコードをマージして最高連続シーケンス番号を特定し、操作を再実行してインメモリ状態を再構築します。
この層状設計は、ページキャッシュ遅延・静かな LSE 及び順序バグを軽減し、5 層の組み合わせにより単一コピーに依存せずに大規模データベースやストレージシステムで信頼性の高いクラッシュリカバリを実現します。
本文
書き込み先行ログ(WAL)の真実
書き込み先行ログ(Write‑Ahead Log、WAL)は、一見すると単純そうに聞こえるデータベース概念です。
まずディスクへレコードを書き込み、その後でメモリ上の状態を更新します。クラッシュしたらログを再生して復旧するだけ―これが「完了」です。
しかし実際はディスクが嘘をつくこともあるのです。PostgreSQL、SQLite、RocksDB、Cassandra… いわゆる耐久性を主張する本番システムはいずれもWALに頼っています。「ここに書けばデータは確実に残る」と約束しますが、その約束を守るには「ディスクが無音で失敗する」様々なケースを理解しておく必要があります。
① 単純想定と現実
write(fd, record, sizeof(record)); // これで完了?
ラップトップのテスト環境ではうまく動きます。
しかし日々数百万回の書き込みを行えば、1 ミリオンに1の確率でエラーが発生し、何度も起こります。テストでは捕捉できない失敗は多いです。
- ページキャッシュ問題 –
はカーネルバッファへコピーするだけで、まだディスクには書き込まれていません。クラッシュすると消えてしまいます。write() - 成功を嘘つくディスク –
が成功を返し、カーネルは同期したと言うが、実際に物理的に保存されていないケースがあります。write() - 順序混乱 – 書き込みAとBが同時に開始して、B が先に完了すると、リカバリコードは A を見逃す可能性があります。
- 単一障害点 – WAL の唯一のコピーにひっかけられたセクタエラーで全データを失います。
これが「耐久性」に対する恐怖の根底です。
② より安全な設計 ― 五つの防御層
WAL を強化するには、次のように五段階で対策します。
それぞれは「どこで失敗しうるか?」という質問への具体的な答えです。
レイヤー 1:チェックサム(CRC32C)
レコードごとに内容のハッシュを付与し、書き込み後に同じ値になることを確認します。
Record Header (20 bytes): [magic_num : 4][sequence_num : 8][checksum : 4] [payload : variable] [padding to 512‑byte alignment]
ハードウェアのビットフリップやディスクファームウェアの誤動作、メモリバスの乱れなどはエラーを返さず、I/O は成功したとみなされます。チェックサムがないと、復旧時にログが汚染されたことに気づかず、数週間後に大きな障害となります。
レイヤー 2:二重 WAL ファイル(LSE 対策)
「潜在的セクタエラー(Latent Sector Error, LSE)」を防ぐため、異なるディスクに同じ WAL を二重で書きます。
Google の運用調査では LSE が頻繁に発生し、単一コピーは無責任と判定されました。
WAL Primary WAL Secondary [Record 1] [Record 1] [Record 2] [Record 2] [Record 3] [Record 3] …
片方に破損が見つかっても、もう一方がバックアップになるので安全です。
レイヤー 3:O_DIRECT + O_DSYNC
write() の際に O_DIRECT と O_DSYNC を併用します。
- O_DIRECT – カーネルページキャッシュをバイパスし、書き込みは直ちにディスクへ行くようにします。
注意:Linux の全ファイルシステムがサポートしているわけではありません。 - O_DSYNC –
が戻る前にデータを物理的に確定させます。write()
これらは「遅くても耐久性が欲しい」ことを明示しています。ページキャッシュは読み取り中心の最適化で、WAL には逆効果です。
レイヤー 4:リンク付き I/O 順序(Linux の io_uring)
io_uring は送信キューと完了キューという二つのリングバッファを持ち、コンテキストスイッチやシステムコールオーバーヘッドを減らします。書き込み→
fsync() をリンクし、同様に副次 WAL も連結します。
Submit: [Write Primary] → [Fsync Primary] → [Write Secondary] → [Fsync Secondary] (それぞれが相手とリンク)
これで、両方の書き込みが完了するまでアプリは戻らず、順序保証を確保できます。カーネル I/O スケジューラに任せると、実際には A が先に完了しても B を先に処理するケースがあります。
レイヤー 5:fsync
後の検証
fsync両方の
fsync() が終了したら、直前に書いたデータを読み取りチェックサムを再計算します。これで潜在的セクタエラーを即座に検知し、破損が発見された場合は二重 WAL を利用して復旧できます。
③ 復旧プロセス
システム起動時の手順:
- 両方の WAL を順次読み込み、有効なチェックサムを持つレコードだけを集める。
- 重複(主副で同じ操作が記録されている)を統合。
- 最も高い連続したシーケンス番号を見つけ、復旧ポイントとする。
- その順序で操作を再実行し、メモリ状態を再構築。
これにより:
- 耐久性が保証されたすべてのトランザクションが再演出される。
- 未コミットの操作はスキップされる。
- 一方の WAL が部分的に破損していても、安定した状態へ復旧できる。
④ なぜこれらが必要なのか?
「五層構造は過剰だ」と思うかもしれません。趣味プロジェクトなら足りないこともあります。しかし本番環境では次のような事態が起きます。
シナリオ 1:静かな破損
WAL 書込み完了 → 48 時間後に LSE が発生 → 知らずに進む。
*二重 WAL 未使用時:データは消失し、耐久性保証が破綻。
*二重 WAL + 検証あり:副次 WAL が即座に検知し、復旧は正常に行える。
シナリオ 2:ページキャッシュの罠
アプリが WAL に書き込み → カーネルが成功を返す → データはページキャッシュに残る。数秒・数分後にカーネルクラッシュ。
*
O_DIRECT 未使用時:データは失われる。*
O_DIRECT 使用時:fsync() が戻った瞬間に物理的にディスクへ書き込まれる。
これらは実際に本番で発生している問題です。経験者が苦しみ、他の人は学びながら設計を整えていきました。
⑤ 結論
本格的な WAL は単なるコードではなく、「このレコードを書けば確実に残る」ことを約束する契約です。
それを守るには:
- チェックサムで破損検知
- 冗長性(二重 WAL)で障害耐性
+O_DIRECT
で物理的な書き込み保証O_DSYNC- 操作リンクで順序を確保し、並行性を失わない
- 復旧前に 検証読み取り を実施
ディスクは嘘をつく。ページキャッシュも同様。ファームウェアのバグやセクタエラーも。WAL はそれらすべてを信用しません。
この設計を体現したコード例は GitHub で公開しています。