利益相关:笔者是多租户微信小程序 SaaS 项目的主力工程师,技术栈 Java 21 + Spring Boot,生产 PostgreSQL、本地开发 H2。项目目前 85 个 Flyway 迁移文件、版本号推进到 V113,覆盖 27 个业务域。迁移文件少的时候怎么写都行,多到这个量级,翻车方式会换一种形态——本文讲我们把翻车概率压下去的五条纪律,SQL 均为脱敏示意。
一、背景:迁移多了以后,事故换了形态
前 20 个迁移是无感的:建表、加索引,写完就跑。真正开始疼,是三个人并行开发、环境变成"本地 H2 + 预发 PG + 生产 PG"之后。三类事故各留一次复盘现场。
事故一:同版本号冲突,CI 在合并那刻爆炸
周二早上,两位同事各自从主干拉分支,A 写了 V108__add_coupon_table.sql,B 不知道,也写了一个 V108__add_member_level.sql。两个分支单跑 CI 都绿——各自环境里只有一个 V108。合并后的第一次构建,应用在 Flyway 启动阶段直接退出:
FlywayException: Found more than one migration with version 108
Offending files:
db/migration/V108__add_coupon_table.sql
db/migration/V108__add_member_level.sql
谁改号?改号方要重跑全量回归——1241 个测试方法,没人愿意接。最后后合并者改 V109,核对两文件没操作同一张表才放行。从红到绿四十多分钟。此后写迁移前,两人先互相猜号。
事故二:H2 跑不了 PG 专属语法
有个迁移混进了行级安全和 partial index:
ALTER TABLE t_demo ENABLE ROW LEVEL SECURITY;
CREATE INDEX idx_demo_partial ON t_demo (tenant_id) WHERE deleted = false;
同事本地起服务,H2 给出标志性报错样式——用 [*] 标出它消化不了的第一个词:
Syntax error in SQL statement "
ALTER TABLE T_DEMO ENABLE[*] ROW LEVEL SECURITY
"; expected "., COMMENT, ONLY"; SQL statement:
一条语句把本地环境摁死,三个人半天起不了服务。更磨人的是反方向:有些写法 H2 能跑、行为却与 PG 不一致,本地绿、预发红,排查要跨两个库对行为。PG 专属语法没有资格进通用目录。
事故三:想改历史迁移,被校验和拦下
已上线的 V96 默认值写错了,最顺手的念头是"改一下 V96 重发"。同事真改了文件,本地一启动就被拦住:
Validate failed: Migration checksum mismatch for migration version 96
-> Applied to database : 3882337120
-> Resolved locally : -621098431
两个数字并排:上面是历史表记的旧校验和,下面是新文件算的;生产记的同样是旧值——改文件不是"改一处",是所有环境同时失配,等于拆掉全部环境的迁移基线。同事撤回改动,新写一个补数迁移把错数据 UPDATE 修正,三分钟解决。这道墙在替所有环境守"历史不可编辑"的底线。
三类事故的共同根因:把迁移当普通代码对待。迁移不是代码,是已发生的历史——历史只能追加,不能编辑。
二、纪律清单:五条
- 版本号唯一,只前进,不改历史。 新迁移永远取当前最大号 +1;出现空洞(废弃分支、合并 squash)很正常,但绝不回收复用。改历史文件 = 毁掉所有环境的校验和,错的迁移用新迁移补救,
repair命令不是为改历史准备的。 - 通用与 PG 专属双目录。
db/migration放 H2 与 PG 都能跑的通用 DDL;db/postgresql放 RLS 策略、jsonb、partial index 这类 PG 专属语法。本地 H2 只跑通用目录,生产/预发/CI 的 PG 通道两个目录都执行。注意:两目录共享同一套版本号空间,跨目录同样不许撞号。 - 破坏性变更走"扩展—迁移—收缩"三步。 先加新列、再回填数据、最后删旧列——拆成多个迁移、多次发布,任何一步出问题都停在一个安全点,而不是站在"改了一半的表"上。
- 数据守卫也用迁移管。 外键、CHECK 约束、RLS 策略、触发器,全部版本化。目前 85 个迁移里有十余个专门的 RLS/守卫迁移:每加一张业务表,伴随一个"开 RLS + 建策略"迁移。守卫不进迁移,各环境必然漂移——而漂移的守卫等于没有守卫。
- 迁移也要有测试。 CI 硬门槛:从零库全量执行到目标版本。只跑增量迁移会漏掉历史漂移——某环境的库是被"手工修过"的,全量重建一跑就现形。
三、示例:加列-回填-删列三步走
场景:订单表 t_order(示意表名)的退款标记要从 0/1 整数列 refund_flag 换成枚举列 refund_status。目录结构:
src/main/resources/db/
├── migration/ # 通用目录:H2 与 PG 都执行
└── postgresql/ # PG 专属目录:仅 PG 环境执行
第一步:扩展——加新列,故意不加 NOT NULL(V114,通用目录)。
ALTER TABLE t_order ADD COLUMN refund_status varchar(16);
-- 为什么分步:直接 NOT NULL 会立刻为存量行求值,
-- 表里有历史数据就报错,迁移当场失败。
-- 先允许 NULL,给回填留窗口。
第二步:回填——迁数据(V115,通用目录)。
-- 为什么单独成迁移:回填和加列挤在一个文件,事务包住整张大表,
-- 锁等待顺着连接池堵到应用层,也没法分批。
UPDATE t_order
SET refund_status = CASE WHEN refund_flag = 1 THEN 'REFUNDED' ELSE 'NONE' END
WHERE refund_status IS NULL
AND id BETWEEN :batchStart AND :batchEnd;
-- 分批策略:按主键区间切片,每批 5000 行,批间提交;
-- 一把梭 = 长事务 + 全表行锁;分批把每段事务压到毫秒级,
-- 锁窗口随之变短;
-- WHERE ... IS NULL 让脚本天然可重跑,中断续跑不重不漏。
第三步:收缩——收约束、删旧列(V116,通用目录)。
-- 收 NOT NULL 前先确认回填覆盖率 100%,否则这句直接失败
ALTER TABLE t_order ALTER COLUMN refund_status SET NOT NULL;
-- 删列放最后:PG 的 DROP COLUMN 是元数据操作、瞬间完成,
-- 但依赖旧列的索引、视图不会自动善后,先清依赖再删
ALTER TABLE t_order DROP COLUMN refund_flag;
关键在发布节奏,不只是 SQL:V114 上线后应用代码立刻双写新旧列;V115 回填;V116 上线前确认没有代码再读旧列。三次发布、三次可停——出问题回滚的是应用版本,不是迁移本身(迁移不回滚,向前修)。
四、守卫迁移示例:脏数据在写入那刻被挡住
纪律 4 的样子:营销表 t_coupon(示意表名)要挡两类脏数据——租户内优惠码重复、折扣率跑出 0.1 到 0.9。守卫迁移(V117,通用目录):
-- 唯一约束:重复优惠码在 INSERT 当刻被拒,而非等对账半夜发现
ALTER TABLE t_coupon
ADD CONSTRAINT uk_coupon_tenant_code UNIQUE (tenant_id, coupon_code);
-- 检查约束:折扣率只许 0.1 到 0.9,脏值写入直接报错回滚
ALTER TABLE t_coupon
ADD CONSTRAINT ck_coupon_discount CHECK (discount BETWEEN 0.1 AND 0.9);
被挡的样子:插入第二条 coupon_code = 'SAVE10',PG 返回 ERROR: duplicate key value violates unique constraint "uk_coupon_tenant_code",事务回滚——脏数据没机会落库过夜。
五、踩坑清单
- 同版本号冲突。 现象:合并主干后 CI 报
Found more than one migration with version 108,两个分支单跑却全绿。定位:报错直接列出冲突文件路径。修法:取号前置——开迁移 PR 先占号(checklist 或流水线预检),号被占就顺延,后合并者改号。 - checksum 失配。 现象:启动即
Migration checksum mismatch,新旧校验和并排打印。定位:跑validate确认哪个文件被改,git log -p找改动来源。修法:能撤回就撤回;确需修数据就新开迁移,别条件反射repair——它改的是历史表。 - 大表回填一把梭。 现象:回填几十分钟不返回,锁等待堆积、连接池耗尽。定位:查 PG 锁视图与事务时长,确认是单个长事务。修法:主键区间分批、每批数千行、批间提交;卡住的先终止事务,用可重跑脚本续跑。
- PG 专属语法误入通用目录。 现象:本地 H2 起不来,报错带
[*]标注的 syntax error,全组阻塞。定位:看报错文件在db/migration还是db/postgresql,九成是目录放错。修法:移入 PG 目录重新占号;review 先看目录再看 SQL。 - 删列善后。 现象:DROP COLUMN 瞬间完成,随后某报表或夜间任务报
column refund_flag does not exist。定位:全局搜旧列名,列出引用它的索引、视图、RLS 策略。修法:先清依赖再删列,单独一次发布,别与其他变更混车。
三句话总结
- 迁移历史是只读的:版本号唯一、只前进、不复用,写错了用新迁移补救,永远不改旧文件。
- 双目录是纪律不是技巧:通用目录人人能跑,PG 专属目录只在真库执行,约束和 RLS 守卫同样全部版本化。
- 破坏性变更永远三步走:加列、回填、删列各自成迁移、各自发布,每一步都停得上、站得稳。
运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。
