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