当数据量增长到数十万、数百万级别时,全表查询和逐条插入的性能会急剧下降。Spring Data 对分页和排序提供了统一而强大的抽象,而批量插入的优化则需要从 JPA 底层和 JDBC 层面入手。本节将结合实战场景,分析几种常用优化策略及其权衡。
13.5.1 分页与排序
Spring Data 的分页和排序支持是通过 Pageable 和 Sort 参数渗透到所有 Repository 方法中的。无论你使用 JPA、MongoDB 还是其他数据模块,编程模型完全一致。
1. 基本用法
在 Repository 接口中,直接在查询方法参数中添加 Pageable 或 Sort 即可:
public interface OrderRepository extends JpaRepository<Order, Long> {
// 根据用户ID分页查询订单,按创建时间降序
Page<Order> findByUserId(Long userId, Pageable pageable);
// 查询所有订单,仅排序不分页
List<Order> findByStatus(String status, Sort sort);
}
调用方构造 PageRequest(实现了 Pageable):
Page<Order> page = orderRepository.findByUserId(
1001L,
PageRequest.of(0, 20, Sort.by("createTime").descending())
);
// page对象携带了丰富的信息
List<Order> content = page.getContent(); // 当前页数据
long totalElements = page.getTotalElements(); // 总记录数
int totalPages = page.getTotalPages(); // 总页数
boolean hasNext = page.hasNext(); // 是否有下一页
2. 分页结果的高效利用
分页接口往往服务于 Web 层,直接返回 Page 对象可能会暴露过多内部结构。通常我们会构建一个专门的 PageResult<T> DTO:
public class PageResult<T> {
private List<T> data;
private long total;
private int page;
private int size;
public static <T> PageResult<T> from(Page<T> page) {
PageResult<T> result = new PageResult<>();
result.data = page.getContent();
result.total = page.getTotalElements();
result.page = page.getNumber();
result.size = page.getSize();
return result;
}
}
Controller 中直接转换:
@GetMapping("/users/{id}/orders")
public PageResult<Order> listOrders(@PathVariable Long id,
@PageableDefault(size = 20) Pageable pageable) {
return PageResult.from(orderRepository.findByUserId(id, pageable));
}
@PageableDefault 允许设置默认每页条数、默认排序字段等,避免前端漏传参数。
3. 动态排序
某些场景要求排序字段和方向完全由前端控制,应当对允许的字段做白名单校验,避免 SQL 注入或暴露内部列名:
private static final Set<String> ALLOWED_SORT_FIELDS = Set.of("createTime", "amount", "status");
public Sort buildSort(String sortField, String direction) {
if (sortField == null || !ALLOWED_SORT_FIELDS.contains(sortField)) {
sortField = "createTime"; // 默认值
}
Sort.Direction dir = "desc".equalsIgnoreCase(direction) ? Sort.Direction.DESC : Sort.Direction.ASC;
return Sort.by(dir, sortField);
}
然后构建 Pageable:
Pageable pageable = PageRequest.of(page, size, buildSort(sortField, direction));
4. 分页陷阱:大偏移量性能问题
LIMIT 1000, 20 这种大偏移量分页在 MySQL 中性能很差,因为数据库需要扫描前 1020 行再丢弃前 1000 行。当 total 动辄百万时,深分页会让查询越来越慢。
应对方案:
- 基于索引的游标分页(推荐):不使用
page和size,改用WHERE createTime < lastCreateTime ORDER BY createTime DESC LIMIT 20,前端每次传递上一页最后一条记录的排序字段值。Spring Data 没有原生游标支持,需要自定义查询:
@Query("SELECT o FROM Order o WHERE o.userId = :userId AND o.createTime < :lastTime ORDER BY o.createTime DESC")
List<Order> findNextPage(@Param("userId") Long userId,
@Param("lastTime") LocalDateTime lastTime,
Pageable pageable);
- 限制最大页码:如果业务允许,对分页深度做硬限制(如最多翻到第 200 页)。
- 使用
Slice替代Page:Slice不执行 count 查询,不返回总记录数,只用hasNext()判断是否还有更多数据,减轻数据库压力。写法完全一致,将返回类型改为Slice<Order>即可。
Slice<Order> findByUserId(Long userId, Pageable pageable);
13.5.2 批量插入优化
JPA 默认对每一条 save() 都执行一条 INSERT 语句,当批量插入几千条数据时,性能会变得难以忍受。优化路径需要从 JPA 配置、JDBC 批次一竿子插到底。
1. 启用 Hibernate 批量写入
在 application.properties 中配置:
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
spring.jpa.properties.hibernate.batch_versioned_data=true
batch_size:告诉 Hibernate 每 50 条 INSERT 语句打包为一个批次发送给数据库。不要设得过大,一般 30~50 为宜,过大会导致内存抖动或事务过长。order_inserts/order_updates:允许 Hibernate 重新排序插入和更新语句,让相同表的操作聚集在一起,提高批处理效率。batch_versioned_data:令版本数据(乐观锁版本号)也能参与批量操作。
单靠 Hibernate 的批处理,性能能有数量级的提升,但仍受限于 JDBC 层面能否真正支持批量。
2. 确保 JDBC 驱动开启批量改写
MySQL 的 JDBC 驱动默认不一定将多条 INSERT 语句改写为一条多值 INSERT。需要在连接 URL 添加参数:
spring.datasource.url=jdbc:mysql://localhost:3306/mydb?rewriteBatchedStatements=true&useSSL=false
rewriteBatchedStatements=true 是关键,它让驱动将
INSERT INTO t (a,b) VALUES (1,2);
INSERT INTO t (a,b) VALUES (3,4);
改写为
INSERT INTO t (a,b) VALUES (1,2),(3,4);
这可以大幅减少网络往返和解析开销。对于 PostgreSQL,驱动 reWriteBatchedInserts=true 也有类似效果。
3. 正确管理持久化上下文,防止内存溢出
批量插入操作中,持久化上下文(一级缓存)会持有所有被管理的实体,很容易拖垮内存。应当每处理若干条记录后刷新并清理:
@Transactional
public void batchInsert(List<Order> orders) {
int batchSize = 50;
for (int i = 0; i < orders.size(); i++) {
entityManager.persist(orders.get(i));
if (i % batchSize == 0 && i > 0) {
entityManager.flush(); // 将持久化上下文中的改动同步到数据库
entityManager.clear(); // 清空缓存,释放内存
}
}
entityManager.flush();
entityManager.clear();
}
注意:如果使用 saveAll(),Spring Data JPA 内部已经做了类似的批处理逻辑,但 saveAll 仍然会调用 merge 而非 persist,且对大规模数据,手动 persist + 定期 flush/clear 更显式可控。
4. 使用 JdbcTemplate 进行批量操作——终极性能方案
当数据量到达数十万以上,JPA 的脏检查、缓存等开销会成为瓶颈。此时应当回归 JdbcTemplate.batchUpdate 进行裸 SQL 批量写入:
@Repository
public class OrderBatchRepository {
private final JdbcTemplate jdbcTemplate;
public OrderBatchRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public void batchInsert(List<Order> orders) {
String sql = "INSERT INTO orders (user_id, amount, status, create_time) VALUES (?, ?, ?, ?)";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
Order order = orders.get(i);
ps.setLong(1, order.getUserId());
ps.setBigDecimal(2, order.getAmount());
ps.setString(3, order.getStatus());
ps.setTimestamp(4, Timestamp.valueOf(order.getCreateTime()));
}
@Override
public int getBatchSize() {
return orders.size();
}
});
}
}
如果需要处理超大数据集,可结合分批次提交,每 1000 条执行一次并开启多线程或异步,同时监控数据库事务日志增长。这种方式放弃了 JPA 的实体管理,但可获得与数据库最直接的交互性能,适合 ETL、数据迁移等场景。
5. 其他实用技巧
- 使用数据库特有的导入工具:如 MySQL 的
LOAD DATA INFILE或 PostgreSQL 的COPY,对海量数据(百万级)导入比任何 JDBC 批处理都快一个量级。可以在 Spring 项目中通过JdbcTemplate执行LOAD DATA LOCAL INFILE语句,但需注意文件权限和安全风险。 - 关闭自动提交:批处理应始终在事务中执行,连接不能是
auto-commit模式,Spring 事务管理器已确保这一点。 - 避免生成主键的策略拖累:如果使用
IDENTITY主键生成策略,Hibernate 无法批量插入,因为它必须每插一条立即获取自增值,会禁用批处理。应改用SEQUENCE或TABLE策略,配合pooled或pooled-lo优化器,使得 Hibernate 可以预先拿出一批 ID,然后批量发送 INSERT。对于 MySQL,无法使用序列,但可以选用UUID或手动分配 ID(如雪花算法),牺牲一点 ID 友好度换取批量性能。 - 合理评估与监控:增加
batch_size后必须监控数据库的响应时间、事务日志大小、内存消耗以及可能的长事务锁竞争。批量虽然快,但一个超大事务可能导致锁持有时间过长,影响线上并发。最佳实践是将一个大批次拆成多个小事务依次提交,每个小批次内部执行批量写入。
13.5.3 综合权衡
分页和排序的优化更偏向使用习惯和架构设计(游标 vs 偏移),而批量插入的优化则是在 JPA 的便利性与 JDBC 的极致性能之间做取舍。在实际项目中,我们的选择往往是分层的:常规 CRUD 使用 Spring Data JPA 的分页和排序能力,享受开发效率;批处理作业、数据同步等高性能场景则下沉到 JdbcTemplate 甚至数据库原生导入。两者在同一个应用中可以和谐共存,重要的不是追随某个教条,而是明确每个场景的瓶颈所在,以数据验证优化效果,做出切合实际的工程选择。