一句 ALTER TABLE,把線上寫入鎖了整整兩分鐘
Leo Wu,主力 Go 的後端工程師,做過金流與高併發系統,也在大表 migration 上鎖住過整個線上服務。
三秒後告警就開始響
那天下午的需求小到不能再小:訂單表要多一個 settled_at 欄位,記錄清算完成的時間。我在本機測過,在 staging 也跑過,ALTER TABLE orders ADD COLUMN settled_at TIMESTAMP 一秒就回來。我很有信心地在低峰前的空檔按下 enter——現在回想起來,「低峰前」這四個字就是我當天最大的錯誤。
大概三秒後,PagerDuty 開始響。API p99 從 80ms 直接飆到 30 秒,接著一整排的 context deadline exceeded。下單、查詢、連個人資料都在逾時。我第一個反應是「怎麼可能,我只是加一個欄位」。但監控面板很誠實:資料庫的 active connection 瞬間打滿,全部卡在等待狀態。那張 orders 表當時大約三千萬列。
我一開始想錯的方向
告警一響,我的直覺是「一定是剛剛部署的服務有問題」,於是我先去看應用層——是不是連線池設定被改壞了?是不是有人同時上了別的東西?我甚至一度懷疑是 GC 停頓。花了寶貴的三、四分鐘在看 Go 服務的 pprof,結果什麼異常都沒有。
真正點醒我的是同事一句話:「你那個 migration 跑完了嗎?」我回去看,那條 ALTER TABLE 還卡在那裡沒回來。這時我才意識到,問題不在應用,而在那句我以為「一秒就好」的 DDL。
真正的根因:鎖,加上排在它後面的所有交易
這套系統當時跑的是 MySQL 5.7。加欄位這件事本身,InnoDB 的 online DDL 理論上不該長時間鎖表。但問題出在 metadata lock(MDL):我的 ALTER TABLE 要拿這張表的 MDL,而當下剛好有一個跑很久、忘了 commit 的分析型交易正握著 orders 的讀鎖。
於是恐怖的連鎖就發生了:
- 我的 DDL 拿不到鎖,進入等待佇列。
- 關鍵在於——一旦有 DDL 在排隊等鎖,排在它後面的所有查詢也全部被擋住,連原本一秒就能跑完的
SELECT也不例外。這就是「鎖佇列」效應,一個卡住的 DDL 會像塞子一樣堵死整條路。 - 這些被擋住的請求各自佔著一條資料庫連線不放,連線池幾秒內就被吃光。
- 連線池一空,連跟這張表完全無關的請求也拿不到連線,整個服務跟著雪崩。
所以真相是:壓垮線上的不是「加欄位」這個動作有多重,而是它在等鎖、以及它等鎖時堵住了後面所有人。我當時做的處置是砍掉那個跑很久的分析交易,DDL 立刻拿到鎖、幾秒完成,連線池洩壓,服務三十秒內恢復。前後鎖了大約兩分鐘,但那兩分鐘感覺像兩小時。
不是每個 DDL 都一樣
覆盤時我才認真去分清楚,DDL 之間的差異其實很大:
- 純 metadata 變更:像某些版本的加欄位,只改字典、不動資料,本身很快。但快不等於安全——它照樣要拿鎖,照樣可能卡在別人後面。
- 需要 rewrite 整張表:改欄位型別、某些加索引或改預設值的操作,資料庫得把整張表重寫一遍,三千萬列就是實打實的幾分鐘 I/O,期間依實作可能鎖住寫入甚至讀取。
- 可 online / instant 的操作:用對演算法可以不鎖或幾乎不鎖地完成。
關鍵是:你不能假設「加個欄位」就一定屬於第一種。它落在哪一類,取決於資料庫、版本、以及你具體改了什麼。
MySQL 與 PostgreSQL 各有各的雷
這兩套我後來都補了功課,注意點其實不太一樣:
MySQL:InnoDB 從 5.6 起支援 online DDL,8.0.12 之後加欄位還能 ALGORITHM=INSTANT 瞬間完成。但要小心:一是很多操作仍會退回 COPY 演算法、重寫整表並鎖住;二是就算 DDL 本身很快,MDL 一樣會卡在長交易後面,也就是我踩的坑。所以大表變更我後來一律改用 pt-online-schema-change 或 gh-ost——它們的做法是建一張影子表、用觸發器或 binlog 同步,慢慢把資料搬過去,最後瞬間切換,全程幾乎不鎖原表。
PostgreSQL:最經典的一條是加索引要用 `CREATE INDEX CONCURRENTLY`,普通的 CREATE INDEX 會鎖住整張表的寫入。另一個要分版本:PG 11 以後,加一個帶「非 volatile 預設值」的欄位是純 metadata、瞬間完成;但 11 之前,加帶預設值的欄位會重寫整張表。還有一點跟 MySQL 神似——PG 的 ALTER TABLE 要拿 ACCESS EXCLUSIVE 鎖,只要它卡在某個長交易後面,後面所有讀寫全被堵死,所以務必先設 lock_timeout,寧可讓 DDL 失敗重試,也不要讓它無限期地堵住全站。
兩邊的共同教訓是:別讓 DDL 無限等待。設好 lock_timeout,拿不到鎖就快速失敗,總比拖著整個服務一起沉底好。
現在我怎麼跑大表的 migration
這次之後,團隊的 migration 規矩硬性改了:
- 大表 DDL 一律先在資料量相當的環境跑一遍,量測真實耗時,不拿空表的秒數騙自己。
- 加索引用 concurrent / online 方式;會 rewrite 的變更改用影子表工具遷移。
- 一定設
lock_timeout,失敗自動退出,不堵住連線池。 - 上線前先掃一遍有沒有跑很久的長交易,那才是真正的地雷引信。
- 把大改動拆成可回滾的小步:先加可為 null 的新欄位、雙寫、回填、再上約束,而不是一條指令幹到底。
- 真要動,挑真正的離峰,並且有人盯著監控,隨時能砍。
小結
那天我學到最實在的一課是:在一張有資料的線上大表上,DDL 從來不是「改一下 schema」這麼單純,它是一次可能鎖住全站的操作。 危險的往往不是變更本身有多重,而是它要排隊等鎖、等鎖時又堵住後面所有人,再順著連線池把災難放大成雪崩。把它當成一次正式的線上操作來對待——量耗時、設 timeout、拆小步、避長交易——那句 ALTER TABLE 才會真的像它看起來那麼人畜無害。
留言討論
有想法、有不同經驗、或想糾正我?歡迎在下面留言,免註冊,填個暱稱就能留。