1. 为什么数据库初始化/更新不能靠“手搓”
1.1 从一次线上“炸库”事故说起
先说个真实经历。某次我负责一个Go写的内部工单系统,开发阶段的数据库表结构每天都在变。当时团队人少,大家约定“谁改表结构谁就在群里喊一声,然后自己连上测试库执行SQL”。听起来没什么问题是吧?直到有一次,一位小伙伴在本地库加了一个deleted_at字段,群里倒是喊了,但生产环境和测试环境执行的细节不一样——测试环境加了DEFAULT NULL,生产环境建的是NOT NULL。上线后,老数据的deleted_at全部是零值,接口直接报错,一堆工单查不出来。
那次事故之后我就明白了一个道理:数据库结构的管理,必须和代码一样,有版本、有记录、有回滚手段,不能依赖人的记性和自觉。尤其是Go项目,编译出来就是一个二进制文件,部署时数据库变更还得单独拉一份SQL去手工执行,这完全违背了“一键部署”的思路。所以我开始认真调研Go生态里做 schema 初始化/更新的方案,也在项目里踩了不少坑,这篇文章就是把整个过程记录一下。
如果你也在写Go服务,或者你正在维护一个表结构经常变的小项目,这篇文章会比较适合你。文章不会讲太深奥的原理,重点是怎么把“初始化数据库”和“后续更新字段/索引”这两件事做得规范、可重复、可回滚。
1.2 方案选型:三选一背后的取舍逻辑
我先花点时间说说方案选型,因为很多人一上来就搜“go 数据库迁移”,结果被一堆库搞晕。我当时调研了三个主流方向,用下来的感受完全不同。
第一个是社区讨论度很高的golang-migrate。它支持文件形式的SQL迁移(.up.sql和.down.sql),也支持直接写Go代码做迁移;数据库方面覆盖面很广,PostgreSQL、MySQL、SQLite、ClickHouse 这些都支持。更关键的是,它支持go:embed,可以把迁移文件直接编译进二进制,部署的时候不用单独带SQL文件。
第二个是dbmate。它更偏向“数据库运维工具”这个定位,用起来有点类似 Rails 的 migration 体验,支持自动生成迁移文件模板、执行迁移、回滚、查看状态,代码量不大,也有很多人在用。
第三个是GORM AutoMigrate。如果你整个项目就是用 GORM 操作数据库,那这个方案最省事——它根据你定义的 model 结构体自动帮你建表、加字段。但它的问题也很明显:它只管“向前加”,不管“向后缩”。字段改名、删除字段、加复合索引这类操作,AutoMigrate 经常没法处理或处理得很隐蔽;更别提数据回填了,它完全做不了。
我当时的选择是golang-migrate,原因是它完美契合我下面这几个需求:
- 迁移文件是纯SQL,review代码的时候能直接看到数据库到底怎么变;
- 支持 down 迁移,紧急回滚时有退路;
- 迁移记录写到数据库里(
schema_migrations表),多个人、多个环境跑都不会重复执行; - 能
go:embed进二进制,部署时少一个外部依赖。
下表是三个方案在我心中的横向对比,方便你按项目情况选:
| 对比维度 | golang-migrate | dbmate | GORM AutoMigrate |
|---|---|---|---|
| 迁移文件形式 | SQL或Go代码 | SQL | 无(根据model生成) |
| 回滚支持 | 支持(down文件) | 支持(rollback) | 不支持 |
| 数据回填 | 自己写SQL | 自己写SQL | 不支持 |
| 嵌入二进制 | 支持(go:embed) | 需要在容器中带文件 | 无需文件 |
| 学习成本 | 中等 | 较低 | 最低 |
| 适合场景 | 团队协作、多环境、生产环境 | 小团队、快速起步 | 原型项目、单人项目 |
选型这件事我多说一句:如果项目刚起步,表结构可能天天变,用 GORM AutoMigrate 做“开发期的豆腐块工程”可以,但上线前一定要换成文件形式的迁移方案。我见过不止一个项目因为依赖 AutoMigrate,到了生产环境发现有两张表的字段顺序和测试环境完全不一样,查了半天查不出原因,最后只能手工改库。到那时你宁可当初多花半天时间把迁移体系搭好。
2. 迁移文件的设计与组织
2.1 从目录结构到命名规范:为什么这么较真
用 golang-migrate 这类工具时,迁移文件本身就是一个项目的“数据库时间线”。目录结构我习惯这么组织:
migrations/ ├── 000001_create_users_table.up.sql ├── 000001_create_users_table.down.sql ├── 000002_add_orders_table.up.sql ├── 000002_add_orders_table.down.sql ├── 000003_add_user_email_index.up.sql ├── 000003_add_user_email_index.down.sql └── 000004_seed_initial_configs.up.sql └── 000004_seed_initial_configs.down.sql文件名的格式是固定的:{版本号}_{描述}.{up|down}.sql。版本号必须是从000001开始的连续整数,前面补零是为了排序时按字符串比较也能得到正确顺序。描述部分是给人看的,尽量用能表达意图的短横线拼接短语,比如add_orders_table、create_users_table。
为什么要这么较真?因为迁移文件的执行顺序完全依赖版本号的字典序。如果哪天你写了个000010,和000009放一起时,字典序会把它排在000002前面,到时候顺序就全乱了,这是最隐蔽的一个坑。
每个迁移文件必须成对出现:一个 up(执行正向迁移)、一个 down(回滚)。down 不一定是 up 的逆操作,它的目的是“把数据库恢复到这一步之前的状态”。比如000002建了 orders 表,那down文件就是DROP TABLE IF EXISTS orders;。但如果是给表加字段,up 是ALTER TABLE xxx ADD COLUMN yyy ...;,down 就是ALTER TABLE xxx DROP COLUMN yyy;。
这里有个细节:不要把多个逻辑无关的变更塞进同一个迁移文件。比如在000005里同时改了 users 表和 orders 表,一旦线上执行失败,dirty 状态出现,回滚时你脑子里要同时记两件事,很容易出错。迁移文件应该保持单一职责,一个文件只做一件表结构相关的事情。
1.2 版本表:数据库如何记住自己“迁移到哪了”
golang-migrate 首次运行时会自动创建一张版本表,名字默认是schema_migrations。这张表结构很简单,核心就两列:version(迁移版本号)和dirty(是否脏状态)。
每次执行迁移时,工具会先查询这张表,找到当前数据库所处版本;然后按顺序把大于当前版本的 up 文件逐个执行。每成功执行一个迁移,就把版本号写进版本表。执行 down 时反过来:从当前版本开始,先执行 down 文件,再从版本表里删掉对应版本记录。
为什么要特别关注这张表?因为dirty状态是工具保护你的一个机制。如果一个迁移脚本执行到一半失败了,工具会把版本表里的 dirty 标记为 true,下次再跑任何迁移命令时,它不会继续往下走,而是提示你先处理脏状态。这个时候你不能直接再执行一次 migrate,因为不知道上一条 SQL 到底执行到哪一步了,重复执行很可能造成副作用(比如重复插入数据、重复加索引)。
处理脏状态的正确姿势是:先打开数据库,看schema_migrations表里记录的 version 是多少,再手动对比迁移文件内容,判断失败的那条 SQL 是否已经部分执行。如果只是执行了一半,需要手工把剩余 SQL 补齐,然后手动把 dirty 标记改成 false(或者用工具提供的 force 命令)。这一步非常考验细心程度,实际生产环境里大部分“迁移卡住”的问题都是这么来的。
3. 从零到上线:初始化与更新的完整实现
3.1 第一步:数据库连接与配置项的准备工作
在写迁移逻辑之前,得先把数据库连接搞定。Go 语言里最底层、最通用的数据库接口是database/sql,配合对应数据库的 driver 使用。生产项目里基本不会直接用 ORM 来做连接,而是先拿sql.DB,再在上面跑迁移。
我一般会在项目里单独建一个配置文件,比如config.yaml,内容大致如下:
database: host: 127.0.0.1 port: 5432 user: app_user password: ${DB_PASSWORD} dbname: myapp sslmode: require max_open_conns: 50 max_idle_conns: 10 conn_max_lifetime: 1h然后在代码里读取并拼装 DSN(Data Source Name),用sql.Open打开连接。拿 PostgreSQL 举例:
func openDB(cfg *config.Database) (*sql.DB, error) { dsn := fmt.Sprintf( "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode, ) db, err := sql.Open("postgres", dsn) if err != nil { return nil, err } db.SetMaxOpenConns(cfg.MaxOpenConns) db.SetMaxIdleConns(cfg.MaxIdleConns) db.SetConnMaxLifetime(cfg.ConnMaxLifetime) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := db.PingContext(ctx); err != nil { return nil, err } return db, nil }这里有个经验:连接参数不要照抄网上的默认值。MaxOpenConns设置太大,数据库连接数一多,连接池和数据库端都会出现性能问题;设置太小,高并发下请求会排队。我这边一个中等流量的服务,MaxOpenConns设置在 50 左右,MaxIdleConns10,压测下来比较稳定。如果你的服务依赖数据库非常频繁,需要结合实际压测慢慢调,没有一劳永逸的参数。
3.2 初始化数据库:建库、建表与基础数据一气呵成
这里要区分两个概念:创建数据库实例本身和创建数据库内部的表结构。
golang-migrate 这类工具默认不负责“创建数据库”。比如你要连的库叫myapp,但它现在还不存在,那连接就失败。所以第一步一般是在代码里先连到一个不会不存在的库,比如 PostgreSQL 的postgres库或者template1,然后执行CREATE DATABASE myapp;。这一步可以手工执行,也可以放到部署脚本里,但我不建议用代码自动创建——因为这涉及权限控制,生产环境通常不允许应用账号拥有创建数据库的权限。
库建好之后,再连到myapp库,跑迁移,把所有表建出来。我的做法是写一个Makefile或 shell 脚本,把“建库、迁移、启动服务”串起来。迁移执行的 Go 代码大致长这样:
import ( "log/slog" "net/url" "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database/postgres" _ "github.com/golang-migrate/migrate/v4/source/file" ) func runMigrations(db *sql.DB, migrationsPath string) error { driver, err := postgres.WithInstance(db, &postgres.Config{}) if err != nil { return err } m, err := migrate.NewWithDatabaseInstance( "file://"+migrationsPath, "postgres", driver, ) if err != nil { return err } if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { return err } slog.Info("database migrations applied") return nil }第一张表的迁移文件我通常这么写(000001_create_users_table.up.sql):
CREATE TABLE IF NOT EXISTS users ( id BIGSERIAL PRIMARY KEY, username VARCHAR(64) NOT NULL UNIQUE, email VARCHAR(128) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, status SMALLINT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() );对应 down 文件:
DROP TABLE IF EXISTS users;注意我在 up 文件里写了IF NOT EXISTS,这不是必须的(工具本身会保证只执行一次),但多写这个条件,能让你在调试时手工执行文件也不容易误伤已有的表。
还有个很容易被忽略的点:如果在初始化阶段就要写入一些基础配置数据(比如系统默认角色、管理员账号),不要直接往第一个建表迁移文件里硬塞 INSERT。把建表和种子数据分开,放在后面的迁移文件里(比如000004_seed_initial_configs),这样如果种子数据有问题,你可以单独回滚那一步,而不影响前面的表结构。种子数据迁移文件里,up 是INSERT ... ON CONFLICT DO NOTHING,down 则删除这些数据,前提是要有明确的筛选条件,千万别写无脑的DELETE FROM xxx;。
3.3 增量更新的正确姿势:加字段、改索引、数据回填
项目上线后,表结构更新就是常规操作了。大部分“更新”无非三类:加字段、加索引、给已有数据做回填。这三类我分开说说具体写法。
第一种:加字段。
up 文件:
ALTER TABLE users ADD COLUMN nickname VARCHAR(64) NOT NULL DEFAULT '';down 文件:
ALTER TABLE users DROP COLUMN nickname;这里有一个非常关键的注意点:给已有大量数据的表加字段时,用NOT NULL DEFAULT会导致全表重写,线上会锁表。如果是小表(几千行)无所谓,但如果表里有几百万行,这个操作在生产环境可能跑几分钟甚至更久。我处理这种问题的经验是分两步走:先加一个允许 NULL 的列(ADD COLUMN nickname VARCHAR(64)),等业务代码兼容了空值之后,再做一次填充和加约束的迁移。
第二种:加索引。
up 文件:
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_created_at ON users (created_at);加CONCURRENTLY这个关键字,是为了让建索引的过程不阻塞读写。但注意,golang-migrate 执行 SQL 时如果开了事务,CONCURRENTLY会报错(它不能在事务块里执行)。好在 golang-migrate 对纯SQL迁移默认就是非事务模式,直接跑没问题。如果你用的是其他工具,可能需要手动把迁移配置改成非事务。
第三种:数据回填。
比如你给orders表加了total_amount字段,需要把order_items汇总出来填进去。这种回填逻辑,我建议先写成 SELECT 语句在测试库上反复模拟,确认数据没问题,再放进迁移文件。up 文件可能长这样:
UPDATE orders o SET total_amount = sub.total FROM ( SELECT order_id, SUM(amount) AS total FROM order_items GROUP BY order_id ) sub WHERE sub.order_id = o.id AND o.total_amount IS NULL;down 文件就把total_amount更新回 NULL:
UPDATE orders SET total_amount = NULL WHERE total_amount IS NOT NULL;数据回填类的迁移,最怕的是幂等性不好。如果某条 SQL 执行到一半失败,重跑时会重复更新,结果可能就错了。所以写这类迁移时,一定要加一个“只处理目标状态数据”的条件(比如上面的AND o.total_amount IS NULL),让它能安全地重复执行。
3.4 在 Docker/Kubernetes 里跑迁移的注意事项
如果你的 Go 服务最终部署在容器里,迁移的执行时机很关键。我见过不少团队把迁移逻辑和服务启动放一起,进程一启动就执行m.Up(),然后才开始接收流量。如果是单副本部署,这种方式勉强能跑;一旦多副本同时启动,多个实例会同时尝试执行迁移,虽然工具内部有锁,但并发场景下经常出现各种怪异的报错。
更推荐的做法是:把迁移放到独立的初始化容器(Init Container)里执行,主服务容器等迁移完成后再启动。每次发布新版本时,先跑迁移容器,再滚动更新业务容器。这样能保证整个发布过程是串行的,不会出现业务代码已经更新了但数据库结构还没变的情况。
还有一个小细节:迁移文件必须打进镜像里。用go:embed是最好的,不用留一个外部目录;如果项目里不方便 embed,也要在 Dockerfile 里显式COPY migrations/ /app/migrations/,并且要注意相对路径问题。路径错是迁移执行报错里最常见的原因之一,排查看日志很容易忽略。
4. 常见问题与排查技巧实录
4.1 问题速查表
以下是我在实际跑迁移时遇到过的典型问题,整理成一张速查表,方便你直接对照排查:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
迁移执行报错:No change | 当前版本已经等于目标版本 | 不要太意外,这是正常提示,确认目标版本正确即可 |
Dirty database version X | 上个迁移文件执行中失败,版本表 dirty 置为 true | 先查schema_migrations,手工检查已执行了哪些SQL,手动修复后 force 版本号 |
Timeout 45s reached | 某条 DDL 锁表等待过久 | 检查是否有长事务占用锁;把 lock_timeout 调大或拆小迁移步骤 |
| 迁移文件顺序乱了 | 版本号写了不等长数字,比如1和10 | 统一补零为 6 位或 8 位数字编号 |
Embedding static files报路径错 | 代码里 embed 目录与实际迁移目录不匹配 | 仔细核对 embed 指令的路径,注意 go:embed 不支持父目录引用 |
| 语句执行成功但版本表未更新 | 工具处于非事务模式时,中途失败不会记录版本 | 手动处理剩余语句,手工维护版本表记录 |
排障的总原则是:不要慌,先开个数据库可视化工具看版本表和表结构的实际状态,再决定重跑还是手工修。最忌讳的就是不分析原因,直接删掉schema_migrations表或者手工把 version 改大,那等于放弃了整个迁移体系的信任基础。
4.2 亲自踩过的几个典型坑
这里多写几段我亲自踩坑的细节,给读者一个更具体的参考。
坑一:回滚脚本写反了。某次我在down文件里本来想删掉刚加的nickname列,结果手滑写成了DELETE FROM users WHERE nickname IS NOT NULL;,一执行下去差点把整张表的数据全清掉。还好当时是在开发库上测试回滚,数据可以随便造。这件事给我的教训是:down 文件不等于 up 文件的反向镜像,它操作的对象是“数据”,写的时候要像写生产 SQL 一样小心翼翼,最好先在测试库跑一遍。
坑二:把业务数据的修复合到了 schema 迁移里。有一次某个线上 bug 需要通过 SQL 修正一批脏数据,当时图省事,直接在迁移文件里写了修复逻辑。结果后来需要回滚这次版本,down 文件只能把表结构调整回去,但脏数据修复无法“恢复原样”,导致线上数据状态比回滚前更乱。从这个事以后,我严格区分两类操作:schema 变更走迁移体系,数据修复走单独的数据订正脚本,数据订正脚本不进版本迁移体系,而是用独立工具记录执行状态。
坑三:建索引没有用CONCURRENTLY,导致线上服务抖动。这个是在一个千万级数据量的订单表上发生的,当时直接CREATE INDEX idx_orders_user_id ON orders(user_id);,这个操作会阻塞表的写入,线上业务监控立刻报警,接口延迟飙高。从那次之后,凡是新建索引,只要目标表的数据量可能超过10万行,我都默认用CONCURRENTLY,并且把超时时间拉长。
坑四:多实例并发执行迁移,出现莫名其妙的脏状态。之前有段时间迁移代码写在服务启动阶段,结果发布新版本时,同时多个副本实例一起执行迁移,就遇到了schema_migrations表被锁、dirty 状态被反复置位的情况。后来把迁移抽成独立的初始化容器,这个问题再没出现过。
坑五:embed 迁移目录后忘记重新编译镜像。这是个很蠢但很容易犯的错误:本地测试时迁移文件直接指向本地磁盘目录,一切正常;部署时改用go:embed后,总觉得改一下 SQL 文件代码能自动生效,结果忘记重新编译镜像,上线时压根没有包含最新的迁移文件。解决方案也没什么高深的,就是在 CI 的构建流程里加一步检查,确保镜像内的迁移文件 hash 和仓库中最新代码一致。
4.3 几个我现在的固定操作习惯
写到这里,可以聊点我最终沉淀下来的工作习惯。这些习惯谈不上多高明,但是能省掉不少救火的时间。
第一,每次写迁移前,先看当前环境的状态。我习惯在本地开发时准备一个Makefile目标:make db-status,执行后输出当前所有环境(本地、测试、生产)的版本号。上线前一定先跑一遍,确认生产库停在哪个版本,再决定发布哪几个新迁移。
第二,迁移文件提交前,先在新环境跑一遍完整流程。我会用一个临时数据库,从空的CREATE DATABASE开始,把全部迁移文件 up 跑一遍,再全部 down 跑一遍,确认加了一个新迁移后整个链路是通的。这在多人协作的项目里特别重要,因为别人可能已经在上游加了几个迁移文件,你不完整跑一遍根本不知道会不会跟自己的变更冲突。
第三,给迁移文件加一行注释说明变更动机。纯SQL迁移文件本身没有描述字段,我习惯在文件开头写两句注释,比如:
-- 原因:订单列表页需要按创建时间筛选,查询频率高 -- 影响:订单表数据量约 500 万行,使用 CONCURRENTLY 避免阻塞写入 CREATE INDEX CONCURRENTLY idx_orders_created_at ON orders (created_at);这行注释在两个人同时 review 迁移文件时价值极高,能把“这段SQL是谁为了什么加的”这个信息保留下来,而不是靠群聊记录去翻。
第四,迁移版本号尽量保持连续,但不强求。如果你的迁移文件版本号是 1、2、3、4,突然同事合并进来一个 5,你本地可能没有 5,这没事。但如果你的本地有一个 4,同事也写了一个 4,那合并时就麻烦了。所以我现在会尽量让每个人在自己的分支上用“一个足够大的独立区间”来编号,比如你这次分支从000020开始,他用000030开始,冲突概率就小很多。
5. 从一次“成功迁移”中学到的心态
其实写数据库迁移这个事,做到后来会发现,技术本身并不难——难的是在出现意外时保持冷静,用体系化的方式去处理问题。
我现在的固定动作是,任何一次迁移成功后,都会打开数据库确认三件事:版本表里的版本号是否正确、关键表的数据行数是否符合预期、down 文件是否已经验证过。确认完了,再考虑发布业务服务。这一步看着笨,但确实帮我拦住过好几次“迁移成功但数据不对”的隐性事故。
如果你看完这篇记录也打算给自己的Go项目搭一套数据库迁移体系,建议第一步别急着写代码,先把你当前库里的表结构导出成一份初始化SQL,作为第一个迁移文件,然后在开发环境里把整个项目从零初始化一遍。跑通了,再继续往下加新的迁移。这个过程不需要很复杂,但能让你在最开始就建立“数据库有版本”的意识,而不是继续依赖记忆和群消息。