2026/4/18 7:28:13
网站建设
项目流程
做采集网站的方法,网络搜索引擎,网站建站 公司无锡,整站优化代理深度分页的救星#xff1a;在 SpringBoot 中用 Search After 玩转 Elasticsearch你有没有遇到过这样的场景#xff1f;用户在商品列表页一路翻到了第500页#xff0c;突然接口开始超时、系统CPU飙升#xff0c;甚至整个ES集群出现circuit_breaker报错——“Result window i…深度分页的救星在 SpringBoot 中用 Search After 玩转 Elasticsearch你有没有遇到过这样的场景用户在商品列表页一路翻到了第500页突然接口开始超时、系统CPU飙升甚至整个ES集群出现circuit_breaker报错——“Result window is too large”或者后台导出10万条数据的任务刚跑一半协调节点内存直接被打满GC频繁到几乎卡死这背后很可能就是那个看似无害却暗藏杀机的传统分页方式from size。尤其是在Elasticsearch 整合 SpringBoot的微服务架构中随着数据量从百万级迈向千万级这种“简单粗暴”的分页方式早已不堪重负。而官方早就在文档里划了重点当from size 10,000时请换方案那怎么办ScrollSearch After还是自己写游标今天我们就来聊聊在 SpringBoot 工程实践中如何用Search After实现高效、稳定、低延迟的深度分页彻底告别 deep paging 的性能陷阱。为什么 from/size 不适合深度分页我们先看一个真实案例。假设你的电商系统有 3 个主分片用户想查看第 10000 条后的 10 条商品记录即from10000, size10。Elasticsearch 是怎么处理的协调节点向每个分片发送请求“请返回按时间排序的前 10010 条数据”每个分片本地执行查询并排序返回 top-10010 给协调节点协调节点收到总共 3×10010 30030 条数据进行全局排序跳过前 10000 条最终只返回 10 条给客户端。听起来是不是有点“杀鸡用牛刀”为了拿最后10条系统要搬运上万条中间结果。更糟的是这个成本是随页码线性增长的。 关键问题- 内存占用高协调节点需缓存大量中间结果- 延迟显著增加合并与排序耗时剧增- 集群负载不均某些热点分片可能成为瓶颈这就是所谓的deep paging 问题也是生产环境中最常见的 ES 性能杀手之一。Search After 是什么它凭什么能破局Search After 的核心思想非常朴素我不再跳页了我“接着往下读”就行。它不像from/size那样依赖偏移量而是通过上一页最后一个文档的排序值作为“锚点”告诉 Elasticsearch“从这个位置之后取下一批数据”。举个生活化的比喻from/size就像你要找一本书的第100页哪怕你已经翻到第99页系统还是会从头开始数一遍而Search After则像是你在书签处继续往后翻——无需回溯直接接续。它是怎么工作的第一次请求正常查询前 N 条如 size20按 createTime DESC _id ASC 排序取出最后一条记录的 sort 值比如[ 2025-04-05T10:00:00, abc123 ]下次请求带上search_after: [ 2025-04-05T10:00:00, abc123 ]ES 直接利用倒排索引或 Doc Values 快速定位起始位置仅加载所需数据。⚠️ 注意事项- 必须指定明确的sort规则- 推荐组合排序字段如时间ID以确保顺序唯一- 不支持多值字段multi-value排序否则无法保证一致性和 Scroll API 比有什么区别维度ScrollSearch After是否保持快照✅ 是基于搜索上下文❌ 否实时可见新数据实时性❌ 低✅ 高上下文管理需手动清理 scroll_id无状态无需维护内存开销中等context 存于 heap极低适用场景数据导出、批量处理用户前端翻页、高并发查询简而言之如果你要做全量导出、日志归档这类任务用Scroll但如果是面向用户的实时搜索翻页Search After 才是正解。在 SpringBoot 中实战 Search After我们现在进入正题如何在一个典型的 SpringBoot Elasticsearch 微服务项目中落地 Search After本文使用Spring Data Elasticsearch 4.4版本推荐底层基于elastic/elasticsearch-java新一代客户端完全支持 Search After 功能。Step 1定义实体类Document(indexName product_index) public class Product { Id private String id; Field(type FieldType.Text, analyzer ik_max_word) private String title; Field(type FieldType.Keyword) private String category; Field(type FieldType.Date, format DateFormat.date_time) private LocalDateTime createTime; // getter/setter 略 } 提示createTime字段必须启用Doc Values默认开启否则排序效率极低。文本字段若要排序记得映射为.keyword。Step 2Repository 层声明方法虽然 Spring Data Elasticsearch 的高层抽象对 Search After 支持有限但我们可以通过自定义查询实现public interface ProductRepository extends ElasticsearchRepositoryProduct, String { // 可保留基础方法用于浅层分页 }真正的逻辑放在 Service 层手动构建 Native Query。Step 3Service 层实现分页逻辑Service public class ProductService { Autowired private ElasticsearchOperations operations; public SearchAfterResultProduct searchProducts( String category, Integer size, Object[] searchAfter) { // 固定排序规则时间降序 ID升序避免分页断层 Sort sort Sort.by( Sort.Order.desc(createTime), Sort.Order.asc(_id) ); // 构建查询条件 Query query new NativeSearchQueryBuilder() .withQuery(QueryBuilders.termQuery(category, category)) .withSorts( Sorts.of(createTime, Order.DESC), Sorts.of(_id, Order.ASC) ) .withPageable(PageRequest.of(0, size)) // 注意page 固定为0 .withSearchAfter(searchAfter) // 核心参数锚点值 .build(); SearchHitsProduct hits operations.search(query, Product.class); ListProduct products hits.get().map(SearchHit::getContent).toList(); // 提取下一页所需的 search_after 值 Object[] nextSearchAfter null; if (hits.hasSearchHits()) { SearchHitProduct lastHit hits.get().get(hits.getTotalHits() - 1); nextSearchAfter lastHit.getSortValues().toArray(new Object[0]); } return new SearchAfterResult(products, nextSearchAfter); } }✅ 关键点解析-withSearchAfter(searchAfter)是核心入口-PageRequest.of(0, size)表示不分页页码始终取第一页的数据块- 返回的sort_values即为下一次请求的锚点。Step 4封装返回结果public class SearchAfterResultT { private ListT data; private Object[] nextSearchAfter; // Base64 编码后可传递给前端 public SearchAfterResult(ListT data, Object[] nextSearchAfter) { this.data data; this.nextSearchAfter nextSearchAfter; } // getter/setter }你可以选择将nextSearchAfter数组序列化为字符串如 JSON 或 Base64便于前端存储和传输。Step 5Controller 接口设计RestController RequestMapping(/api/products) public class ProductController { Autowired private ProductService productService; GetMapping public ResponseEntitySearchAfterResultProduct getProducts( RequestParam String category, RequestParam(defaultValue 10) Integer size, RequestParam(required false) String[] searchAfterEncoded) { Object[] searchAfter null; if (searchAfterEncoded ! null searchAfterEncoded.length 0) { searchAfter Arrays.stream(searchAfterEncoded) .map(this::decodeSortValue) // 解码处理 .toArray(); } SearchAfterResultProduct result productService.searchProducts(category, size, searchAfter); return ResponseEntity.ok(result); } private Object decodeSortValue(String encoded) { try { // 示例尝试解析为 ISO 时间格式 return LocalDateTime.parse(encoded, DateTimeFormatter.ISO_LOCAL_DATE_TIME); } catch (DateTimeParseException e) { return encoded; // 兜底为字符串 } } } 使用示例首次请求GET /api/products?categoryphonesize20响应返回nextSearchAfter: [2025-04-05T10:00:00, abc123]下一页请求GET /api/products?categoryphonesize20searchAfter[]2025-04-05T10:00:00searchAfter[]abc123前端可以将这些值存入 URL hash、localStorage 或滚动按钮的>sort: [ { createTime: desc }, { _id: asc } ]这样即使时间相同也能靠_id进一步区分顺序。❗ 2. 文本字段不能直接排序很多人会犯这样一个错误.field(title, Order.ASC) // 错误text 类型未开启 doc_values因为默认的text字段为了支持全文检索关闭了doc_values无法用于排序或聚合。✅ 正确做法映射时启用.keyword子字段json title: { type: text, fields: { keyword: { type: keyword } } }查询时使用title.keyword排序❗ 3. 前端不要试图“跳页”Search After 天然不支持跳转到“第100页”。你只能一页一页往后翻。✅ 设计建议- UI 上只提供“加载更多”或“下一页”- 若需要快速定位考虑结合关键词搜索 过滤条件缩小范围- 或引入“时间分片”策略如按月浏览❗ 4. 数据更新会影响分页连续性由于 Search After 是实时查询如果在翻页过程中有新数据插入到当前锚点之前会导致部分数据被跳过。比如你正在看第5页这时有一条“昨天”的新品上架排序靠前就会插进你已翻阅的部分。✅ 应对策略- 对强一致性要求高的场景可在首次查询时记录时间戳后续查询加range(createTime firstPageMaxTime)- 或改用 Scroll牺牲实时性换取一致性❗ 5. 生产环境一定要监控即便用了 Search After也不能掉以轻心。建议重点关注以下指标指标监控意义elasticsearch.jvm.gc.collection.time协调节点 GC 时间突增可能是 deep paging 回归thread_pool.search.rejected搜索线程池拒绝任务说明负载过高慢查询日志检查是否仍有from 10000的遗留接口可通过 APM 工具如 SkyWalking、Pinpoint追踪 ES 请求链路及时发现异常调用。如何平滑迁移现有系统对于老系统不可能一夜之间全量替换。我们推荐如下渐进式迁移路径识别高危接口找出所有from size 1000的分页接口灰度上线新增/v2/products接口支持 Search After旧接口保留前端适配逐步将“点击页码”改为“无限加载 锚点传递”AB测试对比观察 QPS、P99 延迟、JVM 内存变化全面切换 下线旧接口。我们曾在某电商平台完成此类改造后商品列表页平均响应时间从380ms → 65ms协调节点内存占用下降 70%GC 次数减少 90%。结语Search After 不只是优化更是架构思维的升级当你开始思考“要不要用 Search After”的时候其实已经在做一件更重要的事重新审视系统的可扩展性边界。在数据爆炸的时代简单的 offset 分页早已不合时宜。而 Search After 所代表的“流式拉取 状态延续”模式正是现代分布式系统应对海量数据的标准范式之一。它不仅适用于 Elasticsearch类似的思路也广泛应用于 Kafka 分区消费、数据库游标分页、GraphQL Connection 模型等领域。所以掌握 Search After 并不只是学会一个 API 调用而是理解一种面向大规模数据的分页哲学。而对于正在实践Elasticsearch 整合 SpringBoot的团队来说这门课越早上越好。 如果你已经在项目中落地了 Search After欢迎在评论区分享你的实践经验遇到了哪些坑做了哪些定制封装有没有结合 Redis 缓存锚点我们一起交流共同打造更健壮的搜索系统。