文章目录
- 每日一句正能量
- 前言
- 1. 背景与问题
- 2. 环境与数据
- 3. 复现过程
- 3.1 复现“先删旧列”导致旧版本崩溃
- 3.2 复现“直接加 NOT NULL”导致历史数据失败
- 3.3 复现 ORM 启动校验失败
- 4. 方案实施
- 4.1 Expand:先扩展,不破坏旧结构
- 4.2 Flyway 版本脚本
- 4.3 V2 先双写
- 4.4 MyBatis 双写
- 4.5 JPA/Hibernate 兼容映射
- 4.6 历史数据回填不能一个大事务
- 4.7 回填任务必须可重入
- 4.8 回填事务边界
- 4.9 数据校验
- 4.10 切读:新应用优先使用新字段
- 4.11 什么时候停止双写
- 4.12 Contract:最后删除旧结构
- 4.13 为什么回滚经常不如前滚
- 4.14 真正的回滚窗口
- 4.15 新增 NOT NULL 的安全方式
- 4.16 大表加索引也要当发布项目
- 4.17 MySQL 与 PostgreSQL 的事务差异要尊重
- 4.18 Flyway 异常处理
- 4.19 Liquibase 也不替代变更设计
- 4.20 版本表和审计
- 4.21 失败脚本不要直接手改生产
- 4.22 ORM 的字段生命周期必须和 Schema 对齐
- 4.23 异常与事务边界
- 4.24 风险矩阵
- 5. 结果对比
- 传统一次性变更
- Expand/Contract
- 6. 风险与复盘
- 6.1 Schema 版本不能和应用版本强绑定成“一次完成”
- 6.2 DDL 回滚可能比前滚更危险
- 6.3 数据迁移必须可观察
- 6.4 数据回填不要抢业务资源
- 6.5 不要忘记非应用依赖
- 6.6 ORM 自动建表不适合生产治理
- 6.7 Contract 才是真正高风险阶段
- 结语
每日一句正能量
修炼一颗强大的内心,外界的喧嚣和攻击就无法轻易伤害你。
喧嚣与攻击是外部的“力”,而强大的内心是内部的“结构”。当结构足够稳固时,外力便难以使其变形或崩塌。
前言
应用升级里最危险的操作,往往不是部署新代码,而是“顺手改一下数据库”。
代码发布失败,可以快速回滚镜像;数据库一旦执行了删除列、修改字段类型、重建大索引或批量回填,回滚成本通常远高于应用回滚。更麻烦的是,持续交付环境里新旧应用版本往往会并存一段时间:旧 Pod 还在处理请求,新 Pod 已经开始写新字段,如果数据库结构只兼容其中一个版本,灰度发布就会立刻失去意义。
所以数据库变更管理的核心,不是“把 SQL 放进 Flyway 就算规范”,而是要解决三个问题:
Schema 是否同时兼容新旧应用? DDL 是否会阻塞线上业务? 失败时应该回滚,还是前滚修复?真正可靠的方案通常采用 Expand/Contract 思路:先扩展结构,让新旧应用都能运行;再切换应用;最后收缩旧结构。数据库变更从一次性动作,变成一个可验证、可停留、可回退的发布过程。
1. 背景与问题
假设订单表最初有:
CREATETABLEorders(idBIGINTPRIMARYKEYAUTO_INCREMENT,order_noVARCHAR(64)NOTNULLUNIQUE,user_idBIGINTNOTNULL,receiver_nameVARCHAR(64)NOTNULL,statusVARCHAR(32)NOTNULL,created_atTIMESTAMPNOTNULLDEFAULTCURRENT_TIMESTAMP);新版本要把:
receiver_name升级成:
receiver_first_name receiver_last_name最直觉的操作可能是:
ALTERTABLEordersDROPCOLUMNreceiver_name;ALTERTABLEordersADDCOLUMNreceiver_first_nameVARCHAR(32)NOTNULL,ADDCOLUMNreceiver_last_nameVARCHAR(32)NOTNULL;如果这段 DDL 在应用发布前执行,旧版本代码马上失败:
SELECTreceiver_nameFROMordersWHEREid=?;如果 DDL 在应用发布后执行,新版本又可能先启动失败。
更现实的问题是灰度期间:
V1 应用 V2 应用会同时存在。
这时数据库必须同时满足:
V1 还能读 receiver_name V2 已能写 receiver_first_name / receiver_last_name这就是持续交付下数据库变更与传统“停机升级”的最大不同。
2. 环境与数据
示例环境:
JDK 21 Spring Boot 3.3+ MySQL 8.0+ PostgreSQL 15+ Flyway / Liquibase HikariCP MyBatis 3.x Hibernate 6 / JPA测试表:
CREATETABLEorders(idBIGINTPRIMARYKEYAUTO_INCREMENT,order_noVARCHAR(64)NOTNULLUNIQUE,user_idBIGINTNOTNULL,receiver_nameVARCHAR(64)NOTNULL,statusVARCHAR(32)NOTNULL,created_atTIMESTAMP(6)NOTNULLDEFAULTCURRENT_TIMESTAMP(6));测试数据:
INSERTINTOorders(order_no,user_id,receiver_name,status)VALUES('O-1001',101,'Zhang San','CREATED'),('O-1002',102,'Li Si','CREATED');旧版本 DTO:
publicrecordOrderDto(Longid,StringorderNo,StringreceiverName,Stringstatus){}新版本 DTO:
publicrecordOrderDtoV2(Longid,StringorderNo,StringreceiverFirstName,StringreceiverLastName,Stringstatus){}3. 复现过程
3.1 复现“先删旧列”导致旧版本崩溃
先执行:
ALTERTABLEordersDROPCOLUMNreceiver_name;旧 MyBatis SQL:
<selectid="findById"resultType="OrderDto">SELECT id, order_no, receiver_name, status FROM orders WHERE id = #{id}</select>下一次请求直接报:
Unknown column 'receiver_name'这说明:
数据库变更已经让旧应用失去兼容性。即使新版本应用完全正确,灰度也无法继续。
3.2 复现“直接加 NOT NULL”导致历史数据失败
直接执行:
ALTERTABLEordersADDCOLUMNreceiver_first_nameVARCHAR(32)NOTNULL;对已有数据而言,新列没有值。
不同数据库和版本的行为可能不同,但生产上最稳妥的做法不是依赖隐式默认值,而是明确分阶段:
先加 nullable 回填 验证 再加 NOT NULL3.3 复现 ORM 启动校验失败
Hibernate 项目如果开启:
ddl-auto=validate而 Entity 已经期待新字段:
@Column(name="receiver_first_name")privateStringreceiverFirstName;数据库迁移还没执行,新版本实例可能在启动阶段直接失败。
这说明应用和 Schema 的发布顺序必须被流水线严格管理。
4. 方案实施
4.1 Expand:先扩展,不破坏旧结构
第一阶段只新增新列:
ALTERTABLEordersADDCOLUMNreceiver_first_nameVARCHAR(32)NULL,ADDCOLUMNreceiver_last_nameVARCHAR(32)NULL;旧列:
receiver_name继续保留。
此时:
V1 可以继续使用旧列 V2 可以开始识别新列这是第一安全点。
4.2 Flyway 版本脚本
例如:
V173_01__expand_receiver_columns.sql内容:
ALTERTABLEordersADDCOLUMNreceiver_first_nameVARCHAR(32)NULL,ADDCOLUMNreceiver_last_nameVARCHAR(32)NULL;不要把:
新增列 回填 删除旧列全部塞进一个 migration。
拆开后每一步才能:
独立观察 独立暂停 独立验证4.3 V2 先双写
新应用写订单时同时维护:
receiver_name receiver_first_name receiver_last_nameJDBC:
publicvoidinsertOrder(OrderRequestreq){jdbcTemplate.update(""" INSERT INTO orders( order_no, user_id, receiver_name, receiver_first_name, receiver_last_name, status ) VALUES (?, ?, ?, ?, ?, 'CREATED') """,req.orderNo(),req.userId(),req.fullName(),req.firstName(),req.lastName());}为什么还要写旧字段?
因为灰度期间旧版本应用仍可能读取:
receiver_name4.4 MyBatis 双写
<insertid="insertOrder">INSERT INTO orders( order_no, user_id, receiver_name, receiver_first_name, receiver_last_name, status ) VALUES( #{orderNo}, #{userId}, #{receiverName}, #{receiverFirstName}, #{receiverLastName}, 'CREATED' )</insert>旧 Mapper 不需要修改,仍然读旧列。
新 Mapper 可以优先读新列。
4.5 JPA/Hibernate 兼容映射
V2 Entity 可以暂时同时映射:
@Column(name="receiver_name")privateStringreceiverName;@Column(name="receiver_first_name")privateStringreceiverFirstName;@Column(name="receiver_last_name")privateStringreceiverLastName;保存前同步:
@PrePersist@PreUpdatepublicvoidsyncReceiverName(){this.receiverName=receiverFirstName+" "+receiverLastName;}这种过渡代码不是永久架构,而是:
迁移期兼容层。等 V1 完全下线后再删除。
4.6 历史数据回填不能一个大事务
错误:
UPDATEordersSETreceiver_first_name=...,receiver_last_name=...;如果表有几千万行,这可能产生:
大事务 长时间锁 Redo/WAL 膨胀 主从延迟 回滚困难应该分批。
例如:
UPDATEordersSETreceiver_first_name=SUBSTRING_INDEX(receiver_name,' ',1),receiver_last_name=SUBSTRING_INDEX(receiver_name,' ',-1)WHEREid>?ANDid<=?ANDreceiver_first_nameISNULL;应用任务:
publicvoidbackfill(longstartId,longendId){jdbcTemplate.update(""" UPDATE orders SET receiver_first_name = SUBSTRING_INDEX( receiver_name, ' ', 1 ), receiver_last_name = SUBSTRING_INDEX( receiver_name, ' ', -1 ) WHERE id > ? AND id <= ? AND receiver_first_name IS NULL """,startId,endId);}4.7 回填任务必须可重入
条件:
ANDreceiver_first_nameISNULL非常重要。
任务失败重跑时,不应该重复覆盖已经迁移完成的数据。
可重入是 Schema 数据迁移的基本要求。
4.8 回填事务边界
不要:
@TransactionalpublicvoidmigrateAll(){for(100000batches){update();}}这会把所有批次放进一个巨型事务。
应该:
每批一个事务例如:
@Transactional(propagation=Propagation.REQUIRES_NEW)publicintmigrateBatch(longstart,longend){returnrepository.backfill(start,end);}外层循环不持有大事务。
4.9 数据校验
回填结束后,不要立刻删旧列。
先验证:
SELECTCOUNT(*)FROMordersWHEREreceiver_first_nameISNULLORreceiver_last_nameISNULL;还要抽样:
SELECTreceiver_name,receiver_first_name,receiver_last_nameFROMordersORDERBYidDESCLIMIT100;复杂迁移最好同时记录:
migrated_count failed_count last_id duration4.10 切读:新应用优先使用新字段
新版本读取:
publicStringdisplayName(OrderRowrow){if(row.receiverFirstName()!=null){returnrow.receiverFirstName()+" "+row.receiverLastName();}returnrow.receiverName();}这是典型:
read new, fallback old策略。
如果某些历史数据还未回填,也不会立刻报错。
4.11 什么时候停止双写
只有确认:
V1 已经完全下线 回填完成 新字段持续正确 所有消费者都已兼容之后,V2 才能停止写旧字段。
这一步最好单独发布。
不要在同一版本里同时:
停止旧写 删除旧列否则很难验证。
4.12 Contract:最后删除旧结构
最后一个脚本:
V173_05__drop_receiver_name.sql内容:
ALTERTABLEordersDROPCOLUMNreceiver_name;这个动作应该发生在:
所有旧版本应用消失 所有报表/ETL/脚本确认不再依赖之后。
4.13 为什么回滚经常不如前滚
如果应用部署失败:
回滚镜像很容易。
如果数据库已经:
DROP COLUMN则回滚需要:
重新建列 恢复数据 恢复索引 恢复约束甚至根本没有原始数据可恢复。
所以 Schema 变更通常更强调:
Forward Fix / 前滚修复而不是物理回滚。
4.14 真正的回滚窗口
在 Expand 阶段:
旧结构仍在所以应用可以轻松退回 V1。
进入 Contract 后:
旧字段已经删除回滚难度骤增。
这也是为什么 Contract 必须延后。
4.15 新增 NOT NULL 的安全方式
不要一步:
ALTERTABLEordersADDCOLUMNsourceVARCHAR(16)NOTNULL;推荐:
第一步:
ALTERTABLEordersADDCOLUMNsourceVARCHAR(16)NULL;第二步新代码开始写:
source第三步回填:
UPDATEordersSETsource='LEGACY'WHEREsourceISNULL;第四步验证:
SELECTCOUNT(*)FROMordersWHEREsourceISNULL;最后才:
ALTERTABLEordersMODIFYsourceVARCHAR(16)NOTNULL;4.16 大表加索引也要当发布项目
错误:
CREATEINDEXidx_status_createdONorders(status,created_at);直接在高峰期执行。
应先确认数据库是否支持:
在线 DDL 并发索引创建并监控:
锁等待 复制延迟 IO CPU DDL 进度Schema 管理不仅是列变更。
4.17 MySQL 与 PostgreSQL 的事务差异要尊重
不同数据库对 DDL 的:
事务 锁 在线能力并不完全一样。
所以不要在应用代码里假设:
@Transactionalpublicvoidmigration(){ddl1();ddl2();}一定能像普通 DML 一样完整回滚。
Schema migration 应由专门迁移工具和发布流程控制。
4.18 Flyway 异常处理
应用启动时自动 migration 的风险是:
多个实例同时启动 DDL 执行时间长 数据库锁阻塞 应用启动失败生产更成熟的做法通常是:
独立 migration job -> 执行成功 -> 再部署应用而不是让每个应用实例自行承担 DDL。
4.19 Liquibase 也不替代变更设计
Liquibase 可以表达:
changeset precondition rollback但工具无法自动判断:
删列是否兼容 V1这仍然是架构和发布流程问题。
4.20 版本表和审计
每次迁移至少应该知道:
版本号 脚本 checksum 执行时间 执行人/流水线 执行结果Flyway 的 schema history 就是为这类可追踪性服务。
4.21 失败脚本不要直接手改生产
如果:
V173_03已经在部分环境执行,不应该随意修改同名脚本内容。
更安全:
增加 V173_04 修复脚本保持迁移历史不可变。
4.22 ORM 的字段生命周期必须和 Schema 对齐
JPA 删除:
receiverName之前,数据库旧字段可以先保留。
数据库删列以后,旧 Entity 才真正不可兼容。
Schema 与 ORM 的演进应该是:
数据库先兼容 应用后切换 数据库最后清理4.23 异常与事务边界
假设 V2 双写:
@TransactionalpublicvoidupdateReceiver(...){orderRepository.updateNewFields(...);auditRepository.insert(...);}如果第二步失败:
新字段更新必须回滚。迁移期也不能为了“兼容”就吞异常。
同样,回填任务每批失败时:
只回滚当前批次 记录 checkpoint 后续重试而不是让几百万行一起回滚。
4.24 风险矩阵
特别要警惕:
列改名 删列 类型变更 大表加索引 大范围 NOT NULL 大事务回填这些操作都不应该被当成“普通发布 SQL”。
5. 结果对比
传统一次性变更
流程:
停应用 改表 部署新版本 启动适合:
低可用要求 维护窗口明确 小规模系统但持续交付环境问题明显:
无法灰度 无法双版本并存 失败回滚困难Expand/Contract
流程:
Expand -> V2 双写 -> 回填 -> 切读 -> V1 下线 -> 停止旧写 -> Contract优势:
新旧版本兼容 可以灰度 可以逐步验证 回滚窗口更长 大数据迁移可分批代价:
迁移周期更长 代码短期存在兼容逻辑 需要更严格发布纪律对于高可用在线系统,这个成本通常值得。
6. 风险与复盘
6.1 Schema 版本不能和应用版本强绑定成“一次完成”
真实迁移经常跨:
2~3 个应用版本这是正常现象。
不要为了“看起来干净”,强行在一次发布里:
加列 迁移 删列全部做完。
6.2 DDL 回滚可能比前滚更危险
DDL 已经执行、数据已经变化后,再反向恢复结构可能造成:
二次锁表 再次全表扫描 更多故障所以生产上要优先设计:
安全停留点 前滚修复6.3 数据迁移必须可观察
至少记录:
已处理 ID 处理行数 错误数 吞吐量 剩余量 复制延迟否则你不知道“回填完成”到底意味着什么。
6.4 数据回填不要抢业务资源
应设置:
批次大小 sleep QPS 上限 低峰运行 数据库负载阈值迁移任务不能把线上请求挤死。
6.5 不要忘记非应用依赖
删列前要检查:
报表 ETL BI 脚本 存储过程 视图 触发器 定时任务它们往往比应用代码更容易被遗漏。
6.6 ORM 自动建表不适合生产治理
开发环境:
ddl-auto=update很方便。
生产环境更应该:
DDL 显式脚本化 版本化 审计化让 Schema 变化可预测。
6.7 Contract 才是真正高风险阶段
Expand 通常是增加兼容性。
Contract 是删除兼容性。
所以:
DROP COLUMN DROP TABLE DROP INDEX都应该发生在最后,并经过依赖确认。
结语
数据库变更管理的本质,不是管理 DDL 文件,而是管理:
应用版本 Schema 版本 数据状态三者之间的兼容关系。
持续交付环境里最可靠的顺序通常是:
先扩展 Schema, 再升级应用, 再迁移数据, 最后收缩旧结构。可以把整套原则总结成一句话:
数据库先向前兼容, 应用再逐步切换, 破坏性变更永远最后执行。只要坚持这个顺序,Schema 变更就不再是“发布时赌一把”,而会成为可以灰度、可以验证、可以停留、可以前滚修复的工程化交付过程。
转载自:https://blog.csdn.net/u014727709/article/details/165243352
欢迎 👍点赞✍评论⭐收藏,欢迎指正