PostgreSQL Shared Buffers の内部構造を徹底解説:Clock Sweep、Ring Buffer、ロックまで

はじめに

この記事では、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との比較

InnoDBPostgreSQL
取得buf_page_get_genReadBuffer
追い出し防止buf_fix_count++refcount++(Pin)
ロックMTR内で自動取得LockBufferで明示的に取得
解放MTRコミットで一括個別にUnlock + Unpin
ページサイズ16KB8KB

メモリの中身を覗く: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ではBGWriterCheckpointerという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のフラッシュとの比較

InnoDBPostgreSQL
ダーティページ管理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種類のロック

  1. BufMappingLock(パーティションLWLock)
    • 保護対象:BufferTag → BufferDesc のハッシュテーブル
    • 実装:NUM_BUFFER_PARTITIONS(128) 個のLWLock
    • InnoDBの Hash Map Lock(16パーティション)に相当
    • PostgreSQLの方がパーティション数が多い
  2. buffer_strategy_lock(スピンロック)
    • 保護対象:Free ListとClock Sweepの針(nextVictimBuffer)
    • 実装:システム全体で1つのスピンロック
    • InnoDBのFree List Mutex + LRU List Mutexに相当
    • ただしClock Sweepの針はアトミック操作で動かせるので、実際にこのロックが必要な場面は限定的
  3. Buffer Header Spinlock(BM_LOCKEDフラグ)
    • 保護対象:BufferDescのstateフィールド
    • 実装:stateの1ビット(BM_LOCKED)をスピンロックとして使用
    • InnoDBのBlock Mutexに相当
    • 数命令分しか保持しない超短期ロック
  4. 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とのロック比較

役割InnoDBPostgreSQL
ハッシュテーブル保護Hash Map Lock(16パーティション)BufMappingLock(128パーティション)
リスト/戦略保護LRU/Free/Flush List Mutex(各1つ)buffer_strategy_lock(1つ)
バッファヘッダ保護Block MutexBM_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との比較を交えて振り返ります:

テーマPostgreSQLInnoDB
基本インターフェース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キャッシュとの関係、プロセスモデルなど)に合わせた合理的な選択です。

参考

コメント

タイトルとURLをコピーしました