﻿---
title: "Go 项目的数据库迁移：手写 .sql 文件，一次变更怎么走到生产"
description: "Go 服务新版本上线，日志里刷出 column \"coupon_code\" does not exist：加列的 SQL 写了，生产库没人执行。顺着这个现象往下追：手写的 .sql 文件怎么交给 golang-migrate 和 goose 去执行，执行到一半失败了库里留下什么，两个分支各加一个文件时会不会漏掉一个，一个 .sql 从提交、CI、打包到部署要走哪几步、由谁来执行，一条只改元数据的 ALTER 为什么能把整张表堵住，新旧代码同时在线时列名怎么跨几个版本改，最后是 down 文件到底能不能用来回滚。"
pubDate: 2026-10-21
tags: ["数据库", "Go", "PostgreSQL", "MySQL", "运维"]
hero: "./hero.svg"
refs:
  - id: golang-migrate
    title: "golang-migrate/migrate：Database migrations written in Go"
    url: https://github.com/golang-migrate/migrate
    site: GitHub
    date: 2026-10-21
    note: "文中命令行输出来自 v4.20.1（`go install -tags postgres github.com/golang-migrate/migrate/v4/cmd/migrate@latest`）。"
  - id: golang-migrate-files
    title: "golang-migrate：Migration Files（MIGRATIONS.md）"
    url: https://github.com/golang-migrate/migrate/blob/master/MIGRATIONS.md
    site: GitHub
    date: 2026-10-21
    note: "文件名格式 `{version}_{title}.up.{extension}` / `.down.{extension}`；文件内容由驱动原样执行；建议每个迁移都写 down。"
  - id: golang-migrate-faq
    title: "golang-migrate：FAQ"
    url: https://github.com/golang-migrate/migrate/blob/master/FAQ.md
    site: GitHub
    date: 2026-10-21
    note: "执行前先置 dirty 标记，失败后 dirty 保留，需要手工修复再 `force` 到期望版本；PostgreSQL 驱动用 `pg_advisory_lock`、MySQL 驱动用 `GET_LOCK` 防止多个实例同时迁移。"
  - id: goose
    title: "pressly/goose：A database migration tool"
    url: https://github.com/pressly/goose
    site: GitHub
    date: 2026-10-21
    note: "默认每个迁移包在事务里，`-- +goose NO TRANSACTION` 关闭；默认拒绝乱序的缺失版本，推荐“时间戳 + 顺序号”的混合编号，在 CI 里跑 `goose fix`；支持 `embed.FS`。文中输出来自 v3.29.0。"
  - id: dbmate
    title: "amacneil/dbmate：A lightweight, framework-agnostic database migration tool"
    url: https://github.com/amacneil/dbmate
    site: GitHub
    date: 2026-10-21
    note: "`-- migrate:up` / `-- migrate:down` 分段，默认每个迁移包在事务里（`transaction:false` 关闭）；`--migration-lock` 打开后用 PostgreSQL advisory lock 让多个实例排队。文中输出来自 v2.37.0。"
  - id: sql-migrate
    title: "rubenv/sql-migrate：SQL schema migration tool for Go"
    url: https://github.com/rubenv/sql-migrate
    site: GitHub
    date: 2026-10-21
    note: "`-- +migrate Up` / `Down` 分段，`notransaction` 关闭事务；记录表 `gorp_migrations` 按文件名记每个迁移。文中输出来自 v1.8.1，源码里没有加锁逻辑。"
  - id: tern
    title: "jackc/tern：The SQL Fan's Migrator"
    url: https://github.com/jackc/tern
    site: GitHub
    date: 2026-10-21
    note: "pgx 作者写的，只支持 PostgreSQL；顺序号文件，缺号报 `Missing migration`、重号报 `Duplicate migration`；执行时取 `pg_advisory_lock`。文中输出来自 v2.4.3。"
  - id: flyway
    title: "Flyway：Schema history table"
    url: https://documentation.red-gate.com/fd/flyway-schema-history-table-273973417.html
    site: Redgate
    date: 2026-10-21
    note: "历史表记录每个迁移的版本、checksum 和是否成功。文中输出来自 Flyway 13.10.3 命令行。"
  - id: liquibase
    title: "Liquibase：Formatted SQL Changelogs"
    url: https://docs.liquibase.com/concepts/changelogs/sql-format.html
    site: Liquibase
    date: 2026-10-21
    note: "`--changeset 作者:id` 分段，`DATABASECHANGELOG` 记录每个 changeset 和 MD5，`DATABASECHANGELOGLOCK` 表做锁。文中输出来自 5.0.1 社区版。"
  - id: liquibase-fsl
    title: "Liquibase Community for the Future: FSL"
    url: https://www.liquibase.com/blog/liquibase-community-for-the-future-fsl
    site: Liquibase
    date: 2025-09
    note: "Liquibase 5.0 起社区版改用 FSL-1.1-ALv2，每个版本发布两年后转为 Apache 2.0。"
  - id: ent
    title: "ent：Versioned Migrations"
    url: https://entgo.io/docs/versioned-migrations/
    site: ent
    date: 2026-10-21
    note: "版本化迁移基于 Atlas；文档给出了生成 golang-migrate、goose、dbmate、Flyway、Liquibase 格式目录的示例。"
  - id: gorm-migration
    title: "GORM：Migration"
    url: https://gorm.io/docs/migration.html
    site: GORM
    date: 2026-10-21
    note: "AutoMigrate 会建表、补缺失的外键、约束、列和索引，但“不会删除不再使用的列，以保护数据”。"
  - id: pg-protocol
    title: "PostgreSQL 文档：Message Flow · Multiple Statements in a Simple Query"
    url: https://www.postgresql.org/docs/current/protocol-flow.html#PROTOCOL-FLOW-MULTI-STATEMENT
    site: PostgreSQL
    date: 2026-10-21
    note: "一次简单查询里的多条语句，除非显式写了事务控制语句，否则作为一个事务执行，任何一条失败整体回滚。"
  - id: mysql-implicit-commit
    title: "MySQL 8.0 Reference Manual：Statements That Cause an Implicit Commit"
    url: https://dev.mysql.com/doc/refman/8.0/en/implicit-commit.html
    site: MySQL
    date: 2026-10-21
    note: "CREATE / ALTER / DROP TABLE 等 DDL 执行前会隐式提交当前事务，所以 MySQL 上的迁移文件无法整体回滚。"
  - id: atlas-versioned
    title: "Atlas：Versioned Migrations"
    url: https://atlasgo.io/versioned/intro
    site: Atlas
    date: 2026-10-21
    note: "`atlas.sum` 记录迁移目录里每个文件的哈希，用来“强制开发者处理迁移顺序或内容被事后修改的情况”。`--dir` 可以加 `?format=golang-migrate` 或 `goose` 直接读这两种目录。文中输出来自 Atlas v1.3.5。"
  - id: squawk
    title: "Squawk：Linter for Postgres migrations"
    url: https://squawkhq.com/docs/rules
    site: Squawk
    date: 2026-10-21
    note: "文中输出来自 squawk 2.70.0（`pip install squawk-cli`）。"
  - id: pg-altertable
    title: "PostgreSQL 文档：ALTER TABLE"
    url: https://www.postgresql.org/docs/current/sql-altertable.html
    site: PostgreSQL
    date: 2026-10-21
    note: "默认取 ACCESS EXCLUSIVE 锁；带非 volatile 默认值的 ADD COLUMN 不重写表；改列类型通常重写整表和索引；`NOT VALID` 约束之后的 `VALIDATE CONSTRAINT` 只取 SHARE UPDATE EXCLUSIVE。"
  - id: gocardless
    title: "Zero-downtime Postgres migrations – the hard parts"
    url: https://gocardless.com/blog/zero-downtime-postgres-migrations-the-hard-parts/
    author: Chris Sinjakli
    site: GoCardless
    note: "一次只改空表的迁移让 API 超时约 15 秒：ALTER 本身几百毫秒，时间都花在排队等锁上。建议在迁移里设 `lock_timeout`，“宁可让部署失败，也不要让应用停摆”。"
  - id: mysql-online-ddl
    title: "MySQL 8.0 Reference Manual：Online DDL Operations"
    url: https://dev.mysql.com/doc/refman/8.0/en/innodb-online-ddl-operations.html
    site: MySQL
    date: 2026-10-21
    note: "ADD COLUMN 8.0.12 起默认 INSTANT，8.0.29 起 DROP COLUMN 也默认 INSTANT；改列类型只支持 COPY，不允许并发 DML；每张表最多 64 次 INSTANT 增删列。"
  - id: ghost-readme
    title: "gh-ost：GitHub's Online Schema-migration Tool for MySQL"
    url: https://github.com/github/gh-ost
    site: GitHub
    date: 2026-10-21
    note: "建影子表、分块拷贝、读 binlog 追增量、原子切换；不用触发器，可以节流和推迟切换。"
  - id: parallel-change
    title: "ParallelChange"
    url: https://martinfowler.com/bliki/ParallelChange.html
    author: Danilo Sato
    site: martinfowler.com
    date: 2014-05-13
    note: "expand、migrate、contract 三步；文中说大多数数据库重构都遵循这个模式。"
  - id: pgroll
    title: "pgroll：Zero-downtime, reversible, schema migrations for Postgres"
    url: https://github.com/xataio/pgroll
    site: GitHub
    date: 2026-10-21
    note: "在物理表上为每个 schema 版本建一套视图，新旧两个版本同时可用；`complete` 时才删旧列，之前可以随时 `rollback`。"
---

设想一个 Go 写的订单服务发了新版本，刚滚到第三台机器，日志里开始刷同一行错误。

```text
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{})`，让它对照结构体补齐表和列。它确实能加列、加索引，但只会往上加：按官方文档，它“不会删除不再使用的列”[^gorm-migration]，也做不了改名，每次启动都在执行，库里也不留下任何“执行到哪了”的记录。更要紧的是，谁也说不清某次发布实际会对生产库执行哪些语句。

所以大多数团队选择自己写 `.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.sql`[^golang-migrate-files]。版本号可以是顺序号，也可以是时间戳，本文用时间戳。`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` 一个个交给数据库[^golang-migrate]：

```text
$ 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 写在同一个文件里，用注释分段[^goose]：

```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` 每执行一个版本就追加一行。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 460" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gm1-title gm1-desc">
<title id="gm1-title">golang-migrate 和 goose 的文件与记录表</title>
<desc id="gm1-desc">左边 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。</desc>
<rect class="dd-frame" width="800" height="460" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="376" height="428" rx="8"/>
<rect class="dd-zone" x="408" y="16" width="376" height="428" rx="8"/>
<text class="t-zone" x="32" y="44">golang-migrate · 一对 up / down 文件</text>
<text class="t-zone" x="424" y="44">goose · 一个文件两段</text>
<rect class="dd-card" x="32" y="64" width="344" height="188" rx="6"/>
<path class="dd-card-head" d="M38 64 H370 Q376 64 376 70 V96 H32 V70 Q32 64 38 64 Z"/>
<text class="t-name" x="48" y="85">migrations/</text>
<text class="t-note" x="360" y="85" text-anchor="end">6 个文件</text>
<text class="t-val" x="48" y="113">20261001000000_create_orders.up.sql</text>
<line class="dd-hair" x1="32" y1="122" x2="376" y2="122"/>
<text class="t-val" x="48" y="139">20261001000000_create_orders.down.sql</text>
<line class="dd-hair" x1="32" y1="148" x2="376" y2="148"/>
<text class="t-val" x="48" y="165">20261008000000_add_created_at.up.sql</text>
<line class="dd-hair" x1="32" y1="174" x2="376" y2="174"/>
<text class="t-val" x="48" y="191">20261008000000_add_created_at.down.sql</text>
<line class="dd-hair" x1="32" y1="200" x2="376" y2="200"/>
<text class="t-mono" x="48" y="217"><tspan class="t-acc">20261015000000</tspan>_add_coupon_code.up.sql</text>
<line class="dd-hair" x1="32" y1="226" x2="376" y2="226"/>
<text class="t-mono" x="48" y="243"><tspan class="t-acc">20261015000000</tspan>_add_coupon_code.down.sql</text>
<rect class="dd-card" x="32" y="276" width="344" height="84" rx="6"/>
<path class="dd-card-head" d="M38 276 H370 Q376 276 376 282 V308 H32 V282 Q32 276 38 276 Z"/>
<text class="t-name" x="48" y="297">schema_migrations</text>
<text class="t-note" x="360" y="297" text-anchor="end">1 行</text>
<text class="t-note" x="48" y="325">version</text>
<text class="t-note" x="296" y="325">dirty</text>
<line class="dd-hair" x1="32" y1="334" x2="376" y2="334"/>
<text class="t-mono t-acc" x="48" y="351">20261015000000</text>
<text class="t-val" x="296" y="351">f</text>
<text class="t-say" x="32" y="428">整张表只有一行：当前版本和 dirty 标记</text>
<rect class="dd-card" x="424" y="64" width="344" height="162" rx="6"/>
<path class="dd-card-head" d="M430 64 H762 Q768 64 768 70 V96 H424 V70 Q424 64 430 64 Z"/>
<text class="t-mono" x="440" y="85"><tspan class="t-acc">00003</tspan>_add_coupon_code.sql</text>
<text class="t-note" x="440" y="113">-- +goose Up</text>
<line class="dd-hair" x1="424" y1="122" x2="768" y2="122"/>
<text class="t-val" x="440" y="139">alter table orders</text>
<line class="dd-hair" x1="424" y1="148" x2="768" y2="148"/>
<text class="t-val" x="456" y="165">add column coupon_code text;</text>
<line class="dd-hair" x1="424" y1="174" x2="768" y2="174"/>
<text class="t-note" x="440" y="191">-- +goose Down</text>
<line class="dd-hair" x1="424" y1="200" x2="768" y2="200"/>
<text class="t-val" x="440" y="217">alter table orders drop column coupon_code;</text>
<rect class="dd-card" x="424" y="244" width="344" height="162" rx="6"/>
<path class="dd-card-head" d="M430 244 H762 Q768 244 768 250 V276 H424 V250 Q424 244 430 244 Z"/>
<text class="t-name" x="440" y="265">goose_db_version</text>
<text class="t-note" x="752" y="265" text-anchor="end">4 行</text>
<text class="t-note" x="440" y="293">id</text>
<text class="t-note" x="480" y="293">version_id</text>
<text class="t-note" x="592" y="293">is_applied</text>
<text class="t-note" x="680" y="293">tstamp</text>
<line class="dd-hair" x1="424" y1="302" x2="768" y2="302"/>
<text class="t-val" x="440" y="319">1</text><text class="t-val" x="480" y="319">0</text><text class="t-val" x="592" y="319">t</text><text class="t-val" x="680" y="319">03:34:25</text>
<line class="dd-hair" x1="424" y1="328" x2="768" y2="328"/>
<text class="t-val" x="440" y="345">2</text><text class="t-val" x="480" y="345">1</text><text class="t-val" x="592" y="345">t</text><text class="t-val" x="680" y="345">03:34:25</text>
<line class="dd-hair" x1="424" y1="354" x2="768" y2="354"/>
<text class="t-val" x="440" y="371">3</text><text class="t-val" x="480" y="371">2</text><text class="t-val" x="592" y="371">t</text><text class="t-val" x="680" y="371">03:34:25</text>
<line class="dd-hair" x1="424" y1="380" x2="768" y2="380"/>
<text class="t-val" x="440" y="397">4</text><text class="t-mono t-acc" x="480" y="397">3</text><text class="t-val" x="592" y="397">t</text><text class="t-val" x="680" y="397">03:34:25</text>
<text class="t-say" x="424" y="428">每执行一个版本追加一行，历史都在</text>
</svg>
</div>
<figcaption>图 1 · 同一个加列变更的两种写法：golang-migrate 只记“现在是哪个版本”，goose 记下执行过的每一个版本</figcaption>
</figure>

发布流程里只要在新代码启动前跑一次 `up`，每个环境都会自己补齐缺的那几个版本。开头那次事故，就是少了这一步。

其余几个工具的思路相同，差别在文件怎么分段、库里记多少、会不会校验已经执行过的文件。我们把它们都装上[^dbmate][^sql-migrate][^tern][^flyway][^liquibase]、用同一组迁移文件跑了一遍：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="gmt0-title">同类工具：文件怎么写、库里记什么</div>
<div class="dt-scroll">
<table class="dt dt-sticky" aria-labelledby="gmt0-title">
<colgroup><col style="width:16%"><col style="width:16%"><col><col><col style="width:15%"></colgroup>
<thead>
<tr><th scope="col">工具</th><th scope="col">形态</th><th scope="col">.sql 怎么写</th><th scope="col">库里记什么</th><th scope="col">已执行文件被改</th></tr>
</thead>
<tbody>
<tr><th scope="row">golang-migrate</th><td>Go 库 + 命令行</td><td>每个版本一对 <span class="mono">.up.sql</span> / <span class="mono">.down.sql</span></td><td class="warn">一行：当前版本 + dirty</td><td class="bad">不检查</td></tr>
<tr><th scope="row">goose</th><td>Go 库 + 命令行</td><td>一个文件，<span class="mono">-- +goose Up</span> / <span class="mono">Down</span> 两段</td><td>每个版本一行</td><td class="bad">不检查</td></tr>
<tr><th scope="row">dbmate</th><td>Go 写的命令行</td><td>一个文件，<span class="mono">-- migrate:up</span> / <span class="mono">-- migrate:down</span></td><td>每个版本一行</td><td class="bad">不检查</td></tr>
<tr><th scope="row">sql-migrate</th><td>Go 库 + 命令行</td><td>一个文件，<span class="mono">-- +migrate Up</span> / <span class="mono">Down</span></td><td>每个文件名一行，带执行时间</td><td class="bad">不检查</td></tr>
<tr><th scope="row">tern</th><td>Go 库 + 命令行，只支持 PostgreSQL</td><td>顺序号文件 <span class="mono">003_x.sql</span>，用 <span class="mono">---- create above / drop below ----</span> 分隔</td><td class="warn">一行：当前版本号</td><td class="bad">不检查</td></tr>
<tr><th scope="row">Atlas</th><td>Go 写的命令行</td><td>普通 .sql 加一个 <span class="mono">atlas.sum</span>；也能直接读上面几种格式</td><td>每个版本一行</td><td class="good"><span class="mono">atlas.sum</span> 哈希</td></tr>
<tr><th scope="row">ent</th><td>Go ORM</td><td>由 Go 代码定义的 schema 经 Atlas 生成 .sql，可输出成上面几种格式</td><td>取决于输出格式</td><td>同所选格式</td></tr>
<tr><th scope="row">Flyway</th><td>Java 命令行</td><td><span class="mono">V3__desc.sql</span>，只写 up</td><td>每个版本一行 + checksum</td><td class="good">checksum 校验</td></tr>
<tr><th scope="row">Liquibase</th><td>Java 命令行</td><td>changelog（SQL / XML / YAML），每段 <span class="mono">--changeset 作者:id</span></td><td>每个 changeset 一行 + MD5</td><td class="good">MD5 校验</td></tr>
<tr><th scope="row">GORM AutoMigrate</th><td>Go ORM</td><td>不写 .sql，按结构体补齐</td><td class="bad">不记录</td><td>—</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">版本：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 一行来自它的文档。</p>
</div>
<figcaption>图 2 · 十个同类工具：Go 的五个都只认文件名和版本号，不校验内容；Atlas、Flyway、Liquibase 会记下文件的哈希</figcaption>
</figure>

Go 的几个工具都把 down 写在同一个文件或成对的文件里，Flyway 社区版只写 up。ent 本身不执行迁移，它生成的文件交给 Atlas 或上面任何一种工具去跑[^ent]。还要留意许可：Liquibase 从 5.0 起社区版改用 FSL，每个版本发布两年后才转为 Apache 2.0[^liquibase-fsl]。

只记一行还是记每一行，看起来只是实现细节，后面会看到它决定了两件事：执行失败时库里留下什么，以及有文件被漏掉时能不能发现。**先看失败：一个 `.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` 变成了这样：

```text
    version     | dirty
----------------+-------
 20261020000000 | t
```

版本号已经写成了新版本，`dirty` 是 `t`。把文件改对后再 `up`，它拒绝执行：

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

为什么要这样？golang-migrate 在执行每个文件之前，先把版本号写进去并标上 dirty，执行成功再清掉[^golang-migrate-faq]。它只记一行，没法知道这个文件执行到了哪一句，所以出了错就停下来，要人去确认库里的实际结构，再用 `force` 告诉它“现在真正处于哪个版本”：

```text
$ 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 对“一次简单查询里的多条语句”默认当作一个事务，一条失败就整体回滚[^pg-protocol]。所以这里 `force` 回上一个版本是对的。

换成 MySQL 就不是这样了。MySQL 的 DDL 会隐式提交[^mysql-implicit-commit]，第一句加列已经生效，第二句失败。这时如果直接 `force` 回上一个版本再 `up`，第一句会因为列已存在而再次失败。正确的做法是先手工删掉 `refund_status`，或者 `force` 到新版本后另写一个迁移补上缺的列。这一段我们没有在 MySQL 上实测，是根据隐式提交的规则推出来的。

### goose：默认包在事务里

goose 的做法是把每个迁移包在一个事务里执行，成功才写版本记录。同一个错误文件，goose 报错后版本仍停在上一个，库里也没有半截的列，改好文件直接 `goose up` 就行，不需要 `force`。

但并不是所有 DDL 都能放进事务。PostgreSQL 的 `CREATE INDEX CONCURRENTLY` 就不行：

```text
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`）。

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="gmt1-title">迁移文件执行到一半失败时</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="gmt1-title">
<colgroup><col style="width:22%"><col><col><col></colgroup>
<thead>
<tr><th scope="col">情形</th><th scope="col">库里的结构</th><th scope="col">版本记录</th><th scope="col">怎么恢复</th></tr>
</thead>
<tbody>
<tr><th scope="row">golang-migrate · PostgreSQL</th><td class="good">整个文件回滚</td><td class="warn">写成新版本，dirty = t</td><td>改文件，<span class="mono">force</span> 回上一个版本，再 <span class="mono">up</span></td></tr>
<tr><th scope="row">golang-migrate · MySQL</th><td class="bad">前面的 DDL 已提交，留下半截</td><td class="warn">写成新版本，dirty = t</td><td>先手工处理半截结构，再 <span class="mono">force</span></td></tr>
<tr><th scope="row">goose · 默认</th><td class="good">整个迁移回滚</td><td class="good">停在上一个版本</td><td>改文件，直接 <span class="mono">up</span></td></tr>
<tr><th scope="row">goose · NO TRANSACTION</th><td class="bad">前面的语句已生效</td><td class="good">停在上一个版本</td><td>语句写成可重复执行</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">MySQL 一行是按隐式提交规则推出的，没有实测；goose 在 MySQL 上同样受隐式提交影响，事务包不住 DDL。</p>
</div>
<figcaption>图 3 · 失败后要不要人来收拾，取决于工具有没有把文件包进事务，以及数据库能不能回滚 DDL</figcaption>
</figure>

由此有了写 `.sql` 的第一条约定：**一个文件只做一件事**。语句越少，失败时越容易判断停在了哪。另一条约定是**执行过的文件不再修改**，要改就写一个新版本。不过从图 2 可以看到，Go 的这几个工具都不检查文件有没有被改过，也未必能发现另一种更隐蔽的情况：**两个人同时写迁移，会不会有一个被漏掉？**

## 三、并行分支与被漏掉的文件

### 现象：一个更早的文件被静默跳过

A 在 10 月 12 日拉了分支 `feature/channel`，写了 `20261012000000_add_channel.up.sql`，评审拖了一周。这期间 B 的 `20261015000000_add_coupon_code` 先合进主干并上了线。等 A 的分支合进来，目录里多了一个时间戳比当前版本更早的文件。

golang-migrate 的反应是：

```text
$ migrate ... up
no change
```

`channel` 列没有建出来，没有报错，也没有警告。原因就在图 1 里：`schema_migrations` 只记了一个数 `20261015000000`，`up` 的含义是“执行所有比它大的版本”，`20261012000000` 比它小，在它眼里就是已经执行过的。直到 A 的代码上线去读 `channel`，才会再报一次开头那样的错。

### goose：记了每一行，就能发现缺口

同样的场景，goose 会拒绝执行：

```text
$ 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）就和新建的库（按文件名排序）不一样了。两个变更互不相干时无所谓，相干时就可能出问题。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gm3-title gm3-desc">
<title id="gm3-title">更早的文件后合并进来</title>
<desc id="gm3-desc">迁移目录按时间戳排列：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。</desc>
<defs>
<marker id="gm3-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="420" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="148" rx="8"/>
<rect class="dd-zone" x="16" y="180" width="376" height="224" rx="8"/>
<rect class="dd-zone" x="408" y="180" width="376" height="224" rx="8"/>
<text class="t-zone" x="32" y="44">合并后的 migrations/，按文件名里的时间戳排序</text>
<line class="dd-edge" x1="56" y1="112" x2="740" y2="112" marker-end="url(#gm3-arrow)"/>
<circle class="dd-dot" cx="120" cy="112" r="6"/>
<circle class="dd-dot" cx="260" cy="112" r="6"/>
<circle class="dd-fill-acc" cx="380" cy="112" r="7"/>
<circle class="dd-dot" cx="520" cy="112" r="6"/>
<text class="t-note" x="120" y="92" text-anchor="middle">已执行</text>
<text class="t-note" x="260" y="92" text-anchor="middle">已执行</text>
<text class="t-note t-acc" x="380" y="92" text-anchor="middle">后合并进来</text>
<text class="t-note" x="520" y="92" text-anchor="middle">已执行 · 当前版本</text>
<text class="t-mono" x="120" y="140" text-anchor="middle">20261001</text>
<text class="t-mono" x="260" y="140" text-anchor="middle">20261008</text>
<text class="t-mono t-acc" x="380" y="140" text-anchor="middle">20261012</text>
<text class="t-mono" x="520" y="140" text-anchor="middle">20261015</text>
<text class="t-zone" x="32" y="208">golang-migrate v4.20.1</text>
<text class="t-zone" x="424" y="208">goose v3.29.0</text>
<rect class="dd-card" x="32" y="224" width="344" height="58" rx="6"/>
<path class="dd-card-head" d="M38 224 H370 Q376 224 376 230 V256 H32 V230 Q32 224 38 224 Z"/>
<text class="t-name" x="48" y="245">schema_migrations</text>
<text class="t-val" x="48" y="273">version 20261015000000 · dirty f</text>
<text class="t-mono" x="48" y="312">$ migrate up</text>
<text class="t-mono t-acc" x="48" y="334">no change</text>
<text class="t-val" x="48" y="356">orders.channel 不存在</text>
<text class="t-say" x="32" y="390">只执行比当前版本大的文件，20261012 被跳过</text>
<text class="t-mono" x="424" y="236">$ goose up</text>
<text class="t-val" x="424" y="258">error: found 1 missing migrations</text>
<text class="t-val" x="424" y="280">before current version 20261015000000:</text>
<text class="t-mono t-acc" x="440" y="302">version 20261012000000</text>
<text class="t-mono" x="424" y="330">$ goose up -allow-missing</text>
<text class="t-val" x="424" y="352">OK   20261012000000_add_channel.sql</text>
<text class="t-say" x="424" y="390">每个版本一行，能发现中间缺了一个</text>
</svg>
</div>
<figcaption>图 4 · 同一个后合并进来的旧文件：golang-migrate 只比较一个版本号，把它当成已执行；goose 对得出缺口，拒绝继续</figcaption>
</figure>

### 解决：让问题在合并代码时暴露

两个工具各有对策。goose 推荐“混合编号”：本地开发时用时间戳，避免两个人撞号；分支要合进主干时，在 CI 里跑 `goose fix`，把时间戳文件名改成接在最后的顺序号[^goose]。我们试了一下，它会把 `20261012000000_add_channel.sql` 改成 `00003_add_channel.sql` 这样的名字。顺序号是连续的，谁晚合并谁排在后面，就不会有“比当前版本小”的新文件。

用 golang-migrate 的团队，最省事的办法是在 CI 里加一道检查：新增的迁移文件，版本号必须大于主干上最大的那个，否则让作者重新生成文件名。

还剩“执行过的文件被修改”这个问题，Go 的五个工具都不检查。这里可以借 Atlas 的一个免费功能：它能直接读 golang-migrate、goose、dbmate 等格式的目录，`atlas migrate hash` 生成一个 `atlas.sum`，记下每个文件的哈希[^atlas-versioned]。之后谁改了已有的文件，`atlas migrate validate` 就会失败：

```text
$ 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 合并时这一行必然冲突，也顺带挡住了上面那个并行分支的问题。

八个能执行迁移的工具，在同一组场景下的表现放在一起看：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="gmt2-title">同一组场景的实测 · PostgreSQL 16</div>
<div class="dt-scroll">
<table class="dt dt-sticky" aria-labelledby="gmt2-title">
<colgroup><col style="width:18%"><col><col><col></colgroup>
<thead>
<tr><th scope="col">工具</th><th scope="col">文件第二句写错</th><th scope="col">更早的文件后合并进来</th><th scope="col">两个实例同时执行</th></tr>
</thead>
<tbody>
<tr><th scope="row">golang-migrate</th><td class="warn">整体回滚，但留下 dirty，要 <span class="mono">force</span></td><td class="bad hl">静默跳过，<span class="mono">no change</span></td><td class="good"><span class="mono">pg_advisory_lock</span> 排队</td></tr>
<tr><th scope="row">goose</th><td class="good">事务回滚，版本不动</td><td class="good">报 missing，<span class="mono">-allow-missing</span> 放行</td><td class="bad">命令行不加锁，第二个重复执行报错；代码里可加会话锁</td></tr>
<tr><th scope="row">dbmate</th><td class="good">事务回滚，版本不动</td><td class="warn">直接补执行，不提示</td><td class="warn">默认不加锁、重复执行报错；<span class="mono">--migration-lock</span> 后排队</td></tr>
<tr><th scope="row">sql-migrate</th><td class="good">事务回滚，版本不动</td><td class="warn">直接补执行，不提示</td><td class="bad">不加锁，第二个重复执行报错</td></tr>
<tr><th scope="row">tern</th><td class="good">事务回滚，版本不动</td><td class="good">编号必须连续，重号报 <span class="mono">Duplicate migration</span></td><td class="good"><span class="mono">pg_advisory_lock</span> 排队</td></tr>
<tr><th scope="row">Atlas</th><td class="good">事务回滚，版本不动</td><td class="good">报 <span class="mono">added out of order</span></td><td class="good">加锁排队</td></tr>
<tr><th scope="row">Flyway</th><td class="good">回滚，<span class="mono">Changes successfully rolled back</span></td><td class="good">默认拒绝，<span class="mono">outOfOrder</span> 放行</td><td class="good">加锁排队</td></tr>
<tr><th scope="row">Liquibase</th><td class="good">回滚，不记录该 changeset</td><td class="warn">直接补执行，不提示</td><td class="good">锁表，<span class="mono">Waiting for changelog lock</span></td></tr>
</tbody>
</table>
</div>
<p class="dt-note">ent 和 GORM 不在表里：ent 生成的文件交给上面某个工具执行，GORM AutoMigrate 没有版本的概念。Atlas 还能由期望的 schema 自动算出 .sql，但它只比较结构：实验里把列改名，生成的是 DROP COLUMN 加 ADD COLUMN，执行成功后原来 1000 行数据全变成了 NULL，自动生成的文件同样要人逐行看。“回滚”都是 PostgreSQL 上的结果，MySQL 的 DDL 会隐式提交，回滚不了。</p>
</div>
<figcaption>图 5 · 八个工具在同一组场景下的实测：失败时几乎都能干净回滚，差别集中在“旧文件后合并”和“多实例并发”两列</figcaption>
</figure>

到这里，一个 `.sql` 文件该怎么写、出错怎么收拾、并行开发怎么防漏，都有了答案。**可是它最终要在生产库上执行，这件事由谁、在发布流程的哪一步来做？**

## 四、一次变更的完整流程

### 从提交到生产要经过哪几步

把前面的约定串起来，一个 `.sql` 文件从写下来到在生产库生效，要走六步。我们跟着 `20261015000000_add_coupon_code.up.sql` 走一遍。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 464" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gm5-title gm5-desc">
<title id="gm5-title">一个 .sql 文件从提交到生产</title>
<desc id="gm5-desc">六步：写 up 和 down 两个文件；CI 里用 squawk、atlas migrate validate 检查，并在一次性 PostgreSQL 上跑 up、down、up；用 go:embed 和代码打进同一个镜像；部署时先执行迁移，加 advisory lock，版本变成 20261015000000；再滚动发布代码；下一个版本才做删除旧列这类收尾。</desc>
<defs>
<marker id="gm5-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="gm5-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="464" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="192" rx="8"/>
<rect class="dd-zone" x="16" y="224" width="768" height="224" rx="8"/>
<text class="t-zone" x="32" y="44">提交到合并 · 开发者和 CI</text>
<text class="t-zone" x="32" y="252">部署 · 生产环境</text>
<line class="dd-edge-acc" x1="256" y1="124" x2="286" y2="124" marker-end="url(#gm5-arrow-acc)"/>
<line class="dd-edge-acc" x1="512" y1="124" x2="542" y2="124" marker-end="url(#gm5-arrow-acc)"/>
<path class="dd-edge-acc" d="M656 192 V208 Q656 216 648 216 H152 Q144 216 144 224 V266" marker-end="url(#gm5-arrow-acc)"/>
<line class="dd-edge-acc" x1="256" y1="336" x2="286" y2="336" marker-end="url(#gm5-arrow-acc)"/>
<line class="dd-edge dd-dash" x1="512" y1="336" x2="542" y2="336" marker-end="url(#gm5-arrow)"/>
<rect class="dd-card" x="32" y="56" width="224" height="136" rx="6"/>
<text class="t-name" x="48" y="80">① 写 up / down 两个文件</text>
<text class="t-mono t-acc" x="48" y="106">20261015000000_</text>
<text class="t-mono t-acc" x="48" y="128">add_coupon_code.up.sql</text>
<text class="t-val" x="48" y="150">set lock_timeout = '2s';</text>
<text class="t-val" x="48" y="172">add column coupon_code</text>
<rect class="dd-card" x="288" y="56" width="224" height="136" rx="6"/>
<text class="t-name" x="304" y="80">② CI 检查</text>
<text class="t-mono" x="304" y="106">squawk *.up.sql</text>
<text class="t-mono" x="304" y="128">atlas migrate validate</text>
<text class="t-mono" x="304" y="150">up → down → up</text>
<text class="t-note" x="304" y="172">在一次性的 PG 容器里跑</text>
<rect class="dd-card" x="544" y="56" width="224" height="136" rx="6"/>
<text class="t-name" x="560" y="80">③ 和代码打进同一个镜像</text>
<text class="t-mono" x="560" y="106">//go:embed</text>
<text class="t-mono" x="560" y="128">migrations/*.sql</text>
<text class="t-mono" x="560" y="150">shop:1.8.0</text>
<text class="t-note" x="560" y="172">代码和 .sql 版本绑在一起</text>
<rect class="dd-card" x="32" y="268" width="224" height="136" rx="6"/>
<text class="t-name" x="48" y="292">④ 先执行迁移</text>
<text class="t-mono" x="48" y="318">shop migrate up</text>
<text class="t-mono" x="48" y="340">pg_advisory_lock</text>
<text class="t-note" x="48" y="362">版本变成</text>
<text class="t-mono t-acc" x="48" y="384">20261015000000</text>
<rect class="dd-card" x="288" y="268" width="224" height="136" rx="6"/>
<text class="t-name" x="304" y="292">⑤ 再滚动发布代码</text>
<text class="t-mono" x="304" y="318">1.7.0 → 1.8.0</text>
<text class="t-note" x="304" y="340">逐个替换实例</text>
<text class="t-note" x="304" y="362">旧代码看不见新列，不受影响</text>
<text class="t-note" x="304" y="384">新代码上线就能读到</text>
<rect class="dd-derived" x="544" y="268" width="224" height="136" rx="6"/>
<text class="t-name" x="560" y="292">⑥ 下一个版本再收尾</text>
<text class="t-mono" x="560" y="318">contract 的 .sql</text>
<text class="t-mono" x="560" y="340">drop column …</text>
<text class="t-note" x="560" y="362">确认没有旧代码</text>
<text class="t-note" x="560" y="384">还在读写旧列以后</text>
<text class="t-say" x="32" y="432">虚线框：不在这次发布里，等旧代码全部下线后的下一个版本才做</text>
</svg>
</div>
<figcaption>图 6 · 一次变更的完整路径：.sql 在合并前被检查和演练，和代码一起打包，部署时先于代码执行，破坏性的部分留到下一个版本</figcaption>
</figure>

**① 写文件。** up 和 down 各一个，一个文件只做一件事。会拿表锁的语句前面先写 `set lock_timeout`（第五节会讲为什么）。

**② CI 检查。** 三道检查各管一件事。第一道是静态检查，PostgreSQL 可以用 Squawk[^squawk]，它会逐条指出危险的写法。比如给它一个同时改列类型、建普通索引、加列的文件，它报了 7 条，其中最关键的几条是：

```text
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 也是同样的结果）：

```text
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 都在抢同一把锁。

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="gmt3-title">第 ④ 步放在哪</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="gmt3-title">
<colgroup><col style="width:24%"><col><col></colgroup>
<thead>
<tr><th scope="col">方式</th><th scope="col">好处</th><th scope="col">要注意</th></tr>
</thead>
<tbody>
<tr><th scope="row" class="acc">流水线里单独一步 / K8s Job</th><td class="good">只执行一次；失败就不发布；迁移日志单独可查</td><td>要编排“迁移成功后才更新 Deployment”</td></tr>
<tr><th scope="row">应用启动时执行</th><td>不用额外编排</td><td class="warn">必须加锁；迁移慢时实例迟迟不就绪，可能被健康检查杀掉</td></tr>
<tr><th scope="row">initContainer</th><td>和应用同一个镜像</td><td class="warn">同上，副本越多，抢锁的越多</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">三种方式都要求迁移本身向后兼容：迁移执行完到旧实例全部替换之前，旧代码一直在用新的 schema。</p>
</div>
<figcaption>图 7 · 迁移放在哪执行，决定了它跑几次、失败时会不会拦住发布</figcaption>
</figure>

流程搭好以后，开头那种“忘了执行”的事故不会再发生。但还有一类事故，流程本身挡不住：**在一张正在被频繁读写的大表上，第 ④ 步执行一条看起来很快的 `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`，谁在等谁一目了然：

```text
 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 EXCLUSIVE[^pg-altertable]，它和所有锁都冲突，包括普通 `SELECT` 拿的 ACCESS SHARE。A 的事务还没结束，B 只能排队。关键在于锁请求按先来后到排队：C 想要的锁虽然和 A 不冲突，但和排在前面的 B 冲突，所以也排在后面。之后进来的每一个查询都一样，表实际上已经“停了”，而那条 ALTER 本身一行数据都还没碰。

GoCardless 写过一次一模一样的事故[^gocardless]：迁移只改了几张空表，事后在备份上重放只要几百毫秒，生产上却让 API 超时了约 15 秒，时间全花在排队等锁上。MySQL 也有同样的问题，那里叫元数据锁（MDL），后面的查询会全部显示 `Waiting for table metadata lock`[^mysql-online-ddl]。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 488" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="dbm5-title dbm5-desc">
<title id="dbm5-title">锁队列时间线</title>
<desc id="dbm5-desc">上半部分不设 lock_timeout：A 持有读锁 8 秒，B 的 ALTER 从第 1 秒排队到第 8 秒，C 的主键查询从第 2 秒排在 B 后面，等了 6.08 秒。下半部分设 lock_timeout 为 2 秒：B 在第 3 秒放弃并报 lock timeout，C 随即返回，只等了 1.02 秒。</desc>
<defs>
<marker id="dbm5-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="dbm5-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="488" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="220" rx="8"/>
<rect class="dd-zone" x="16" y="252" width="768" height="220" rx="8"/>
<text class="t-zone" x="32" y="44">不设 lock_timeout</text>
<text class="t-say" x="440" y="44">虚线框：在排队等锁，还没开始执行</text>
<text class="t-zone" x="32" y="280">SET lock_timeout = '2s'</text>
<text class="t-name" x="32" y="83">A 报表事务</text>
<text class="t-name" x="32" y="127">B 迁移 ALTER</text>
<text class="t-name" x="32" y="171">C 主键查询</text>
<rect class="dd-card" x="176" y="64" width="512" height="28" rx="4"/>
<text class="t-note" x="188" y="83">count(*) 之后不提交，ACCESS SHARE 一直持有</text>
<rect class="dd-derived" x="240" y="108" width="453" height="28" rx="4"/>
<text class="t-note" x="252" y="127">排队等 ACCESS EXCLUSIVE</text>
<text class="t-mono" x="700" y="127">7.08 s</text>
<rect class="dd-derived" x="304" y="152" width="389" height="28" rx="4"/>
<text class="t-note t-acc" x="316" y="171">和 A 不冲突，却排在 B 后面</text>
<text class="t-mono t-acc" x="700" y="171">6.08 s</text>
<line class="dd-edge dd-dash" x1="240" y1="106" x2="240" y2="96" marker-end="url(#dbm5-arrow)"/>
<line class="dd-edge-acc dd-dash" x1="304" y1="150" x2="304" y2="140" marker-end="url(#dbm5-arrow-acc)"/>
<line class="dd-edge" x1="176" y1="196" x2="752" y2="196"/>
<text class="t-note" x="176" y="216" text-anchor="middle">0</text>
<text class="t-note" x="240" y="216" text-anchor="middle">1</text>
<text class="t-note" x="304" y="216" text-anchor="middle">2</text>
<text class="t-note" x="368" y="216" text-anchor="middle">3</text>
<text class="t-note" x="432" y="216" text-anchor="middle">4</text>
<text class="t-note" x="496" y="216" text-anchor="middle">5</text>
<text class="t-note" x="560" y="216" text-anchor="middle">6</text>
<text class="t-note" x="624" y="216" text-anchor="middle">7</text>
<text class="t-note" x="688" y="216" text-anchor="middle">8 s</text>
<text class="t-name" x="32" y="319">A 报表事务</text>
<text class="t-name" x="32" y="363">B 迁移 ALTER</text>
<text class="t-name" x="32" y="407">C 主键查询</text>
<rect class="dd-card" x="176" y="300" width="512" height="28" rx="4"/>
<text class="t-note" x="188" y="319">同上，ACCESS SHARE 持有 8 s</text>
<rect class="dd-derived" x="240" y="344" width="130" height="28" rx="4"/>
<text class="t-mono" x="380" y="363">ERROR: canceling statement due to lock timeout</text>
<rect class="dd-derived" x="304" y="388" width="65" height="28" rx="4"/>
<text class="t-mono t-acc" x="380" y="407">1.02 s 返回</text>
<line class="dd-edge dd-dash" x1="240" y1="342" x2="240" y2="332" marker-end="url(#dbm5-arrow)"/>
<line class="dd-edge-acc dd-dash" x1="304" y1="386" x2="304" y2="376" marker-end="url(#dbm5-arrow-acc)"/>
<line class="dd-edge" x1="176" y1="432" x2="752" y2="432"/>
<text class="t-note" x="176" y="452" text-anchor="middle">0</text>
<text class="t-note" x="240" y="452" text-anchor="middle">1</text>
<text class="t-note" x="304" y="452" text-anchor="middle">2</text>
<text class="t-note" x="368" y="452" text-anchor="middle">3</text>
<text class="t-note" x="432" y="452" text-anchor="middle">4</text>
<text class="t-note" x="496" y="452" text-anchor="middle">5</text>
<text class="t-note" x="560" y="452" text-anchor="middle">6</text>
<text class="t-note" x="624" y="452" text-anchor="middle">7</text>
<text class="t-note" x="688" y="452" text-anchor="middle">8 s</text>
</svg>
</div>
<figcaption>图 8 · 主键查询等的不是那个慢事务，而是排在它前面的 ALTER；给 ALTER 设上等锁时限，最坏情况就从“等慢事务结束”变成“等 2 秒”</figcaption>
</figure>

### 解决：在 .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` 解决的是“等锁”。拿到锁以后还要看“持锁多久”：只改元数据的操作几毫秒就放锁，要重写整张表的操作，会在持锁期间把每一行都改写一遍。

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="dbmt2-title">常见 DDL 的代价 · 本机 PostgreSQL 16，orders 200 万行</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="dbmt2-title">
<colgroup><col style="width:24%"><col><col><col style="width:14%"></colgroup>
<thead>
<tr><th scope="col">操作</th><th scope="col">PostgreSQL</th><th scope="col">MySQL 8.0（InnoDB）</th><th scope="col">实测</th></tr>
</thead>
<tbody>
<tr><th scope="row">加列（可空，或带常量默认值）</th><td class="good">只改元数据（11 起）</td><td class="good">INSTANT（8.0.12 起）</td><td class="mono num">4.3 ms</td></tr>
<tr><th scope="row">删列</th><td class="good">只标记为不可见</td><td class="good">INSTANT（8.0.29 起）</td><td class="mono num">0.7 ms</td></tr>
<tr><th scope="row">改列类型 int → bigint</th><td class="bad">重写整表和索引，全程 ACCESS EXCLUSIVE</td><td class="bad">只能 COPY，期间不允许写</td><td class="mono num bad hl">3207 ms</td></tr>
<tr><th scope="row">加索引</th><td><span class="mono">CREATE INDEX CONCURRENTLY</span> 不阻塞读写</td><td>INPLACE，允许并发读写</td><td class="mono num">803 ms</td></tr>
<tr><th scope="row">加 NOT NULL</th><td class="warn">全表扫描；大表先加 <span class="mono">CHECK … NOT VALID</span> 再 <span class="mono">VALIDATE</span></td><td class="warn">INPLACE，要重建表</td><td class="mono num">165 ms</td></tr>
<tr class="dt-group"><th colspan="4" scope="rowgroup">共同点：每一种都要先拿到表上的排他锁，等锁那一段就是图 8 的风险</th></tr>
</tbody>
</table>
</div>
<p class="dt-note">MySQL 一列来自官方 Online DDL 文档，没有在本机实测；INSTANT 增删列每张表最多累计 64 次，之后要重建表。</p>
</div>
<figcaption>图 9 · 加列、删列、加索引都有不重写表的做法，改列类型是少数绕不过去的“整表重写”</figcaption>
</figure>

200 万行改一次类型要 3.2 秒，这 3.2 秒里所有读写都排在后面。在 MySQL 上，这类必须重写整表的变更通常不放进迁移文件，而是交给 gh-ost 这样的在线变更工具单独执行：它建一张改好结构的影子表，分块拷贝旧行，读 binlog 追上拷贝期间的新写入，最后原子地交换表名[^ghost-readme]。在 PostgreSQL 上，更常见的做法是把它拆成几个不重写表的小步，也就是下一节要讲的模式。**而拆成几步以后，就要面对一个新问题：代码和 schema 不可能在同一瞬间切换。**

## 六、跨版本的变更：expand / contract

### 现象：新旧代码同时在线

第四节的第 ⑤ 步里，滚动发布期间旧版本和新版本同时在线，读写同一个库。加列没问题，因为旧代码看不见新列。改列名就不一样了。假设要把 `amount` 改名为 `total_amount`，迁移一执行，还没下线的旧代码立刻出错：

```text
ERROR:  column "amount" does not exist
```

先发代码也不行，新代码会找不到 `total_amount`。一条 `RENAME` 要求代码和 schema 在同一瞬间切换，而滚动发布恰恰做不到“同一瞬间”。

### 解决：把一次切换拆成几个版本

办法是让新旧两种 schema 有一段时间同时成立。这个模式叫 parallel change，也叫 expand / contract[^parallel-change]：先扩展（expand），让新旧两种用法都能工作；再迁移使用方；最后收缩（contract），去掉旧的那一半。落到 `.sql` 文件和发布上，是三个版本：

- **版本 N** 带一个 expand 的迁移文件：加列 `total_amount`，再加一个触发器，插入或更新时哪一列是空的就用另一列补上。代码不变。
- 版本 N 上线后，用一个**单独的回填脚本**把旧数据补进新列。回填不放进迁移文件：它要跑很久，放进迁移会让第 ④ 步长时间占着锁、拖住发布。
- **版本 N+1** 只改代码：读写 `total_amount`。
- **版本 N+2** 带一个 contract 的迁移文件：`set not null`、删触发器、删 `amount`。这就是图 6 里那个虚线框。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="dbm8-title dbm8-desc">
<title id="dbm8-title">用 expand/contract 改列名</title>
<desc id="dbm8-desc">五个阶段：expand 加列和触发器，回填 40 批共 18.4 秒，发布 v2 时 v1 与 v2 共存，v1 下线后只剩 v2，contract 删触发器和旧列。amount 列和同步触发器从开始存在到 contract；total_amount 列从 expand 开始，回填后有数据；应用 v1 只认识 amount，v2 只认识 total_amount，第三阶段两者同时在线。</desc>
<rect class="dd-frame" width="800" height="420" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="388" rx="8"/>
<text class="t-zone" x="32" y="44">把 amount 改名为 total_amount 的五步</text>
<rect class="dd-bar" x="422" y="252" width="115" height="80"/>
<line class="dd-hair" x1="307" y1="112" x2="307" y2="332"/>
<line class="dd-hair" x1="422" y1="112" x2="422" y2="332"/>
<line class="dd-hair" x1="537" y1="112" x2="537" y2="332"/>
<line class="dd-hair" x1="652" y1="112" x2="652" y2="332"/>
<line class="dd-hair" x1="32" y1="248" x2="768" y2="248"/>
<rect class="dd-card" x="196" y="56" width="107" height="48" rx="6"/>
<text class="t-name" x="249" y="76" text-anchor="middle">① expand</text>
<text class="t-note" x="249" y="96" text-anchor="middle">版本 N 的 .sql</text>
<rect class="dd-card" x="311" y="56" width="107" height="48" rx="6"/>
<text class="t-name" x="364" y="76" text-anchor="middle">② 回填</text>
<text class="t-note" x="364" y="96" text-anchor="middle">单独的脚本</text>
<rect class="dd-card" x="426" y="56" width="107" height="48" rx="6"/>
<text class="t-name" x="479" y="76" text-anchor="middle">③ 发布 v2</text>
<text class="t-note" x="479" y="96" text-anchor="middle">版本 N+1 的代码</text>
<rect class="dd-card" x="541" y="56" width="107" height="48" rx="6"/>
<text class="t-name" x="594" y="76" text-anchor="middle">④ v1 下线</text>
<text class="t-note" x="594" y="96" text-anchor="middle">只剩 v2</text>
<rect class="dd-card" x="656" y="56" width="112" height="48" rx="6"/>
<text class="t-name" x="712" y="76" text-anchor="middle">⑤ contract</text>
<text class="t-note" x="712" y="96" text-anchor="middle">版本 N+2 的 .sql</text>
<text class="t-name" x="32" y="149">amount 列</text>
<text class="t-name" x="32" y="189">total_amount 列</text>
<text class="t-name" x="32" y="229">同步触发器</text>
<text class="t-name" x="32" y="277">应用 v1</text>
<text class="t-name" x="32" y="317">应用 v2</text>
<rect class="dd-card" x="192" y="132" width="460" height="24" rx="4"/>
<text class="t-note" x="204" y="149">旧代码读写的列</text>
<text class="t-mono" x="664" y="149">DROP</text>
<rect class="dd-derived" x="192" y="172" width="230" height="24" rx="4"/>
<text class="t-note" x="204" y="189">新列，NULL → 逐批回填</text>
<rect class="dd-card" x="422" y="172" width="346" height="24" rx="4"/>
<rect class="dd-fill-acc" x="422" y="192" width="346" height="4"/>
<text class="t-note t-acc" x="434" y="189">已回填，和 amount 一致</text>
<text class="t-mono t-acc" x="664" y="189">NOT NULL</text>
<rect class="dd-card" x="192" y="212" width="460" height="24" rx="4"/>
<text class="t-note" x="204" y="229">写任意一列，自动补上另一列</text>
<rect class="dd-card" x="192" y="260" width="345" height="24" rx="4"/>
<text class="t-note" x="204" y="277">只认识 amount</text>
<rect class="dd-card" x="422" y="300" width="346" height="24" rx="4"/>
<text class="t-note t-acc" x="434" y="317">只认识 total_amount</text>
<text class="t-say" x="32" y="360">灰底是新旧代码同时在线的那一段：库里两列都在、互相同步，两边都能工作</text>
<text class="t-say" x="32" y="384">直接 RENAME 等于把五步压成一瞬间，v1 立刻报 column "amount" does not exist</text>
</svg>
</div>
<figcaption>图 10 · expand/contract 让每个时刻的库都同时满足正在运行的所有代码版本，改名被拆成五个各自安全的小步</figcaption>
</figure>

我们在 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 同时可用，等旧代码全部下线后才真正删列[^pgroll]。

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

## 七、down 文件与回滚

golang-migrate 建议每个迁移都写 down[^golang-migrate-files]，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 把不可逆的删除推到最后。

## 参考资料
