PostgreSQL 還是 MySQL?兩個都被它咬過之後的老實話
後端工程師,PostgreSQL 與 MySQL 都在 production 維運過。
凌晨三點,磁碟剩 4%
那是一個禮拜五的凌晨,我手機震到從床上彈起來。監控告訴我交易所後台的 PostgreSQL 主庫磁碟使用率衝到 96%,而且還在往上爬。那台機器掛了 2TB 的 SSD,三個禮拜前還只用了一半。
我登進去第一件事是看哪個 table 肥成這樣。結果是一張記錄使用者掛單狀態變更的表,叫它 order_events 好了。實際資料量大概 80GB,但這張表在磁碟上佔了快 600GB。中間那 500 多 GB,全是 dead tuple。
那天我才真正、痛切地理解什麼叫 PostgreSQL 的 MVCC 與 vacuum。在這之前我以為我懂,我會背「PostgreSQL 用多版本並發控制,更新時寫新版本、舊版本由 autovacuum 回收」這種面試答案。但背得出來跟凌晨三點看著磁碟見底是兩回事。
這篇想老實聊聊 PostgreSQL 跟 MySQL,不是規格表比拚,是這幾年它們各自在 production 咬過我哪一口、哪些差異是真的會影響你半夜睡不睡得著的。我會盡量不當任何一邊的粉絲,因為這兩個我都愛過也都恨過。
MVCC:兩種髒空間,兩種痛法
PostgreSQL 跟 MySQL 的 InnoDB 都用 MVCC,但實作哲學差很多,這個差異是我覺得最值得先講的,因為它直接決定了你的維運長相。
PostgreSQL 的做法很直接:更新一筆資料,它不是改原地,而是寫一個新版本的 tuple,舊的那個標記成過期但留在原本的 heap page 裡。誰來清這些過期版本?autovacuum。聽起來很合理,問題出在我那張 order_events。
那張表的特性是「寫入極兇、更新極頻繁、但很少 DELETE」。每一筆掛單從建立到成交可能會 UPDATE 十幾次狀態。每次 UPDATE 都產生一個新 tuple,舊的變 dead。正常情況 autovacuum 會跟上,但我們那段時間做了一檔大行情活動,QPS 翻了五倍,autovacuum 的預設參數根本追不上產生 dead tuple 的速度。它一邊清、新的一邊以三倍速堆積,於是 table bloat 就這樣失控。
更陰險的是,PostgreSQL 的 autovacuum 預設只在 dead tuple 比例超過 20% 才觸發(autovacuum_vacuum_scale_factor 預設 0.2)。一張幾億列的大表,20% 是幾千萬列的垃圾,等它觸發時 bloat 早就很可觀了。後來我把這張熱表單獨設 scale_factor 到 0.02、autovacuum_vacuum_cost_limit 拉高,才讓清理跟得上。
InnoDB 的 MVCC 哲學不一樣。它把舊版本放在 undo log(rollback segment),主表頁面盡量保持「當前版本」。清理舊版本的工作叫 purge,由背景 purge thread 做。聽起來好像比較不會 bloat?某種程度是,InnoDB 的主表不太會像 PostgreSQL 那樣腫成三倍。但 InnoDB 有它自己的版本:當你有一個跑很久的交易(比如某個 BI 工具開了個 transaction 然後忘了 commit),purge 沒辦法清掉那些還可能被這個老交易看到的版本,undo log 就會無止境膨脹,history list length 一路飆。我在另一個金流系統就踩過這個,一個漏 commit 的 read transaction 掛了四十分鐘,undo 撐到把 ibdata 檔吃掉幾十 GB。
所以結論很反直覺:兩邊都會因為「長交易 + 高更新」出事,只是腫的地方不一樣。PostgreSQL 腫在 table heap 跟 index,你用 pg_stat_user_tables 看 dead tuple;MySQL 腫在 undo,你用 SHOW ENGINE INNODB STATUS 看 history list length。哪一個比較好維運?老實說,我覺得 MySQL 的 purge 在「沒有人寫白癡長交易」的前提下比較省心,但 PostgreSQL 的 vacuum 一旦你摸熟了參數,可控性其實更高,因為它把問題攤在陽光下,每張表你都看得到 dead tuple 數字。
還有一個 PostgreSQL 專屬的雷:transaction ID wraparound
這個我得單獨提,因為它嚇過我一次。PostgreSQL 的事務 ID 是 32 bit,會用完、會繞回去。為了避免繞回去之後舊資料被誤判成「未來的交易」而消失,autovacuum 還肩負一個 freeze 的任務。如果你的 autovacuum 長期被你關掉或調到追不上,PostgreSQL 會在接近 wraparound 時強制進入一種防護模式,嚴重時直接拒絕寫入,整個資料庫只能讀。MySQL 沒有這個對應的東西。這是 PostgreSQL 維運上你必須知道、否則會吃大虧的一個獨有風險。
複製:一個是邏輯派,一個是物理派
選型的時候,複製模型常常被忽略,但它其實是最該先想清楚的,因為它牽涉到你的災難復原、讀寫分離、跨版本升級全部。
MySQL 的複製傳統上是邏輯複製:binlog 記錄的是「發生了什麼操作」(row-based 模式下是「哪些列變成什麼樣」)。優點是非常靈活,主從可以是不同的 MySQL 版本、甚至不同的表結構、你可以只複製某幾個 schema、可以拿 binlog 去餵 Kafka 做 CDC。我做交易所的時候,整套即時行情、風控、對帳系統都是吃 MySQL binlog 出來的事件流,這個生態太成熟了,Canal、Debezium、Maxwell 一堆工具現成的。
PostgreSQL 傳統的複製是物理複製:直接把 WAL(write-ahead log)這種「磁碟層級的位元組變更」串流給 standby。優點是 standby 跟主庫位元組級一致,沒有邏輯複製那種「某些 DDL 或函數不確定性會導致主從漂移」的問題,replication 本身極穩。缺點是 standby 必須是完全一樣的版本、一樣的架構,沒辦法只複製一部分,要做跨大版本升級就很痛(PostgreSQL 大版本升級向來是它的痛點,雖然 pg_upgrade 有改善)。
PostgreSQL 從 10 開始也有了邏輯複製,能做選擇性複製跟跨版本,但成熟度跟 MySQL 那套生態比起來,老實說還在追。我自己的經驗是:如果你的架構重度依賴 CDC、事件驅動、要把資料庫變更餵給下游一堆系統,MySQL 的 binlog 生態現階段還是比較順手。如果你要的是穩如老狗的主從、災難復原時 standby 能無痛頂上,PostgreSQL 的物理複製讓我睡得比較安穩。
順帶講個複製延遲的真實案例。MySQL 傳統的單執行緒 replica apply(舊版本)在主庫寫入尖峰時,從庫延遲可以飆到好幾分鐘,我做秒殺系統時讀寫分離就被這個咬過:使用者下單成功,跳轉到「我的訂單」卻查不到,因為查的是延遲了二十秒的從庫。後來靠多執行緒複製跟「剛寫完的讀走主庫」才解決。PostgreSQL 的物理複製延遲通常更小更穩,但它有個特性叫 hot standby feedback,開了之後從庫上的長查詢會反過來拖累主庫的 vacuum,又繞回上一節那個 bloat 問題。沒有白吃的午餐。
JSON、索引、那些真的有差的功能
功能比拚我只挑幾個在 production 真的讓我有感的。
JSON 這塊 PostgreSQL 是真的強。它的 jsonb 是二進位儲存、可以建 GIN 索引,你可以對 JSON 內部的欄位做高效查詢、可以用 jsonb 的各種操作子做包含查詢。我做後台的時候有一張存「動態表單設定」的表,欄位結構天天變,用 jsonb 存再配 GIN 索引,查詢效能好得不像話。MySQL 5.7 之後也有了 JSON 型別,8.0 還能用 generated column 對 JSON 內欄位建索引,可用,但用起來就是沒 PostgreSQL 的 jsonb 那麼渾然天成,那種「JSON 是一等公民」的感覺 MySQL 還差一截。
索引類型也是 PostgreSQL 明顯豐富。partial index(部分索引)我超愛,比如那張 order_events,我只想對「狀態還沒完成的掛單」建索引,因為已完成的歷史單根本不會被熱查詢,partial index 讓我的索引體積只有全表索引的零頭。還有 GIN、GiST、BRIN 這些,做全文檢索、地理空間、時序範圍各有所長。MySQL 這邊索引類型就樸素很多,主力就是 B-tree,全文檢索有但弱,空間索引有但生態跟 PostGIS 比是雲泥之別。如果你的系統有任何「非典型查詢模式」,PostgreSQL 的索引彈性會在某天救你一命。
但反過來講,MySQL 在「最常見的那種 OLTP 查詢」上,效能調校的資料、踩雷經驗、線上文章多到爆炸,遇到問題你 Google 得到答案的機率高很多。InnoDB 的 clustered index 設計(主鍵即資料)對主鍵查詢非常友善。這種「生態厚度」本身就是一種 production 價值,不該被忽略。
預設隔離級別這個坑
這個差異坑過不少人,包含我同事。MySQL 的 InnoDB 預設隔離級別是 REPEATABLE READ,PostgreSQL 預設是 READ COMMITTED。
這不只是個名詞差異。我同事把一段在 MySQL 上跑得好好的程式碼搬到 PostgreSQL,邏輯是「先 SELECT 檢查餘額、再 UPDATE 扣款」,在同一個交易裡。在 MySQL 的 REPEATABLE READ 下,加上 InnoDB 的 gap lock 行為,它某種程度上「剛好」沒出事。到了 PostgreSQL 的 READ COMMITTED,同一交易裡兩次讀可能讀到不同結果,並發扣款就出現了超賣。問題的根因從來不是哪個資料庫比較爛,是那段程式碼本來就有競態,只是 MySQL 的預設行為幫它遮住了。真正的解法是 SELECT FOR UPDATE 或樂觀鎖,跟用哪個資料庫無關。但你搬遷時如果不知道預設隔離級別不一樣,會很莫名其妙地被咬。
那次我選了 PostgreSQL,跟那次我留在 MySQL
講個實際選型。有一次要做一個新的支付對帳服務,全新專案、沒有歷史包袱。我選了 PostgreSQL。理由不是 benchmark,是:這服務要存大量結構不固定的第三方回調資料(每家金流商欄位都不一樣,jsonb 完美),要對帳邏輯做很多複雜的範圍與條件查詢(partial index 跟豐富的索引類型派上用場),而且這服務不需要餵 CDC 給下游。對這個 case,PostgreSQL 的特性剛好打在需求上。
但同一時期,公司主交易系統要不要從 MySQL 遷到 PostgreSQL,我投了反對票。不是因為 PostgreSQL 不好,是因為:整個團隊七八個人全部是 MySQL 老手,所有監控、備份腳本、CDC 管線、線上排錯的肌肉記憶全建在 MySQL 上;交易系統的查詢模式單純就是大量主鍵 OLTP,InnoDB 處理得很好;遷移風險極高、收益極小。把一個跑得好好的核心系統,為了「PostgreSQL 比較潮」去遷,是拿營收去賭一個 benchmark 上的小數點,這筆帳怎麼算都不划算。
這兩個決定看起來矛盾,其實是同一個原則:選資料庫不是選「哪個比較強」,是選「哪個跟你的需求、你的團隊、你的維運現狀最契合」。
寫在最後
帶過幾年線上系統之後,我對「PostgreSQL 還是 MySQL」這個問題的態度,從年輕時的偏執,變成現在的務實。兩個都是工程界打磨了二三十年的成熟產品,對絕大多數應用來說,效能、穩定性都遠遠超過你真正需要的程度,你的瓶頸九成九不會是「選錯資料庫」,而是某個沒加的索引、某段 N+1 查詢、某個忘了 commit 的長交易。我半夜被叫起來的那些次,沒有一次的根因是「當初該選另一個」,全部都是我自己對手上這個工具不夠熟。所以如果你問我新專案該選哪個,我會反問你的團隊現在熟哪個、你公司的維運工具鏈長在哪個上面,因為那個答案比任何 benchmark 圖表都更能決定你接下來幾年睡得好不好。真正值得你投資的,從來不是在兩個資料庫之間反覆橫跳,而是把你選定的那一個,摸到連它凌晨三點會用什麼姿勢咬你都瞭若指掌。