はじめに
この記事では、PostgreSQLのShared Buffersの内部実装を深掘りします。
前回の記事(InnoDB Buffer Pool の内部構造を徹底解説)ではMySQLのBuffer Poolを解説しました。今回はPostgreSQL 17のソースコード(src/backend/storage/buffer/)を読みながら、同じテーマをPostgreSQLの視点で解説します。InnoDBとの違いも随所で比較していきます。
この記事で扱う内容:
- Pin/Unpinインターフェース(InnoDBのFIX/UNFIXに相当)
- メモリ上のデータ構造(BufferDesc、Hash Table、Free List)
- Clock Sweepアルゴリズム(InnoDBのLRUとの違い)
- Ring Buffer戦略(スキャン耐性の仕組み)
- ダーティページの書き戻し(BGWriter、Checkpointer)
- 同時実行制御(4種類のロック)
ソースコードはPostgreSQL 17(REL_17_STABLE)を参照しています。
そもそもShared Buffersって何?
InnoDBのBuffer Poolと同じ役割です。ディスク上のデータをメモリにキャッシュして、ディスクI/Oを最小化する仕組みです。
shared_buffers = 128MB -- デフォルト(本番では物理メモリの25%程度が推奨)
InnoDBとの大きな違いは、PostgreSQLはOSのページキャッシュとの二重キャッシュを前提にしている点です。InnoDBはO_DIRECTでOSキャッシュをバイパスすることが多いですが、PostgreSQLは伝統的にOSキャッシュと協調して動きます。そのためshared_buffersはメモリ全体の25%程度に抑え、残りをOSキャッシュに任せるのが定石です。
Pin/Unpin:ページの借り方・返し方
InnoDBではFIX/UNFIXでしたが、PostgreSQLではPin/Unpinと呼びます。概念は同じで、「このページは使用中だから追い出すな」というマークです。
ステップ1:ページを取得する(Pin)
上位レイヤーがページを必要とするとき、ReadBufferを呼びます:
Buffer buf = ReadBuffer(relation, blockNum);
// relation: テーブルやインデックスのリレーション
// blockNum: 欲しいブロック番号
// 戻り値: バッファID(整数)
Source: https://github.com/postgres/postgres/blob/master/src/backend/storage/bufmgr.c (ReadBuffer)
InnoDBのbuf_page_get_genに相当します。違いは、InnoDBがポインタ(buf_block_t*)を返すのに対し、PostgreSQLは**バッファID(整数)**を返す点です。実際のページデータにアクセスするには、このIDからポインタを取得します:
Page page = BufferGetPage(buf);
// buf: ReadBufferで取得したバッファID
// page: 8KBのページデータへのポインタ
Source: https://github.com/postgres/postgres/blob/master/src/include/storage/bufmgr.h (BufferGetPage)
ReadBufferの内部では、BufferAllocが呼ばれてページの検索・確保が行われます:
// BufferAlloc の流れ(bufmgr.c)
BufferAlloc(smgr, forkNum, blockNum, strategy, &foundPtr) {
// 1. BufferTagを作成(ページの一意識別子)
InitBufferTag(&newTag, &smgr->smgr_rlocator.locator, forkNum, blockNum);
// 2. ハッシュ値を計算し、対応するパーティションロックを取得
newHash = BufTableHashCode(&newTag);
newPartitionLock = BufMappingPartitionLock(newHash);
// 3. ハッシュテーブルを検索
LWLockAcquire(newPartitionLock, LW_SHARED);
existing_buf_id = BufTableLookup(&newTag, newHash);
if (existing_buf_id >= 0) {
// ヒット! Pinして返す
buf = GetBufferDescriptor(existing_buf_id);
PinBuffer(buf, strategy); // refcount++, usage_count++
LWLockRelease(newPartitionLock);
return buf;
}
// ミス → victimバッファを確保してハッシュテーブルに登録
LWLockRelease(newPartitionLock);
victim_buffer = GetVictimBuffer(strategy, io_context);
// ... ハッシュテーブルに新しいタグを登録 ...
}
Source: https://github.com/postgres/postgres/blob/master/src/backend/storage/bufmgr.c (BufferAlloc)
ステップ2:ページにロックをかける
InnoDBではFIXとラッチ取得がbuf_page_get_gen内で一体化していましたが、PostgreSQLではPinとLockが別の操作です:
// 1. まずPin(追い出し防止)
Buffer buf = ReadBuffer(relation, blockNum);
// 2. 次にLock(同時アクセス制御)
LockBuffer(buf, BUFFER_LOCK_SHARE); // 読み取りロック
// または
LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); // 書き込みロック
// 3. ページにアクセス
Page page = BufferGetPage(buf);
// ... ページを読み書き ...
// 4. Unlock + Unpin(順番に注意:先にUnlock、次にUnpin)
LockBuffer(buf, BUFFER_LOCK_UNLOCK);
ReleaseBuffer(buf); // Unpin: refcount--
Source: https://github.com/postgres/postgres/blob/master/src/backend/storage/bufmgr.c (ReadBuffer, PinBuffer, LockBuffer)
InnoDBのMTR(Mini-Transaction)のような「まとめて解放」の仕組みはありません。各バッファを個別にUnlock/Unpinします。これはPostgreSQLのWAL(Write-Ahead Log)の設計がInnoDBのMTRとは異なるためです。
InnoDBとの比較
| InnoDB | PostgreSQL | |
|---|---|---|
| 取得 | buf_page_get_gen | ReadBuffer |
| 追い出し防止 | buf_fix_count++ | refcount++(Pin) |
| ロック | MTR内で自動取得 | LockBufferで明示的に取得 |
| 解放 | MTRコミットで一括 | 個別にUnlock + Unpin |
| ページサイズ | 16KB | 8KB |
メモリの中身を覗く:BufferDesc、BufferTag、Hash Table
BufferTag:ページの一意識別子
InnoDBではpage_id(スペースID + ページ番号)でページを識別しますが、PostgreSQLではBufferTagという構造体を使います:
typedef struct buftag {
Oid spcOid; // テーブルスペースOID
Oid dbOid; // データベースOID
RelFileNumber relNumber; // リレーションファイル番号
ForkNumber forkNum; // フォーク番号(main, fsm, vmなど)
BlockNumber blockNum; // ブロック番号
} BufferTag;
Source: https://github.com/postgres/postgres/blob/master/src/include/storage/buf_internals.h (BufferTag)
InnoDBのpage_idが2要素なのに対し、BufferTagは5要素です。PostgreSQLはテーブルスペース・データベース・リレーション・フォークという階層構造を持つため、これだけの情報が必要になります。
BufferDesc:バッファの管理構造体
InnoDBのbuf_block_tに相当するのがBufferDescです:
typedef struct BufferDesc {
BufferTag tag; // このバッファが保持するページの識別子
int buf_id; // バッファのインデックス番号(0始まり)
pg_atomic_uint32 state; // 状態フラグ + refcount + usage_count
// (1つの32ビット整数にパック)
int wait_backend_pgprocno; // Pin待ちのバックエンド
int freeNext; // Free Listの次のバッファ
LWLock content_lock; // ページ内容を保護するロック
} BufferDesc;
Source: https://github.com/postgres/postgres/blob/master/src/include/storage/buf_internals.h (BufferDesc)
InnoDBのbuf_block_tと比べるとかなりコンパクトです。特徴的なのはstateフィールドで、フラグ・refcount・usage_countを1つの32ビット整数にパックしています:
// state フィールドのビットレイアウト
// ビット 0-17: refcount(Pin数、最大262143)
// ビット 18-21: usage_count(0〜5、Clock Sweepで使用)
// ビット 22-31: フラグ
#define BM_LOCKED (1U << 22) // ヘッダスピンロック中
#define BM_DIRTY (1U << 23) // ダーティ(要書き戻し)
#define BM_VALID (1U << 24) // データが有効
#define BM_TAG_VALID (1U << 25) // タグが割り当て済み
#define BM_IO_IN_PROGRESS (1U << 26) // I/O実行中
#define BM_IO_ERROR (1U << 27) // 前回のI/Oが失敗
#define BM_JUST_DIRTIED (1U << 28) // 書き込み開始後にダーティ化
#define BM_PIN_COUNT_WAITER (1U << 29) // Pin数=1待ちのプロセスあり
#define BM_CHECKPOINT_NEEDED (1U << 30) // チェックポイントで書き出し必要
#define BM_PERMANENT (1U << 31) // 永続バッファ
#define BM_MAX_USAGE_COUNT 5 // usage_countの上限
Source: https://github.com/postgres/postgres/blob/master/src/include/storage/buf_internals.h (BUF_REFCOUNT_MASK, BUF_USAGECOUNT_MASK)
1つの整数にパックすることで、アトミック操作(CAS)で複数の状態を同時に更新できます。これはロック競合を減らすための重要な最適化です。
InnoDBとの構造比較
InnoDB:
buf_block_t(数百バイト)
├─ buf_page_t page // ページ情報
├─ byte* frame // 16KBデータへのポインタ
├─ rw_lock_t lock // 読み書きロック
└─ buf_fix_count // FIXカウンタ
PostgreSQL:
BufferDesc(約64バイト)
├─ BufferTag tag // ページ識別子
├─ pg_atomic_uint32 state // フラグ+refcount+usage_count
├─ int freeNext // Free Listリンク
└─ LWLock content_lock // 読み書きロック
※ ページデータ(8KB)は別の連続メモリ領域に配置
PostgreSQLではBufferDescの配列とページデータの配列が別々のメモリ領域に確保されます。buf_idをインデックスとして両方にアクセスします。InnoDBのChunkのように1つの連続領域にまとめる方式とは異なります。
Hash Table:パーティション分割
InnoDBと同様に、ページの検索にはハッシュテーブルを使います。PostgreSQLでもパーティション分割されています:
// NUM_BUFFER_PARTITIONS = 128(デフォルト)
// InnoDBの innodb_page_hash_locks = 16 より多い
newHash = BufTableHashCode(&newTag);
newPartitionLock = BufMappingPartitionLock(newHash);
// 共有ロック(検索時)
LWLockAcquire(newPartitionLock, LW_SHARED);
buf_id = BufTableLookup(&newTag, newHash);
LWLockRelease(newPartitionLock);
// 排他ロック(新規登録時)
LWLockAcquire(newPartitionLock, LW_EXCLUSIVE);
BufTableInsert(&newTag, newHash, victim_buf_id);
LWLockRelease(newPartitionLock);
Source: https://github.com/postgres/postgres/blob/master/src/include/storage/buf_internals.h (NUM_BUFFER_PARTITIONS)
Free List
InnoDBと同じく、未使用バッファのリストです。起動直後はすべてのバッファがFree Listにいます。InnoDBとの違いは、PostgreSQLのFree Listは片方向リンクリストで、buffer_strategy_lockというスピンロック1つで保護されている点です。
// BufferDesc の freeNext フィールドでリンク
// StrategyControl->firstFreeBuffer が先頭を指す
Source: https://github.com/postgres/postgres/blob/master/src/backend/storage/freelist.c (StrategyControl)
InnoDBのようなFlush List(ダーティページ専用のリスト)はありません。PostgreSQLではBGWriterとCheckpointerがバッファ配列を直接走査してダーティページを見つけます。
Clock Sweep:InnoDBのLRUとは違うアプローチ
InnoDBはLRU List(New/Old Sublist分割)でページの置換を管理していました。PostgreSQLはClock Sweep(時計の針アルゴリズム)を使います。
Clock Sweepの基本的な考え方
バッファ配列を円形に見立てて、「時計の針」(nextVictimBuffer)が一周しながら追い出し候補を探します:
バッファ配列(円形に見立てる)
[0] usage=3
/ \
[7] usage=0 [1] usage=1
← 針はここ
[6] usage=2 [2] usage=5
[5] usage=1 [3] usage=0
\ /
[4] usage=1
針が指すバッファの usage_count を確認:
- 0 なら → 追い出し候補として選択
- 0 より大きければ → usage_count を1減らして、針を進める
実装を読む:StrategyGetBuffer
StrategyGetBufferがvictimバッファを選ぶ関数です(freelist.c):
BufferDesc *
StrategyGetBuffer(BufferAccessStrategy strategy, uint32 *buf_state, bool *from_ring) {
// ★ まずRing Buffer戦略があればそちらを優先(後述)
if (strategy != NULL) {
buf = GetBufferFromRing(strategy, buf_state);
if (buf != NULL) return buf;
}
// ★ Free Listにバッファがあれば取り出す
if (StrategyControl->firstFreeBuffer >= 0) {
while (true) {
SpinLockAcquire(&StrategyControl->buffer_strategy_lock);
buf = GetBufferDescriptor(StrategyControl->firstFreeBuffer);
// Free Listから外す
StrategyControl->firstFreeBuffer = buf->freeNext;
SpinLockRelease(&StrategyControl->buffer_strategy_lock);
// refcount=0 かつ usage_count=0 なら使える
local_buf_state = LockBufHdr(buf);
if (BUF_STATE_GET_REFCOUNT(local_buf_state) == 0
&& BUF_STATE_GET_USAGECOUNT(local_buf_state) == 0) {
return buf;
}
// 使えなければ次を試す
UnlockBufHdr(buf, local_buf_state);
}
}
// ★ Free Listが空 → Clock Sweepで探す
trycounter = NBuffers;
for (;;) {
buf = GetBufferDescriptor(ClockSweepTick()); // 針を1つ進める
local_buf_state = LockBufHdr(buf);
if (BUF_STATE_GET_REFCOUNT(local_buf_state) == 0) {
if (BUF_STATE_GET_USAGECOUNT(local_buf_state) != 0) {
// usage_count > 0 → 1減らして次へ
local_buf_state -= BUF_USAGECOUNT_ONE;
} else {
// usage_count == 0 → このバッファを選択!
return buf;
}
}
// Pinされているバッファはスキップ
UnlockBufHdr(buf, local_buf_state);
}
}
Source: https://github.com/postgres/postgres/blob/master/src/backend/storage/freelist.c (StrategyGetBuffer)
ClockSweepTick:針の進め方
static inline uint32 ClockSweepTick(void) {
// アトミックに針を1つ進める
// 複数プロセスが同時に呼んでも安全
victim = pg_atomic_fetch_add_u32(&StrategyControl->nextVictimBuffer, 1);
if (victim >= NBuffers) {
victim = victim % NBuffers; // 一周したら先頭に戻る
}
return victim;
}
Source: https://github.com/postgres/postgres/blob/master/src/backend/storage/freelist.c (ClockSweepTick)
ポイントは、針の移動がアトミック操作で行われることです。InnoDBのLRU Listではリスト操作にMutexが必要でしたが、Clock Sweepではpg_atomic_fetch_add_u32一発で済みます。これがClock SweepがLRUより軽い理由の一つです。
usage_count:人気度の指標
バッファがPinされるたびにusage_countがインクリメントされます(上限BM_MAX_USAGE_COUNT = 5):
// PinBuffer 内部
if (BUF_STATE_GET_USAGECOUNT(buf_state) < BM_MAX_USAGE_COUNT) {
buf_state += BUF_USAGECOUNT_ONE; // usage_count++
}
Source: https://github.com/postgres/postgres/blob/master/src/backend/storage/bufmgr.c (PinBuffer)
Clock Sweepが一周するたびにusage_countが1ずつ減っていくので、頻繁にアクセスされるページほどusage_countが高く維持され、追い出されにくくなります。
InnoDBのLRUとの比較
- InnoDB LRU
- 双方向リンクリストを維持
- アクセス時にリスト内の位置を移動(リスト操作が必要)
- New/Old Sublist分割でスキャン耐性
- 追い出しはリスト末尾から
- リスト操作にMutexが必要
- PostgreSQL Clock Sweep
- リスト構造を維持しない(配列をそのまま使う)
- アクセス時はusage_countをインクリメントするだけ
- Ring Bufferでスキャン耐性(後述)
- 追い出しは針が指す位置から
- 針の移動はアトミック操作で済む
Clock Sweepの利点はロック競合が少ないこと。LRUではページアクセスのたびにリスト操作(先頭への移動)が必要ですが、Clock Sweepではusage_countのインクリメントだけで済みます。
Ring Buffer:スキャン耐性の仕組み
InnoDBはLRU ListのNew/Old Sublist分割でフルスキャンによるBuffer Pool汚染を防いでいました。PostgreSQLはRing Bufferという全く異なるアプローチを取ります。
問題の再確認
-- 巨大テーブルのフルスキャン
SELECT * FROM huge_table; -- 数百万ページ
-- Clock Sweepだけだと:
-- 全ページがShared Buffersに読み込まれる
-- 各ページのusage_countが1になる
-- 既存のホットなページのusage_countが相対的に下がる
-- → Shared Buffers全体が汚染される
Ring Bufferの仕組み
大量のページを一度だけアクセスする操作(シーケンシャルスキャン、VACUUM、COPY INなど)では、通常のClock Sweepの代わりに小さなバッファリングを使います:
// BufferAccessStrategy の種類とリングサイズ
switch (btype) {
case BAS_BULKREAD: // シーケンシャルスキャン
ring_size_kb = 256; // 256KB(32ページ)
break;
case BAS_BULKWRITE: // COPY IN, CREATE TABLE AS SELECT
ring_size_kb = 16 * 1024; // 16MB
break;
case BAS_VACUUM: // VACUUM
ring_size_kb = 2048; // 2MB(vacuum_buffer_usage_limitで変更可)
break;
}
// ただし shared_buffers の 1/8 を超えない
ring_buffers = Min(NBuffers / 8, ring_buffers);
Source: https://github.com/postgres/postgres/blob/master/src/backend/storage/freelist.c (GetAccessStrategy)
Ring Bufferは、割り当てられた少数のバッファを使い回す仕組みです:
Ring Buffer(シーケンシャルスキャンの場合、32ページ)
┌────┬────┬────┬────┬────┬─ ─ ─┬────┐
│ p0 │ p1 │ p2 │ p3 │ p4 │ │p31 │
└────┴────┴────┴────┴────┴─ ─ ─┴────┘
↑
current(次に再利用するスロット)
スキャンが進むと:
1. currentが指すスロットのバッファを再利用
2. 新しいページをそのバッファに読み込む
3. currentを次に進める(リングなので一周する)
→ どんなに巨大なテーブルでも、使うバッファは32個だけ
→ Shared Buffers全体への影響はゼロ
GetBufferFromRing の実装
static BufferDesc *
GetBufferFromRing(BufferAccessStrategy strategy, uint32 *buf_state) {
// リングの次のスロットに進む
if (++strategy->current >= strategy->nbuffers)
strategy->current = 0;
bufnum = strategy->buffers[strategy->current];
if (bufnum == InvalidBuffer)
return NULL; // まだ埋まっていない → 通常のClock Sweepで確保
buf = GetBufferDescriptor(bufnum - 1);
local_buf_state = LockBufHdr(buf);
// refcount=0 かつ usage_count≤1 なら再利用可能
// usage_count>1 は「他の誰かもこのページを使っている」サイン
if (BUF_STATE_GET_REFCOUNT(local_buf_state) == 0
&& BUF_STATE_GET_USAGECOUNT(local_buf_state) <= 1) {
return buf; // このバッファを再利用
}
// 再利用できない → 通常のClock Sweepにフォールバック
return NULL;
}
Source: https://github.com/postgres/postgres/blob/master/src/backend/storage/freelist.c (GetBufferFromRing)
usage_count <= 1という条件がポイントです。自分のスキャンだけが使ったバッファ(usage_count=1)は再利用しますが、他のクエリも使っているバッファ(usage_count>1)はリングから外して通常のClock Sweepに任せます。
ダーティページの扱い:StrategyRejectBuffer
シーケンシャルスキャン(BAS_BULKREAD)では、リング内のバッファがダーティになった場合、そのバッファをリングから外します:
bool StrategyRejectBuffer(BufferAccessStrategy strategy, BufferDesc *buf, ...) {
// BULKREADモードのみ
if (strategy->btype != BAS_BULKREAD)
return false;
// ダーティバッファをリングから除外
strategy->buffers[strategy->current] = InvalidBuffer;
return true; // 「別のバッファを使え」と返す
}
Source: https://github.com/postgres/postgres/blob/master/src/backend/storage/freelist.c (StrategyRejectBuffer)
これにより、読み取り専用のスキャンではWALフラッシュを避けられます。VACUUMやCOPY INではダーティページが前提なので、この除外は行いません。
InnoDBのNew/Old Sublistとの比較
- InnoDB: New/Old Sublist
- LRU Listを5/8(New)と3/8(Old)に分割
- 新規ページはOld Sublistに挿入
- innodb_old_blocks_time 経過後に再アクセスで昇格
- 最悪でもOld Sublist(3/8)だけが汚染される
- PostgreSQL: Ring Buffer
- スキャン用に専用の小さなリングを割り当て
- リング内のバッファだけを使い回す
- Shared Buffers全体への影響はゼロ
- ただしリングサイズ分のバッファは占有される
Ring Bufferの方がスキャン耐性としては徹底しています。InnoDBのNew/Old Sublistは「汚染を3/8に抑える」のに対し、Ring Bufferは「汚染をゼロにする」アプローチです。
ダーティページの書き戻し:BGWriter と Checkpointer
InnoDBではpage coordinator + page cleanerスレッドがBatch Flushを担当していました。PostgreSQLではBGWriterとCheckpointerという2つのプロセスが役割を分担します。
ダーティページの生成
ページを変更するとき:
// 1. バッファをPinしてExclusive Lockを取得
Buffer buf = ReadBuffer(relation, blockNum);
LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE);
// 2. ページを変更
Page page = BufferGetPage(buf);
// ... ページの内容を変更 ...
// 3. ダーティマークをつける
MarkBufferDirty(buf);
// → state に BM_DIRTY フラグをセット
// → BM_JUST_DIRTIED もセット(書き込み中に再度ダーティ化したことを検出するため)
// 4. WALを書く(変更内容をログに記録)
XLogInsert(RM_HEAP_ID, XLOG_HEAP_INSERT);
// 5. Unlock + Unpin
LockBuffer(buf, BUFFER_LOCK_UNLOCK);
ReleaseBuffer(buf);
Source: https://github.com/postgres/postgres/blob/master/src/backend/storage/bufmgr.c (MarkBufferDirty)
InnoDBとの大きな違いは、Flush Listがないことです。InnoDBはダーティページをoldest_modification順のFlush Listで管理していましたが、PostgreSQLにはそのようなリストがありません。
BGWriter:Clock Sweepの先回り掃除
BGWriterは「Clock Sweepの針が到達する前に、ダーティページを先回りして書き出す」プロセスです:
// BgBufferSync(bufmgr.c)の概要
bool BgBufferSync(WritebackContext *wb_context) {
// 1. Clock Sweepの針がどこまで進んだか確認
strategy_buf_id = StrategySyncStart(&strategy_passes, &recent_alloc);
// 2. バッファ割り当て速度から、次に必要になるバッファ数を予測
upcoming_alloc_est = ...; // 移動平均で平滑化
// 3. 針の前方を走査して、ダーティかつ再利用されそうなバッファを書き出す
for (num_to_scan回) {
sync_state = SyncOneBuffer(next_to_clean, true, wb_context);
// skip_recently_used=true: usage_count>0のバッファはスキップ
}
}
Source: https://github.com/postgres/postgres/blob/master/src/backend/storage/bufmgr.c (BgBufferSync)
// SyncOneBuffer の動作
static int SyncOneBuffer(int buf_id, bool skip_recently_used, ...) {
bufHdr = GetBufferDescriptor(buf_id);
local_buf_state = LockBufHdr(bufHdr);
// Pinされている or usage_count>0 → スキップ
if (BUF_STATE_GET_REFCOUNT(local_buf_state) != 0 ||
(skip_recently_used && BUF_STATE_GET_USAGECOUNT(local_buf_state) != 0)) {
return 0;
}
// ダーティなら書き出す
if (local_buf_state & BM_DIRTY) {
PinBuffer_Locked(bufHdr);
LWLockAcquire(BufferDescriptorGetContentLock(bufHdr), LW_SHARED);
FlushBuffer(bufHdr, ...);
// → ディスクに書き出し
// → BM_DIRTY フラグをクリア
return BUF_WRITTEN;
}
return BUF_REUSABLE;
}
Source: https://github.com/postgres/postgres/blob/master/src/backend/storage/bufmgr.c (SyncOneBuffer)
BGWriterの関連パラメータ:
bgwriter_delay = 200ms -- 起動間隔
bgwriter_lru_maxpages = 100 -- 1回あたりの最大書き出しページ数
bgwriter_lru_multiplier = 2.0 -- 予測割り当て数に対する倍率
bgwriter_flush_after = 512kB -- この量を書いたらOSにフラッシュ要求
Checkpointer:全ダーティページの書き出し
Checkpointerは定期的に(または手動で)すべてのダーティページをディスクに書き出します:
// BufferSync(bufmgr.c)の概要
static void BufferSync(int flags) {
// 1. 全バッファを走査して、BM_CHECKPOINT_NEEDED フラグが立っている
// ダーティバッファを収集
for (buf_id = 0; buf_id < NBuffers; buf_id++) {
local_buf_state = LockBufHdr(bufHdr);
if (local_buf_state & BM_CHECKPOINT_NEEDED) {
// チェックポイント対象として記録
}
}
// 2. 収集したバッファをテーブルスペース順にソート
// → ディスクの連続書き込みを促進
// 3. 順番に書き出し
for (各対象バッファ) {
SyncOneBuffer(buf_id, false, &wb_context);
// skip_recently_used=false: usage_countに関係なく書き出す
}
}
Source: https://github.com/postgres/postgres/blob/master/src/backend/storage/bufmgr.c (BufferSync)
Checkpointerの関連パラメータ:
checkpoint_timeout = 5min -- チェックポイント間隔
checkpoint_completion_target = 0.9 -- 次のチェックポイントまでの何割で書き終えるか
max_wal_size = 1GB -- WALがこのサイズを超えるとチェックポイント発動
InnoDBのフラッシュとの比較
| InnoDB | PostgreSQL | |
|---|---|---|
| ダーティページ管理 | Flush List(oldest_modification順) | なし(バッファ配列を直接走査) |
| バックグラウンド書き出し | page cleaner(Batch Flush) | BGWriter |
| チェックポイント | Checkpointer(Sync Flush) | Checkpointer |
| 書き出し量の決定 | ダーティ率 + アクティブRedo量の計算式 | バッファ割り当て速度の予測 |
| 緊急フラッシュ | Single Flush(ユーザースレッドが同期書き出し) | GetVictimBufferでダーティvictimを書き出し |
InnoDBのFlush Listは「oldest_modification順」という情報を持っているため、チェックポイントを効率的に進められます。PostgreSQLにはこの情報がないため、Checkpointerは全バッファを走査する必要があります。一方で、Flush Listの維持コスト(挿入・削除・ロック)がない分、通常のページアクセスは軽くなります。
ロックの話:PostgreSQLの4種類のロック
InnoDBと同様に、PostgreSQLのShared Buffersも4種類のロックで保護されています。
4種類のロック
- BufMappingLock(パーティションLWLock)
- 保護対象:BufferTag → BufferDesc のハッシュテーブル
- 実装:NUM_BUFFER_PARTITIONS(128) 個のLWLock
- InnoDBの Hash Map Lock(16パーティション)に相当
- PostgreSQLの方がパーティション数が多い
- buffer_strategy_lock(スピンロック)
- 保護対象:Free ListとClock Sweepの針(nextVictimBuffer)
- 実装:システム全体で1つのスピンロック
- InnoDBのFree List Mutex + LRU List Mutexに相当
- ただしClock Sweepの針はアトミック操作で動かせるので、実際にこのロックが必要な場面は限定的
- Buffer Header Spinlock(BM_LOCKEDフラグ)
- 保護対象:BufferDescのstateフィールド
- 実装:stateの1ビット(BM_LOCKED)をスピンロックとして使用
- InnoDBのBlock Mutexに相当
- 数命令分しか保持しない超短期ロック
- Buffer Content Lock(LWLock)
- 保護対象:実際のページデータ(8KB)
- 実装:BufferDesc内のcontent_lock(LWLock)
- SHARED(読み取り)/ EXCLUSIVE(書き込み)
- InnoDBのPage Frame Lock(block->lock)に相当
Buffer Header Spinlock の実装
PostgreSQLのBuffer Header Spinlockは独特です。専用のロック変数を持たず、stateフィールドの1ビット(BM_LOCKED)をスピンロックとして使います:
static uint32 LockBufHdr(BufferDesc *desc) {
for (;;) {
uint32 old_buf_state = pg_atomic_read_u32(&desc->state);
// BM_LOCKEDビットが立っていなければ、CASでセット
if (!(old_buf_state & BM_LOCKED)) {
uint32 lock_state = old_buf_state | BM_LOCKED;
if (pg_atomic_compare_exchange_u32(&desc->state,
&old_buf_state,
lock_state)) {
// ロック取得成功、BM_LOCKED以外のビットを返す
return old_buf_state;
}
}
// 取得失敗 → スピン(ビジーウェイト)
}
}
static void UnlockBufHdr(BufferDesc *desc, uint32 buf_state) {
// BM_LOCKEDビットをクリアして書き戻す
pg_atomic_write_u32(&desc->state, buf_state & (~BM_LOCKED));
}
Source: https://github.com/postgres/postgres/blob/master/src/include/storage/buf_internals.h (LockBufHdr)
この設計により、BufferDescのサイズを小さく保ちつつ、ロック取得とstate読み取りを1回のアトミック操作で行えます。
BM_IO_IN_PROGRESS:I/Oの排他制御
InnoDBではio_fixフラグでI/O中のページを保護していました。PostgreSQLではBM_IO_IN_PROGRESSフラグが同じ役割を果たします:
// I/O開始時
static bool StartBufferIO(BufferDesc *buf, bool forInput, bool nowait) {
// BM_IO_IN_PROGRESS をセット
// 他のプロセスがI/O中なら ConditionVariable で待つ
}
// I/O完了時
static void TerminateBufferIO(BufferDesc *buf, bool clear_dirty, ...) {
// BM_IO_IN_PROGRESS をクリア
// BM_VALID をセット(読み込み完了の場合)
// 待っているプロセスを起こす
}
Source: https://github.com/postgres/postgres/blob/master/src/include/storage/bufmgr.c (StartBufferIO)
PostgreSQL 14以前はI/O用の専用LWLockがバッファごとにありましたが、14以降はConditionVariableに置き換えられ、メモリ使用量が削減されました。
InnoDBとのロック比較
| 役割 | InnoDB | PostgreSQL |
|---|---|---|
| ハッシュテーブル保護 | Hash Map Lock(16パーティション) | BufMappingLock(128パーティション) |
| リスト/戦略保護 | LRU/Free/Flush List Mutex(各1つ) | buffer_strategy_lock(1つ) |
| バッファヘッダ保護 | Block Mutex | BM_LOCKED ビット(スピンロック) |
| ページデータ保護 | Page Frame Lock(rw_lock_t) | Content Lock(LWLock) |
| I/O排他 | io_fix フラグ | BM_IO_IN_PROGRESS + ConditionVariable |
| デッドロック回避 | Latch Order(固定順序) | 同様に固定順序 |
PostgreSQLの方がロック実装が軽量な傾向があります。特にBuffer Header Spinlockは1ビットで実現しており、InnoDBのBlock Mutex(専用のmutex構造体)より省メモリです。一方、InnoDBはFlush Listという追加のデータ構造を持つ分、ロックの種類も多くなっています。
まとめ
この記事では、PostgreSQL Shared Buffersの内部実装をソースコードを追いながら解説しました。InnoDBとの比較を交えて振り返ります:
| テーマ | PostgreSQL | InnoDB |
|---|---|---|
| 基本インターフェース | Pin/Unpin + LockBuffer(明示的) | FIX/UNFIX(MTRで自動管理) |
| ページ識別 | BufferTag(5要素) | page_id(2要素) |
| 管理構造体 | BufferDesc(約64バイト、stateにパック) | buf_block_t(数百バイト) |
| 置換アルゴリズム | Clock Sweep(アトミック操作、軽量) | LRU List(New/Old Sublist分割) |
| スキャン耐性 | Ring Buffer(汚染ゼロ) | New/Old Sublist(汚染を3/8に抑制) |
| ダーティページ管理 | なし(配列を直接走査) | Flush List(oldest_modification順) |
| バックグラウンド書き出し | BGWriter(針の先回り) | page cleaner(Batch Flush) |
| ロック | BM_LOCKEDビット(超軽量) | Block Mutex(専用構造体) |
設計思想の違いが面白いです:
- InnoDBはリスト構造を積極的に使い、情報を整理して持つアプローチ。Flush Listのおかげでチェックポイントが効率的だが、リスト維持のコストがかかる
- PostgreSQLは構造を最小限にして、必要なときに走査するアプローチ。ロック競合が少なく軽量だが、Checkpointerは全バッファを走査する必要がある
どちらが優れているということではなく、それぞれのデータベースの全体設計(WALの仕組み、OSキャッシュとの関係、プロセスモデルなど)に合わせた合理的な選択です。
参考
- PostgreSQL 17 Source Code – src/backend/storage/buffer/
- src/backend/storage/buffer/README – バッファ管理の公式設計ドキュメント
- PostgreSQL Documentation – Shared Buffers
- An In-Depth Analysis of Buffer Pool in InnoDB – Kang Wang (ApsaraDB)


コメント