结果控制 — CodeRoadMap搜索结果控制:分页、排序、高亮与字段裁剪
这一课你将能做到
- 写出 from/size 分页并解释其深分页成本,说出 search_after 适合什么场景
- 用 sort 按结构化字段排序,理解为什么 text 默认不能排序、doc_values 与 fielddata 的区别
- 配置高亮(highlight)和
_source 字段裁剪,完成本站搜索接口的完整响应结构
为什么要学
接口能查出数据只完成了一半。搜索框下方的题目列表要分页、要按「最多点赞/最新更新」切换排序、关键词要在标题里标红、列表页不需要把答案和长解析全吐回来——这些都是查询 DSL 的配套参数决定的。用错的代价真实存在:爬虫翻到第 1000 页,一个请求要每个分片各取 10 万条候选再归并,集群直接被拖垮。
人话:from + size 像考试按名次取第 9001–9010 名——得先把前 9010 名全排出来才能取;翻得越深,干的无用功越多。search_after 像游标记住「上一页最后一名的位置」,直接从那儿往后找,适合无限滚动和导出,但不能跳页。
先建立图像
搜索请求的完整骨架:
{
"from": 0,
"size": 10,
"query": { },
"sort": [ ],
"_source": [ ],
"highlight": { },
"aggs": { }
}
三种分页对比:
| 方式 | 原理 | 能跳页 | 深度 | 本站用途 |
|---|
| from/size | 取 from+size 条再截取 | 能 | 默认上限 10000 | 普通翻页 |
| search_after | 以上一页最后一条的排序值为起点 | 不能 | 深 | 无限加载、批量导出 |
| scroll | 快照游标(7.x 风格) | 不能 | 深 | 已不推荐,导出改用 search_after/PIT |
跟着做
步骤 1:from/size 基础分页
# 第 1 页
curl -u elastic:abcd1234 -X GET "http://localhost:9200/questions/_search" -H "Content-Type: application/json" -d'
{
"from": 0,
"size": 10,
"query": { "match": { "stem": "事务" } }
}'
# 第 3 页
curl -u elastic:abcd1234 -X GET "http://localhost:9200/questions/_search" -H "Content-Type: application/json" -d'
{
"from": 20,
"size": 10,
"query": { "match": { "stem": "事务" } }
}'
from + size 默认不能超过 10000,超过会报错。这个上限由 index.max_result_window 控制,不要为了让爬虫翻深页而随意调大——正确需求用 search_after。
步骤 2:为什么翻深页很贵——算一笔账
3 个分片,取第 9000–9010 条:每个分片都不知道全局排名,必须各自返回本地前 9010 条,协调节点拿到 27030 条再排序取 10 条。分片越多、from 越大,内存和 CPU 消耗线性放大。所以要把分页窗口当保护机制。
步骤 3:sort 排序与多字段排序
curl -u elastic:abcd1234 -X GET "http://localhost:9200/questions/_search" -H "Content-Type: application/json" -d'
{
"size": 10,
"query": { "match_all": {} },
"sort": [
{ "difficulty": { "order": "desc" } },
{ "updatedAt": { "order": "desc" } }
]
}'
规则:可排序的是开启了 doc_values 的字段——keyword、数值、date。一旦显式 sort,_score 默认不再计算(变为 null),需要时加 "track_scores": true。
对 text 字段排序会报 fielddata 错误。原因:text 的倒排索引是为查词设计的,不是为「按文档读出列值」设计的。临时开 fielddata 会把所有词项装进堆内存,极易 OOM,生产禁用。需求合理时排 .keyword 子字段:
"sort": [ { "lessonTitle.keyword": { "order": "asc" } } ]
步骤 4:search_after 做无限加载
必须配一个全局唯一且稳定的排序(最后加 _id 兜底保证顺序确定)。第一页:
curl -u elastic:abcd1234 -X GET "http://localhost:9200/questions/_search" -H "Content-Type: application/json" -d'
{
"size": 10,
"query": { "term": { "courseId": "C_JAVA" } },
"sort": [
{ "updatedAt": { "order": "desc" } },
{ "_id": "asc" }
]
}'
取本页最后一条的 sort 值(如 ["2026-09-28", "42"]),下一页带上:
curl -u elastic:abcd1234 -X GET "http://localhost:9200/questions/_search" -H "Content-Type: application/json" -d'
{
"size": 10,
"query": { "term": { "courseId": "C_JAVA" } },
"search_after": ["2026-09-28", "42"],
"sort": [
{ "updatedAt": "desc" },
{ "_id": "asc" }
]
}'
数据在翻页期间发生增删会导致漏读或重复。要求强一致翻页时配合 PIT(Point In Time):先 POST /questions/_pit?keep_alive=1m 拿到时间点 ID,在请求里带 pit 参数,整段翻页基于同一快照。
步骤 5:highlight 高亮
curl -u elastic:abcd1234 -X GET "http://localhost:9200/questions/_search" -H "Content-Type: application/json" -d'
{
"size": 5,
"query": {
"multi_match": {
"query": "事务失效",
"fields": ["stem", "analysis"]
}
},
"highlight": {
"pre_tags": ["<em class=\"kw\">"],
"post_tags": ["</em>"],
"fields": {
"stem": {},
"analysis": { "fragment_size": 80, "number_of_fragments": 1 }
}
}
}'
响应中每个 hit 多出 highlight 对象,命中片段被 <em class="kw"> 包裹,前端直接渲染标红。fragment_size 控制片段长度,number_of_fragments 控制返回几段。高亮默认重新对原文做一次轻量分析,性能开销可控,但没必要高亮的字段别开。
步骤 6:_source 裁剪
curl -u elastic:abcd1234 -X GET "http://localhost:9200/questions/_search" -H "Content-Type: application/json" -d'
{
"size": 10,
"_source": ["stem", "tags", "difficulty", "lessonTitle"],
"query": { "match": { "stem": "事务" } }
}'
可用 includes/excludes 精细控制,例如排除大字段:
"_source": {
"excludes": ["analysis", "options"]
}
这省的是网络传输和序列化开销,不省索引存储。彻底不存原文可在 mapping 设 enabled: false/_source: false,但那样无法 reindex,本站不这么做。
讲清楚要点
- 分页方案的决策树:用户点页码、总深度 <1 万 → from/size;无限滚动/移动端加载、全量导出 → search_after(+PIT 保证一致性);不要碰 scroll(官方已不推荐用于新场景),不要调大 max_result_window 硬翻。
- doc_values 是排序聚合的地基:写入时在磁盘上按列存一份字段值(列式结构),排序和聚合时顺序读列,不占堆内存。text 默认不写 doc_values;keyword/数值/date 默认开启。这解释了「为什么聚合排序只能用 keyword」。
- fielddata 是堆内存陷阱:给 text 临时开启后,倒排词项被反转载入 Java 堆,数据量大就频繁 GC 甚至 OOM,且常驻内存。能不用就不用,绝大多数真实需求排
.keyword 即可。
- 相关性排序与业务排序二选一为主:按 _score 排回答「最相关」;按时间/难度排回答「最新/最难」。混用时可用 function_score(按热度衰减、字段加权)在相关性基础上叠加业务因子,第 12 课实战会演示。
- 响应瘦身是接口设计的一部分:列表页裁
_source、详情页再全取;聚合接口只需要聚合结果可设 size: 0。这些习惯在数据量上来后差异明显。
你可能会懵的地方
-
为什么我 sort 之后每条的 _score 都是 null?
显式排序时 ES 默认不计算得分以节省开销。若业务上仍要看分,加 track_scores: true。
-
search_after 为什么不能直接跳到第 50 页?
它是「无状态游标」式设计,每一页只依赖上一页最后一条的排序值,没有「第 N 页」的概念。要跳页只能用 from/size 并接受其成本,或在应用层缓存游标。
-
total 命中数为什么有时显示 10000 就封顶了?
8.x 默认 track_total_hits: 10000,命中超过此值时 total.relation 为 "gte"(至少这么多),不精确统计。需要精确总数设 track_total_hits: true,但会有额外开销,一般列表不需要。
-
高亮片段为什么和原文标点对不上?
高亮返回的是按边界字符切出的片段,不是整句。想要整句可加大 fragment_size 或设 number_of_fragments: 0(返回整个字段内容)。
-
PIT 会不会一直占着资源?
PIT 有 keep_alive,到期自动释放;也可手动 DELETE /_pit。长时间导出任务记得续期,否则中途失效。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 调大 max_result_window 支持深翻 | 每个分片取海量候选,拖垮集群 | search_after + PIT |
| 对 text 字段开 fielddata 排序 | 堆内存暴涨、OOM | 排 .keyword |
| sort 不加 _id 兜底 | 相同排序值时分页不稳定、漏数据 | 末尾加 {_id: asc} |
| 列表页返回完整 _source | 传输大、接口慢 | _source includes 裁剪 |
| 用 scroll 做用户翻页 | 快照过时、占用资源 | from/size 或 search_after |
| 聚合查询还带 size:10 文档 | 多余 IO | 只算聚合时 size: 0 |
卡点急救
- 翻页报
result window is too large:不是调大窗口,而是把深翻需求改为 search_after;用户可见分页把页码上限控制在 100 页以内(产品上也几乎没人看第 300 页)。
- 排序报
fielddata is disabled:确认字段类型,改用 字段.keyword;若确实需要按文本排,评估搜索建议(search_as_you_type)等更合适的类型,而不是开 fielddata。
- search_after 翻页出现重复/遗漏:检查排序是否唯一(加 _id)、翻页期间是否有写入(改用 PIT 固定快照)、传入的数组是否与 sort 定义一一对应。
面试常问
-
ES 深分页为什么有性能问题?怎么解决?
from/size 需要协调节点向每个分片取 from+size 条候选再全局归并,深度越大开销越大,且默认 1 万条上限。深度翻页或导出改用 search_after:以稳定排序中最后一条的排序值作为下一页起点,配合 _id 保证唯一、PIT 保证数据一致性;scroll 是旧方案,已不推荐。
-
doc_values 和 fielddata 有什么区别?
doc_values 在写入时生成、存于磁盘、不占堆内存,默认用于 keyword/数值/date 的排序聚合;fielddata 是查询时把 text 倒排词项载入堆内存构建,极易引发 GC 和 OOM,默认禁用。合理设计下排序聚合都应落在 doc_values 字段上。
-
搜索高亮是怎么实现的?
ES 对命中文档的原字段重新执行与查询一致的分析,定位命中词项的位置,按 fragment_size 截取片段并用 pre_tags/post_tags 包裹返回;字段配置和开销相互独立,可只对展示字段开启。
自测三问
- 取第 5000 页时,3 个分片各要返回多少条候选?这说明了什么?
- 为什么对
lessonTitle(text)排序会失败?lessonTitle.keyword 为什么可以?
- search_after 请求里为什么必须带「排序字段 + _id」的组合?漏了 _id 会怎样?
小结一句话
分页、排序、高亮、裁剪解决的是「结果怎么呈现」:浅翻用 from/size、深翻用 search_after+PIT,排序聚合靠 doc_values 而不是 fielddata,列表响应用 _source 保持瘦身。
延伸