vivo 团队三轮优化 Elasticsearch 深度分页跳页:50 万数据跳页响应从 10 分钟降至 1 秒内
一切始于一个业务场景的痛点悟空活动系统目前支撑大型摄影比赛活动赛事评审需对数十万级参赛作品展示、筛选与评审涉及多维度条件筛选。该业务场景是“高数据量 多维组合筛选 深度分页”的后台检索系统后台页面列表查询最初用 MySQL 多表关联查询实现。随着赛事规模扩大参赛作品量级攀升列表查询关联多表、条件复杂。数据规模和并发请求增长下MySQL 查询暴露出多表关联查询复杂、SQL 执行成本高数据量增长查询耗时上升高并发场景数据库压力大影响系统稳定性等问题。为降低数据库查询压力、提升检索性能团队将 MySQL 列表查询逻辑迁移至 Elasticsearch利用其在大规模数据检索与多维条件组合过滤方面的优势承载后台页面查询能力。但 Elasticsearch 目前查询能力无法直接跳转任意页为不影响用户体验进行深度分页优化。基础方案选择目前 Elasticsearch 深度查询方案有 from size、scroll API、search after 三种。对于用户点击深度分页场景官方更推荐 search afterscroll API 更适合后台大规模数据扫描遍历查询。团队绘制流程图对比三种方案流程和优劣。方案一有最大数量限制方案二占用大量 Elasticsearch 内存给集群造成压力。最终团队选择方案三该方案无需计算大量偏移数据采用无状态查询方式无需在 Elasticsearch 节点维护额外查询上下文更适合用户在线查询场景。跳页解决方案演进阶段一基础方案确定用 search_after 作为深度分页基础查询方式后团队实现基于缓存 search_after 的分页方案支持深度分页查询。因 search_after 只能顺序翻页需额外缓存机制记录部分分页位置的 search_after 值减少顺序查询次数。该阶段团队设计分段预热缓存策略异步预热用户首次进入列表页面查询时系统启动异步线程按 1000 条数据为步长预热查询结果多粒度缓存在每个步长位置按系统预设分页大小10、20、50、100记录查询结果最后一条数据的 sortValuesRedis 存储将数据位置和 sortValues 缓存到 Redis 的 hash 结构中。用户访问分页位置超 Elasticsearch 默认深度分页限制10000后系统根据分页大小计算页面开始位置从 10000 对应的缓存锚点位置重新执行 search_after 查询按分页大小顺序推进命中目标位置后再查询出当前页面全部数据推进中补充新的 search_after 锚点缓存。该方案命中缓存时查询效率快1 秒内可返回结果。但存在局限性用户首次访问较深分页位置缓存未完全构建需执行大量顺序 search_after 查询锚点缓存依赖预热用户访问分页区间随机缓存命中率可能低极端情况下用户直接跳转到较深分页位置查询延迟高八十万级数据量级首次跳转最后一页需十分钟左右预热时间。阶段二性能优化优化点一引入最近锚点定位阶段一方案通过缓存 search_after 游标缓解顺序翻页性能问题但用户直接跳转到较深分页位置目标页附近无可用缓存游标时仍需多次顺序 search_after 查询耗时较长。团队想利用已缓存锚点提高查询效率在用户查询中找到最近的 searchAfter 记录计算与目标页面相差数据条数再进行一次 searchAfter 操作跳转到目标页数。为实现该想法团队引入 Redis ZSet 作为偏移量索引动态锚点预热用户首次进入时系统以 1000 条为步长自动向后预热将查询位置存为 scoreElasticsearch 的 sortValues 存为 value 维护在 Redis ZSet 中“跳板式”精准命中根据 pageNum * pageSize 计算目标位置 from利用 ZSet 的 reverseRangeBy - Score 命令毫秒级锁定小于且最接近目标位置的“锚点”将几十万量级扫描收缩到 1000 条以内微小区间远程跳跃保护设置最大缓存间隔最近锚点距目标过远时系统先进行大区间预热构建逼近目标区间后切换到小步长精确命中。效果同等数据量下跳到最后一页时间从 10 分钟降到约 6 分钟预热完成后任意跳页约 3 秒。优化点二优化 Elasticsearch 查询效率目前最耗时的是缓存预热和跳页后根据就近锚点深度查找的 Elasticsearch 查找操作五十万级数据量级按 1000 步长搜索构造预热需 500 次查询若每次查询耗时总耗时庞大。预热阶段只需 sortValues无需完整文档数据。团队做了两个关键调整禁用 _source 字段Elasticsearch 查询默认返回完整 _source 文档内容涉及大量磁盘 I/O 和反序列化开销预热阶段可通过“_source”: false 跳过文档内容读取关闭 track_total_hitsElasticsearch 查询默认计算匹配文档总数需扫描所有匹配文档search_after 预热场景不关心总数关闭可省去开销。禁用相关参数后每次查询约 500ms耗时显著减少。查询开销降低后团队调整分页预热步长策略增大每次查询获取数据量减少与 Elasticsearch 交互次数提高预热效率。多次测试调优后将预热步长调整为 5000 条数据用户首次跳转最后一页整体查询时间可稳定控制在一分钟以内但随机跳页场景仍有优化空间。优化点三分割预热区间第二版解决预热速度问题但预热后跳页仍需约 1 秒原因是锚点粒度为 5000用户请求页码大概率不在锚点上需顺序推进。团队思考将锚点粒度做到和最小分页大小一致如 10 条用户跳页可直接命中缓存只需一次 search_after 查询。为实现该构想又不拖慢主预热链路团队设计“大区间同步 小区间异步”分层架构系统以 5000 条数据为大区间预热查询确保主进度覆盖获取大区间数据后后台同步启动异步线程以最小分页大小为基础单位将 5000 条大结果集切割成 500 个细小分页区间并构建缓存用户发起跳页请求时直接定位到页码对应的细粒度锚点取出 search_after 值。该策略降低随机跳页场景顺序查询次数提升深度分页查询响应速度和缓存命中率。测试环境中七十万级数据量级预热完成后随机点击页面 RT 基本能保证在 1s 以内线上模拟五十万级数据量级响应时间也在理想范围内。目前问题优化方案通过 Redis 缓存 search_after 锚点减少深度分页顺序查询次数但实际运行中出现数据漂移问题新数据写入后原来“最后一页”的锚点可能不再是真正尾部跳转到最后一页可能返回空页数据删除后某些锚点对应的位置可能出现数据重复或缺失。问题原因是 Elasticsearch 索引数据与 Redis 中缓存的分页锚点非强一致关系分页锚点记录的是某一时刻 Elasticsearch 索引数据在特定排序条件下的位置数据顺序变化后Redis 中锚点对应旧数据位置导致分页起始位置与用户期望位置偏差出现数据漂移现象。应对策略该方案适用于数据相对稳定的查询场景团队业务中深度分页使用高峰在数据稳定后期数据大量变化概率小对于可能出现的空页问题系统清除缓存并基于最新 Elasticsearch 数据重新预热若用户场景数据变更频繁可能需考虑其他方案如基于时间戳或版本号的增量更新策略。思考回顾优化过程核心思路可提炼。这次优化不仅是 Elasticsearch 性能调优更是面对多目标约束时拆解问题、逼近最优解的工程实践希望经验对遇到类似问题的同学有帮助。