《PostgreSQL 指南:內幕探索》書封

我最近把四散在 Notion、Logseq、Heptabase 的筆記搬到 Obsidian,順便重新整理以前念過的書。這個系列叫 Old School:讀舊書,問新問題。這篇想寫 The Internals of PostgreSQL。

我讀這本書的原因很具體,不是什麼「想打好基礎」。是有一陣子工作常寫 SELECT ... FOR UPDATE,寫著寫著我發現我講不清楚它到底鎖了什麼。鎖整張表?鎖那幾筆 row?鎖到什麼時候?還有一個更根本的問題:UPDATE 到底是怎麼「更新」一筆資料的?

我以為這是兩個問題,讀完書才發現是同一個。因為書給的第一個答案就顛覆直覺:

UPDATE 根本不會更新任何東西。

PostgreSQL 不會把那筆 row 就地改掉。它做的是寫一個新版本,然後把舊版本標記成過期。舊版本不會馬上消失——因為可能還有別的 transaction 正在看它。這就是 MVCC:同一筆資料同時存在好幾個版本,每個 transaction 靠自己的 snapshot 決定看得到哪一個。可以粗略想成 row version + txid + snapshot visibility 的組合。

這張圖是我目前理解的可見性機制:

PostgreSQL transaction MVCC visibility diagram with T1, T2, row versions, snapshots, Repeatable Read, and Read Committed

T1 先建立 snapshot S1,T2 更新同一筆 row 並 commit。T1 再讀一次會看到哪個版本?看 isolation level。Repeatable Read 之下,T1 繼續用原本的 snapshot,所以看到舊版本;Read Committed 之下,第二次 select 拿新的 snapshot,就可能看到 T2 的新值。

以前我背過這兩個 isolation level 的定義,但一直是背的。知道「資料是版本,snapshot 決定可見性」之後,這兩個行為就不用背了——它們只是「什麼時候拿新 snapshot」的差別而已。

那 FOR UPDATE 鎖了什麼?答案跟版本綁在一起:它是 row-level lock,把自己的 transaction ID 咬在那筆 row 的當前版本上。別的 transaction 想改這筆 row,就得等——等到持鎖的 transaction commit 或 rollback 為止。所以鎖的不是表,也不只是「那筆資料」這個抽象概念,是那個版本。

這件事到了高併發下還有麻煩。如果很多 transaction 同時對同一筆熱點 row 持鎖(例如 foreign key 檢查、共享鎖),PostgreSQL 還得用一種叫 MultiXact 的結構去記「現在是哪一群 transaction 一起持有這個鎖」。熱點 row 的鎖競爭,會一路壓到這些你平常根本不知道存在的底層機制上。我後來看線上系統的 lock 問題,第一個問的就是:是不是大家都在搶同一批 row。

版本機制還有另一個代價,用 PostgreSQL 的人遲早會撞到。UPDATE 一直寫新版本,舊版本等到沒有任何 snapshot 需要它,就變成 dead tuple——空間佔著,但沒人要了。dead tuple 沒清掉,資料表就慢慢膨脹,這就是 bloat:活著的資料沒變多,實體卻越來越大,掃描變慢,cache 命中率跟著掉。

負責清理的是 vacuum,PostgreSQL 用 autovacuum 在背景自動跑。但有個很容易中的坑:只要有一個長 transaction 一直開著,它手上的舊 snapshot 就讓那些 dead tuple「可能還有人要看」,vacuum 不敢回收。很多 PostgreSQL 效能問題追到最後,是一個 idle in transaction 沒關掉。

這樣列下來,這套設計的缺點好像不少。但把 MVCC 罵得最兇的公司,剛好也是把 PostgreSQL 用到最極致的公司。

OpenAI 前陣子分享過他們怎麼撐 ChatGPT。文章裡毫不客氣地點名 MVCC 對寫入很重的場景不友善:改一個欄位就要複製整列,一直改就一直生新版本,讀的時候還要跳過一堆舊版本。寫這篇的工程師甚至還跟 CMU 的教授合寫過一篇更有名的批評,標題就叫〈The Part of PostgreSQL We Hate the Most〉。但同一篇文章的另一半是——他們用單一 primary 加將近 50 個 read replica,撐住 8 億使用者、每秒幾百萬次查詢,連 sharding 都還沒動。

最懂它缺點的人,依然選擇把它用到極致。

也有人從另一頭看這件事。有個系列影片的作者寫了快十年 Postgres,他說我們其實還沒摸到 PostgreSQL 的性能上限——光可調的參數就有四百多個,大部分系統連預設值都沒改就在喊資料庫慢。一個嫌得具體,一個誇得也具體,我覺得兩邊講的其實是同一件事:這個系統每一層都是取捨,而取捨是可以被理解的。

書裡到處都是這種取捨,我最喜歡的例子是 victim buffer。Shared buffers 空間有限,新的 page 要進來,就要挑一個舊 buffer 讓位。直覺會想這不就是 LRU 嗎?誰最久沒用就換掉誰。但真正的 LRU 要一直維護精確的使用順序,每次 page 被碰到都要更新它的位置——在高併發資料庫裡,這種精準記帳本身就是成本。所以 PostgreSQL 用接近 clock-sweep 的方式:給每個 buffer 一個 usage count,被掃到降成 0 的才可能變成 victim。不做理論上最漂亮的版本,做高併發下撐得住的版本。

planner 那邊也一樣。它找 estimated cost 最低的路徑執行 query,但前提是統計資訊要夠新——所以 ANALYZE、autovacuum 這些不起眼的背景機制,直接決定 planner 的判斷品質。planner 再聰明,拿到過期的統計也會選錯路。

最後是 WAL。如果讀過 DDIA,大概看過 master-slave、multi-master、leaderless 這些 replication 模型。但看到 PostgreSQL 的 WAL 之後,我開始在意一個更基本的問題:到底怎樣才算寫入成功?

是 leader 寫完就算?

是 follower 收到就算?

是 WAL 寫進 OS page cache 就算?

還是一定要 flush 到 disk 才算?

甚至 follower replay 完、讀得到,才算?

這些語意差很多。讀這本書之前,我沒想過「寫入成功」這四個字需要拆開來問。

回到我一開始的問題。現在如果有人問我 FOR UPDATE 鎖了什麼,我可以從版本講到 snapshot、講到 dead tuple、講到 WAL。倒不是多背了幾個名詞,是我終於知道它們是同一套設計的不同面。

那部影片的結尾,作者問了一個我愣了一下的問題:AI 正在接管各種「操作型」的任務——一個只會操作資料庫的人,和一個了解底層原理的人,哪個保得住飯碗?他的答案是一句話:the secret is in the know-why。

我蠻同意的。下指令、調參數、跑 migration,AI 都會,而且比我熟。但為什麼這個系統長成這個樣子、它在哪裡做了取捨、出事的時候該往哪個方向看——這個 know-why,還是得自己讀出來。

這也是我想做 Old School 系列的原因。書不只是給你答案,是讓你看到一個系統為什麼會長成現在這個樣子。