Spring Boot 3 升级数据迁移,我踩了这3个坑,你千万别再犯 专栏导读Spring Boot 3.x 企业级实战从零到offer的完整路径共7天带你从入门到精通。已发布5篇。天数文章标题状态第1天Spring Boot 3.x 生产环境配置管理实战别再用application.properties踩坑了已发布第2天Spring Boot 3.x 自定义Starter实战面试官死磕的自动配置原理我翻源码帮你画透了已发布第3天Spring Boot 3.x金融系统安全实战JWT双Token、接口防刷与敏感数据加密面试直接拿满分已发布第4天血泪教训线上CPU飙到500%后我这样5分钟救回来的已发布第5天高并发下接口耗时狂飙这3个高可用设计让QPS从500冲到5000已发布文章目录坑一字段类型变更双写方案救我一命坑二增量同步时binlog格式不对漏了数据坑三数据校验偷懒上线后发现1000多单状态不对完整的迁移流程一张图说清楚性能对比双写对业务的影响总结与进阶思考上两篇咱们聊了Spring Boot 3升级时的配置文件改造和依赖兼容性检查后台有哥们留言说他们公司正在搞MySQL 5.7升8.0Spring Boot这边也跟着升到3.x结果数据迁移差点把生产搞挂。他问我有没有什么实战经验能分享。说实话这事儿我太有发言权了。去年我们组把一个跑了5年的老项目从Spring Boot 2.7升到3.2数据库从MySQL 5.7升到8.0光数据迁移这个环节我连续通宵了3天。不是代码难写是坑太多而且都是生产环境才能暴露的那种。今天我把这3个最大的坑分享出来每个坑背后都是真实的血泪史。坑一字段类型变更双写方案救我一命事情是这样的。我们有个订单表t_order里面有个status字段原来定义是tinyint(1)存0和1表示状态。MySQL 8.0之后我们想把它改成varchar(20)存枚举值比如PENDING、SUCCESS、FAILED。结果DBA一句话把我问住了你要改字段类型这表3000万数据alter table至少锁表40分钟咱们能停服吗肯定不能停服啊双十一刚过每天订单量还在高峰。我当时想到的方案就一个双写。简单说就是老字段继续用新字段同步写读的时候优先读新字段过渡期间两个字段都维护。等数据追平了再切到只读写新字段。核心代码如下package com.example.migration.service; import com.example.migration.entity.Order; import com.example.migration.mapper.OrderMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Optional; /** * 订单服务 - 双写过渡期实现 * * author 架构师老李 * since 2024-01-15 */ Slf4j Service RequiredArgsConstructor public class OrderDualWriteService { private final OrderMapper orderMapper; /** * 双写同时维护老字段和新字段 * * 为什么要这样做如果直接读新字段老接口可能还在用老字段的值 * 双写保证两边的数据实时一致 */ Transactional(rollbackFor Exception.class) public void updateOrderStatus(Long orderId, String newStatus) { // 1. 先查出订单避免并发覆盖问题加行锁 Order order orderMapper.selectByIdForUpdate(orderId); if (order null) { log.error(订单不存在: {}, orderId); throw new RuntimeException(订单不存在); } // 2. 老字段兼容status是tinyint存0/1 // 根据新状态反写老字段让老接口还能用 Integer oldStatus convertNewToOld(newStatus); order.setStatus(oldStatus); // 3. 写入新字段 order.setStatusNew(newStatus); order.setGmtUpdate(System.currentTimeMillis()); // 4. 更新数据库一次update同时维护两个字段 int rows orderMapper.updateById(order); if (rows 0) { log.error(更新订单状态失败id{}, newStatus{}, orderId, newStatus); throw new RuntimeException(更新失败请重试); } log.info(双写成功orderId{}, oldStatus{}, newStatus{}, orderId, oldStatus, newStatus); } /** * 新状态 - 旧状态映射 * 这里需要根据实际业务规则转换 */ private Integer convertNewToOld(String newStatus) { return switch (newStatus) { case PENDING, FAILED - 0; case SUCCESS - 1; default - throw new IllegalArgumentException(未知状态: newStatus); }; } /** * 读取订单状态优先读新字段 * 如果新字段为空fallback到老字段 */ public String queryOrderStatus(Long orderId) { Order order orderMapper.selectById(orderId); if (order null) { return UNKNOWN; } // 优先返回新字段 if (order.getStatusNew() ! null !order.getStatusNew().isEmpty()) { return order.getStatusNew(); } // fallback如果新字段没值老数据用老字段推算 return convertOldToNew(order.getStatus()); } private String convertOldToNew(Integer oldStatus) { return oldStatus ! null oldStatus 1 ? SUCCESS : PENDING; } }对应的实体类package com.example.migration.entity; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; /** * 订单实体 - 双写过渡期结构 * 注意这种结构只在过渡期用迁移完成后删除老字段 */ Data TableName(t_order) public class Order { TableId private Long id; /** * 老字段tinyint(1)过渡期后删除 */ TableField(status) private Integer status; /** * 新字段varchar(20)目标字段 */ TableField(status_new) private String statusNew; private Long gmtCreate; private Long gmtUpdate; }⚠️ 血泪教训双写期间的并发问题你可能会想我写完老的再写新的不就行了 不行一定要在同一个事务里同一个SQL里写完。否则可能出现老字段写成功、新字段写失败的脏数据。上面代码里我用了selectByIdForUpdate加行锁就是防止两个请求同时改同一条记录导致状态覆盖。人话解释这段代码想想你在搬家老房子还没退租新房子刚开始住。这时候你不能只在一个地方放东西得两个地方都有不然家里人来老房子找你扑个空。双写就是这个思路过渡期两个字段同时维护等所有上下游都切到新字段了老字段才能彻底扔掉。坑二增量同步时binlog格式不对漏了数据双写搞定了当天的数据但历史数据怎么办表里3000万条老数据status_new字段全是NULL得追平啊。我第一个想法用Canal订阅binlog增量同步到新字段。这方案看起来完美不锁表、不影响业务。结果真干起来第二天就出事了。运维告诉我你这同步程序漏了200多单 我一查还真是。原因出在binlog的格式上。MySQL的binlog有三种格式STATEMENT记录SQL语句ROW记录每行数据变化MIXED混用我们当时配的是MIXED大部分时候没问题但碰到INSERT ... ON DUPLICATE KEY UPDATE这种SQLbinlog里只记录了affected_rows没记录具体的字段变化。结果Canal解析出来的数据不全导致一部分订单的状态没同步过去。排查了一上午最后找到原因。换成ROW格式立马好了。-- 检查当前binlog格式 SHOW VARIABLES LIKE binlog_format; -- 修改为ROW格式需要重启或动态修改 SET GLOBAL binlog_format ROW;但问题来了改binlog格式要重启MySQL吗有些版本支持动态修改但保险起见建议在低峰期重启确认生效。⚠️ 血的教训用Canal或Maxwell做增量同步binlog格式必须是ROW而且必须提前验证。怎么验证拿几条测试数据手动改一下去MQ里看消息能不能完整解析出来。别像我一样上线跑了3天才发现漏数据。批量追平历史数据的完整代码package com.example.migration.sync; import com.example.migration.entity.Order; import com.example.migration.mapper.OrderMapper; import com.github.pagehelper.PageHelper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; /** * 历史数据批量追平 * * 核心思路 * 1. 分页查status_new为NULL的记录 * 2. 根据status老字段推算出status_new的值 * 3. 批量update注意limit控制每次更新的行数避免长事务 * * author 架构师老李 */ Slf4j Service RequiredArgsConstructor public class HistoryDataRepairService { private final OrderMapper orderMapper; private final AtomicInteger successCount new AtomicInteger(0); /** * 分批追平历史数据 * * 为什么要分页更新3000万数据一次updateundo log能撑爆磁盘 * 而且会导致从库延迟巨大。每批1000条是小步快跑的策略 */ Async(migrationExecutor) public void repairHistoryData() { int pageSize 1000; int pageNum 1; int totalHandled 0; log.info(开始追平历史数据...); while (true) { // 分页查status_new为NULL的记录 PageHelper.startPage(pageNum, pageSize, false); ListOrder orders orderMapper.selectWhereStatusNewIsNull(); if (orders.isEmpty()) { log.info(历史数据追平完成总共处理: {} 条, totalHandled); break; } for (Order order : orders) { try { // 根据老status推算新status String newStatus deduceNewStatus(order.getStatus()); order.setStatusNew(newStatus); order.setGmtUpdate(System.currentTimeMillis()); // 逐条更新也可以批量看数据量 int rows orderMapper.updateStatusNewById(order.getId(), order.getStatusNew()); if (rows 0) { successCount.incrementAndGet(); } } catch (Exception e) { log.error(追平单条数据失败id{}, order.getId(), e); } } totalHandled orders.size(); log.info(已处理: {} 条成功: {} 条进度: {}/30000000, totalHandled, successCount.get(), totalHandled); // 每10000条休息1秒避免打满数据库CPU if (totalHandled % 10000 0) { try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } pageNum; } } private String deduceNewStatus(Integer oldStatus) { return oldStatus ! null oldStatus 1 ? SUCCESS : PENDING; } }人话解释这段代码分批追平就像搬家时用小推车一箱一箱搬你不会想一次搬完所有东西太重了。每次搬1000条搬完歇一下再搬下一批。这样数据库不会炸从库也不会延迟太大。坑三数据校验偷懒上线后发现1000多单状态不对历史数据追平了增量同步也跑起来了双写也运行了一周。看起来一切正常DBA问我要不今晚就把老字段删了我想了想说先校验一下数据确认迁移后数据一致。结果一校验吓一跳3000万数据里有1200多单的status_new和status对应关系有问题有的是因为老业务逻辑里有个隐藏状态2退款的我们新枚举里没有有的是因为并发修改导致双写不同步。如果当时一激动直接删了老字段这1200单的数据就丢了妥妥的生产事故。数据校验核心逻辑package com.example.migration.checker; import java.util.ArrayList; import java.util.List; /** * 数据迁移后的校验工具 * * 核心校验逻辑 * 1. 新字段不能为NULL确保数据追平完成 * 2. 新老字段的映射关系必须正确 * 3. 发现不一致的数据记录到fix表人工审核后修复 * * author 架构师老李 */ Slf4j Service RequiredArgsConstructor public class DataConsistencyChecker { private final OrderMapper orderMapper; /** * 全量数据一致性校验 * * return 不一致的数据量统计 */ public ConsistencyReport checkAllData() { int pageSize 500; int pageNum 1; ListString inconsistencies new ArrayList(); int totalChecked 0; int nullCount 0; int mismatchCount 0; log.info(开始全量数据校验...); while (true) { PageHelper.startPage(pageNum, pageSize, false); ListOrder orders orderMapper.selectAllWithPagination(); if (orders.isEmpty()) { break; } for (Order order : orders) { totalChecked; // 校验1新字段不能为NULL if (order.getStatusNew() null || order.getStatusNew().isEmpty()) { nullCount; inconsistencies.add(String.format( 订单%d: status_new为NULL, status%d, order.getId(), order.getStatus() )); continue; } // 校验2新老字段映射关系 String expectedNew convertOldToNew(order.getStatus()); if (!expectedNew.equals(order.getStatusNew())) { mismatchCount; inconsistencies.add(String.format( 订单%d: 映射不一致, status%d, status_new%s, 期望%s, order.getId(), order.getStatus(), order.getStatusNew(), expectedNew )); } // 每检查10万条输出一次进度 if (totalChecked % 100000 0) { log.info(校验进度: {} 条发现不一致: {}, totalChecked, inconsistencies.size()); } } pageNum; } // 如果发现不一致写入修复表 if (!inconsistencies.isEmpty()) { log.error(发现数据不一致共{}条详细信息已写入t_fix_queue表, inconsistencies.size()); saveToFixQueue(inconsistencies); } return new ConsistencyReport(totalChecked, nullCount, mismatchCount); } private String convertOldToNew(Integer oldStatus) { if (oldStatus null) return PENDING; return switch (oldStatus) { case 1 - SUCCESS; case 0, 2 - PENDING; // 2是隐藏的退款状态映射为PENDING default - UNKNOWN; }; } private void saveToFixQueue(ListString inconsistencies) { // 写入修复队列表供人工审核 for (String record : inconsistencies) { orderMapper.insertFixRecord(record); } } /** * 校验报告 */ public record ConsistencyReport(int totalChecked, int nullCount, int mismatchCount) { public boolean isClean() { return nullCount 0 mismatchCount 0; } } }⚠️ 血的教训数据校验不能偷懒别用select count(*) where status_new is null这种简单的检查那只能发现最明显的问题。必须逐行对比新老字段的映射关系而且要用业务规则校验不是简单的等值判断。上面那个隐藏状态2如果不做业务规则校验根本发现不了。人话解释这段代码数据校验就像搬家后的盘点你不能只看东西在不在还得看放得对不对。冰箱里的东西放到了卫生间柜子里虽然东西没丢但肯定不对啊。咱们要逐行检查发现不对的记到修复表人工审核后再修。完整的迁移流程一张图说清楚本来想画个Mermaid图但CSDN的Markdown有时候渲染不好我用文字描述一下整体流程第1步ALTER TABLE添加新字段online DDL不锁表 ↓ 第2步上线双写代码老字段新字段同时写 ↓ 第3步批量追平历史数据status_new为NULL的 ↓ 第4步开启增量同步Canal监听binlogROW格式 ↓ 第5步等待1-2周让增量同步跑稳定 ↓ 第6步全量数据校验业务规则校验不是简单count ↓ 第7步校验通过 ├→ 否修复不一致数据回到第6步 └→ 是停掉增量同步下线双写代码 ↓ 第8步切换所有读接口到新字段 ↓ 第9步观察1周确认无异常 ↓ 第10步DROP COLUMN删除老字段终于可以删了性能对比双写对业务的影响有哥们肯定关心双写会不会拖慢性能给你看实测数据。压测环境机器4核8G ECS x 3台MySQL 8.0 高可用版JVM-Xms2g -Xmx2g -XX:UseG1GC并发500线程持续30分钟压测结果指标单写原方案双写过渡期影响平均QPS85008200↓ 3.5%P99响应时间120ms135ms↑ 12.5%数据库CPU35%42%↑ 7%锁等待0.2次/秒0.8次/秒↑ 4倍订单错误率0.01%0.015%↑ 0.005%说实话性能确实有影响但完全在可接受范围内。一个过渡期3.5%的QPS下降换来的是数据一致性保证这个买卖值。而且过渡期就2-3周之后删了老字段性能就回来了。总结与进阶思考今天讲的这3个坑双写并发问题、binlog格式漏数据、数据校验偷懒每一个都真实发生过。说实话数据迁移这事儿没有银弹都是靠细节堆出来的。你可能觉得这些方案有点重但我要说的是在生产环境改3000万数据不重不行。轻量级方案往往意味着数据风险出了问题你背锅。篇幅有限还有几个高级玩法没展开怎么用Spring AOP实现无侵入的双写不用改Service代码怎么用Redis缓存缓解双写期间的数据库压力怎么设计灰度切换策略让部分流量先走新字段亿级数据的迁移怎么用ShardingSphere分片并行处理这些我在专栏后面的高级进阶篇会详细讲包括源码级别的拆解。最后送你一句话数据迁移的难度不在于写代码在于预判所有可能出问题的地方并且提前兜底。今天这3个坑你要是记住了至少能省3天通宵。觉得有用就点个赞想系统学Spring Boot 3升级全流程的关注专栏。下篇咱们聊更刺激的——AOP灰度开关一行代码无感切换新老逻辑业务方都不知道你做了升级。