Logo

新人日誌

首頁關於我部落格

新人日誌

Logo

網站會不定期發佈技術筆記、職場心得相關的內容,歡迎關注本站!

網站
首頁關於我部落格
部落格
分類系列文

© 新人日誌. All rights reserved. 2020-present.

PostgreSQL MVCC 是什麼?Dead Tuple、資料表膨脹與 VACUUM 完整解析

最後更新:2026年3月15日資料庫

你知道 PostgreSQL 執行一次 UPDATE,背後其實不是修改資料,而是「新增一筆資料」嗎?

這個設計決定了資料表為什麼會愈來愈大,以及為什麼需要 VACUUM 來清理。

這篇文章會帶你實際觀察 PostgreSQL 更新資料時發生了什麼事,從現象出發,一步步揭開背後的原理。

PostgreSQL 怎麼把資料存在硬碟上?

在開始之前,先建立一個重要的觀念。

很多人剛開始學資料庫,會覺得「資料庫就像 Excel,一個格子放一個值」。

這個想法沒有錯,但資料庫在硬碟上的實際運作方式跟 Excel 有一個關鍵差異:每一筆資料都會佔用硬碟上的儲存空間。

欄位愈多、內容愈長,這筆資料佔用的空間就愈大。

PostgreSQL 管理儲存空間的方式,是把硬碟空間切成一格一格,每格固定 8KB,這個單位叫做頁面(page)。

資料就一筆一筆依序放進這些格子裡。

你可以把它想像成一個一個的箱子,每個箱子只能裝 8KB 的東西。

當一個箱子裝滿了,PostgreSQL 就會拿出新的箱子繼續裝。

建立 PostgreSQL 測試資料表

為了方便觀察,我們先建一張精簡版的使用者資料表 users_mini,只放幾個欄位:id、display_name、reputation,再加一個填充欄位 padding,讓每筆資料佔用更多硬碟空間。

這樣做是為了讓每個頁面放不下太多筆資料,方便我們觀察資料是怎麼分佈在不同頁面上的。

如果每筆資料很小,可能要幾百筆才能塞滿一頁,觀察起來不直覺。

CREATE TABLE users_mini (
  id            SERIAL PRIMARY KEY,
  display_name  VARCHAR(50),
  reputation    INT,
  last_login    TIMESTAMP,
  padding       CHAR(500)
);

插入 26 筆資料,對應 26 個英文字母:Alex、Billy、Charlie、Devin……以此類推。

INSERT INTO users_mini (display_name, reputation, padding)
VALUES
  ('Alex',    100, ''),
  ('Billy',   200, ''),
  ('Charlie', 150, ''),
  ('Devin',   300, ''),
  -- … 以此類推,共 26 筆
;

插入完成後,查詢這張表:

SELECT ctid, * FROM users_mini ORDER BY id;

結果長這樣:

ctididdisplay_namereputation
(0,1)1Alex100
(0,2)2Billy200
(0,3)3Charlie150
…………
id1
display_nameAlex
reputation100
id2
display_nameBilly
reputation200
id3
display_nameCharlie
reputation150
id…
display_name…
reputation…

你會看到最左邊有一個叫做 CTID 的欄位,這是 PostgreSQL 自動幫每筆資料加上的系統欄位,不需要你自己設定。

CTID 是什麼?

CTID 裡面有兩個數字,格式是 (頁面編號, 列編號)。

還記得前面提到的頁面嗎?CTID 就是用來記錄這筆資料放在哪一頁、第幾個位置。

  • (0, 1) 代表:第 0 頁,第 1 筆資料
  • (0, 2) 代表:第 0 頁,第 2 筆資料

頁面編號從 0 開始,列編號從 1 開始。這是 PostgreSQL 的設計,不需要太糾結。

因為每筆資料有填充欄位,每頁大約只能放 10 筆資料。

26 筆資料會分佈在 3 個頁面上:

page 0:Alex (0,1)、Billy (0,2)、Charlie (0,3)… 共 10 筆
page 1:第 11~20 筆
page 2:第 21~26 筆(剩餘 6 筆)

查看資料表大小,會顯示 24KB(3 頁 × 8KB)。

UPDATE 之後 CTID 為什麼會改變?

現在對所有資料執行一次更新,模擬所有人同時登入的情況:

UPDATE users_mini SET last_login = NOW();

更新完成後,再查一次 CTID:

SELECT ctid, * FROM users_mini ORDER BY id;

結果長這樣:

ctididdisplay_namereputation
(2,7)1Alex100
(2,8)2Billy200
(2,9)3Charlie150
…………
id1
display_nameAlex
reputation100
id2
display_nameBilly
reputation200
id3
display_nameCharlie
reputation150
id…
display_name…
reputation…

更新前 Alex 在 (0, 1),更新後跑到 (2, 7) 去了。

Billy 原本在 (0, 2),現在跑到 (2, 8)。

所有人的 CTID 都變了,而且全部都跑到 page 2 之後的頁面去了。

為什麼執行完 UPDATE 之後,Alex 的位置會改變?

PostgreSQL 的更新不是「覆蓋」,而是「新增一個版本」

原來 PostgreSQL 執行 UPDATE 的方式,跟你想的不一樣。

一般人直覺會以為:UPDATE 就是把原本的資料找到、改掉,就像在 Excel 裡直接修改一格的內容。

但 PostgreSQL 不是這樣運作的。

它的做法是:

  1. 把舊版本留在原地 — 原本在 (0, 1) 的 Alex 繼續待在那裡,完全沒有被動到
  2. 在新的位置寫入新版本 — 把更新後的 Alex 寫到一個新的位置,也就是 (2, 7)
  3. 把舊版本標記為「已失效」 — 告訴 PostgreSQL:這個版本已經過期了,之後可以清掉

所以執行完 UPDATE 之後,資料表裡其實同時存在兩個版本的 Alex:

ctididdisplay_namelast_login狀態
(0,1)1Alex(舊的登入時間)舊版本 ❌
(2,7)1Alex2024-01-01 12:00:00新版本 ✅
id1
display_nameAlex
last_login(舊的登入時間)
狀態舊版本 ❌
id1
display_nameAlex
last_login2024-01-01 12:00:00
狀態新版本 ✅

你平常用 SELECT 查資料,只會看到新版本。

但舊版本其實還在硬碟上,悄悄地佔著空間。

你會開始好奇:為什麼要這樣做?直接覆蓋不就好了?

為什麼要保留舊版本?因為有人正在讀

如果 PostgreSQL 直接覆蓋資料,會發生什麼事?

假設你執行了一個大型 UPDATE,要更新幾千筆使用者的登入時間,這個操作需要跑好幾秒鐘。

就在你改到第 500 筆的時候,另一個人同時執行 SELECT,想查出所有使用者的資料。

如果 PostgreSQL 直接覆蓋,那個人查到的結果會長這樣:

iddisplay_namelast_login
1Alex2024-01-01 12:00:00
2Billy2024-01-01 12:00:00
………
500(某人)2024-01-01 12:00:00
501(某人)2023-06-15 08:30:00
………
display_nameAlex
last_login2024-01-01 12:00:00
display_nameBilly
last_login2024-01-01 12:00:00
display_name…
last_login…
display_name(某人)
last_login2024-01-01 12:00:00
display_name(某人)
last_login2023-06-15 08:30:00
display_name…
last_login…

前 500 筆的 last_login 已經被更新成 2024-01-01 12:00:00,第 501 筆之後還是更新前的 2023-06-15 08:30:00。

同一次查詢,讀到的資料有新有舊,這在資料庫裡叫做髒讀(Dirty Read),是非常嚴重的問題。

PostgreSQL 的解法:保留舊版本

PostgreSQL 的做法是:寫的人在寫新版本的時候,舊版本繼續留著不動。

這樣一來:

  • 讀的人查到的是一個完整、一致的版本,不會讀到寫到一半的資料
  • 寫的人可以放心地把新版本寫到新的位置,不用擔心影響到正在讀資料的人

當 UPDATE 完成之後,下一個人執行 SELECT 就會讀到新版本了。

在 UPDATE 還沒完成之前讀到舊版本,UPDATE 完成之後讀到新版本,每個人看到的資料永遠是完整一致的,不會出現讀到一半的情況。

這個機制就叫做 MVCC(Multi-Version Concurrency Control,多版本並行控制)。

「Multi-Version」指的是同一筆資料可以同時存在多個版本,「Concurrency Control」指的是讓讀和寫可以同時發生、不互相阻塞。

如果你用過 Microsoft SQL Server,它有類似的功能叫做 RCSI(Read Committed Snapshot Isolation),但預設是關閉的,需要手動開啟。PostgreSQL 的 MVCC 則是預設開啟,而且無法關閉。

MVCC 的代價:Dead Tuple 讓資料表變大

MVCC 雖然解決了讀寫衝突,但帶來了一個新問題。

更新 26 筆資料後,再查一次資料表大小:

  • 原本:24KB(3 頁)
  • 更新後:48KB(6 頁)

因為舊版本還留著,新版本又需要額外的頁面來存放,資料表就從 3 頁膨脹成 6 頁。

這裡要介紹一個 PostgreSQL 的術語:Tuple(元組)

你和我平常說的「一筆資料」,在 PostgreSQL 內部叫做 tuple。

兩個詞指的是同一件事,只是當一筆資料同時存在多個版本時,「列(row)」這個詞就說不清楚了——你說的是哪一個版本?

所以 PostgreSQL 用 tuple 來指「磁碟上實際儲存的那一筆」,每個版本都是一個獨立的 tuple。

以 Alex 為例,執行完 UPDATE 之後,磁碟上同時有兩個 tuple:

ctid內容狀態
(0,1)Alex,舊的 last_loginDead Tuple ❌
(2,7)Alex,新的 last_loginLive Tuple ✅
內容Alex,舊的 last_login
狀態Dead Tuple ❌
內容Alex,新的 last_login
狀態Live Tuple ✅

(0, 1) 那個已經沒有人會用到了,但它還是靜靜地躺在 page 0 上佔著空間。

PostgreSQL 把這種沒人使用的舊版本稱為 Dead Tuple(死元組)。

Dead Tuple 會造成查詢變慢

PostgreSQL 執行 SELECT 的時候,需要一頁一頁掃描資料。

頁面愈多,要掃描的範圍就愈大,查詢就愈慢。

原本 3 頁可以掃完,現在要掃 6 頁,如果資料表有幾百萬筆資料,這個差距就非常明顯了。

Dead Tuple 會讓備份變大

如果你用的是儲存快照備份(直接備份硬碟上的檔案),備份的對象是整個資料表的實際大小,Dead Tuple 佔的空間也會一起被備份進去,備份檔案就跟著變大了。

用 VACUUM 清除 Dead Tuple

Dead Tuple 不會自己消失,需要靠一個叫做 VACUUM 的指令來清除。

VACUUM FULL users_mini;

VACUUM FULL 的運作方式,是把整張資料表從頭重建一次。

它會讀取所有舊頁面,把每一個 tuple 檢查一遍,只保留還活著的 live tuple,跳過所有 dead tuple,然後從頭寫出一份全新、乾淨的資料表。

你可以把它想像成整理房間:不是把垃圾藏起來,而是把所有東西搬出去,只把有用的搬回來,重新擺放整齊。

執行完之後,再查一次 CTID 和資料表大小,你會發現一切都回到了起點:

ctididdisplay_namelast_login
(0,1)1Alex2024-01-01 12:00:00
(0,2)2Billy2024-01-01 12:00:00
(0,3)3Charlie2024-01-01 12:00:00
…………
id1
display_nameAlex
last_login2024-01-01 12:00:00
id2
display_nameBilly
last_login2024-01-01 12:00:00
id3
display_nameCharlie
last_login2024-01-01 12:00:00
id…
display_name…
last_login…
  • Alex 回到了 (0, 1)
  • 資料表大小回到 24KB
  • 所有 dead tuple 消失了

不過 VACUUM FULL 有一個嚴重的缺點:執行期間會鎖住整張資料表,沒有人能讀或寫。

這是因為它需要把整張資料表搬走再重建,這段期間任何人想讀或寫都必須等待。

24KB 的小資料表幾乎是瞬間完成,感覺不出來。但如果你的資料表有幾十 GB 甚至 TB,VACUUM FULL 可能要跑好幾個小時,這段時間整個系統就停擺了。

所以在正式環境中,不會頻繁手動執行 VACUUM FULL。

PostgreSQL 提供了一個背景程序叫做 Auto Vacuum,它會自動偵測 dead tuple 的數量,累積到一定程度就在背後幫你清理。

Auto Vacuum 比 VACUUM FULL 輕量,不會完全鎖住資料表,系統可以繼續正常運作,也不需要你手動執行。

小結

概念說明
CTID每筆資料的頁面座標 (頁面, 列)
Page(頁面)PostgreSQL 儲存資料的單位,每頁 8KB
Tuple磁碟上實際儲存的一筆資料(可能同時存在多個版本)
Dead Tuple已經沒人使用的舊版本 tuple
MVCC保留多個版本,讓讀寫可以同時進行不互相干擾
VACUUM清除 dead tuple、回收空間的指令
Auto Vacuum自動在背景執行清理的機制
說明每筆資料的頁面座標 (頁面, 列)
說明PostgreSQL 儲存資料的單位,每頁 8KB
說明磁碟上實際儲存的一筆資料(可能同時存在多個版本)
說明已經沒人使用的舊版本 tuple
說明保留多個版本,讓讀寫可以同時進行不互相干擾
說明清除 dead tuple、回收空間的指令
說明自動在背景執行清理的機制

核心邏輯串起來就是:

UPDATE 不覆蓋 → 舊版本保留(Dead Tuple)→ 資料表膨脹 → 需要 VACUUM 清理 → Auto Vacuum 自動幫你做。

下一步,可以進一步研究:當資料表有索引時,MVCC 和 VACUUM 的行為有什麼不同?

目前還沒有留言,成為第一個留言的人吧!

發表留言

留言將在審核後顯示。

資料庫

目錄

  • PostgreSQL 怎麼把資料存在硬碟上?
  • 建立 PostgreSQL 測試資料表
  • CTID 是什麼?
  • UPDATE 之後 CTID 為什麼會改變?
  • PostgreSQL 的更新不是「覆蓋」,而是「新增一個版本」
  • 為什麼要保留舊版本?因為有人正在讀
  • 如果 PostgreSQL 直接覆蓋資料,會發生什麼事?
  • PostgreSQL 的解法:保留舊版本
  • MVCC 的代價:Dead Tuple 讓資料表變大
  • Dead Tuple 會造成查詢變慢
  • Dead Tuple 會讓備份變大
  • 用 VACUUM 清除 Dead Tuple
  • 小結