
1. 我不知道它是什么直到一次分页把数据库查挂了先说个真实经历。几个月前我接手一个后台管理系统订单列表那个页面用的是典型的Page分页数据量到了百万级以后每次翻页都慢得让人怀疑人生。查了一下日志发现每一次简单的翻页操作都要先执行一次SELECT COUNT(*)去统计总量再执行一次数据查询两张表 join 之后数据量一大count 那个查询能把数据库 CPU 直接拉满。后来我把Page换成Slicecount 查询没了接口响应时间从两千多毫秒降到了一百毫秒以内。这个事儿让我意识到Spring Data 生态里Slice绝对是被低估的一个接口。很多同学项目里一上来就是Page并不知道Slice存在的意义。今天这篇就把Slice从头到尾讲透包括它和Page的本质区别、使用姿势、底层原理以及我实际踩过的坑。先说清楚这篇适合谁看你要是刚接触 Spring Data JPA想知道分页除了Page还有别的选择或者你已经在用Page但被 count 查询搞得焦头烂额又或者你想把列表接口的性能再抠一抠那这篇文章就是写给你的。全文没有任何花架子全是能直接抄走的代码和思路。我先用一个日常场景来类比Slice和Page的区别。你去餐厅吃饭Page的模式是服务员先跑一趟后厨数清楚今天一共进了多少斤菜然后回来告诉你本店一共能出 300 道菜你现在点的是第 1 页每页 10 道然后才把菜端上来。而Slice的模式是服务员压根不数总数直接问后厨先给我来 10 道菜端上来之后再问一句后厨还有菜吗有我就告诉你下页还有没有我就告诉你这就是最后一批了。你说哪种模式高效数据量小的时候感觉差不多一旦数据量大了Page多跑那一趟数菜的动作就是性能瓶颈。所以Slice的核心设计思想就是放弃精确的总数统计用有没有下一页来代替总共有多少页。这个思路在处理大批量数据、无限滚动加载、或者你压根不需要显示共 N 页这种 UI 场景时简直是降维打击。2. Slice 到底是什么不只是分页工具的另一种选择2.1 从一次真实的性能问题说起刚才说了我那个订单列表的真实经历这里把背景补完整。当时表里已经有两百多万条订单记录列表页有十来个筛选条件服务端用PageOrder来做分页查询。每个筛选条件都要在WHERE里拼接于是 count 查询变成了这样SELECT COUNT(o.id) FROM orders o LEFT JOIN order_items oi ON o.id oi.order_id WHERE o.status 0 AND o.amount 100 AND ...这种多表 join 下的 count 本身就很贵再加上两百多万行数据count 一次经常要几百毫秒甚至一秒多。而真正查当前页数据的 SQL 反而只要几十毫秒。你想想一次翻页操作90% 的时间都花在数总量上这合理吗更不合理的是用户根本不需要精确到个位数的总记录数。列表页底部显示共 2784356 条记录你觉得真的有用户会拿这个数字去核对吗99% 的场景下用户只需要知道还有没有下一页下一页还有多少条就够了。这就给Slice的出现提供了充分的理由牺牲一个不重要的精确总量统计换取巨大的查询性能提升。我当时把 Repository 里的方法签名从PageOrder findAllByStatus(...)改成SliceOrder findSliceByStatus(...)只改了这一个地方count 查询就彻底消失了。磁盘 IO 降了CPU 降了接口响应时间瘦身成功。2.2 Slice 与 Page 的本质区别在 Spring Data JPA 里Slice和Page都是分页查询的返回类型但它们的定位完全不同。先从接口继承关系说起。Page接口本身继承自Slice接口也就是说Page是一种更重的Slice。看下源码你就明白了public interface PageT extends SliceT { int getTotalPages(); long getTotalElements(); // ... 其他扩展方法 } public interface SliceT extends StreamableT { int getNumber(); // 当前页码从 0 开始 int getSize(); // 每页条数 int getNumberOfElements(); // 当前页实际返回的条数 ListT getContent(); // 当前页数据 boolean hasContent(); Sort getSort(); boolean isFirst(); // 是否是第一页 boolean isLast(); // 是否是最后一页 boolean hasNext(); // 是否有下一页 boolean hasPrevious(); // 是否有上一页 Pageable nextPageable(); // 下一页查询条件 Pageable previousPageable(); // 上一页查询条件 }Page在Slice的基础上额外增加了getTotalElements()和getTotalPages()这两个方法分别返回总记录数和总页数。这就意味着当你用Page作为返回类型时Spring Data 的查询执行器会额外执行一条 count 查询来统计总量。而Slice没有这两个方法它只需要查当前页的数据外加多查一条来判断是否还有下一页。这个多查一条具体是怎么实现的Spring Data 在执行Slice查询时实际查询的条数是pageable.getPageSize() 1也就是说你请求 10 条它查 11 条返回前 10 条作为当前页内容然后根据是否还能查出第 11 条来判断hasNext()的结果。这个设计非常经典用一个额外条目的查询成本换掉了整个 count 查询的成本在大数据量场景下性价比极高。我用一个表格总结下两者的核心差异对比项SlicePage是否执行 count 查询否是是否有总页数 / 总条数否是是否支持下一页判断是多查一条是性能表现数据量大时优秀较差适用场景无限滚动、加载更多、对精确总数无感需要显示共 N 页的传统分页返回当前页内容getContent()getContent()支持排序是是划重点你要是想用Page就得接受它背后那个 count 查询。你确定你的业务真的需要总数吗很多场景其实不需要。比如移动端下拉加载更多比如管理后台操作日志列表谁会关心总共 123456 页用户要的无非是还有没有更多内容。2.3 Slice 的设计意图为什么 Spring Data 要造这么个轮子有人可能会问既然Page功能更全直接全用Page不就好了这个问题问到点子上了。Spring Data 官方文档引入Slice的定位非常清晰为那些不需要总数统计的场景提供更轻量的分页方案。从工程实践的角度看Slice的诞生其实是对分页性能的一次重新思考。传统分页offset 分页本身的性能瓶颈不只是 count 查询还包括深翻页时的OFFSET跳跃问题。Slice虽然没有直接解决 offset 深翻页的性能问题但它把分页组件里最重的那部分操作给摘掉了让开发者可以用更细的粒度控制查询成本。另外Slice配合Pageable的nextPageable()方法天然适合做游标式的迭代。比如你要写一个定时任务把这个表里所有数据分批处理完用Slice可以这样SliceOrder slice orderRepository.findSliceByStatus(0, PageRequest.of(0, 1000)); while (slice.hasContent()) { process(slice.getContent()); if (!slice.hasNext()) break; slice orderRepository.findSliceByStatus(0, slice.nextPageable()); }整个过程不会因为某个批次失败而导致必须从头再来当然你也可以这样设计取决于需求也不会因为每次查询都 count 一下所有数据而浪费时间。这种逐页滑动的处理模式在数据迁移、批量任务、报表生成等场景下非常实用。3. Slice 的完整使用步骤从 Repository 到 Service3.1 Repository 层怎么写方法命名和注解两种姿势Slice在 Repository 层的用法和Page几乎一模一样最核心的区别就是返回类型换成Slice而已。官方支持两种写法一种是直接基于方法名推导查询public interface OrderRepository extends JpaRepositoryOrder, Long { // 方式一方法名推导返回 Slice SliceOrder findSliceByStatus(Integer status, Pageable pageable); // 方式二基于 Query 注解自定义查询 Query(SELECT o FROM Order o WHERE o.status :status) SliceOrder findOrderSlice(Param(status) Integer status, Pageable pageable); // 也可以加排序 SliceOrder findByCreateTimeAfter(LocalDateTime time, Pageable pageable); }这里有两个非常容易踩的坑。第一个坑是方法名中的动词不能乱写。你要让 Spring Data 知道这是一个返回Slice的分页查询方法名里的动词部分建议写成findSliceBy或者直接findBy如果你写findPageBy然后返回SliceSpring Data 的执行器可能会用错误的分页方式去处理导致运行时报错。我自己习惯统一用findSliceBy前缀这样语义最清晰也最不容易出错。第二个坑是Query注解的查询里不要写countQuery属性。很多同学在写Page查询时已经习惯了主查询 count 查询的模式迁移到Slice时会把Query(value ..., countQuery ...)这样一个祖传写法粘贴过来。这么写会有什么问题呢Spring Data 在执行Slice查询时根本不关心countQuery你写了也没用纯粹是浪费。更关键的是如果你写的 countQuery 本身有语法错误项目启动时解析 JPQL 可能直接报错。所以写Slice查询时把 countQuery 那一行删掉就完事儿了。Slice不是JpaRepository专属的你在普通的Repository或者JpaSpecificationExecutor上也能用。比如用Specification实现动态条件查询的场景public interface OrderRepository extends JpaRepositoryOrder, Long, JpaSpecificationExecutorOrder { } // 调用侧 SliceOrder slice orderRepository.findAll(specification, PageRequest.of(page, size));3.2 Service 层怎么用别再包装一层 PageResult 了Service 层拿到Slice之后很多同学会习惯性地转成自己的 DTO 对象或者自定义的分页结果类。这个动作本身没错但要注意一个性能点不要为了拼一个总数字段再去单独调用一次 count 查询。我见过不少团队明明用了Slice结果 Service 层为了给前端返回 total又手动调了一次countByStatus(...)这相当于把Slice省下来的性能又亲手还回去了。正确做法是如果你的前端确实需要总条数来展示共 N 条那说明这个场景根本不合适用Slice直接换回Page反而更省事。如果前端只是用来展示没有更多了那就老老实实把slice.hasNext()传出去。分享一下我常用的 Service 层返回结构设计。我不会直接返回Slice对象给 Controller因为 JPA 的实体对象序列化到前端会有各种坑延迟加载、循环引用等所以会做一个轻量封装public class SliceResultT { private ListT content; private boolean hasNext; private boolean hasPrevious; private int number; private int size; public static T SliceResultT from(SliceT slice) { SliceResultT result new SliceResult(); result.setContent(slice.getContent()); result.setHasNext(slice.hasNext()); result.setHasPrevious(slice.hasPrevious()); result.setNumber(slice.getNumber()); result.setSize(slice.getSize()); return result; } }注意封装时只提取Slice已有的信息不要无中生有去补一个 total 字段。如果哪天你发现所有接口都需要 total要么是产品设计出了问题要么是你技术选型出了问题。3.3 每页查多少条Pageable 的参数设计和排序细节Pageable是Slice查询的输入参数它的构造方式直接决定了查询行为和性能。一个典型的构造PageRequest pageRequest PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, createTime)); SliceOrder slice orderRepository.findSliceByStatus(1, pageRequest);这段代码表示查第一页页码从 0 开始、每页 20 条、按创建时间倒序。有些同学会问页码为什么从 0 开始这是 Spring Data 的老传统了习惯就好。你要是觉得别扭可以在 UI 层做转换前端传 1你在后端减 1 再传给PageRequest.of()。排序这里有个值得注意的细节Sort排序字段一定要加索引。Slice查询本质是LIMIT x OFFSET y如果排序字段没有走索引MySQL 会先把满足条件的所有数据都查出来再在内存里排序最后才做 limit这个操作在数据量大时同样会让接口变慢。换句话说Slice解决的是 count 查询的问题但 offset 分页本身的深翻页性能问题它无能为力。关于Pageable的另外一个常见操作是nextPageable()。通过slice.nextPageable()拿到的下一跳查询条件会自动带上当前的排序规则和分页参数。这个 API 特别适合那种不分页码只点加载更多的场景因为客户端只需要传第一页的页码后面的页码完全由服务端通过nextPageable()自己推演。4. Slice 的分页策略与性能优化4.1 offset 分页的痛点为什么翻到 100 页就卡了Slice在使用上虽然轻量但它底层走的还是 MySQL 的LIMIT x OFFSET y这种 offset 分页模式。这种模式有一个著名的性能问题翻页越深查询越慢。原理不复杂LIMIT 10000, 20意味着 MySQL 需要扫描并丢弃前 10000 行只留下最后 20 行返回。你翻到第 100 页它可能已经扫了 200 万行哪怕这些行并不需要返回给客户端。那么Slice能在多大程度上缓解这个问题坦白说Slice不能直接解决深翻页性能问题它解决的是每次翻页都要 count 一次这个相对独立的问题。所以如果你的业务真的会翻到很深很深的页光靠Slice是不够的还得考虑 keyset 分页也叫 seek 分页或者游标分页。keyset 分页的核心思路是不用 offset而是记住上一页最后一条记录的某个排序字段值下一页查询时通过WHERE 排序字段 或 上一页最后一条记录的值来定位数据。这种做法的好处是无论你翻多少页数据库永远只扫描目标数据附近的那一页。Slice在这个策略里正好能派上用场你通过slice.getContent()拿到的最后一条记录的某个字段值作为下一页的游标参数。虽然 Spring Data 没有内置完整的 keyset 分页支持但结合Slice来做这个模式非常顺手。SliceOrder firstPage orderRepository.findSliceByStatus(1, PageRequest.of(0, 20, Sort.by(id).descending())); Order lastOrder firstPage.getContent().get(firstPage.getNumberOfElements() - 1); // 下一页不再传 pageNo只传 lastOrder.getId() 作为游标 SliceOrder nextPage orderRepository.findSliceByStatusAndIdLessThan(1, lastOrder.getId(), PageRequest.of(0, 20, Sort.by(id).descending()));这种模式下的hasNext()判断依然是有效的因为你还是查了size 1条。只不过分页参数不再有页的概念了变成每次取多少条。很多大流量互联网产品的下拉加载更多接口后台就是这么做的。4.2 用 Slice 做滚动分页的实战消息队列消费场景Slice有一个特别适合的应用场景就是消息队列的批量消费。假设你的消息表里积累了大量待处理消息每秒都有新数据进来你需要一个定时任务不停地把新消息捞出来处理。用Page的话每次调度都要 count 一下总量数据量大的时候 count 本身就成为一种负担。用Slice就自然很多Scheduled(fixedDelay 5000) public void processPendingMessages() { SliceMessage slice messageRepository.findSliceByStatus(PENDING, PageRequest.of(0, 500)); while (slice.hasContent()) { messageProcessor.process(slice.getContent()); if (!slice.hasNext()) break; slice messageRepository.findSliceByStatus(PENDING, slice.nextPageable()); } }这段代码里有个细节要注意nextPageable()返回的Pageable是原PageRequest的 next也就是页码加一。如果处理过程中有新数据插入满足查询条件下一轮滑动时可能会重复读到之前处理过的数据也可能漏掉一些数据。具体会不会重复取决于你的查询条件是否稳定、数据状态更新是否及时。我的建议是在处理第一批数据时先把它标记为处理中这样下一批查询就不会再捞到它们了。我在实际项目里就是这么干的每捞出一批先把状态从PENDING改成PROCESSING然后在事务内执行具体的业务处理处理完再改成DONE。这样既利用了Slice的轻量分页能力又保证了数据一致性。当然这已经上升到业务流程设计层面了跟Slice无关但两者配合起来效果非常丝滑。4.3 Slice 配合批量导出一次捞出百万条数据的正确姿势批量导出也是一个高频使用Slice的场景。有些同学做导出时喜欢一次性查全量再写入 Excel数据量一大内存就爆炸。用Slice可以控制每次只捞一批边捞边写SliceLog slice logRepository.findSliceByTimeBetween(start, end, PageRequest.of(0, 10000)); int batch 1; while (slice.hasContent()) { excelWriter.write(slice.getContent()); log.info(exported batch {}, size {}, batch, slice.getContent().size()); if (!slice.hasNext()) break; slice logRepository.findSliceByTimeBetween(start, end, slice.nextPageable()); batch; }这里每批的大小我建议控制在 5000 到 10000 之间太大会导致单次查询内存压力大太小则批次数太多、数据库往返次数太频繁。具体多少合适要看你的表字段数量和单行大小字段多的话 5000 一批比较稳。用Slice做导出的最大优势是整个过程不需要执行任何 count 查询你不需要知道总共多少条只需要知道还有没有下一批。导出进度条也可以用已导出的条数来展示这个累计数字你自己在处理循环里维护即可。5. 常见问题与排查技巧实录5.1 坑一返回了 Slice 但依然执行了 count 查询这是我在实际项目中遇到最多的伪 Slice问题。很多同学把方法返回类型改成Slice完事儿一看日志居然还有 count 查询顿时就慌了是不是写错了先别急着怀疑框架去查两个地方。第一看看你是不是用了继承自 JPA 规范之外的那些库比如 Querydsl 在某些版本里会对Slice做特殊处理第二看看你是不是在方法内部手动调用了count方法。但最让我惊讶的一种情况是方法返回类型确实是Slice但方法名写的是findAllByStatus没有用findSliceByStatus这种明确动词结果 Spring Data 的 Repository 代理在解析时仍然按Page的方式去生成了查询。这个跟版本有关因为 Spring Data 内部对返回类型的处理是通过queryLookupStrategy做的你返回Slice但方法名带了Page的关键字解析器可能直接按Page来执行。排查方法很简单把日志里真正的 SQL 打印出来看一眼有 count 就是没走对没有 count 就是正常的。要开启 JPA SQL 日志在application.yml里配置spring: jpa: show-sql: true properties: hibernate: format_sql: true5.2 坑二Slice 序列化成 JSON 时出现无限递归我曾经在处理一个接口时直接把Slice对象作为响应体返回给了前端结果 JSON 序列化直接报了StackOverflowError。原因是Slice内部持有Pageable参数而Pageable里可能又关联了Sort等对象加上实体之间的关联关系Jackson 序列化时很容易陷入循环引用。解决这个问题有两个思路。第一个思路是我上面提到的在 Service 层封装一层 DTO只提取需要返回的字段不给实体和Slice直接露脸的机会。这也是最推荐的做法既是性能考量也是安全考量实体里的敏感字段不暴露。第二个思路是给实体关联字段加JsonIgnoreProperties但这样侵入性太强而且治标不治本。另外顺手提一个Slice的好习惯拿到slice.getContent()之后如果你在 DTO 里需要的是某种 VO 对象建议用 mapstruct 或者手动转换不要直接把实体丢给前端。这跟Slice无关但跟用Slice后接口快了导致前端把实体当 JSON 用往往是连着出现的。5.3 坑三sort 参数被前端恶意注入Pageable有一个让很多开发者又爱又恨的能力参数里可以直接传排序字段比如前端请求?sortcreateTime,descSpring Data 就能自动帮你的查询加上ORDER BY createTime DESC。这个能力极大地方便了动态表头排序但也带来了一个安全隐患前端可以传任意字段名进行排序。如果你的实体里有某个字段特别不适合作为排序条件比如超长文本、无索引列被恶意传入后可能导致查询极慢。我的建议是在入口处做一次排序字段白名单校验只放行你有索引的字段private static final SetString SORT_WHITELIST Set.of(createTime, id, status, amount); public Pageable buildPageable(int page, int size, String sortField, String direction) { String field SORT_WHITELIST.contains(sortField) ? sortField : createTime; Sort.Direction dir asc.equalsIgnoreCase(direction) ? Sort.Direction.ASC : Sort.Direction.DESC; return PageRequest.of(page, size, Sort.by(dir, field)); }这个白名单用Slice查询时也照样有效不会因为返回类型变化而失效。另外size参数也建议设置上限防止有人传个size1000000把你数据库打垮。我一般限制最大 500。5.4 坑四多表关联查询时 Slice 对 count 的行为还有一种特殊情况。如果你写了Query进行多表 join 查询并且返回类型是Slice注意 Spring Data 不会自动帮你写 count 查询但这并不意味着你没写 count 就一了百了。要注意的是你写的 join 查询本身如果不够优化即使没有 count数据查询部分也会很慢。所以Slice只是从查询策略上帮你省掉一次查询但 SQL 本身的执行效率还是得靠你优化。我见过一个项目用Query写了三段表 join 的复杂查询返回类型是Slice每页 10 条接口还是慢。原因就是三张表 join 之后形成了一个巨大的中间结果集哪怕只取 10 条MySQL 也要先把所有中间结果算出来再做 limit。这种场景下Slice帮不了你你得去优化 join 本身比如做冗余字段、加索引、拆查询。不要指望一个Slice能解决所有性能问题。5.5 问题速查表现象可能原因解决方案返回 Slice 但日志里出现 count 查询方法名前缀不对 / 查询执行器按 Page 处理改用findSliceBy前缀检查返回值类型与方法名Slice 直接序列化报 StackOverflowError实体的关联关系互相引用 / Pageable 参与序列化封装 DTO不要把 Slice 直接暴露给前端深翻页速度越来越慢offset 分页本身的性能瓶颈换用 keyset 分页用排序字段做游标排序字段传参导致查询慢无索引字段参与 ORDER BY白名单校验排序字段加索引多表 join 后 Slice 查询依然慢join 本身产生大量中间结果集优化关联查询增加冗余或拆分查询hasNext() 返回结果不准确查询过程中数据发生了变化根据业务逻辑确认是否需要快照机制6. 顺便澄清slice 与 splice别再混淆了我知道这篇文章的标题是《Spring Data Slice 使用指南》但既然搜索热词里老有人把slice和splice放一起这里就顺便把这两个概念讲清楚。它们俩长得像但完全不是一个东西。6.1 两个函数的本质差异slice和splice是 JavaScript 里两个数组方法也是初学者最容易搞混的一对。核心区别一句话就能说清slice不修改原数组只返回一个新数组的副本splice会直接修改原数组删除或替换元素。光记这句话还不够我给你两个实际场景。场景一你想从数组里截取一段数据但是原数组保持不变。用sliceconst arr [1, 2, 3, 4, 5]; const newArr arr.slice(1, 3); // [2, 3] console.log(arr); // [1, 2, 3, 4, 5] 原数组没变场景二你想删除数组里某个位置的元素或者在某处插入新元素。用spliceconst arr [1, 2, 3, 4, 5]; // 从第 2 个位置开始删除 1 个元素然后插入 99 const removed arr.splice(2, 1, 99); console.log(arr); // [1, 2, 99, 4, 5] 原数组已经被改了注意slice的第二个参数是结束位置不含而splice的第二个参数是删除几个。这个细节也容易错。JavaScript 里的slice还有一个特点是支持负数参数比如arr.slice(-2)会取最后两个元素这在处理分页切分时非常方便。6.2 回到后端slice 和 splice 的精神分别对应什么虽然 Spring Data 里的Slice跟 JavaScript 的slice没有任何直接的代码关系但从设计精神上是相通的都是取一段而不影响整体。Java 里List.subList()、Stream.limit()、Stream.skip()的组合做的事情也是slice式的——从已有数据流里切一段出来。而splice对应的则是原地修改集合的操作比如List.removeIf()、List.add()这种改变原集合本身的方法。理解了这个对应关系你在学习时就能举一反三不会看到一个slice就一头雾水了。顺带说一个最近经常被提到的slice算子概念。在 Spark 或者 Flink 这类大数据处理框架里slice算子或者类似的窗口切分操作指的都是把一个数据集按照某种规则切分成若干片段本质上还是取一段的思路。跟 Spring DataSlice的分页取数逻辑也算有着异曲同工之妙。7. 选择 Slice 还是 Page我的最终建议做了这么多分析最后给一个不绕弯子的结论。7.1 什么场景无脑选 Slice移动端列表接口下拉加载更多没有共 N 页的展示需求管理后台的操作日志、流水记录查询用户只关心最近的记录批量任务处理、数据迁移、消息消费需要分批捞数据但不需要总数前端设计是无限滚动、分页器只显示加载中/没有更多了任何数据量超百万且多条件 join 的列表查询count 成本过高7.2 什么场景老老实实用 Page前端必须展示共 100 页当前第 5 页的传统分页器电商后台订单管理页带共 X 条的统计信息报表系统里分页之外还需要汇总条数做数据校验数据量小千级以内count 查询本身没什么成本用 Page 反而代码更少7.3 我个人在实际操作中的体会用Slice不等于高级用Page也不等于落后关键是匹配业务需求。但有一个趋势我是比较坚定的新写的列表接口默认用Slice除非产品明确要求展示总条数。因为绝大多数列表场景用户真正依赖的信息是有没有更多内容而不是总共有多少条。从数据量小的项目开始写Slice到数据量大的时候你就不会因为 count 查询而被迫重构了。这个内容后续还可以这样扩展如果你想彻底解决深翻页的性能问题可以把Slice和 keyset 分页结合用WHERE 排序字段 游标值的方式替代OFFSET如果想进一步提升读取效率还可以在数据库层面使用覆盖索引让Slice的查询直接命中索引而不回表。这些都是很有意思的方向以后有空我再单独写一篇展开。