不停機改 schema:我在正式環境動資料表的完整流程
Louis Wu,台灣資深後端工程師,主力 Go,做過交易所撮合、金流與高併發系統,相信慢就是快。
這幾年我待過交易所撮合、金流、後台這幾種系統,最讓我半夜不敢睡的,從來不是寫新功能,而是「線上改資料表」。新功能寫壞了,最多就是某個按鈕點下去沒反應,使用者罵兩句;但 schema 改錯,可能整張訂單表被鎖住,所有下單、出金、對帳全部卡住,那種壓力是完全不同等級的。
這篇我想把自己這幾年在正式環境改 schema 的流程整理出來。不是教科書那種「理論上應該這樣」,而是我實際踩過坑、被 oncall 叫起來、半夜回滾過之後沉澱下來的做法。會以 PostgreSQL 跟 MySQL 為主,因為這兩個是我手上系統最常用的。
為什麼線上改 schema 這麼危險
很多剛入行的工程師對改 schema 沒什麼戒心,因為在本機跑一句 ALTER TABLE 加個欄位,一瞬間就好了。本機表裡面可能就十筆資料,當然快。問題是正式環境那張訂單表有八千萬筆,而且每秒都有人在下單、在更新狀態。
線上改 schema 真正的風險來自三個地方。
第一個是鎖表。 大部分的 DDL 操作都會對表加鎖,差別只在於鎖的強度跟持續時間。最危險的是那種需要 rewrite 整張表的操作,比如改欄位型別。資料庫得把整張表重寫一遍,這期間如果它拿的是會擋住讀寫的鎖,那麼八千萬筆資料重寫的那幾分鐘甚至幾十分鐘,你的服務就等於掛了。我看過有人在尖峰時段對大表跑了一句看似無害的 ALTER,結果連帶把後面排隊等鎖的查詢全部卡死,連單純的 SELECT 都進不去,因為它們排在那個 DDL 後面。
第二個是長交易。 就算你的 DDL 本身很快,如果這時候剛好有一個跑很久的交易還沒結束,你的 DDL 會被擋在那邊等。更糟的是,在 PostgreSQL 裡,一個正在等鎖的 DDL 會擋住所有後來想存取這張表的查詢,形成一條鎖的隊伍。也就是說,一個本來只是想加索引的操作,因為前面卡了一個跑十分鐘的報表查詢,結果讓整張表在這十分鐘內對所有人都不可用。這種連鎖效應比 DDL 本身慢還可怕。
第三個是複製延遲。 如果你有讀寫分離、有 read replica,主庫上一個重的 DDL 或大批量資料異動,會在複製鏈路上產生延遲。MySQL 傳統的單執行緒複製尤其明顯,主庫改完了,從庫還在慢慢追,這段時間從庫讀到的是舊資料,對帳、報表、任何走從庫的查詢都會看到不一致的狀態。在金流系統裡,這種不一致是會出事的。
理解這三個風險之後,整個改 schema 的方法論其實就是圍繞著「怎麼避開這三件事」展開的。
核心原則:向後相容
如果這篇你只能記住一句話,那就是這句:任何一次 schema 變更,都必須讓「舊版程式」跟「新版程式」能同時正常運作。
為什麼?因為部署本身就不是原子的。你不可能讓所有機器在同一瞬間從舊版切到新版。實務上,不管是滾動更新還是藍綠部署,總會有一段時間是「一部分機器跑舊程式、一部分機器跑新程式」,它們連的是同一個資料庫。如果你的 schema 改法讓舊程式跟新 schema 不相容,那這段過渡期就是事故現場。
我自己的鐵則是:schema 變更跟程式變更永遠分開部署,而且 schema 先行、向後相容。 資料庫的變更要設計成不管前面那版程式還在不在跑,都不會壞。這聽起來很基本,但九成的線上事故都是因為違反了這條。
舉個最常見的反例。有人想把訂單表的 user_name 欄位改名成 customer_name,他寫了一個 migration 直接 RENAME COLUMN,然後在同一個部署裡把程式碼也改成讀 customer_name。看起來很合理對吧?問題是部署過程中,舊程式還在跑、還在寫 user_name,但欄位已經改名了,舊程式瞬間全部噴錯。或者反過來,程式先上、欄位還沒改,新程式找 customer_name 找不到。不管哪個順序,中間都有一段死亡空窗。
所以接下來講的所有技巧,本質上都是在實現「向後相容」這四個字。
加欄位:看似簡單也有坑
加欄位是最常見的變更,也是大家最掉以輕心的。
最安全的加欄位方式是:新增一個可為 null 的欄位,不帶任何預設值。 在現代的 PostgreSQL 跟 MySQL 裡,加一個 nullable 而且沒有 volatile 預設值的欄位,是只改 metadata 的操作,幾乎是瞬間完成,不需要 rewrite 整張表。
真正會出事的是加欄位同時帶 NOT NULL 加預設值。在比較舊的資料庫版本裡,這會觸發整張表的 rewrite,因為它得幫每一筆現有資料都填上那個預設值。八千萬筆的訂單表你這樣搞下去,鎖表鎖到天荒地老。
PostgreSQL 從 11 版開始有做優化,加一個帶常數預設值的 NOT NULL 欄位不再 rewrite 整張表,它會把預設值存在 metadata 裡。但我還是建議養成保守習慣,因為你不會永遠記得每個資料庫版本的優化邊界在哪。
我自己的標準做法是把「加欄位」拆成幾步:
- 第一步,加一個 nullable 的新欄位,不帶預設值,這步幾乎零成本。
- 第二步,如果需要填值,分批回填(後面會講怎麼批)。
- 第三步,等資料都填好、程式也都改成會寫這個欄位之後,再視需要加上 NOT NULL 約束。
而且這裡有個對應的程式部署順序:新欄位加上去之後,要先讓程式開始「寫」這個欄位,但讀的時候還要能容忍它是 null(因為舊資料還沒回填)。等回填完成、確認沒有 null 了,才把讀取邏輯改成完全信任這個欄位。
擴張收縮模式:改欄位的正確姿勢
加欄位簡單,難的是改欄位、改型別、改名、刪欄位這些「破壞性」變更。這裡的核心工具就是 expand-contract,台灣這邊也有人叫它擴張收縮模式。
它的精神是:不要試圖一步到位,而是先擴張(同時保留新舊兩種結構),等程式跟資料都遷移完,再收縮(移除舊結構)。整個過程拆成幾個獨立、各自可回滾的步驟,每一步都維持向後相容。
我用一個真實的例子來講。假設我們有一張訂單表,原本金額欄位叫 amount,型別是 integer,存的是「分」。後來業務要做多幣種,需要支援小數位數不固定的幣別,必須把它改成 numeric 型別、而且語意改成存「元」。這是個又改型別、又改語意的硬需求,直接 ALTER 是不可能的,因為改型別要 rewrite,改語意會讓所有舊程式算錯錢。
用擴張收縮,我會這樣拆:
第一步,擴張——加新欄位。 加一個 nullable 的 amount_decimal,型別 numeric。這步只改 metadata,瞬間完成。這時候程式完全沒動,舊欄位 amount 照常運作。
第二步,雙寫。 部署一版程式,讓它在每次寫訂單金額的時候,同時寫 amount(舊,整數分)跟 amount_decimal(新,numeric 元)。注意這版程式「讀」的時候還是讀舊欄位 amount,新欄位只寫不讀。這樣的好處是,萬一這版有問題要回滾,回到上一版完全沒差,因為上一版根本不知道有 amount_decimal 這個欄位,它讀寫的都是 amount,資料是完整的。
第三步,回填。 寫一個批次任務,把所有舊資料的 amount 換算後填進 amount_decimal。這步要分批做,絕對不能一句 UPDATE 全表跑下去。具體怎麼分批後面專門講。回填的時候新進來的訂單已經被雙寫覆蓋了,所以你只要處理回填開始那個時間點之前的舊資料就好。
第四步,校驗。 回填完跑一個對帳查詢,確認所有列的 amount_decimal 跟 amount 在換算後完全一致,沒有漏的、沒有算錯的。這步我一定做,因為金流系統一旦金額對不上,後果不是修個 bug 就能了事的。
第五步,切讀。 再部署一版程式,把讀取邏輯改成讀 amount_decimal。這時候還是維持雙寫,因為你要保留隨時能回滾到「讀舊欄位」那版的能力。讓這版在線上跑個幾天,觀察沒問題。
第六步,停掉雙寫。 確認新欄位讀寫都穩了,再部署一版程式,停止寫舊欄位 amount,只讀寫 amount_decimal。
第七步,收縮——刪舊欄位。 等上面那版穩定運行一段時間、確定不需要回滾了,才執行 DROP COLUMN amount。
你可能會覺得,七步也太囉嗦了吧,改個欄位搞成這樣。但我跟你保證,在一張承載真金白銀的訂單表上,這七步每一步都是用事故換來的。它的價值在於:任何一步出問題,你都能安全地停在那裡或回退一步,整個系統在過程中的每一刻都是可用而且資料正確的。 這就是不停機的本質。
刪欄位跟改名:絕對不能跟程式部署綁在一起
我把這件事單獨拉出來講,因為它是新手最常踩的雷。
刪欄位跟改名,永遠是擴張收縮的最後一步,而且必須是一個獨立的、在「沒有任何程式還會用到這個欄位」之後才執行的部署。
改名這件事,正確的做法根本不是 RENAME。在擴張收縮的框架下,「改名」等於「加一個新名字的欄位 + 雙寫 + 回填 + 切讀 + 刪舊欄位」,跟改型別是一模一樣的流程。RENAME COLUMN 這個指令在正式環境我基本上當它不存在,因為它無法做到向後相容——改名的那一瞬間,舊程式就找不到欄位了。
刪欄位也是。哪怕你確定新版程式已經完全不碰這個欄位了,你也要先把「不碰這個欄位的程式」部署上去、跑穩、確認沒有任何回滾需求,之後再用一個獨立的 migration 去 DROP。如果你把 DROP COLUMN 跟「不再使用該欄位的程式」放在同一個部署,那一旦程式部署到一半要回滾,舊程式回來了、欄位卻已經沒了,直接炸。
順序我再強調一次:先讓程式不再依賴某個欄位(並且這版程式穩定、不會回滾),然後才刪欄位。 程式的「停止依賴」一定要走在資料庫的「刪除」前面,中間還要留緩衝。
大表加索引:別用會鎖表的方式
加索引是另一個經典陷阱。在小表上 CREATE INDEX 一瞬間就好,但在大表上,預設的建索引方式會鎖住整張表的寫入,建索引那幾分鐘到幾十分鐘,誰都別想寫進去。
PostgreSQL 的解法是 CREATE INDEX CONCURRENTLY。 加上 CONCURRENTLY 之後,建索引的過程不會擋住讀寫,它會用比較久的時間、掃描兩次表來換取不鎖表。代價有幾個要注意:
- CONCURRENTLY 不能在交易區塊裡跑,所以很多 migration 工具預設把每個 migration 包在交易裡的話,你得特別設定讓這條跳出交易。
- 它比較慢,而且如果中途失敗,會留下一個無效的索引,你得手動清掉再重來。
- 我習慣建完之後查一下 pg_index,確認那個索引的 indisvalid 是 true,不要建完就走人。
MySQL 這邊,新版本(5.6 以後)很多 DDL 已經支援 online DDL,加索引預設就不鎖表了。 但「支援 online」不代表「所有操作都 online」,有些變更(特別是改型別、改字元集這類需要 rebuild 的)即使你指定了 ALGORITHM=INPLACE 跟 LOCK=NONE,它也可能悄悄退回成鎖表的做法,甚至直接報錯。所以我在 MySQL 上跑 DDL,會明確寫上 ALGORITHM=INPLACE, LOCK=NONE,這樣如果這個操作其實做不到不鎖表,它會直接報錯讓我知道,而不是默默地鎖了表。
對於 MySQL 那些真的沒辦法 online 的重操作,我會用 gh-ost 或 pt-osc 這類工具。 它們的原理是建一張新結構的影子表,把舊表的資料慢慢拷過去,同時用觸發器(pt-osc)或解析 binlog(gh-ost)把過程中的增量變更同步過去,最後用一個瞬間的 rename 把影子表換成正式表。我自己偏好 gh-ost,因為它不靠觸發器,對主庫的負擔比較可控,而且它可以掛在從庫上去讀變更,對主庫更友善。用這類工具的好處是它們對複製延遲有內建的節流機制,發現從庫追不上就自動放慢,這在有 replica 的環境裡很重要。
回填大量資料:一定要分批
擴張收縮的回填那步,是另一個容易把資料庫弄掛的地方。
新手最常犯的錯,就是寫一句 UPDATE orders SET amount_decimal = amount 然後對全表跑。 在一張八千萬筆的表上這樣幹,等於開了一個巨大的長交易,它會:鎖住大量的列、把交易日誌撐爆、產生嚴重的複製延遲、而且萬一中途失敗整個 rollback,前面跑的全白費。
正確的做法是分批。我的做法大概是這樣:
- 用主鍵範圍當游標,每次處理固定一批,比如一萬到五萬筆,依表的寬度跟系統負載調整。
- 每一批是一個獨立的小交易,跑完馬上 commit,讓鎖盡快釋放。
- 批跟批之間故意 sleep 一小段時間,給主從複製喘息的空間,也給線上正常流量讓路。
- 整個批次任務要能記住跑到哪了(記錄最後處理的主鍵),這樣中途斷了可以從斷點續跑,不用從頭來。
- 跑的時候盯著複製延遲跟資料庫負載的監控,延遲一拉高就主動降速或暫停。
我通常會把回填寫成一個獨立的腳本或一次性的 job,而不是塞在 migration 工具裡。因為 migration 工具大多是設計來跑快速 DDL 的,跑一個要好幾個小時、需要節流的回填,用它很彆扭,而且會卡住你後面的 migration。把回填獨立出來,它的進度、節流、重試都好控制得多。
還有一個細節:回填只需要處理「雙寫開始之前」的存量資料。雙寫開始之後新進來或被更新的資料,已經會自動把新欄位寫好了。所以回填的範圍可以用「建立時間早於雙寫上線時間」或主鍵小於某個值來框定,不用每次都掃全表。
遷移要可回滾
每一個 migration,我都會問自己一句:這步如果上線後發現有問題,我能不能安全退回去?
對於加欄位、加索引這類擴張型操作,回滾相對單純,把加上去的東西刪掉就好(刪索引也要記得用 CONCURRENTLY)。真正要小心的是收縮型操作——一旦你 DROP COLUMN,那欄位的資料就沒了,這是不可逆的。所以收縮步驟我從來不急,一定要等到前面所有步驟都穩定運行夠久、確定不會回滾,才動手刪。
我習慣每個 migration 都成對寫 up 跟 down。但我心裡很清楚,down 不是萬靈丹:
- 加欄位的 down(刪欄位)會丟掉這段時間寫進去的資料,所以「能回滾」不等於「回滾沒代價」。
- 回填的操作通常沒有有意義的 down,因為你很難「反向回填」。這類操作我會設計成冪等的,重跑不會出錯,這比硬寫一個 down 實際得多。
- 真正破壞性的 DROP,我寧可不寫 down,而是靠「上線前先做好備份、出事就從備份恢復」這條後路。假裝一個 DROP 有 down 反而會給人虛假的安全感。
所以對我來說,「可回滾」更多是一種設計思維:把破壞性的動作儘量往後推、拆細、留緩衝,讓你在大多數時候根本用不到那個危險的 down。
跟程式部署的順序:實戰編排
把前面所有東西串起來,一次完整的破壞性 schema 變更,部署順序大概長這樣。我以前面那個 amount 改 amount_decimal 的例子收尾。
- 發布 1(純資料庫): 加 nullable 的 amount_decimal 欄位。此時所有程式版本都不知道它存在,完全無害。
- 發布 2(純程式): 上線雙寫版本,寫 amount 跟 amount_decimal,讀還是讀 amount。這版能安全回滾到發布 2 之前。
- 發布 3(資料任務): 跑分批回填 + 校驗。獨立的 job,不阻塞部署管線,跑完人工確認對帳結果。
- 發布 4(純程式): 切讀,改成讀 amount_decimal,維持雙寫。觀察幾天。
- 發布 5(純程式): 停止寫 amount,只用 amount_decimal。
- 發布 6(純資料庫): DROP COLUMN amount。確定發布 5 不會回滾之後才做。
你會發現一個規律:資料庫的擴張(加東西)走在程式前面,資料庫的收縮(刪東西)走在程式後面。 加新欄位一定先於用它的程式;刪舊欄位一定後於停用它的程式。中間夾著的程式版本,每一版都同時相容新舊 schema。這個「擴張在前、收縮在後、程式夾中間」的節奏,就是整套不停機遷移的骨架。
至於這些步驟之間要隔多久,沒有標準答案,看你的系統多重要、流量多大、回滾的代價多高。金流訂單表我會走得很慢,每一步之間隔幾天甚至一兩週,確認線上指標完全正常才走下一步。內部後台的小表我可能一天就走完。重點是節奏由風險決定,不是由急著上線的心情決定。
一些雜七雜八但救過我的習慣
最後講幾個不成系統、但實際幫我躲過事故的小習慣。
- 跑任何線上 DDL 前,先設一個 lock timeout。 在 PostgreSQL 裡設 lock_timeout,MySQL 裡設 lock_wait_timeout。這樣萬一你的 DDL 卡在等鎖,它會在幾秒後自己放棄,而不是一直卡著、順便把後面排隊的查詢全堵死。寧可 DDL 失敗重來,也不要釀成連鎖鎖死。
- 避開尖峰時段。 這很基本但很多人忽略。再怎麼號稱不鎖表的操作,都會增加資料庫負載,挑流量低谷做總是比較安全。
- 在跟正式環境同等級的資料量上演練過再上。 本機十筆資料跑得飛快不代表什麼。我會在一個資料量接近正式的環境上,把整個 migration 跑一遍,量它要跑多久、會不會鎖表、複製延遲多嚴重。
- 改之前確認沒有長交易卡著。 在 PostgreSQL 裡查一下 pg_stat_activity,看有沒有跑很久還沒結束的交易或閒置中的交易,有的話先處理掉,不然你的 DDL 一上去就會被它擋住,然後堵住整張表。
- DDL 跟回填都要有監控盯著。 至少盯複製延遲、鎖等待、資料庫 CPU 跟 IO。我跑大遷移的時候一定開著監控面板,看到不對勁就能馬上喊停。
寫到這裡,你大概也感覺到了,不停機改 schema 這件事,技術難度其實不高,難的是紀律。每一個「為了省事跳過的步驟」,都是在賭那段過渡期不會出事。我帶過的新人最常問的就是「真的有必要搞這麼多步嗎」,我的答案永遠是:在會動到錢的表上,有。因為一次半夜被叫起來回滾、看著對帳數字對不上冷汗直流的經驗,就足以讓你心甘情願地把這每一步都做好。
慢就是快。在正式環境的資料表上,這句話是用代價換來的真理。