1. 项目背景与核心需求
在业务系统开发中,批量数据的插入或更新(saveOrUpdateBatch)是一个高频需求。MyBatis-Plus虽然提供了saveOrUpdateBatch方法,但默认仅支持根据主键(@TableId)进行判断。实际业务中,我们经常需要根据非主键字段(如唯一索引字段、业务编码字段等)作为判断依据来执行批量操作。
这个需求源于几个典型场景:
- 数据同步场景:需要根据第三方系统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);
}
}
2.2 自定义方案设计
我们需要改造的核心点:
- 替换默认的主键判断逻辑
- 支持自定义字段作为匹配条件
- 保持批量操作的性能优势
方案架构:
code复制自定义Service层
↓
重写saveOrUpdateBatch方法
↓
构建QueryWrapper动态条件
↓
批量查询存在性检查
↓
分流处理(批量插入/批量更新)
3. 完整实现代码
3.1 基础实现类
java复制public class CustomServiceImpl<M extends BaseMapper<T>, T> extends ServiceImpl<M, T> {
/**
* 根据指定字段批量保存或更新
* @param entityList 实体列表
* @param fieldNames 匹配字段名数组
* @return 执行结果
*/
public boolean saveOrUpdateBatchByFields(Collection<T> entityList, String... fieldNames) {
if (CollectionUtils.isEmpty(entityList) || ArrayUtils.isEmpty(fieldNames)) {
return false;
}
// 分批处理避免过大事务
List<List<T>> batches = Lists.partition(new ArrayList<>(entityList), 1000);
batches.forEach(batch -> {
// 1. 构建查询条件
Map<QueryWrapper<T>, T> wrapperMap = new HashMap<>();
batch.forEach(entity -> {
QueryWrapper<T> wrapper = new QueryWrapper<>();
for (String fieldName : fieldNames) {
Object value = ReflectionUtils.getFieldValue(entity, fieldName);
if (value == null) {
throw new RuntimeException(fieldName + " cannot be null");
}
wrapper.eq(StrUtil.toUnderlineCase(fieldName), value);
}
wrapperMap.put(wrapper, entity);
});
// 2. 批量查询存在记录
List<T> existList = list(wrapperMap.keySet());
// 3. 分流处理
List<T> toInsert = new ArrayList<>();
List<T> toUpdate = new ArrayList<>();
batch.forEach(entity -> {
boolean exists = existList.stream().anyMatch(e ->
Arrays.stream(fieldNames)
.allMatch(field ->
Objects.equals(
ReflectionUtils.getFieldValue(entity, field),
ReflectionUtils.getFieldValue(e, field)
)
)
);
if (exists) {
toUpdate.add(entity);
} else {
toInsert.add(entity);
}
});
// 4. 执行批量操作
if (!toInsert.isEmpty()) {
saveBatch(toInsert);
}
if (!toUpdate.isEmpty()) {
updateBatchById(toUpdate);
}
});
return true;
}
}
3.2 反射工具类
java复制public class ReflectionUtils {
private static final Map<Class<?>, Map<String, Field>> FIELD_CACHE = new ConcurrentHashMap<>();
public static Object getFieldValue(Object obj, String fieldName) {
try {
Field field = getField(obj.getClass(), fieldName);
field.setAccessible(true);
return field.get(obj);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static Field getField(Class<?> clazz, String fieldName) {
return FIELD_CACHE.computeIfAbsent(clazz, k -> new ConcurrentHashMap<>())
.computeIfAbsent(fieldName, name -> {
Field field = null;
Class<?> current = clazz;
while (current != Object.class && field == null) {
try {
field = current.getDeclaredField(name);
} catch (NoSuchFieldException ignored) {
current = current.getSuperclass();
}
}
if (field == null) {
throw new RuntimeException("Field not found: " + name);
}
return field;
});
}
}
4. 高级功能扩展
4.1 支持Lambda表达式
java复制public <V> boolean saveOrUpdateBatchByFields(
Collection<T> entityList,
SFunction<T, V>... fieldFunctions) {
String[] fieldNames = Arrays.stream(fieldFunctions)
.map(LambdaUtils::getFieldName)
.toArray(String[]::new);
return saveOrUpdateBatchByFields(entityList, fieldNames);
}
4.2 批量更新优化策略
对于大批量更新场景,可以采用:
- 临时表策略:先批量导入到临时表,再通过JOIN更新
- CASE WHEN语句:生成动态SQL
- 分批并行处理
java复制// CASE WHEN示例
public void batchUpdateByCondition(List<T> entityList, String... fieldNames) {
String tableName = getTableName();
String idColumn = getKeyColumn();
String sql = "UPDATE " + tableName + " SET " +
entityList.stream()
.flatMap(entity -> {
String idValue = String.valueOf(ReflectionUtils.getFieldValue(entity, "id"));
return Arrays.stream(fieldNames)
.map(field -> {
String column = StrUtil.toUnderlineCase(field);
Object value = ReflectionUtils.getFieldValue(entity, field);
return column + " = CASE WHEN " + idColumn + " = '" + idValue +
"' THEN '" + value + "' ELSE " + column + " END";
});
})
.collect(Collectors.joining(", ")) +
" WHERE " + idColumn + " IN (" +
entityList.stream()
.map(e -> "'" + ReflectionUtils.getFieldValue(e, "id") + "'")
.collect(Collectors.joining(",")) +
")";
executeSql(sql);
}
5. 性能优化建议
5.1 批量操作参数调优
yaml复制# application.yml
mybatis-plus:
global-config:
db-config:
# 批次提交量
batch-size: 1000
# 重试次数
retry-count: 3
configuration:
# 开启批量操作模式
default-executor-type: batch
5.2 索引优化建议
- 确保匹配字段有索引
- 复合字段匹配时建立联合索引
- 大数据量时考虑分区表
sql复制-- 示例索引
CREATE INDEX idx_match_fields ON your_table(field1, field2, field3);
5.3 事务控制策略
java复制@Transactional(propagation = Propagation.REQUIRED,
isolation = Isolation.READ_COMMITTED,
rollbackFor = Exception.class)
public boolean saveOrUpdateBatchWithTx(Collection<T> entityList, String... fields) {
return saveOrUpdateBatchByFields(entityList, fields);
}
6. 常见问题排查
6.1 字段值为null导致匹配失败
现象:当匹配字段包含null值时操作失败
解决方案:
java复制// 修改Wrapper构建逻辑
if (value == null) {
wrapper.isNull(StrUtil.toUnderlineCase(fieldName));
} else {
wrapper.eq(StrUtil.toUnderlineCase(fieldName), value);
}
6.2 批量操作超时
现象:大数据量时出现超时
优化方案:
- 增加超时时间
java复制@Transactional(timeout = 300)
- 采用分批次处理
- 使用异步处理
6.3 字段名转换问题
现象:驼峰字段与下划线字段不匹配
解决方案:
java复制// 自定义字段名转换器
public String convertFieldName(String fieldName) {
// 自定义转换逻辑
return StrUtil.toUnderlineCase(fieldName);
}
7. 最佳实践建议
-
字段选择原则:
- 优先选择具有唯一约束的字段
- 避免使用可空字段作为匹配条件
- 复合字段匹配时注意顺序一致性
-
事务隔离建议:
- 读操作使用READ_COMMITTED
- 写操作考虑SERIALIZABLE
-
监控指标:
java复制// 添加性能监控 StopWatch watch = new StopWatch(); watch.start("batchOperation"); // 执行操作... watch.stop(); log.info("操作耗时:{}ms", watch.getTotalTimeMillis()); -
异常处理策略:
java复制try { return saveOrUpdateBatchByFields(entityList, fieldNames); } catch (Exception e) { // 重试逻辑 return retryTemplate.execute(ctx -> saveOrUpdateBatchByFields(entityList, fieldNames)); }
在实际项目中,我通常会根据数据量大小选择不同的实现策略。对于小批量数据(<1000条),直接使用本文的基础方案即可;对于大批量数据,建议采用临时表+CASE WHEN的组合方案。特别注意字段选择要谨慎,不恰当的匹配字段可能导致严重的性能问题甚至数据一致性问题。
