
线上发布最怕什么?不是代码有 Bug,而是一条看似人畜无害的 ALTER TABLE 把连接池打满,主从延迟直接飙到分钟级,监控大屏一片飘红,最后只能硬着头皮等 DDL 跑完或者杀连接。这种戏码,做过几次生产发布的人都懂。
高可用不是靠堆机器堆出来的,是卡在每个变更环节抠出来的。数据库结构变更就是其中最脆的一环。今天不聊虚的架构理论,直接拆 Spring Boot + Flyway 落地时那些容易踩的坑,以及一套在生产环境验证过的平滑 DDL 变更打法。
线上 DDL 为什么会"炸"?
很多人以为 MySQL 加个索引、添个字段只是改改元数据。实际上,DDL 的本质是 InnoDB 引擎对物理页的重构。它怎么跑,全看 ALGORITHM 和 LOCK 的配合:
早期版本默认的 COPY 会重建整表,期间直接上表级写锁,读写全堵。现在主流用的 INPLACE 是原地修改,大多数场景能跑,但碰到改列类型、换字符集这种,依然会触发隐式重建。到了 MySQL 8.0 之后,官方推了 INSTANT,纯改元数据,毫秒级,但限制很死,比如不能改 Nullable 属性,不能加主键。
生产环境里,真正把发布干趴下的通常就两个原因:
一是长事务或空闲连接死死攥着元数据锁。你以为只是加个字段,结果有个跑批任务或者连接池里的空闲连接没提交,DDL 直接卡在 Waiting for table metadata lock。后续请求疯狂排队,HikariCP 很快就被耗尽。
二是主从复制延迟放大。大表变更在主库可能十几分钟搞定,但落到 Binlog 里就是海量 Row 事件或者一个超长事务。从库回放跟不上,延迟从秒级跳到分钟级。这时候如果把读流量切到从库,要么读到旧数据,要么直接报 Unknown column。
说句实在话,零停机演进的根本逻辑不是"让 DDL 跑得更快",而是"让应用和数据库在结构不一致的那段时间里,依然能跑通业务流程"。
为什么团队最后都选了 Flyway
数据库版本管理工具,业内基本就是 Flyway 和 Liquibase 两家。Liquibase 的 XML/YAML 抽象层确实能跨数据库,写一次到处跑,但实际落地时,DBA 根本不想看那些标签,开发调试也绕弯子。Flyway 就直白得多,纯 SQL 脚本扔进 classpath:db/migration,靠文件名里的版本号排序,执行完记一笔 Checksum。谁敢改已经跑过的历史脚本,启动直接报错拦截。这种"笨办法"反而最贴合生产纪律。
Spring Boot 集成几乎开箱即用,但配置得按生产规矩来:
yaml
spring:
flyway:
enabled: true
locations: classpath:db/migration
# 遗留库接入必备:不校验历史,只认当前基线
baseline-on-migrate: true
baseline-version: "1.0.0"
# 校验失败直接拦截,别带病发布
validate-on-migrate: true
# 生产环境死守底线,严禁 clean
clean-disabled: true
# 禁止乱序执行,除非你明确知道自己在干什么
out-of-order: false
落地时还有几个细节得盯紧:
- 基线锚定 :老系统第一次接 Flyway,别傻乎乎从
V1开始跑。用baseline把当前库状态钉死,后续的变更只认这个锚点之后的脚本。 - 多环境隔离 :别把所有环境的脚本混在一个目录。按
db/migration/common/放公共变更,db/migration/env/{profile}/放环境专属的,配合 Spring Profiles 动态加载。 - 版本命名 :
V{时间戳}__{简短描述}.sql。时间戳必须严格单调递增,别用序号,多人协作一合并 Git 绝对冲突。
平滑变更的实际打法:扩、迁、收
并行变更(Parallel Change)是业界跑出来的硬规矩,拆开看就是三个动作,别想着一个发布窗口全干完。
第一步:只扩不收(Expand)
上线前先把新字段、新索引建好。记住,新字段必须能 NULL 或者带个无害的默认值。索引用 ALGORITHM=INPLACE, LOCK=NONE 显式声明,避免引擎猜错策略。这个阶段代码层不需要动,或者只加个降级逻辑:读不到新字段时 fallback 到旧逻辑。数据库结构永远要比应用跑得快半步。
第二步:双写与切流(Migrate)
代码里加上双写。写操作同时打旧字段和新字段,读操作通过开关灰度切到新字段。双写最怕数据不一致,建议走最终一致性:旧逻辑写完,丢个 MQ 消息补偿新字段,或者用定时任务对账。监控两边数据差异,窗口期内允许微小偏差,但别把差异滚雪球。
第三步:清理旧债(Contract)
新结构全量接管、跑满至少一个完整发布周期后,再动手清理。关双写开关,下线旧代码,最后执行 DROP COLUMN 或 DROP INDEX。清理脚本照样得走 Flyway 流程。千万别在刚切完流量当晚就删字段,半夜出问题你连回退的抓手都没有。
这条铁律刻在脑子里:应用升级和破坏性 DDL 绝对不在同一个发布窗口。DB 向后兼容,应用优先升级。
脚本怎么写、怎么回滚、发布前怎么查
Flyway 不搞自动回滚,这是设计哲学,不是缺陷。DDL 本身在 MySQL 里就是隐式提交,物理上没法 ROLLBACK。所以得自己准备 U__ 开头的补偿脚本。命名和正向脚本对应,里面写 DROP 或者数据修正 DML。跑飞了,人工评估后执行补偿脚本,或者靠快照拉逻辑备份恢复。
写迁移脚本,养成几个习惯:
- 显式判断存在性 :虽然 Flyway 保证不重复执行,但调试时难免手滑。MySQL 8.0.19+ 支持
ADD COLUMN IF NOT EXISTS,加上没坏处。PostgreSQL 支持事务 DDL,可以直接包在BEGIN/COMMIT里。 - DDL 和 DML 拆开:别在同一个脚本里既改表结构又刷历史数据。改结构跑完了,刷数据用独立的批量脚本走,失败只影响数据,不影响元数据。
- 大索引独立执行:千万级以上的表加索引,单独放一个脚本。避免和字段变更耦合,锁表时间不可控。
发布前,CI/CD 流水线必须卡三道关:
- DryRun 语法检查 :用 Flyway CLI 跑
info带dryRunOutput,不真执行,只校验 SQL 语法和权限依赖。 - 环境水位确认 :跑之前查磁盘剩余空间(至少是表大小的 2 倍)、主从延迟、当前活跃连接数。脚本查
information_schema就行,别在生产环境跑全表扫描。 - 执行账号最小权限 :发布用户只给
ALTER, CREATE, INDEX, DROP权限,别给SUPER或ALL PRIVILEGES。流水线拉取SHOW GRANTS校验,权限不对直接阻断。
K8s 滚动发布时的 DB 协调陷阱
这里踩坑的人最多。很多人把 Flyway 放在 Spring Boot 启动流程里,应用一启动自动跑迁移。上了 K8s 滚动更新,新 Pod 一个个起来,每个都会去抢着执行 Flyway。要么报 Checksum 冲突,要么数据库连接被打爆。
正确姿势:Flyway 必须从应用启动链路上剥离。
把它抽成独立的 CI/CD 步骤,或者 K8s 的 Job。发布流程变成:
- 流水线先触发 Flyway Job,执行第一阶段兼容脚本,等
schema_version表落盘成功。 - 确认主从同步完毕,延迟回到基线。
- 再触发应用滚动更新。新 Pod 起来连的是已经扩好结构的库,旧 Pod 连的也是同一个库,两边都能跑。
过渡期要防两件事:旧代码读新字段时,代码层做好 IFNULL 或者默认值兜底;双写逻辑必须带去重,用业务唯一键或者分布式锁过滤重复请求,别让补偿任务把表刷爆。读写分离路由在结构切换期建议临时打回主库,避开从库 DDL 回放延迟导致的字段不可见。多扛点读压力,比数据错乱强。
监控、熔断与兜底预案
别指望靠眼睛盯日志。把关键指标接进 Prometheus 和告警中心:
- 迁移执行时长。大表变更跑超 5 分钟没动静,直接触发预警。
- InnoDB 锁等待。查
performance_schema.data_locks或者SHOW ENGINE INNODB STATUS,WAIT 超过 10 秒就得盯紧。 - 主从延迟。超过 30 秒,暂停切流,先保数据一致性。
- 业务 5xx 错误率。突增超过 1%,立刻触发熔断,停后续发布。
熔断逻辑得写死在流水线里。脚本超时、锁表不释放、业务报错飙升,CI/CD 钩子直接发终止信号,K8s 执行 rollout undo 回退应用。别硬扛,线上不讲道理。
如果迁移跑了一半卡住,表结构半新半旧,第一件事是把流量全切回旧版本实例,库切只读。等发布窗口稳住,再开补偿 Job 对账,用幂等 UPSERT 把缺口补齐。对外先降级非核心功能,保住主交易链路。
复盘与落地清单
故障一:大表加索引打满连接池
8000 万行的流水表,晚上直接 ALTER TABLE ADD INDEX。没指定算法,InnoDB 走全表重建,14 分钟没跑完,应用端 HikariCP 直接抛 ConnectionPoolTimeoutException。
教训 :MySQL 8.0 以下的大表变更,老老实实用 gh-ost 或 pt-online-schema-change。8.0 以上也要显式写 ALGORITHM=INPLACE,并且提前限流。监控没接进流水线是致命伤,超时不熔断等于裸奔。
故障二:滚动发布期从库查不到新字段
DDL 还没同步完,新 Pod 就起来了,查新字段直接报 Unknown column。
教训 :发布顺序反了。必须是 DDL 落盘 -> 等主从延迟归零 -> 再升应用。K8s 的 readinessProbe 最好挂个自定义脚本,校验同步状态达标后才标记 Ready。
落地 Checklist(打印贴墙上那种)
- Staging 环境完整跑一遍
migrate+ 双写验证,数据核对无误。 - 生产发布前,确认磁盘水位 > 2 倍目标表大小,主从延迟 < 10s。
- Flyway 脚本已剥离应用,走独立 Job/CI 步骤执行。
- 第一阶段脚本只加不减,跑完确认
schema_version状态为 SUCCESS。 - 灰度 1-2 个节点,开读验证开关,盯 15 分钟无异常再全量。
- 双写开启后,跑数据比对脚本,差异率 < 0.01% 才继续推进。
- 保持当前状态跑满 3 天,无慢查询突增、无业务客诉。
- 执行清理脚本,移除冗余字段,验证执行计划回归基线。
零停机不是靠什么银弹工具,是发布纪律、代码兜底和监控熔断拧在一起的结果。Flyway 只是帮你把版本管起来,真正决定生死的,是"先扩库、再升应用、最后收债"这套节奏能不能咬死。云原生数据库和在线 DDL 工具越来越强,但业务逻辑和数据结构的解耦原则不会变。把这套流程跑顺了,半夜发版至少能睡个整觉。
🎁 福利时间
如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。
知识库地址:https://farerboy.com/
