
专栏导读:Spring Boot 3.x 企业级实战:从零到offer的完整路径,共7天带你从入门到精通。已发布5篇。

文章目录
上两篇咱们聊了Spring Boot 3升级时的配置文件改造和依赖兼容性检查,后台有哥们留言说他们公司正在搞MySQL 5.7升8.0,Spring 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这种SQL,binlog里只记录了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万数据一次update,undo 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);
List<Order> 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;
List<String> inconsistencies = new ArrayList<>();
int totalChecked = 0;
int nullCount = 0;
int mismatchCount = 0;
log.info("开始全量数据校验...");
while (true) {
PageHelper.startPage(pageNum, pageSize, false);
List<Order> 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(List<String> 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监听binlog,ROW格式!)
↓
第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分钟
压测结果:
| 指标 | 单写(原方案) | 双写(过渡期) | 影响 |
|---|---|---|---|
| 平均QPS | 8500 | 8200 | ↓ 3.5% |
| P99响应时间 | 120ms | 135ms | ↑ 12.5% |
| 数据库CPU | 35% | 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灰度开关:一行代码无感切换新老逻辑,业务方都不知道你做了升级。
946

被折叠的 条评论
为什么被折叠?



