场景:批量同步 500 条很慢,怎么系统性优化(脱敏版 + MyBatis-Plus 伪代码)
说明:本文所有表名/字段/类名/业务标识均已脱敏 ;但性能痛点与优化路径与真实代码一致。示例用 MyBatis-Plus 风格伪代码,便于迁移思路。
1. 背景与现象
你有一个"外部平台 → 本地系统"的品牌/字典类数据同步任务:
- 外部接口分页拉取,每页 500 条(或类似量级)
- 本地落库逻辑是典型 存在则更新,不存在则创建(Upsert)
- 早期实现:500 条大约 10 秒 ,优化一轮后到 2秒,仍想继续压
目标:在不改变业务语义(幂等、单条失败不中断、可追溯)的前提下,提升吞吐。
2. 初始实现(慢的根因)
2.1 典型"逐条查询 + 逐条写入"(N+1)
伪代码如下(痛点保持一致):
java
for (SyncDTO dto : pageFromRemote) {
// 1) 每条先查一次:是否存在
Entity entity = entityMapper.selectOne(
new LambdaQueryWrapper<Entity>()
.eq(Entity::getBizKey, dto.getBizKey())
.eq(Entity::getDeleted, false)
);
if (entity == null) {
// 2) 每条 insert 一次
Entity newEntity = convert(dto);
entityMapper.insert(newEntity);
} else {
// 3) 每条 update 一次(即使字段没变化也写)
apply(dto, entity);
entityMapper.updateById(entity);
}
}
2.2 为什么 500 条会慢
- 数据库往返次数太多:500 次 select + 500 次 insert/update(至少 1000 次往返)
- 行级更新无差别落库:字段没变也 update,吞吐被写 IO 拉低
- 重复业务键:同一批里如果 key 重复,会重复查/重复写
- 索引缺失或不命中:where 条件没索引时,selectOne 会退化扫描
- 日志过细:每条成功都打 info 会让 I/O 变成瓶颈之一(尤其容器化/集中日志)
3. 优化总览:从"逐条 IO"到"批量决策 + 批量 IO"
核心思路分三层:
- Query 合并:把 N 次查询合成 1 次批量查询(IN)
- Write 减少:能不写就不写(diff),必须写就批量写(batch)
- 数据质量兜底:同批去重、幂等键一致、异常隔离
4. 优化方案(脱敏版)
4.1 P0:批量预加载(1 次查询替代 N 次查询)
步骤
- 收集本批 DTO 的业务键
bizKey - 一次性查出所有已存在记录,构建 Map
- 循环时用 Map 判断存在与否
伪代码(MyBatis-Plus):
java
Set<String> keys = dtos.stream()
.map(SyncDTO::getBizKey)
.filter(StringUtils::isNotBlank)
.collect(toSet());
List<Entity> existing = entityMapper.selectList(
new LambdaQueryWrapper<Entity>()
.in(Entity::getBizKey, keys)
.eq(Entity::getDeleted, false)
.select(Entity::getId, Entity::getBizKey,
Entity::getFieldA, Entity::getFieldB, Entity::getFieldC)
);
Map<String, Entity> existingMap = existing.stream()
.collect(toMap(Entity::getBizKey, e -> e, (a, b) -> a));
关键点:
select(...)只取需要的字段,减少网络与反序列化成本- 依赖 bizKey 索引(见 4.4)
4.2 P1:diff 更新(无变化不落库)
很多同步数据"重复推送"或"变化比例很低"。
无差别 update 会造成大量写放大(write amplification)。
伪代码:
java
boolean applyIfChanged(SyncDTO dto, Entity e) {
boolean changed = false;
if (!Objects.equals(e.getFieldA(), dto.getFieldA())) {
e.setFieldA(dto.getFieldA());
changed = true;
}
if (!Objects.equals(e.getFieldB(), dto.getFieldB())) {
e.setFieldB(dto.getFieldB());
changed = true;
}
if (!Objects.equals(e.getFieldC(), dto.getFieldC())) {
e.setFieldC(dto.getFieldC());
changed = true;
}
if (changed) {
e.setUpdatedAt(now());
e.setUpdatedBy(SYSTEM_USER_ID);
}
return changed;
}
收益:当"更新但未变化"的比例高时,写入量可下降到接近 0。
4.3 P1:批量写入(insert/update 分批执行)
将本批 DTO 分为:
toInserttoUpdate(仅包含 diff 后确实变化的)
并分片批量写(例如 200 条一批,防止 SQL/事务过大):
java
List<Entity> toInsert = new ArrayList<>();
List<Entity> toUpdate = new ArrayList<>();
for (SyncDTO dto : dtos) {
Entity existing = existingMap.get(dto.getBizKey());
if (existing == null) {
toInsert.add(buildForInsert(dto));
// 占位避免同批重复 key 再 insert
existingMap.put(dto.getBizKey(), PLACEHOLDER);
} else if (existing != PLACEHOLDER) {
if (applyIfChanged(dto, existing)) {
toUpdate.add(existing);
}
}
}
// 分片批量写
int chunk = 200;
for (List<Entity> part : partition(toInsert, chunk)) {
// MyBatis-Plus 常用 saveBatch 由 Service 层提供;这里用伪代码表示
entityService.saveBatch(part, chunk);
}
for (List<Entity> part : partition(toUpdate, chunk)) {
entityService.updateBatchById(part, chunk);
}
注意:
- MyBatis-Plus 的批量通常在
ServiceImpl(saveBatch/updateBatchById)里 - 真正的"批量"取决于执行器配置(
ExecutorType.BATCH)与 JDBC driver 行为
但即便不是纯 batch,也能减少 flush/事务压力。
4.4 索引与唯一性(决定批量查询性能上限)
同步幂等键(bizKey)必须具备:
- 普通索引 :提升
IN (...)查询性能 - 视业务而定可加 唯一索引:防止脏数据导致重复记录
SQL(脱敏示例):
sql
CREATE INDEX idx_entity_biz_key ON entity_table(biz_key);
-- 或
CREATE UNIQUE INDEX uk_entity_biz_key ON entity_table(biz_key);
建议:如果你的"存在判定"完全依赖 bizKey,唯一索引通常更安全(但要先清洗历史重复数据)。
4.5 同批去重(避免重复写与逻辑抖动)
如果外部数据同一批可能出现重复 key,建议先做去重:
java
// 保留最后一条(或第一条,按业务口径)
Map<String, SyncDTO> dedup = new LinkedHashMap<>();
for (SyncDTO dto : dtos) {
dedup.put(dto.getBizKey(), dto);
}
List<SyncDTO> uniq = new ArrayList<>(dedup.values());
4.6 日志与错误处理(快且可排查)
建议日志策略:
- 成功:每批输出 summary(count/耗时)
- 失败:只对失败条目输出详情(key + 堆栈)
- 不要对每条成功
info(I/O 成本真实存在)
伪代码:
java
StopWatch sw = new StopWatch();
sw.start();
int ok = 0, fail = 0;
for (...) {
try {
...
ok++;
} catch (Exception ex) {
fail++;
log.warn("sync failed, key={}, reason={}", dto.getBizKey(), ex.getMessage(), ex);
}
}
log.info("sync batch finished, ok={}, fail={}, costMs={}", ok, fail, sw.getTime());
5. 效果预期(经验值)
具体取决于:DB 性能、索引是否存在、变更率、是否真 batch、网络与日志环境等。
常见收益区间:
- P0(批量预加载 + 索引):10s → 6s(与你的实际一致)
- P1(diff + 批量写) :
- 如果变化率低:可能进一步明显下降
- 如果变化率高:也会因批量写减少往返而继续下降
- 终极(DB UPSERT):有机会到 1~2s,但侵入更大
6. 终极方案(可选):数据库 UPSERT(侵入更大,收益更高)
前提:
- bizKey 有唯一索引
MySQL 示例(脱敏):
sql
INSERT INTO entity_table(biz_key, field_a, field_b, updated_at)
VALUES (?, ?, ?, ?), (?, ?, ?, ?), ...
ON DUPLICATE KEY UPDATE
field_a = VALUES(field_a),
field_b = VALUES(field_b),
updated_at = VALUES(updated_at);
优点:一条 SQL 完成一批 upsert
缺点:绕过部分 ORM 生命周期/审计/校验,需要评估风险。
7. 小结
这类"同步 500 条很慢"的问题,本质不是某个函数调用慢,而是批处理模式不对:
- 从 逐条查询/逐条写 转为 批量预加载/批量写
- 从 无差别 update 转为 diff update
- 用 索引 把查询复杂度降下来
- 用 去重/日志策略 把边界问题和 I/O 成本控制住
只要这几个点做到位,绝大多数同步任务都能从"十秒级"进入"几秒级",甚至更低。