找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4161

积分

0

好友

545

主题
发表于 10 小时前 | 查看: 4| 回复: 0

本文介绍了一种基于 search_after + Redis 多级锚点缓存的 Elasticsearch 深度分页跳页方案。 针对 Elasticsearch 原生不支持随机跳页的限制,通过三轮优化——分段预热缓存、最近锚点定位 + Elasticsearch 查询提速(禁用 _source/关闭 track_total_hits)、大区间预热 + 小分页精细锚点——将 50 万数据量下的任意跳页响应时间从 10 分钟级优化至 1 秒以内。

ES深度分页任意跳页优化全景流程图

背景

悟空活动系统目前支撑了一系列大型摄影比赛活动。在赛事评审环节,管理后台需要对数十万级参赛作品进行集中展示、筛选与评审,尤其涉及多维度条件筛选(评审等级、作者信息、拍摄单元等)。该业务场景本质上是一个“高数据量 + 多维组合筛选 + 深度分页”的后台检索系统。后台页面的列表查询最初通过 MySQL 多表关联实现。

随着赛事规模逐年扩大,参赛作品量级持续攀升,MySQL 查询逐渐暴露出以下问题:

  • 多表关联查询复杂,SQL 执行成本较高;
  • 数据量持续增长,查询耗时明显上升;
  • 高并发场景下数据库压力较大,影响系统整体稳定性。

为降低数据库查询压力并提升检索性能,我们对系统进行了重构优化,将原有的 MySQL 列表查询逻辑迁移至 Elasticsearch,利用 Elasticsearch 在大规模数据检索与多维条件组合过滤方面的优势来承载后台的页面查询能力。

但是,在实际业务场景中,用户可能会在评审过程中随意跳页查看评审数据和作品信息。Elasticsearch 自身提供的分页能力仅支持顺序翻找(scroll API 及 search_after),无法直接跳转至任意页。为不影响用户使用体验,我们针对 Elasticsearch 深度分页进行了专项优化。

基础方案选择

目前 Elasticsearch 提供的深度查询方案主要有三种:from+sizescroll API 以及 search_after。对于用户深度分页的场景,Elasticsearch 官方更推荐使用 search_after;而 scroll API 更适合后台大规模数据扫描、稳定遍历整批索引数据的场景。为直观对比三种方案的流程与优劣,我们绘制了如下流程图:

ES三种分页方案对比流程图

如图所示,方案一存在最大数量限制,方案二会占用大量 Elasticsearch 内存,给集群带来压力。最终我们选择方案三。与方案一相比,它不需要计算大量偏移数据;与方案二相比,它采用无状态查询,无需在 Elasticsearch 节点上维护额外的查询上下文,更适合用户的在线查询场景。

跳页解决方案演进

3.1 阶段一:基础方案

确定使用 search_after 作为深度分页的基础查询方式后,我们首先实现了一套基于 缓存 + search_after 的分页方案,用以在一定程度上支持用户的深度分页查询。

由于 search_after 只能基于上一页的排序位置顺序翻页,无法直接跳转至任意分页位置,因此需要额外的缓存机制记录部分分页位置对应的 search_after 值,从而减少顺序查询的次数。

该阶段我们设计了一套分段预热的缓存策略:

  • 异步预热: 用户首次进入列表页面进行查询时,系统会启动一个异步线程,后端按照 1000 条数据为一个步长对查询结果进行预热处理。
  • 多粒度缓存: 在每个步长位置,系统会继续按照当前系统预设的分页大小(10、20、50、100)分别记录当前查询结果最后一条数据对应的 sortValues
  • Redis 存储: 将数据的位置和对应的 sortValues 缓存到 Redis 的 hash 结构中。

当用户访问的分页位置超过 Elasticsearch 默认的深度分页限制(10000)之后,系统会根据用户当前的分页大小,计算出当前页面的开始位置(from),并从 10000 对应的缓存锚点位置重新开始执行 search_after 查询,按照用户当前的分页大小顺序向后推进,直到命中目标位置(from)。之后再进行一次 search_after 查询,获取当前页面的全部数据。推进过程中,系统会继续补充新的 search_after 锚点缓存,以逐步完善缓存中的分页位置数据。

该方案在命中缓存时能根据当前缓存的 search_after 值快速查询出指定页面的数据,查询效率相对较快,通常在 1 秒内就能返回结果。

首次深度分页响应时间线(479ms)

然而,该方案在实际运行中仍存在一定局限:

  • 当用户第一次访问某个较深的分页位置时,缓存尚未完全构建,系统仍需执行大量的顺序 search_after 查询;
  • 锚点缓存依赖预热过程,若用户访问的分页区间分布较为随机,缓存命中率可能较低;
  • 极端情况下,用户直接跳转到较深的分页位置,查询延迟仍然很高。在八十万级数据量下,用户首次直接跳转最后一页,大约需要十分钟的预热时间。

3.2 阶段二:性能优化

3.2.1 优化点一:引入最近锚点定位

阶段一的方案通过缓存部分 search_after 游标缓解了顺序翻页的性能问题,但仍存在明显不足:当用户直接跳转到较深分页时,若目标页附近没有可用缓存游标,系统仍需执行大量顺序查询,耗时较长。

既然我们已经在用户首次访问时做了缓存预热,那后续操作应该尽可能最大化利用已缓存的锚点,以提高查询效率。很自然的一个想法是:在用户每次查询时,找到距离当前目标位置最近的 search_after 记录,动态计算出和目标页面相差的数据条数,再进行一次 search_after 操作跳至目标页。

为实现这个想法,我们引入了 Redis ZSet 作为偏移量索引,在数据链路上钉上“锚点”。

1. 动态锚点预热

  • 步长机制:用户首次进入时,系统以 1000 条为步长,自动向后预热。
  • 缓存结构:将查询位置存为 score,将 Elasticsearch 的 sortValues 存为 value,维护在 Redis ZSet 中。

2. “跳板式”精准命中

  • 坐标换算:根据 pageNum * pageSize 计算目标位置 from
  • 快速定位:利用 ZSet 的 reverseRangeByScore 命令,毫秒级锁定小于且最接近目标位置的那个“锚点”。
  • 区间收缩:将原本几十万量级的扫描,瞬间收缩到 1000 条以内的微小区间。

3. 远程跳跃保护
设置最大缓存间隔。若最近锚点距离目标过远,系统先进行大区间的预热构建,快速逼近目标区间后,再切换到小步长精确命中。

详细流程如下图所示:

异步预热与远程跳跃保护机制

效果: 同等数据量下,跳到最后一页的时间从约 10 分钟降到了约 6 分钟,预热完成后任意跳页约 3 秒。虽有进步,但还不够。

优化前请求耗时6.5分钟

3.2.2 优化点二:优化 Elasticsearch 查询效率

目前最耗时的部分仍然是缓存预热以及大幅度跳页后根据就近锚点深度查找过程中的 Elasticsearch 查找操作。对于五十万级别的数据量,以 1000 为步长进行搜索构造,预热阶段需要执行 500 次查询,如果每次查询耗时较长,累积耗时非常庞大。而预热阶段我们根本不需要完整的文档数据,只需要 sortValues

于是我们做了两个关键调整:

(1)禁用 _source 字段
Elasticsearch 查询默认会返回完整的 _source 文档内容,这涉及大量磁盘 I/O 和反序列化开销。在预热阶段,我们只需要排序值,完全可以通过 "_source": false 跳过文档内容的读取。

(2)关闭 track_total_hits
Elasticsearch 查询默认会计算匹配文档的总数(total_hits),这需要扫描所有匹配文档。在 search_after 预热场景中,我们并不关心总数,关闭后可以省去这部分开销。

下图是未禁用相关参数时的耗时:

禁用参数前查询耗时日志

而下图是禁用 _source 以及关闭 track_total_hits 后的同等耗时,单次查询约在 500 ms 左右,相较于默认查询,耗时有了显著减少。

禁用_source后查询~500ms日志

在查询开销降低后,我们进一步调整了分页预热的步长策略。在减少返回数据量的前提下,适当增大每次查询获取的数据量,可以减少与 Elasticsearch 的交互次数,提高整体预热效率。经多次测试与调优,我们将预热步长调整为 5000 条数据。在该配置下,用户首次跳转至最后一页时的整体查询时间已能稳定控制在一分钟以内,相比初始方案有了明显提升。但在实际使用中,若用户直接跳转至任意较深的分页位置,系统仍需通过已有锚点执行少量 search_after 顺序推进查询,整体查询耗时仍可能达到 1 秒以上。因此,随机跳页场景仍存在进一步优化空间。

3.2.3 优化点三:分割预热区间

第二版解决了预热速度的问题,但仍有一个痛点:预热后跳页仍需约 1 秒。原因是锚点粒度仍然是 5000,用户请求的页码大概率不会刚好落在锚点上,还需做一段距离的顺序推进。

因此我们进一步思考:如果把锚点粒度做到和最小分页大小一致(比如 10 条),那用户跳到任何页都能直接命中缓存,仅需一次 search_after 查询就能拿到结果。

为了实现这个构想,同时又不拖慢主预热链路,我们设计了一套“大区间同步 + 小区间异步”的 分层架构

  1. 系统维持原有逻辑,以 5000 条数据为一个大区间进行预热查询,确保主进度的覆盖。
  2. 在获取到大区间数据后,后台同步启动一个异步线程,以最小分页大小(如 pageSize = 10)为基础单位,将 5000 条的结果集切割成 500 个细小的分页区间并构建缓存。
  3. 当用户发起跳页请求时,会直接定位到该页码对应的细粒度锚点,取出对应的 search_after 值。

最终的流程如下图所示:

大区间+小分页跳页逻辑流程图

通过这种“大区间预热 + 小分页锚点缓存”的策略,系统在保证缓存覆盖范围的同时,将随机跳页场景下的顺序查询次数降至最低,从而有效提升了深度分页查询的响应速度,并进一步提高了缓存命中率。在测试环境的实际测试中,对于七十万级的数据量,预热完成后随机点击页面的 RT 基本能够保证在 1 秒以内。

优化后请求耗时820ms

线上模拟的五十万级数据级别的响应时间也都在理想范围内:

线上五十万级数据响应时间

目前问题

在上述优化方案中,我们通过 Redis 对 search_after 锚点进行了缓存,从而在用户跳页时能够快速定位到接近目标位置的数据区间。然而,在实际运行过程中也暴露出了一个新问题:数据漂移,具体表现为:

  • 新数据写入后,原来“最后一页”的锚点可能不再是真正的尾部,跳到最后一页可能返回空页;
  • 数据删除后,某些锚点对应的位置可能出现数据重复或缺失。

该问题的根本原因在于 Elasticsearch 索引数据与 Redis 中缓存的分页锚点之间并非强一致关系。当前方案中,分页锚点是在预热阶段通过查询 Elasticsearch 生成并缓存到 Redis 中的,这些锚点本质上记录的是某一时刻 Elasticsearch 索引数据在特定排序条件下的位置。

当上述情况发生时,Elasticsearch 中的真实数据顺序可能已经变化,而 Redis 中缓存的锚点仍然对应旧的数据位置。此时,当系统使用 Redis 中的锚点作为 search_after 的起点继续执行查询时,实际的分页起始位置就可能与用户期望的分页位置产生偏差,从而出现数据漂移现象。

应对策略:

  • 该方案本质上更适用于数据相对稳定的查询场景——在我们的业务中,深度分页的使用高峰主要在数据趋于稳定的后期阶段,数据大量变化的概率较小;
  • 对于可能出现的空页问题,系统会清除缓存并基于最新的 Elasticsearch 数据重新预热;
  • 如果你的场景中数据变更非常频繁,可能需要考虑其他方案(如基于时间戳或版本号的增量更新策略)。

思考

回顾整个优化过程,核心思路可以提炼为:

三轮优化效果对比表

这次优化的过程,本质上不只是一次 Elasticsearch 性能调优,更是一次面对多目标约束时如何拆解问题、逐步逼近最优解的工程实践。希望这些经验对遇到类似问题的同学有所帮助。类似的高并发后端实战,也欢迎在云栈社区继续探讨。




上一篇:向量数据库入门:从向量原理到RAG实战,像喝水一样简单
下一篇:IT汇报实操指南:用六个步骤构建业务影响仪表盘,赢得高管认可
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-7-19 14:04 , Processed in 0.776156 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表