Buffer Management:PostgreSQL 如何用 Clock-Sweep 管理記憶體 | PostgreSQL

2026/06/11
Buffer Management:PostgreSQL 如何用 Clock-Sweep 管理記憶體 | PostgreSQL

Buffer ManagementPostgreSQL 記憶體架構的核心組件。Shared Buffer Pool 是所有後端程序共享的頁面快取,目標是讓高頻存取的資料頁留在記憶體中,避免反覆讀取磁碟。理解 Buffer 管理機制是效能調校的關鍵——它直接決定了你的查詢是從記憶體還是磁碟取得資料。

Shared Buffer Pool 概述

Shared Buffer Pool 位於 PostgreSQL 管理的共享記憶體中,大小由 shared_buffers 參數決定。所有 Backend Process 與 Background Worker 都存取同一個 Buffer Pool。

記憶體層次結構:

┌─────────────────────────────────────────────────────┐
│              Shared Memory(共享區域)                │
│                                                     │
│   ┌───────────────────────────────────────────────┐ │
│   │         Shared Buffer Pool                    │ │
│   │   (shared_buffers,預設 128MB)                 │ │
│   │                                               │ │
│   │   ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐       │ │
│   │   │ BUF  │ │ BUF  │ │ BUF  │ │ BUF  │  ...  │ │
│   │   │  1   │ │  2   │ │  3   │ │  4   │       │ │
│   │   │(8KB) │ │(8KB) │ │(8KB) │ │(8KB) │       │ │
│   │   └──────┘ └──────┘ └──────┘ └──────┘       │ │
│   └───────────────────────────────────────────────┘ │
│                                                     │
│   ┌─────────────┐   ┌────────────┐                 │
│   │  WAL Buffers│   │ CLOG Cache │  (其他共享結構)  │
│   └─────────────┘   └────────────┘                 │
└─────────────────────────────────────────────────────┘

每個 Buffer Slot 固定為 8KB(一個 PostgreSQL Page 的大小),Buffer Pool 中的 Slot 數量 = shared_buffers / 8KB。

Buffer 結構

每個 Buffer 由三個部分組成:

Buffer Tag(識別符)

用來識別此 Buffer 對應磁碟上的哪個頁面:

Buffer Tag:
  ┌──────────────────────────────────────────────────────┐
  │  RelFileNode                                         │
  │  ┌──────────────┬───────────────┬──────────────────┐ │
  │  │ spcOid       │ dbOid         │ relOid           │ │
  │  │ (Tablespace) │ (Database)    │ (Relation)       │ │
  │  └──────────────┴───────────────┴──────────────────┘ │
  │  ForkNumber(主資料 / FSM / Visibility Map)          │
  │  BlockNumber(頁面在 Relation 中的位置)              │
  └──────────────────────────────────────────────────────┘

Buffer Descriptor(元資料)

每個 Buffer 的控制結構,記錄狀態與鎖定資訊:

欄位說明
buf_tagBuffer Tag,此 Buffer 對應的頁面識別
refcountPin 計數,有多少程序正在使用此 Buffer
usage_count使用頻率,Clock-Sweep 演算法使用(0-5)
flagsdirty(已修改)、valid(有效)等旗標
content_lock保護 Buffer 內容的 LWLock
io_in_progress_lockI/O 進行中的鎖

Buffer Content(實際資料)

一個完整的 8KB PostgreSQL Page:

8KB Page 結構:
  ┌────────────────────────────────────────────────────┐
  │  Page Header (24 bytes)                            │
  │  lsn, checksum, flags, lower, upper, special      │
  ├────────────────────────────────────────────────────┤
  │  Line Pointer Array (4 bytes per slot)             │
  ├────────────────────────────────────────────────────┤
  │  Free Space                                        │
  ├────────────────────────────────────────────────────┤
  │  Tuple Data(從底部往上填充)                       │
  └────────────────────────────────────────────────────┘

Clock-Sweep 替換演算法

當 Buffer Pool 已滿,需要新頁面時,PostgreSQL 使用 Clock-Sweep(時鐘掃描)演算法選擇替換哪個 Buffer。

Clock-Sweep 演算法示意:

Buffer Pool 陣列(簡化為 8 個 Buffer):
  ┌────┬────┬────┬────┬────┬────┬────┬────┐
  │B1  │B2  │B3  │B4  │B5  │B6  │B7  │B8  │
  │uc=2│uc=0│uc=3│uc=1│uc=0│uc=4│uc=2│uc=1│
  └────┴────┴────┴────┴────┴────┴────┴────┘
         ↑
      clock hand(掃描指針)

演算法步驟:

  1. clock hand 從當前位置開始向右掃描
  2. usage_count > 0:減 1,繼續向右
  3. usage_count = 0
    • refcount > 0(有程序正在使用):跳過繼續
    • refcount = 0:選擇此 Buffer 替換
  4. 替換前若 dirty = true,先寫回磁碟

使用頻率遞增規則:每次存取 Buffer 時 usage_count = min(usage_count + 1, 5)。因此熱點頁面(高頻存取)的 usage_count 快速達到 5,不易被替換;冷頁面的 usage_count 快速降為 0,優先被替換。

Ring Buffer:防止快取污染

大型 Sequential Scan 如果使用普通的 Shared Buffer Pool,會將大量頁面載入 Pool,驅逐其他熱點頁面——這稱為快取污染PostgreSQL 對特定操作使用 Ring Buffer(環形緩衝區)策略避免此問題。

操作類型觸發條件Ring 大小
大型 Seq ScanRelation 大小 > shared_buffers / 4256KB(32 個 Page)
Bulk Write(COPY 等)大量寫入操作8MB
VACUUMVACUUM 清理操作256KB
Ring Buffer 運作方式:

  Shared Buffer Pool (128MB)
  ┌──────────────────────────────────────────┐
  │  正常 Buffer(熱點頁面)                  │
  │  不受 Seq Scan 影響                       │
  │                                          │
  │  ┌──────────────────────┐                │
  │  │ Ring Buffer (256KB)  │ ← Seq Scan 專用 │
  │  │ [p1][p2]...[p32]     │   循環使用      │
  │  │  ↑________________┘  │                │
  │  └──────────────────────┘                │
  └──────────────────────────────────────────┘

Ring Buffer 的限制:若相同頁面被另一個 Backend 以非 Ring 方式請求,該頁面升格為普通 Buffer,不再受 Ring 限制。

Double Buffering 問題

PostgreSQL 的頁面從磁碟到應用程序需要經過兩層快取:Shared BufferOS Page Cache,這稱為 Double Buffering(雙重緩衝)問題。

I/O 路徑:

  ┌──────────────────┐
  │ Shared Buffer    │ ← 第一層:PostgreSQL 管理
  │ (shared_buffers) │
  └────────┬─────────┘
           │ Cache Miss
           ▼
  ┌──────────────────┐
  │  OS Page Cache   │ ← 第二層:作業系統管理
  └────────┬─────────┘
           │ Cache Miss
           ▼
  ┌──────────────────┐
  │   磁碟(Disk)    │
  └──────────────────┘

同一份資料可能同時存在於兩層 Cache 中,浪費記憶體。這也是為什麼 shared_buffers 不應設太大——OS Page Cache 同樣是重要的緩衝層。

effective_cache_size 參數告訴 Query Planner 作業系統快取大約有多大,影響 Index Scan vs Seq Scan 的選擇,但不實際分配記憶體。

Buffer Pin 與 Content Lock

存取 Buffer 需要適當的 Pin 和 Lock 保護,確保並行安全。

讀取頁面流程

1. 在 Buffer Tag Hash Table 中查找目標頁面
2. Pin Buffer(refcount++),防止 Buffer 被替換
3. 獲取 Content Lock(shared mode)
4. 讀取頁面內容
5. 釋放 Content Lock
6. (完成後)Unpin(refcount--)

修改頁面流程

1. 查找 / 載入 Buffer
2. Pin Buffer(refcount++)
3. 獲取 Content Lock(exclusive mode)
4. 修改頁面內容
5. 標記 Buffer 為 dirty
6. 同時寫入 WAL 記錄(先 WAL,後 dirty page)
7. 釋放 Content Lock
8. (後續)Unpin

關鍵規則:

  • refcount > 0(被 Pin):Buffer 不能被替換
  • dirty = true:寫回磁碟前必須先確保對應 WAL 已 flush
  • WAL Protocol:dirty page 寫回磁碟前,其對應的 WAL 記錄必須已持久化

使用 pg_buffercache 分析 Buffer Pool

pg_buffercache 擴展可以直接查看 Buffer Pool 的內部狀態:

-- 載入擴展(超級使用者執行一次)
CREATE EXTENSION IF NOT EXISTS pg_buffercache;

-- 查看 Buffer Pool 整體使用狀況
SELECT
    COUNT(*) AS total_buffers,
    COUNT(*) FILTER (WHERE reldatabase IS NOT NULL) AS used_buffers,
    COUNT(*) FILTER (WHERE reldatabase IS NULL) AS free_buffers,
    pg_size_pretty(COUNT(*) * 8192) AS total_size,
    pg_size_pretty(
        COUNT(*) FILTER (WHERE reldatabase IS NOT NULL) * 8192
    ) AS used_size
FROM pg_buffercache;

-- 查看各 Relation 佔用的 Buffer 數量
SELECT
    c.relname,
    COUNT(*) AS buffer_count,
    pg_size_pretty(COUNT(*) * 8192) AS buffered_size,
    round(100.0 * COUNT(*) /
        (SELECT COUNT(*) FROM pg_buffercache), 2) AS pct
FROM pg_buffercache b
JOIN pg_class c ON c.relfilenode = b.relfilenode
WHERE b.reldatabase = (
    SELECT oid FROM pg_database
    WHERE datname = current_database()
)
GROUP BY c.relname
ORDER BY buffer_count DESC
LIMIT 20;

監控 Cache Hit Ratio

Cache Hit Ratio 是衡量 Buffer Pool 效能的最重要指標:

-- 資料庫級別的 Cache Hit Ratio
SELECT
    datname,
    blks_hit,
    blks_read,
    CASE
        WHEN blks_hit + blks_read = 0 THEN NULL
        ELSE round(100.0 * blks_hit / (blks_hit + blks_read), 2)
    END AS cache_hit_ratio
FROM pg_stat_database
WHERE datname = current_database();

-- 目標:cache_hit_ratio > 99%
-- 若低於 99%,考慮增加 shared_buffers

-- 各表的 Cache Hit Ratio
SELECT
    schemaname, tablename,
    heap_blks_hit, heap_blks_read,
    CASE
        WHEN heap_blks_hit + heap_blks_read = 0 THEN NULL
        ELSE round(100.0 * heap_blks_hit /
            (heap_blks_hit + heap_blks_read), 2)
    END AS heap_hit_ratio
FROM pg_statio_user_tables
ORDER BY heap_blks_read DESC
LIMIT 20;

usage_count 分佈分析

-- 查看 usage_count 分佈(了解熱點程度)
SELECT
    usagecount,
    COUNT(*) AS buf_count,
    round(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 2) AS pct
FROM pg_buffercache
GROUP BY usagecount
ORDER BY usagecount;

若大量 Buffer 的 usagecount 為 0,表示 Buffer Pool 可能過大;若 usagecount = 5 的 Buffer 佔比很高,表示熱點集中,Buffer Pool 使用效率良好。

pg_prewarm:預熱 Buffer Pool

PostgreSQL 重啟後 Buffer Pool 是空的。pg_prewarm 擴展可以在啟動時預熱常用頁面:

-- 載入擴展
CREATE EXTENSION pg_prewarm;

-- 手動預熱指定表
SELECT pg_prewarm('my_important_table');

PG11+ 支援自動預熱:

# postgresql.conf
shared_preload_libraries = 'pg_prewarm'
pg_prewarm.autoprewarm = on
pg_prewarm.autoprewarm_interval = 300  # 每 5 分鐘儲存一次 buffer 狀態

自動預熱會在關閉時記錄 Buffer Pool 狀態到 $PGDATA/autoprewarm.blocks,重啟後自動載入。

pg_stat_io(PG16+)

PG16 新增 pg_stat_io 視圖,提供更細粒度的 I/O 統計:

-- PG16+:per-object I/O 統計
SELECT
    backend_type,
    object,     -- relation, temp relation...
    context,    -- normal, vacuum, bulkread, bulkwrite...
    reads,      -- 從磁碟讀取次數
    hits,       -- Buffer Pool 命中次數
    evictions,  -- 從 Buffer Pool 驅逐的次數
    reuses      -- Ring Buffer 重用次數
FROM pg_stat_io
ORDER BY reads DESC
LIMIT 20;

context 欄位可以區分不同操作的 I/O:normal(普通查詢)、bulkread(大型 Seq Scan)、bulkwrite(COPY 等)、vacuum(VACUUM 操作)。

生產環境建議配置

# postgresql.conf

# Shared Buffer Pool 大小(通常設 RAM 的 25%)
shared_buffers = 8GB          # 若 RAM = 32GB

# 注意:超過 16GB 通常不再帶來明顯效益(雙重緩衝問題)

# 告訴 Planner OS Cache 大小(不分配記憶體)
effective_cache_size = 24GB   # 若 RAM = 32GB,設 75%

# 啟用 Huge Pages(減少 TLB miss)
huge_pages = try              # PG16+,嘗試使用,失敗則回退

常見陷阱

shared_buffers 設太大

系統 RAM = 32GB
shared_buffers = 24GB  ← 錯誤!

OS Page Cache 同樣是重要的緩衝層。shared_buffers 過大會壓縮 OS Page Cache 的空間,反而降低整體命中率。建議不超過 RAM 的 40%,通常 25% 最佳。

誤以為大型 Seq Scan 會污染 Buffer Pool

PostgreSQL 對大表 Seq Scan 自動使用 Ring Buffer(256KB),不會大量驅逐熱點頁面。前提是表大小 > shared_buffers / 4。注意 Index Scan 的頁面不使用 Ring Buffer。

blks_read 不等於真正磁碟 I/O

Shared Buffer 中找不到頁面時 blks_read 計數增加,但頁面可能已在 OS Page Cache 中。使用 iotoppg_stat_io 觀察實際磁碟 I/O 才是真相。

總結

Buffer Management 是 PostgreSQL 效能的關鍵引擎:

  • Shared Buffer Pool 以 8KB Page 為單位快取資料,所有 Backend 共享存取
  • Clock-Sweep 演算法透過 usage_count 智慧地保留熱點頁面、替換冷頁面
  • Ring Buffer 防止大型 Seq Scan 和 VACUUM 污染快取
  • Double Buffering 提醒我們不能把所有記憶體都給 shared_buffers
  • Cache Hit Ratio > 99% 是生產環境的健康指標
  • pg_buffercachepg_stat_io 是深入分析 Buffer Pool 的利器

下一篇,我們將探討 VACUUM 機制——PostgreSQL 如何清理 Dead Tuple、防止表膨脹與 Transaction ID Wraparound。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。