PostgreSQL 的 MVCC:同一筆資料為什麼可以同時有好幾個版本
寫給耀達 Lite 雲端進銷存(2026-11-03)。補充 postgres-design-sql.html 第 2 節的「脾氣一」。
前提:AWS 東京、RDS PostgreSQL,所有店家共用資料表、用 RLS 隔開。PostgreSQL 版本還沒定,內容以 PostgreSQL 18 文件為準。資料表名稱都是示範,資料模型還沒定。
這份文件的可信度
- 沒有標記的事實,大多來自 PostgreSQL 官方文件,來源在第 18 節。
- 這次寫作沒有開資料庫實測。文中的交易編號(100、205 等)都是為了說明而編的,不是真實輸出。
- 已實測 只用在引用
postgres-design-sql.html的結果:2026-11-02 在本機 Windows 上用 PostgreSQL 18.6 跑過,數字只能看量級。 - 未實測 表示語法或行為有文件支持,或是從文件推論的,但這次沒有跑過。
- Claude 的看法 都還沒拍板。
1. 先講結論
MVCC(Multiversion Concurrency Control,多版本並行控制):改資料時不蓋掉舊的,而是多寫一個新版本。每條 SQL 看的是某個時間點的快照,所以讀的人不用等寫的人,寫的人也不用等讀的人。
- 它保證的:一般的
SELECT和寫入不會因為列鎖互相等待,也讀不到別人還沒提交的資料。 - 它沒有保證的:同時改同一列仍然要排隊;「先讀再判斷、然後寫」的邏輯,MVCC 不會替你防止超賣(第 7、8 節)。
- 不互相擋不等於不互相影響:大報表不會因為鎖卡住開單,但一樣會吃 CPU 和磁碟。這也是報表可能要放到讀取副本的原因。
- 它的代價:舊版本要靠
VACUUM清理;開太久的交易會讓舊版本清不掉,資料表因此膨脹(第 9、10 節)。 - 不用另外開:這是 PostgreSQL 的基本運作方式,沒有要設定的開關。要做決定的是怎麼寫交易和查詢。
2. 沒有 MVCC 會怎樣
假設資料庫裡每筆資料只有一份。店員正在改商品 A 的庫存,還沒提交,老闆這時要看庫存。只有一份資料的話,資料庫只能選下面其中一種做法:
| 做法 | 老闆看到什麼 | 問題 |
|---|---|---|
| 讓老闆等店員結束 | 正確的資料,但要等 | 店員的交易開多久,老闆就等多久。反過來,跑很久的報表也可能擋住店員開單 |
| 讓老闆直接讀 | 店員還沒提交的數字 | 店員如果取消,老闆看到的是從來沒有成立過的數字(髒讀) |
| MVCC:舊版本留著給老闆讀 | 店員開始改之前、已經提交的數字 | 不用等、也不會讀到沒提交的資料,但要付出儲存和清理的成本 |
PostgreSQL 文件的說法是:查詢用的鎖和寫入用的鎖不衝突,所以「讀不會擋寫,寫也不會擋讀」。即使在最嚴格的 Serializable 隔離層級也維持這個保證。
3. 一筆資料怎麼變成好幾個版本
你在 SQL 裡看到的「一列」,在磁碟上可能是好幾個列版本(row version)。每個版本都帶著兩個交易編號,寫在每列的表頭裡:
| 欄位 | 意思 |
|---|---|
xmin | 建立這個版本的交易編號 |
xmax | 刪除這個版本的交易編號;沒被刪除時是 0 |
ctid | 這個版本在資料表裡的實體位置(第幾頁、第幾個)。更新後會變,所以不能拿來當識別碼,要用主鍵 |
三種寫入指令實際做的事:
| 指令 | 實際動作 |
|---|---|
INSERT | 寫一個新版本,xmin 是自己的交易編號 |
DELETE | 資料不會消失,只是在舊版本的 xmax 寫上自己的交易編號 |
UPDATE | 等於上面兩個合起來:舊版本的 xmax 寫上自己,再寫一個新版本。舊版本的 ctid 會指向新版本 |
4. 怎麼決定你看到哪一版:快照
快照(snapshot)不是把資料複製一份,只是一張很小的清單,記下「拍的當下,哪些交易已經結束、哪些還在進行」。文字格式是 xmin:xmax:進行中的編號。
名字撞到了:快照的 xmin、xmax 跟列版本的 xmin、xmax 是兩回事,只是名字相同。快照的 xmin 是「最小的進行中交易」,xmax 是「已結束的最大編號再加 1」。
例如快照 205:207:205,代表拍的當下 205 還在進行、206 已經結束。判斷方式是:
| 某個交易編號 | 符合的條件 | 對這張快照來說 |
|---|---|---|
| 150 | 比快照的 xmin 小 | 已經結束;有提交才算數,復原了就不算 |
| 205 | 在進行中清單裡 | 還沒結束,當作沒發生 |
| 206 | 在範圍內,但不在清單裡 | 已經結束;有提交才算數 |
| 207 以上 | 大於等於快照的 xmax | 拍的時候還沒結束,當作沒發生 |
有了這張清單,檢查每個列版本就只要問兩件事(簡化版):
- 建立它的交易,對我的快照來說算數嗎?(看
xmin)不算數就是還不存在。 - 刪除它的交易,對我的快照來說算數嗎?(看
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 Uncommitted | PostgreSQL 可以指定,但行為跟 Read Committed 相同,不會讀到沒提交的資料 | |||
| Read Committed(預設) | 每一句開始時 | 不會 | 可能 | 等對方結束,用最新版本重新判斷條件後繼續 |
| Repeatable Read | 第一句非交易控制指令開始時,整筆交易沿用 | 不會 | 不會 | 等對方結束;對方提交了就報錯 40001,要重試整筆交易 |
| Serializable | 跟 Repeatable Read 相同 | 不會 | 不會 | 同上;另外還會偵測出「無法排成先後順序」的交易並讓其中一筆失敗(40001) |
報表最容易碰到的差異
老闆的報表先查「今天的單數」,再查「今天的金額合計」,店員剛好在兩句中間提交了一張單:
| 第一句:單數 | 店員提交一張 500 元的單 | 第二句:金額合計 | 結果 | |
|---|---|---|---|---|
| Read Committed | 10 張 | 發生 | 包含 500 元(第二句拍了新快照) | 單數和金額對不起來 |
| Repeatable Read | 10 張 | 發生 | 不包含 500 元(沿用同一張快照) | 兩個數字一致,少的那張下次才算進來 |
同一句 SQL 只會用同一張快照,所以能用一句查完的報表,在 Read Committed 下也是一致的。只有跨好幾句、又要求彼此對得起來時,才需要 Repeatable Read。唯讀交易不會發生更新衝突,不用為了這個重試。
Serializable 擋的是哪一種問題
下面這個例子的規則只是示範,Lite 還沒有信用額度的設計。假設規則是「客戶未收款合計不能超過額度」,兩個店員同時替同一位客戶開單:
- 兩筆交易各自讀未收款單據,合計都沒超過,都判斷可以開。
- 各自
INSERT一張新單。兩筆交易寫的是不同的列,沒有衝突,也不用排隊。 - 都提交成功,合計超過額度。
這在 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 = 8Read Committed |
錯:實際賣 4 個,帳上只少 2 個,沒有任何錯誤 | 第二個人重新判斷了 WHERE,但寫入的是 C# 算好的 8 |
| 同樣的寫法 Repeatable Read |
第二個人報錯 40001,重試後正確 |
不會靜默算錯,但要自己寫重試 |
一句 UPDATE qty = qty - 2Read Committed |
對:10 → 8 → 6 | 第二個人等第一個人提交後,以最新版本重新計算 |
一句 UPDATE ... WHERE qty >= 2Read Committed |
對:庫存不夠時更新 0 列 | 重新判斷條件時,用的也是最新版本 |
先 SELECT ... FOR UPDATERead 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
舊版本不會自己消失。一個版本的生命週期是這樣:
9.1 VACUUM 做什麼
- 回收空間:把沒有人需要的舊版本標成可以重複使用。
- 更新 visibility map:記下哪些資料頁對所有交易都看得到。Index-only scan 靠它才能少讀資料表。
- 凍結舊資料:防止交易編號回捲(第 11 節)。
- 更新統計資料:
ANALYZE給查詢規劃器用的統計。
平常由 autovacuum 自動跑。一般的 VACUUM 可以跟正常讀寫同時進行。
9.2 一般 VACUUM 跟 VACUUM FULL
一般 VACUUM | VACUUM FULL | |
|---|---|---|
| 空間 | 留在表內重複使用,通常不還給作業系統 | 重寫整張表,可以還給作業系統 |
| 正常讀寫 | 可以同時進行 | 全部擋住(ACCESS EXCLUSIVE) |
| 額外磁碟 | 不需要 | 約等於這張表的大小 |
| 文件建議 | 優先使用 | 盡量避免 |
9.3 HOT 更新:少寫一點索引
每個新版本在資料表裡的位置不同,所以一般的 UPDATE 連索引也要多寫。同時符合兩個條件時,PostgreSQL 可以做 HOT 更新(Heap-Only Tuple):
- 改的欄位沒有出現在任何索引裡(BRIN 這類摘要型索引除外)。
- 舊版本所在的資料頁還有空位放新版本。
HOT 更新不用新增索引項目,中間的舊版本在一般操作中(包括 SELECT)就可能被順手清掉,不必等 VACUUM。所以常被改的欄位(例如庫存量)不要隨便建索引。圖和相關數字見 postgres-design-sql.html 第 2 節。
9.4 膨脹跟帳單的關係
膨脹是指死資料列和空位佔掉太多空間。對 RDS 來說,這不只是查詢變慢:
- RDS 儲存按配置量計費,只能加不能減(
rds.html,gp3 單機每 GB 每月 0.138 美元)。 - 膨脹觸發自動擴充後,就算之後把空間清回來,配置量和帳單也不會降回去。自動擴充上限設 100 GiB,最多約每月 13.80 美元。
- 因為所有店家共用資料表,一家大店大量修改造成的膨脹,別家也會一起受影響。
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 帶來的問題:
- 老闆在副本上跑一張要 5 分鐘的報表,需要舊版本。
- 主機不知道副本上有人在讀,
VACUUM把舊版本清掉,這個清理動作透過 WAL 傳到副本。 - 副本要套用這筆清理,就會跟那張報表衝突。
- 副本等
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();
- 快照從哪一句開始:文件寫的是第一句非交易控制指令。依這個說法,上面的快照會在
set_config那一句拍,這是推論。未實測 - Repeatable Read 的寫入交易,以及所有 Serializable 交易(唯讀的也可能失敗,除非用
SERIALIZABLE READ ONLY DEFERRABLE)要準備重試:收到40001時重來整筆交易,包含set_config。可以用PostgresException.IsTransient判斷(postgres-design-sql.html第 9.3 節)。 - 交易不要開在等待外部服務的時候:電子發票、金流、寄信都放到交易外(同文件第 9.4 節)。
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 和死資料列 | 膨脹觸發擴充後,配置量降不回來 |
會碰到的還沒定的題目:
- 負庫存要不要擋:決定
UPDATE要不要加qty >= $1,沒有改變「不要先讀再寫」。 - 資料模型:庫存有沒有餘額列,會決定第 8 節哪些寫法能用。
- 第 8 題告警:死資料列數量、最長交易時間、
MaximumUsedTransactionIDs。 - RDS Proxy(第 7 題):跟 MVCC 本身無關,但交易開得短,連線也比較容易重複使用。
16. 還沒查證的事
- 這份文件所有 SQL 和 C# 這次都沒跑過。第 5 節和第 10 節的語法看文件應該沒問題,但沒有實際輸出。
- RDS 上的副本參數:
hot_standby_feedback、max_standby_streaming_delay的預設值和能否修改,這次沒查。本文寫的是社群版 PostgreSQL 的預設。Aurora 的行為更不確定,postgres-design-sql.html第 17 節也列著。 - RDS 主帳號看
pg_stat_activity的範圍:能不能看到其他帳號的query,這次沒查。 - 取消交易會留下死資料列:來自 Claude 的知識,加上可見性規則的推論,沒有找到文件裡直接這樣寫的句子。
- MySQL、MariaDB、Oracle 的 undo 做法:來自 Claude 的知識,只用於比較。
- Aurora:儲存架構不同。本文的 MVCC 規則是 PostgreSQL 引擎層的行為,但「空間怎麼算錢」不能照第 9.4 節套用(
aurora-serverless.html第 7 節)。
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:
- Concurrency Control: Introduction(定義、讀寫不互擋)
- Transaction Isolation(隔離層級、快照時機、重新判斷、
40001、重試) - Explicit Locking(資料表鎖、列鎖、死結)
- Caveats(
TRUNCATE、重寫資料表的ALTER TABLE、副本不支援 Serializable) - System Columns(
xmin、xmax、ctid) - Database Page Layout(資料頁大小、列表頭)
- System Information Functions(快照格式、
pg_current_xact_id) - Routine Vacuuming(清理、VACUUM FULL、visibility map、回捲與凍結)
- Heap-Only Tuples(HOT 更新)
- Index Uniqueness Checks(唯一鍵衝突時等待)
- Client Connection Defaults(各種逾時、預設隔離層級)
- Hot Standby、Replication 設定(副本查詢衝突、預設值)
Microsoft 文件:
- Transaction locking and row versioning guide(
READ_COMMITTED_SNAPSHOT的預設值與行為)
同目錄文件:postgres-design-sql.html(第 2、9、11、12 節與實測數字)、rds.html(儲存費用、讀取副本)、db-scaling.html。