0.90 同时有 query、filter、bool filter 和 and/or/not。本文按固定标签核对它们的组合、范围边界与缓存行为,再用最小数据集说明为什么迁移不仅是改 JSON 结构。

说明

本文描述问题及解决方法同样适用于 腾讯云 Elasticsearch Service(ES)。

本文讨论历史实现,不表示当前云服务支持这些旧接口。旧版请求仅用于准备隔离环境中的验证,不得在生产集群执行,也不要将旧版本服务对外开放。本文没有启动历史集群,示例的预期结果来自语义推导,不是实测记录。

先把过滤和打分分开看

0.90 的 filtered 查询把一条 query 和一条 filter 组合起来:query 负责查询匹配及相关性计算,filter 进一步限制可接受的文档。理解这两部分分别表达什么,比记住今天应该替换成哪个字段更重要。

当时的文档这样描述它:

A query that applies a filter to the results of another query. This query maps to Lucene FilteredQuery.

这句话说明抽象关系,不应读成“总是先取出完整查询结果,再做一次客户端过滤”。实际执行由查询、过滤器和 Lucene 的组合决定。把文档中的解释直接当成固定执行顺序,容易在性能排查时判断错误。

本文聚焦 v0.90.13,涉及范围语法的变更时才引用同一标签记录的 patch 注记。0.90 不是一个完全不变的接口集合,同一个小版本系列中的参数也会演进。看到老项目写着“支持 0.90”,仍需知道实际运行和客户端针对的是哪一个 patch。

我建议先用固定输入核对命中集合、边界值和得分,再替换查询结构。只要这些验收对象没分开,查询能够正常解析就很容易被误认成迁移完成。

filtered 连接的是两套解析入口

官方最小示例可以写成下面的查询片段。这是旧 DSL 的组成部分,不是完整 HTTP 请求:

{
  "filtered": {
    "query": {
      "term": { "tag": "wow" }
    },
    "filter": {
      "range": { "age": { "from": 10, "to": 20 } }
    }
  }
}

query 内部进入查询解析,filter 内部进入过滤器解析。文档还明确说明 filter 对象放的是 filter 元素,而不是随意放一段 query。因此当时有 query 与 filter 各自的类型列表,也有在两套表示之间转换的包装形式。

不要把这个区别简化为“query 只能搜索文本,filter 只能检查数值”。两边都有 term、range 等相似名称,区别主要在所在结构和是否需要计算分数。全文检索、精确匹配和数值范围是条件的内容,query/filter 是处理这些条件时的语义,两者不是同一个维度。

FilteredQueryParser读取两部分后,还会对一些特殊情况做处理。比如 filter 为空或为 match-all 时,并不一定返回一个完整的过滤包装;代码中存在直接返回 query 的分支,见第 114–131 行。

这也是为什么排查时应保留实际发送的请求,而不是只看客户端的构建代码。客户端可能省略空对象、忽略不存在的条件,或生成与预想不同的外层结构。构建器里出现了 filtered,不证明底层一定执行同一种组合。

从读源码的角度,可以按下面这个顺序理解。图是对解析关系的简化,不是运行时的逐条文档调用跟踪:

flowchart TD
    A[读取 filtered 对象] --> B[解析 query 子对象]
    B --> C[解析 filter 子对象]
    C --> D[处理空条件与特殊情况]
    D --> E[按配置处理过滤器缓存]
    E --> F[构建查询与过滤组合]

外层 boost、执行策略以及内部过滤器配置也可能影响最终行为。本文的对照样例不设置这些额外参数,是为了先隔离条件本身的语义;迁移真实请求时不能因此把原请求中的这些选项一并丢掉。

当时已经有 bool filter

“0.90 用 and/or/not,后来才有 bool”是不准确的。固定标签中有独立的bool filter 文档,包含 must、must_not 和 should,并说明内部条件是 filter。

所以当时至少要区分三种结构:bool query、bool filter,以及单独的 and/or/not filter。名称类似不代表解析器相同,更不能把其中一个对象直接复制到另一个位置,认为只要 JSON 合法就能保持语义。

and 表达多个过滤条件同时满足,or 表达候选过滤条件的并集,not 表达取反。bool filter 则把组合关系放在一个对象中。它们在常见逻辑上有可对应之处,但历史版本中的执行实现和可接受参数仍需要分别核实。

BoolFilterParser明确处理对象与数组形式的 must、must_not、should,还处理 _cache 等选项。这说明阅读旧请求时,不能只搜索 and/or/not;省略 bool filter 会漏掉另一类常见旧写法。

尤其是同时存在必须条件和可选条件的请求,不要根据今天记忆中的默认值去解释历史 bool filter。应准备一个只满足 must、不满足任何 should 的文档,再准备只满足 should、不满足 must 的文档,逐项确认接受集合。这个方法可以发现默认匹配规则差异,而不必把两个年代的行为想当然地视为一致。

布尔结构还要保持括号层级。例如“租户条件并且满足状态 A 或状态 B”,应理解为租户条件与状态并集的交集。如果把租户条件放进同一个 should 数组,命中文档的范围就会扩大。该错误与是否打分无关,查询可能正常执行,但已经不再表达原来的条件。

租户条件也不能成为唯一的权限措施。查询由客户端拼装时,应由服务端校验用户能访问的索引和数据范围;不能接受调用方自行删除限制。这里讨论的是查询语义,不是用 DSL 代替鉴权。

范围参数在 0.90 内部就发生过变化

旧示例经常使用 from、to,但 v0.90.13 的range filter 文档已经列出 gt、gte、lt、lte,并标注旧参数从 0.90.4 起废弃。

因此不能写成“0.90 只认 from/to,直到 2.0 才有 gte/lte”。这会混淆两个独立变化:范围参数的演进,以及 query/filter 的合并。一个项目可能已经使用新范围参数,同时仍在使用旧 filtered 结构。

比时间线更容易出错的是区间端点。RangeFilterParser 第 60–64 行将 includeLower 和 includeUpper 都初始化为 true。所以没有显式改变包含关系时,from: 10, to: 20 对应包含两端的区间。

旧写法的含义对应的新范围写法
from: 10,下界默认包含gte: 10
from: 10,include_lower: falsegt: 10
to: 20,上界默认包含lte: 20
to: 20,include_upper: falselt: 20

直接把 from/to 全部替换成 gte/lt,会把等于上界的文档排除。这个问题通常不会让请求报错,中间值也看不出差异,必须把边界值放进测试数据。

语法映射来自解析器的参数处理。同一段代码把 gt 转为不包含下界,把 gte 转为包含下界,并对上界作对应处理。它比从示例中的某个日期写法猜测区间含义更可靠。

日期字段还涉及解析格式和时区,不能把整数边界测试的结果直接外推到所有时间表达式。时间窗口如果要求左闭右开,就应明确配置这一语义;如果旧请求原来两端都包含,也不应为了“现在通常这么写”而悄悄改变它。

准备一组能暴露边界差异的数据

下面使用旧版 REST 请求记法列出完整准备过程,供隔离的 v0.90.13 环境验证。旧版本没有现代 Dev Tools 的完整配套,执行时需通过受控的 HTTP 客户端发送对应方法、路径和 JSON;不要把这些请求提交给当前生产版本。

测试索引使用一个主分片,并显式定义整数与未分词字符串。这样做不是生产分片建议,而是减少验收中的不相关变量:

PUT /filtered_boundary_090
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0
  },
  "mappings": {
    "item": {
      "properties": {
        "tag": { "type": "string", "index": "not_analyzed" },
        "age": { "type": "integer" },
        "surname": { "type": "string", "index": "not_analyzed" }
      }
    }
  }
}

PUT /filtered_boundary_090/item/a
{"tag":"wow","age":9,"surname":"baker"}

PUT /filtered_boundary_090/item/b
{"tag":"wow","age":10,"surname":"baker"}

PUT /filtered_boundary_090/item/c
{"tag":"wow","age":20,"surname":"baker"}

PUT /filtered_boundary_090/item/d
{"tag":"wow","age":21,"surname":"baker"}

PUT /filtered_boundary_090/item/e
{"tag":"other","age":15,"surname":"baker"}

PUT /filtered_boundary_090/item/f
{"tag":"wow","age":15,"surname":"cooper"}

POST /filtered_boundary_090/_refresh

tag 不分词,使 term 查询的含义清楚;age 是整数,使范围比较不受字符串字典顺序影响;surname 用于额外的 prefix 条件。如果依赖动态 mapping,字段类型差异本身就可能影响结果,不利于核对 DSL。

准备完成后,分别检查每次写入的结果,不能假设请求全部成功。索引已存在时不要覆盖用户已有数据,换一个专用名称再验证。这里也没有附带删除索引命令,测试结束后的清理应在确认目标归属后单独进行。

旧查询采用文档中的 and 结构:

GET /filtered_boundary_090/item/_search
{
  "size": 20,
  "query": {
    "filtered": {
      "query": { "term": { "tag": "wow" } },
      "filter": {
        "and": [
          { "range": { "age": { "from": 10, "to": 20 } } },
          { "prefix": { "surname": "ba" } }
        ]
      }
    }
  }
}

按条件推导,应该接受 b 和 c:a 在下界之外,d 在上界之外,e 不满足查询条件,f 不满足 prefix 条件。这里检查的是 ID 集合,不规定它们的得分大小或返回先后,避免把未实跑的排序写成事实。

随后只把范围条件换成 gte: 10, lte: 20,其他部分不动,集合仍应为 b、c。再单独把 lte 改成 lt,应只保留 b。三个请求只改变一个变量,能把参数别名替换与包含关系变化分开检查。

如果第一条请求就没有得到预期集合,应先检查 mapping、写入响应和 refresh,再检查实际发送的请求体。不要在输入尚未确认时同时修改缓存或执行策略,那会让一个语义问题增加多个不确定因素。

不算分,不等于所有结果都会缓存

旧文档说过滤器可以更快,尤其在缓存时。这是一项有条件的能力描述,不是每个 filter 都默认缓存的保证。外层组合过滤器和内部子过滤器还可能具有不同的缓存策略。

例如and filter 的缓存说明写得很清楚:组合结果默认不缓存,允许设置 _cache: true,但通常不需要。因为增加这个选项后,过滤器列表要放到 filters 中,所以请求结构也从数组形式变成对象形式。

{
  "and": {
    "filters": [
      { "term": { "tag": "wow" } },
      { "range": { "age": { "gte": 10, "lte": 20 } } }
    ],
    "_cache": true
  }
}

这是配置结构示例,不是推荐在所有组合上启用缓存。父对象不缓存,并不证明子过滤器不缓存;父对象缓存,也不证明它的构建成本和内存占用一定值得。查看一个 _cache 标志,不能概括整条查询的缓存行为。

bool filter 的同版本文档也说明组合结果默认不缓存,内部过滤器则可能缓存。两个文档共同说明,当时的缓存知识需要细化到过滤器类型与组合层级。

从实现看,BoolFilterParser 第 138–145 行只在外层 cache 为真时对组合执行缓存包装。因此,手工检查一个旧请求时应先画出条件树,再标出每层的配置,不能把某个内部 _cache 当成整棵树的总开关。

同样,“查询要算分,所以查询完全没有可复用的底层数据”也过于绝对。这里讨论的是当时查询与过滤器结果的缓存策略,不是说全文查询每次都从物理磁盘重新读取全部数据。文件系统缓存、索引结构复用和过滤结果缓存不是同一层东西。

range 的执行方式还会改变缓存条件

v0.90.13 的 range filter 除了区间端点,还有 execution。文档标注它从 0.90.8 起存在,包含 index 与 fielddata 两种路径,见第 35–50 行。

其中 index 使用字段倒排索引,fielddata 使用字段数据。文档对小范围、大范围分别给出一般性性能倾向,并提醒 fielddata 需要更多内存。但这不是本文测得的固定分界,也没有给出适用于所有字段的范围宽度阈值。

更重要的是,缓存说明紧接着限定了默认条件:第 55–59 行写明,只有 execution 为 index 时结果才默认自动缓存;未做 rounding 的 now 日期表达式,即使显式设置 _cache: true 也不会缓存,包装它的过滤器同样受影响。

因此,旧稿中“动态时间让缓存频繁失效”的解释不够准确。对这里明确列出的未取整 now,文档描述的是不进入缓存,不能一概说成先缓存再反复淘汰。诊断时应区分没有建立缓存、建立后未命中,以及发生驱逐。

时间取整也不是无代价的调优。把滑动窗口的端点取整到分钟或小时,可能改变边界时刻接受哪些事件。若业务需要严格的最近若干秒窗口,不能为了缓存复用而直接改变条件;应先确认时间误差是否允许,再评估查询成本。

评估 fielddata 路径时,还要记录字段原本是否已经用于排序或 facets。如果为了单条范围查询额外建立这份数据,资源成本与本来就有这份数据的情况不同。旧文档的建议有这个上下文,不能提炼为“大范围永远使用 fielddata”。

这个例子也说明,接口上的 _cache 并不是唯一决定因素。底层执行路径、动态参数和组合关系共同决定缓存是否有意义。只比较两份 JSON 中开关的真假,无法解释全部延迟差异。

后来改成 bool.filter,保留的是条件语义

2.0 的迁移说明要求把 filtered.query 移到 bool.must,把 filtered.filter 移到 bool.filter。这里说明的是常见结构迁移,详细的 2.0 上下文与缓存变化见query 与 filter 合并。

对前面的例子,对照结构可以写成:

{
  "size": 20,
  "query": {
    "bool": {
      "must": [
        { "term": { "tag": "wow" } }
      ],
      "filter": [
        { "range": { "age": { "gte": 10, "lte": 20 } } },
        { "prefix": { "surname": "ba" } }
      ]
    }
  }
}

这是给支持 bool.filter 的版本使用的查询体,不是让 0.90 接受的新语法。跨版本测试需要分别创建目标版本的 mapping;不能把上面的旧 string 字段定义也不加判断地复制到现代集群。

这个改写有三个独立要求:原 query 仍承担查询语义,原 filter 仍只限制文档集合,范围保持包含上界。任何一项改错,都可能让迁移后的表现变化。把原过滤条件移到需要打分的位置,即使命中集合一样,也可能改变得分;把原查询全部移入过滤上下文,则会失去原本需要的相关性贡献。

复杂 OR 条件最好保留独立的 bool 分组,并显式写出 minimum_should_match,避免依赖外层是否存在 must/filter 所带来的默认值。这里给出的是迁移时的明确化建议,不表示旧 bool filter 支持同样的参数。

对于 NOT,还要定义缺失字段的业务含义。取反“字段等于某值”往往会接受原条件不命中的其他文档,其中可能包括缺少该字段的文档;如果业务要求字段必须存在,应另外验证存在性条件,而不是只把 not 换成 must_not。

这些检查的共同目的,是保护原请求的集合逻辑。不要把“迁移到统一接口”和“重新设计相关性策略”混在同一批修改里。前者需要尽量保持可解释的行为,后者则需要独立的业务验收标准。

接口合并没有取消缓存

2.0 迁移说明第 95–103 行说的是 _cache、_cache_key 被废弃,传入时忽略,过滤上下文中的子句改为在合适时自动缓存。算法考虑使用频率、执行成本及构建成本。

这与“2.0 之后 filter 缓存被完全移除,所以不需要再考虑缓存”不是一回事。变化在于控制接口和自动决策,而非缓存这个概念消失。也不能把它归因成“doc values 出现后就不需要过滤缓存”,这不是该迁移说明给出的解释。

同一份迁移说明还分别描述了 and、or 的执行合并,以及 filtered 的废弃。废弃不等于该版本立刻删除;阅读兼容性记录时,应区分警告、忽略参数、保留别名与拒绝解析。把几个阶段合并写成“2.0 全部不能用了”,会误导真实升级排查。

接口设计上的收益可以保守地概括为:条件表达减少了一套类型区分,是否需要得分主要由上下文表达,缓存策略不再依赖原来的逐条件开关。它降低了部分配置负担,但没有消除对查询成本和资源的理解需求。

迁移后某条查询变慢,也不能只凭这份变更说明断定问题来自缓存。分片布局、数据分布、映射、段状态、执行计划和并发负载可能都变了。先保存输入与配置,再逐项排除,比直接给新版本加一组旧缓存参数有效。

验收要分别记录集合、分数与成本

第一组验收比较 ID 集合,使用稳定的数据和完整的结果范围。只比较第一页可能看不出集合差异:边界文档可能本来就不在前几名,错误的 OR 条件也可能在更后面才体现。小型固定数据集适合验证逻辑,大型真实样本适合观察分布,两者用途不同。

第二组验收比较相关性行为。不要要求不同 Elasticsearch/Lucene 版本的每个浮点分数都完全相同;需要先明确业务依赖的是绝对分数阈值、相对排序,还是只关心是否匹配。如果应用直接使用固定 _score 阈值过滤结果,升级风险与仅使用排名的应用不同。

第三组验收比较资源成本,记录查询是否重复、是否处于缓存已预热状态、分片与副本、段数、并发以及输入数据是否相同。单次 took 只能提供一次请求的观察值,不能证明某种过滤器实现普遍更快,更不能用第一次和第二次请求的差异直接归因到指定缓存。

测试维度最小样本或动作要发现的问题
区间端点下界外、下界、上界、上界外闭区间误改成半开区间
AND 与 OR只满足一个条件、全部满足、全部不满足组合层级改变
NOT 与缺失字段匹配、不匹配、缺失取反后的接受集合变化
查询与过滤位置保持数据不变,仅调整上下文原本不打分的条件影响得分
动态时间固定时间、取整表达式、未取整表达式时间语义和缓存条件混淆
缓存层级分别检查父组合与子条件把一处配置当成全局行为

如果准备回退,应保留旧查询模板及其适用版本,而不是只保存改写后的请求。应用层回退查询是一回事,回退存储版本是另一回事;不要让旧二进制直接打开已经被新版本写入的数据目录来试验。

对已经无法启动的历史环境,也仍然可以完成一部分静态核对:确定标签、读取对应解析器、检查字段 mapping、整理边界样本,并明确哪些预期尚未运行。静态检查的价值在于减少错误假设,不能用“源码看起来如此”替代真实集群的全部验证。

写在最后

读 0.90 的旧请求,至少要同时识别 query/filter 的位置、布尔组合层级、范围包含关系和缓存条件。bool 当时已经存在,gte/lte 也并非等到 2.0 才出现;后来的上下文统一更没有让缓存消失。

维护老代码时,最有价值的动作是把隐含假设变成可检查的样本。等于上界的文档、只命中一项的文档、缺字段的文档,以及没有缓存资格的动态条件,都能比一份只改字段名的对照表提供更可靠的验收依据。

参考

本文核对了固定标签的文档及查询解析器,流程图为简化说明。示例为本文构造的历史验证方案,未启动集群、未运行历史测试;命中集合为条件推导,未提供实测分数与性能结论。