一条查询做多件事,靠 ES|QL 的 FORK 和 INLINE STATS

ES|QL 的 FORK 和 INLINE STATS 都已转正:前者把一条查询劈成几路并行跑,后者聚合完还能保住原始行。本文讲清两者的用法、版本状态和各自的使用边界。

说明

实测环境:本地分别起 Elasticsearch 8.19.8 与 9.4.2 单节点实例(官方 tar 包,堆内存 512MB,安全与机器学习关闭),curl 连本地 9200 端口,_query 接口走 ES|QL;两版都写入同一批 4 条文档(fk1,k 取 a/a/b/c、v 取 1/2/3/4)。

它们解决的是同一类问题

ES|QL 有一批命令是在解决同一类问题:有些事以前要跑多条查询、或者在应用层拼,现在一条就够了。这篇讲两个都已转正的命令:FORK 和 INLINE STATS。

先看版本,两个都转正了,别按老印象用

命令状态
FORK9.1–9.3 预览,9.4+ GA(serverless GA)
INLINE STATS9.2 预览,9.3+ GA(serverless GA)

依据是文档头部的 applies_to:FORK 写的是 stack: preview 9.1-9.3, ga 9.4+;INLINE STATS 写的是 stack: preview =9.2, ga 9.3+。

FORK 把一条查询劈成几条并行跑

痛点很具体:同一批数据,想同时算几个不同口径,以前只能拆成多条查询各跑一遍,回应用层自己拼。

FORK 的定义是(6–7 行):创建多个执行分支,对同一份输入数据做处理,再把结果合并到一张输出表里。

FORK ( <processing_commands> ) ( <processing_commands> ) ... ( <processing_commands> )

_fork 判别列

合并时会加一列 _fork,标明每行来自哪个分支,取值是 fork1、fork2、fork3……按分支定义顺序(42–43 行)。

输出行为

三条要记住(47–53 行):

  • 各分支可以输出不同的列。
  • 同名列的类型必须在所有分支里一致,缺失的列填 null。
  • 每个分支内部保持行顺序,但不同分支的行可能交错。想按分支分组看结果,用 SORT _fork。

一个很实在的例子

官方有个例子是"一条查询同时拿 Top 5 明细和总数"(simpleForkWithStats @ v9.5.4):

FROM books METADATA _score
| WHERE author:"Faulkner"
| EVAL score = round(_score, 2)
| FORK (SORT score DESC, author | LIMIT 5 | KEEP author, score)
       (STATS total = COUNT(*))
| SORT _fork, score DESC, author

结果是:

author:textscore:double_fork:keywordtotal:long
Colleen Faulkner2.18fork1null
William Faulkner2.18fork1null
Danny Faulkner2.02fork1null
Paul Faulkner2.02fork1null
William Faulkner2.02fork1null
nullnullfork218

fork1 是评分最高的 5 本,fork2 是总数 18,对不上的列填 null。一次性拿回"明细"和"汇总",不用跑两遍。

9.4 起,隐式 LIMIT 没了

这是个重要的行为变化。文档分版本写得很清楚(25–31 行):

  • 9.1–9.3(预览):每个 FORK 分支会加一个隐式的 LIMIT 1000。
  • 9.4+(GA):FORK 分支不再有隐式 LIMIT 1000。

也就是说,同样一条查询,从 9.3 升到 9.4,每个分支返回的行数上限从 1000 变成了不限。如果你的分支本来就在靠这个隐式 LIMIT 兜底,升级后返回量可能会变大,得自己补上显式的 LIMIT。

限制

(57–59 行)

  • 最多 8 个分支。
  • 9.3.0 之前,FORK 不支持配合远端集群引用。
  • 一个查询里只能有一个 FORK。

另外:FORK 是混合搜索的基础

FORK 和 FUSE 命令配合,就能做 hybrid search——把多路检索的结果合并起来重新打分(20 行)。比如一个分支走 match 全文、一个分支走 kNN 向量,再 FUSE 融合。

二、INLINE STATS:聚合完还能保住原始行

STATS 的问题:聚合之后原始行就没了,只剩分组结果。想给每行带上"本组的平均值""全表的最大值"这种数,以前要么自己再 JOIN 回去,要么查两次。

INLINE STATS 补的就是这个。官方定义(6–10 行):

The INLINE STATS processing command groups rows according to a common value and calculates one or more aggregated values over the grouped rows. The results are appended as new columns to the input rows.

The command is identical to STATS except that it preserves all the columns from the input table.

跟 STATS 一样分组聚合,但结果作为新列追加到原来的行上,行数不变。 写过 SQL 的话,这个感觉接近窗口函数。

语法(15–20 行):

INLINE STATS [column1 =] expression1 [WHERE boolean_expression1][,
      ...,
      [columnN =] expressionN [WHERE boolean_expressionN]]
      [BY [grouping_name1 =] grouping_expression1[,
          ...,
          [grouping_nameN = ] grouping_expressionN]]

注意每个聚合值后面都能跟一个 WHERE,用来筛选参与这个聚合的行——这是它比 STATS 灵活的地方。

几个行为要点

(48–59 行)

  • 输出表和输入表行数相同。
  • 只新增列,或者覆盖同名列。列名重叠时,已有列的值可能被覆盖,列顺序也可能变——新列会按 INLINE STATS 里定义的顺序排。
  • 省略 BY 就是对全表聚合。
  • 分组函数支持 BUCKET 和 TBUCKET(68–69 行)。
  • 计算聚合时,单个 null 值会被跳过(42 行)。

限制

(107–111 行)

  • 不支持 CATEGORIZE 分组函数。
  • INLINE STATS 之前不能用 LIMIT(显式或隐式都不行),因为会导致结果不符合预期。
  • INLINE STATS 之前不能用 FORK。

第三条正好跟上面 FORK 那条对上:虽然 9.4 起 FORK 分支不再加隐式 LIMIT 1000,但 v9.5.4 的文档里这条限制仍然写着,用之前留意一下。

三、什么时候用哪个

按理解:

  • 要把一份数据按不同口径分别算,最后一起看 → FORK。典型是"明细 + 汇总",或者混合搜索的多路召回。
  • 要给每一行补一个聚合出来的参照值,行数不能变 → INLINE STATS。比如给每条订单补一个"该用户的平均订单金额"。

两者不能串着用(FORK 后不能接 INLINE STATS),所以别指望"分叉之后再各自补聚合列"。

在 8.19.8 和 9.4.2 上各跑一遍

「两个命令都已转正」这句话,拿同一批数据、同一条查询在两个版本上跑就能验证。

图 1:9.4.2 上,普通 STATS n = COUNT(*) 只回一行 [[4]];换成 INLINE STATS 后 4 条原始行都还在、每行都带上 n=4;带 BY k 的分组写法把每组的 SUM(v) 附加回组内的每一行(a 组两行都是 3、c 组是 4)。本地实测输出。

图 2:FORK 按 v >= 3 / v < 3 劈成两路,各自 STATS ... BY _fork 得到 fork1=2、fork2=2,分支标记出现在结果里。本地实测输出。

图 3:8.19.8 上,同样两条查询分别报 mismatched input 'INLINE' 与 mismatched input 'FORK'——解析器不认识这两个命令。本地实测输出。

curl -s -XPOST 'http://localhost:9200/_query' \
  -H 'Content-Type: application/json' \
  -d '{"query": "FROM fk1 | INLINE STATS n = COUNT(*) | KEEP k, n | SORT k | LIMIT 10"}'
# 9.4.2: [["a",4],["a",4],["b",4],["c",4]]
# 8.19.8: mismatched input 'INLINE'

图 1 把 INLINE STATS 与 STATS 的差别摆在了一起:普通聚合把 4 行压成 1 行,INLINE 版本保住每一行,聚合值作为新列附回去;带 BY 时按组算,同组每行拿到同一个值。图 2 里 FORK 的结果是两路各自的聚合,_fork 列标出这行来自哪一路。图 3 说明这两个命令在 8.19.8 上连语法都过不去,属于 9.x 才有的能力。

结论是升级到 9.x 之后,原先「先查明细再发一条聚合请求」的两步写法可以合成一条查询,但调用方得先确认服务端版本——把 9.x 的查询发给 8.x 会直接 400。我的建议是给跨版本共用的查询层加一条能力探测,别只按版本号硬编码;FORK 的每一路都会实际执行,数据量大时要留意分支数翻倍的开销,本机只有 4 条文档,性能没有测。

边界:

  • 图 1–图 3 出自两个版本的单节点实例,只有 4 条文档,只试了 COUNT(*) / SUM(v) 两种聚合和三路以内的 FORK;
  • INLINE STATS 的其它限制、FORK 的分支数上限与并行执行开销没有实测;
  • 文章依据 v9.5.4 源码与 v9.3.0 发布说明,本机最高 9.4.2,9.5 里的调整未核对。

写在最后

这两个命令的共同点是把原来要在应用层拼的活收回到一条查询里。用的时候有一件事别忘了:它们都已经转正,但行为在转正过程中改过(比如 FORK 分支的隐式 LIMIT 1000 在 9.4 没了),别再按老印象写。

"收回到一条查询里"有什么好处:以前要做多件事,得发多条查询、在应用层合并结果——网络往返和应用代码都是成本。收回之后这些都没了,而且计算可以由引擎统一优化。

但也不是所有情况都该收:如果各分支的结果要按完全不同的方式处理(比如一边画图、一边导出),合成一条反而让应用逻辑变复杂。判断依据是"这些结果是不是要一起用"——一起看就合成一条,各走各的就分开。

关于 FORK 的隐式 LIMIT 被去掉这件事,要提:它说明转正不等于冻结。预览期的设计会按实际反馈调整,而这类调整往往不显眼——你不看 changelog 就会一直按老写法写,然后奇怪为什么结果条数不对。

建议:任何预览期用过的功能,转正之后都该重新确认一次当前行为。这个习惯成本很低(瞄一眼文档),但能避免一类很隐蔽的问题:代码没改、查询没改、结果变了——这种问题排查起来特别费劲,因为你会怀疑数据而不是版本。

参考

本文对照 Elasticsearch v9.5.4 标签源码与 v9.3.0 标签发布说明核对,示例取自官方示例文件。「在 8.19.8 和 9.4.2 上各跑一遍」里的查询已在本机两个版本的单节点实例上实际执行:9.4.2 上 INLINE STATS 保住原始行并把聚合值附回每行、带 BY 时按组附值,FORK 两路各自聚合并给出 _fork 标记;8.19.8 上两个命令都报 mismatched input(见该节)。分支数上限、并行开销与 9.5 的调整未实测。

0
0
0
0
评论
未登录
暂无评论