Spring Data JPA 批量查询与批量写入优化实战指南

Spring Data JPA 批量查询与批量写入优化实战指南

一、为什么需要批量优化

1.1 逐条操作的隐性成本

一次看似简单的 repository.findByCode(code) 调用,底层经历了以下过程:

复制代码
应用代码 → 获取数据库连接(连接池)
         → 构建SQL → 网络传输到DB
         → DB解析+执行+返回结果
         → 网络传输回应用
         → 释放连接到连接池
         → ORM结果映射

单次耗时约 3-10ms,但循环 N 次后:

N(数据量) 总耗时(@5ms/次) 连接获取/释放次数
100 0.5s 100次
1000 5s 1000次
5000 25s 5000次
50000 250s(超时) 50000次

1.2 批量操作的本质

将 N 次 "请求-响应" 循环压缩为 1 次(或常数次),利用数据库 IN 查询一次性返回所有结果。

复制代码
-- 逐条:执行N次
SELECT * FROM member_base WHERE seller_code = '8800001';
SELECT * FROM member_base WHERE seller_code = '8800002';
...
SELECT * FROM member_base WHERE seller_code = '8800N';

-- 批量:执行1次
SELECT * FROM member_base WHERE seller_code IN ('8800001','8800002',...,'8800N');

注:

博客:

https://blog.csdn.net/badao_liumang_qizhi

二、Spring Data JPA 批量查询 API

2.1 方法命名派生查询

java 复制代码
public interface UserRepository extends JpaRepository<User, Integer> {

    // 单条查询(返回唯一结果,多条会抛NonUniqueResultException)
    User findByEmail(String email);

    // 单条查询(安全版,多条时取第一条)
    User findFirstByEmail(String email);

    // 批量IN查询(核心API)
    List<User> findByIdIn(List<Integer> ids);
    List<User> findByEmailIn(Collection<String> emails);
    List<User> findByStatusIn(Set<Integer> statuses);
}

2.2 Spring Data JPA 命名规则

关键字 方法示例 生成SQL
In findByIdIn(List) WHERE id IN (?,?,?)
NotIn findByIdNotIn(List) WHERE id NOT IN (?,?,?)
Between findByAgeBetween(a,b) WHERE age BETWEEN ? AND ?
First findFirstByCode(code) LIMIT 1(避免多条异常)

2.3 @Query 自定义批量查询

java 复制代码
public interface OrderRepository extends JpaRepository<Order, Long> {

    // JPQL方式
    @Query("SELECT o FROM Order o WHERE o.orderNo IN :orderNos")
    List<Order> findByOrderNos(@Param("orderNos") List<String> orderNos);

    // 原生SQL方式
    @Query(value = "SELECT * FROM orders WHERE status = :status AND create_time > :time",
           nativeQuery = true)
    List<Order> findRecentByStatus(@Param("status") Integer status, 
                                   @Param("time") Date time);
}

2.4 findAllById 内置方法

JPA 内置了 findAllById,适合按主键批量查询:

java 复制代码
// JpaRepository 内置方法
List<User> users = userRepository.findAllById(Arrays.asList(1, 2, 3, 4, 5));

三、批量查询的底层原理

3.1 IN 查询的执行计划

sql 复制代码
EXPLAIN SELECT * FROM member_base WHERE seller_code IN ('88001','88002',...,'88500');
场景 索引命中 执行方式
seller_code 有索引,IN 数量 < 1000 Index Range Scan
seller_code 有索引,IN 数量 > 1000 可能退化 可能全表扫描
seller_code 无索引 Full Table Scan

3.2 MySQL IN 子句限制

限制维度 默认值 说明
max_allowed_packet 64MB 单次 SQL 最大字节数
IN 元素数量 无硬性上限 但超过 1000 个优化器可能不走索引
预编译参数数 65535 JDBC PreparedStatement 参数上限

最佳实践:每批 500-1000 个,超出分批查询。

3.3 Hibernate 的 IN 查询优化

Hibernate 5.2+ 引入了 IN clause padding,会将 IN 参数数量对齐到 2 的幂次方,增加 PreparedStatement 缓存命中率:

yaml 复制代码
spring:
  jpa:
    properties:
      hibernate:
        query:
          in_clause_parameter_padding: true
-- 原始:IN (?,?,?,?,?) 5个参数
-- Padding后:IN (?,?,?,?,?,?,?,?) 8个参数(补null)
-- 下次 IN 6/7/8个参数时可复用同一PreparedStatement

四、批量写入 API 与优化

4.1 saveAll vs 循环 save

java 复制代码
// ❌ 逐条保存:N次事务 + N次flush
for (User user : userList) {
    userRepository.save(user);
}

// ✅ 批量保存:1次事务 + 1次flush
userRepository.saveAll(userList);

4.2 Hibernate 批量 INSERT 配置

默认情况下 saveAll 仍可能逐条执行 INSERT,需配置批量参数:

yaml 复制代码
spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 500          # 每500条刷一次
        order_inserts: true        # 同类型INSERT排序后批量执行
        order_updates: true        # 同类型UPDATE排序后批量执行
        generate_statistics: true  # 开启统计(调试用)

配置后,Hibernate 会将多条 INSERT 合并为一次 JDBC executeBatch()

复制代码
-- 未优化:逐条执行
INSERT INTO user (name,email) VALUES ('A','a@x.com');
INSERT INTO user (name,email) VALUES ('B','b@x.com');

-- 优化后:JDBC Batch
addBatch: INSERT INTO user (name,email) VALUES ('A','a@x.com');
addBatch: INSERT INTO user (name,email) VALUES ('B','b@x.com');
executeBatch(); // 一次网络往返

4.3 主键生成策略的影响

策略 支持批量INSERT 原因
IDENTITY(自增) Hibernate 需逐条 INSERT 获取生成的 ID
SEQUENCE(序列) 可预分配 ID 段
TABLE(表生成) 可预分配 ID 段
UUID(应用生成) 无需数据库参与

注意 :如果实体使用 @GeneratedValue(strategy = IDENTITY),即使配了 batch_size,Hibernate 也不会真正批量 INSERT。


五、批量查询结果转 Map 的模式

5.1 基础模式

java 复制代码
// 查询结果 → Map<key, entity>
Map<String, User> userMap = userRepository.findByEmailIn(emails)
    .stream()
    .collect(Collectors.toMap(User::getEmail, u -> u));

// 使用:O(1) 查找
User user = userMap.get("test@example.com");

5.2 处理 key 重复

java 复制代码
// 一个 key 对应多条记录时,取第一条
Map<String, User> map = list.stream()
    .collect(Collectors.toMap(User::getEmail, u -> u, (u1, u2) -> u1));

// 一个 key 对应多条记录时,按列表分组
Map<String, List<User>> groupMap = list.stream()
    .collect(Collectors.groupingBy(User::getDeptCode));

5.3 判断存在性用 Set

java 复制代码
// 只需判断"是否存在"时,用 Set 更高效
Set<String> existingEmails = userRepository.findByEmailIn(emails)
    .stream()
    .map(User::getEmail)
    .collect(Collectors.toSet());

// O(1) 判断
if (existingEmails.contains("test@example.com")) { ... }

六、分批查询工具方法

6.1 通用分批查询工具

java 复制代码
/**
 * 分批执行IN查询,避免单次IN过大.
 *
 * @param allKeys 所有待查询的key
 * @param batchSize 每批大小
 * @param queryFn 查询函数
 * @return 合并后的结果
 */
public static <K, R> List<R> batchQuery(List<K> allKeys, int batchSize,
        Function<List<K>, List<R>> queryFn) {
    if (allKeys == null || allKeys.isEmpty()) {
        return new ArrayList<>();
    }
    List<R> result = new ArrayList<>();
    for (int i = 0; i < allKeys.size(); i += batchSize) {
        List<K> batch = allKeys.subList(i, Math.min(i + batchSize, allKeys.size()));
        List<R> batchResult = queryFn.apply(batch);
        if (batchResult != null) {
            result.addAll(batchResult);
        }
    }
    return result;
}

使用方式:

java 复制代码
List<User> allUsers = BatchUtil.batchQuery(
    allEmails, 500, 
    batch -> userRepository.findByEmailIn(batch)
);

6.2 分批查询 + 转 Map 组合

java 复制代码
public static <K, V, R> Map<K, V> batchQueryToMap(
        List<K> allKeys, int batchSize,
        Function<List<K>, List<R>> queryFn,
        Function<R, K> keyMapper,
        Function<R, V> valueMapper) {
    List<R> allResults = batchQuery(allKeys, batchSize, queryFn);
    return allResults.stream()
        .collect(Collectors.toMap(keyMapper, valueMapper, (v1, v2) -> v1));
}

七、完整示例代码

以下以"学生成绩批量录入"为场景演示完整的批量优化实现。

7.1 实体定义

java 复制代码
@Data
@Entity
@Table(name = "student")
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "student_no", unique = true)
    private String studentNo;

    @Column(name = "name")
    private String name;

    @Column(name = "class_code")
    private String classCode;

    @Column(name = "status")
    private Integer status;
}

@Data
@Entity
@Table(name = "score_record")
public class ScoreRecord {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "student_no")
    private String studentNo;

    @Column(name = "subject")
    private String subject;

    @Column(name = "score")
    private BigDecimal score;

    @Column(name = "exam_date")
    private Date examDate;

    @Column(name = "create_time")
    private Date createTime;
}

7.2 Repository 定义

java 复制代码
public interface StudentRepository extends JpaRepository<Student, Long> {

    // 单条查询(可能抛 NonUniqueResultException)
    Student findByStudentNo(String studentNo);

    // 安全的单条查询
    Student findFirstByStudentNo(String studentNo);

    // ★ 批量IN查询
    List<Student> findByStudentNoIn(List<String> studentNos);

    // 按班级批量查询
    List<Student> findByClassCodeIn(List<String> classCodes);
}

public interface ScoreRecordRepository extends JpaRepository<ScoreRecord, Long> {

    // 批量查询已存在的成绩记录
    List<ScoreRecord> findByStudentNoInAndSubject(
            Collection<String> studentNos, String subject);
}

7.3 Service 实现(优化前 vs 优化后)

java 复制代码
@Slf4j
@Service
public class ScoreImportServiceImpl implements ScoreImportService {

    @Resource
    private StudentRepository studentRepository;

    @Resource
    private ScoreRecordRepository scoreRecordRepository;

    /**
     * ❌ 优化前:逐条查询 + 逐条保存.
     * 5000条数据约需 35-60秒
     */
    public ImportResult importScoresSlow(List<ScoreExcelDto> dataList, String subject) {
        List<String[]> failList = new ArrayList<>();
        int successCount = 0;

        for (ScoreExcelDto dto : dataList) {
            // 每条数据 1 次DB查询
            Student student = studentRepository.findFirstByStudentNo(dto.getStudentNo());
            if (student == null) {
                failList.add(new String[]{dto.getStudentNo(), "学生不存在"});
                continue;
            }

            // 每条数据 1 次DB查询
            List<ScoreRecord> existing = scoreRecordRepository
                    .findByStudentNoInAndSubject(
                            Collections.singletonList(dto.getStudentNo()), subject);
            if (!existing.isEmpty()) {
                failList.add(new String[]{dto.getStudentNo(), "成绩已录入"});
                continue;
            }

            // 每条数据 1 次DB写入
            ScoreRecord record = new ScoreRecord();
            record.setStudentNo(dto.getStudentNo());
            record.setSubject(subject);
            record.setScore(dto.getScore());
            record.setExamDate(new Date());
            record.setCreateTime(new Date());
            scoreRecordRepository.save(record);
            successCount++;
        }

        return new ImportResult(successCount, failList.size());
    }

    /**
     * ✅ 优化后:批量预查询 + 内存校验 + 批量保存.
     * 5000条数据约需 3-8秒
     */
    @Override
    public ImportResult importScoresFast(List<ScoreExcelDto> dataList, String subject) {
        List<String[]> failList = new ArrayList<>();
        List<ScoreRecord> successList = new ArrayList<>();

        // ========== 第一步:提取并去重所有学号 ==========
        List<String> allStudentNos = dataList.stream()
                .map(ScoreExcelDto::getStudentNo)
                .filter(Objects::nonNull)
                .map(String::trim)
                .distinct()
                .collect(Collectors.toList());

        // ========== 第二步:批量预查询(分批,每批500) ==========

        // 2.1 批量查询学生信息 → Map
        Map<String, Student> studentMap = new HashMap<>();
        for (int i = 0; i < allStudentNos.size(); i += 500) {
            List<String> batch = allStudentNos.subList(i,
                    Math.min(i + 500, allStudentNos.size()));
            List<Student> students = studentRepository.findByStudentNoIn(batch);
            if (students != null) {
                for (Student s : students) {
                    studentMap.putIfAbsent(s.getStudentNo(), s);
                }
            }
        }

        // 2.2 批量查询已存在的成绩记录 → Set
        Set<String> existingStudentNos = new HashSet<>();
        for (int i = 0; i < allStudentNos.size(); i += 500) {
            List<String> batch = allStudentNos.subList(i,
                    Math.min(i + 500, allStudentNos.size()));
            List<ScoreRecord> existingRecords =
                    scoreRecordRepository.findByStudentNoInAndSubject(batch, subject);
            if (existingRecords != null) {
                for (ScoreRecord r : existingRecords) {
                    existingStudentNos.add(r.getStudentNo());
                }
            }
        }

        // ========== 第三步:内存中逐条校验(0次DB) ==========
        Set<String> batchDuplicate = new HashSet<>();

        for (ScoreExcelDto dto : dataList) {
            String studentNo = dto.getStudentNo();

            if (studentNo == null || studentNo.trim().isEmpty()) {
                failList.add(new String[]{studentNo, "学号不能为空"});
                continue;
            }
            String trimmedNo = studentNo.trim();

            // 批次内去重
            if (batchDuplicate.contains(trimmedNo)) {
                failList.add(new String[]{studentNo, "文件中学号重复"});
                continue;
            }

            // 学生是否存在(Map O(1))
            Student student = studentMap.get(trimmedNo);
            if (student == null) {
                failList.add(new String[]{studentNo, "学生不存在"});
                continue;
            }

            // 学生状态校验(内存判断)
            if (!Objects.equals(student.getStatus(), 1)) {
                failList.add(new String[]{studentNo, "学生已休学或退学"});
                continue;
            }

            // 成绩是否已录入(Set O(1))
            if (existingStudentNos.contains(trimmedNo)) {
                failList.add(new String[]{studentNo, "该科目成绩已录入"});
                continue;
            }

            // 校验通过,构建实体
            ScoreRecord record = new ScoreRecord();
            record.setStudentNo(trimmedNo);
            record.setSubject(subject);
            record.setScore(dto.getScore());
            record.setExamDate(new Date());
            record.setCreateTime(new Date());
            successList.add(record);
            batchDuplicate.add(trimmedNo);
        }

        // ========== 第四步:批量保存(1次DB) ==========
        int successCount = 0;
        if (!successList.isEmpty()) {
            try {
                scoreRecordRepository.saveAll(successList);
                successCount = successList.size();
            } catch (Exception e) {
                log.error("批量保存失败,降级为逐条", e);
                for (ScoreRecord record : successList) {
                    try {
                        scoreRecordRepository.save(record);
                        successCount++;
                    } catch (Exception ex) {
                        failList.add(new String[]{record.getStudentNo(),
                                "保存失败:" + ex.getMessage()});
                    }
                }
            }
        }

        return new ImportResult(successCount, failList.size(), failList);
    }
}

7.4 性能对比测试

java 复制代码
@SpringBootTest
public class ScoreImportPerfTest {

    @Resource
    private ScoreImportService scoreImportService;

    @Test
    public void testPerformanceComparison() {
        // 准备5000条测试数据
        List<ScoreExcelDto> dataList = generateTestData(5000);

        // 逐条方式
        long start1 = System.currentTimeMillis();
        ImportResult result1 = scoreImportService.importScoresSlow(dataList, "数学");
        long cost1 = System.currentTimeMillis() - start1;

        // 批量方式
        long start2 = System.currentTimeMillis();
        ImportResult result2 = scoreImportService.importScoresFast(dataList, "英语");
        long cost2 = System.currentTimeMillis() - start2;

        System.out.println("逐条方式耗时: " + cost1 + "ms"); // ~35000-60000ms
        System.out.println("批量方式耗时: " + cost2 + "ms"); // ~3000-8000ms
        System.out.println("性能提升: " + (cost1 / cost2) + "倍");
    }
}

八、总结与最佳实践

原则 做法
查询用 IN 批量 findByXxxIn(List) 替代循环 findByXxx(single)
结果用 Map/Set 缓存 查一次,后续 O(1) 访问
写入用 saveAll 批量 收集到 List 后一次性保存
IN 不超过 1000 超出则分批查询
配合 Hibernate batch_size 让 saveAll 真正生成 batch SQL
批量失败要降级 saveAll 异常时逐条重试定位问题
批次内去重 用 Set 记录已处理的 key,防止 Excel 内重复数据