設想一個 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 是撤銷它的語句:
-- 20261015000000_add_coupon_code.up.sqlalter table orders add column coupon_code text;
-- 20261015000000_add_coupon_code.down.sqlalter table orders drop column coupon_code;在空庫上執行 migrate up,它按版本號從小到大把 .up.sql 一個個交給資料庫3:
$ migrate -path migrations -database "$DATABASE_URL" up20261001000000/u create_orders (11.022316ms)20261008000000/u add_created_at (19.27576ms)20261015000000/u add_coupon_code (27.091677ms)goose:一個檔案,上下兩段
goose 把 up 和 down 寫在同一個檔案裡,用註釋分段4:
-- +goose Upalter table orders add column coupon_code text;
-- +goose Downalter table orders drop column coupon_code;執行 goose up 的效果一樣。兩者真正的區別在庫裡那張記錄表:golang-migrate 的 schema_migrations 永遠只有一行,記著當前版本和一個 dirty 標記;goose 的 goose_db_version 每執行一個版本就追加一行。
釋出流程裡只要在新程式碼啟動前跑一次 up,每個環境都會自己補齊缺的那幾個版本。開頭那次事故,就是少了這一步。
其餘幾個工具的思路相同,差別在檔案怎麼分段、庫裡記多少、會不會校驗已經執行過的檔案。我們把它們都裝上56789、用同一組遷移檔案跑了一遍:
| 工具 | 形態 | .sql 怎麼寫 | 庫裡記什麼 | 已執行檔案被改 |
|---|---|---|---|---|
| golang-migrate | Go 庫 + 命令列 | 每個版本一對 .up.sql / .down.sql | 一行:當前版本 + dirty | 不檢查 |
| goose | Go 庫 + 命令列 | 一個檔案,-- +goose Up / Down 兩段 | 每個版本一行 | 不檢查 |
| dbmate | Go 寫的命令列 | 一個檔案,-- migrate:up / -- migrate:down | 每個版本一行 | 不檢查 |
| sql-migrate | Go 庫 + 命令列 | 一個檔案,-- +migrate Up / Down | 每個檔名一行,帶執行時間 | 不檢查 |
| tern | Go 庫 + 命令列,只支援 PostgreSQL | 順序號檔案 003_x.sql,用 ---- create above / drop below ---- 分隔 | 一行:當前版本號 | 不檢查 |
| Atlas | Go 寫的命令列 | 普通 .sql 加一個 atlas.sum;也能直接讀上面幾種格式 | 每個版本一行 | atlas.sum 雜湊 |
| ent | Go ORM | 由 Go 程式碼定義的 schema 經 Atlas 生成 .sql,可輸出成上面幾種格式 | 取決於輸出格式 | 同所選格式 |
| Flyway | Java 命令列 | V3__desc.sql,只寫 up | 每個版本一行 + checksum | checksum 校驗 |
| Liquibase | Java 命令列 | changelog(SQL / XML / YAML),每段 --changeset 作者:id | 每個 changeset 一行 + MD5 | MD5 校驗 |
| GORM AutoMigrate | Go 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 一行來自它的文件。
Go 的幾個工具都把 down 寫在同一個檔案或成對的檔案裡,Flyway 社群版只寫 up。ent 本身不執行遷移,它生成的檔案交給 Atlas 或上面任何一種工具去跑10。還要留意許可:Liquibase 從 5.0 起社群版改用 FSL,每個版本釋出兩年後才轉為 Apache 2.011。
只記一行還是記每一行,看起來只是實現細節,後面會看到它決定了兩件事:執行失敗時庫裡留下什麼,以及有檔案被漏掉時能不能發現。先看失敗:一個 .sql 檔案執行到一半出錯了,會怎樣?
二、執行失敗以後
現象:golang-migrate 留下一個 dirty 標記
我們寫一個有錯的遷移:第一句正確,第二句把 column 拼成了 colum。
-- 20261020000000_add_refund.up.sqlalter 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 ... up20261020000000/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。
由此有了寫 .sql 的第一條約定:一個檔案只做一件事。語句越少,失敗時越容易判斷停在了哪。另一條約定是執行過的檔案不再修改,要改就寫一個新版本。不過從圖 2 可以看到,Go 的這幾個工具都不檢查檔案有沒有被改過,也未必能發現另一種更隱蔽的情況:兩個人同時寫遷移,會不會有一個被漏掉?
三、並行分支與被漏掉的檔案
現象:一個更早的檔案被靜默跳過
A 在 10 月 12 日拉了分支 feature/channel,寫了 20261012000000_add_channel.up.sql,評審拖了一週。這期間 B 的 20261015000000_add_coupon_code 先合進主幹並上了線。等 A 的分支合進來,目錄裡多了一個時間戳比當前版本更早的檔案。
golang-migrate 的反應是:
$ migrate ... upno changechannel 列沒有建出來,沒有報錯,也沒有警告。原因就在圖 1 裡:schema_migrations 只記了一個數 20261015000000,up 的含義是“執行所有比它大的版本”,20261012000000 比它小,在它眼裡就是已經執行過的。直到 A 的程式碼上線去讀 channel,才會再報一次開頭那樣的錯。
goose:記了每一行,就能發現缺口
同樣的場景,goose 會拒絕執行:
$ goose upgoose 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)就和新建的庫(按檔名排序)不一樣了。兩個變更互不相干時無所謂,相干時就可能出問題。
解決:讓問題在合併程式碼時暴露
兩個工具各有對策。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 editedError: checksum mismatchatlas.sum 第一行是整個目錄的雜湊,兩個分支各加一個檔案,Git 合併時這一行必然衝突,也順帶擋住了上面那個並行分支的問題。
八個能執行遷移的工具,在同一組場景下的表現放在一起看:
| 工具 | 檔案第二句寫錯 | 更早的檔案後合並進來 | 兩個例項同時執行 |
|---|---|---|---|
| golang-migrate | 整體回滾,但留下 dirty,要 force | 靜默跳過,no change | pg_advisory_lock 排隊 |
| goose | 事務回滾,版本不動 | 報 missing,-allow-missing 放行 | 命令列不加鎖,第二個重複執行報錯;程式碼裡可加會話鎖 |
| dbmate | 事務回滾,版本不動 | 直接補執行,不提示 | 預設不加鎖、重複執行報錯;--migration-lock 後排隊 |
| sql-migrate | 事務回滾,版本不動 | 直接補執行,不提示 | 不加鎖,第二個重複執行報錯 |
| tern | 事務回滾,版本不動 | 編號必須連續,重號報 Duplicate migration | pg_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 會隱式提交,回滾不了。
到這裡,一個 .sql 檔案該怎麼寫、出錯怎麼收拾、並行開發怎麼防漏,都有了答案。可是它最終要在生產庫上執行,這件事由誰、在釋出流程的哪一步來做?
四、一次變更的完整流程
從提交到生產要經過哪幾步
把前面的約定串起來,一個 .sql 檔案從寫下來到在生產庫生效,要走六步。我們跟著 20261015000000_add_coupon_code.up.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 operationswarning[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:embed migrations/*.sqlvar 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。
流程搭好以後,開頭那種“忘了執行”的事故不會再發生。但還有一類事故,流程本身擋不住:在一張正在被頻繁讀寫的大表上,第 ④ 步執行一條看起來很快的 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。
解決:在 .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 解決的是“等鎖”。拿到鎖以後還要看“持鎖多久”:只改後設資料的操作幾毫秒就放鎖,要重寫整張表的操作,會在持鎖期間把每一行都改寫一遍。
| 操作 | PostgreSQL | MySQL 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 再 VALIDATE | INPLACE,要重建表 | 165 ms |
| 共同點:每一種都要先拿到表上的排他鎖,等鎖那一段就是圖 8 的風險 | |||
MySQL 一列來自官方 Online DDL 文件,沒有在本機實測;INSTANT 增刪列每張表最多累計 64 次,之後要重建表。
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 裡那個虛線框。
我們在 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 列每一行都有值,期望的終態是:
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。它還允許在終態檔案裡寫一句註解,告訴它這是改名:
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-diff | DROP + ADD,標 DELETES_DATA | 標 DELETES_DATA,不放行就拒絕 | 標出鎖表重寫;CREATE INDEX CONCURRENTLY | 每句 3 秒等鎖時限,改表語句執行也限 3 秒;大表改型別可能被取消 |
| Skeema | DROP + 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 列原有的資料。
原因:兩份結構裡沒有的東西,diff 看不到
四個工具算出的結構都是對的,執行完表都長成了終態的樣子。問題出在 diff 的輸入只有兩份結構:庫裡現在是什麼、檔案裡想要什麼。凡是這兩份結構裡沒寫的資訊,它都看不到。
第一類是意圖。一列消失、一列出現,可能是改名,也可能真的是刪一列再加一列,兩份結構上看完全一樣。sqldef 的 @renamed 就是讓人把意圖寫回終態檔案。但這句註解只對這一次變更有意義,生效以後就成了該刪掉的歷史,它其實就是一個混在終態檔案裡的遷移步驟。
第二類是過程。第六節的改名要分三個版本,每個版本的庫都長得不一樣。終態寫法只能描述“下一站”,描述不了“途經哪幾站”。想用它做 expand/contract,就得每次釋出各提交一份中間終態,拆版本的事還是由人來想。
第三類是資料。回填舊資料、修正髒資料、把一列拆成兩列,都不是結構變化,diff 里根本沒有它們的位置。
第四類是執行那一刻。計劃是對著執行時的庫現算的,而不是在 CI 裡審過的那一份。生產庫要是被人手動改過一列,算出的計劃也會跟著變,上線時執行的就成了誰也沒看過的 SQL。會不會鎖表、鎖多久,還要看錶有多大:pg-schema-diff 把這些經驗寫成了標註和 3 秒時限,可 3 秒在 200 萬行上正好卡在邊上。
所以終態寫法能用,但它省掉的只是“寫 ALTER 語句”這一步,而這一步本來就是整件事裡最簡單的。難的部分,即意圖、過程、資料和執行時的風險,還是要人來判斷。它最合適的場合是開發庫、測試庫,以及還沒上線、資料可以隨便丟的專案。
解決:終態生成版本化檔案,人改過再提交
兩種寫法可以結合:終態檔案用來描述和審閱“表應該是什麼樣”,由它生成版本化的 .sql,人改過再提交,之後照第四節的流程執行。Atlas 的 migrate diff 能直接輸出 golang-migrate 的格式15:
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.sql20261026134949_orders_v2.down.sqlup 檔案裡就是上面那條 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,舊程式碼不受影響。如果哪天要把它改名或改型別,就照第六節分三個版本來做。
小結
- 改表 SQL 誰來執行、執行到哪? 手寫的
.sql按版本放進倉庫,golang-migrate 或 goose 按順序執行,並在庫裡記下當前版本。 - 執行到一半失敗了? golang-migrate 留下 dirty 等人確認後
force;goose 預設包在事務裡,失敗就整體回滾。PostgreSQL 能回滾 DDL,MySQL 會隱式提交、留下半截。 - 兩個分支同時加遷移? golang-migrate 只記一個版本號,更早的檔案會被靜默跳過;goose、Atlas、Flyway 會拒絕;dbmate、sql-migrate、Liquibase 直接補執行;tern 要求編號連續。Go 的工具都要在 CI 裡補檢查:版本號遞增、已有檔案不許改(
atlas.sum)。 - 一次變更怎麼走到生產? 寫檔案、CI 檢查和演練、和程式碼打進同一個映象、部署時先遷移後發程式碼,破壞性的部分留到下一個版本。遷移最好在流水線裡單獨執行一次,放在應用啟動時就必須加鎖。
- 一條很快的 ALTER 為什麼能停掉整張表? 它要拿排他鎖,排隊期間後來的查詢都排在它後面。在
.sql裡先設lock_timeout,儘量選只改後設資料的操作;MySQL 上要重寫整表的變更交給 gh-ost。 - 新舊程式碼同時線上怎麼改列名? expand/contract:分三個版本,加列和同步、回填、切程式碼、最後刪舊列。
- 能不能只寫終態,讓工具算出變更? 能算出結構,但 Atlas、pg-schema-diff、Skeema 都把改名當成刪列加列,sqldef 要靠
@renamed註解;過程、資料和執行時的風險都不在 diff 裡。比較穩的做法是用atlas migrate diff由終態生成 golang-migrate 格式的檔案,人改過再提交。 - down 檔案能回滾嗎? 只能回滾結構,回滾不了資料,實踐中多是向前修復;expand/contract 把不可逆的刪除推到最後。
參考資料
參考資料
-
GORM:Migrationgorm.io ↗AutoMigrate 會建表、補缺失的外來鍵、約束、列和索引,但“不會刪除不再使用的列,以保護資料”。https://gorm.io/docs/migration.html ↩
-
golang-migrate:Migration Files(MIGRATIONS.md)github.com ↗檔名格式
{version}_{title}.up.{extension}/.down.{extension};檔案內容由驅動原樣執行;建議每個遷移都寫 down。https://github.com/golang-migrate/migrate/blob/master/MIGRATIONS.md ↩ ↩2 -
golang-migrate/migrate:Database migrations written in Gogithub.com ↗文中命令列輸出來自 v4.20.1(
go install -tags postgres github.com/golang-migrate/migrate/v4/cmd/migrate@latest)。https://github.com/golang-migrate/migrate ↩ -
pressly/goose:A database migration toolgithub.com ↗預設每個遷移包在事務裡,
-- +goose NO TRANSACTION關閉;預設拒絕亂序的缺失版本,推薦“時間戳 + 順序號”的混合編號,在 CI 裡跑goose fix;支援embed.FS。文中輸出來自 v3.29.0。https://github.com/pressly/goose ↩ ↩2 -
amacneil/dbmate:A lightweight, framework-agnostic database migration toolgithub.com ↗
-- migrate:up/-- migrate:down分段,預設每個遷移包在事務裡(transaction:false關閉);--migration-lock開啟後用 PostgreSQL advisory lock 讓多個例項排隊。文中輸出來自 v2.37.0。https://github.com/amacneil/dbmate ↩ -
rubenv/sql-migrate:SQL schema migration tool for Gogithub.com ↗
-- +migrate Up/Down分段,notransaction關閉事務;記錄表gorp_migrations按檔名記每個遷移。文中輸出來自 v1.8.1,原始碼裡沒有加鎖邏輯。https://github.com/rubenv/sql-migrate ↩ -
jackc/tern:The SQL Fan's Migratorgithub.com ↗pgx 作者寫的,只支援 PostgreSQL;順序號檔案,缺號報
Missing migration、重號報Duplicate migration;執行時取pg_advisory_lock。文中輸出來自 v2.4.3。https://github.com/jackc/tern ↩ -
Flyway:Schema history tabledocumentation.red-gate.com ↗歷史表記錄每個遷移的版本、checksum 和是否成功。文中輸出來自 Flyway 13.10.3 命令列。https://documentation.red-gate.com/fd/flyway-schema-history-table-273973417.html ↩
-
Liquibase:Formatted SQL Changelogsdocs.liquibase.com ↗
--changeset 作者:id分段,DATABASECHANGELOG記錄每個 changeset 和 MD5,DATABASECHANGELOGLOCK表做鎖。文中輸出來自 5.0.1 社群版。https://docs.liquibase.com/concepts/changelogs/sql-format.html ↩ -
ent:Versioned Migrationsentgo.io ↗版本化遷移基於 Atlas;文件給出了生成 golang-migrate、goose、dbmate、Flyway、Liquibase 格式目錄的示例。https://entgo.io/docs/versioned-migrations/ ↩ ↩2
-
Liquibase Community for the Future: FSLliquibase.com ↗Liquibase 5.0 起社群版改用 FSL-1.1-ALv2,每個版本釋出兩年後轉為 Apache 2.0。https://www.liquibase.com/blog/liquibase-community-for-the-future-fsl ↩
-
golang-migrate:FAQgithub.com ↗執行前先置 dirty 標記,失敗後 dirty 保留,需要手工修復再
force到期望版本;PostgreSQL 驅動用pg_advisory_lock、MySQL 驅動用GET_LOCK防止多個例項同時遷移。https://github.com/golang-migrate/migrate/blob/master/FAQ.md ↩ -
PostgreSQL 文件:Message Flow · Multiple Statements in a Simple Querypostgresql.org ↗一次簡單查詢裡的多條語句,除非顯式寫了事務控制語句,否則作為一個事務執行,任何一條失敗整體回滾。https://www.postgresql.org/docs/current/protocol-flow.html#PROTOCOL-FLOW-MULTI-STATEMENT ↩
-
MySQL 8.0 Reference Manual:Statements That Cause an Implicit Commitdev.mysql.com ↗CREATE / ALTER / DROP TABLE 等 DDL 執行前會隱式提交當前事務,所以 MySQL 上的遷移檔案無法整體回滾。https://dev.mysql.com/doc/refman/8.0/en/implicit-commit.html ↩
-
Atlas:Versioned Migrationsatlasgo.io ↗
atlas.sum記錄遷移目錄裡每個檔案的雜湊,用來“強制開發者處理遷移順序或內容被事後修改的情況”。--dir可以加?format=golang-migrate或goose直接讀這兩種目錄。文中輸出來自 Atlas v1.3.5。https://atlasgo.io/versioned/intro ↩ ↩2 -
Squawk:Linter for Postgres migrationssquawkhq.com ↗文中輸出來自 squawk 2.70.0(
pip install squawk-cli)。https://squawkhq.com/docs/rules ↩ -
PostgreSQL 文件:ALTER TABLEpostgresql.org ↗預設取 ACCESS EXCLUSIVE 鎖;帶非 volatile 預設值的 ADD COLUMN 不重寫表;改列型別通常重寫整表和索引;
NOT VALID約束之後的VALIDATE CONSTRAINT只取 SHARE UPDATE EXCLUSIVE。https://www.postgresql.org/docs/current/sql-altertable.html ↩ -
Zero-downtime Postgres migrations – the hard partsgocardless.com ↗一次只改空表的遷移讓 API 超時約 15 秒:ALTER 本身幾百毫秒,時間都花在排隊等鎖上。建議在遷移裡設
lock_timeout,“寧可讓部署失敗,也不要讓應用停擺”。https://gocardless.com/blog/zero-downtime-postgres-migrations-the-hard-parts/ ↩ -
MySQL 8.0 Reference Manual:Online DDL Operationsdev.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 ↩
-
gh-ost:GitHub's Online Schema-migration Tool for MySQLgithub.com ↗建影子表、分塊複製、讀 binlog 追增量、原子切換;不用觸發器,可以節流和推遲切換。https://github.com/github/gh-ost ↩
-
ParallelChangemartinfowler.com ↗expand、migrate、contract 三步;文中說大多數資料庫重構都遵循這個模式。https://martinfowler.com/bliki/ParallelChange.html ↩
-
pgroll:Zero-downtime, reversible, schema migrations for Postgresgithub.com ↗在物理表上為每個 schema 版本建一套檢視,新舊兩個版本同時可用;
complete時才刪舊列,之前可以隨時rollback。https://github.com/xataio/pgroll ↩ -
Atlas:Declarative Schema Migrationsatlasgo.io ↗
atlas schema apply對比庫和期望的 schema,列印計劃後執行;--auto-approve跳過確認。文中輸出來自 Atlas v1.3.5。https://atlasgo.io/declarative/apply ↩ -
sqldef:Idempotent schema management for MySQL, PostgreSQL, SQLite and SQL Servergithub.com ↗Go 寫的 psqldef、mysqldef 等,輸入期望的 CREATE 語句,算出差異執行。v3 預設跳過 DROP,要
--enable-drop;列名後寫-- @renamed from=舊名生成 RENAME。文中輸出來自 v3.11.27。https://github.com/sqldef/sqldef ↩ -
pg-schema-diff:Go library for diffing Postgres schemas and generating SQL migrationsgithub.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 ↩ -
Skeema:Declarative pure-SQL schema management for MySQL and MariaDBskeema.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 ↩