.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 自动算出 .sql,但它只比较结构:实验里把列改名,生成的是 DROP COLUMN 加 ADD COLUMN,执行成功后原来 1000 行数据全变成了 NULL,自动生成的文件同样要人逐行看。“回滚”都是 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(第五节会讲为什么)。

② 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。

做到这里,一次变更已经可以在不停机的情况下上线了。最后剩一个问题:上线以后发现有问题,那些 .down.sql 能不能用来回滚?

七、down 文件与回滚

golang-migrate 建议每个迁移都写 down2,goose 的文件里也留着 -- +goose Down 那一段。执行 migrate down 1 或 goose down,就会撤销最近一个版本。

可是 down 文件只能回滚结构,回滚不了数据。加列的 down 是删列,可列里已经写进去的数据呢?删列的 down 是加列,原来的数据早就没了。所以实际做法通常是只向前修复:出了问题再写一个新版本改回来。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. down 文件能回滚吗? 只能回滚结构,回滚不了数据,实践中多是向前修复;expand/contract 把不可逆的删除推到最后。

参考资料

Footnotes

  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/ ↩

  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 ↩

  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 ↩