作者:来自 Elastic Vinay Chandrasekhar 及 Miguel Sánchez

通过四个调查案例进行逐步演示,从 CPU 饱和到 Pod 内存压力,每个案例都通过一个跨信号类型的查询得到答案。
ES|QL 现在可以通过另一个查询的实时结果来过滤一个可观测性信号。 WHERE field IN (subquery) 在 Elastic Stack 9.5 和 Elastic Cloud Serverless 中处于技术预览阶段,而且由于这些子查询可以嵌套,因此一个查询可以同时跨越日志、指标和追踪数据。
其优势在于中间结果集的存放位置。当你询问哪些饱和主机记录了日志时,饱和主机列表会在 Elasticsearch 内部计算并使用。6 个主机名或 500 个追踪 ID 永远不会出现在剪贴板或 AI agent 的上下文窗口中,而且每次运行查询时,该结果集都会根据当前数据重新计算。
这改变了调查的基本单位。你可以从 “一个缓慢的请求因为锁而被阻塞” 转变为 “最慢的请求中大多数都因为锁而被阻塞”,而只有第二个答案才能告诉你应该通知哪个团队。当不同信号之间的关联程度越高时,这一点就越重要:在许多可观测性技术栈中,日志、指标和追踪数据分别存储在三个独立的系统中,每个系统都有自己的查询语言、自己的时间选择器,以及自己对主机的定义,因此同一个问题必须被询问两到三次,然后再手动将答案关联起来。
在本文中,我们将逐步介绍四个调查案例,每个案例都是自包含的。对于每个案例,我们都会说明触发调查的场景、能够回答问题的查询、查询返回的表格,以及尝试通过其他方式获得相同答案时会遇到的困难。
| 模式 | 起点 | 回答的问题 |
|---|---|---|
| 指标到日志 | CPU 饱和 | 哪些错误模式只出现在饱和主机上? |
| 日志到指标 | 错误日志 | 出现错误的主机与正常主机相比是否存在不同表现? |
| 追踪到日志 | 缓慢 span | 在这些特定请求期间,每个服务记录了什么日志? |
| 三种信号全部关联 | Pod 内存压力 | 哪些日志行对应于在这种压力下失败的请求? |
数据通过 OpenTelemetry 采集,并写入 logs-*.otel-*、traces-*.otel-* 和 metrics-*.otel-* data streams,其中字段保留其 语义约定 中的名称,而不是被改写成另一种 schema。这种关联模式同样适用于 Elastic Agent integrations,不过查询需要进行转换,而不仅仅是重命名:ECS 使用文本字段 log.level 来表示日志严重性,而不是使用数值型 severity_number;System integration 使用独立的 system.cpu.*.pct 字段报告 CPU,而不是使用带有状态维度的单个指标;APM 则以微秒记录持续时间。无论采用哪种方式,所有数据最终都会进入同一个集群,而这正是子查询所依赖的部分。
在 Discover 中,时间选择器已经应用了时间范围,因此下面的示例省略了显式的 @timestamp 过滤条件。在 Discover 之外,你需要自行添加时间过滤,可以在查询中使用具体的时间戳,也可以在查询中使用 ?_tstart 和 ?_tend,并将对应的值放入 _query 请求的 params 数组中。下面的每个结果都来自一个由 300 台主机组成的模拟集群,在一小时的时间窗口内生成。
从指标到日志:饱和主机在抱怨什么?
一条基础设施告警告诉你,在过去一小时内,一个由数百台主机组成的集群中,有少数几台主机的 CPU 使用率超过了 90%。这只能告诉你哪些主机处于高负载状态,却无法告诉你原因。真正值得回答的问题是:这些主机是否存在共同的故障模式,还是它们只是因为互不相关的原因而处于繁忙状态,而这次告警只是一个巧合。
FROM logs-*.otel-*
| WHERE severity_number >= 17
AND resource.attributes.host.name IN (
TS metrics-hostmetrics.otel-*
| WHERE attributes.state == "idle"
| STATS idle = AVG(AVG_OVER_TIME(metrics.system.cpu.utilization))
BY resource.attributes.host.name
| WHERE idle < 0.1
| KEEP resource.attributes.host.name
)
| STATS errors = COUNT(*), hosts = COUNT_DISTINCT(resource.attributes.host.name)
BY pattern = CATEGORIZE(body.text), service = resource.attributes.service.name
| SORT errors DESC
查询的两部分分别对应问题的两个部分。子查询计算出哪些主机处于饱和状态:计算每台主机的平均 CPU 利用率,并保留平均空闲率低于 10% 的主机,也就是在这个时间窗口内平均忙碌度超过 90% 的主机。然后,外层查询计算这些主机在抱怨什么:提取它们的错误日志,并使用 CATEGORIZE 将数千条单独的日志行归并为少量错误类别。
其中有两个选择值得特别说明。
子查询使用 TS,而不是 FROM,因为一台主机不会只报告一个 CPU 数值。它会针对每种 CPU 状态分别报告一个时间序列,如果你的 collector 配置为分别报告每个逻辑核心,那么还会针对每个逻辑核心分别报告时间序列,因此需要分两个阶段进行聚合。AVG_OVER_TIME 首先将每个时间序列归并为一个值,然后外层的 AVG 再将这些值组合成每台主机的一个数值。
为内部函数明确指定名称的重要性比看起来更高。如果单独写 AVG(metrics.system.cpu.utilization),那么 TS 会自动为你提供 LAST_OVER_TIME,计算的是每个时间序列最后一个样本的平均值,而不是整个时间窗口内的平均值。在这个数据集中,这一个替换会让某台主机从 7% 的空闲率变成 10% 的空闲率,而这正是它是否出现在结果中的区别。使用 TS 命令查询指标 对这两个聚合阶段进行了更深入的介绍。
日志过滤条件测试的是 severity_number(在 OpenTelemetry 的等级标准中,17 是 ERROR 的最低值),而不是 severity 文本,因为数值等级由规范固定,而文本则取决于产生日志的库决定写入什么内容。这在这里并不是一个假设性的区别:这个集群中的错误日志带有四种不同的标签,其中包括来自 Java 服务的 SEVERE。仅匹配文本会返回 checkout 的 2,754 条错误日志,并完全遗漏 billing 服务,而使用数值过滤器则会返回全部 5,910 条错误日志,同时也包含 billing。
当你在 Discover 中运行这个查询时,结果会是一个简短的错误类别表,并且范围限定在处于饱和状态的主机上:

子查询返回了 6 台饱和主机,而且这 6 台主机上的相同服务都在针对一个上游依赖超时,并耗尽其连接池。hosts 列让你无需打开任何内容就可以把另外两行放到一边:证书错误只出现在 6 台主机中的 2 台上,而网关拒绝在一小时内只出现了 5 行。这两种情况都不像 checkout 模式那样与这个用户群组相关。
三个信号已经存储在同一个数据存储中,这本身就已经省去了导出数据这一步。子查询又进一步消除了后续的手动步骤,而消除最后这一环的重要性比听起来更大。当你从图表中读出 6 个主机名,然后把它们输入日志搜索时,这个集合已经发生了变化:一分钟前刚刚超过阈值的主机可能不在你的列表中,而已经恢复正常的主机却还在列表里。这里的主机列表每次运行查询时都会根据当前数据生成,因此在故障期间重新运行查询,就能获得当前的用户群组。
过时的列表只是其中一半的问题。手动完成的关联只存在于执行关联的人的脑海中,因此其他人无法检查、保存,也无法在第二天再次运行。
从日志到指标:出现错误的主机与正常主机相比是否存在不同表现?
根据日志生成的主机集合来过滤指标,可以回答相反的问题。payments 服务在部分主机上不断产生错误,而在其他主机上却没有。你希望在开始查看部署历史之前,先确定资源压力是否能够解释这种差异。
这是一个比较问题,因此查询需要包含两个用户群组。FORK 会基于相同的输入运行两个分支,而针对同一个由日志生成的主机集合使用 IN 和 NOT IN,就可以将整个集群划分成这两个用户群组。
TS metrics-hostmetrics.otel-*
| WHERE attributes.state == "idle"
| STATS idle = AVG(AVG_OVER_TIME(metrics.system.cpu.utilization))
BY host = resource.attributes.host.name
| EVAL busy = 1 - idle
| FORK
( WHERE host IN (
FROM logs-*.otel-*
| WHERE severity_number >= 17
AND resource.attributes.service.name == "payments"
AND resource.attributes.host.name IS NOT NULL
| STATS errors = COUNT(*) BY resource.attributes.host.name
| KEEP resource.attributes.host.name )
| EVAL cohort = "logging errors" )
( WHERE host NOT IN (
FROM logs-*.otel-*
| WHERE severity_number >= 17
AND resource.attributes.service.name == "payments"
AND resource.attributes.host.name IS NOT NULL
| STATS errors = COUNT(*) BY resource.attributes.host.name
| KEEP resource.attributes.host.name )
| EVAL cohort = "no errors" )
| STATS hosts = COUNT(*), mean_busy = AVG(busy), busiest_host = MAX(busy)
BY cohort
这个查询从上到下可以看作三个阶段。指标查询首先运行,将整个集群缩减为每台主机一个繁忙度数值。然后,FORK 在两个分支中使用相同的日志查询,将整个集群一分为二:一组是出现在日志查询结果中的主机,另一组是没有出现在结果中的主机。最后的 STATS 对每个组进行汇总,因此两个用户群组会以同一张表中的两行返回,在相同的时间窗口内使用相同的方式进行测量。
这个查询比其他查询更长,其中有三个部分不像表面看起来那么直观。
给两个用户群组打标签最自然的方式应该是 EVAL cohort = CASE(host IN (...), "erroring", "healthy"),但 ES|QL 会拒绝这种写法。在 9.5 中,IN 子查询必须作为 WHERE 条件中的顶层谓词,而不能作为标量函数的参数,因此这里通过 FORK 在命令级别完成划分。
每个子查询中的 STATS ... BY 看起来是多余的,因为仅使用 KEEP 也会返回相同的主机名。但实际上并非如此:没有它,子查询会针对每一条匹配的日志文档返回一行,而不是每台主机返回一行,并且这些行都会保存在内存中,供外层查询进行过滤。先进行聚合,可以将数百万行转换成几百个主机名。
IS NOT NULL 过滤器防止了这里最棘手的问题,而这个数据集提供的是一个真实示例,而不是假设性的情况。NOT IN 遵循 SQL 的 null 语义,因此只要子查询结果中存在一个 null,该谓词就完全不会匹配任何内容。这个集群中有 5 条 payments 错误日志来自一个丢失了主机名的 sidecar。删除两个分支中的这一行后,查询仍然可以成功执行,但它只会返回一行:这些 null 会悄悄删除整个包含 286 台主机的“无错误”用户群组,而剩下的结果看起来完全像是针对另一个问题得到的合理答案。
在 Discover 中运行这个查询,你会得到两行,每个用户群组一行:

CPU 无法解释这种差异,而数据两次证明了这一点。这 14 台产生错误的主机平均 CPU 使用率实际上比那 286 台没有错误的主机略低,而其中最繁忙的主机在这一小时内平均 CPU 使用率也只有 50%,相比之下,没有错误的用户群组中却有一台主机平均达到了 95%。无论这 14 台主机出现了什么故障,它们在整个期间都拥有充足的资源余量,因此接下来的 10 分钟更应该花在查看部署历史上。
这样的否定性结果与肯定性结果同样有价值,而人们通常会跳过前者。采用传统方式获取这个结果,需要针对两个手动构建的主机列表分别运行一次指标查询,然后再将结果进行对比。这其中的摩擦足以让人干脆不做这项检查。这里的两个用户群组来自同一次执行中的同一个日志查询,并且使用完全相同的时间窗口,因此无需进行任何结果对齐,也没有理由不进行检查。
从追踪到日志:每个服务在这些缓慢请求期间记录了什么?
一个服务级别目标(SLO)燃烧告警因 checkout 延迟而触发。追踪数据可以提供这些缓慢请求及其 spans,接下来的问题是,在这些特定请求执行期间,相关服务在其日志中记录了什么。
追踪 ID 就是关联键,而追踪 ID 的数量太多,根本不适合手动传递。
FROM logs-*.otel-*
| WHERE trace_id IN (
FROM traces-*.otel-*
| WHERE kind == "Server"
AND resource.attributes.service.name == "checkout"
AND name == "POST /api/orders"
AND duration > 2000000000
| SORT duration DESC
| LIMIT 500
| KEEP trace_id
)
| STATS lines = COUNT(*), traces = COUNT_DISTINCT(trace_id)
BY pattern = CATEGORIZE(body.text),
service = resource.attributes.service.name,
severity_text
| SORT traces DESC
子查询回答的是“哪些请求比较慢”。它查看的是 checkout 端点的入站请求 span,而不是其下方的客户端和内部 spans,然后保留 500 个超过两秒的最慢请求。持续时间以纳秒记录,因此阈值中会出现这么多 0。
外层查询回答的是“这些请求执行期间记录了什么日志”,提取与这些追踪 ID 中任意一个相匹配的每一条日志,并将它们归类为不同的模式。
按每种模式统计不同追踪 ID 的数量,是让结果保持可读性的关键。某个日志模式在 4 个追踪 ID 中出现了 30,000 次,说明只是某个请求产生了大量日志;而某个模式出现在 500 个最慢请求中的 470 个请求中,则说明它与请求缓慢之间存在关联。

从上面的结果来看,第一行是查询设计决定必然出现的,因此可以直接忽略:checkout 每个订单都会写入一条 order submitted 日志,所以它会出现在全部 500 个追踪 ID 中,并不能说明为什么这 500 个请求会变慢。
从上面的结果来看,第三行才是答案,它指向了一个之前没有人关注的服务。inventory 中的锁等待超时位于告警触发位置下游两跳,在 500 个最慢请求中的 470 个请求里都出现了,而这 470 个追踪 ID 背后的 947 条日志意味着其中相当一部分请求进行了多次重试。payment gateway 的拒绝确实是真实的故障,但它们只出现在 500 个请求中的 19 个,因此并不是导致 SLO 消耗过快的原因。
如果手动完成这项工作,就意味着逐个打开缓慢请求的追踪记录,然后阅读每个请求关联的日志。处理 10 个请求已经很繁琐,处理 500 个请求更没人会把它当作一个可行的方案。当 spans 和日志存储在不同系统中时,你检查每个追踪记录都需要复制 ID 并切换上下文,这使得你能够承担的样本量降低到大约 3 个。3 个追踪记录足以形成一个理论,却不足以验证这个理论。将缓慢请求视为一个整体,才能将“这个追踪记录存在锁等待”转变为“500 个最慢请求中的 470 个都存在锁等待”,而这一差异决定了你是否应该通知 inventory 团队。
三种信号全部关联:从 Pod 内存压力到导致故障的日志行
IN 子查询可以嵌套,因此这种模式可以扩展到问题所需的任意数量的信号类型。
一次部署之后,一个节点池开始报告内存压力。你希望找到处于压力之下的 Pod 上实际失败的请求所对应的日志行,这意味着需要从指标到追踪,再到日志,中间无需停下来。
FROM logs-*.otel-*
| WHERE trace_id IN (
FROM traces-*.otel-*
| WHERE kind == "Server"
AND status.code == "Error"
AND resource.attributes.k8s.pod.uid IN (
TS metrics-kubeletstats.otel-*
| STATS peak = MAX(MAX_OVER_TIME(metrics.k8s.pod.memory_limit_utilization))
BY resource.attributes.k8s.pod.uid
| WHERE peak > 0.95
| KEEP resource.attributes.k8s.pod.uid
)
| STATS failures = COUNT(*) BY trace_id
| SORT failures DESC
| LIMIT 1000
| KEEP trace_id
)
| STATS lines = COUNT(*), traces = COUNT_DISTINCT(trace_id)
BY pattern = CATEGORIZE(body.text), service = resource.attributes.service.name
| SORT traces DESC
从内向外阅读这个查询,你可以看到每一层都在回答问题的一部分。最内层的子查询识别内存峰值超过其限制 95% 的 Pod,使用 MAX_OVER_TIME 获取每个 Pod 的峰值,而不是平均值。中间层将范围缩小到这些 Pod 上实际失败的请求,并将其减少到最多 1,000 个追踪 ID。然后,外层查询收集这些追踪 ID 对应的日志,涵盖参与请求的每个服务,包括运行在完全健康的 Pod 上的服务,而最后这一部分恰恰是答案所在。
这里根据 Pod 的 UID 而不是名称进行匹配,因为不同命名空间和重启后的 Pod 可能使用相同的名称。该查询假设 Kubernetes 元数据已经进入你的 spans,这正是 collector 的 k8sattributes processor 所负责的工作;如果没有 Kubernetes 元数据,使用主机 ID 或容器 ID 也可以采用相同的方式。

从上面的结果来看,第一行正好是 1,000,因为这是子查询的 LIMIT,因此它描述的是样本规模,而不是故障规模。这个样本中的每个追踪 ID 都包含 cart 的 deadline 日志,而这正是你开始调查时已经知道的症状。
看看第二行。product-catalog 在相同的 1,000 个追踪 ID 中,有 946 个请求遭到了超大 payload 的拒绝,而且它从未出现在 Pod 子查询中:它的 Pod 内存峰值只有其内存限制的 75%,远低于 95% 的阈值。这次部署开始发送更大的 payload,这可以同时解释 cart 的内存增长和这些故障。Checkout 的重试预算在大约一半的请求中耗尽,这就是故障最终对用户可见的原因。
如果直接根据承受压力的 Pod 来过滤日志,你会看到 cart 的日志,却看不到 product-catalog 的日志。也就是说,这种方式会确认症状,却掩盖原因。如果不使用子查询,就需要执行三个查询并手动构建两个列表,而第二个列表包含 1,000 个追踪 ID。通常到了这一步,人们就会在第一个跳转之后停下来,并认定 cart 是原因。这里第二次跳转之所以成本很低,是因为三个信号都位于同一个数据存储中,并通过相同的查询语言进行访问,因此从 Pod 扩展到追踪,再扩展到追踪中涉及的所有服务,只需要增加一个查询条件,而不需要开展一个完整的项目。
为什么 ES|QL 子查询对 AI agent 很重要?
将中间结果保留在集群内部,对人来说很方便,而对于代表你执行查询的 agent 来说,这几乎是必需的。
如果拆分成两次工具调用,中间结果就必须传递出去。500 个追踪 ID 的列表会作为工具响应返回,占用模型的上下文,然后 agent 还必须将其中每一个追踪 ID 重新写入下一次查询。这会在每一次跳转中消耗 token,也是发生截断和抄写错误的地方。使用子查询时,中间结果会保留在 Elasticsearch 内部,而 agent 始终只需要看到最终表格。
当信号分散在不同系统中时,问题会进一步加剧。此时 agent 需要分别获取每个系统的凭证和客户端,并掌握每个系统的查询语言,同时还需要判断如何关联那些对同一个主机使用不同名称的结果。一个数据存储和一种查询语言,可以将这些工作简化为 agent 只需要掌握的一项 skill。
一个 ES|QL 字符串本身也是对整个关联过程的完整描述,因此可以让调查过程具有可复现性:agent 可以将查询放入其摘要中,而人类可以将查询粘贴到 Discover 中,并针对当前数据获得相同的逻辑结果。中间包含硬编码主机列表的两次调用序列则无法做到这一点。需要注意的一点是,调用 _query API 的 agent 必须自行过滤 @timestamp,因为此时没有任何机制会绑定时间选择器。
编写 ES|QL IN 子查询前需要了解的四件事
IN 子查询目前在 Elastic Stack 9.5 和 Elastic Cloud Serverless 中处于技术预览阶段,而 TS 和 FORK 自 9.4 起已经正式发布。CATEGORIZE 自 9.1 起已经正式发布,并且需要 Platinum 许可证;如果改为根据已有字段进行分组,上面的每个查询都可以在没有该许可证的情况下运行。
在编写自己的查询之前,有四点值得了解:
-
在 9.5 中,子查询只返回一列,这就是每个示例末尾的
KEEP所实现的功能。 -
在返回结果之前,通过
STATS ... BY将子查询聚合为不重复的值。其结果会被物化,供外层查询进行过滤,因此返回几百个主机名,而不是几百万行,既更快也更安全。 -
对任何
NOT IN子查询都要过滤掉 null,因为 SQL 的 null 语义意味着只要存在一个 null,该谓词就不会匹配任何内容。 -
子查询是不相关的。它们独立运行,无法引用外层查询中的列,因此这是一种集合过滤,而不是逐行关联。当你需要逐行丰富数据时,应使用 LOOKUP JOIN,我们在《使用 ES|QL joins 实现更丰富的可观测性》中介绍过这一点。
在你自己的数据上尝试 ES|QL 信号关联
这四个示例背后的模式都是相同的。你从一个可以在某种信号中描述的数据集合开始,同时提出一个只能在另一种信号中回答的问题,然后由子查询替你将这个数据集合跨越信号边界。
语法只是实现这一点较小的一部分。之所以能够实现,是因为日志、指标和追踪数据都位于一个数据存储和一个查询引擎之后,并且保留了数据写入时所使用的字段名称,因此从一种信号跨越到另一种信号,只需要在查询中增加一个条件,而不需要构建和维护一个集成方案。如果不是这样,同样的四个调查就会变成一连串的数据导出、转换和手动关联,而这带来的代价不仅仅是更慢的答案。它会悄无声息地减少人们愿意提出的问题数量,而否定性结果会最先被放弃。
这里的每种模式都将两到三个查询替换为一个查询,并且不需要在查询之间传递主机列表或追踪 ID 列表。更少的步骤意味着更少的出错点,同时你还可以将关联过程保存为一个字符串并交给其他人。
要尝试:
-
打开 Elastic Cloud Serverless 上的 Observability 项目,或者升级到 Elastic Stack 9.5。
-
使用 Elastic Distributions of OpenTelemetry 发送数据,或者将现有的 collector 指向 Elasticsearch。
-
在 Discover 中切换到 ES|QL,并从上面的指标到日志查询开始,将其中的数据流和阈值替换为你自己的数据。
-
阅读 IN 子查询参考,了解可以在子查询中使用的完整命令集合。
原文:https://www.elastic.co/observability-labs/blog/esql-subqueries-correlate-logs-metrics-traces
855

被折叠的 条评论
为什么被折叠?



