设想一个 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 自动算出 .sql,但它只比较结构:实验里把列改名,生成的是 DROP COLUMN 加 ADD COLUMN,执行成功后原来 1000 行数据全变成了 NULL,自动生成的文件同样要人逐行看。“回滚”都是 PostgreSQL 上的结果,MySQL 的 DDL 会隐式提交,回滚不了。
到这里,一个 .sql 文件该怎么写、出错怎么收拾、并行开发怎么防漏,都有了答案。可是它最终要在生产库上执行,这件事由谁、在发布流程的哪一步来做?
四、一次变更的完整流程
从提交到生产要经过哪几步
把前面的约定串起来,一个 .sql 文件从写下来到在生产库生效,要走六步。我们跟着 20261015000000_add_coupon_code.up.sql 走一遍。
① 写文件。 up 和 down 各一个,一个文件只做一件事。会拿表锁的语句前面先写 set lock_timeout(第五节会讲为什么)。
② 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。
做到这里,一次变更已经可以在不停机的情况下上线了。最后剩一个问题:上线以后发现有问题,那些 .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,旧代码不受影响。如果哪天要把它改名或改类型,就照第六节分三个版本来做。
小结
- 改表 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:分三个版本,加列和同步、回填、切代码、最后删旧列。
- down 文件能回滚吗? 只能回滚结构,回滚不了数据,实践中多是向前修复;expand/contract 把不可逆的删除推到最后。
参考资料
Footnotes
-
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/ ↩
-
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 ↩ -
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 ↩