告别 N+1:MyBatis-Plus 批量同步优化套路(预加载、去重、批量落库)

场景:批量同步 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 次查询)

步骤
  1. 收集本批 DTO 的业务键 bizKey
  2. 一次性查出所有已存在记录,构建 Map
  3. 循环时用 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 分为:

  • toInsert
  • toUpdate(仅包含 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 的批量通常在 ServiceImplsaveBatch/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 成本控制住

只要这几个点做到位,绝大多数同步任务都能从"十秒级"进入"几秒级",甚至更低。

相关推荐
sxhcwgcy2 小时前
Spring Boot中集成MyBatis操作数据库详细教程
数据库·spring boot·mybatis
yuweiade2 小时前
Spring Boot 整合 MyBatis 与 PostgreSQL 实战指南
spring boot·postgresql·mybatis
弹简特3 小时前
【JavaEE】MybatisPlus速成
java·数据库·java-ee·mybatis
polaris06304 小时前
Spring Boot 集成 MyBatis 全面讲解
spring boot·后端·mybatis
MegaDataFlowers4 小时前
整合MyBatis
mybatis
tumeng07114 小时前
Spring Boot与MyBatis
spring boot·后端·mybatis
Lyyaoo.5 小时前
Mybatis
mybatis
荒古前21 小时前
Spring Boot + MyBatis 启动报错:不允许有匹配 “[xX][mM][lL]“ 的处理指令目标
spring boot·后端·mybatis
w1225h1 天前
IDEA搭建SpringBoot,MyBatis,Mysql工程项目
spring boot·intellij-idea·mybatis