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_at 和 id。这样查询不会随着页码线性变重。
问题也很明显:如果用户直接输入第 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_URL、INDEX_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 的边界,然后在边界之外搭一层适合业务访问模式的跳转索引。只要锚点粒度、预热策略和查询参数配合好,分钟级跳页完全有机会被压到秒级体验。