1. 项目背景与核心需求
在业务系统开发中,批量数据的插入或更新(saveOrUpdateBatch)是一个高频需求场景。MyBatis-Plus作为MyBatis的增强工具,虽然提供了saveOrUpdateBatch方法,但其默认实现仅支持根据主键(@TableId)进行判断。这在实际业务中常常遇到瓶颈——很多场景需要根据业务唯一键(如用户手机号、订单编号等非主键字段)来决定执行insert还是update操作。
最近在开发一个电商订单管理系统时,就遇到了这样的典型场景:需要根据order_no字段批量处理订单数据。系统每天要处理数十万条订单状态的更新,如果全部走主键ID判断,需要先查询出所有ID,这在性能和代码简洁性上都是不可接受的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术方案选型分析
2.1 默认方案的局限性
MyBatis-Plus默认的saveOrUpdateBatch实现逻辑如下:
java复制// 简化后的伪代码逻辑
for (T entity : entityList) {
if (entity.getId() != null) {
updateById(entity);
} else {
save(entity);
}
}
这种实现存在三个明显问题:
- 强依赖主键ID字段
- 批量操作实际被拆分为单条执行(3.5.0之前版本)
- 无法利用数据库的批量操作特性
2.2 可行的改造方案
经过对MyBatis-Plus源码的分析和实际验证,我们总结出三种可行的改造方案:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 方案1:继承重写 | 继承ServiceImpl重写方法 | 改动最小,易于维护 | 需要处理泛型类型 |
| 方案2:AOP拦截 | 通过AOP拦截方法调用 | 无侵入性 | 实现复杂度高 |
| 方案3:自定义SQL | 使用@Insert/@Update注解 | 性能最优 | 需要手写SQL |
考虑到团队技术栈和后期维护成本,我们最终选择了方案1作为基础实现。这个方案的核心在于扩展ServiceImpl类,通过反射获取自定义的唯一键字段。
3. 核心实现细节
3.1 基础框架搭建
首先创建基础抽象类,使用泛型支持不同类型的实体:
java复制public abstract class CustomBatchServiceImpl<M extends BaseMapper<T>, T>
extends ServiceImpl<M, T> {
@Override
public boolean saveOrUpdateBatch(Collection<T> entityList, int batchSize) {
// 实现细节见下文
}
}
3.2 唯一键字段识别
通过反射获取标注了特定注解的字段(这里我们自定义了@UniqueKey注解):
java复制private Field getUniqueKeyField(Class<?> clazz) {
for (Field field : clazz.getDeclaredFields()) {
if (field.isAnnotationPresent(UniqueKey.class)) {
field.setAccessible(true);
return field;
}
}
throw new BusinessException("未找到唯一键字段");
}
3.3 批量操作优化
改造后的核心逻辑分为三个步骤:
- 按唯一键分组现有数据
- 分离需要插入和更新的记录
- 执行真正的批量操作
关键实现代码:
java复制// 获取数据库已有记录(按唯一键分组)
Map<Object, T> existMap = listByUniqueKeys(uniqueKeys)
.stream()
.collect(Collectors.toMap(
e -> getFieldValue(e, uniqueField),
Function.identity()
));
// 分离插入和更新集合
List<T> insertList = new ArrayList<>();
List<T> updateList = new ArrayList<>();
for (T entity : entityList) {
Object keyValue = getFieldValue(entity, uniqueField);
if (existMap.containsKey(keyValue)) {
// 设置主键后加入更新集合
setId(entity, existMap.get(keyValue));
updateList.add(entity);
} else {
insertList.add(entity);
}
}
// 执行批量操作
if (!insertList.isEmpty()) {
saveBatch(insertList, batchSize);
}
if (!updateList.isEmpty()) {
updateBatchById(updateList, batchSize);
}
4. 性能优化要点
4.1 批量查询优化
原始方案中,如果直接循环查询每个记录是否已存在,会产生N+1查询问题。我们通过以下方式优化:
java复制@Select("<script>" +
"SELECT * FROM ${tableName} WHERE ${uniqueColumn} IN " +
"<foreach item='item' collection='list' open='(' separator=',' close=')'>" +
"#{item}" +
"</foreach>" +
"</script>")
List<T> listByUniqueKeys(@Param("tableName") String tableName,
@Param("uniqueColumn") String uniqueColumn,
@Param("list") Collection<?> values);
4.2 事务控制策略
批量操作必须考虑事务边界:
- 建议在Service方法上添加@Transactional
- 批量大小(batchSize)建议设置为500-1000
- 对于超大批量操作,考虑分片处理
4.3 并发处理方案
对于高并发场景,额外需要注意:
java复制// 添加分布式锁保护
String lockKey = "batch_update:" + entityClass.getSimpleName();
try {
if (redisLock.tryLock(lockKey, 10, TimeUnit.SECONDS)) {
// 执行批量操作
}
} finally {
redisLock.unlock(lockKey);
}
5. 实际应用案例
5.1 电商订单同步场景
在订单状态批量更新场景中,我们这样使用:
java复制@Service
public class OrderServiceImpl extends CustomBatchServiceImpl<OrderMapper, Order> {
public void batchSyncOrders(List<Order> orders) {
// 根据orderNo字段进行批量保存或更新
saveOrUpdateBatch(orders, 1000);
}
}
5.2 用户信息批量导入
对于用户信息的处理:
java复制@Entity
public class User {
@UniqueKey
private String mobile;
// 其他字段...
}
// 使用方式
userService.saveOrUpdateBatch(userList);
6. 常见问题排查
6.1 唯一键冲突异常
错误现象:
code复制Duplicate entry 'xxx' for key 'uk_mobile'
解决方案:
- 检查实体类@UniqueKey注解配置
- 确认数据库唯一索引是否建立
- 检查批量数据中是否存在重复值
6.2 字段值为null的问题
当唯一键字段为null时,会抛出NPE异常。需要在业务逻辑中加入校验:
java复制if (keyValue == null) {
throw new IllegalArgumentException("唯一键值不能为null");
}
6.3 性能瓶颈分析
当处理10万+数据时,可能会遇到:
- 内存溢出:需要分批次处理
- 执行超时:调整batchSize大小
- 数据库连接耗尽:配置合适的连接池参数
7. 扩展思考
7.1 多唯一键支持
某些场景可能需要组合多个字段作为业务唯一键。我们可以扩展注解:
java复制@UniqueKeys({
@UniqueKey(fields = {"field1", "field2"}),
@UniqueKey(fields = {"field3"})
})
public class Entity {
// 字段定义
}
7.2 与MyBatis-Plus新版本兼容
从3.5.0版本开始,MyBatis-Plus原生支持了批量操作的优化。我们的方案可以与之结合:
java复制// 在配置类中
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new BatchInsertInnerInterceptor());
return interceptor;
}
7.3 与Spring Data JPA的对比
相比JPA的@DynamicUpdate等特性,我们的方案:
- 更贴近MyBatis的灵活特性
- 性能优化空间更大
- 适合需要精细控制SQL的场景
在实际项目中,这个改造使我们的订单同步接口性能提升了8倍,从原来的平均1200ms降低到150ms左右。最大的收获是认识到框架提供的默认方案虽然通用,但针对特定业务场景进行定制化改造,往往能获得意想不到的效果。
