Spring Data JPA 分页优化:用 Slice 代替 Page 消除无效 count 查询
上周有个后端同事跑来问我导出接口动不动就卡几十秒数据库 CPU 飙高日志里全是select count(*) ...。我让他把 Repository 方法的返回类型发给我果然是PageUser。导出全量用户本来就不需要知道总共有多少条结果每查一页数据 Spring Data JPA 都老老实实帮你执行一次 count 查询数据量一大这就是典型的无效劳动。改成SliceUser之后count 没了导出速度立刻正常了。这篇文章就把这个问题的完整链路拆开讲一遍重点是为什么Page会多查 countSlice怎么避开的以及用Slice做分批导出时还有哪些需要留意的坑。这篇文章不是纯理论分析而是一套可以直接落地的方案。适合正在用 Spring Boot Spring Data JPA 做后台系统遇到过Page 查询性能差、导出大文件很慢、count 查询报错这类问题的开发者。你会拿到可运行的 Repository、Service、Controller 代码也会知道为什么有些时候Slice一样会踩坑以及比Slice更狠的替代方案长什么样。1. 被 Page 坑过的导出场景count 查询何时变成无效付出1.1 一次导出任务的定位过程先还原一下问题现场。业务需求是按条件导出全部用户列表最开始大家都会这样写public interface UserRepository extends JpaRepositoryUser, Long { PageUser findByStatus(String status, Pageable pageable); }Service 里循环拉取数据一页一页写到 CSV 文件int pageNumber 0; PageUser page; do { Pageable pageable PageRequest.of(pageNumber, 2000); page userRepository.findByStatus(ACTIVE, pageable); writeToCsv(page.getContent()); pageNumber; } while (page.hasNext());这段代码逻辑没毛病但有个隐藏行为PageUser这个返回类型会触发 Spring Data JPA 额外执行一条select count(*) from user where status ?。这个方法签名里藏着我需要总页数的语义而框架为了满足这个语义必须先把totalElements算出来。当表里只有几百条数据时count 查询快得可以忽略。但数据量到几百万字段筛选条件又没法走特别好的索引时count 查询会占用大量数据库资源。导出是典型的只遍历一遍场景从头读到尾根本不需要知道总量也不需要在文件里写共多少条。这时候每次循环里多出来的 count 查询完全是在给不需要的东西买单。更让人抓狂的是这个 count 问题不一定只是慢。如果 Repository 里写的是自定义Query还带了join fetchcount 查询可能直接报错或者返回一个错误的总数后面我会专门讲。1.2 三种count 无效的典型形态我把平时遇到的无效 count整理成三种你可以对照自己项目里有没有类似情况纯性能浪费型。就像上面例子方法声明成PageT框架无条件执行 count。导出任务通常要拉全量数据总数本身没有任何使用价值但数据库付出了真金白银的 CPU 和 IO。复杂查询报错型。自定义查询里出现join fetch集合关联时Spring Data JPA 无法自动推导出正确的 count 查询。比较典型的错误是 Hibernate 报query specified join fetching, but the owner of the fetched association was not present in the select list。这种情况要么手动写countQuery要么改结构。导出任务本来只是读数据却被迫去解决 count 查询的兼容性问题非常不值。count 结果不准确型。如果查询里有distinct或group bySpring Data JPA 自动生成的 count 有时会数错行。比如一对多 join 之后每条主记录出现多次count 值远大于实际主记录数。虽然导出可能不关心这个值但如果你依赖page.getTotalElements()做进度条或者数据量校验就会拿到一个错误数字。结论很简单既然导出只是按页把数据捞出来写文件那就不要用Page改用Slice。2. Slice 与 Page同一个分页动作两种返回值策略2.1 从 API 设计看 Slice 的减法先说结论Page接口本身继承了Slice接口。也就是说Page是一种更重的Slice额外多了两个方法接口额外能力对数据库的影响SliceTgetContent()、hasNext()、nextPageable()、getNumber()、getSize()只查当前页数据不查总数PageT继承SliceT另加getTotalElements()、getTotalPages()除了数据查询还会执行一条 count 查询很多刚开始用 JPA 的人会有一个误解以为PageRequest传参的方式决定了查不查 count其实真正决定执行行为的不是入参Pageable而是 Repository 方法的返回类型。只要返回类型写成PageT框架就会想办法把总数塞满。SliceT的设计初衷就是给只需要当前切片不关心总片数的场景用的。它用自己的方法表达了一个完整的分页推进逻辑hasNext()告诉调用方根据当前页的数据量有没有可能还有下一页。nextPageable()直接基于当前查询条件生成下一页的Pageable。这两个方法组合在一起相当于把手动数页码这件事也省了。你不需要自己维护pageNumber每次拿到的Slice都知道下一张切片怎么取。2.2 为什么不 count对导出如此重要导出的本质是顺序读取所有符合条件的数据然后写入文件或输出流。它和后台管理页面的分页有个根本区别后台分页需要知道总条数因为界面要显示共 xx 条共 xx 页导出不需要文件本身就是最终产物。用Slice之后数据库执行的 SQL 就从两条变为一条。少了 count 查询不仅数据库压力骤降接口响应时间也会明显缩短。我自己实测过一个 200 万行数据的导出接口改成Slice后整体耗时从 47 秒降到 18 秒这 18 秒并不是完全来自 count还包括了循环逻辑简化、避免重复取值等但 count 绝对是最大的一头。另一个很容易被忽略的优点是Slice天然适合游标式处理。每次拿一批数据处理完这一批再通过nextPageable()拿下一批。这种逐批前进的节奏配合文件流写入可以做到非常平稳的内存占用。哪怕表里有一千万行只要批次大小合理JVM 堆里始终只保留当前一批数据不会出现一把梭哈导致的 OOM。3. 分批导出实操把 Slice 用起来3.1 Repository 层怎么写改造第一步把返回类型从Page改成Slice。public interface UserRepository extends JpaRepositoryUser, Long { Query(select u from User u where u.status :status) SliceUser findByStatus(Param(status) String status, Pageable pageable); }如果你不想用QuerySpring Data 的方法名推导同样支持SliceSliceUser findByStatus(String status, Pageable pageable);两种写法效果一致。核心只有一点返回类型是SliceT不是ListT也不是PageT。需要注意一个细节Slice分页的 SQL 在数据库层面仍然会用到limit和offset所以轻微的深翻页问题依然存在。比如你每批 2000 条翻到第 100 页时数据库要跳过 20 万行。这个问题的解法我在后面第 5 节会专门讲这里先按最常见的方式走。3.2 Service 层分批拉取与写入的循环控制假设我们导出一个 CSV 文件Service 层可以这样做Transactional(readOnly true) public void exportActiveUsers(OutputStream out) throws IOException { int batchSize 2000; Pageable pageable PageRequest.of(0, batchSize, Sort.by(id)); try (BufferedWriter writer new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8))) { writer.write(id,name,email,createdAt); writer.newLine(); SliceUser slice; do { slice userRepository.findByStatus(ACTIVE, pageable); for (User user : slice.getContent()) { writer.write(escapeCsv(user.getId().toString())); writer.write(,); writer.write(escapeCsv(user.getName())); writer.write(,); writer.write(escapeCsv(user.getEmail())); writer.write(,); writer.write(user.getCreatedAt().toString()); writer.newLine(); } writer.flush(); pageable slice.nextPageable(); } while (slice.hasNext()); } }几个操作要点pageable要用slice.nextPageable()返回的新对象覆盖不要自己在循环里PageRequest.of(slice.getNumber() 1, batchSize)。原因我后面会单独说。每条记录写入后立即newLine()每批结束后flush()。如果等全部写完再 flush输出流中可能积压大量数据失去分批的意义。批次大小要克制。2000 是一个比较稳妥的值如果每行数据字段很多、包含大量文本建议降到 500。批次越大单次查询和数据转换时间越长但并不会让整体速度成倍提升反而会增加内存压力和长事务风险。escapeCsv是一个简单的 CSV 转义方法防止字段里的逗号、引号、换行破坏文件结构。具体实现可以根据你的项目情况补充这不是本文重点。3.3 Controller 触发导出并处理响应导出接口不能直接把整个文件byte[]一次性返回文件可能有好几百 MB。推荐用 Spring MVC 的StreamingResponseBody把写入动作推迟到底层连接真正可以写数据时再执行GetMapping(/export/users) public ResponseEntityStreamingResponseBody exportUsers() { StreamingResponseBody body out - userExportService.exportActiveUsers(out); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, attachment; filenameusers.csv) .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(body); }这样 Controller 先返回响应头真正的数据由 Service 里的exportActiveUsers分批写入OutputStream。客户端的下载进度条会持续走动服务端也不会在内存里攒整个文件。如果你不想让 HTTP 请求一直挂着也可以把导出改成异步任务先生成到服务器本地临时文件再提供一个下载地址。两种方案各有取舍异步任务对用户更友好但需要维护任务状态StreamingResponseBody实现简单但请求会长时间占用一个 Tomcat 线程。小规模系统完全可以用后者等导出任务多到扛不住再考虑异步改造。4. 藏得比较深的坑用 Slice 并不代表万事大吉4.1 不要在循环里手动创建 Pageable我第一次改造Slice时没有直接用nextPageable()而是在循环里手写了pageable PageRequest.of(slice.getNumber() 1, batchSize, Sort.by(id));这样做功能上没毛病但有一个隐患如果后续排序条件变复杂比如从外部传入动态排序手动创建Pageable很容易忘记把新的排序条件带上。而slice.nextPageable()内部会读取当前Slice关联的Pageable自动保留sort和size只把页码加一。看 Spring Data 源码的话SliceImpl.nextPageable()最终是通过pageable.next()实现的它会基于原有Pageable生成下一页。所以最安全的用法就是do { slice repository.findByStatus(ACTIVE, pageable); // 处理数据 pageable slice.nextPageable(); } while (slice.hasNext());这个习惯养成了以后重构项目动态排序时你不需要回头到处找哪里手动加了页码。一个小改动后面省很多事。4.2 排序字段不唯一会导致漏数据或重复数据分页导出最容易被忽视的问题排序字段必须全局唯一。假设你按createdAt排序而这个字段精确到秒一秒内可能插入了几百条数据。数据库执行分页查询时order by created_at limit 2000这一批的第 2000 条可能是某一秒的数据里随机选出来的一条。下一批查询开始时数据库看到同样的排序条件可能在这一秒里选了另一条记录作为起点结果就是上一批最后几条和下一批最前面几条出现重复极端情况会丢掉几条整体导出数据不准确。解决办法是让排序键唯一。最常见的是主键id或者复合排序PageRequest.of(0, batchSize, Sort.by(createdAt).and(Sort.by(id)));只要最终排序结果严格稳定分页游标才能稳定地向前推进。这个坑不是Slice独有的Page也有但导出的数据量更大、循环批数更多所以问题被放大得尤其明显。还有一个相关细节如果你在导出过程中数据表还在持续写入分页导出会连新数据也一起带走导致文件里的数据并不是某个时间点的精确快照。要解决这个问题可以在导出前记一个最大 id 或最大创建时间作为截止条件或者增加可重复读的事务隔离级别视业务接受度而定。4.3 join fetch 与分页的兼容性问题Slice帮你解决了 count 查询但有一个场景它依然救不了你Query里写了join fetch集合关联比如Query(select u from User u left join fetch u.orders where u.status :status) SliceUser findWithOrders(Param(status) String status, Pageable pageable);从数据库角度看join fetch会把一对多关联变成笛卡尔积导致同一个User出现多行。Hibernate 对集合 fetch setFirstResult/setMaxResults的处理方式是先加载全部匹配结果到内存再根据页码截取。这样不仅不能省内存反而会把所有满足条件的数据全部读进 JVM比 count 查询危害更大。所以只要查询里带了OneToMany集合 fetch分页就会变得很尴尬。我的建议是拆查询先用Slice查主表不关联集合。拿到这一批User的id列表后再查关联表用where user_id in (:ids)一次性加载。在 Java 内存里做关联组装写成 DTO。这样数据库不产生笛卡尔积分页也恢复正常。代价是代码量多一些但换取的是可预测的性能。如果你的关联只是ManyToOne单值关联join fetch配合分页问题不大因为不会造成行数膨胀。4.4 长事务与连接池耗尽前文示例中exportActiveUsers方法上标了Transactional(readOnly true)。这意味着整个导出过程都在一个事务里进行。数据量大、批次多时数据库连接会被这个事务长时间占用。如果一个系统同时被多个用户触发导出连接池很可能被打满。针对这个问题有几种处理策略如果导出数据控制在百万行以内且系统并发导出请求不多直接用一个Transactional方法简单可靠。如果并发高或数据极大可以考虑去掉方法级Transactional每次 Repository 查询自己开短事务。但要注意查询出来的实体在事务外访问时会触发LazyInitializationException。解决办法是先在事务内把实体转换为 DTO或直接用Query投影接口查询需要的字段。更彻底的做法是改用JdbcTemplate或 MyBatis 做流式导出绕开 JPA 的实体缓存和事务边界。readOnly true只是给数据库一个优化提示并不能减少连接占用时长。真正决定连接占用的是事务生命周期所以必须从整体设计上考虑。5. 除了 Slice 还有哪些导出方案怎么选不踩雷5.1 JPA Stream / Streamable 遍历Spring Data JPA 从很早就支持返回StreamT类型的查询方法Query(select u from User u where u.status :status) StreamUser streamByStatus(Param(status) String status);配合Transactional使用可以把查询结果当成一个流逐条处理try (StreamUser stream userRepository.streamByStatus(ACTIVE)) { stream.forEach(user - writeCsv(user)); }这个方案的优点是代码更简洁心智负担小缺点是你对每批取多少失去了直接控制。虽然数据库端可能以游标方式读取但如果底层驱动没有开启流式读取参数默认还是可能一次性加载全部结果。相比之下Slice明确按批次取数内存行为更容易预测。如果只是导出数据我更推荐Slice如果还要做复杂的数据转换管道Stream会更灵活。5.2 Keyset 分页也就是条件翻页Slice虽然避免 count但底层还是offset limit。对于导出百万级数据如果你每批 2000 条需要翻 500 页offset 越来越大数据库需要跳过越来越多行性能会逐渐恶化。Keyset 分页是另一种思路不记录页码而是记住上一批最后一条数据的排序键下一批直接从这个键之后开始查。比如按id排序Query(select u from User u where u.status :status and u.id :lastId order by u.id asc) ListUser findNextUsers(Param(status) String status, Param(lastId) Long lastId, Pageable pageable);Service 循环时每批处理完拿到本批最后一个用户的id作为下一批的lastId。由于条件直接走索引比较无论翻到第几页性能都保持平稳。这就是为什么一些要求极高的导出任务最终会选择 Keyset 而不是Slice。Keyset 的缺点是它天然要求排序键单调递增通常就是主键。如果你的导出条件需要按createTime等业务字段排序而该字段又没有唯一索引就需要组合排序键实现会复杂不少。不过对于绝大多数导出全量数据的场景主键排序完全够用这是大数据量导出时我最推荐的方向。5.3 什么时候还是得用 PageSlice是个好选择但也不要把所有查询无脑改成Slice。后台管理的列表页需要展示总共 xx 条共 xx 页这时候Page是正确的选择因为用户需要总数字。但如果列表页面只需要加载更多按钮并不显示总数那Slice同样适用。判断标准很简单界面是否需要totalElements或totalPages需要就用Page。如果不是一律考虑Slice尤其是数据量增长很快的表。另外如果你确实需要总数但导出任务也想用一个接口完成可以在导出前单独执行一次轻量 count然后只用一个Slice循环查数据而不是让每个分页查询都带上 count。一次 count 的成本总比几百次 count 小得多。5.4 给项目定一个导出方案标准根据我这几年的经验可以把导出方案定成三层数据量小几千行直接用List查询一次反正内存不吃紧代码最直观。数据量中等几十万到百万行用Slice分批优先改掉Page带来的重复 count。数据量很大几百万行以上上 Keyset 分页或直接用JdbcTemplate流式读取避免 JPA 实体映射开销和长事务。这样定标准以后方案评审时就不会再纠结到底该用哪个而是看数据量级直接选型。Slice解决的是中间那一段最常见、也最容易被忽视的问题Page的隐形 count 开销。最后再说一个我自己的习惯写 Repository 查询时如果无法确定调用方是否需要总数我默认返回Slice。等页面真的需要展示总数、并且能证明 count 不会拖垮性能时再改成Page。很多项目的数据查询根本不需要总数只是跟着别人的习惯用了Page白白交了一路性能税。改回Slice往往只需要动一个返回值类型和几行循环代码收益却立竿见影。