人人都会AI编程

13.5 分页、排序、批量插入优化

更新时间:2026-07-10

当数据量增长到数十万、数百万级别时,全表查询和逐条插入的性能会急剧下降。Spring Data 对分页和排序提供了统一而强大的抽象,而批量插入的优化则需要从 JPA 底层和 JDBC 层面入手。本节将结合实战场景,分析几种常用优化策略及其权衡。

13.5.1 分页与排序

Spring Data 的分页和排序支持是通过 PageableSort 参数渗透到所有 Repository 方法中的。无论你使用 JPA、MongoDB 还是其他数据模块,编程模型完全一致。

1. 基本用法

在 Repository 接口中,直接在查询方法参数中添加 PageableSort 即可:

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 动辄百万时,深分页会让查询越来越慢。

应对方案

  • 基于索引的游标分页(推荐):不使用 pagesize,改用 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 替代 PageSlice 不执行 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 无法批量插入,因为它必须每插一条立即获取自增值,会禁用批处理。应改用 SEQUENCETABLE 策略,配合 pooledpooled-lo 优化器,使得 Hibernate 可以预先拿出一批 ID,然后批量发送 INSERT。对于 MySQL,无法使用序列,但可以选用 UUID 或手动分配 ID(如雪花算法),牺牲一点 ID 友好度换取批量性能。
  • 合理评估与监控:增加 batch_size 后必须监控数据库的响应时间、事务日志大小、内存消耗以及可能的长事务锁竞争。批量虽然快,但一个超大事务可能导致锁持有时间过长,影响线上并发。最佳实践是将一个大批次拆成多个小事务依次提交,每个小批次内部执行批量写入。

13.5.3 综合权衡

分页和排序的优化更偏向使用习惯和架构设计(游标 vs 偏移),而批量插入的优化则是在 JPA 的便利性与 JDBC 的极致性能之间做取舍。在实际项目中,我们的选择往往是分层的:常规 CRUD 使用 Spring Data JPA 的分页和排序能力,享受开发效率;批处理作业、数据同步等高性能场景则下沉到 JdbcTemplate 甚至数据库原生导入。两者在同一个应用中可以和谐共存,重要的不是追随某个教条,而是明确每个场景的瓶颈所在,以数据验证优化效果,做出切合实际的工程选择。