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字段的父子关系只能存在于一个索引内部("Ajoinfield relationship can exist between documents within a single index")。nested也是把对象数组存在同一篇文档里。所以它俩压根跨不到第二个索引。termslookup 查询:官方 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"的硬性数字,能定的就两条边界:
结构上限——只能一个主分片。
index.mode=lookup会在建索引时强制校验:显式设成多分片直接报错,不设则自动补成 1(IndexMode里的校验和 settings provider)。所以整张维表得塞进一个分片里,规模自然受"单个分片能健康承载多少"约束——理论上一个 Lucene 分片上限约 21 亿文档,但实践中远早于此就会被分片体积、节点堆内存、查询延迟拖住。换句话说,按你集群里单分片的健康大小来衡量维表就行。运行时上限——会熔断。 官方文档 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 JOINworks 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 标签):