线上接口的响应时间从 80ms 飙到 1.2s 那天,我把查询日志翻出来,发现罪魁祸首是一条拼了七个条件的 DSL——业务方为了“只写一个接口满足所有筛选”,把关键词、类目、价格区间、时间范围、状态、标签、排序全塞进了同一个 bool 里,还顺手在 text 字段上用了 term。这类问题在 Elasticsearch 多条件查询的实践里几乎每周都能遇到一次:语法没错,结果不对,或者结果对了但慢得离谱。Elasticsearch 多条件查询表面上是把几个查询子句拼在一起,实际上牵扯到查询上下文与过滤上下文的选择、字段类型与分词器的匹配、评分权重与缓存命中、分页方式与深翻页上限这一整套东西。这篇内容适合三类人看:刚接手搜索模块、能把单条件查询跑通但对 bool 组合一知半解的开发者;被“结果少了一条”“should 不生效”“翻到第 200 页超时”这类问题反复折磨的后端同学;以及需要给团队定一套查询书写规范的技术负责人。我按自己踩坑的顺序把逻辑重排了一遍,从结构选型讲到落地代码,再讲到排查和调优,能直接抄的部分我都给全了。
1. 多条件查询的真正难点不在语法
1.1 从“一个条件能查”到“多个条件查得准”的落差
单条件查询谁都会写,match一下关键词,返回结果看着挺对,于是很多人就默认多条件查询只是把几个match用bool包一层。真到业务里就会发现问题层出不穷:关键词搜索带了类目过滤,结果类目之外的文档也冒出来了;加了状态过滤,搜索相关性排序突然变得很奇怪;明明文档里有这个值,term就是查不到;加上时间范围后接口耗时翻了五倍。
这些现象的根源不是语法写错,而是 Elasticsearch 对**查询上下文(query context)和过滤上下文(filter context)**的处理方式完全不同。前者要算相关性评分,参与_score计算,开销大且结果受打分模型影响;后者只回答“匹配还是不匹配”,不算分,结果可以被缓存成位图,重复查询时几乎是白拿。你把一个本该做过滤的条件写进must,等于让引擎为每一条命中文档算一遍分,再拿这个分数去干扰排序,顺带丢掉了缓存收益。
我用一个生活化的类比来解释:query像是让评委给每个选手打分排名,filter像是门卫按名单放人。门卫那关能筛掉的人,千万别送到评委面前。多条件查询写得对不对,很大程度取决于你有没有把这两类工作分清楚。
还有一层落差来自字段类型。同一个业务字段,你在 mapping 里定义成text,它会被分词器切成若干词元;定义成keyword,它就是一个整体。查询时用term还是match,取决于目标字段是哪种类型,而不是取决于你想干什么。这一点在单条件查询里不容易暴露,因为大家往往只查一个字段;多条件组合一上来,跨字段、跨类型的混用立刻原形毕露。
1.2 四个子句的角色分工要一次记牢
bool查询里的四个子句不是四个平级的“并且”,它们的语义和副作用差别很大,我用一张表把它们对齐:
| 子句 | 是否算分 | 是否可缓存 | 语义 | 典型用途 |
|---|---|---|---|---|
must | 是 | 否(结果集可缓存但评分不可) | 必须匹配,参与打分 | 关键词相关性搜索 |
filter | 否 | 是 | 必须匹配,不算分 | 类目、状态、时间、价格区间 |
should | 是 | 否 | 应该匹配,影响分数 | 加权、召回扩展、同义词 |
must_not | 否 | 是 | 必须不匹配 | 排除下架、排除黑名单 |
记这张表的正确姿势不是背定义,而是记住一条判断线:这个条件会不会影响“谁排在前面”?会,就放must或should;不会,只影响“谁该出现”,就放filter。比如“标题包含‘降噪耳机’”会影响排序,放must;“类目是数码配件”只决定范围,放filter;“是否自营”也只是范围,放filter;只有“同城商家优先”这种业务偏好才值得用should去加权。
must_not有个容易忽略的特性:它不参与打分,也不贡献缓存收益的正面价值,但它同样在过滤上下文里执行,能有效缩小候选集。常见的错误是把“排除已删除”写成must_not: { term: { deleted: true } }之外的复杂形态,比如用bool套should再取反,逻辑上没错,但可读性和性能都不划算。排除类条件尽量写成最扁平的term或terms。
1.3 为什么我总建议先想 filter 再想 must
一个查询从“能跑”到“跑得稳”,最大的杠杆往往是把能下推到filter的条件全部下推。我做过一次很粗糙的对比:同一个组合条件,全放must时热查询的 P99 在 400ms 上下;改成“关键词留must、其余六项全进filter”之后,P99 掉到 90ms 以内,而且抖动明显变小。原因很直接——过滤上下文的结果会被缓存,同一批筛选条件在短时间内被反复请求时,引擎不必重新遍历倒排表。
这里有个实操细节:过滤缓存的命中依赖查询结构的稳定性。如果你的 DSL 里条件顺序随机、空的terms数组时有时无、时间范围每次都用毫秒时间戳而不是相对表达式,缓存就很难复用。我在项目里定过一条规矩:DSL 由统一的构造器生成,条件为空时整段剔除,不生成空子句。这条规矩听起来琐碎,但它让缓存命中率上了一个台阶。
还有一点值得说清楚:filter不算分不代表结果没有分数。当bool里同时存在must和filter时,_score只由must/should部分决定,filter部分对分数没有贡献。所以你如果发现“加了过滤条件之后排序变了”,那不是 bug,是评分本来就没把过滤算进去——这恰恰是你想要的行为。
2. 查询结构设计与方案选型
2.1 bool 查询的骨架与隐式规则
bool是多条件查询的主力容器,但它有两条隐式规则必须知道,否则会写出“看起来对、跑起来不对”的 DSL。
第一条:当bool里存在must或filter时,should的默认minimum_should_match是 0。也就是说,should只是加分项,不匹配也能命中。很多人写“关键词匹配 或者 标签匹配”时用should包两个match,又顺手加了个时间filter,结果标签完全不匹配的文档也返回了,然后怀疑是分词问题。其实规则早就写在那里。
第二条:当bool里只有should(没有must/filter)时,默认至少要匹配一个。这时候你写三个should,实际语义是“命中任意一个”。如果你想要“命中至少两个”,必须显式写minimum_should_match: 2。
所以我在写任何bool之前会先问自己三个问题:这几个条件是“必须同时满足”还是“满足任意即可”?有没有纯粹用于缩小范围的字段?需不需要按匹配程度排序?答案直接映射到must、filter、should的分配上,写出来的结构基本不会跑偏。
结构上我倾向于扁平优先、按语义分层。扁平指的是同类条件合并成terms而不是写十几个term;分层指的是把“关键词相关性”放一层bool,把“业务筛选”放另一层bool,两层用filter串起来。这样即使后面要加条件,也知道该往哪一层塞,不会把整个 DSL 搅成一锅粥。
2.2 query context 与 filter context 的取舍标准
判断标准可以简化成一句话:需要分数的进 query,不需要分数的进 filter。但现实中有一类灰色地带,比如“品牌是 A 或 B”——它明显是过滤,但如果业务上希望 A 品牌的文档排得更靠前,就需要额外加权,这时不能靠把terms挪进must来解决,而应该保持filter不变,另加一个should里的term加boost。
还有一类常见误区是把range放进must。时间范围、价格区间这类条件对相关性毫无帮助,放进must只会让引擎白算分,还阻断了缓存。我见过一条查询把五个range全塞进must,改到filter之后耗时直接砍掉七成,业务方还以为是加了索引。
constant_score是个值得记住的小工具:当你只有一个过滤条件、又想让命中文档有个固定分数时,可以用它把filter包起来,让所有命中文档得到同一个boost分。它在“纯筛选但要参与后续排序”的场景里比硬塞must干净得多。
注意:
filter里不要放会随时变化的高基数字段做等值匹配,比如每次请求都不同的用户 ID 列表。这类条件缓存价值低、还会挤占缓存空间,反而不如让它走must或者干脆在应用层做二次过滤。
2.3 text 与 keyword:多条件查询最常见的翻车点
如果一个字段既要支持全文搜索又要支持精确过滤,正确的做法是同一份内容用两种类型各存一份,最常见的是text加一个keyword子字段:
{ "mappings": { "properties": { "title": { "type": "text", "analyzer": "standard", "fields": { "raw": { "type": "keyword", "ignore_above": 256 } } }, "category_id": { "type": "keyword" }, "price": { "type": "scaled_float", "scaling_factor": 100 }, "status": { "type": "keyword" }, "created_at": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss||epoch_millis" } } } }这样title走match做相关性搜索,title.raw走term做精确命中,两者互不干扰。代价是索引体积略增,但比起“查不到数据”的排查成本,这点存储完全值得。
反过来,keyword字段上写match也不是不行,但它的行为是“把查询词整体当成一个词元去匹配”,不做分词,模糊搜索能力直接没了。很多人抱怨“搜索‘蓝牙耳机’搜不到‘蓝牙降噪耳机’”,根子就在目标字段是keyword而不是text。
ignore_above这个参数也值得提一句:超过长度阈值的内容不会被索引进keyword子字段,于是这些文档在精确过滤时会被静默跳过。如果你的字段内容可能很长(比如商品副标题、备注),要么调大阈值,要么接受它不被精确匹配。
2.4 boost 权重设计:让“应该匹配”体现优先级
多条件查询里should的最大价值就是加权。但boost不是越大越好,它只在同一组should内部做相对比较,你给 100 还是给 10,效果取决于同组其他条件的分值量级。
我的经验做法是把权重控制在 1 到 5 之间,最多不超过 10,然后用_explain验证一次实际分数的分布。理由是match查询的分数受 TF-IDF/BM25 影响,本身可能落在个位数到几十之间,如果你给某个should加boost: 100,它会直接碾压其他所有条件,最终效果等同于“只按这一项排序”,反而失去了多条件综合排序的意义。
另一个常被忽略的点是should与minimum_should_match的组合。比如“标题命中、标签命中、描述命中,三者至少命中两个”,写法是:
{ "query": { "bool": { "filter": [ { "term": { "status": "on_sale" } } ], "should": [ { "match": { "title": "降噪耳机" } }, { "match": { "tags": "降噪耳机" } }, { "match": { "description": "降噪耳机" } } ], "minimum_should_match": 2 } } }注意这里的minimum_should_match必须显式写,因为外层有filter,默认值是 0。这条 DSL 的语义是“在售商品中,三处至少两处命中”,排序则由命中数量和命中质量共同决定。这类结构在召回与精排之间做了个折中,比单纯堆must灵活得多。
3. 核心细节解析与实操要点
3.1 minimum_should_match 的正确用法
这个参数支持三种形态:整数、百分比、以及组合表达式。整数最直观,2就是至少两个。百分比按“should子句数量 × 百分比”向下取整,比如 5 个should加75%,实际要求是 3 个(3.75 向下取整)。组合表达式可以写2<-25% 9<-3,意思是“子句数大于 2 时至少 25%,大于 9 时至少 3 个”,适合那种条件数量随业务动态变化的场景。
我踩过的坑是把这个参数当成“可选优化”随手一放。有一次做“多标签召回”,should里动态拼了 20 个标签条件,minimum_should_match写了2,结果只要命中两个冷门标签的文档就能排到前面,热门标签的大量命中反而占不到便宜。后来改成按标签权重分组,热门标签单独一个should组、minimum_should_match: 1,冷门标签另一组、minimum_should_match: 2,召回质量才恢复正常。
还有一点必须提醒:minimum_should_match只对同级的should生效,嵌套在子bool里的should有它自己的规则。写复杂 DSL 时最好把每一层bool的minimum_should_match都显式写出来,别依赖默认值——默认值在不同结构下不一样,这是多条件查询里最隐蔽的逻辑错误来源。
3.2 range 查询与日期数学表达式
range看着最简单,实际上细节不少。数值范围要注意字段类型:如果price定义成scaled_float,你传99.99没问题,但如果定义成integer却传了小数,行为可能不符合预期。日期范围则要关注时区和格式。
日期数学表达式是range的利器,now-7d/d表示“七天前的当天零点”,now/d是今天零点,now+1M/M是下个月一号。末尾那个/d、/M是取整操作,少了它,now-7d就是精确到毫秒的七天前,做“最近七天”这种业务统计会出现边界抖动——同一个请求在 23:59:59 和 00:00:01 执行,结果集差一条。
{ "range": { "created_at": { "gte": "now-7d/d", "lt": "now/d", "format": "yyyy-MM-dd HH:mm:ss", "time_zone": "+08:00" } } }time_zone这个参数在跨时区业务里必须显式给。Elasticsearch 内部按 UTC 存储日期,如果你的业务按东八区算“今天”,不指定时区的话now/d取整取的是 UTC 零点,等于把当天的前八小时算进了昨天。这个偏差不会报错,只会让运营同学看着报表说“数不对”。
还有一个性能细节:range放在filter里能吃到缓存,但缓存的粒度是按查询结构算的。如果你的时间范围每次都是精确到毫秒的now-7d,那每次请求的 DSL 都不同,缓存基本不会命中。用取整表达式不仅业务语义更准,缓存友好度也更高。
3.3 nested 查询:对象数组的条件组合陷阱
这是多条件查询里最容易被低估的一块。假设订单文档里有个items数组,每个元素有sku_id和qty。你想查“买了 SKU-A 且数量大于 2”的订单,直觉写法是:
{ "bool": { "must": [ { "term": { "items.sku_id": "SKU-A" } }, { "range": { "items.qty": { "gt": 2 } } } ] } }这条查询会命中“买了 SKU-A 数量 1,同时买了 SKU-B 数量 5”的订单——因为两个条件分别匹配到了数组中不同的元素上,而 Elasticsearch 对普通对象数组是扁平化存储的,元素之间的边界丢失了。
解决办法是把items声明成nested类型,查询时用nested子句把条件包在同一个path下:
{ "mappings": { "properties": { "items": { "type": "nested", "properties": { "sku_id": { "type": "keyword" }, "qty": { "type": "integer" } } } } } }{ "query": { "nested": { "path": "items", "query": { "bool": { "must": [ { "term": { "items.sku_id": "SKU-A" } }, { "range": { "items.qty": { "gt": 2 } } } ] } }, "inner_hits": { "size": 3 }, "score_mode": "avg" } } }nested的代价是每个嵌套文档在底层是独立存储的,查询时需要 join,开销比普通字段高。所以如果业务上根本不需要“同一元素内多字段联合判断”,就别滥用nested。我见过把所有数组字段都改成nested的索引,查询耗时是原来的三倍,纯粹是防御性设计过度。
inner_hits值得单独说:它能把命中的具体数组元素返回,做订单详情页时非常有用,省掉了应用层再遍历一遍的功夫。score_mode控制多个嵌套命中的分数如何合并,avg、max、sum各有适用场景,默认是avg,我一般在“看命中几个”的场景用sum,在“看命中质量”的场景用max。
3.4 精确值、通配符与正则的边界
term和terms是精确匹配的主力,terms支持一次传多个值,等价于多个term的should,但性能更好、DSL 更短。传空数组时要注意:terms: []会匹配不到任何文档,如果你的业务语义是“不筛选”,应该在构造 DSL 时把这个条件整个去掉,而不是传空数组。
wildcard、prefix、regexp这三个属于“能不用就不用”的家族。它们的共同问题是无法利用倒排索引的有序性(prefix稍好一些),尤其是前导通配符*abc,等于对每个词元做一次扫描。我在一个日志检索场景里见过用wildcard: "*timeout*"查日志内容,单次查询扫了几千万文档,直接把节点 CPU 打满。
如果确实需要前缀匹配,prefix比wildcard好;如果需要中缀匹配,正确做法是用ngram分词器在写入时预处理,把“模糊查”变成“精确查”。这个改造听着麻烦,但它是把 O(n) 扫描变成 O(log n) 查索引的唯一正路。
还有一种情况是用wildcard做“包含”查询却忘了大小写。keyword字段是大小写敏感的,*Error*和*error*是两个结果。要么在写入时统一转小写,要么用normalizer处理,别指望查询时自动忽略大小写。
3.5 分页、排序与 _source 过滤的配合
多条件查询落地到接口层,几乎必然要处理分页和排序。这里有两个坑。
第一个是排序稳定性。按created_at排序时,如果大量文档的created_at相同,翻页会出现重复或漏数据——因为相同排序值的文档在不同分片上的顺序不确定。解决办法是在排序字段后面追加一个唯一性字段(比如文档 ID)作为第二排序键:
{ "sort": [ { "created_at": "desc" }, { "_id": "asc" } ] }第二个是深分页。from + size超过index.max_result_window(默认 10000)会直接报错。这不是配置问题,是设计问题:from: 9990, size: 20意味着每个分片都要取出前 10010 条再合并丢弃,代价随页码线性增长。真正要翻深页,用search_after配合上一次结果的排序值,或者用游标方式导出。我在导出报表的场景里用search_after把 50 万条数据的导出从“跑不完”变成“两分钟内跑完”。
_source过滤是个小优化但很实用。列表页往往只需要五六个字段,却把整个文档(可能带几十个字段、几 KB 的详情)拉回来,网络和反序列化开销都浪费了。用"_source": ["id", "title", "price", "cover"]或者"_source": { "includes": [...], "excludes": [...] }能省下可观的带宽。但注意别把排序字段、聚合字段排除掉,虽然它们不依赖_source,但排查问题时容易误导自己。
4. 一套完整的多条件查询实现过程
4.1 索引设计先行:mapping 决定查询上限
我在所有搜索项目里都坚持一个顺序:先把查询需求列成清单,再倒推 mapping,最后才写 DSL。反过来做,一定会遇到“这个字段想精确匹配但它是 text”的尴尬。
以商品检索为例,需求清单大概是:关键词搜标题和卖点、按类目和品牌筛选、按价格区间筛选、按上架状态筛选、按更新时间倒序、支持按销量加权。倒推出来的 mapping 关键点:
title、selling_point用text,配ik_max_word之类的中文分词器,同时挂keyword子字段备用;category_id、brand_id、status用keyword;price用scaled_float,scaling_factor: 100,避免浮点精度问题;updated_at用date,格式显式声明;sales_30d用integer,作为排序和加权的依据。
这里解释两个选择背后的理由。price用scaled_float而不是double,是因为金额做范围比较时浮点误差会导致“99.99 查不到 99.99”这种诡异现象,缩放成整数存储可以精确比较,同时比keyword省空间。sales_30d单独存一个字段而不是每次聚合算,是因为排序和加权都需要它,实时聚合在大数据量下开销太高,用离线任务每天刷一次更实际。
提示:mapping 一旦建立,字段类型基本不可改(只能新增字段)。上线前用一批真实数据把查询跑一遍,比上线后重建索引便宜得多。
4.2 把组合条件拼成一条可读的 DSL
下面这条是我在项目里用的完整版本,去掉了业务隐私字段,结构保留:
{ "track_total_hits": true, "query": { "bool": { "must": [ { "multi_match": { "query": "降噪耳机", "fields": ["title^3", "selling_point^1"], "type": "best_fields", "operator": "and" } } ], "filter": [ { "terms": { "category_id": ["c1001", "c1002"] } }, { "terms": { "brand_id": ["b20", "b31"] } }, { "term": { "status": "on_sale" } }, { "range": { "price": { "gte": 100, "lte": 800 } } }, { "range": { "updated_at": { "gte": "now-30d/d", "time_zone": "+08:00" } } } ], "should": [ { "term": { "is_self_run": { "value": true, "boost": 2 } } }, { "range": { "sales_30d": { "gte": 100, "boost": 1.5 } } } ], "minimum_should_match": 0, "must_not": [ { "term": { "is_deleted": true } } ] } }, "sort": [ { "_score": "desc" }, { "sales_30d": "desc" }, { "updated_at": "desc" }, { "_id": "asc" } ], "from": 0, "size": 20, "_source": ["id", "title", "price", "cover", "sales_30d"] }几个细节值得展开。multi_match的type: best_fields表示取字段中的最高分作为文档分,适合“标题或卖点命中即可”的场景;如果希望两个字段都命中时分数叠加,用most_fields。operator: and要求查询词全部命中,能有效压制“降噪”和“耳机”分开命中的噪音,但如果查询词本身较长,可能召回不足,这时可以改成minimum_should_match: "70%"。
minimum_should_match: 0是显式写法,因为外层有filter,默认就是 0。写出来是为了让后来维护的人一眼看懂“这两个 should 只是加分,不参与筛选”。这是我坚持的规范:所有依赖默认值的地方都显式写。
排序里_score放第一位,后面依次是销量、更新时间、ID。为什么要四层?因为销量相同的商品很多,更新时间相同的也很多,只有 ID 唯一,四层排序能保证翻页绝对稳定。排序字段过多会有性能开销,但在这个数据量级上完全可接受。
4.3 用官方 Java 客户端落地
DSL 写对只是第一步,客户端代码里把条件拼装成对象,才是日常维护的主战场。下面是 8.x 客户端的写法,思路在 7.x 上也一样,只是类名不同:
public SearchResponse search(ProductQuery q) { List<Query> filters = new ArrayList<>(); if (CollectionUtils.isNotEmpty(q.getCategoryIds())) { filters.add(Query.of(b -> b.terms(t -> t .field("category_id") .terms(v -> v.value(q.getCategoryIds().stream() .map(FieldValue::of).toList()))))); } if (q.getMinPrice() != null || q.getMaxPrice() != null) { filters.add(Query.of(b -> b.range(r -> { r.field("price"); if (q.getMinPrice() != null) r.gte(JsonData.of(q.getMinPrice())); if (q.getMaxPrice() != null) r.lte(JsonData.of(q.getMaxPrice())); return r; }))); } if (q.getDays() != null && q.getDays() > 0) { filters.add(Query.of(b -> b.range(r -> r .field("updated_at") .gte(JsonData.of("now-" + q.getDays() + "d/d")) .timeZone("+08:00")))); } Query must = StringUtils.hasText(q.getKeyword()) ? Query.of(b -> b.multiMatch(m -> m .query(q.getKeyword()) .fields("title^3", "selling_point^1") .operator(Operator.And))) : Query.of(b -> b.matchAll(m -> m)); Query bool = Query.of(b -> b.bool(bb -> { bb.must(must); if (!filters.isEmpty()) bb.filter(filters); bb.mustNot(mn -> mn.term(t -> t.field("is_deleted").value(true))); return bb; })); SearchRequest req = SearchRequest.of(s -> s .index("product_v1") .query(bool) .from(q.getPage() * q.getSize()) .size(q.getSize()) .trackTotalHits(t -> t.enabled(true)) .source(src -> src.filter(f -> f.includes( "id", "title", "price", "cover", "sales_30d"))) .sort(so -> so .score(sc -> sc.order(SortOrder.Desc)) .field(f -> f.field("sales_30d").order(SortOrder.Desc)) .field(f -> f.field("updated_at").order(SortOrder.Desc)))); try { return client.search(req, SearchResponse.class); } catch (IOException e) { throw new IllegalStateException("search failed", e); } }这段代码的核心思想是条件构造与查询执行分离。filters列表按需增长,空条件根本不进列表,最终 DSL 里不会出现空子句。这带来的直接好处是查询结构稳定、缓存可复用,间接好处是排查时打出的 DSL 干净,一眼能看出哪些条件真的生效了。
我在项目里还加了一个开关:调试模式下把最终 DSL 序列化打日志。这个习惯救过很多次——线上“查不到数据”的问题,十次有八次是某个条件被悄悄拼进去了。
4.4 排序与分页的稳定实现
接口层的分页我一般只做前 100 页,翻页超过阈值就引导用户加筛选条件。听着像偷懒,但这是最划算的取舍:真实用户很少翻到第 50 页,而支持无限翻页的代价是每个分片都要维持大堆排序缓冲,内存压力随并发线性上升。
如果业务确实需要深翻(比如运营导出),用search_after:
SearchRequest req = SearchRequest.of(s -> s .index("product_v1") .query(bool) .size(1000) .sort(so -> so.field(f -> f.field("updated_at").order(SortOrder.Asc)) .field(f -> f.field("_id").order(SortOrder.Asc))) .searchAfter(lastSortValues));search_after的关键是排序里必须有唯一性字段,否则同值文档会漏或重。每次拿到结果后,从最后一条文档里取出排序值数组,作为下一次请求的search_after。整个过程没有from,不需要维护大偏移量,性能稳定。
还有个细节:search_after不能和from混用,也不能跳页,只能顺序向后。所以它适合导出、适合无限滚动,不适合“跳到第 500 页”。
4.5 聚合与查询条件的复用
多条件查询往往要配聚合,做筛选面板的计数。这里有个容易混淆的组合:query、post_filter、filter聚合三者影响范围不同。
query里的条件影响命中文档和所有聚合结果;post_filter只影响返回的文档,不影响聚合——适合“用户点了某个筛选但不想让这个筛选影响计数”的场景;filter聚合在聚合结果内部再过滤,不影响外层命中。
最常见的是筛选面板:主查询里包含已选条件,聚合里排除当前维度,这样用户才能看到“如果再加一个品牌,会有多少条”。实现上就是用filter聚合包住排除该维度的子查询,或者直接发两次请求——后者更简单,我用得更多,因为面板维度通常只有五六个,多几次请求换来代码清晰度完全值得。
5. 常见问题与排查技巧实录
5.1 should 条件“消失”了
症状:写了should,结果里却出现了完全不该命中的文档。
排查顺序固定为三步。第一步,看同级有没有must或filter,有的话minimum_should_match默认是 0,should只是加分项。第二步,如果确实想筛选,显式写minimum_should_match: 1。第三步,如果写了还不生效,检查should是不是被写进了子bool而外层还有别的条件——嵌套层的规则和外层独立,容易看漏。
我还遇到过一个更隐蔽的变体:should里的term查的是text字段,永远匹配不到,于是“should 不生效”实际上是“should 从来没匹配上”。用_explain一看,should部分的matched: false,原因就清楚了。
5.2 查不到、多出来、分数怪:三类现象的定位法
我把多条件查询的问题归成三类,各有对应的排查手段。
查不到:先确认字段类型和查询子句是否匹配。term查text是重灾区,match查keyword则失去分词能力。其次用GET /index/_analyze看目标词被分成了什么,这一步能解决八成“分词导致查不到”的问题。最后看是否有must_not或filter意外排除了目标文档,可以逐条注释条件做二分。
多出来:这类问题基本出在should的默认规则、nested的对象扁平化、以及operator默认是or这三处。match默认operator是or,查“降噪耳机”时“降噪”和“耳机”任一命中就算,很容易多召回一堆无关文档。
分数怪:用_explain看单条文档的分数构成,能看到每个子句贡献了多少。我用它定位过一次“加了销量 boost 之后排序完全乱掉”的问题,原因是boost给到了 50,直接把相关性的作用压成了零。也在profile: true里看过每个查询子句的耗时分布,发现某个wildcard占了 90% 的时间。
5.3 深分页与超时抖动的处理
症状:翻到后面几页开始超时,或并发上来后 P99 抖动明显。
深分页的处理前面说过,用search_after。超时抖动则要看几个地方:查询是否用了wildcard前置通配;size是否给得过大(一次取 500 条还要排序,开销不小);是否有script或script_score,这类查询完全无法缓存,每次都要跑脚本;track_total_hits是否一直是true,如果你只关心“有没有超过一万条”,设成10000就够,能省掉精确计数的开销。
还有一个容易被忽略的点是分片数。一个索引分 5 个分片和分 20 个分片,同样的查询开销能差好几倍,因为每个分片都要独立执行再合并。小索引没必要分太多片,这条经验我用很多次翻车换来的。
5.4 多条件查询问题速查表
| 现象 | 最可能的原因 | 快速验证 | 处理方式 |
|---|---|---|---|
should不筛选 | 同级有must/filter,默认minimum_should_match为 0 | 显式加minimum_should_match: 1看结果变化 | 显式声明阈值 |
term查不到 | 目标字段是text,被分词 | _analyze看分词结果 | 改用keyword子字段或改match |
| 数组条件串味 | 对象数组未声明nested | 加一条跨元素矛盾的条件看是否命中 | 改 mapping 为nested,用nested查询 |
| 结果多召回 | match默认operator: or | 加operator: and对比 | 设operator或minimum_should_match |
| 时间范围差一天 | 未指定time_zone,按 UTC 取整 | 对比 UTC 与本地时区的整点数据 | 显式time_zone: "+08:00" |
| 翻页重复或漏数据 | 排序字段有大量重复值 | 按相同排序值翻两页比对 ID | 追加唯一字段作为第二排序键 |
| 深翻报错 | from + size超 10000 | 看错误信息中的max_result_window | 改search_after或加筛选 |
| 查询越来越慢 | filter条件写进了must,缓存失效 | profile看各子句耗时占比 | 能下推的过滤全部进filter |
| 聚合计数对不上 | post_filter影响了文档但聚合没跟着变 | 对比有无post_filter的聚合值 | 明确query与post_filter的边界 |
6. 性能调优与我在实际项目里的取舍
6.1 查询结构层面的四件事
第一件是把不需要分数的条件全部下推到filter。这条我说了三遍,因为它的收益最大、改动最小、风险最低。一个典型的电商列表查询,如果筛选条件有五六个,全放must和全放filter的耗时能差三到五倍。
第二件是控制should的数量。should越多,打分计算越重,而且每个should都要参与候选集合并。我一般把should控制在 5 个以内,超过就考虑用function_score统一加权,或者干脆拆成两次查询在应用层合并。
第三件是避免在查询路径上做重活。script、script_score、regexp、前置wildcard都属于重活,能挪到写入阶段就挪。比如“是否包含敏感词”这种判断,写入时算好打标签,查询时就是一次term。
第四件是给查询加边界。size上限、时间范围上限、terms数组长度上限,都该在应用层做校验。我见过一个接口被传入 8000 个 ID 的terms查询,直接触发了max_terms_count限制,接口 500。加个长度校验加分批查询,问题就没了。
6.2 索引设计层面的三件事
分片数要跟数据量和查询并发匹配。分片太少,单分片数据量大,查询扫得慢;分片太多,每次查询要合并的结果多,协调节点压力大。我的经验值是按“单分片 10GB 到 30GB”来估,小索引直接一个分片加一个副本就够,别为了“看起来专业”分十个片。
字段能不用就别用。text字段带分词、带位置信息,索引体积比keyword大不少;nested字段每个元素独立存储,开销更高。曾经有个索引把所有字符串字段都设成了text加ik_max_word,索引体积是优化后的四倍,查询也慢,因为每个字段都要走分词匹配。按用途选类型,这个原则听起来基础,但真正执行到位的项目不多。
再就是别忽视doc_values。需要排序和聚合的字段必须开着(默认开),不需要排序的text字段关掉能省空间。这类调整属于“知道就好,别乱改”的范畴。
6.3 我踩过的几个坑,写在这里当反面教材
第一个坑是“为了通用把条件全塞进一个接口”。早期我做过一个万能查询接口,参数有十几个,内部用大量if拼 DSL。上线三个月后,这条 DSL 有三百多行,任何人都看不懂,改一个条件要跑一遍全量回归。后来拆成了三个专用接口,每个接口的 DSL 不超过五十行,维护成本立刻降下来。多条件查询的通用性是有上限的,超过上限就该拆分。
第二个坑是“索引别名切换时忘了同步 mapping”。做索引重建时,新旧索引字段类型不一致,切换后查询在旧索引上好好的,新索引上直接查不到数据。教训是:重建索引前先 diff 两边的 mapping,把差异列出来确认。
第三个坑是“相信了默认值”。match的operator默认or、bool的minimum_should_match依赖同级子句、from + size的默认上限是 10000、track_total_hits默认开启但在某些封装里被关掉。这些默认值各自都有道理,但组合起来就会产生“我明明写对了”的错觉。现在我的习惯是,凡是影响结果集的默认值,全部显式写出来,哪怕它和默认值一样。多打几个字,省下几小时排查。
第四个坑跟缓存有关。有段时间我发现某些查询的响应时间忽快忽慢,profile看不出问题,最后定位到是查询结构不稳定——同一个业务请求,条件顺序随机、空条件有时带上有时不带,导致过滤缓存反复失效。改成统一构造器生成之后,P99 从 300ms 收敛到 100ms 出头,而且曲线平了。这件事让我意识到,多条件查询的性能问题,很多时候不在单个子句,而在整个 DSL 的稳定性上。
如果你正在写或者要重构一块多条件查询,我的建议顺序是:先把字段类型和 mapping 对齐,再把条件按 query/filter 分好层,然后用统一的构造器生成 DSL,最后才是加权重、加聚合。顺序反了,后面每一步都是在补前面的债。