Spring Data JPA 批量查询与批量写入优化实战策略

 更新时间:2026年06月26日 09:13:45   作者:霸道流氓气质  
本文详细解析了SpringDataJPA批量查询与批量写入的优化策略,包括批量查询API使用、底层原理及最佳实践,以及批量写入的配置与优化技巧,感兴趣的朋友跟随小编一起看看吧

一、为什么需要批量优化

1.1 逐条操作的隐性成本

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

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

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

N(数据量)总耗时(@5ms/次)连接获取/释放次数
1000.5s100次
10005s1000次
500025s5000次
50000250s(超时)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');

二、Spring Data JPA 批量查询 API

2.1 方法命名派生查询

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
InfindByIdIn(List)WHERE id IN (?,?,?)
NotInfindByIdNotIn(List)WHERE id NOT IN (?,?,?)
BetweenfindByAgeBetween(a,b)WHERE age BETWEEN ? AND ?
FirstfindFirstByCode(code)LIMIT 1(避免多条异常)

2.3@Query自定义批量查询

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.4findAllById内置方法

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

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

三、批量查询的底层原理

3.1IN查询的执行计划

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

3.2 MySQL IN 子句限制

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

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

3.3 Hibernate 的 IN 查询优化

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

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

四、批量写入 API 与优化

4.1saveAllvs 循环save

// ❌ 逐条保存:N次事务 + N次flush
for (User user : userList) {
    userRepository.save(user);
}
// ✅ 批量保存:1次事务 + 1次flush
userRepository.saveAll(userList);

4.2 Hibernate 批量 INSERT 配置

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

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 基础模式

// 查询结果 → 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 重复

// 一个 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

// 只需判断"是否存在"时,用 Set 更高效
Set<String> existingEmails = userRepository.findByEmailIn(emails)
    .stream()
    .map(User::getEmail)
    .collect(Collectors.toSet());
// O(1) 判断
if (existingEmails.contains("test@example.com")) { ... }

六、分批查询工具方法

6.1 通用分批查询工具

/**
 * 分批执行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;
}

使用方式:

List<User> allUsers = BatchUtil.batchQuery(
    allEmails, 500, 
    batch -> userRepository.findByEmailIn(batch)
);

6.2 分批查询 + 转 Map 组合

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 实体定义

@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 定义

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 优化后)

@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 性能对比测试

@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 内重复数据

到此这篇关于Spring Data JPA 批量查询与批量写入优化实战指南的文章就介绍到这了,更多相关Spring Data JPA 批量查询与批量写入内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • springboot使用war包部署到外部tomcat过程解析

    springboot使用war包部署到外部tomcat过程解析

    这篇文章主要介绍了springboot使用war包部署到外部tomcat过程解析,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
    2020-01-01
  • 解决HttpServletResponse和HttpServletRequest取值的2个坑

    解决HttpServletResponse和HttpServletRequest取值的2个坑

    这篇文章主要介绍了解决HttpServletResponse和HttpServletRequest取值的2个坑问题,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教
    2023-12-12
  • 详解Java数组扩容缩容与拷贝的实现和原理

    详解Java数组扩容缩容与拷贝的实现和原理

    这篇文章主要带大家学习数组的扩容、缩容及拷贝,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-05-05
  • java  Super 用法详解及实例代码

    java Super 用法详解及实例代码

    这篇文章主要介绍了java Super 用法详解及实例代码的相关资料,需要的朋友可以参考下
    2017-03-03
  • MyBatis找不到mapper文件的实现

    MyBatis找不到mapper文件的实现

    这篇文章主要介绍了MyBatis找不到mapper文件的实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2020-10-10
  • Spring Boot Admin 监控指标接入Grafana可视化的实例详解

    Spring Boot Admin 监控指标接入Grafana可视化的实例详解

    Spring Boot Admin2 自带有部分监控图表,如图,有线程、内存Heap和内存Non Heap,这篇文章主要介绍了Spring Boot Admin 监控指标接入Grafana可视化,需要的朋友可以参考下
    2022-11-11
  • Java中判断字符串是否相等的实现

    Java中判断字符串是否相等的实现

    这篇文章主要介绍了Java中判断字符串是否相等的实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2021-01-01
  • Spring-DI依赖注入全过程

    Spring-DI依赖注入全过程

    Spring DI是核心特性,通过容器管理依赖注入,降低耦合度,实现方式包括组件扫描、构造器/设值/字段注入、自动装配及作用域配置,支持灵活的依赖管理与生命周期控制,提升代码可维护性与可测试性
    2025-08-08
  • 详解JAVA使用Comparator接口实现自定义排序

    详解JAVA使用Comparator接口实现自定义排序

    这篇文章主要介绍了JAVA使用Comparator接口实现自定义排序,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2019-03-03
  • Java编程 多态

    Java编程 多态

    这篇文章主要介绍了关于Java编程的多态,多态通过分离做什么和怎么做,从另一个角度将接口和实现分离开来。构建可扩展的程序,需要的朋友可以参考下
    2021-10-10

最新评论