為什麼需要快取#
在現代運算系統中,快取無所不在——硬體與軟體層皆然。光是處理器就可能有三、四層快取,RAID 控制器與磁碟也各有自己的快取。
快取用來弭平快慢兩種記憶體之間的效能差距:
- 快速記憶體昂貴、容量小
- 慢速記憶體便宜、容量大
- 快速記憶體無法容納慢速記憶體中的全部資料
但多數情況下,每個特定時刻只有一小部分資料被積極使用。因此撥出一些快速記憶體作為快取來保存熱資料,就能大幅降低存取慢速記憶體的開銷。
在 PostgreSQL 中,緩衝快取保存關聯的頁面,藉此平衡磁碟(毫秒級)與 RAM(奈秒級)之間的存取時間。
為什麼接受雙重快取#
作業系統也有自己的快取,用途相同。因此資料庫系統通常設計成避免雙重快取:磁碟上的資料直接查詢,繞過 OS 快取。
但 PostgreSQL 採取不同做法:它透過緩衝式檔案操作來讀寫所有資料。
側註:direct I/O 的取捨與社群的長期工程
套用 direct I/O 就能避免雙重快取,好處是:
- 降低開銷——PostgreSQL 可使用直接記憶體存取(DMA),而非把緩衝頁面複製到 OS 位址空間
- 獲得對磁碟實體寫入的即時控制權
然而 direct I/O 不支援緩衝化所帶來的資料預取,因此必須另外透過非同步 I/O 實作。這需要對 PostgreSQL 核心進行大規模程式碼修改,還要處理各作業系統在 direct 與非同步 I/O 支援上的不相容。
不過一旦建立起非同步通訊,就能享受「磁碟存取不必等待」的額外好處。PostgreSQL 社群已展開這項重大工程,但實際成果要出現還需要很長時間。
緩衝快取的設計#
緩衝快取位於伺服器的共享記憶體中,所有行程都能存取。它佔據共享記憶體的主要部分,無疑是 PostgreSQL 中最重要也最複雜的資料結構之一。
理解快取如何運作本身就有價值,更重要的是——許多其他結構(子交易、CLOG 交易狀態、WAL 條目)都使用類似的快取機制,只是較為簡單。
這個快取的名字來自它的內部結構:它由一個緩衝區陣列構成,每個緩衝區保留一塊能容納單一資料頁面及其標頭的記憶體。

圖 9-1:緩衝快取由一組緩衝區構成,每個緩衝區容納一個資料頁面與其標頭
標頭包含關於該緩衝區與其中頁面的資訊:
| 標頭資訊 | 說明 |
|---|---|
| 頁面的實體位置 | 檔案 ID、分支、分支檔案中的區塊編號 |
| 髒(dirty)屬性 | 頁面中的資料已被修改,遲早得寫回磁碟 |
| 使用計數(usage count) | 該緩衝區被使用的次數 |
| pin 計數(reference count) | 目前有多少行程正在使用它 |
雜湊鍵由關聯檔案的 ID、分支類型,以及該頁面在此分支檔案中的 ID 構成。因此只要知道頁面,PostgreSQL 就能快速找到含有它的緩衝區。

圖 9-2:以雜湊表由「檔案 ID/分支/區塊編號」快速定位緩衝區
行程要取得某個關聯的資料頁面時,會向緩衝管理器(buffer manager)請求,並得到包含該頁面的緩衝區 ID。接著它讀取快取資料,必要時直接在快取中修改。
頁面使用期間,其緩衝區被 pin 住。Pin 禁止該快取頁面被淘汰,且可與其他鎖並用;每個 pin 也會使使用計數加一。
只要頁面還在快取中,使用它就不會產生任何檔案操作。
用 pg_buffercache 擴充可以探索緩衝快取:
=> CREATE EXTENSION pg_buffercache;
=> CREATE TABLE cacheme(
id integer
) WITH (autovacuum_enabled = off);
=> INSERT INTO cacheme VALUES (1);輔助函式:查詢特定表格的緩衝區
=> CREATE FUNCTION buffercache(rel regclass)
RETURNS TABLE(
bufferid integer, relfork text, relblk bigint,
isdirty boolean, usagecount smallint, pins integer
) AS $$
SELECT bufferid,
CASE relforknumber
WHEN 0 THEN 'main'
WHEN 1 THEN 'fsm'
WHEN 2 THEN 'vm'
END,
relblocknumber,
isdirty,
usagecount,
pinning_backends
FROM pg_buffercache
WHERE relfilenode = pg_relation_filenode(rel)
ORDER BY relforknumber, relblocknumber;
$$ LANGUAGE sql;=> SELECT * FROM buffercache('cacheme');
bufferid | relfork | relblk | isdirty | usagecount | pins
−−−−−−−−−−+−−−−−−−−−+−−−−−−−−+−−−−−−−−−+−−−−−−−−−−−−+−−−−−−
268 | main | 0 | t | 1 | 0
(1 row)該頁面是髒的:它已被修改,但尚未寫入磁碟;使用計數為 1。
快取命中#
緩衝管理器必須讀取頁面時,會先檢查緩衝快取。所有緩衝區 ID 都存放在一個雜湊表中以加速搜尋。
雜湊鍵由三部分構成:關聯檔案的 ID、分支類型、以及頁面在該分支檔案中的 ID。因此只要知道頁面,PostgreSQL 就能快速找到含該頁面的緩衝區,或確認該頁面目前未被快取。
雜湊表有多種實作;緩衝快取採用的是以鏈結解決雜湊碰撞的可擴充表(extendible table)。
這個實作長期以來受到一項批評:雜湊表在「找出被某個關聯的頁面所佔用的全部緩衝區」時毫無用處——而執行
DROP/TRUNCATE或清理期間截斷表格時,正需要這項操作來把頁面移出快取。然而至今沒人提出足夠好的替代方案。
若雜湊表中含有所需的緩衝區 ID,緩衝管理器就 pin 住該緩衝區並把 ID 回傳給行程,該行程隨即能使用快取頁面而不產生任何 I/O 流量。
Pin 的機制是把標頭中的 pin 計數器加一;一個緩衝區可同時被多個行程 pin 住。只要 pin 計數大於零,該緩衝區就被視為使用中,內容不允許發生劇烈變化——例如新元組可以出現(依可見性規則它是不可見的),但頁面本身不能被替換。
帶 analyze 與 buffers 選項執行 EXPLAIN 可看到使用的緩衝區數量:
=> EXPLAIN (analyze, buffers, costs off, timing off, summary off)
SELECT * FROM cacheme;
QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
Seq Scan on cacheme (actual rows=1 loops=1)
Buffers: shared hit=1
Planning:
Buffers: shared hit=12 read=7
(4 rows)hit=1 表示唯一需要讀取的頁面在快取中被找到了。Pin 緩衝區會使使用計數加一。
Pin 的實際觀察#
開啟游標會持有緩衝區 pin,因為它必須提供對結果集下一列的快速存取:
=> BEGIN;
=> DECLARE c CURSOR FOR SELECT * FROM cacheme;
=> FETCH c;
=> SELECT * FROM buffercache('cacheme');
bufferid | relfork | relblk | isdirty | usagecount | pins
−−−−−−−−−−+−−−−−−−−−+−−−−−−−−+−−−−−−−−−+−−−−−−−−−−−−+−−−−−−
268 | main | 0 | t | 3 | 1
(1 row)行程若無法使用某個被 pin 住的緩衝區,通常會跳過它、改選另一個。清理時就能看到這一點:
=> VACUUM VERBOSE cacheme; Skipped 1 page due to buffer pins, 0 frozen pages.該頁面被跳過,因為其元組無法從被 pin 住的緩衝區中實體移除。
但若正是需要這個緩衝區,行程就會排隊等待取得對它的排他存取——帶凍結的清理就是這類操作的例子。
游標關閉或移到另一頁時,緩衝區的 pin 就被釋放(本例中發生在交易結束時)。
頁面修改由同一套 pin 機制保護。PostgreSQL 不會立即寫入磁碟:頁面會以髒的狀態在緩衝快取中停留一段時間,這為讀寫兩方面都帶來效能提升。
快取未命中#
若雜湊表中沒有所查詢頁面的對應項目,代表該頁面未被快取。此時會指派一個新的緩衝區(並立即 pin 住),把頁面讀進該緩衝區,並相應修改雜湊表的參照。
重啟實例清空快取後再讀取,計畫顯示的是 read 而非 hit:
=> EXPLAIN (analyze, buffers, costs off, timing off, summary off)
SELECT * FROM cacheme;
QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
Seq Scan on cacheme (actual rows=2 loops=1)
Buffers: shared read=1 dirtied=1
Planning:
Buffers: shared hit=15 read=7
(4 rows)此外該頁面變髒了(
dirtied=1),因為這個查詢修改了一些提示位元。
pg_statio_all_tables 視圖包含表格使用緩衝快取的完整統計;PostgreSQL 也為索引與序列提供類似視圖。它們同樣能顯示 I/O 操作的統計,但必須啟用 track_io_timing(預設關閉)。
緩衝區搜尋與淘汰#
為頁面挑選緩衝區並不簡單,有兩種情境:
1. 仍有空閒緩衝區
伺服器啟動後所有緩衝區都是空的,並被串成一個清單。只要還有空閒緩衝區,下一個從磁碟讀取的頁面就會佔用清單中的第一個,該緩衝區隨即從清單移除。
緩衝區只有在「其頁面消失且未被其他頁面取代」時才會回到清單——例如呼叫 DROP/TRUNCATE,或清理期間表格被截斷。
2. 沒有空閒緩衝區了
由於資料庫通常比配置給快取的記憶體大,遲早會走到這一步。此時緩衝管理器必須選一個使用中的緩衝區,把其中的快取頁面淘汰。
採用的是時鐘掃描(clock sweep)演算法,以時鐘作比喻:指標指向某個緩衝區,開始繞行整個緩衝快取,每經過一個快取頁面就把它的使用計數減一。指標找到的第一個未被 pin 住且計數為零的緩衝區就會被清空。
於是:使用計數在緩衝區被存取(pin)時遞增,在緩衝管理器搜尋淘汰對象時遞減。結果是最近最少使用的頁面先被淘汰,而被較常存取的頁面能在快取中留得更久。
如你所料,若所有緩衝區的使用計數都非零,指標就得繞不只一圈才能讓某個計數歸零。為避免跑太多圈,PostgreSQL 把使用計數限制在 5 以內。

圖 9-3:時鐘掃描——指標繞行緩衝快取,逐一遞減使用計數,找到第一個未被 pin 住且計數為零的緩衝區
找到待淘汰的緩衝區後:
- 必須把雜湊表中指向該緩衝區內舊頁面的參照移除
- 若該緩衝區是髒的(含有修改過的資料),舊頁面不能直接丟棄——緩衝管理器必須先把它寫到磁碟
- 緩衝管理器把新頁面讀進找到的緩衝區(不論它是被清空的還是原本就空閒)。它使用緩衝式 I/O,因此只有在作業系統的快取中也找不到時,才會真正從磁碟讀取
- 雜湊表更新為指向新頁面,緩衝區被 pin 住,使用計數設為 1——這給了它一些時間,在時鐘指標繞行期間把計數提高
使用 direct I/O、不依賴 OS 快取的資料庫系統,會區分邏輯讀取(從 RAM,即緩衝快取)與實體讀取(從磁碟)。
但從 PostgreSQL 的角度,頁面要不是從緩衝快取讀出,就是向作業系統請求——後者的情況無從得知它究竟是在 RAM 中找到的,還是真的從磁碟讀來的。
大量淘汰#
執行大量讀寫時有個風險:一次性資料可能很快把有用的頁面擠出緩衝快取。
作為預防,大量操作使用相當小的緩衝環(buffer ring),淘汰只在環的界限內進行,不影響其他緩衝區。
程式碼中也使用「ring buffer」一詞,但這個同義詞相當含糊——ring buffer 本身是由數個(屬於緩衝快取的)緩衝區構成的。就此而言,「buffer ring」(緩衝環)更精確。
緩衝環的運作方式:
- 特定大小的緩衝環由一組被依序使用的緩衝區陣列構成
- 起初緩衝環是空的,緩衝區以一般方式從緩衝快取選出後逐一加入
- 之後淘汰開始運作,但只在環的界限內
- 加入環中的緩衝區不會被排除於緩衝快取之外,仍可被其他操作使用。因此若待重用的緩衝區恰好被 pin 住、或使用計數大於 1,它就會被移出環、由另一個緩衝區取代
PostgreSQL 支援三種淘汰策略:
| 策略 | 觸發時機 | 緩衝環大小 |
|---|---|---|
| 大量讀取 | 大表格的循序掃描,且其大小超過緩衝快取的 1/4 | 256 kB(32 個標準頁面) |
| 大量寫入 | COPY FROM、CREATE TABLE AS SELECT、CREATE MATERIALIZED VIEW,以及會造成表格重寫的 ALTER TABLE 變體 | 預設 16 MB(2048 個標準頁面),但絕不超過緩衝快取總大小的 1/8 |
| 清理 | 清理行程執行「不考慮可見性映射」的全表掃描時 | 256 kB(32 個標準頁面) |
若發現該表格已在被掃描,啟動另一次掃描的行程會加入既有的緩衝環、取用當下可得的資料,不產生額外 I/O。第一個行程完成掃描後,第二個再回頭處理被跳過的部分。
- 若
UPDATE或DELETE影響大量列,其表格掃描套用大量讀取策略,但由於頁面持續被修改,緩衝環實質上變得無用- 在 TOAST 表格中存放超大資料時,儘管可能要讀取大量資料,toasted 值永遠透過索引存取,因此繞過了緩衝環
實驗:驗證大量讀取策略的 32 個緩衝區
建立一張讓每列佔滿整頁的表格。預設緩衝快取為 16,384 個頁面(每個 8 kB),所以表格必須超過 4096 頁,掃描才會使用緩衝環:
=> CREATE TABLE big(
id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
s char(1000)
) WITH (fillfactor = 10);
=> INSERT INTO big(s)
SELECT 'FOO' FROM generate_series(1,4096+1);
=> ANALYZE big; -- big: 4097 頁, big_pkey: 14 頁重啟伺服器清快取後做循序掃描,heap 頁面只佔用 32 個緩衝區——正是這次操作的緩衝環:
=> SELECT count(*) FROM pg_buffercache
WHERE relfilenode = pg_relation_filenode('big'::regclass);
count
−−−−−−−
32
(1 row)但索引掃描不使用緩衝環,結果整張表與整個索引都進了緩衝快取:
relfilenode | count
−−−−−−−−−−−−−+−−−−−−−
16546 | 4097
16551 | 14
(2 rows)選擇緩衝快取大小#
緩衝快取大小由 shared_buffers 參數定義,預設值(128 MB)眾所周知偏低,安裝完 PostgreSQL 後立刻調高是合理的。
修改此參數必須重啟伺服器,因為快取用的共享記憶體是在伺服器啟動時配置的。
如何決定適當的值?
即使非常大的資料庫,同時被使用的熱資料集也是有限的。理想情況下,正是這個資料集該塞得進緩衝快取(並保留一些空間給一次性資料)。
- 快取太小 → 積極使用的頁面不斷互相淘汰,導致過多 I/O 操作
- 不假思索地放大也不好 → RAM 是稀缺資源,而且較大的快取會帶來較高的維護成本
最佳的緩衝快取大小因系統而異:取決於可用記憶體總量、資料樣態、工作負載類型等。不存在一體適用的魔術數字或公式。
還要記得:PostgreSQL 中的快取未命中未必觸發實體 I/O。若緩衝快取相當小,OS 快取會使用剩下的空閒記憶體,某種程度上能緩衝一下。但與資料庫不同,作業系統對讀取的資料一無所知,因此它套用的是不同的淘汰策略。
典型建議是從 RAM 的 1/4 開始,再依需要調整。
最好的方法是實驗:增減快取大小並比較系統效能。這自然需要一套與正式環境完全類似的測試系統,而且你必須能重現典型的工作負載。
用 pg_buffercache 做分析:使用分佈與各關聯的快取比例
依使用計數檢視緩衝區分佈:
=> SELECT usagecount, count(*)
FROM pg_buffercache
GROUP BY usagecount
ORDER BY usagecount;
usagecount | count
−−−−−−−−−−−−+−−−−−−−
1 | 4128
2 | 50
3 | 4
4 | 4
5 | 73
| 12125
(6 rows)NULL 使用計數對應空閒緩衝區。
檢查每個關聯有多少比例被快取,以及資料是否「熱」(此處把使用計數大於 1 視為熱):
=> SELECT c.relname,
count(*) blocks,
round( 100.0 * 8192 * count(*) /
pg_table_size(c.oid) ) AS "% of rel",
round( 100.0 * 8192 * count(*) FILTER (WHERE b.usagecount > 1) /
pg_table_size(c.oid) ) AS "% hot"
FROM pg_buffercache b
JOIN pg_class c ON pg_relation_filenode(c.oid) = b.relfilenode
WHERE b.reldatabase IN (
0, -- cluster-wide objects
(SELECT oid FROM pg_database WHERE datname = current_database())
)
AND b.usagecount IS NOT NULL
GROUP BY c.relname, c.oid
ORDER BY 2 DESC
LIMIT 10;
relname | blocks | % of rel | % hot
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−+−−−−−−−−+−−−−−−−−−−+−−−−−−−
big | 4097 | 100 | 1
pg_attribute | 30 | 48 | 47
big_pkey | 14 | 100 | 0
...此例顯示 big 表格與其索引已被完整快取,但它們的頁面並未被積極使用。
執行
pg_buffercache查詢時請遵守兩條簡單規則:
- 重複執行數次,因為回傳的數字會有一定程度的變動
- 不要不停地跑,因為
pg_buffercache擴充會鎖住它檢視的緩衝區,即使只是很短暫
快取暖機#
伺服器重啟後,快取需要一些時間暖機(warm up),也就是累積積極使用的資料。有時把某些表格立刻載入快取會很有幫助,pg_prewarm 擴充正是為此而生:
=> CREATE EXTENSION pg_prewarm;
=> SELECT pg_prewarm('big');
pg_prewarm
−−−−−−−−−−−−
4097
(1 row)除了把表格載入緩衝快取(或僅載入 OS 快取),這個擴充還能把目前的快取狀態寫到磁碟,並在伺服器重啟後還原。要啟用此功能,必須把該擴充的函式庫加入 shared_preload_libraries 並重啟伺服器:
=> ALTER SYSTEM SET shared_preload_libraries = 'pg_prewarm';若 pg_prewarm.autoprewarm 設定未變更,伺服器重載後會自動啟動一個名為 autoprewarm leader 的行程;它每隔 pg_prewarm.autoprewarm_interval 秒(預設 300 秒)把快取頁面清單刷到磁碟(佔用一個 max_parallel_processes 名額)。
頁面清單被寫入 PGDATA/autoprewarm.blocks 檔案(文字格式,含資料庫、表空間、檔案的 ID 以及分支與區段編號)。可以等 autoprewarm leader 第一次執行,也可手動觸發:
=> SELECT autoprewarm_dump_now();重啟後,表格會立刻出現在快取中。仍是 autoprewarm leader 完成所有前置工作:它讀取檔案、依資料庫把頁面分類、重新排序(盡可能讓磁碟讀取變成循序),再交給 autoprewarm worker 處理。
本地快取#
暫存表格不遵循上述流程。既然暫存資料只對單一行程可見,把它載入共享緩衝快取毫無意義。因此暫存資料使用擁有該表格之行程的本地快取(local cache)。
本地緩衝快取的運作大致與共享快取相同:
- 頁面搜尋透過雜湊表進行
- 淘汰遵循標準演算法(但不使用緩衝環)
- 頁面可被 pin 住以避免淘汰
然而本地快取的實作簡單得多,因為它既不必處理記憶體結構上的鎖(緩衝區只能被單一行程存取),也不必處理容錯(暫存資料最多存活到 session 結束)。
由於通常只有少數 session 使用暫存表格,本地快取記憶體是按需配置的。單一 session 可用的本地快取上限由 temp_buffers 參數限制(預設 8 MB)。
儘管名稱相似,
temp_file_limit參數與暫存表格毫無關係;它涉及的是查詢執行期間為暫時存放中間資料而可能建立的檔案。
在 EXPLAIN 輸出中,所有對本地緩衝快取的呼叫都標記為 local 而非 shared:
=> CREATE TEMPORARY TABLE tmp AS SELECT 1;
=> EXPLAIN (analyze, buffers, costs off, timing off, summary off)
SELECT * FROM tmp;
QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
Seq Scan on tmp (actual rows=1 loops=1)
Buffers: local hit=1
Planning:
Buffers: shared hit=12 read=7
(4 rows)