Elasticsearch 终于能在查询时 JOIN 了

简介: ES|QL 的 LOOKUP JOIN 让查询时关联另一张表成为可能,不用再靠应用层拼或者写入时冗余。

ES|QL 的 LOOKUP JOIN 让查询时关联另一张表成为可能,不用再靠应用层拼或者写入时冗余。这篇从用法讲到边界,包括它能关联什么、哪些情况会失败、以及在日志和告警场景里怎么用。

ES 终于有 JOIN 了

先祝大家中秋节快乐呀!趁着这个团圆的日子,给各位送上一篇实用干货。

这篇拖得久了,中间停更了快一个月。原因是这个月几个项目赶在一起,时间被占满,一直腾不出手。趁着中秋这段空档把欠的补上,下面进正文。

相信大家多少都遇到过 ES 的一个问题:两个索引想通过某个字段关联,却没有 JOIN。

订单在一个索引,用户资料在另一个索引;日志里只有 host.id,而主机归属、环境、负责人在另一张主机表里。想在一次查询里把它们对上,过去只能绕:要么写入时就把数据冗余到一起,要么查两次、在应用层自己拼。

现在 ES|QL 里的 LOOKUP JOIN 把这件事补上了。逻辑很简单:查询时,拿当前结果里的某个字段,去另一个索引里匹配,匹配上就把那边的字段接过来,没匹配上就填 null。写过 SQL 的话,这就是 LEFT JOIN。

这个能力是一路迭代过来的,到 9.3 已经打磨得相当完整,所以本文就以 9.3 为准来讲。

这能不能用 DSL 做

我知道不少人还是更习惯 Query DSL。ES|QL 这几年推得很凶,但很多人手里的告警、看板、脚本都是围着 DSL 建的,不太想为了一个 JOIN 就换一套语言。这可以理解,但结论得先摆出来:JOIN 这件事,DSL 确实做不到——这不是我的主观判断,是有官方文档和实现依据的。

先看总的说法。官方的 Joining queries 文档开门见山:

Performing full SQL-style joins in a distributed system like Elasticsearch is prohibitively expensive. Instead, Elasticsearch offers two forms of join which are designed to scale horizontally.

翻译过来:在 ES 这种分布式系统里做完整的 SQL 式 JOIN 代价太大,所以官方只提供了两种"为水平扩展而设计"的 join。而这两种,恰恰都不是我们要的跨索引关联。逐个看:

  • nested / join 类型(parent-child):同一篇文档明确写了,join 字段的父子关系只能存在于一个索引内部("A join field relationship can exist between documents within a single index")。nested 也是把对象数组存在同一篇文档里。所以它俩压根跨不到第二个索引。
  • terms lookup 查询:官方 terms query 文档的 Terms lookup 一节说得很清楚,它是"fetches the field values of an existing document"——去取某一篇已有文档、某个字段的值,拿回来当作一批 term 去做过滤。注意它取回来的是"搜索词",不是"要拼进结果的列",对方的其他字段根本带不回来。它是个 filter,不是 JOIN。
  • enrich 处理器:看它挂在哪就知道了——它是个 ingest processor,跑在写入 pipeline 里,"enrich documents with data from another index"是在文档入库时把参照数据冗余进去的。数据一变就得重新处理,这跟"查询时关联"是两回事。

所以结论很硬:"查询时把另一个索引的字段接到结果上"这个能力,Query DSL 侧就是没有对应实现。 官方自己也是这个态度——正因为 DSL 补不上这一块,才在 ES|QL 里专门做了 LOOKUP JOIN。想要它,就只能走 ES|QL。好消息是,你只需要在要 JOIN 的那条查询上用它,不必把整套东西都迁走。

直接看表:

方式 何时关联 能不能取回对方字段 跨索引
nested / join 类型 查询时 能,但同索引内 否
terms lookup(DSL) 查询时 不能,只做过滤 是(仅取一个字段做过滤)
enrich 处理器 写入时 能(冗余进文档) 写入时拼好
**ES QL LOOKUP JOIN** 查询时 能 是

版本状态先说清楚:基础 LOOKUP JOIN 从 9.1 起就 GA、可以放心用;后面会提到的字段间比较、全文匹配还挂着"技术预览",上生产前先评估一下。这些不是我说了算,官方适用版本标注写得清楚,下面的示例也都在 9.3 上核过。

先跑一个

右边那个被 JOIN 的索引,得建成 lookup 模式——这是它跟普通索引唯一的区别,其他都一样:

PUT languages_lookup
{
  "settings": { "index.mode": "lookup" },
  "mappings": {
    "properties": {
      "language_code": { "type": "integer" },
      "language_name": { "type": "keyword" }
    }
  }
}

POST languages_lookup/_bulk?refresh
{"index":{}}
{"language_code":1,"language_name":"English"}
{"index":{}}
{"language_code":2,"language_name":"French"}
{"index":{}}
{"language_code":3,"language_name":"Spanish"}

然后在员工数据上把 language_name 拼进来。下面这个 employees 是官方测试数据集,带 emp_no、languages 等字段;你自己试的时候换成自己的索引就行:

FROM employees
| EVAL language_code = languages
| LOOKUP JOIN languages_lookup ON language_code
| WHERE emp_no >= 10091 AND emp_no < 10094
| SORT emp_no
| KEEP emp_no, language_code, language_name

结果:

emp_no | language_code | language_name
10091  |             3 | Spanish
10092  |             1 | English
10093  |             3 | Spanish

这段就是官方测试 lookup-join.csv-spec 里的 basicOnTheDataNode,原样搬过来的。有几点先记一下:

  • ON language_code 是靠同名字段对齐的。左边没有这一列,就先用 EVAL 或 RENAME 造一个(例子里那句 EVAL language_code = languages 就是干这个的)。
  • 匹配的是精确值,不是全文检索,别指望它分词模糊匹配。
  • 左连接:没匹配上的行不会被丢掉,右边字段填 null。

ON 能写成什么样

从简单到灵活,一共四种:

一、单字段(最常用,就是上面那种):

FROM employees
| LOOKUP JOIN languages_lookup ON language_code

二、多字段(9.2 起 GA)。几个字段全都相等才算匹配:

FROM multi_column_joinable
| LOOKUP JOIN multi_column_joinable_lookup ON id_int, name_str
| KEEP id_int, name_str, extra1, other1, other2
| SORT id_int, name_str, extra1, other1, other2

对应测试 lookupJoinOnTwoFields。

三、字段之间的比较(9.2 起,预览)。到这一步左右字段不用同名了,>、<、!= 这些也能写:

FROM employees
| WHERE emp_no == 10001
| LOOKUP JOIN languages_lookup ON languages == language_code
| KEEP emp_no, languages, language_code, language_name

这条来自 lookup-join-expression.csv-spec,员工 10001 匹配到 French。要多加条件就用 AND 连起来;左右字段重名的话,先 RENAME 分开。

四、ON 里带全文函数(9.3 新增,预览)。比如一个条件走等值,另一个对 lookup 字段做 MATCH:

FROM multi_column_joinable
| RENAME id_int AS id_left
| LOOKUP JOIN multi_column_joinable_lookup ON id_int == id_left AND MATCH(other1, "beta")

对应 lookupJoinExpressionOnRightFieldNotAnywhereElse。要说明的是,这是往 JOIN 条件里加了一个全文匹配,不等于"两列之间做模糊 JOIN",别理解偏了。

跨集群(9.2 起):FROM 可以带远端集群前缀,但 LOOKUP JOIN 后面那个索引名不能带远端前缀:

FROM logs-*, remote_a:logs-*
| LOOKUP JOIN clients ON ip
| STATS count() BY tier

这里有个容易踩的坑,后面单独讲。

具体实现

先看整体流程,再落到几处关键代码上:

flowchart TB
    IN["输入表<br/>(FROM 或前面管道的结果)"] --> OP["LookupFromIndexOperator<br/>按页攒 join key,异步发请求"]
    OP -->|"一批 key 的 Page"| SVC["AbstractLookupService<br/>_local 路由到那个唯一分片"]
    SVC --> MATCH{"在 lookup 分片上<br/>逐 key 跑 Lucene 精确查询"}
    LK[("lookup 维表<br/>强制 1 个主分片,可带副本")] --- MATCH
    MATCH -->|命中| PULL["取回要的右侧字段<br/>带上对应的左表行号 position"]
    MATCH -->|未命中| NP["该 position 无右侧数据"]
    PULL -->|"右侧结果 Pages"| MERGE["RightChunkedLeftJoin<br/>按 position 合并回左表"]
    NP --> MERGE
    MERGE --> OUT["输出表<br/>命中多条→多行;未命中→补 null"]

    classDef box fill:#eaf2ff,stroke:#4b6fa8,color:#172b4d
    classDef store fill:#fff3df,stroke:#af7b29,color:#5b4018
    class IN,OP,SVC,PULL,NP,MERGE,OUT box
    class LK,MATCH store

它不是"逐行同步查",而是一个异步算子。LookupFromIndexOperator 把当前 Page 里的 join key 攒成一批,包成请求丢给 lookup 服务,回调里再拿结果去和左表合并——LookupFromIndexOperator#performAsync:

        LookupFromIndexService.Request request = new LookupFromIndexService.Request(
            sessionId,
            lookupIndex,
            lookupIndexPattern,
            newMatchFields,
            new Page(inputBlockArray),
            loadFields,
            source,
            rightPreJoinPlan,
            joinOnConditions
        );
        lookupService.lookupAsync(
            request,
            parentTask,
            listener.map(pages -> new OngoingJoin(new RightChunkedLeftJoin(inputPage, loadFields.size()), pages.iterator()))
        );

请求到了服务端,第一件事就是确认维表真的只有一个分片——这也是架构图里"单主分片"那个约束在代码里的落点,AbstractLookupService#lookupAsync:

    public final void lookupAsync(R request, CancellableTask parentTask, ActionListener<List<Page>> outListener) {
   
        ClusterState clusterState = clusterService.state();
        var projectState = projectResolver.getProjectState(clusterState);
        List<SearchShardRouting> shardIterators = clusterService.operationRouting()
            .searchShards(projectState, new String[] {
    request.index }, Map.of(), "_local");
        if (shardIterators.size() != 1) {
   
            outListener.onFailure(new EsqlIllegalArgumentException("target index {} has more than one shard", request.index));
            return;
        }

"_local" 表示优先命中本节点的那份副本;单分片意味着不用向多个主分片散开再归并。之后就在这个分片上逐 key 跑 Lucene 精确查询,把命中文档要的字段读出来,同时给每条结果带上它属于左表的哪一行(position)。

最后一步 RightChunkedLeftJoin 负责把右侧结果按 position 拼回左表。它的实现注释 21-58 行里带了一张示意图,把"一对多展开"和"未命中补 null"讲得很清楚。下面按那张图重画、并简化了行数(原注释还多了 l03、l99 两行,脚注是英文,这里换成中文批注):

 "left hand"                 "right hand"
 | lhdata |             | positions | r1 | r2 |
 ----------             -----------------------
 |    l00 |             |         0 |  1 |  2 |
 |    l01 |             |         1 |  2 |  3 |
 |    l02 |             |         1 |  3 |  3 |

 合并后:
 | lhdata  |  r1  |  r2  |
 -------------------------
 |     l00 |    1 |    2 |
 |     l01 |    2 |    3 |
 |     l01 |    3 |    3 |   ← l01 的 position 出现两次,复制成两行
 |     l02 | null | null |   ← l02 没被匹配到,补 null

到这就跟前面说的语义对上了:左表每行至少出现一次,命中几条就复制几行,没命中就补 null。

几个跟用法直接相关的点:

  • 左连接。 没匹配上的行还在(右边列 null),匹配到多条就展开成多行——所以 JOIN 之后行数可能变多,别当"一进一出"的字典映射用。
  • 顺序不保证。 想要固定顺序,把 SORT 放在最后一个 LOOKUP JOIN 之后。
  • key 是精确匹配。 用 keyword、数值、ip、boolean 这类能精确匹配的类型。左边 text 可以配右边 keyword,但右边直接用 text 当 key 会在解析阶段报错,换成 keyword(或 text 的 .keyword 子字段)。
  • key 是多值的那行匹配不上,但行不会丢:右边填 null,同时给一条 warning。

那 lookup 维表到底能多大?

先纠正一个常见的说法:单分片限制的是右边的 lookup 维表,左边主表该几个分片还是几个分片,不受影响。

那维表能多大?我翻了 9.3 的源码和文档,官方并没有给出"最多多少行/多少 GB"的硬性数字,能定的就两条边界:

  1. 结构上限——只能一个主分片。 index.mode=lookup 会在建索引时强制校验:显式设成多分片直接报错,不设则自动补成 1(IndexMode 里的校验和 settings provider)。所以整张维表得塞进一个分片里,规模自然受"单个分片能健康承载多少"约束——理论上一个 Lucene 分片上限约 21 亿文档,但实践中远早于此就会被分片体积、节点堆内存、查询延迟拖住。换句话说,按你集群里单分片的健康大小来衡量维表就行。

  2. 运行时上限——会熔断。 官方文档 Limitations 一节只给了这么一句定性描述:

    The query will circuit break if there are too many matching documents in the lookup index, or if the documents are too large. More precisely, LOOKUP JOIN works in batches of, normally, about 10,000 rows...

    注意它说的是 matching documents(命中的文档) 太多或太大就会熔断,"约 1 万行一批"也只是近似说法、不是代码里的固定常量。

所以真正要盯的不是维表总行数,而是单个 join key 会命中多少、命中的文档有多大。一个 key 匹配出成千上万条、或者维表文档又宽又大(几 MB 级),才是触发熔断的元凶。实践上的建议就三条:维表当"参考表"用、体积控制在单分片舒适区;join key 尽量唯一、别高扇出;维表只留要 JOIN 出去的那几列,别把大字段也塞进去。真需要几十上百 GB、高扇出的大表关联,就不该用 LOOKUP JOIN。

用之前先看一眼这些限制(9.3)

项 说明
右侧索引 必须 index.mode=lookup,一个主分片、可带副本
join key 类型 精确匹配类型;左 text 可配右 keyword,右侧不能直接用 text
多值 join key 不匹配,但左行保留、右侧填 null,并给 warning
右侧别名 支持,但每个相关集群里必须解析到单个具体 lookup 索引
右侧通配符 不支持,哪怕只匹配到一个索引也会被拒
跨集群 FROM 可带远端;LOOKUP JOIN 右侧不能带远端前缀(见下)
JOIN 语义 LEFT OUTER;未命中补 null,多命中展开多行
排序 不保证保留前面的 SORT;要顺序就把 SORT 放到 JOIN 之后
安全 支持文档级/字段级安全(DLS/FLS)

跨集群这一条单独讲

前面说的坑就是它。这个跟大家写分布式 SQL 的直觉是反的,很容易搞错。

可以这么写(数据源跨集群,字典表在各集群本地各存一份):

FROM logs-*, remote_a:logs-*
| LOOKUP JOIN clients ON ip

不能这么写(把字典表写成远端的):

FROM logs-*
| LOOKUP JOIN remote_a:clients ON ip

两个要点:

  • LOOKUP JOIN 右边不要加集群前缀。本地和远端数据源混着查的时候,每个参与查询的集群都要有自己那份同名的 clients(用别名指向各自的具体索引也可以),各查各的本地数据,这份字典不会自动同步,需要你自己保证各集群都有。反过来,只查远端数据源时,协调集群反而不需要有这份字典。这一点有官方集成测试佐证。
  • 跨集群的 LOOKUP JOIN 不能跟在 STATS、SORT、显式 LIMIT 或协调端 ENRICH 后面;把这些命令放到 JOIN 之后一般就没问题。

什么时候用,什么时候别用,和 ENRICH 怎么选

适合的场景:

  • 语言、地区、行业分类这类字典表;user → org、ip → tenant、host → env 这类关联。
  • 给 logs-* 拼主机/服务/客户端信息,给告警拼资产/责任人,用 DLS/FLS 控权限。
  • 参照数据经常变、又不想每次重跑 enrich 的场景——数据更新频繁恰恰是官方推荐用它的理由之一,别被"字典表就该是静态的"这个惯性想法带偏。

不适合、或者要先掂量的:

  • 拿它硬 JOIN 两张大表。右边只有一个主分片,大表很容易压垮它。
  • 需要 RIGHT / FULL OUTER 语义——没有。
  • 想在 text 上做模糊 JOIN,或者按 CIDR / 范围来关联——这些不归它管(范围关联可以看 ENRICH 的 range policy)。
  • 一次匹配命中很多、或者文档很宽:返回的批次和内存开销都会涨,严重时会触发熔断。所以别只看右表总共多少行,还要看单次匹配能扇出多少、每条要取多少字段。

和 ENRICH 怎么选? 看两件事:数据怎么更新、要什么匹配语义。

  • ENRICH 是在写入时把数据固化进派生索引,查询时比较轻,但源数据一改就得重跑 execute;它还能做范围/空间匹配。
  • LOOKUP JOIN 是在查询时读一个可以随时更新的 lookup 索引,数据跟着普通索引的生命周期走,一对多还能像 SQL 那样展开成多行。

也别直接下"ENRICH 一定更快"的结论——两者查询时其实共用同一套 lookup 机制,谁快谁慢取决于数据分布、命中数量、字段宽度和并发,还是拿自己的数据实测一下最靠谱。

参考

官方文档:

源码与测试(均为 v9.3.0 标签):

目录
相关文章
|
24天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
9479 31
|
1天前
|
云安全 人工智能 运维
当攻击全面Agent化,你的安全防御能否跟上AI Speed?
多起客户侧AK泄露事件应急的真实观察
1156 0
|
10天前
|
存储 人工智能 并行计算
大模型本地部署终端选型方法论:以 Qwen3.8-27B 为例的四档分层完整流程
本文提出一套大模型本地部署终端选型方法论:定约束、定档位、定框架、定参数四步决策法,配合入门、主力、质量、无损四档分层模型。以 Qwen3.8-27B 实测数据为例,逐环节解读显存、带宽、存储、散热、系统、预算等要素,给出面向不同预算的优选方案、决策自查清单与市场观察框架。文末前瞻 AI 笔记本的 CPU+GPU 与统一内存两条路线,论证四步决策法在新品类上的延续性。
|
9天前
|
人工智能 JSON 自然语言处理
2026 年 Jev 决策模型深度拆解:原理解读、实战测评与保姆级落地教程
有一款特殊AI模型在开发者圈子刷屏,它摒弃传统大模型擅长的对话聊天能力,专注做高速结构化决策,它就是TypeSafe AI推出的Jev模型。该模型由ChatGPT共同发明人Diogo Almeida主导研发,定位为**System One Model(系统一模型)**,对标人类大脑快速直觉判断的思维模式,在响应延迟、调用成本、结构化输出稳定性上相比传统生成式大模型有着巨大差异。本文会完整拆解Jev底层原理、三大核心原语能力、适用业务场景,同时提供可直接运行的curl、Python代码示例,并且结合多组实测数据,客观分析模型优势与能力边界,帮助普通开发者和AI应用从业者快速上手落地。
716 1
|
5天前
|
人工智能 JSON 自然语言处理
2026 年 Jev 决策模型全解析:核心原理拆解,性能实战测评与落地部署教程
有一款特殊AI模型在开发者圈子刷屏,它摒弃传统大模型擅长的对话聊天能力,专注做高速结构化决策,它就是TypeSafe AI推出的Jev模型。该模型由ChatGPT共同发明人Diogo Almeida主导研发,定位为System One Model(系统一模型),对标人类大脑快速直觉判断的思维模式,在响应延迟、调用成本、结构化输出稳定性上相比传统生成式大模型有着巨大差异。本文会完整拆解Jev底层原理、三大核心原语能力、适用业务场景,同时提供可直接运行的curl、Python代码示例,并且结合多组实测数据,客观分析模型优势与能力边界,帮助普通开发者和AI应用从业者快速上手落地。
470 0
|
22天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
2548 4
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
4天前
|
存储 缓存 测试技术
QWT 3D 模块重构记:旧渲染管线改进介绍及实时瀑布图示例
QWT 3D模块完成重大重构:解耦窗口与图形(Plot+Item),摒弃OpenGL 1.x固定管线,迁至Core Profile着色器渲染;支持多图元同场景叠加,并以实时3D瀑布图验证——滑动窗口、固定色域、64帧×256点达61fps。兼容Qt 5.12+/6,LGPL协议。
178 120
|
16天前
|
Windows
【保姆级】.NET Framework 3.5离线安装教程(2026最新实测)
.NET Framework 3.5是微软经典运行库,专为兼容老软件、老游戏设计。Win7自带,Win10/11需手动启用(在线或离线安装)。它与4.x系列并存不冲突,缺它会导致“找不到组件”报错。本文详解两种安装方式及常见问题解决,小白也能轻松上手。(239字)
2080 0