这是《Elasticsearch 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 搜索请求看起来只是一条 JSON,但背后是协调节点、多个分片、Lucene 查询树、缓存、排序队列和 fetch 阶段的协作。理解这个过程,才能解释深分页、查询超时、缓存命中、评分差异和查询取消。
25.1 查询的两个阶段
Elasticsearch 默认搜索分为 query phase 和 fetch phase。
sequenceDiagram
participant C as Client
participant N as Coordinating Node
participant S1 as Shard 0
participant S2 as Shard 1
participant S3 as Shard 2
C->>N: search request
N->>S1: query phase
N->>S2: query phase
N->>S3: query phase
S1-->>N: top N doc id + score + sort values
S2-->>N: top N doc id + score + sort values
S3-->>N: top N doc id + score + sort values
N->>N: merge and choose final top size
N->>S1: fetch selected docs
N->>S2: fetch selected docs
N->>S3: fetch selected docs
S1-->>N: _source / highlight
S2-->>N: _source / highlight
N-->>C: final response
25.1.1 Query Phase
每个分片执行:
- 解析查询 DSL;
- 构造 Lucene 查询树;
- 找到候选文档;
- 计算评分或应用 filter;
- 按
_score或 sort values 排序; - 返回前
from + size条文档标识。
这一阶段不返回 _source,只返回足够的排序信息和 doc id。
25.1.2 Fetch Phase
协调节点归并出最终结果后,只去相关分片取被选中的文档内容:
_source;- selected fields;
- highlight;
- nested 或 parent/child 需要的字段;
- script fields;
- explain 信息。
因此,返回 20 条结果通常比返回 1000 条便宜得多。深分页的问题不在最后一页展示多少条,而在于每个分片必须为前 from + size 条维护排序队列。
25.2 查询树改写
DSL 会被解析成 Lucene 查询对象,并在执行前改写。例如:
GET products-v3/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "轻薄笔记本" } }
],
"filter": [
{ "term": { "status": "on_sale" } },
{ "range": { "price": { "lte": 8000 } } }
]
}
}
}
可能被改写为类似结构:
BooleanQuery
MUST: BM25 scored query for title
FILTER: TermQuery(status=on_sale)
FILTER: IndexOrDocValuesQuery(price <= 8000)
查询改写会考虑:
- 字段类型;
- 是否有
norms; - 是否需要评分;
- 是否能使用缓存;
- range 查询适合倒排索引还是 Doc Values;
- match phrase 是否需要 positions;
- nested 查询是否能精确关联子对象。
这也是为什么 Mapping 会影响查询性能,而不仅是影响磁盘占用。
25.3 Query 与 Filter
query 上下文回答“这个文档是否匹配,匹配得怎么样”,会计算相关性评分;filter 上下文回答“是或不是”,不评分,结果可缓存。
GET products-v3/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "笔记本" } }
],
"filter": [
{ "term": { "status": "on_sale" } },
{ "range": { "price": { "gte": 3000, "lte": 8000 } } },
{ "term": { "category_id": "cat_101" } }
]
}
}
}
判断标准:
| 条件 | 建议 |
|---|---|
| 关键词、标题、描述 | must / should,参与评分 |
| 状态、类目、标签、权限 | filter |
| 时间范围、价格范围 | filter |
| 需要影响相关性的运营加权 | should 或 function_score |
| 只做统计范围限制 | post_filter 或聚合外部条件 |
经验法则:能放 filter 的条件不要放 must。权限过滤尤其应该放 filter,并可被缓存。
25.4 DFS Query Then Fetch
BM25 评分需要词项在分片内的统计信息。默认 query then fetch 使用分片本地统计,不同分片对同一词的 IDF 可能略有差异。
GET products-v3/_search?dfs_query_then_fetch
{
"query": {
"match": { "title": "elasticsearch" }
}
}
DFS Query Then Fetch 会先收集全局词频,再执行评分,分数更接近单索引语义,但多一轮通信,通常更慢。
是否使用 DFS:
| 场景 | 建议 |
|---|---|
| 数据量大、分片统计均匀 | 默认即可 |
| 小索引、低频词、评分奇怪 | 可测试 DFS |
| 高 QPS 在线搜索 | 谨慎使用 |
| 排序主要由业务因子决定 | 默认即可 |
生产搜索通常不会盲目开启 DFS,而是通过评测集判断收益。
25.5 归并与排序
协调节点拿到每个分片的前 from + size 条结果后,按排序键归并。
排序键可能是:
_score;- 字段值;
_id;- geo distance;
- script score;
- nested 或 parent/child 内部排序。
如果排序键存在相同值,结果顺序可能不稳定。常见处理:
GET products-v3/_search
{
"sort": [
{ "sales_30d": "desc" },
{ "_id": "asc" }
]
}
用 _id 做 tie-breaker 能得到稳定顺序,但 _id 是字符串,排序成本较高。业务系统也可以引入单调递增的 sequence_no 或 updated_seq。
25.6 深分页
25.6.1 From / Size
from = 10000, size = 20
每个分片需要取前 10020 条
协调节点最多归并 分片数 * 10020 条排序候选
默认限制:
index.max_result_window = 10000
查看和修改:
GET products-v3/_settings/index.max_result_window
PUT products-v3/_settings
{
"index.max_result_window": 20000
}
不要为了业务随手调大。深分页会放大内存和 CPU 压力,还可能被恶意请求利用。
25.6.2 Search After
Search After 适合无跳页的连续翻页:
GET products-v3/_search
{
"size": 20,
"sort": [
{ "sales_30d": "desc" },
{ "_id": "asc" }
],
"search_after": [10000, "sku_10001"]
}
每个结果会返回 sort 值,下一页把它作为游标:
"sort": [12300, "sku_10020"]
限制:
- 不能直接跳到第 N 页;
- 排序值必须稳定唯一;
- 页面刷新期间数据变化可能导致重复或遗漏;
- 需要配合 PIT 获得更一致的快照。
25.6.3 Point In Time
PIT 保存一个轻量的索引视图:
POST products-v3/_pit?keep_alive=2m
返回:
{
"id": "pit_id_base64"
}
带 PIT 查询:
GET /_search
{
"size": 20,
"pit": {
"id": "pit_id_base64",
"keep_alive": "2m"
},
"sort": [
{ "sales_30d": "desc" },
{ "_id": "asc" }
],
"search_after": [10000, "sku_10001"]
}
删除 PIT:
DELETE /_pit
{
"id": "pit_id_base64"
}
PIT 会持有 searcher 相关资源,必须设置合理 keep_alive,用完及时删除。
25.6.4 Scroll
Scroll 适合离线导出:
POST products-v3/_search?scroll=2m
{
"size": 1000,
"sort": ["_doc"]
}
Scroll 快照成本高,不适合普通用户分页。新代码应优先考虑 PIT + search after,导出场景也应设置清楚超时和清理机制。
25.7 缓存
Elasticsearch 有多类缓存:
| 缓存 | 作用对象 | 失效时机 |
|---|---|---|
| Node Query Cache | filter 查询结果位图 | 段结构变化、容量淘汰 |
| Request Cache | size=0 请求结果 | 索引 refresh、分段变化 |
| Field Data Cache | text 聚合等正排需求 | 建议避免使用 |
| Page Cache | 操作系统磁盘页 | 内存压力 |
| Shard Request Cache | 聚合与 suggestion | refresh 后失效 |
查看缓存:
GET products-v3/_stats/query_cache,request_cache,fielddata
Request Cache 只对 size=0 的请求生效:
GET products-v3/_search
{
"size": 0,
"query": {
"bool": {
"filter": [
{ "range": { "created_at": { "gte": "now-1d/d", "lte": "now/d" } } }
]
}
},
"aggs": {
"brands": {
"terms": { "field": "brand_id" }
}
},
"request_cache": true
}
如果查询里使用 now 精确到毫秒,会导致缓存几乎无法命中。把时间取整为当天、当小时,能显著提高复用率。
25.8 查询取消与超时
搜索可以设置超时:
GET products-v3/_search
{
"timeout": "2s",
"query": {
"match_all": {}
}
}
timeout 是尽力而为的检查点,不保证请求一定被立即取消。响应中的 timed_out 表示部分结果可能不完整:
"timed_out": true
查询取消来源包括:
- 客户端断开连接;
- 搜索超时;
- 搜索任务被取消;
- 分片级超时或失败;
- 搜索线程池拒绝。
查看当前搜索任务:
GET /_tasks?actions=*search*&detailed
GET /_cat/tasks?v
取消任务:
POST /_tasks/task_id:1/_cancel
应用侧必须设置请求超时和最大返回值,不能把用户输入直接变成无约束查询。
25.9 查询线程池
搜索请求使用 search thread pool:
GET /_cat/thread_pool/search?v&h=node_name,name,active,queue,rejected,completed
GET /_nodes/stats/indices/search
常见压力来源:
- 查询扇出过大;
- 深分页;
- 大 size;
- 高基数字段聚合;
- 通配符前缀查询;
- script 遍历大量文档;
- 查询时间范围过宽;
- 查询与写入争抢资源。
治理顺序通常是:
- 限制 from/size 和索引范围;
- 改写查询,缩小 filter;
- 优化 Mapping 和字段类型;
- 使用预聚合;
- 拆分冷热集群;
- 增加节点或分片;
- 调整线程池,但这通常是最后一步。
25.10 Explain 与 Profile
25.10.1 Explain
explain 查看文档为什么匹配:
GET products-v3/_search
{
"explain": true,
"query": {
"match": { "title": "轻薄笔记本" }
}
}
也可以解释指定文档:
GET products-v3/_explain/sku_10001
{
"query": {
"match": { "title": "轻薄笔记本" }
}
}
explain 本身昂贵,只适合排查单条请求,不应在高 QPS 常规查询中开启。
25.10.2 Profile
Profile API 查看查询耗时分布:
GET products-v3/_search
{
"profile": true,
"query": {
"bool": {
"must": [
{ "match": { "title": "笔记本" } }
],
"filter": [
{ "term": { "status": "on_sale" } }
]
}
}
}
关注:
- rewrite time;
- collect time;
- next doc time;
- score time;
- build_scorer time;
- aggregation time;
- fetch phase。
Profile 会增加开销,输出也很大。适合测试环境或低峰单请求诊断。
25.11 查询优化清单
- 请求只指向必要的索引或别名;
- 强制时间范围;
size尽量小;- 深分页改 search after + PIT;
- filter 优先;
- 避免通配符前缀和宽泛正则;
- 高基数字段聚合白名单化;
- 排序字段使用 Doc Values;
_source只返回必要字段;- 脚本参数化并避免全量遍历;
- 聚合结果可缓存时使用固定时间桶;
- 用 profile 定位,不靠猜;
- 查询 QPS 和扇出有上限;
- 慢查询日志接入监控。
本章小结
一次搜索由 query phase 找到每个分片的候选结果,再由 fetch phase 取回最终文档内容。查询扇出、深分页、缓存、评分和排序队列共同决定了性能。优化搜索时,先缩小数据范围和返回规模,再优化 DSL 和 Mapping,最后才考虑扩容。
思考题
- Query phase 和 fetch phase 分别做什么?
- 为什么深分页会放大内存和 CPU 成本?
- Search After 为什么必须配合稳定排序?
- Request Cache 为什么对
size=0请求更有价值? - 遇到查询延迟毛刺,你会用哪些工具定位?