数据库迁移不能直接执行 alter table,因其易触发表锁导致查询阻塞,且存在 schema 与代码不同步引发 panic 的风险;应使用 goose 或 migrate 等版本化工具,配合时间戳命名、单职责迁移文件、启动前版本校验、双写渐进式适配及独立连接池等实践保障零停机。

数据库迁移为什么不能直接执行 ALTER TABLE
在 Golang 服务运行中直接执行 ALTER TABLE(尤其是加索引、改字段类型、重命名列)极易触发表锁,导致查询阻塞甚至超时。MySQL 5.6+ 虽支持 ALGORITHM=INPLACE,但并非所有操作都免锁;PostgreSQL 的某些变更(如 ADD COLUMN ... NOT NULL)仍需全表扫描。更麻烦的是,迁移脚本若与代码逻辑不同步(比如新字段已在 Go struct 中定义,但 DB 还没加),sql.Scan 会直接 panic。
用 goose 或 migrate 管理版本化迁移
选一个轻量、可嵌入的 CLI 工具比手写 SQL 更可控。goose 支持多数据库、迁移回滚、状态检查;migrate 更成熟,对 PostgreSQL 的 CONCURRENTLY 索引创建有显式支持。关键不是工具本身,而是必须做到:
- 所有迁移文件名带时间戳前缀(如
202405121430_add_user_status.up.sql),避免并发部署时顺序错乱 - 每个
.up.sql文件只做一件事(例如仅加字段,不同时加索引),便于定位失败点 - Go 启动时调用
migrate.Up()或goose.Up()前,先检查当前 DB 版本是否落后于代码期望版本(可通过goose status或migrate version获取) - 禁止在迁移中写业务逻辑(比如“迁移时把旧数据转成新格式”),这类操作应放在应用层、分批处理,并打上
TODO: migrate-data注释提醒后续清理
零停机迁移的关键:双写 + 渐进式读取
真正平滑的核心不在工具,而在代码如何适配过渡期。比如要把 user.name 拆成 user.first_name 和 user.last_name:
- 第一阶段:新增字段,保留旧字段,写操作同时更新三者(
name、first_name、last_name) - 第二阶段:读逻辑优先取新字段,回退到旧字段(
if first_name == "" { return name }),并启动后台 Job 把存量数据补全 - 第三阶段:确认补全完成、无写旧字段路径后,删掉旧字段和兼容逻辑
这个过程必须靠 Go 代码控制,而不是指望迁移工具自动处理。每次 deploy 前要确认 DB schema 和 Go struct 的字段映射关系是否仍兼容——sql.NullString 和指针字段的零值行为差异常被忽略。
迁移失败时怎么快速恢复
线上迁移卡住最怕盲目重试。实际做法是:
- 所有迁移命令加
--dry-run参数预检(goose -dir migrations status可看下一步该跑哪个) - 生产环境迁移必须走独立连接池(不要复用主应用的
*sql.DB),避免锁竞争影响在线请求 - 如果
up失败,立刻执行down(前提是.down.sql写得正确),而不是手动删表或改 schema - 记录每条迁移的耗时和行数(比如
UPDATE users SET status = 'active' WHERE created_at 影响 200 万行?那得拆成 1 万一批 + <code>time.Sleep(100 * time.Millisecond))
最难的从来不是“怎么跑完迁移”,而是“怎么让 Go 代码在字段增减、类型变更、索引重建期间不崩”。schema 变更是常态,代码适配才是真正的平滑所在。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











