在一个 ES|QL 查询中关联日志、指标和追踪数据

📅 2026/8/20 10:12:35
在一个 ES|QL 查询中关联日志、指标和追踪数据
作者来自 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_numberSystem 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 中并针对当前数据获得相同的逻辑结果。中间包含硬编码主机列表的两次调用序列则无法做到这一点。需要注意的一点是调用_queryAPI 的 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