数据库基础体系 · 第 41/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
Elasticsearch 查询与聚合:Query DSL、相关性、分页和统计
本文讨论 Elasticsearch 官方稳定版本中公开、常用的搜索语义。示例使用 Elasticsearch 8.x 风格 REST API 和 JSON Query DSL;请求通过 HTTP 发送到一个已连接的 Elasticsearch 集群。Elasticsearch 不提供关系数据库意义上的跨文档事务,示例中的写入、刷新和搜索可见性遵循其近实时模型。
一、先建立整体模型
一次 Elasticsearch 搜索通常包含以下部分:
GET /products/_search
{
"query": { ... },
"sort": [ ... ],
"from": 0,
"size": 10,
"aggs": { ... }
}
它们分别解决不同问题:
query:哪些文档匹配,以及匹配文档的相关性分数是多少。sort:结果按照什么顺序返回。from、size:返回哪一页、多少条文档。aggs:对匹配文档进行分组、计数、求和、平均值等统计。track_total_hits:是否精确计算匹配总数。search_after、PIT:在深分页和一致遍历场景中控制位置与快照。
搜索过程并不是“在一个全局表上执行一次查询”。对于含有多个主分片的索引,协调节点通常会:
- 接收客户端请求;
- 将请求转发到相关分片;
- 每个分片本地执行查询、排序和聚合;
- 协调节点合并各分片的结果;
- 返回最终文档、总命中数、聚合结果和耗时等信息。
因此,相关性分数、分页边界、聚合计数和结果一致性都必须结合“分片级执行、协调节点合并”来理解。
1. 近实时,而不是事务立即可见
文档写入后,数据首先进入事务日志和内存缓冲区。经过 refresh 后,新的 Lucene segment 才能被搜索看到。默认情况下,索引通常是近实时的,而不是每次写入都立即可见。
可以在测试中显式刷新:
POST /products/_refresh
这适合测试验证,不应简单地把每次写入后的强制 refresh 当作生产方案,因为频繁 refresh 会增加 segment 创建和合并压力。
单次写入也可以使用:
PUT /products/_doc/1?refresh=wait_for
{
"name": "Elasticsearch 实战",
"category": "book",
"price": 88
}
refresh=wait_for 会等待下一次 refresh 后再返回,便于需要“写入后立即搜索”的请求,但它仍然不是跨文档事务,也不会把多个写入变成原子操作。
二、Query DSL:用 JSON 表达搜索条件
Query DSL 是 Elasticsearch 的 JSON 查询语言。一个查询通常是一个查询对象,例如:
{
"match": {
"title": "分布式搜索"
}
}
查询可以嵌套组合,形成复杂条件:
{
"bool": {
"must": [
{
"match": {
"title": "Elasticsearch"
}
}
],
"filter": [
{
"term": {
"status": "published"
}
}
]
}
}
要正确理解 Query DSL,必须先区分字段类型、分词和查询方式。
1. text 与 keyword
一个典型 Mapping 可能如下:
PUT /products
{
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "standard",
"fields": {
"keyword": {
"type": "keyword"
}
}
},
"category": {
"type": "keyword"
},
"description": {
"type": "text"
},
"price": {
"type": "scaled_float",
"scaling_factor": 100
},
"published_at": {
"type": "date"
},
"sales": {
"type": "integer"
}
}
}
}
这里有两个不同用途的字段:
title是text,用于全文检索,会经过 analyzer 分词;title.keyword是多字段中的keyword,保留完整字符串,适合精确过滤、排序和聚合;category是keyword,适合精确值匹配;price、sales是数值字段;published_at是日期字段。
例如,文档中的:
{
"title": "Elasticsearch 查询基础",
"category": "database"
}
title 可能被分析为若干词项,而 title.keyword 通常仍是完整值 "Elasticsearch 查询基础"。实际词项取决于 Mapping 中配置的 analyzer、语言分析器和版本行为,不能仅凭原始字符串猜测。
可以用 _analyze 检查分析结果:
POST /products/_analyze
{
"analyzer": "standard",
"text": "Elasticsearch 查询基础"
}
查询时使用的分析过程必须和索引时的字段设计相匹配。一个字段如果被设计成全文字段,却使用 term 查询原始长句,往往查不到结果。
三、全文查询与精确查询
1. match:对输入文本进行分析
match 是常用的全文查询:
GET /products/_search
{
"query": {
"match": {
"title": "分布式 搜索"
}
}
}
对于 text 字段,Elasticsearch 会对查询文本执行相应的搜索 analyzer,然后根据得到的词项查找倒排索引。默认情况下,多个词项通常按 OR 逻辑参与匹配;也可以明确要求全部词项匹配:
{
"match": {
"title": {
"query": "分布式 搜索",
"operator": "and"
}
}
}
operator: and 表示文档必须同时包含分析后的各个词项,但不等于短语查询。词项可以出现在不同位置。
2. match_phrase:要求词项按相邻顺序出现
GET /products/_search
{
"query": {
"match_phrase": {
"title": "分布式 搜索"
}
}
}
它要求分析后的词项以相应顺序、相邻位置出现,除非设置 slop 允许一定距离:
{
"match_phrase": {
"title": {
"query": "分布式 搜索",
"slop": 2
}
}
}
match_phrase 不是字符串的字节级相等判断。它仍然受 analyzer、词项位置和字段类型影响。
3. term:不分析查询值
term 查询用于精确匹配一个已存在的索引词项:
GET /products/_search
{
"query": {
"term": {
"category": "database"
}
}
}
对于 keyword、数值、日期等字段,term 通常适合过滤。对 text 字段使用 term 是常见错误:
{
"term": {
"title": "Elasticsearch 查询基础"
}
}
如果 title 建立倒排索引时已经被拆成多个词项,那么完整字符串通常不是一个词项,这个查询可能返回 0 条结果。若需要匹配完整原始值,应使用 title.keyword:
{
"term": {
"title.keyword": "Elasticsearch 查询基础"
}
}
大小写也会影响 term 查询。term 不会替你执行通常意义上的全文分析、大小写归一化或分词。
4. 范围查询
数值、日期和可排序字段可以使用 range:
GET /products/_search
{
"query": {
"range": {
"price": {
"gte": 50,
"lt": 100
}
}
}
}
边界含义如下:
gte:大于等于;gt:大于;lte:小于等于;lt:小于。
日期示例:
{
"range": {
"published_at": {
"gte": "now-30d/d",
"lt": "now"
}
}
}
日期数学表达式的解析依赖字段类型和请求时间。需要可重复测试时,应尽量使用固定时间,而不是直接依赖 now。
四、Bool 查询:把条件组合成逻辑树
bool 查询是 Query DSL 中最重要的组合结构之一:
{
"bool": {
"must": [],
"filter": [],
"should": [],
"must_not": [],
"minimum_should_match": 1
}
}
四类子句含义不同。
1. must
must 中的查询必须匹配,并参与相关性评分:
{
"bool": {
"must": [
{
"match": {
"title": "Elasticsearch"
}
}
]
}
}
2. filter
filter 中的查询必须匹配,但不参与相关性评分:
{
"bool": {
"must": [
{
"match": {
"title": "Elasticsearch"
}
}
],
"filter": [
{
"term": {
"category": "database"
}
},
{
"range": {
"price": {
"lte": 100
}
}
}
]
}
}
这个查询表达的是:
其中,只有 title 的全文查询影响 _score。分类和价格只是资格条件。
把稳定的结构化条件放在 filter 中,通常更符合语义,也避免让价格、状态等条件改变文本相关性。缓存是否生效、如何生效由 Elasticsearch 和底层实现决定,不能把 filter 简化为“必然缓存”。
3. must_not
must_not 中的查询不能匹配:
{
"bool": {
"must_not": [
{
"term": {
"status": "deleted"
}
}
]
}
}
must_not 是过滤性质的排除条件,不用于给文档增加正向相关性分数。
4. should
should 表示可选的、通常用于提高相关性的条件:
{
"bool": {
"must": [
{
"match": {
"description": "搜索"
}
}
],
"should": [
{
"match": {
"title": {
"query": "搜索",
"boost": 3
}
}
},
{
"term": {
"title.keyword": {
"value": "搜索",
"boost": 5
}
}
}
]
}
}
此时文档只要满足 must 就可能匹配,满足 should 的文档会获得额外分数。
should 是否变成必选条件,取决于它和 must、filter 的组合方式。工程上不要假设“只要写了 should 就必须满足”。如果必须至少满足若干个 should 条件,应明确设置:
{
"bool": {
"should": [
{ "term": { "category": "database" } },
{ "term": { "category": "search" } }
],
"minimum_should_match": 1
}
}
如果 bool 中已经存在 must 或 filter,should 默认通常是可选的;需要强制约束时,显式写 minimum_should_match 更清楚。
五、相关性:_score 是如何产生的
相关性是搜索结果“为什么排在前面”的机制。全文查询通常会给每个匹配文档计算一个 _score,再按分数降序返回。
1. BM25 的基本形式
Elasticsearch 默认常见的文本相似度算法是 BM25。可以将单个查询词项对文档的贡献近似表示为:
其中:
- :查询词项;
- :文档;
- :词项 在文档 中出现的次数;
- :文档长度,通常指字段中的词项数量;
- :该字段所有文档的平均长度;
- :词频饱和参数;
- :文档长度归一化参数;
- :逆文档频率,越稀有的词通常越重要。
直觉分三步:
- 一个词在文档中出现,文档获得分数;
- 出现次数增加会提高分数,但收益逐渐递减;
- 在较长文档中出现同样次数,贡献通常会因长度归一化而降低;
- 只在少数文档中出现的词比出现在大量文档中的词更有区分度。
多个查询词的得分通常会组合起来。精确组合公式会受查询类型、协调方式、是否使用短语、boost 等因素影响,因此不能把 _score 当作概率或百分制相关度。
2. 一个完整算例
假设字段中有三个文档,分析后得到以下词项:
| 文档 | 词项 |
|---|---|
| A | elasticsearch, query, query |
| B | elasticsearch, query, aggregate, index |
| C | database, index |
查询词为 query elasticsearch。
第一步,匹配集合是 A 和 B,C 不包含这两个词项。
第二步,统计文档频率:
query出现在 A、B,共 2 篇;elasticsearch出现在 A、B,共 2 篇;- 总文档数为 3。
因此,两者的 IDF 都低于只在一篇文档中出现的词,例如 aggregate。
第三步,比较词频和长度:
- A 中
query出现 2 次; - B 中
query出现 1 次; - B 的字段更长,长度归一化可能使其单次词项贡献降低。
A 可能因为 query 的更高词频获得更高分,但 BM25 的词频收益是饱和的,并不意味着出现两次就获得两倍分数。实际数值还受 analyzer、相似度配置、分片局部统计和查询结构影响。
3. 分片会影响相关性统计
倒排索引在每个分片上独立维护。默认搜索时,每个分片可能基于自己的文档频率计算 IDF。某个词在分片 1 很稀有、在分片 2 很常见时,同一个词可能在不同分片得到不同分数。
可以使用:
GET /products/_search?search_type=dfs_query_then_fetch
{
"query": {
"match": {
"title": "Elasticsearch"
}
}
}
这个搜索类型会先收集更全面的词项统计,再执行查询,以改善分片间相关性统计的一致性,但会增加一次协调阶段和网络成本。它不是所有相关性问题的解决方案,也不能替代合理的分片设计。
4. boost、constant_score 和 function_score
boost 用于表达业务上的相对偏好:
{
"match": {
"title": {
"query": "Elasticsearch",
"boost": 2
}
}
}
它改变的是相关性组合中的权重,不是“增加 2 分”的固定含义。
如果只需要“满足条件即可”,可以使用 constant_score:
{
"constant_score": {
"filter": {
"term": {
"status": "published"
}
},
"boost": 1
}
}
所有匹配文档获得相同的基础分数。若查询中只有过滤条件,使用:
{
"bool": {
"filter": [
{ "term": { "status": "published" } }
]
}
}
通常更直接。
如果需要把数值字段、衰减函数或自定义函数加入相关性,可使用 function_score。例如按评分排序时,仍应确认数值字段的业务含义、缺失值和函数对极端值的影响,而不能把“业务权重”与文本相关性混成一个未经验证的黑盒分数。
5. 相关性诊断:_explain 和 Profile
针对单个文档查看匹配原因:
GET /products/_explain/1
{
"query": {
"match": {
"title": "Elasticsearch"
}
}
}
响应会说明:
- 文档是否匹配;
- 总分如何由子查询组成;
- 哪些词项命中了;
- 词频、文档频率等因素如何影响分数。
_explain 适合单文档诊断,不适合对大批量文档循环调用。
分析查询执行阶段可以使用:
GET /products/_search
{
"profile": true,
"query": {
"bool": {
"must": [
{ "match": { "title": "Elasticsearch" } }
],
"filter": [
{ "term": { "category": "database" } }
]
}
}
}
Profile 输出的是查询执行细节和耗时分解。它本身会增加执行开销,且测量环境与生产环境可能不同,不能直接将 Profile 数值当作端到端用户延迟。
六、分页:from/size、search_after 与 PIT
分页不仅是“跳过几条数据”,还涉及排序、分片协调、数据变化和资源消耗。
1. from 和 size
最简单的分页:
GET /products/_search
{
"from": 0,
"size": 20,
"query": {
"match": {
"title": "Elasticsearch"
}
},
"sort": [
{ "_score": "desc" },
{ "_id": "asc" }
]
}
第二页:
{
"from": 20,
"size": 20
}
在分布式搜索中,每个分片不能只返回最终页面的 20 条。假设协调节点需要全局第 21~40 条,那么每个分片至少要提供足够多的候选结果,协调节点才能合并排序。因此,from + size 越大,分片侧候选集和协调开销通常越大。
Elasticsearch 对普通深分页设置了 index.max_result_window 限制,默认常见值为 10000。修改这个限制只是扩大资源消耗上限,不会消除深分页的代价。
2. 排序必须稳定
只按 _score 排序时,多个文档可能分数相同;只按字段排序时,也可能存在相同值。分页时如果排序键不唯一,文档可能在页与页之间重复或遗漏。
可以增加唯一的 tie-breaker:
"sort": [
{ "published_at": "desc" },
{ "_id": "asc" }
]
但要注意,_id 虽然通常可用于排序和提供稳定次序,但在大规模场景中,专门建立一个具有 doc_values 的唯一关键字段通常更适合作为排序字段。排序字段必须在索引 Mapping 和查询设计阶段准备好。
3. search_after
search_after 不使用页码,而是使用上一页最后一条文档的排序值作为游标:
第一页:
GET /products/_search
{
"size": 20,
"query": {
"match": {
"title": "Elasticsearch"
}
},
"sort": [
{ "published_at": "desc" },
{ "_id": "asc" }
]
}
假设最后一条结果的 sort 值为:
["2025-01-20T10:00:00.000Z", "product-123"]
下一页:
GET /products/_search
{
"size": 20,
"query": {
"match": {
"title": "Elasticsearch"
}
},
"search_after": [
"2025-01-20T10:00:00.000Z",
"product-123"
],
"sort": [
{ "published_at": "desc" },
{ "_id": "asc" }
]
}
search_after 数组必须与 sort 字段和顺序对应。第一条请求的排序定义不能改变,否则游标没有明确含义。
它适合:
- 无限滚动;
- 导出大量结果;
- 顺序遍历结果集;
- 避免
from深分页的候选集增长。
它不适合天然的“跳到第 500 页”,因为游标依赖上一页的排序值。
4. PIT:固定搜索视图
如果分页期间有文档新增、更新或删除,只使用 search_after,不同请求可能看到不同的索引状态。结果可能发生变化。
PIT(Point in Time)用于在一段时间内固定一个搜索视图:
POST /products/_pit?keep_alive=2m
响应会返回一个 id。之后搜索:
GET /_search
{
"pit": {
"id": "返回的 PIT ID",
"keep_alive": "2m"
},
"size": 20,
"query": {
"match": {
"title": "Elasticsearch"
}
},
"sort": [
{ "published_at": "desc" },
{ "_shard_doc": "asc" }
]
}
下一页继续使用响应中的 sort 值:
GET /_search
{
"pit": {
"id": "返回的 PIT ID",
"keep_alive": "2m"
},
"size": 20,
"search_after": [
"2025-01-20T10:00:00.000Z",
12345
],
"query": {
"match": {
"title": "Elasticsearch"
}
},
"sort": [
{ "published_at": "desc" },
{ "_shard_doc": "asc" }
]
}
使用 PIT 时,响应里的 PIT ID 可能发生变化,客户端应使用最新返回的 ID。遍历结束后主动关闭:
DELETE /_pit
{
"id": "当前 PIT ID"
}
PIT 会保留相关搜索视图和 segment,长时间、大量并发 PIT 会增加文件句柄、磁盘和 segment 生命周期压力。keep_alive 不是越长越好,应按一次遍历的预计时间设置,并处理 PIT 过期错误。
PIT 和 search_after 解决的是搜索视图与游标问题,不是事务快照。它不会让外部数据库、多个索引或后续写入获得原子一致性。
七、总命中数与结果数量
搜索响应通常包含:
"hits": {
"total": {
"value": 10000,
"relation": "gte"
},
"max_score": 1.42,
"hits": []
}
total 有两种重要状态:
relation: "eq":返回值是精确总数;relation: "gte":返回值是“至少有这么多”,达到统计上限后不再继续精确计数。
可以要求精确统计:
GET /products/_search
{
"track_total_hits": true,
"query": {
"term": {
"category": "database"
}
}
}
也可以设置数值上限:
{
"track_total_hits": 10000
}
这表示最多精确计算到 10000;超过后可以返回 gte。
精确总数不是免费信息。对于只需要展示“有很多结果”的页面,限制计数通常比强制精确计算更合适。size: 0 只是不返回文档本身,不代表查询和聚合没有成本。
八、聚合:对匹配文档做统计
聚合(Aggregation)是在搜索结果集合上执行的统计框架。一个请求可以同时返回文档结果和聚合:
GET /products/_search
{
"query": {
"match": {
"description": "搜索"
}
},
"aggs": {
"by_category": {
"terms": {
"field": "category"
}
},
"price_stats": {
"stats": {
"field": "price"
}
}
}
}
默认情况下,聚合针对 query 匹配的文档执行。size 只控制返回多少条文档,不控制聚合输入集合。
若只需要统计,不需要文档:
GET /products/_search
{
"size": 0,
"query": {
"term": {
"status": "published"
}
},
"aggs": {
"price_stats": {
"stats": {
"field": "price"
}
}
}
}
1. Metrics 聚合
Metrics 聚合计算数值或统计指标。
min、max、avg、sum
{
"aggs": {
"average_price": {
"avg": {
"field": "price"
}
},
"total_sales": {
"sum": {
"field": "sales"
}
},
"highest_price": {
"max": {
"field": "price"
}
}
}
}
对于没有该字段值的文档,数值聚合通常不会把它作为有效数值参与计算。avg 的分母是有值文档数,而不是所有命中文档数。
stats
{
"aggs": {
"price_stats": {
"stats": {
"field": "price"
}
}
}
}
结果包含:
countminmaxavgsum
例如有价格 10、20、30,其中一个文档缺失价格,则:
不是以全部四个文档作为分母。
cardinality
{
"aggs": {
"unique_categories": {
"cardinality": {
"field": "category"
}
}
}
}
cardinality 用于估算去重后的值数量,通常基于近似算法,而不是对任意大集合执行精确集合计数。它适合高基数字段的统计,但需要理解精度和内存之间的取舍。不要把它默认当作精确的 COUNT(DISTINCT ...)。
2. Bucket 聚合
Bucket 聚合把文档分到桶中,再统计每个桶的文档数或执行子聚合。
terms
GET /products/_search
{
"size": 0,
"aggs": {
"by_category": {
"terms": {
"field": "category",
"size": 10
}
}
}
}
响应中可能包含:
"buckets": [
{ "key": "database", "doc_count": 120 },
{ "key": "search", "doc_count": 95 }
]
terms 适合对 keyword、数值等未分析字段分组。对 text 字段直接聚合通常会被禁止或不适合,因为全文字段被拆成词项,词项聚合不等于原始字符串分组。若确实需要按原始值分组,应使用 field.keyword 或专门的 keyword 字段。
terms 是分布式聚合。各分片先选取本地候选桶,再由协调节点合并。由于某个全局高频值可能在单个分片上并不突出,它可能未进入该分片的候选集合,因此返回的 doc_count 可能不是严格精确值。响应可能提供:
doc_count_error_upper_bound:文档数误差上界;sum_other_doc_count:未返回桶中的其他文档总数。
增加 terms.shard_size 可以提高候选桶数量、改善准确性,但会增加分片侧和协调节点的内存与网络开销。size 控制最终返回桶数,shard_size 控制分片候选数量,两者不是同一个参数。
date_histogram
GET /products/_search
{
"size": 0,
"aggs": {
"sales_per_day": {
"date_histogram": {
"field": "published_at",
"calendar_interval": "day",
"min_doc_count": 0
},
"aggs": {
"daily_sales": {
"sum": {
"field": "sales"
}
}
}
}
}
}
calendar_interval 按日历单位划分,例如天、月;fixed_interval 按固定时间长度划分。二者不能混为一谈,尤其在时区和夏令时切换环境中,日历日不一定等于固定的 24 小时。
时区可以明确指定:
{
"date_histogram": {
"field": "published_at",
"calendar_interval": "day",
"time_zone": "Asia/Shanghai"
}
}
如果不指定业务时区,时间桶边界可能按默认时区计算,导致报表日期与业务日期不一致。
3. 嵌套聚合
聚合可以层层嵌套:
{
"aggs": {
"by_category": {
"terms": {
"field": "category"
},
"aggs": {
"avg_price": {
"avg": {
"field": "price"
}
}
}
}
}
}
执行逻辑是:
- 按
category建立桶; - 将每个类别中的文档送入
avg_price; - 分别计算每个类别的平均价格。
这对应:
它不是“所有文档平均价格”再按类别展示,而是每个桶独立计算。
九、过滤范围与聚合范围
1. query 同时影响文档结果和聚合
{
"query": {
"term": {
"category": "database"
}
},
"aggs": {
"by_status": {
"terms": {
"field": "status"
}
}
}
}
这里的 by_status 只统计 category=database 的文档。
2. post_filter 只影响返回文档
在筛选页面中,经常需要:
- 聚合展示完整候选分布;
- 文档列表应用用户当前选择的过滤条件。
可以使用 post_filter:
GET /products/_search
{
"query": {
"match": {
"description": "搜索"
}
},
"post_filter": {
"term": {
"category": "database"
}
},
"aggs": {
"all_categories": {
"terms": {
"field": "category"
}
}
}
}
执行语义是:
query决定基础匹配集合;- 聚合基于基础匹配集合统计;
post_filter只过滤最终返回的文档命中;- 因此聚合不会被
post_filter缩小。
如果把 category 放进 query.bool.filter,聚合也会只看到该类别。二者的结果不同,这是筛选导航中最容易误用的边界之一。
3. global 聚合
如果需要在聚合中比较“当前查询结果”和“全索引基线”,可以使用 global:
{
"query": {
"term": {
"category": "database"
}
},
"aggs": {
"all_products": {
"global": {},
"aggs": {
"by_status": {
"terms": {
"field": "status"
}
}
}
}
}
}
global 桶会忽略顶层 query,统计整个索引范围内的文档;其内部仍可继续使用过滤聚合缩小范围。
十、过滤、查询和排序如何共同工作
考虑以下请求:
GET /products/_search
{
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": "Elasticsearch 查询",
"fields": [
"title^3",
"description"
]
}
}
],
"filter": [
{
"term": {
"status": "published"
}
},
{
"range": {
"price": {
"lte": 100
}
}
}
]
}
},
"sort": [
"_score",
{
"published_at": "desc"
},
{
"_id": "asc"
}
],
"size": 10
}
逐步解释:
multi_match在title和description中搜索文本;title^3让标题匹配相对于描述匹配具有更高权重;status和price只是资格条件,不改变文本分数;- 首先按
_score降序; - 分数相同时按发布时间降序;
- 仍相同时按
_id升序,形成更稳定的次序。
如果改成:
"sort": [
{ "published_at": "desc" }
]
那么 _score 不再是主要排序依据。结果仍可能匹配全文查询,但“最相关”不再决定文档顺序。查询条件和排序条件是两个独立概念,不能因为使用了全文查询就假设结果一定按相关性排序。
十一、常见错误与诊断路径
1. term 查询 text 字段返回 0 条
失败表现:
GET /products/_search
{
"query": {
"term": {
"title": "Elasticsearch 查询"
}
}
}
诊断顺序:
-
查看 Mapping:
GET /products/_mapping -
检查字段是否为
text; -
检查 analyzer 的词项:
POST /products/_analyze { "field": "title", "text": "Elasticsearch 查询" } -
如果需要全文搜索,改用
match; -
如果需要完整值匹配,使用
title.keyword; -
确认目标文档写入后已经 refresh。
2. 聚合结果缺失或报字段错误
常见原因:
- 对
text字段聚合; - 字段在不同索引中 Mapping 不一致;
- 字段不存在或值类型冲突;
- 使用了错误的多字段路径;
- 字段没有适合聚合的
doc_values。
查看 Mapping:
GET /products/_mapping/field/category*
如果需要按标题原文分组,应在设计阶段建立 title.keyword,而不是在查询时试图把已经分析的词项重新拼回原文。
3. 分页重复或遗漏
可能原因:
- 排序键有大量相同值;
- 没有唯一 tie-breaker;
- 分页期间发生写入、更新或删除;
- 使用
_score,但分片局部统计变化; search_after与上一页的sort值不一致;- PIT 已过期。
处理方式:
- 固定完整的
sort; - 增加稳定且唯一的排序键;
- 长列表使用
search_after; - 需要遍历期间视图稳定时使用 PIT;
- 检查响应中的
sort数组,不要自行重新计算游标。
4. 聚合总数与搜索命中总数不一致
需要区分:
hits.total是匹配文档数量,可能受track_total_hits限制;terms的桶只返回前size个值;sum_other_doc_count表示未返回桶中的其他文档;- 分布式
terms可能存在候选桶误差; - 聚合可能基于
query,也可能因post_filter、global等结构而使用不同范围。
因此不能仅看到一个 doc_count 就把它理解成“整个索引中该值的绝对精确总数”。
5. 相关性看起来“不合理”
诊断步骤:
- 用
_analyze检查索引和查询文本的词项; - 检查字段是否选错,例如查询了
title.keyword; - 用
_explain查看单个文档的分数组成; - 检查
bool中是否错误地使用了filter; - 检查
boost、minimum_should_match和multi_match字段权重; - 检查分片数是否导致词项统计差异;
- 需要时比较普通搜索和
dfs_query_then_fetch的结果; - 用 Profile 分析执行路径,而不是只看总耗时。
十二、生产边界与取舍
1. 查询语义依赖索引设计
Query DSL 不能补救错误 Mapping:
- 需要全文检索的字段应设计为
text; - 需要精确匹配、排序和聚合的字段应设计为
keyword或数值类型; - 需要两种用途时使用多字段;
- 需要嵌套对象独立匹配时,应评估
nested类型,而不是默认使用普通对象; - analyzer 一旦影响已有倒排词项,通常需要重新索引才能使新设计生效。
2. 查询超时不是回滚
搜索请求可以设置超时:
GET /products/_search?timeout=2s
{
"query": {
"match": {
"description": "搜索"
}
}
}
超时意味着请求可以提前返回部分结果或被中止,具体响应需要结合请求状态判断。它不是关系数据库事务中的回滚,也不撤销已经完成的分片计算或之前的写入。
3. 聚合需要控制桶数量
terms 的 size 过大、层层嵌套高基数字段,可能造成较大的堆内存、网络传输和协调开销。对于需要遍历全部唯一值的场景,应评估 composite 聚合。它通过分页方式返回组合桶,适合批量处理,而不是一次性构造所有桶。
示例:
GET /products/_search
{
"size": 0,
"aggs": {
"by_category_and_status": {
"composite": {
"size": 100,
"sources": [
{
"category": {
"terms": {
"field": "category"
}
}
},
{
"status": {
"terms": {
"field": "status"
}
}
}
]
}
}
}
}
若响应包含 after_key,下一页将其作为 after:
{
"size": 0,
"aggs": {
"by_category_and_status": {
"composite": {
"size": 100,
"after": {
"category": "database",
"status": "published"
},
"sources": [
{
"category": {
"terms": {
"field": "category"
}
}
},
{
"status": {
"terms": {
"field": "status"
}
}
}
]
}
}
}
}
这解决的是“分页遍历聚合桶”,与文档结果的 search_after 是两种不同机制。
4. 一致性必须明确范围
Elasticsearch 搜索的一致性至少有几个边界:
- refresh 之前,新写入可能不可搜索;
- 多次独立搜索可能看到不同的索引状态;
- PIT 可以固定一个搜索视图,但不是跨系统事务;
- 聚合和命中文档在同一次搜索请求中按该请求看到的搜索视图执行;
- 分片失败、超时或部分结果场景必须检查响应中的分片状态,而不能只读取
hits。
在生产代码中,应处理 HTTP 错误、查询解析错误、字段类型错误、超时、PIT 过期以及分片失败,并记录实际 Query DSL、索引别名、排序条件和游标信息。否则出现“结果少了”“分页跳过数据”“统计不准”时,很难还原当时的执行条件。
Elasticsearch 查询的核心不是记住若干 JSON 片段,而是明确四个集合和两种顺序:
- 哪些文档满足查询;
- 哪些条件参与分数;
- 聚合看到哪些文档;
- 返回结果按什么稳定顺序排列;
- 总命中数是否要求精确;
- 多次请求之间是否需要固定搜索视图。
Query DSL 负责表达匹配逻辑,相关性负责衡量文本匹配质量,分页负责在有序结果中定位窗口,聚合负责在匹配集合上计算统计。把这四部分和 Mapping、分片执行、refresh 边界联系起来,才能正确解释 Elasticsearch 返回的每一个结果。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:Elasticsearch Mapping 与分词:字段类型、Analyzer 和索引设计
- 下一篇:Elasticsearch 分片与集群:路由、副本、恢复、容量和故障诊断
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论