ダイレクトI/OとバッファドI/Oの設計判断
データベースが自前バッファを持ちO_DIRECTを選ぶ理由が腑に落ちます。ページキャッシュ経由のバッファドI/Oとダイレクトの違い、二重バッファリングを避ける設計判断を原理から押さえます。
- 1.バッファドI/Oはページキャッシュを経由し、readはヒットで高速・writeはライトバックで即返るが、永続化の窓と二重コピーを抱える。O_DIRECTはこの層を飛ばしユーザーバッファとデバイスを直結する。
- 2.DBが独自バッファプールを持つのは、アクセス頻度やWALの順序・回復可能性をアプリだけが正確に知っており、汎用LRUのページキャッシュより賢く・確実にキャッシュ管理できるから。
- 3.両方が同じデータをメモリに抱える二重バッファリングはRAMとコピーの浪費。O_DIRECTでカーネル層を外すか、posix_fadvise/madviseでキャッシュを抑制して回避する。
二つのI/O経路:何が違うのか
ファイルへの read/write には、カーネルのページキャッシュを経由する経路と、経由せずデバイスへ直結する経路の二つがあります。前者がバッファドI/O(通常の open の既定)、後者が O_DIRECT フラグで開いたときのダイレクトI/Oです。違いは「データがメモリ上のページキャッシュという中間バッファを通るかどうか」の一点に尽きます。
int fd1 = open("data", O_RDWR); // バッファドI/O:ページキャッシュ経由
int fd2 = open("data", O_RDWR | O_DIRECT); // ダイレクトI/O:キャッシュを迂回
バッファドI/Oでは、read はページキャッシュにヒットすればメモリから返り、write はキャッシュに書いて即座に返ります(ライトバック)。この透過キャッシュの挙動はページキャッシュとライトバックの仕組みが土台です。一方ダイレクトI/Oは、ユーザーバッファとストレージデバイスの間をカーネルのキャッシュを介さずDMAで直接やり取りします。
| 観点 | バッファドI/O | ダイレクトI/O(O_DIRECT) |
|---|---|---|
| 経路 | ユーザー ↔ ページキャッシュ ↔ デバイス | ユーザーバッファ ↔ デバイス(DMA直結) |
| readヒット時 | メモリから即返る(I/Oなし) | 常にデバイスへI/O(キャッシュなし) |
| write | キャッシュに書いて即返る(遅延書戻し) | デバイスへ届くまで原則ブロック |
| メモリコピー | キャッシュ⇄ユーザーで1回余分 | なし(バッファへ直接DMA) |
| 整列制約 | なし | オフセット・長さ・バッファをブロック境界に整列 |
バッファドI/Oの利得と代償
ページキャッシュは「使われていない物理メモリを遊ばせず、ディスク内容で埋める」思想で動きます。これがもたらす利得は明確です。第一にホットデータのキャッシュで再読込がメモリヒットになる。第二に先読み(readahead)で連続アクセスを予測し先回りで取り込む。第三に書き込みの併合と遅延で、同じページへの連続更新を1回のI/Oにまとめ、write を即座に返す。
代償は二つです。一つは永続化の窓——write が返ってもデータはまだメモリ上のダーティページで、fsync を呼ぶまでクラッシュで失われ得ます(fsyncによるクラッシュ整合性)。もう一つが、本稿の主題に直結する余分なメモリコピーです。データは必ずページキャッシュを経由するため、ユーザーバッファ ⇄ ページキャッシュ の間でCPUコピーが1回挟まります。アプリが既に自前でデータをキャッシュしている場合、このコピーとカーネル側キャッシュは純粋な無駄になります。
なぜデータベースは独自バッファを持つのか
PostgreSQL の共有バッファ、MySQL/InnoDB のバッファプール、Oracle の SGA——主要なDBは例外なく自前のバッファプールをプロセス内に確保し、ページキャッシュとは別に管理します。理由は「アプリの方がカーネルより賢くキャッシュできる」からです。
| 判断 | カーネルのページキャッシュ | DBの独自バッファプール |
|---|---|---|
| 置換ポリシー | 汎用LRU/Clock(アクセス頻度の意味を知らない) | ワークロード特化(索引・カタログを優先保持など) |
| 書き戻し順序 | ダーティ閾値とタイマ任せ | WALを先に永続化してからデータ更新(先行書込) |
| 回復の前提 | ページの意味を知らない | チェックポイント・ログ列番号で回復可能 |
| 単位 | ページ(典型4KB) | DBページ(8KB/16KBなど)で揃え整合管理 |
決定的なのは回復可能性の制御です。DBはクラッシュ整合性のために、データページを書き換える前にWAL(先行書込ログ)を確実に永続化する、という順序を厳格に守る必要があります(Write-Ahead Logging)。汎用のページキャッシュは「どのページがログでどれがデータか」「どちらを先に書くべきか」を知らず、ダーティ閾値とタイマで勝手な順序で書き戻します。DBは自分のバッファを持ち、flushの順序とタイミングをアプリが完全に握ることで、回復可能な状態を保証します。さらに、どのページがホットかはクエリの意味を知るDB自身が最も正確に判断でき、汎用LRUより的確な置換ができます。
ページキャッシュの置換は、ページが索引ノードなのか巨大テーブルの1回しか読まない部分なのかを区別しません。大きな全表走査が一度走ると、ホットな索引ページを押し出してキャッシュを汚す(キャッシュ汚染)ことすらあります。DBは自前バッファで「この種のページは保持、走査の使い捨てページは即破棄」といったワークロード特化の選別ができます。
二重バッファリング問題
ここで問題が起きます。DBが自前バッファを持ったままバッファドI/Oでファイルを読むと、同じデータがページキャッシュとバッファプールの両方に載ります。これが二重バッファリング(double buffering)です。
バッファドI/O + 自前バッファプール(二重キャッシュ):
ストレージ ──DMA──► ページキャッシュ ──CPUコピー──► バッファプール ──► クエリ実行
(カーネルが保持) (DBが保持)
▲ ▲
同じ8KBページが二箇所のメモリを占有・コピー1回分の浪費
弊害は二つ。メモリの二重消費——限られたRAMの一部をカーネルが重複して抱え、バッファプールに回せる分が減る。余分なコピー——ストレージ→ページキャッシュ→バッファプール でCPUコピーが1回挟まり、帯域とCPUを食う。DBにとってページキャッシュは「自分の方が上手にやれる仕事を、勝手に下手にやり直す邪魔者」になりがちです。
サーバーの物理メモリをバッファプールに大きく割り当てたつもりが、同じデータがページキャッシュにも載って実効キャッシュ量が目減りする、という事態が起こります。さらにメモリ逼迫時には、回収可能なページキャッシュとアプリのバッファプールの間で回収圧力がせめぎ合い、予期せぬ書き戻しI/Oやレイテンシのスパイクを招きます。
回避策:O_DIRECT とキャッシュ抑制
二重バッファリングの最も直接的な解が O_DIRECT です。ページキャッシュ層を外し、バッファプール ⇄ デバイス をDMAで直結するので、カーネル側の重複キャッシュと余分なコピーが消えます。DBはキャッシュ管理を一手に握れます。ただし O_DIRECT には厳しい整列制約があり、I/Oのオフセット・長さ・ユーザーバッファのアドレスをいずれもデバイスの論理ブロックサイズ(典型512Bや4KB)の倍数に揃える必要があります。整列していないと EINVAL で失敗します。
void *buf;
posix_memalign(&buf, 4096, 4096); // バッファを4KB境界に整列
int fd = open("data", O_RDWR | O_DIRECT);
pread(fd, buf, 4096, 0); // オフセット・長さも4KB倍数
もう一つの軸は、バッファドI/Oのままキャッシュの保持を抑制する助言です。posix_fadvise でアクセスパターンをカーネルに伝えたり、読んだ直後に POSIX_FADV_DONTNEED で該当範囲のキャッシュ破棄を促せます。madvise(MADV_DONTNEED) も同系統です。これらは強制ではなく助言ですが、走査後のキャッシュ汚染を抑える軽量な手段になります。
| 手段 | ページキャッシュ | 整列制約 | 向く場面 |
|---|---|---|---|
| O_DIRECT | 完全に迂回 | 厳格(ブロック境界) | DB等の独自バッファ・キャッシュを自前管理 |
| バッファド + fadvise | 使うが破棄を助言 | なし | 汚染だけ抑えたい・整列が難しい |
| 素のバッファド | フル活用 | なし | OSのキャッシュに任せたい一般用途 |
O_DIRECT はページキャッシュを迂回するだけで、データがストレージの媒体まで届いたことは意味しません。デバイス側の揮発書込キャッシュに留まる可能性があるため、永続性が必要なら依然として fsync(または O_DSYNC 併用)でデバイスキャッシュのフラッシュまで行う必要があります。「ダイレクト=同期で安全」と取り違えると、クラッシュでデータを失います。
設計判断のまとめ
(1)バッファドI/Oはページキャッシュ経由でread高速・write即返だが、二重コピーと永続化の窓を持つ。O_DIRECTはキャッシュを迂回しDMA直結する。(2)DBが独自バッファを持つ理由は、置換ポリシーと書き戻し順序(WAL先行書込)をアプリが握り、回復可能性を保証するため。(3)両方がキャッシュすると二重バッファリングでメモリとコピーが浪費される。(4)O_DIRECTは整列制約を伴い、かつ永続性は別途fsyncが必要、の4点が頻出です。
要点は「キャッシュの主導権を誰が握るか」です。アクセスパターンや回復順序をアプリが正確に知っているなら(DBがその典型)、O_DIRECT でカーネルのキャッシュを外し、自前バッファで賢く管理する方が速く確実です。逆にアプリが特別な知識を持たない一般用途では、ページキャッシュに任せる方が先読みと併合の恩恵を素直に得られます。判断軸は、アプリのキャッシュ知識の有無・二重バッファリングの損得・整列と永続化の運用コストの三つです。この経路の先で、デバイスまでのI/O経路の最適化はストレージのキャッシュ階層、コピー自体を削る技法はゼロコピーI/Oの技法へ繋がります。
OS Article
ダイレクトI/OとバッファドI/Oの設計判断を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
Linuxカーネル
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
DBが独自バッファプールを持つのは、アクセス頻度やWALの順序・回復可能性をアプリだけが正確に知っており、汎用LRUのページキャッシュより賢く・確実にキャッシュ管理できるから。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「Linuxカーネル / ダイレクトI/O」に近いか確認する。
- 強みである「バッファドI/Oはページキャッシュを経由し、readはヒットで高速・writeはライトバックで即返るが、永続化の窓と二重コピーを抱える。O_DIRECTはこの層を飛ばしユーザーバッファとデバイスを直結する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。