Elasticsearch 任意跳页提速:用 search_after 和 Redis 锚点把深分页从分钟级压到秒级

2026-07-03 33 预计阅读时间: 1 分钟
来源: my.oschina.net AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:9 分钟

Elasticsearch 的深度分页一直有个尴尬点:from + size 能随机跳页,但页数越深越慢;search_after 性能稳定,却天然只能“下一页、下一页”地走。来源案例的核心思路,是把这两者之间的矛盾拆开:查询仍然使用 search_after,但用 Redis 缓存不同粒度的“锚点”,让任意页跳转变成“先跳到最近锚点,再向前滚动少量页”。

在 50 万量级数据下,这类方案的优化目标不是让 Elasticsearch 原生支持随机跳页,而是把随机跳页改造成可控距离内的顺序查询。文章提到经过三轮优化后,任意跳页耗时从 10 分钟级降到 1 秒级,这背后值得关注的是工程拆解方式,而不只是某一个参数。

为什么 from + size 会在深页失控

假设每页 20 条,用户跳到第 20000 页,from 就是 399980。Elasticsearch 需要在每个分片上收集大量候选结果,再合并、排序、丢弃前面的数据。你只想要 20 条,但集群要为前面几十万条记录付出排序和内存成本。

search_after 的思路不同:它要求你给出上一页最后一条记录的排序值,然后继续向后取。例如按 created_at desc, id asc 排序时,下一页请求会带上上一页最后一条的 created_atid。这样查询不会随着页码线性变重。

问题也很明显:如果用户直接输入第 20000 页,你没有第 19999 页最后一条记录的排序值,就没法直接调用 search_after

所以优化的关键变成了:

  • 为部分页码保存 search_after 所需的排序值,也就是“锚点”;
  • 跳页时找到离目标页最近的锚点;
  • 从锚点页开始用 search_after 追到目标页;
  • 用 Redis 做多级缓存,避免每次都从第 1 页重新走。

三轮优化的工程逻辑

第一轮通常是“分段预热缓存”:比如每隔 100 页保存一个锚点。用户跳到第 20340 页时,不从第 1 页走,而是从第 20300 页的锚点开始走 40 页。

第二轮是在查询本身上减负。来源摘要提到的两个关键动作很实用:禁用 _source、关闭 track_total_hits。如果列表页只需要少数字段,可以用 stored_fields: []docvalue_fields 或精简 _source;如果不需要精确总数,就不要让 Elasticsearch 每次都统计完整命中量。

第三轮是“大区间预热 + 小分页精细锚点”。可以理解为两层索引:

  • 粗粒度锚点:例如每 1000 页一个,用来快速进入大区间;
  • 细粒度锚点:热点区间内每 10 页或 20 页一个,用来减少追页次数;
  • 最近访问锚点:用户连续跳页或反复查看相邻页时,直接复用。

这个方案的精髓不在于固定间隔是多少,而在于把“缓存成本”和“追页成本”放到一起算。间隔太大,跳页时还要滚很多页;间隔太小,Redis 写入量和预热成本又会升高。

可以这样实践:一个可改造的 Python 示例

下面的示例演示一个最小可运行思路:使用 Elasticsearch Python 客户端查询,用 Redis 保存页码锚点。你需要把 ES_URLINDEX_NAME、排序字段改成自己的环境。

安装依赖:

pip install elasticsearch redis

示例代码:

import json
import math
import os
from typing import Optional

import redis
from elasticsearch import Elasticsearch

ES_URL = os.getenv("ES_URL", "http://localhost:9200")
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
INDEX_NAME = os.getenv("INDEX_NAME", "orders")
PAGE_SIZE = 20
ANCHOR_STEP = 100

es = Elasticsearch(ES_URL)
r = redis.Redis.from_url(REDIS_URL, decode_responses=True)

SORT = [
    {"created_at": "desc"},
    {"id.keyword": "asc"}
]


def anchor_key(page: int) -> str:
    return f"es-anchor:{INDEX_NAME}:page:{page}"


def save_anchor(page: int, sort_values: list) -> None:
    r.set(anchor_key(page), json.dumps(sort_values), ex=3600)


def load_anchor(page: int) -> Optional[list]:
    raw = r.get(anchor_key(page))
    return json.loads(raw) if raw else None


def find_nearest_anchor(target_page: int) -> tuple[int, Optional[list]]:
    anchor_page = (target_page // ANCHOR_STEP) * ANCHOR_STEP
    while anchor_page > 0:
        anchor = load_anchor(anchor_page)
        if anchor:
            return anchor_page, anchor
        anchor_page -= ANCHOR_STEP
    return 1, None


def fetch_page_after(search_after: Optional[list]) -> dict:
    body = {
        "size": PAGE_SIZE,
        "sort": SORT,
        "track_total_hits": False,
        "_source": ["id", "created_at", "status", "amount"],
        "query": {"match_all": {}}
    }
    if search_after:
        body["search_after"] = search_after
    return es.search(index=INDEX_NAME, body=body)


def jump_to_page(target_page: int) -> list[dict]:
    if target_page < 1:
        raise ValueError("target_page must be >= 1")

    current_page, search_after = find_nearest_anchor(target_page)

    hits = []
    while current_page <= target_page:
        resp = fetch_page_after(search_after)
        hits = resp["hits"]["hits"]
        if not hits:
            return []

        last_sort = hits[-1]["sort"]

        if current_page % ANCHOR_STEP == 0:
            save_anchor(current_page, last_sort)

        if current_page == target_page:
            return [hit["_source"] for hit in hits]

        search_after = last_sort
        current_page += 1

    return []


if __name__ == "__main__":
    page = int(os.getenv("PAGE", "1000"))
    rows = jump_to_page(page)
    print(json.dumps(rows[:3], ensure_ascii=False, indent=2))

运行方式:

ES_URL=http://localhost:9200 \
REDIS_URL=redis://localhost:6379/0 \
INDEX_NAME=orders \
PAGE=20000 \
python deep_page.py

这段代码还只是骨架。生产环境里,通常要补上查询条件哈希,例如把筛选条件、排序字段、租户 ID 拼进 Redis key,否则不同查询会误用同一批锚点。

查询参数也要跟着收紧

锚点只能减少“从哪里开始查”的成本,单次查询本身仍要足够轻。可以参考这种 Elasticsearch 请求结构:

POST /orders/_search
{
  "size": 20,
  "track_total_hits": false,
  "_source": ["id", "created_at", "status", "amount"],
  "sort": [
    { "created_at": "desc" },
    { "id.keyword": "asc" }
  ],
  "search_after": ["2024-06-01T10:20:30Z", "order_900001"],
  "query": {
    "bool": {
      "filter": [
        { "term": { "status": "paid" } }
      ]
    }
  }
}

几个细节很重要:

  • 排序字段必须稳定,最好有唯一字段兜底,比如 created_at + id
  • 不要在深分页路径上返回大字段,列表页需要什么就取什么;
  • 不需要精确总数时关闭 track_total_hits
  • 查询条件变化后,锚点缓存必须隔离或失效;
  • 索引写入频繁时,要接受锚点附近结果轻微漂移,或者引入 PIT 保持一致视图。

什么时候值得上这套方案

如果产品真的要求“输入页码直接跳到任意页”,而数据量又已经超过 from + size 的舒适区,search_after + Redis 锚点 是一个务实方案。它不魔法,也不免费,本质是用缓存和预热把最坏路径缩短。

落地前可以用这张检查表判断:

  • 页面是否必须支持任意跳页,而不是只支持下一页或无限滚动;
  • 排序字段是否稳定,并且可以追加唯一字段作为 tie-breaker;
  • 查询条件组合是否可控,避免缓存 key 爆炸;
  • Redis 容量是否能承受多级锚点;
  • 数据更新频率是否允许锚点短时间内近似有效;
  • 业务是否能接受“不精确总数”换取查询速度。

深分页优化的重点不是追求一个万能分页组件,而是承认 Elasticsearch 的边界,然后在边界之外搭一层适合业务访问模式的跳转索引。只要锚点粒度、预热策略和查询参数配合好,分钟级跳页完全有机会被压到秒级体验。


相关推荐