.sql 檔案一級級走進資料庫 三張遷移檔案 001、002、003 像臺階一樣通向一個資料庫圓柱;圓柱上的表格正在插入一列新的強調色的列,表格旁一個小鎖和 2s 字樣表示等鎖時限。左邊寫著 up.sql、add_coupon_code 和 lock_timeout = 2s。 up.sql add_coupon_code lock_timeout = 2s 001 002 003 2s

設想一個 Go 寫的訂單服務發了新版本,剛滾到第三臺機器,日誌裡開始刷同一行錯誤。

ERROR: column "coupon_code" does not exist

新程式碼要讀 orders.coupon_code,加這一列的 SQL 也寫好了,就在需求單的附件裡。測試環境有人手動執行過,生產庫沒人執行。

這類改表結構的 SQL 叫 DDL(CREATE TABLE、ALTER TABLE 這些),表結構本身叫 schema,把 schema 從一個版本改到下一個版本,就是本文說的“遷移”(migration)。補執行一下當然能救火,可是細想會冒出更多疑問:這條 SQL 應該放在哪、由誰、在什麼時候執行?怎麼知道每個環境執行到了哪一條?orders 有兩百萬行、每秒都有人下單的時候,執行它又會發生什麼?

在 Go 專案裡,最常見的做法是自己手寫 .sql 檔案,再交給一個小工具去執行。我們就沿著一個 .sql 檔案,看它從寫下來到在生產庫上生效,一路會碰到哪些問題。文中的輸出都來自一臺機器上的實驗:PostgreSQL 16.15,orders 表 200 萬行。

一、把 .sql 檔案交給工具執行

為什麼是手寫 .sql

Go 專案裡如果用了 GORM,最省事的辦法是啟動時調一次 db.AutoMigrate(&Order{}),讓它對照結構體補齊表和列。它確實能加列、加索引,但只會往上加:按官方文件,它“不會刪除不再使用的列”1,也做不了改名,每次啟動都在執行,庫裡也不留下任何“執行到哪了”的記錄。更要緊的是,誰也說不清某次釋出實際會對生產庫執行哪些語句。

所以大多數團隊選擇自己寫 .sql:執行什麼一目瞭然,能進程式碼評審,DBA 也看得懂。剩下的問題是誰來執行、怎麼記住執行到哪。這類工具本身不多:Go 裡常見的是 golang-migrate、goose、dbmate、sql-migrate、tern 五個;Atlas 和 ent 走的是“由 schema 生成 .sql”的路子;JVM 生態的 Flyway、Liquibase 也有不少 Go 團隊直接用它們的命令列。我們先拿用得最多的 golang-migrate 和 goose 細看,再把其餘幾個放進同一張表。

golang-migrate:一對 up / down 檔案

golang-migrate 要求每個版本寫兩個檔案,{版本号}_{标题}.up.sql 和 .down.sql2。版本號可以是順序號,也可以是時間戳,本文用時間戳。up 是要執行的變更,down 是撤銷它的語句:

SQL
-- 20261015000000_add_coupon_code.up.sql
alter table orders add column coupon_code text;
-- 20261015000000_add_coupon_code.down.sql
alter table orders drop column coupon_code;

在空庫上執行 migrate up,它按版本號從小到大把 .up.sql 一個個交給資料庫3:

$ migrate -path migrations -database "$DATABASE_URL" up
20261001000000/u create_orders (11.022316ms)
20261008000000/u add_created_at (19.27576ms)
20261015000000/u add_coupon_code (27.091677ms)

goose:一個檔案,上下兩段

goose 把 up 和 down 寫在同一個檔案裡,用註釋分段4:

SQL
-- +goose Up
alter table orders add column coupon_code text;
-- +goose Down
alter table orders drop column coupon_code;

執行 goose up 的效果一樣。兩者真正的區別在庫裡那張記錄表:golang-migrate 的 schema_migrations 永遠只有一行,記著當前版本和一個 dirty 標記;goose 的 goose_db_version 每執行一個版本就追加一行。

golang-migrate 和 goose 的檔案與記錄表 左邊 golang-migrate:migrations 目錄裡三對 up/down 檔案,schema_migrations 只有一行,version 20261015000000,dirty f。右邊 goose:00003_add_coupon_code.sql 一個檔案裡分 Up 和 Down 兩段,goose_db_version 有 4 行,version_id 0 到 3。 golang-migrate · 一對 up / down 檔案 goose · 一個檔案兩段 migrations/ 6 個檔案 20261001000000_create_orders.up.sql 20261001000000_create_orders.down.sql 20261008000000_add_created_at.up.sql 20261008000000_add_created_at.down.sql 20261015000000_add_coupon_code.up.sql 20261015000000_add_coupon_code.down.sql schema_migrations 1 行 version dirty 20261015000000 f 整張表只有一行:當前版本和 dirty 標記 00003_add_coupon_code.sql -- +goose Up alter table orders add column coupon_code text; -- +goose Down alter table orders drop column coupon_code; goose_db_version 4 行 id version_id is_applied tstamp 10t03:34:25 21t03:34:25 32t03:34:25 43t03:34:25 每執行一個版本追加一行,歷史都在
圖 1 · 同一個加列變更的兩種寫法:golang-migrate 只記“現在是哪個版本”,goose 記下執行過的每一個版本

釋出流程裡只要在新程式碼啟動前跑一次 up,每個環境都會自己補齊缺的那幾個版本。開頭那次事故,就是少了這一步。

其餘幾個工具的思路相同,差別在檔案怎麼分段、庫裡記多少、會不會校驗已經執行過的檔案。我們把它們都裝上56789、用同一組遷移檔案跑了一遍:

同類工具:檔案怎麼寫、庫裡記什麼
工具形態.sql 怎麼寫庫裡記什麼已執行檔案被改
golang-migrateGo 庫 + 命令列每個版本一對 .up.sql / .down.sql一行:當前版本 + dirty不檢查
gooseGo 庫 + 命令列一個檔案,-- +goose Up / Down 兩段每個版本一行不檢查
dbmateGo 寫的命令列一個檔案,-- migrate:up / -- migrate:down每個版本一行不檢查
sql-migrateGo 庫 + 命令列一個檔案,-- +migrate Up / Down每個檔名一行,帶執行時間不檢查
ternGo 庫 + 命令列,只支援 PostgreSQL順序號檔案 003_x.sql,用 ---- create above / drop below ---- 分隔一行:當前版本號不檢查
AtlasGo 寫的命令列普通 .sql 加一個 atlas.sum;也能直接讀上面幾種格式每個版本一行atlas.sum 雜湊
entGo ORM由 Go 程式碼定義的 schema 經 Atlas 生成 .sql,可輸出成上面幾種格式取決於輸出格式同所選格式
FlywayJava 命令列V3__desc.sql,只寫 up每個版本一行 + checksumchecksum 校驗
LiquibaseJava 命令列changelog(SQL / XML / YAML),每段 --changeset 作者:id每個 changeset 一行 + MD5MD5 校驗
GORM AutoMigrateGo ORM不寫 .sql,按結構體補齊不記錄—

版本:golang-migrate v4.20.1、goose v3.29.0、dbmate v2.37.0、sql-migrate v1.8.1、tern v2.4.3、Atlas v1.3.5、Flyway 13.10.3、Liquibase 5.0.1,均在 PostgreSQL 16 上實測;ent 一行來自它的文件。

圖 2 · 十個同類工具:Go 的五個都只認檔名和版本號,不校驗內容;Atlas、Flyway、Liquibase 會記下檔案的雜湊

Go 的幾個工具都把 down 寫在同一個檔案或成對的檔案裡,Flyway 社群版只寫 up。ent 本身不執行遷移,它生成的檔案交給 Atlas 或上面任何一種工具去跑10。還要留意許可:Liquibase 從 5.0 起社群版改用 FSL,每個版本釋出兩年後才轉為 Apache 2.011。

只記一行還是記每一行,看起來只是實現細節,後面會看到它決定了兩件事:執行失敗時庫裡留下什麼,以及有檔案被漏掉時能不能發現。先看失敗:一個 .sql 檔案執行到一半出錯了,會怎樣?

二、執行失敗以後

現象:golang-migrate 留下一個 dirty 標記

我們寫一個有錯的遷移:第一句正確,第二句把 column 拼成了 colum。

SQL
-- 20261020000000_add_refund.up.sql
alter table orders add column refund_status smallint;
alter table orders add colum refunded_at timestamptz;

migrate up 報語法錯誤,schema_migrations 變成了這樣:

version | dirty
----------------+-------
20261020000000 | t

版本號已經寫成了新版本,dirty 是 t。把檔案改對後再 up,它拒絕執行:

error: Dirty database version 20261020000000. Fix and force version.

為什麼要這樣?golang-migrate 在執行每個檔案之前,先把版本號寫進去並標上 dirty,執行成功再清掉12。它只記一行,沒法知道這個檔案執行到了哪一句,所以出了錯就停下來,要人去確認庫裡的實際結構,再用 force 告訴它“現在真正處於哪個版本”:

$ migrate ... force 20261015000000 # 退回上一个版本号,不执行任何 SQL
$ migrate ... up
20261020000000/u add_refund (8.795884ms)

半截的結構:取決於資料庫

那麼第一句 add column refund_status 到底執行了沒有?在 PostgreSQL 上沒有,我們查了 information_schema.columns,refund_status 不存在。原因在協議層:golang-migrate 把整個檔案當作一條查詢發出去,PostgreSQL 對“一次簡單查詢裡的多條語句”預設當作一個事務,一條失敗就整體回滾13。所以這裡 force 回上一個版本是對的。

換成 MySQL 就不是這樣了。MySQL 的 DDL 會隱式提交14,第一句加列已經生效,第二句失敗。這時如果直接 force 回上一個版本再 up,第一句會因為列已存在而再次失敗。正確的做法是先手工刪掉 refund_status,或者 force 到新版本後另寫一個遷移補上缺的列。這一段我們沒有在 MySQL 上實測,是根據隱式提交的規則推出來的。

goose:預設包在事務裡

goose 的做法是把每個遷移包在一個事務裡執行,成功才寫版本記錄。同一個錯誤檔案,goose 報錯後版本仍停在上一個,庫裡也沒有半截的列,改好檔案直接 goose up 就行,不需要 force。

但並不是所有 DDL 都能放進事務。PostgreSQL 的 CREATE INDEX CONCURRENTLY 就不行:

ERROR 00005_idx_user.sql: failed to run SQL migration: ...
ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block (SQLSTATE 25001)

這時要在檔案第一行寫上 -- +goose NO TRANSACTION,代價是這個檔案又回到了“失敗可能留下半截”的狀態,所以裡面的語句最好能重複執行(if not exists)。

遷移檔案執行到一半失敗時
情形庫裡的結構版本記錄怎麼恢復
golang-migrate · PostgreSQL整個檔案回滾寫成新版本,dirty = t改檔案,force 回上一個版本,再 up
golang-migrate · MySQL前面的 DDL 已提交,留下半截寫成新版本,dirty = t先手工處理半截結構,再 force
goose · 預設整個遷移回滾停在上一個版本改檔案,直接 up
goose · NO TRANSACTION前面的語句已生效停在上一個版本語句寫成可重複執行

MySQL 一行是按隱式提交規則推出的,沒有實測;goose 在 MySQL 上同樣受隱式提交影響,事務包不住 DDL。

圖 3 · 失敗後要不要人來收拾,取決於工具有沒有把檔案包進事務,以及資料庫能不能回滾 DDL

由此有了寫 .sql 的第一條約定:一個檔案只做一件事。語句越少,失敗時越容易判斷停在了哪。另一條約定是執行過的檔案不再修改,要改就寫一個新版本。不過從圖 2 可以看到,Go 的這幾個工具都不檢查檔案有沒有被改過,也未必能發現另一種更隱蔽的情況:兩個人同時寫遷移,會不會有一個被漏掉?

三、並行分支與被漏掉的檔案

現象:一個更早的檔案被靜默跳過

A 在 10 月 12 日拉了分支 feature/channel,寫了 20261012000000_add_channel.up.sql,評審拖了一週。這期間 B 的 20261015000000_add_coupon_code 先合進主幹並上了線。等 A 的分支合進來,目錄裡多了一個時間戳比當前版本更早的檔案。

golang-migrate 的反應是:

$ migrate ... up
no change

channel 列沒有建出來,沒有報錯,也沒有警告。原因就在圖 1 裡:schema_migrations 只記了一個數 20261015000000,up 的含義是“執行所有比它大的版本”,20261012000000 比它小,在它眼裡就是已經執行過的。直到 A 的程式碼上線去讀 channel,才會再報一次開頭那樣的錯。

goose:記了每一行,就能發現缺口

同樣的場景,goose 會拒絕執行:

$ goose up
goose run: error: found 1 missing migrations before current version 20261015000000:
version 20261012000000: migrations/20261012000000_add_channel.sql

因為 goose_db_version 裡每個執行過的版本都有一行,它能對出“檔案有、記錄裡沒有、又比當前版本小”的那一個。確認這個檔案和後面的變更互不相干,就加 -allow-missing 補執行;相干,就該重寫成一個新版本。

其餘工具:第三種反應

把同一個場景交給其他工具,反應分成三類。和 goose 一樣拒絕的有 Atlas(migration file 20261012000000_add_channel.sql was added out of order)和 Flyway(Detected resolved migration not applied to database)。tern 只認連續的順序號,兩個分支各寫一個 004 時直接報 Duplicate migration 4,問題在合併時就暴露了。

第三類是直接補執行、不給任何提示:dbmate、sql-migrate、Liquibase。它們記了每個版本,所以知道 20261012000000 沒執行過,status 裡能看到它是待執行,但 up 會照常把它執行掉,channel 列被建了出來。這比靜默跳過好,可生產庫的執行順序(10-01、10-08、10-15、10-12)就和新建的庫(按檔名排序)不一樣了。兩個變更互不相干時無所謂,相干時就可能出問題。

更早的檔案後合並進來 遷移目錄按時間戳排列:20261001、20261008、20261015 已執行,20261012 是後合並進來的。golang-migrate 的 schema_migrations 只記 20261015000000,migrate up 輸出 no change,20261012 被跳過;goose 報 found 1 missing migrations,加 -allow-missing 後執行了 20261012000000_add_channel.sql。 合併後的 migrations/,按檔名裡的時間戳排序 已執行 已執行 後合並進來 已執行 · 當前版本 20261001 20261008 20261012 20261015 golang-migrate v4.20.1 goose v3.29.0 schema_migrations version 20261015000000 · dirty f $ migrate up no change orders.channel 不存在 只執行比當前版本大的檔案,20261012 被跳過 $ goose up error: found 1 missing migrations before current version 20261015000000: version 20261012000000 $ goose up -allow-missing OK 20261012000000_add_channel.sql 每個版本一行,能發現中間缺了一個
圖 4 · 同一個後合並進來的舊檔案:golang-migrate 只比較一個版本號,把它當成已執行;goose 對得出缺口,拒絕繼續

解決:讓問題在合併程式碼時暴露

兩個工具各有對策。goose 推薦“混合編號”:本地開發時用時間戳,避免兩個人撞號;分支要合進主幹時,在 CI 裡跑 goose fix,把時間戳檔名改成接在最後的順序號4。我們試了一下,它會把 20261012000000_add_channel.sql 改成 00003_add_channel.sql 這樣的名字。順序號是連續的,誰晚合併誰排在後面,就不會有“比當前版本小”的新檔案。

用 golang-migrate 的團隊,最省事的辦法是在 CI 里加一道檢查:新增的遷移檔案,版本號必須大於主幹上最大的那個,否則讓作者重新生成檔名。

還剩“執行過的檔案被修改”這個問題,Go 的五個工具都不檢查。這裡可以借 Atlas 的一個免費功能:它能直接讀 golang-migrate、goose、dbmate 等格式的目錄,atlas migrate hash 生成一個 atlas.sum,記下每個檔案的雜湊15。之後誰改了已有的檔案,atlas migrate validate 就會失敗:

$ atlas migrate validate --dir "file://migrations?format=golang-migrate" --dev-url ...
You have a checksum error in your migration directory.
L4: 20261015000000_add_coupon_code.up.sql was edited
Error: checksum mismatch

atlas.sum 第一行是整個目錄的雜湊,兩個分支各加一個檔案,Git 合併時這一行必然衝突,也順帶擋住了上面那個並行分支的問題。

八個能執行遷移的工具,在同一組場景下的表現放在一起看:

同一組場景的實測 · PostgreSQL 16
工具檔案第二句寫錯更早的檔案後合並進來兩個例項同時執行
golang-migrate整體回滾,但留下 dirty,要 force靜默跳過,no changepg_advisory_lock 排隊
goose事務回滾,版本不動報 missing,-allow-missing 放行命令列不加鎖,第二個重複執行報錯;程式碼裡可加會話鎖
dbmate事務回滾,版本不動直接補執行,不提示預設不加鎖、重複執行報錯;--migration-lock 後排隊
sql-migrate事務回滾,版本不動直接補執行,不提示不加鎖,第二個重複執行報錯
tern事務回滾,版本不動編號必須連續,重號報 Duplicate migrationpg_advisory_lock 排隊
Atlas事務回滾,版本不動報 added out of order加鎖排隊
Flyway回滾,Changes successfully rolled back預設拒絕,outOfOrder 放行加鎖排隊
Liquibase回滾,不記錄該 changeset直接補執行,不提示鎖表,Waiting for changelog lock

ent 和 GORM 不在表裡:ent 生成的檔案交給上面某個工具執行,GORM AutoMigrate 沒有版本的概念。Atlas 還能由期望的 schema 直接算出變更,第七節單獨講。“回滾”都是 PostgreSQL 上的結果,MySQL 的 DDL 會隱式提交,回滾不了。

圖 5 · 八個工具在同一組場景下的實測:失敗時幾乎都能幹淨回滾,差別集中在“舊檔案後合併”和“多例項併發”兩列

到這裡,一個 .sql 檔案該怎麼寫、出錯怎麼收拾、並行開發怎麼防漏,都有了答案。可是它最終要在生產庫上執行,這件事由誰、在釋出流程的哪一步來做?

四、一次變更的完整流程

從提交到生產要經過哪幾步

把前面的約定串起來,一個 .sql 檔案從寫下來到在生產庫生效,要走六步。我們跟著 20261015000000_add_coupon_code.up.sql 走一遍。

一個 .sql 檔案從提交到生產 六步:寫 up 和 down 兩個檔案;CI 裡用 squawk、atlas migrate validate 檢查,並在一次性 PostgreSQL 上跑 up、down、up;用 go:embed 和程式碼打進同一個映象;部署時先執行遷移,加 advisory lock,版本變成 20261015000000;再滾動釋出程式碼;下一個版本才做刪除舊列這類收尾。 提交到合併 · 開發者和 CI 部署 · 生產環境 ① 寫 up / down 兩個檔案 20261015000000_ add_coupon_code.up.sql set lock_timeout = '2s'; add column coupon_code ② CI 檢查 squawk *.up.sql atlas migrate validate up → down → up 在一次性的 PG 容器裡跑 ③ 和程式碼打進同一個映象 //go:embed migrations/*.sql shop:1.8.0 程式碼和 .sql 版本綁在一起 ④ 先執行遷移 shop migrate up pg_advisory_lock 版本變成 20261015000000 ⑤ 再滾動釋出程式碼 1.7.0 → 1.8.0 逐個替換例項 舊程式碼看不見新列,不受影響 新程式碼上線就能讀到 ⑥ 下一個版本再收尾 contract 的 .sql drop column … 確認沒有舊程式碼 還在讀寫舊列以後 虛線框:不在這次釋出裡,等舊程式碼全部下線後的下一個版本才做
圖 6 · 一次變更的完整路徑:.sql 在合併前被檢查和演練,和程式碼一起打包,部署時先於程式碼執行,破壞性的部分留到下一個版本

① 寫檔案。 up 和 down 各一個,一個檔案只做一件事。會拿表鎖的語句前面先寫 set lock_timeout(第五節會講為什麼)。檔案也可以由一份終態 schema 生成,再由人改過(第七節)。

② CI 檢查。 三道檢查各管一件事。第一道是靜態檢查,PostgreSQL 可以用 Squawk16,它會逐條指出危險的寫法。比如給它一個同時改列型別、建普通索引、加列的檔案,它報了 7 條,其中最關鍵的幾條是:

warning[require-lock-timeout]: Missing `set lock_timeout` before potentially slow ACCESS EXCLUSIVE lock operations
warning[changing-column-type]: Changing a column type requires an `ACCESS EXCLUSIVE` lock on the table which blocks reads and writes while the table is rewritten. ...
warning[require-concurrent-index-creation]: During normal index creation, table updates are blocked, but reads are still allowed.
Found 7 issues in 1 file (checked 1 source file)

第二道是第三節說的 atlas migrate validate,加上“版本號必須遞增”的檢查。第三道是真的跑一遍:在 CI 裡起一個一次性的 PostgreSQL 容器,執行 up、down、再 up。這樣 down 檔案至少被執行過一次,也能發現 up 檔案不能在乾淨的庫上重放的問題。

③ 打包。 把 .sql 用 go:embed 編進服務的二進位制,和程式碼打進同一個映象。這樣映象版本就決定了它帶著哪些遷移,部署時不會出現“程式碼是新的、遷移目錄是舊的”這種錯配。

④ 先執行遷移。 部署時先對生產庫執行 up,成功了再發程式碼。執行它的可以是同一個二進位制的一個子命令(比如 shop migrate up),也可以就是 golang-migrate 或 goose 的命令列。

⑤ 再發布程式碼。 遷移已經生效,滾動釋出期間新舊程式碼同時線上。加一列可空的列,舊程式碼看不見,不受影響;新程式碼一上線就能讀到。

⑥ 破壞性的部分留到下一個版本。 刪列、刪表、改名這類舊程式碼會受影響的變更,不能和使用新結構的程式碼在同一次釋出裡做,要等舊程式碼全部下線後再發。第六節會細講。

第 ④ 步由誰執行

把遷移放在哪兒執行,常見的有三種做法。

第一種是在部署流水線裡單獨做一步:在 Kubernetes 上通常是一個 Job(用 Helm 時可以掛在 pre-upgrade hook 上),跑完再滾動更新 Deployment。遷移只執行一次,失敗了釋出就停在這裡。

第二種是應用啟動時自己執行,不需要額外編排。程式碼大致是這樣,用 goose 的 Provider,把編進二進位制的檔案交給它,再配一個 PostgreSQL 會話鎖:

Go
//go:embed migrations/*.sql
var embedMigrations embed.FS
func migrate(db *sql.DB) error {
locker, err := lock.NewPostgresSessionLocker()
if err != nil {
return err
}
migrations, _ := fs.Sub(embedMigrations, "migrations")
provider, err := goose.NewProvider(goose.DialectPostgres, db, migrations,
goose.WithSessionLocker(locker))
if err != nil {
return err
}
_, err = provider.Up(context.Background())
return err
}

我們讓一個遷移先 pg_sleep(3),同時啟動兩個例項:第一個執行了 2 個遷移,第二個在鎖上等了約 2.7 秒,拿到鎖時遷移已經做完,applied 0。golang-migrate 的命令列自帶同樣的鎖,第二個例項在 pg_stat_activity 裡顯示為等待 pg_advisory_lock,最後輸出 no change。

鎖不能省。我們用 goose 命令列(不帶鎖)同時跑兩個例項,兩個都執行了同一個遷移,第二個報錯(dbmate 預設、sql-migrate 也是同樣的結果):

ERROR 20261021000000_slow.sql: failed to run SQL migration: ...
ERROR: column "note" of relation "orders" already exists (SQLSTATE 42701)

各工具在這一點上差別很大(見圖 5 最後一列):golang-migrate、tern、Atlas、Flyway 自帶資料庫鎖,第二個例項會排隊;Liquibase 用一張 DATABASECHANGELOGLOCK 表當鎖,第二個例項列印 Waiting for changelog lock....;dbmate 要加 --migration-lock 才排隊;goose 的命令列和 sql-migrate 不加鎖。

第三種是 Kubernetes 的 initContainer,每個 Pod 啟動前先跑遷移。它和第二種是一回事,同樣要靠鎖,副本多時所有 Pod 都在搶同一把鎖。

第 ④ 步放在哪
方式好處要注意
流水線裡單獨一步 / K8s Job只執行一次;失敗就不釋出;遷移日誌單獨可查要編排“遷移成功後才更新 Deployment”
應用啟動時執行不用額外編排必須加鎖;遷移慢時例項遲遲不就緒,可能被健康檢查殺掉
initContainer和應用同一個映象同上,副本越多,搶鎖的越多

三種方式都要求遷移本身向後相容:遷移執行完到舊例項全部替換之前,舊程式碼一直在用新的 schema。

圖 7 · 遷移放在哪執行,決定了它跑幾次、失敗時會不會攔住釋出

流程搭好以後,開頭那種“忘了執行”的事故不會再發生。但還有一類事故,流程本身擋不住:在一張正在被頻繁讀寫的大表上,第 ④ 步執行一條看起來很快的 ALTER TABLE,會發生什麼?

五、大表上的鎖

現象:一條只改後設資料的 ALTER,把主鍵查詢堵了 6 秒

我們在 200 萬行的 orders 上加一列可空的 coupon_code。在 PostgreSQL 11 以後,這類加列只改表的後設資料,不碰任何一行資料,單獨執行只要幾毫秒。

實驗裡同時有三個會話:

  • A:一個報表查詢,在事務裡 select count(*) from orders,之後不提交,停 8 秒(模擬一個慢事務)。
  • B:第 1 秒開始執行遷移 alter table orders add column coupon_code text。
  • C:第 2 秒開始一個普通的主鍵查詢 select amount from orders where id=42,平時 0.8 毫秒。

結果 B 用了 7.08 秒,C 用了 6.08 秒。第 3 秒時看一眼 pg_stat_activity,誰在等誰一目瞭然:

pid | blocked_by | wait_event_type | query
-----+------------+-----------------+--------------------------------------------------
558 | {} | Timeout | begin; select count(*) from orders; select pg_sl
565 | {558} | Lock | alter table orders add column coupon_code text;
570 | {565} | Lock | select amount from orders where id=42;

C 和 A 根本不衝突,都是讀。它是被 B 堵住的。

原因:鎖請求要排隊

PostgreSQL 的 ALTER TABLE 預設要拿表上最強的鎖 ACCESS EXCLUSIVE17,它和所有鎖都衝突,包括普通 SELECT 拿的 ACCESS SHARE。A 的事務還沒結束,B 只能排隊。關鍵在於鎖請求按先來後到排隊:C 想要的鎖雖然和 A 不衝突,但和排在前面的 B 衝突,所以也排在後面。之後進來的每一個查詢都一樣,表實際上已經“停了”,而那條 ALTER 本身一行資料都還沒碰。

GoCardless 寫過一次一模一樣的事故18:遷移只改了幾張空表,事後在備份上重放只要幾百毫秒,生產上卻讓 API 超時了約 15 秒,時間全花在排隊等鎖上。MySQL 也有同樣的問題,那裡叫後設資料鎖(MDL),後面的查詢會全部顯示 Waiting for table metadata lock19。

鎖佇列時間線 上半部分不設 lock_timeout:A 持有讀鎖 8 秒,B 的 ALTER 從第 1 秒排隊到第 8 秒,C 的主鍵查詢從第 2 秒排在 B 後面,等了 6.08 秒。下半部分設 lock_timeout 為 2 秒:B 在第 3 秒放棄並報 lock timeout,C 隨即返回,只等了 1.02 秒。 不設 lock_timeout 虛線框:在排隊等鎖,還沒開始執行 SET lock_timeout = '2s' A 報表事務 B 遷移 ALTER C 主鍵查詢 count(*) 之後不提交,ACCESS SHARE 一直持有 排隊等 ACCESS EXCLUSIVE 7.08 s 和 A 不衝突,卻排在 B 後面 6.08 s 0 1 2 3 4 5 6 7 8 s A 報表事務 B 遷移 ALTER C 主鍵查詢 同上,ACCESS SHARE 持有 8 s ERROR: canceling statement due to lock timeout 1.02 s 返回 0 1 2 3 4 5 6 7 8 s
圖 8 · 主鍵查詢等的不是那個慢事務,而是排在它前面的 ALTER;給 ALTER 設上等鎖時限,最壞情況就從“等慢事務結束”變成“等 2 秒”

解決:在 .sql 檔案裡給等鎖設時限

既然危險來自“排隊排太久”,那就別讓遷移無限期地排。在遷移檔案的開頭寫上:

SQL
set lock_timeout = '2s';
alter table orders add column coupon_code text;

第二組實驗裡,B 等滿 2 秒就放棄,報 canceling statement due to lock timeout,C 只多等了 1.02 秒。遷移失敗了,但業務只卡了一下。按第二節的分析,在 PostgreSQL 上這個失敗是乾淨的(goose 不會推進版本,golang-migrate 留下 dirty 等人 force),之後挑個時機重試,或者先查出那個長事務是誰。這也是第 ② 步裡 Squawk 報的第一條警告。

進一步:哪些操作要重寫整張表

lock_timeout 解決的是“等鎖”。拿到鎖以後還要看“持鎖多久”:只改後設資料的操作幾毫秒就放鎖,要重寫整張表的操作,會在持鎖期間把每一行都改寫一遍。

常見 DDL 的代價 · 本機 PostgreSQL 16,orders 200 萬行
操作PostgreSQLMySQL 8.0(InnoDB)實測
加列(可空,或帶常量預設值)只改後設資料(11 起)INSTANT(8.0.12 起)4.3 ms
刪列只標記為不可見INSTANT(8.0.29 起)0.7 ms
改列型別 int → bigint重寫整表和索引,全程 ACCESS EXCLUSIVE只能 COPY,期間不允許寫3207 ms
加索引CREATE INDEX CONCURRENTLY 不阻塞讀寫INPLACE,允許併發讀寫803 ms
加 NOT NULL全表掃描;大表先加 CHECK … NOT VALID 再 VALIDATEINPLACE,要重建表165 ms
共同點:每一種都要先拿到表上的排他鎖,等鎖那一段就是圖 8 的風險

MySQL 一列來自官方 Online DDL 文件,沒有在本機實測;INSTANT 增刪列每張表最多累計 64 次,之後要重建表。

圖 9 · 加列、刪列、加索引都有不重寫表的做法,改列型別是少數繞不過去的“整表重寫”

200 萬行改一次型別要 3.2 秒,這 3.2 秒裡所有讀寫都排在後面。在 MySQL 上,這類必須重寫整表的變更通常不放進遷移檔案,而是交給 gh-ost 這樣的線上變更工具單獨執行:它建一張改好結構的影子表,分塊複製舊行,讀 binlog 追上複製期間的新寫入,最後原子地交換表名20。在 PostgreSQL 上,更常見的做法是把它拆成幾個不重寫表的小步,也就是下一節要講的模式。而拆成幾步以後,就要面對一個新問題:程式碼和 schema 不可能在同一瞬間切換。

六、跨版本的變更:expand / contract

現象:新舊程式碼同時線上

第四節的第 ⑤ 步裡,滾動釋出期間舊版本和新版本同時線上,讀寫同一個庫。加列沒問題,因為舊程式碼看不見新列。改列名就不一樣了。假設要把 amount 改名為 total_amount,遷移一執行,還沒下線的舊程式碼立刻出錯:

ERROR: column "amount" does not exist

先發程式碼也不行,新程式碼會找不到 total_amount。一條 RENAME 要求程式碼和 schema 在同一瞬間切換,而滾動釋出恰恰做不到“同一瞬間”。

解決:把一次切換拆成幾個版本

辦法是讓新舊兩種 schema 有一段時間同時成立。這個模式叫 parallel change,也叫 expand / contract21:先擴充套件(expand),讓新舊兩種用法都能工作;再遷移使用方;最後收縮(contract),去掉舊的那一半。落到 .sql 檔案和釋出上,是三個版本:

  • 版本 N 帶一個 expand 的遷移檔案:加列 total_amount,再加一個觸發器,插入或更新時哪一列是空的就用另一列補上。程式碼不變。
  • 版本 N 上線後,用一個單獨的回填指令碼把舊資料補進新列。回填不放進遷移檔案:它要跑很久,放進遷移會讓第 ④ 步長時間佔著鎖、拖住釋出。
  • 版本 N+1 只改程式碼:讀寫 total_amount。
  • 版本 N+2 帶一個 contract 的遷移檔案:set not null、刪觸發器、刪 amount。這就是圖 6 裡那個虛線框。
用 expand/contract 改列名 五個階段:expand 加列和觸發器,回填 40 批共 18.4 秒,釋出 v2 時 v1 與 v2 共存,v1 下線後只剩 v2,contract 刪觸發器和舊列。amount 列和同步觸發器從開始存在到 contract;total_amount 列從 expand 開始,回填後有資料;應用 v1 只認識 amount,v2 只認識 total_amount,第三階段兩者同時線上。 把 amount 改名為 total_amount 的五步 ① expand 版本 N 的 .sql ② 回填 單獨的指令碼 ③ 釋出 v2 版本 N+1 的程式碼 ④ v1 下線 只剩 v2 ⑤ contract 版本 N+2 的 .sql amount 列 total_amount 列 同步觸發器 應用 v1 應用 v2 舊程式碼讀寫的列 DROP 新列,NULL → 逐批迴填 已回填,和 amount 一致 NOT NULL 寫任意一列,自動補上另一列 只認識 amount 只認識 total_amount 灰底是新舊程式碼同時線上的那一段:庫裡兩列都在、互相同步,兩邊都能工作 直接 RENAME 等於把五步壓成一瞬間,v1 立刻報 column "amount" does not exist
圖 10 · expand/contract 讓每個時刻的庫都同時滿足正在執行的所有程式碼版本,改名被拆成五個各自安全的小步

我們在 200 萬行的表上走了一遍。expand 檔案里加列 1.8 毫秒,建觸發器 1.6 毫秒。回填按主鍵每批 5 萬行、每批單獨提交,40 批共 18.4 秒,之後兩列不一致的行數為 0。這時插一行只寫 amount 的舊式資料,讀出來兩列都是 9.90;只寫 total_amount 的新式資料,兩列都是 19.90,新舊程式碼互不干擾。contract 檔案裡 set not null 165 毫秒,刪觸發器、刪 amount 各不到 2 毫秒。

整件事比一條 RENAME 麻煩得多,要分三次釋出,但每個遷移檔案都只包含“只改後設資料”的操作,沒有一個檔案會長時間鎖表。第五節裡 int 改 bigint 的拆法也是這樣:加一個 bigint 新列,同步、回填、切程式碼、刪舊列。PostgreSQL 上的 pgroll 把這套流程自動化了,它用檢視讓新舊兩個版本的 schema 同時可用,等舊程式碼全部下線後才真正刪列22。

做到這裡,一次變更已經可以在不停機的情況下上線了,可是每一步的 .sql 都是人想、人寫的。能不能只寫下表最後該長什麼樣,讓工具把這些步驟算出來?

七、只寫終態,讓工具算出變更

想法:倉庫裡只放“表應該長什麼樣”

前面每一次改表,都是人來想清楚要執行哪幾句、拆成幾個版本,再一句句寫進新檔案。很自然會想反過來做:倉庫裡只維護一份表的終態,像 Kubernetes 的 YAML 那樣宣告“應該是什麼樣”,要加哪幾列、刪哪幾列、改哪個型別,讓工具對比生產庫自己算出來。

這種做法叫宣告式(declarative),而且已經有現成的工具,主流的四個恰好都是 Go 寫的:Atlas 的 schema apply23,sqldef(PostgreSQL 用 psqldef,MySQL 用 mysqldef)24,Stripe 開源的 pg-schema-diff25,以及只支援 MySQL 的 Skeema26。我們用同一個變更把它們都試了一遍。庫裡的 orders 現在有 1000 行,note 列每一行都有值,期望的終態是:

SQL
CREATE TABLE orders (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL, -- 原来是 integer
amount numeric(10,2) NOT NULL,
remark text, -- 原来叫 note
coupon_code text -- 新加的
); -- legacy_flag 不要了
CREATE INDEX idx_orders_user ON orders (user_id);

一份檔案裡包含了改型別、改列名、刪列、加列、建索引五種變更。註釋是寫給讀者看的,工具看到的只有結構。

現象:同一份終態,算出四種計劃

Atlas 把所有變化併成一條 ALTER,再加一句普通的 CREATE INDEX:

-> ALTER TABLE "orders" ALTER COLUMN "user_id" TYPE bigint, DROP COLUMN "note", DROP COLUMN "legacy_flag", ADD COLUMN "remark" text NULL, ADD COLUMN "coupon_code" text NULL;
-> CREATE INDEX "idx_orders_user" ON "orders" ("user_id");

note 不見了、remark 出現了,在它看來就是刪一列、加一列。加上 --auto-approve 執行,兩句分別用了 6 毫秒和 2 毫秒,之後 1000 行的 remark 全是 NULL,原來 note 裡的資料沒了。

psqldef 的計劃不一樣:

BEGIN;
ALTER TABLE "public"."orders" ALTER COLUMN "user_id" TYPE bigint;
ALTER TABLE "public"."orders" ADD COLUMN "remark" text;
ALTER TABLE "public"."orders" ADD COLUMN "coupon_code" text;
CREATE INDEX idx_orders_user ON orders (user_id);
-- Skipped: ALTER TABLE "public"."orders" DROP COLUMN "legacy_flag";
-- Skipped: ALTER TABLE "public"."orders" DROP COLUMN "note";
COMMIT;

刪列預設跳過,要加 --enable-drop 才執行,所以 note 和它的資料還在,只是多出一列空的 remark。它還允許在終態檔案裡寫一句註解,告訴它這是改名:

SQL
remark text, -- @renamed from=note

這樣算出來的就是 RENAME COLUMN "note" TO "remark",1000 行都在。mysqldef 的行為相同,MySQL 上生成的是 CHANGE COLUMN note remark。

pg-schema-diff 的計劃最長,每一句前面都設了等鎖和執行的時限,危險的語句都帶著標註:

/*
Statement 2
- ACQUIRES_ACCESS_EXCLUSIVE_LOCK: This will completely lock the table while the data is being re-written. ...
*/
SET SESSION statement_timeout = 3000;
SET SESSION lock_timeout = 3000;
ALTER TABLE "public"."orders" ALTER COLUMN "user_id" SET DATA TYPE bigint using "user_id"::bigint;
/*
Statement 5
- DELETES_DATA: Deletes all values in the column
*/
SET SESSION statement_timeout = 3000;
SET SESSION lock_timeout = 3000;
ALTER TABLE "public"."orders" DROP COLUMN "note";
/*
Statement 6
- INDEX_BUILD: ... They also can take a while but do not lock out writes.
*/
SET SESSION statement_timeout = 1200000;
SET SESSION lock_timeout = 3000;
CREATE INDEX CONCURRENTLY idx_orders_user ON public.orders USING btree (user_id);

這幾乎就是第五節那張檢查表:每句先設等鎖時限,改型別標出會鎖表重寫,索引用 CONCURRENTLY 建。帶標註的語句預設不執行,apply 直接拒絕:Prohibited hazards in the following statements: ... DELETES_DATA,要用 --allow-hazards 逐類放行。可是它同樣認不出改名,全部放行後 remark 也是 1000 行 NULL。3 秒的語句時限在大表上也會出問題:在 200 萬行上改 int 為 bigint,第五節測過要 3.2 秒左右,我們跑了兩次,一次剛好跑完,一次報 canceling statement due to statement timeout,列還是 integer。

Skeema 在 MySQL 上直接拒絕:

[ERROR] Desired alteration for table `orders` is not safe: column `note` would be dropped; column `legacy_flag` would be dropped.
[WARN] Skipping 127.0.0.1:3306 sk1 due to 1 unsafe statement. Use --allow-unsafe or --safe-below-size to permit this operation.

加 --allow-unsafe 後,它把所有變化併成一條 ALTER 執行,remark 同樣是空的。它的文件還說可以把大表的 ALTER 交給 gh-ost 或 pt-online-schema-change 去跑,這一點我們沒有實測。

同一份終態,四個宣告式工具的計劃
工具note 改名為 remark刪 legacy_flag改型別、建索引執行時的保護
Atlas
schema apply
DROP + ADD,1000 行變成 NULL直接執行並進一條 ALTER;普通 CREATE INDEX先列印計劃,確認後執行
psqldef / mysqldef預設加列、不刪舊列;寫 @renamed 後 RENAME,1000 行都在預設跳過,--enable-drop 才刪直接改型別;普通 CREATE INDEX整個計劃包在一個事務裡,沒有等鎖時限
pg-schema-diffDROP + ADD,標 DELETES_DATA標 DELETES_DATA,不放行就拒絕標出鎖表重寫;CREATE INDEX CONCURRENTLY每句 3 秒等鎖時限,改表語句執行也限 3 秒;大表改型別可能被取消
SkeemaDROP + ADD判為 unsafe 拒絕,--allow-unsafe 放行全部並進一條 ALTER只支援 MySQL;可接 gh-ost(未實測)

版本:Atlas v1.3.5、sqldef v3.11.27、pg-schema-diff v1.0.9 在 PostgreSQL 16 上實測,Skeema 1.14.1 和 mysqldef 在 MariaDB 10.11 上實測。1000 行指 note 列原有的資料。

圖 11 · 四個工具算出的結構變化都對,差別在於會不會替你踩剎車:沒有一個能自己認出改名

原因:兩份結構裡沒有的東西,diff 看不到

四個工具算出的結構都是對的,執行完表都長成了終態的樣子。問題出在 diff 的輸入只有兩份結構:庫裡現在是什麼、檔案裡想要什麼。凡是這兩份結構裡沒寫的資訊,它都看不到。

diff 只看兩份結構 上方左邊是生產庫裡現在的 orders:id、user_id integer、amount、note text 且 1000 行有值、legacy_flag。右邊是 schema.sql 裡的終態:id、user_id bigint、amount、remark、coupon_code 和索引 idx_orders_user。下方是 diff 算出的計劃:改 user_id 型別、刪 note、刪 legacy_flag、加 remark、加 coupon_code、建索引。旁邊四個虛線框是 diff 看不到的東西:note 改名為 remark 要保留 1000 行;改型別要重寫整表、鎖多久;新舊程式碼同時線上要分幾個版本;新列的舊資料由誰回填。 生產庫裡現在 · orders schema.sql 裡的終態 diff 算出的計劃 diff 看不到的 id bigserial user_id integer amount numeric(10,2) note text 1000 行有值 legacy_flag boolean id bigserial user_id bigint amount numeric(10,2) remark text coupon_code text index idx_orders_user ALTER user_id TYPE bigint DROP COLUMN note DROP COLUMN legacy_flag ADD COLUMN remark text ADD COLUMN coupon_code text CREATE INDEX idx_orders_user note → remark 是改名,1000 行要留著 改型別要重寫整表,會鎖多久 新舊程式碼同時線上,要拆成幾個版本 新列裡的舊資料,由誰回填 虛線框:兩份結構裡都沒有寫,diff 算不出來,只能由人補上
圖 12 · diff 只比較“現在”和“終態”兩份結構,結構對了不等於資料對了、過程安全

第一類是意圖。一列消失、一列出現,可能是改名,也可能真的是刪一列再加一列,兩份結構上看完全一樣。sqldef 的 @renamed 就是讓人把意圖寫回終態檔案。但這句註解只對這一次變更有意義,生效以後就成了該刪掉的歷史,它其實就是一個混在終態檔案裡的遷移步驟。

第二類是過程。第六節的改名要分三個版本,每個版本的庫都長得不一樣。終態寫法只能描述“下一站”,描述不了“途經哪幾站”。想用它做 expand/contract,就得每次釋出各提交一份中間終態,拆版本的事還是由人來想。

第三類是資料。回填舊資料、修正髒資料、把一列拆成兩列,都不是結構變化,diff 里根本沒有它們的位置。

第四類是執行那一刻。計劃是對著執行時的庫現算的,而不是在 CI 裡審過的那一份。生產庫要是被人手動改過一列,算出的計劃也會跟著變,上線時執行的就成了誰也沒看過的 SQL。會不會鎖表、鎖多久,還要看錶有多大:pg-schema-diff 把這些經驗寫成了標註和 3 秒時限,可 3 秒在 200 萬行上正好卡在邊上。

所以終態寫法能用,但它省掉的只是“寫 ALTER 語句”這一步,而這一步本來就是整件事裡最簡單的。難的部分,即意圖、過程、資料和執行時的風險,還是要人來判斷。它最合適的場合是開發庫、測試庫,以及還沒上線、資料可以隨便丟的專案。

解決:終態生成版本化檔案,人改過再提交

兩種寫法可以結合:終態檔案用來描述和審閱“表應該是什麼樣”,由它生成版本化的 .sql,人改過再提交,之後照第四節的流程執行。Atlas 的 migrate diff 能直接輸出 golang-migrate 的格式15:

Terminal window
atlas migrate diff orders_v2 \
--dir "file://migrations?format=golang-migrate" \
--to file://schema.sql \
--dev-url "postgres://localhost:5432/atlasdev?sslmode=disable"

它在一個臨時庫(--dev-url)上把現有遷移回放一遍,再和 schema.sql 對比,寫出一對檔案:

20261026134949_orders_v2.up.sql
20261026134949_orders_v2.down.sql

up 檔案裡就是上面那條 DROP COLUMN "note" ... ADD COLUMN "remark"。它不會直接執行,而是作為一個普通的遷移檔案出現在 PR 裡。人在這裡把 DROP 加 ADD 改成 RENAME,或者按第六節拆成 expand 和 contract 兩個檔案,開頭補上 set lock_timeout,再執行 atlas migrate hash 更新 atlas.sum。之後 Squawk 檢查、up → down → up 演練、go:embed 打包、由 golang-migrate 執行,和手寫的檔案沒有區別。ent 的版本化遷移也是這個思路,只是終態寫在 Go 程式碼裡10。

這樣做的好處是終態和過程各有一個可信的來源。想知道表現在長什麼樣,看 schema.sql;想知道某個環境執行過哪些語句,看 migrations/ 和庫裡的版本表。每個環境執行的都是同一份、審過的檔案。

做到這裡,一次變更已經能不停機上線,寫檔案也可以交給工具起草。最後剩一個問題:上線以後發現有問題,那些 .down.sql 能不能用來回滾?

八、down 檔案與回滾

golang-migrate 建議每個遷移都寫 down2,goose 的檔案裡也留著 -- +goose Down 那一段。執行 migrate down 1 或 goose down,就會撤銷最近一個版本。

可是 down 檔案只能回滾結構,回滾不了資料。加列的 down 是刪列,可列裡已經寫進去的資料呢?刪列的 down 是加列,原來的資料早就沒了。第七節裡 Atlas 生成的 down 檔案就是這樣,它把 note 加了回來,但只是一列空的。所以實際做法通常是隻向前修復:出了問題再寫一個新版本改回來。down 檔案真正有用的地方,是第 ② 步裡那次 up → down → up 演練,以及開發時在本地反覆調整同一個遷移。

expand/contract 在這裡又幫了一次忙。在版本 N 和 N+1 之間,庫裡新舊兩列都在、都是完整的,發現 N+1 的程式碼有問題,回滾程式碼就夠了,資料庫什麼也不用動。真正不可逆的只有 N+2 的刪列,而它被推到了最後,推到新程式碼已經穩定執行一段時間之後。

九、回到那次上線

現在回頭看開頭那條 column "coupon_code" does not exist。

加 coupon_code 的 SQL 應該寫成 20261015000000_add_coupon_code.up.sql 和對應的 .down.sql,和使用它的 Go 程式碼放在同一個 PR 裡。CI 用 Squawk 和 atlas migrate validate 檢查它,在一次性的資料庫上跑一遍 up → down → up。它透過 go:embed 和程式碼打進同一個映象,部署時由一個遷移 Job 先執行,檔案開頭的 lock_timeout 保證即使碰上一個沒提交的報表事務,最壞也只是部署失敗、業務卡兩秒。遷移成功後,新程式碼才開始滾動釋出。加一列可空的列只改後設資料,屬於 expand,舊程式碼不受影響。如果哪天要把它改名或改型別,就照第六節分三個版本來做。

小結

  1. 改表 SQL 誰來執行、執行到哪? 手寫的 .sql 按版本放進倉庫,golang-migrate 或 goose 按順序執行,並在庫裡記下當前版本。
  2. 執行到一半失敗了? golang-migrate 留下 dirty 等人確認後 force;goose 預設包在事務裡,失敗就整體回滾。PostgreSQL 能回滾 DDL,MySQL 會隱式提交、留下半截。
  3. 兩個分支同時加遷移? golang-migrate 只記一個版本號,更早的檔案會被靜默跳過;goose、Atlas、Flyway 會拒絕;dbmate、sql-migrate、Liquibase 直接補執行;tern 要求編號連續。Go 的工具都要在 CI 裡補檢查:版本號遞增、已有檔案不許改(atlas.sum)。
  4. 一次變更怎麼走到生產? 寫檔案、CI 檢查和演練、和程式碼打進同一個映象、部署時先遷移後發程式碼,破壞性的部分留到下一個版本。遷移最好在流水線裡單獨執行一次,放在應用啟動時就必須加鎖。
  5. 一條很快的 ALTER 為什麼能停掉整張表? 它要拿排他鎖,排隊期間後來的查詢都排在它後面。在 .sql 裡先設 lock_timeout,儘量選只改後設資料的操作;MySQL 上要重寫整表的變更交給 gh-ost。
  6. 新舊程式碼同時線上怎麼改列名? expand/contract:分三個版本,加列和同步、回填、切程式碼、最後刪舊列。
  7. 能不能只寫終態,讓工具算出變更? 能算出結構,但 Atlas、pg-schema-diff、Skeema 都把改名當成刪列加列,sqldef 要靠 @renamed 註解;過程、資料和執行時的風險都不在 diff 裡。比較穩的做法是用 atlas migrate diff 由終態生成 golang-migrate 格式的檔案,人改過再提交。
  8. down 檔案能回滾嗎? 只能回滾結構,回滾不了資料,實踐中多是向前修復;expand/contract 把不可逆的刪除推到最後。

參考資料

參考資料

  1. GORM:MigrationGORM · 2026-10-21gorm.io ↗AutoMigrate 會建表、補缺失的外來鍵、約束、列和索引,但“不會刪除不再使用的列,以保護資料”。https://gorm.io/docs/migration.html ↩

  2. golang-migrate:Migration Files(MIGRATIONS.md)GitHub · 2026-10-21github.com ↗檔名格式 {version}_{title}.up.{extension} / .down.{extension};檔案內容由驅動原樣執行;建議每個遷移都寫 down。https://github.com/golang-migrate/migrate/blob/master/MIGRATIONS.md ↩ ↩2

  3. golang-migrate/migrate:Database migrations written in GoGitHub · 2026-10-21github.com ↗文中命令列輸出來自 v4.20.1(go install -tags postgres github.com/golang-migrate/migrate/v4/cmd/migrate@latest)。https://github.com/golang-migrate/migrate ↩

  4. pressly/goose:A database migration toolGitHub · 2026-10-21github.com ↗預設每個遷移包在事務裡,-- +goose NO TRANSACTION 關閉;預設拒絕亂序的缺失版本,推薦“時間戳 + 順序號”的混合編號,在 CI 裡跑 goose fix;支援 embed.FS。文中輸出來自 v3.29.0。https://github.com/pressly/goose ↩ ↩2

  5. amacneil/dbmate:A lightweight, framework-agnostic database migration toolGitHub · 2026-10-21github.com ↗-- migrate:up / -- migrate:down 分段,預設每個遷移包在事務裡(transaction:false 關閉);--migration-lock 開啟後用 PostgreSQL advisory lock 讓多個例項排隊。文中輸出來自 v2.37.0。https://github.com/amacneil/dbmate ↩

  6. rubenv/sql-migrate:SQL schema migration tool for GoGitHub · 2026-10-21github.com ↗-- +migrate Up / Down 分段,notransaction 關閉事務;記錄表 gorp_migrations 按檔名記每個遷移。文中輸出來自 v1.8.1,原始碼裡沒有加鎖邏輯。https://github.com/rubenv/sql-migrate ↩

  7. jackc/tern:The SQL Fan's MigratorGitHub · 2026-10-21github.com ↗pgx 作者寫的,只支援 PostgreSQL;順序號檔案,缺號報 Missing migration、重號報 Duplicate migration;執行時取 pg_advisory_lock。文中輸出來自 v2.4.3。https://github.com/jackc/tern ↩

  8. Flyway:Schema history tableRedgate · 2026-10-21documentation.red-gate.com ↗歷史表記錄每個遷移的版本、checksum 和是否成功。文中輸出來自 Flyway 13.10.3 命令列。https://documentation.red-gate.com/fd/flyway-schema-history-table-273973417.html ↩

  9. Liquibase:Formatted SQL ChangelogsLiquibase · 2026-10-21docs.liquibase.com ↗--changeset 作者:id 分段,DATABASECHANGELOG 記錄每個 changeset 和 MD5,DATABASECHANGELOGLOCK 表做鎖。文中輸出來自 5.0.1 社群版。https://docs.liquibase.com/concepts/changelogs/sql-format.html ↩

  10. ent:Versioned Migrationsent · 2026-10-21entgo.io ↗版本化遷移基於 Atlas;文件給出了生成 golang-migrate、goose、dbmate、Flyway、Liquibase 格式目錄的示例。https://entgo.io/docs/versioned-migrations/ ↩ ↩2

  11. Liquibase Community for the Future: FSLLiquibase · 2025-09liquibase.com ↗Liquibase 5.0 起社群版改用 FSL-1.1-ALv2,每個版本釋出兩年後轉為 Apache 2.0。https://www.liquibase.com/blog/liquibase-community-for-the-future-fsl ↩

  12. golang-migrate:FAQGitHub · 2026-10-21github.com ↗執行前先置 dirty 標記,失敗後 dirty 保留,需要手工修復再 force 到期望版本;PostgreSQL 驅動用 pg_advisory_lock、MySQL 驅動用 GET_LOCK 防止多個例項同時遷移。https://github.com/golang-migrate/migrate/blob/master/FAQ.md ↩

  13. PostgreSQL 文件:Message Flow · Multiple Statements in a Simple QueryPostgreSQL · 2026-10-21postgresql.org ↗一次簡單查詢裡的多條語句,除非顯式寫了事務控制語句,否則作為一個事務執行,任何一條失敗整體回滾。https://www.postgresql.org/docs/current/protocol-flow.html#PROTOCOL-FLOW-MULTI-STATEMENT ↩

  14. MySQL 8.0 Reference Manual:Statements That Cause an Implicit CommitMySQL · 2026-10-21dev.mysql.com ↗CREATE / ALTER / DROP TABLE 等 DDL 執行前會隱式提交當前事務,所以 MySQL 上的遷移檔案無法整體回滾。https://dev.mysql.com/doc/refman/8.0/en/implicit-commit.html ↩

  15. Atlas:Versioned MigrationsAtlas · 2026-10-21atlasgo.io ↗atlas.sum 記錄遷移目錄裡每個檔案的雜湊,用來“強制開發者處理遷移順序或內容被事後修改的情況”。--dir 可以加 ?format=golang-migrate 或 goose 直接讀這兩種目錄。文中輸出來自 Atlas v1.3.5。https://atlasgo.io/versioned/intro ↩ ↩2

  16. Squawk:Linter for Postgres migrationsSquawk · 2026-10-21squawkhq.com ↗文中輸出來自 squawk 2.70.0(pip install squawk-cli)。https://squawkhq.com/docs/rules ↩

  17. PostgreSQL 文件:ALTER TABLEPostgreSQL · 2026-10-21postgresql.org ↗預設取 ACCESS EXCLUSIVE 鎖;帶非 volatile 預設值的 ADD COLUMN 不重寫表;改列型別通常重寫整表和索引;NOT VALID 約束之後的 VALIDATE CONSTRAINT 只取 SHARE UPDATE EXCLUSIVE。https://www.postgresql.org/docs/current/sql-altertable.html ↩

  18. Zero-downtime Postgres migrations – the hard partsChris Sinjakli · GoCardlessgocardless.com ↗一次只改空表的遷移讓 API 超時約 15 秒:ALTER 本身幾百毫秒,時間都花在排隊等鎖上。建議在遷移裡設 lock_timeout,“寧可讓部署失敗,也不要讓應用停擺”。https://gocardless.com/blog/zero-downtime-postgres-migrations-the-hard-parts/ ↩

  19. MySQL 8.0 Reference Manual:Online DDL OperationsMySQL · 2026-10-21dev.mysql.com ↗ADD COLUMN 8.0.12 起預設 INSTANT,8.0.29 起 DROP COLUMN 也預設 INSTANT;改列型別只支援 COPY,不允許併發 DML;每張表最多 64 次 INSTANT 增刪列。https://dev.mysql.com/doc/refman/8.0/en/innodb-online-ddl-operations.html ↩

  20. gh-ost:GitHub's Online Schema-migration Tool for MySQLGitHub · 2026-10-21github.com ↗建影子表、分塊複製、讀 binlog 追增量、原子切換;不用觸發器,可以節流和推遲切換。https://github.com/github/gh-ost ↩

  21. ParallelChangeDanilo Sato · martinfowler.com · 2014-05-13martinfowler.com ↗expand、migrate、contract 三步;文中說大多數資料庫重構都遵循這個模式。https://martinfowler.com/bliki/ParallelChange.html ↩

  22. pgroll:Zero-downtime, reversible, schema migrations for PostgresGitHub · 2026-10-21github.com ↗在物理表上為每個 schema 版本建一套檢視,新舊兩個版本同時可用;complete 時才刪舊列,之前可以隨時 rollback。https://github.com/xataio/pgroll ↩

  23. Atlas:Declarative Schema MigrationsAtlas · 2026-10-26atlasgo.io ↗atlas schema apply 對比庫和期望的 schema,列印計劃後執行;--auto-approve 跳過確認。文中輸出來自 Atlas v1.3.5。https://atlasgo.io/declarative/apply ↩

  24. sqldef:Idempotent schema management for MySQL, PostgreSQL, SQLite and SQL ServerGitHub · 2026-10-26github.com ↗Go 寫的 psqldef、mysqldef 等,輸入期望的 CREATE 語句,算出差異執行。v3 預設跳過 DROP,要 --enable-drop;列名後寫 -- @renamed from=舊名 生成 RENAME。文中輸出來自 v3.11.27。https://github.com/sqldef/sqldef ↩

  25. pg-schema-diff:Go library for diffing Postgres schemas and generating SQL migrationsGitHub (Stripe) · 2026-10-26github.com ↗計劃裡每句帶 statement_timeout 和 lock_timeout,危險操作標成 hazard(DELETES_DATA、ACQUIRES_ACCESS_EXCLUSIVE_LOCK 等),不用 --allow-hazards 放行就拒絕執行;索引用 CONCURRENTLY 建。文中輸出來自 v1.0.9。https://github.com/stripe/pg-schema-diff ↩

  26. Skeema:Declarative pure-SQL schema management for MySQL and MariaDBSkeema · 2026-10-26skeema.io ↗刪列、刪表等會丟資料的變更判為 unsafe,預設拒絕,--allow-unsafe 或 --safe-below-size 放行;alter-wrapper 可以把 ALTER 交給 gh-ost、pt-online-schema-change 執行。文中輸出來自 1.14.1。https://www.skeema.io/docs/options/#allow-unsafe ↩