PostgreSQL 的 MVCC:同一筆資料為什麼可以同時有好幾個版本

寫給耀達 Lite 雲端進銷存(2026-11-03)。補充 postgres-design-sql.html 第 2 節的「脾氣一」。
前提:AWS 東京、RDS PostgreSQL,所有店家共用資料表、用 RLS 隔開。PostgreSQL 版本還沒定,內容以 PostgreSQL 18 文件為準。資料表名稱都是示範,資料模型還沒定。

這份文件的可信度

1. 先講結論

MVCC(Multiversion Concurrency Control,多版本並行控制):改資料時不蓋掉舊的,而是多寫一個新版本。每條 SQL 看的是某個時間點的快照,所以讀的人不用等寫的人,寫的人也不用等讀的人。

2. 沒有 MVCC 會怎樣

假設資料庫裡每筆資料只有一份。店員正在改商品 A 的庫存,還沒提交,老闆這時要看庫存。只有一份資料的話,資料庫只能選下面其中一種做法:

做法老闆看到什麼問題
讓老闆等店員結束正確的資料,但要等店員的交易開多久,老闆就等多久。反過來,跑很久的報表也可能擋住店員開單
讓老闆直接讀店員還沒提交的數字店員如果取消,老闆看到的是從來沒有成立過的數字(髒讀)
MVCC:舊版本留著給老闆讀店員開始改之前、已經提交的數字不用等、也不會讀到沒提交的資料,但要付出儲存和清理的成本

PostgreSQL 文件的說法是:查詢用的鎖和寫入用的鎖不衝突,所以「讀不會擋寫,寫也不會擋讀」。即使在最嚴格的 Serializable 隔離層級也維持這個保證。

3. 一筆資料怎麼變成好幾個版本

你在 SQL 裡看到的「一列」,在磁碟上可能是好幾個列版本(row version)。每個版本都帶著兩個交易編號,寫在每列的表頭裡:

欄位意思
xmin建立這個版本的交易編號
xmax刪除這個版本的交易編號;沒被刪除時是 0
ctid這個版本在資料表裡的實體位置(第幾頁、第幾個)。更新後會變,所以不能拿來當識別碼,要用主鍵

三種寫入指令實際做的事:

指令實際動作
INSERT寫一個新版本,xmin 是自己的交易編號
DELETE資料不會消失,只是在舊版本的 xmax 寫上自己的交易編號
UPDATE等於上面兩個合起來:舊版本的 xmax 寫上自己,再寫一個新版本。舊版本的 ctid 會指向新版本
店員的交易(編號 205)把庫存從 10 改成 7,還沒提交 舊版本 qty = 10 xmin = 100(早就提交) xmax = 205(還沒提交) 新版本 qty = 7 xmin = 205(還沒提交) xmax = 0 ctid 指向新版本 老闆(別的交易) 205 還沒提交,所以看到 10 店員(交易 205 自己) 自己改的看得到,所以看到 7
交易編號是示意。兩個版本都放在資料表本身的資料頁裡(預設每頁 8 kB),不是放在另外的地方。

4. 怎麼決定你看到哪一版:快照

快照(snapshot)不是把資料複製一份,只是一張很小的清單,記下「拍的當下,哪些交易已經結束、哪些還在進行」。文字格式是 xmin:xmax:進行中的編號。

名字撞到了:快照的 xmin、xmax 跟列版本的 xmin、xmax 是兩回事,只是名字相同。快照的 xmin 是「最小的進行中交易」,xmax 是「已結束的最大編號再加 1」。

例如快照 205:207:205,代表拍的當下 205 還在進行、206 已經結束。判斷方式是:

某個交易編號符合的條件對這張快照來說
150比快照的 xmin 小已經結束;有提交才算數,復原了就不算
205在進行中清單裡還沒結束,當作沒發生
206在範圍內,但不在清單裡已經結束;有提交才算數
207 以上大於等於快照的 xmax拍的時候還沒結束,當作沒發生

有了這張清單,檢查每個列版本就只要問兩件事(簡化版):

  1. 建立它的交易,對我的快照來說算數嗎?(看 xmin)不算數就是還不存在。
  2. 刪除它的交易,對我的快照來說算數嗎?(看 xmax)算數就是已經不存在了。

兩關都過,這個版本才看得到。自己交易改的資料例外,自己看得到(實際上還會比對同一交易裡的指令順序,cmin、cmax 就是做這個的)。

文件也寫明:xmax 不是 0 的版本仍然可能看得到,通常表示刪除它的交易還沒提交,或已經復原。

5. 完整走一遍:店員改庫存,老闆看報表

商品 A 的庫存原本是 10,用預設的 Read Committed。交易編號是示意。

時間發生什麼事磁碟上的版本誰看到什麼
開始 沒有人在改 版本 1:qty=10, xmin=100, xmax=0 所有人:10
1 店員 BEGIN,把庫存改成 7,取得交易編號 205 版本 1:xmax=205
版本 2:qty=7, xmin=205, xmax=0
店員:7
老闆:10,不用等
2 店員 COMMIT 版本都不用改寫,只是 205 的狀態變成已提交 新的查詢看到 7
開始得更早、還在跑的查詢仍然看到 10
3 老闆再查一次,拍新快照 同上 老闆:7
4 再也沒有快照需要版本 1 版本 1 成了死資料列,等 VACUUM 回收 所有人:7

如果店員取消呢

第 2 步改成 ROLLBACK:205 的狀態變成已復原。版本 1 的 xmax 雖然還寫著 205,但 205 不算數,所以版本 1 繼續有效,所有人都看到 10。版本 2 的 xmin 不算數,沒有人看得到。

所以取消交易也會留下垃圾:版本 2 一樣佔著空間,要等 VACUUM。這一點來自 Claude 的知識,這次沒有逐字對過文件,但可以從上面的可見性規則推出來。未實測

想親眼看的話

開兩個 psql 視窗,用測試資料庫跑。xmin、xmax、ctid 是每張表都有的系統欄位,要明確寫出來才查得到。未實測

-- 準備
CREATE TABLE demo_stock (id int PRIMARY KEY, qty int);
INSERT INTO demo_stock VALUES (1, 10);

-- 視窗一:店員
BEGIN;
UPDATE demo_stock SET qty = 7 WHERE id = 1;
SELECT pg_current_xact_id(), ctid, xmin, xmax, qty FROM demo_stock;   -- 看到 7

-- 視窗二:老闆(不會卡住)
SELECT ctid, xmin, xmax, qty FROM demo_stock;   -- 看到 10,xmax 是視窗一的交易編號
SELECT pg_current_snapshot();                   -- 看目前的快照

-- 視窗一
ROLLBACK;

-- 視窗二
SELECT ctid, xmin, xmax, qty FROM demo_stock;   -- 還是 10,xmax 仍然不是 0

6. 快照什麼時候拍:隔離層級

隔離層級最主要的差別,就是快照拍的時機,以及遇到衝突時怎麼處理。PostgreSQL 的預設是 Read Committed。

層級快照何時拍會不會讀到別人沒提交的同一交易查兩次可能不同嗎要更新的列已被別人改掉時
Read UncommittedPostgreSQL 可以指定,但行為跟 Read Committed 相同,不會讀到沒提交的資料
Read Committed(預設)每一句開始時不會可能等對方結束,用最新版本重新判斷條件後繼續
Repeatable Read第一句非交易控制指令開始時,整筆交易沿用不會不會等對方結束;對方提交了就報錯 40001,要重試整筆交易
Serializable跟 Repeatable Read 相同不會不會同上;另外還會偵測出「無法排成先後順序」的交易並讓其中一筆失敗(40001)

報表最容易碰到的差異

老闆的報表先查「今天的單數」,再查「今天的金額合計」,店員剛好在兩句中間提交了一張單:

第一句:單數店員提交一張 500 元的單第二句:金額合計結果
Read Committed10 張發生包含 500 元(第二句拍了新快照)單數和金額對不起來
Repeatable Read10 張發生不包含 500 元(沿用同一張快照)兩個數字一致,少的那張下次才算進來

同一句 SQL 只會用同一張快照,所以能用一句查完的報表,在 Read Committed 下也是一致的。只有跨好幾句、又要求彼此對得起來時,才需要 Repeatable Read。唯讀交易不會發生更新衝突,不用為了這個重試。

Serializable 擋的是哪一種問題

下面這個例子的規則只是示範,Lite 還沒有信用額度的設計。假設規則是「客戶未收款合計不能超過額度」,兩個店員同時替同一位客戶開單:

  1. 兩筆交易各自讀未收款單據,合計都沒超過,都判斷可以開。
  2. 各自 INSERT 一張新單。兩筆交易寫的是不同的列,沒有衝突,也不用排隊。
  3. 都提交成功,合計超過額度。

這在 Repeatable Read 下也攔不住,因為每筆交易都看不到對方新增的那張單。Serializable 能偵測出來,讓其中一筆失敗。另一種做法是在 Read Committed 下先 SELECT ... FOR UPDATE 鎖住這位客戶的那一列,讓兩筆交易排隊。

Claude 的看法:跟 postgres-design-sql.html 第 9.3 節一致,第一版用預設的 Read Committed,不要全站開 Serializable;需要時才在特定地方用鎖,或用 Repeatable Read 跑多句的報表。

7. MVCC 沒有替你擋的事

情況實際會發生什麼
兩個人改同一列 第二個人要等第一個人的交易結束。列上的鎖不擋一般讀取,只擋其他寫入和加鎖的人。只要沒有偵測到死結,就會一直等,所以要設 lock_timeout
先讀再寫 兩個交易讀到同一個舊值,各自算完寫回,其中一個人的改動就不見了,而且不會報錯(第 8 節)
新增相同的唯一鍵 就算對方還沒提交,唯一索引也會發現衝突,後來的人要等。對方提交,後來的人報重複鍵錯誤(23505);對方復原,後來的人成功
死結 兩個交易互相等對方的鎖。PostgreSQL 會偵測並取消其中一筆,但無法預測是哪一筆。寫入順序要一致(postgres-design-sql.html 第 9.2 節)
改資料表結構 只有 ACCESS EXCLUSIVE 鎖會擋一般 SELECT。DROP TABLE、TRUNCATE、VACUUM FULL,以及許多形式的 ALTER TABLE 都要這個鎖。排隊中的鎖也會卡住後面的查詢(同文件第 12 節)
不遵守 MVCC 的指令 TRUNCATE 和會重寫資料表的 ALTER TABLE 提交後,快照比較舊、之前又沒碰過這張表的交易,可能看到一張空表。線上環境清資料要特別注意這點

順帶一提,這也有好處:UPDATE 沒有改到外鍵參照的唯一鍵時,取的是較弱的 FOR NO KEY UPDATE,跟外鍵檢查用的 FOR KEY SHARE 不衝突。所以改商品名稱,不會擋到別人開一張參照這個商品的單。

8. 庫存:進銷存最要緊的一題

MVCC 的「各看各的」用在庫存上會出事:兩個人都看到庫存 10,都覺得沒問題。

寫法(兩人同時各扣 2)結果為什麼
先 SELECT,C# 算完再 UPDATE qty = 8
Read Committed
錯:實際賣 4 個,帳上只少 2 個,沒有任何錯誤 第二個人重新判斷了 WHERE,但寫入的是 C# 算好的 8
同樣的寫法
Repeatable Read
第二個人報錯 40001,重試後正確 不會靜默算錯,但要自己寫重試
一句 UPDATE qty = qty - 2
Read Committed
對:10 → 8 → 6 第二個人等第一個人提交後,以最新版本重新計算
一句 UPDATE ... WHERE qty >= 2
Read Committed
對:庫存不夠時更新 0 列 重新判斷條件時,用的也是最新版本
先 SELECT ... FOR UPDATE
Read Committed
對:讀到的是最新值,別人要排隊 適合要先做複雜判斷、或一次動多個商品時

實測對照(postgres-design-sql.html 第 9.1 節):8 條連線各扣 1,000 次,應該少 8,000。先讀再寫只少 995,一句 UPDATE 少了剛好 8,000;庫存 100、共搶 800 次時,WHERE qty >= $1 最後剛好是 0。已實測

負庫存要不要擋還沒決定。無論答案是什麼,扣的方式都不該是先讀再寫。

Claude 的看法:一句 UPDATE 的做法要有一列「庫存餘額」可以更新。如果之後的資料模型改成每次從異動明細加總算庫存,就沒有那一列可以排隊,會變成第 6 節信用額度那種情況。這點要留到談資料模型時一起看。

9. 代價一:死資料列與 VACUUM

舊版本不會自己消失。一個版本的生命週期是這樣:

有效 目前的版本 已被取代 還有快照需要它 VACUUM 不能動 死資料列 沒有人需要了 等 VACUUM 空位 留給這張表重複使用 通常不還給作業系統

9.1 VACUUM 做什麼

平常由 autovacuum 自動跑。一般的 VACUUM 可以跟正常讀寫同時進行。

9.2 一般 VACUUM 跟 VACUUM FULL

一般 VACUUMVACUUM FULL
空間留在表內重複使用,通常不還給作業系統重寫整張表,可以還給作業系統
正常讀寫可以同時進行全部擋住(ACCESS EXCLUSIVE)
額外磁碟不需要約等於這張表的大小
文件建議優先使用盡量避免

9.3 HOT 更新:少寫一點索引

每個新版本在資料表裡的位置不同,所以一般的 UPDATE 連索引也要多寫。同時符合兩個條件時,PostgreSQL 可以做 HOT 更新(Heap-Only Tuple):

  1. 改的欄位沒有出現在任何索引裡(BRIN 這類摘要型索引除外)。
  2. 舊版本所在的資料頁還有空位放新版本。

HOT 更新不用新增索引項目,中間的舊版本在一般操作中(包括 SELECT)就可能被順手清掉,不必等 VACUUM。所以常被改的欄位(例如庫存量)不要隨便建索引。圖和相關數字見 postgres-design-sql.html 第 2 節。

9.4 膨脹跟帳單的關係

膨脹是指死資料列和空位佔掉太多空間。對 RDS 來說,這不只是查詢變慢:

autovacuum 的門檻和調整方式,見 postgres-design-sql.html 第 11.1 節。

10. 代價二:長交易讓舊版本清不掉

整份文件最實際的一條規則:交易要短。只要還有一個舊快照可能需要舊版本,VACUUM 就不能回收。文件寫得很明白:就算沒有持有什麼重要的鎖,一筆開著的交易也會妨礙清理,長時間閒置會造成資料表膨脹。

實測(postgres-design-sql.html 第 11.2 節):開著一個 Repeatable Read 交易時更新 1 萬列,VACUUM 回報 0 removed, 10000 are dead but not yet removable;交易結束後再跑一次,1 萬列都回收了。已實測

會讓舊版本清不掉的東西

來源常見原因怎麼查
開太久的交易程式忘記提交;交易開著時呼叫外部服務;大報表pg_stat_activity 的 backend_xid、backend_xmin
閒置的交易(idle in transaction)開了交易後不做事也不結束,例如人工連進資料庫開交易後忘記關同上,state 是 idle in transaction
舊的 prepared transaction兩階段提交沒做完pg_prepared_xacts
過期的複寫槽接收端已經不在了,槽還留著pg_replication_slots
副本的回報開了 hot_standby_feedback,副本上的查詢會讓主機延後清理(第 12 節)看副本的設定

怎麼防

先找出開最久、還佔著快照的連線:未實測

SELECT pid, state, now() - xact_start AS 開了多久, backend_xmin, left(query, 80) AS 最後的查詢
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL OR backend_xid IS NOT NULL
ORDER BY xact_start;

下面這些逾時預設都是 0(不限制)。設定方式和起始值見 postgres-design-sql.html 第 9.6 節:

設定救什麼情況
idle_in_transaction_session_timeout開著交易卻不做事
transaction_timeout整筆交易開太久(PostgreSQL 17 起)
statement_timeout單一語句跑太久
lock_timeout排隊等鎖太久。這條跟清理無關,但常常要一起設

11. 代價三:交易編號會用完

MVCC 靠交易編號判斷先後,而編號只有 32 位元,用完會繞回去。比較編號時,每個編號之後約 20 億個算「未來」、之前約 20 億個算「過去」。如果一列存活超過約 20 億筆交易,它會突然變成「未來才出現」,看起來就像資料消失了。

解法是凍結(freeze):VACUUM 把夠舊的版本標成「對所有交易來說都是過去」,它就不再受編號影響。

距離回捲/參數會發生什麼
資料表年齡超過 autovacuum_freeze_max_age(預設 2 億)一定會觸發 autovacuum,就算 autovacuum 被關掉也一樣
剩不到 4,000 萬開始發出警告,要求盡快 VACUUM
剩不到 300 萬拒絕配發新的交易編號:只能跑唯讀交易,寫入全部失敗,要由管理者處理

長交易也會讓這件事更危險,因為凍結一樣要等舊快照結束。文件的處理步驟是先處理舊的 prepared transaction、結束舊交易、清掉過期的複寫槽,再跑一般的 VACUUM,不要跑 VACUUM FULL。

另外,交易要到真正需要編號時才會配發:pg_current_xact_id() 的文件寫,還沒改過資料的交易可能還沒有編號。所以消耗編號的主要是寫入。

對應的 CloudWatch 指標是 MaximumUsedTransactionIDs,門檻留到第 8 題(postgres-design-sql.html 第 11.5 節)。

12. 讀取副本上的 MVCC

rds.html 把讀取副本放在「報表拖慢開單時才加」。到那時會碰到一個 MVCC 帶來的問題:

  1. 老闆在副本上跑一張要 5 分鐘的報表,需要舊版本。
  2. 主機不知道副本上有人在讀,VACUUM 把舊版本清掉,這個清理動作透過 WAL 傳到副本。
  3. 副本要套用這筆清理,就會跟那張報表衝突。
  4. 副本等 max_standby_streaming_delay(PostgreSQL 預設 30 秒)後,取消那張報表,回傳類似 canceling statement due to conflict with recovery 的錯誤。

注意:這 30 秒不是查詢的最長執行時間,而是副本為了套用已收到的 WAL 最多可以延遲多久。前面的查詢已經耗掉這段時間的話,後面衝突的查詢能等的時間會更短。

做法好處代價
程式遇到取消就重試不改設定,文件也寫重跑通常可能成功報表越長越可能一直被取消
把 max_standby_streaming_delay 調大長報表比較不會被取消副本的資料會落後更多
開 hot_standby_feedback(PostgreSQL 預設關)副本告訴主機自己還需要什麼,幾乎消除這類取消主機延後清理,等於第 10 節的問題搬回主機

還有兩點:副本上最高只支援 Repeatable Read,不支援 Serializable;而且 db-scaling.html 第 8 節講「報表交給副本」時沒提到這個取消問題。這三個參數在 RDS 上的預設值和能不能改,這次沒有查(第 16 節)。

13. 跟其他資料庫比

舊版本放哪裡讀會不會等寫怎麼清理
PostgreSQL資料表本身不會VACUUM
SQL Server(READ_COMMITTED_SNAPSHOT 關閉,SQL Server 預設)預設的讀取不用舊版本會,用共用鎖不適用
SQL Server(打開上述選項,Azure SQL Database 預設)版本存放區(可以放在 tempdb)不會系統自動
MySQL/MariaDB InnoDB、Oracle資料頁放最新版本,舊版本放 undo不會背景清理 undo

SQL Server 兩列來自 Microsoft 文件;MySQL、MariaDB、Oracle 這一列來自 Claude 的知識,這次沒有查文件。

PostgreSQL 把舊版本放在資料表本身,所以復原和讀舊版本都不用另外去 undo 找,但膨脹和 VACUUM 的問題也就留在資料表裡。這是取捨,不是誰比較好。

14. C# 程式裡怎麼寫

一般的單據交易用預設層級,直接開交易就好。下面是需要 Repeatable Read 的唯讀報表,dataSource 是 NpgsqlDataSource。未實測

await using var conn = await dataSource.OpenConnectionAsync();
await using var tx = await conn.BeginTransactionAsync(IsolationLevel.RepeatableRead);

await using (var cmd = new NpgsqlCommand("SET TRANSACTION READ ONLY", conn, tx))
    await cmd.ExecuteNonQueryAsync();

await using (var cmd = new NpgsqlCommand("SELECT set_config('app.store_id', $1, true)", conn, tx))
{
    cmd.Parameters.AddWithValue(storeId.ToString());
    await cmd.ExecuteNonQueryAsync();
}

// 這裡開始的每一句都看同一張快照
var count = await QueryOrderCountAsync(conn, tx, date);
var total = await QueryOrderTotalAsync(conn, tx, date);

await tx.CommitAsync();

15. 對耀達 Lite 的意義

Claude 的看法,都還沒拍板:

地方怎麼做因為
開單與報表第一版可以放同一台,不用怕鎖互相擋;真的拖慢時才考慮副本讀寫不互擋,但還是共用 CPU 和磁碟
扣庫存一句 UPDATE 加減;一次多個商品先照順序 FOR UPDATE第 8 節。先讀再寫實測會靜默算錯
多句的報表盡量一句查完;真的要多句又要對得起來,才用 Repeatable Read 唯讀交易第 6 節
交易時間只放資料庫操作,並設逾時第 10 節。長交易同時害到鎖、連線和清理
常被更新的表庫存餘額、單號計數表少建索引,並調低 autovacuum 門檻第 9 節。讓 HOT 更新有機會發生
Jesse 連進資料庫查東西不要開了 BEGIN 就放著;帳號也設 idle_in_transaction_session_timeout一個忘記關的視窗就會妨礙全庫清理
小機器t4g.micro 起步配 20 GiB,要盯 FreeStorageSpace 和死資料列膨脹觸發擴充後,配置量降不回來

會碰到的還沒定的題目:

16. 還沒查證的事

17. 名詞對照

名詞白話
MVCC多版本並行控制:改資料時留舊版本,讓讀的人不用等
列版本(row version)同一列在某個時間點的狀態,每次更新多一個
快照(snapshot)一張清單,記下哪些交易算已結束、哪些還在進行
xmin、xmax(列版本)建立、刪除這個版本的交易編號
ctid這個版本的實體位置,會變,不能當識別碼
死資料列(dead tuple)已經沒有任何交易需要的舊版本
膨脹(bloat)死資料列和空位佔掉太多空間
VACUUM、autovacuum回收舊版本的工作;後者是自動進行的版本
HOT 更新改的欄位沒有索引、同一頁又有空位時,不用動索引的更新
visibility map記錄哪些資料頁對所有交易都看得到的小地圖
隔離層級決定快照什麼時候拍、遇到衝突怎麼處理
髒讀讀到別人還沒提交的資料,PostgreSQL 不會發生
交易編號回捲32 位元的編號繞回去。靠凍結避免
凍結(freeze)把夠舊的版本標成對所有交易都是過去
40001序列化失敗,要重試整筆交易
23505違反唯一性(重複鍵)

18. 來源

PostgreSQL 官方文件,2026-11-03 查閱,版本 18:

Microsoft 文件:

同目錄文件:postgres-design-sql.html(第 2、9、11、12 節與實測數字)、rds.html(儲存費用、讀取副本)、db-scaling.html。