news 2026/9/14 1:17:34

应用版本升级中的数据库变更管理——Schema 变更、持续交付与回滚方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
应用版本升级中的数据库变更管理——Schema 变更、持续交付与回滚方案

文章目录

    • 每日一句正能量
    • 前言
    • 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 NULL

3.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_name

JDBC:

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_name

4.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 duration

4.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
欢迎 👍点赞✍评论⭐收藏,欢迎指正

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 1:15:21

SSD主控固件DDR初始化:从硅片时序到FTL数据结构实战

1. 这不是内存“填空”&#xff0c;而是主控固件的生死时序战SSD 主控固件启动时在 DDR 中初始化哪些数据结构&#xff1f;这个问题表面看是问“填了什么”&#xff0c;但实际是在问&#xff1a;主控芯片上电复位后&#xff0c;如何在毫秒级窗口内&#xff0c;用最精简、最确定…

作者头像 李华
网站建设 2026/9/14 0:55:43

TensorFlow人脸识别全链路实现:从预处理到ArcFace部署

简介&#xff1a;本资源是一套基于Python与TensorFlow框架实现的完整人脸识别系统源代码&#xff0c;面向计算机科学、人工智能及电子工程等专业的高年级本科生、研究生与技术爱好者&#xff0c;适用于课程设计、实验开发与科研原型构建。代码经过充分测试&#xff0c;可直接运…

作者头像 李华
网站建设 2026/9/14 0:46:03

油烟机霍尔传感器损坏难题:FOC驱动方案全面解析与可靠性提升

油烟机霍尔传感器容易损坏&#xff1f;这个问题在电机控制圈子里确实太典型了。做厨电驱动这几年&#xff0c;我经手过不少返修机&#xff0c;拆开一看十有八九是霍尔传感器先挂了&#xff0c;电机转不动、转速反馈忽高忽低&#xff0c;最后整机报故障。更头疼的是&#xff0c;…

作者头像 李华
网站建设 2026/9/14 0:44:34

SSD1306 OLED驱动实战:从Adafruit库到局部刷新优化

简介&#xff1a;Adafruit SSD1306驱动库是为SSD1306 OLED显示模块设计的开源库&#xff0c;主要面向Arduino、ESP8266等微控制器开发者&#xff0c;其核心优势在于用简洁API替代复杂的底层寄存器操作&#xff0c;让用户无需深入理解驱动原理即可实现文本、图形、图像与动画显示…

作者头像 李华
网站建设 2026/9/14 0:43:28

跨会话实体画像演进:从单轮对话到全生命周期用户认知

跨会话实体画像演进&#xff1a;从单轮对话到全生命周期用户认知在构建面向 C 端陪伴型或 B 端高净值客户专属的智能体&#xff08;Agent&#xff09;系统时&#xff0c;用户与系统的交互通常分散在**跨越数月甚至数年的成百上千次独立会话&#xff08;Cross-Session Dialogues…

作者头像 李华