错误最多的服务运行正常:使用 ES|QL 从日志进行根因分析

作者:来自 Elastic Jeffrey Rengifo

指标显示三个服务的错误率完全相同,均为 19.1%。针对同一批 4,688 条 OpenTelemetry 日志记录的四条 ES|QL 查询,将 956 条故障链中的 955 条追溯到了其中一个服务,而且无需任何服务依赖模型。

Elasticsearch 会在数据摄取时将原始日志转换为结构化、可搜索的数据。按照收集和分析日志教程,端到端地了解整个过程。立即开始免费云试用,或者现在就在你的本地机器上尝试 Elastic。


指标显示三个服务的错误率完全相同,均为 19.1%,因此必须通过日志进行根因分析来确定排序。按照错误日志的数量进行排序后,catalog-api 以 1,820 个错误位居首位,尽管它对收到的每个请求都返回了 200。针对同一批 4,688 条日志记录执行四条 ES|QL 查询后,发现真正的问题是 ledger-service:956 条故障链中有 955 条始于该服务,其连接池大小为 8,但可用连接数为 0。整个过程无需服务依赖模型,也无需在故障事件之间维护任何内容,只需要每条记录中都有一个 trace.id。

在本文中,我们使用 Elastic Distribution of OpenTelemetry(EDOT) 为四个 Python 服务添加 instrumentation,使其中一个服务达到饱和状态,然后使用 ES|QL 找出最先发生故障的服务。指标会告诉你出了问题;而日志则会告诉你为什么出问题,前提是你按照请求而不是按照服务对日志进行分组。

前置条件

  • Elasticsearch 和 Kibana 9.1 或更高版本。

  • Python 3.10 或更高版本。

  • Elastic Cloud Managed OTLP 端点及其 API 密钥。Kibana 会在 Add data > OpenTelemetry 下显示这两项。

配套笔记本会重现此故障事件,并运行下面的每一条查询,因此你可以使用自己的集群跟随操作。

从日志进行根因分析:为什么错误计数无法构建因果图

因果图是一种有向图,其节点是发生故障的组件,边则记录哪个故障导致了哪个故障。读取因果图的过程是机械性的:从用户报告问题的服务开始,沿着边反向追踪,而那个无法继续向后追溯的节点就是根因。

因果边可以根据服务依赖模型预先声明,该模型涵盖模型能够预见的每一种故障,并且随着 架构 发生变化,需要与架构同步维护。本文则采用另一种方式:事后从日志字段中推导因果边。这种方式针对的是单个故障事件,而不是所有可能的故障,并且无需维护任何模型。

仅靠错误计数只能得到节点,无法得到边。级联中的每个服务都会针对每个失败请求记录一个错误,因此它们的错误计数会趋于一致。与此同时,一个健康的服务如果在常规事件中记录错误,只要该事件足够常见,其错误数就会超过所有这些服务。在本实验中,catalog-api 接收的流量少于 checkout-api,但记录的错误几乎是后者的两倍,因为它超过一半的请求都会报错,而级联中的服务只有五分之一的请求报错。

trace.id 字段提供了错误计数无法提供的结构:

  • 具有相同 trace.id 的两个错误属于同一个请求,因此其中一个可能是另一个的原因。

  • 具有不同 trace.id 的两个错误彼此无关,无论它们的时间戳有多接近。

使用 EDOT 进行 instrumentation 的四个 Python 服务

checkout-api 接收订单并调用 payment-gateway,后者调用 ledger-service,而 ledger-service 使用一个固定的、包含 8 个数据库连接的连接池。catalog-api 位于这条路径之外,在每次图像缓存未命中时记录一个 ERROR,但仍然返回 200。

按照文档中的设置说明,安装 EDOT Python 以及正在使用的库所需的 instrumentation:

ini 复制代码
`

1.  pip install elastic-opentelemetry flask requests
2.  edot-bootstrap --action=install

`AI写代码

edot-bootstrap 会检查已安装的软件包,并添加匹配的 instrumentation,包括 opentelemetry-instrumentation-logging,该组件会将 trace 上下文附加到日志记录中。

每个服务都是使用标准库 logger 编写的普通 Flask 代码。下面是 ledger-service,也就是发生故障的服务:

kotlin 复制代码
`

1.  POOL_SIZE = 8
2.  ACQUIRE_TIMEOUT_S = 0.25

4.  pool = threading.BoundedSemaphore(POOL_SIZE)
5.  state = {"hold_ms": 15}

7.  @app.post("/reserve")
8.  def reserve():
9.      order_id = request.json.get("order_id")
10.      if not pool.acquire(timeout=ACQUIRE_TIMEOUT_S):
11.          logger.error(
12.              "connection pool exhausted, no connection available after %.0fms",
13.              ACQUIRE_TIMEOUT_S * 1000,
14.              extra={
15.                  "db.connection_pool.size": POOL_SIZE,
16.                  "db.connection_pool.available": 0,
17.                  "error.kind": "pool_timeout",
18.                  "order.id": order_id,
19.              },
20.          )
21.          return jsonify({"error": "pool_timeout"}), 503
22.      try:
23.          time.sleep(state["hold_ms"] / 1000.0)
24.          return jsonify({"reservation_id": f"res-{order_id}"}), 200
25.      finally:
26.          pool.release()

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

ledger-service 文件中不包含任何 OpenTelemetry 代码。extra 中的每个键都会成为 Elasticsearch 中可搜索的字段。

两个调用方会添加一个字段,其他服务则不会。当 payment-gateway 因其依赖服务发生故障而失败时,它会记录具体是哪个依赖服务:

bash 复制代码
`

1.  logger.error(
2.      "ledger rejected reservation with status %s, cannot authorize payment",
3.      resp.status_code,
4.      extra={
5.          "error.kind": "authorization_failed",
6.          "upstream.service": "ledger-service",
7.          "upstream.status_code": resp.status_code,
8.          "order.id": order_id,
9.      },
10.  )

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

将服务指向你的 OTLP 端点,并通过 opentelemetry-instrument 启动它们:

ini 复制代码
`

1.  export OTEL_EXPORTER_OTLP_ENDPOINT="https://<your-managed-otlp-endpoint>"
2.  export OTEL_EXPORTER_OTLP_HEADERS="Authorization=ApiKey%20<your-api-key>"
3.  export OTEL_RESOURCE_ATTRIBUTES="service.

5.  opentelemetry-instrument python ledger_service.py

`AI写代码

OTEL_EXPORTER_OTLP_HEADERS 使用 URL 编码,因此 ApiKey 后面的空格必须写成 %20。

流量生成器每秒产生大约 12 个结账请求和 8 个产品查询,然后将 ledger 连接的占用时间从 15ms 提高到 900ms。在 8 个连接且每个连接占用 900ms 的情况下,该服务每秒大约只能处理 8.9 个请求,因此连接池会达到饱和,请求在获取连接时超时。没有任何服务返回硬编码的错误。记录的运行过程包括 2 分钟基线、4 分钟饱和和 1 分钟恢复,共产生 4,053 次成功结账和 956 次失败。

trace 关联的日志记录包含什么?

将 OTLP 发送到 Managed OTLP 端点后,日志会以原生 OpenTelemetry 结构存储在 logs-generic.otel-default 中,不进行任何 schema 转换。下面是本次运行中的一条 ERROR 记录,其中省略了 scope 和主机元数据:

bash 复制代码
`

1.  {
2.    "@timestamp": "2026-07-26T09:03:45.139Z",
3.    "resource": {
4.      "attributes": {
5.        "service.name": "ledger-service",
6.        "deployment.environment": "obs-labs-causal-graph"
7.      }
8.    },
9.    "trace_id": "f9b32dca62c1e142537413a047fb9b69",
10.    "span_id": "c4548cab42cbdd15",
11.    "severity_text": "ERROR",
12.    "body": { "text": "connection pool exhausted, no connection available after 250ms" },
13.    "attributes": {
14.      "error.kind": "pool_timeout",
15.      "order.id": "10c9e056ae8c",
16.      "db.connection_pool.size": 8,
17.      "db.connection_pool.available": 0
18.    }
19.  }

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

三个细节决定了这些查询可以实现什么:

  • trace_id 和 span_id 无需任何应用代码进行设置即可产生,因为 logging instrumentation 会将活动 span 上下文附加到请求中产生的日志记录。

  • extra 中的每个键都会保留原有的点号,并写入 attributes 下,而 Elasticsearch 会使用其短名称公开每个字段。代码写入的字段 upstream.service,就是查询使用的字段。资源属性也是如此,因此 service.name 和 deployment.environment 可以按照原样进行查询。

  • ECS 名称仍然可以解析。trace.id、span.id、log.level 和 message 是 trace_id、span_id、severity_text 和 body.text 的内置别名,因此下面的查询会使用可读性更好的名称。

接下来的四个步骤会逐步为图添加一个部分,从没有边的节点开始:

第 1 步:按故障率和错误量对服务进行排名

首先,计算每个服务的故障率:

sql 复制代码
`

1.  FROM traces-*.otel-*
2.  | WHERE @timestamp >= "2026-07-26T08:59:00.000Z" AND @timestamp < "2026-07-26T09:07:00.000Z"
3.      AND deployment.environment == "obs-labs-causal-graph" AND kind == "Server"
4.  | STATS failed = COUNT(*) WHERE status.code == "Error", total = COUNT(*) BY service.name
5.  | EVAL failed_pct = ROUND(100.0 * failed / total, 1)
6.  | SORT failed_pct DESC

`AI写代码
less 复制代码
![](https://i-blog.csdnimg.cn/direct/a3418432c0dd4450aef87fae4637c3be.png)

checkout-api、ledger-service 和 payment-gateway 的故障率均为 19.1%,每个服务都有 956 次故障。由于在同步调用链中,当最深层的调用发生故障时,每一跳都会失败,因此这些故障率完全相同。指标只能指出受影响的服务,却无法对它们进行排序。

现在,在相同时间窗口内,统计每个服务的 ERROR 记录数:

sql 复制代码
`

1.  FROM logs-*.otel-*
2.  | WHERE @timestamp >= "2026-07-26T08:59:00.000Z" AND @timestamp < "2026-07-26T09:07:00.000Z"
3.      AND deployment.environment == "obs-labs-causal-graph" AND log.level == "ERROR"
4.  | STATS errors = COUNT(*), traces = COUNT_DISTINCT(trace.id) BY service.name
5.  | SORT errors DESC

`AI写代码
less 复制代码
![](https://i-blog.csdnimg.cn/direct/2d272c5d248e473aafe2cc528d5d7d6b.png)

catalog-api 以 1,820 条错误记录位居首位,几乎是其他任何服务的两倍,而且它的 3,345 个请求全部返回了 200。按照错误数量进行排序,实际上是按照日志记录的详细程度对服务进行排名,而这与故障从哪里开始毫无关系。

第 2 步:按 trace ID 对错误日志进行分组

按 trace.id 进行分组,并统计每个请求中记录错误的不同服务数量:

sql 复制代码
`

1.  FROM logs-*.otel-*
2.  | WHERE @timestamp >= "2026-07-26T08:59:00.000Z" AND @timestamp < "2026-07-26T09:07:00.000Z"
3.      AND deployment.environment == "obs-labs-causal-graph" AND log.level == "ERROR"
4.  | STATS services = COUNT_DISTINCT(service.name) BY trace.id
5.  | STATS traces = COUNT(*) BY services
6.  | SORT services ASC

`AI写代码
less 复制代码
![](https://i-blog.csdnimg.cn/direct/f37be56c257a4db8be96711121af7721.png)

这批数据分成两类。只有一个服务发生错误的 trace 是局部问题,没有向外传播;有三个服务发生错误的 trace 则属于级联故障。

Trace 类型 Trace 数量 记录错误的服务
单服务,从未传播 1,820 catalog-api
三服务级联 956 checkout-api、ledger-service、payment-gateway

catalog-api 没有出现在任何级联故障中,因此该查询会在无需任何人阅读消息的情况下,将这个错误量最高的服务排除在调查之外。

第 3 步:找出每个 trace 中最先发生故障的服务

在级联故障中,最先记录错误的服务就是候选根因。ES|QL 没有窗口函数,因此我们构建一个由时间戳和服务名称组成的可排序字符串,在每个 trace 中取最小值,然后再将服务名称截取出来:

sql 复制代码
`

1.  FROM logs-*.otel-*
2.  | WHERE @timestamp >= "2026-07-26T08:59:00.000Z" AND @timestamp < "2026-07-26T09:07:00.000Z"
3.      AND deployment.environment == "obs-labs-causal-graph" AND log.level == "ERROR"
4.  | EVAL marker = CONCAT(DATE_FORMAT("yyyy-MM-dd HH:mm:ss.SSS", @timestamp), "|", service.name)
5.  | STATS origin_marker = MIN(marker), services = COUNT_DISTINCT(service.name) BY trace.id
6.  | WHERE services > 1
7.  | EVAL origin_service = SUBSTRING(origin_marker, 25)
8.  | STATS cascade_traces = COUNT(*) BY origin_service
9.  | SORT cascade_traces DESC

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

从 8.16.0 开始,MIN() 接受 keyword 字段,而格式化后的时间戳固定为 23 个字符,因此最早的标记在字典序上也是最小的标记。SUBSTRING(origin_marker, 25) 会跳过时间戳和分隔符。

ledger-service 是 956 条级联故障中 955 条的起点,checkout-api 是其中 1 条的起点。

跨服务的时间戳排序有多可靠?

不要假设它可靠,而是进行测量:

less 复制代码
`

1.  FROM logs-*.otel-*
2.  | WHERE @timestamp >= "2026-07-26T08:59:00.000Z" AND @timestamp < "2026-07-26T09:07:00.000Z"
3.      AND deployment.environment == "obs-labs-causal-graph" AND log.level == "ERROR"
4.  | EVAL ms = DATE_FORMAT("yyyy-MM-dd HH:mm:ss.SSS", @timestamp)
5.  | STATS spread_ms = DATE_DIFF("milliseconds", MIN(@timestamp), MAX(@timestamp)),
6.          distinct_ms = COUNT_DISTINCT(ms),
7.          services = COUNT_DISTINCT(service.name) BY trace.id
8.  | WHERE services > 1
9.  | STATS cascade_traces = COUNT(*),
10.          median_spread_ms = MEDIAN(spread_ms),
11.          max_spread_ms = MAX(spread_ms),
12.          traces_with_a_collision = COUNT(*) WHERE distinct_ms < services

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

在 956 条级联故障中,第一个错误与最后一个错误之间的时间差中位数为 2ms ,最大值为 16ms,而 259 个 trace (占总数的 27%)至少包含一次毫秒级时间戳碰撞。之所以会如此频繁地发生碰撞,是因为这些调用跳转都是本地 HTTP 调用,而 @timestamp 只能精确到毫秒。

发生碰撞并不意味着结果一定错误。按字母顺序排序时,checkout-api 排在 ledger-service 之前,而 ledger-service 排在 payment-gateway 之前。当 ledger-service 和 payment-gateway 落在同一毫秒内时,MIN() 仍然会返回 ledger-service,而这正是正确的起点。只有 checkout-api 和 ledger-service 之间发生时间戳并列时,结果才会出错。这就是为什么 259 个 trace 存在碰撞,而其中只有一个报告了错误的起点。

在快速调用跳转中,以及主机之间的时钟偏差超过所测量的时间间隔时,时间戳排序的可靠性会下降。应将其视为一个强有力的提示,而不是确定性证据。

第 4 步:根据 upstream.service 构建因果边列表

upstream.service 字段记录调用方正在等待哪个依赖,因此包含该字段的每条错误记录都是一条有向边。使用一个 CONCAT 即可将这些边组合成一个边列表:

less 复制代码
`

1.  FROM logs-*.otel-*
2.  | WHERE @timestamp >= "2026-07-26T08:59:00.000Z" AND @timestamp < "2026-07-26T09:07:00.000Z"
3.      AND deployment.environment == "obs-labs-causal-graph"
4.      AND log.level == "ERROR" AND upstream.service IS NOT NULL
5.  | EVAL edge = CONCAT(upstream.service, " -> ", service.name)
6.  | STATS traces = COUNT_DISTINCT(trace.id), first_seen = MIN(@timestamp), last_seen = MAX(@timestamp) BY edge
7.  | SORT traces DESC

`AI写代码

两条边均在全部 956 条级联故障中被观察到:

markdown 复制代码
`

1.  ledger-service  -> payment-gateway   956 traces
2.  payment-gateway -> checkout-api      956 traces

`AI写代码

这个边列表不使用任何时间戳比较,因此既不需要时钟同步,也不需要处理并列情况。在全部 956 个 trace 中,它都与时间戳方法的结果一致,包括时间戳方法判断错误的那一个。

时间戳排序 声明的上游服务
需要的代码更改 无 每条错误日志增加一个字段
本次运行中的正确结果 956 中有 955 个 956 中有 956 个
失败场景 调用跳转耗时低于 1 毫秒、时钟发生漂移 字段缺失

哪些服务在未指定上游服务的情况下发生故障?

图的根节点就是作为源出现、但从未作为目标出现的节点。通过查询哪些服务在发生故障时没有指定上游服务,可以找出候选根节点:

sql 复制代码
`

1.  FROM logs-*.otel-*
2.  | WHERE @timestamp >= "2026-07-26T08:59:00.000Z" AND @timestamp < "2026-07-26T09:07:00.000Z"
3.      AND deployment.environment == "obs-labs-causal-graph" AND log.level == "ERROR"
4.  | STATS errors = COUNT(*), traces = COUNT_DISTINCT(trace.id),
5.          names_an_upstream = COUNT_DISTINCT(upstream.service) BY service.name
6.  | WHERE names_an_upstream == 0
7.  | SORT errors DESC

`AI写代码

这会返回 catalog-api 和 ledger-service。将其与第 2 步中的级联故障参与服务取交集后,只剩下一个服务:ledger-service。

这两个查询生成的是某个故障事件的传播结构,而不是一个可重复使用的因果模型。这些边不包含任何概率信息,也无法说明该时间窗口内未发生的故障。它们是观测结果,并且只适用于你所查询的这些 trace。

根因服务报告了什么

现在读取该服务报告的内容,同时保留连接池的数值,让证据能够随着故障模式一起传递:

less 复制代码
`

1.  FROM logs-*.otel-*
2.  | WHERE @timestamp >= "2026-07-26T08:59:00.000Z" AND @timestamp < "2026-07-26T09:07:00.000Z"
3.      AND deployment.environment == "obs-labs-causal-graph"
4.      AND log.level == "ERROR" AND service.name == "ledger-service"
5.  | STATS events = COUNT(*), first_seen = MIN(@timestamp), last_seen = MAX(@timestamp)
6.          BY error.kind,
7.             db.connection_pool.size,
8.             db.connection_pool.available
9.  | SORT events DESC

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

在全部 956 个故障事件中,只有一种故障模式:pool_timeout。它贯穿整个四分钟的饱和期,其中 db.connection_pool.size 为 8,db.connection_pool.available 为 0。下一条查询会完整显示与之对应的消息:

arduino 复制代码
`connection pool exhausted, no connection available after 250ms`AI写代码

展开一个三个错误共享同一毫秒时间戳的 trace,可以看到即使不依赖时间戳,整个故障链仍然完整:

sql 复制代码
`

1.  FROM logs-*.otel-*
2.  | WHERE trace.id == "f9b32dca62c1e142537413a047fb9b69"
3.  | KEEP @timestamp, service.name, log.level, message, upstream.service
4.  | SORT @timestamp ASC

`AI写代码
less 复制代码
![](https://i-blog.csdnimg.cn/direct/85d67cbc79b141fe8af83d162a43b3f0.png)

upstream.service 对 ledger-service 显示为 (null),对 payment-gateway 显示为 ledger-service,对 checkout-api 显示为 payment-gateway。

为错误日志添加 trace ID 和结构化字段

这些查询需要你的日志具备三项内容,其中只有一项需要付出额外工作:

  • 每条记录都包含 trace.id。 OpenTelemetry 自动 instrumentation 会为活动 span 内产生的日志自动提供该字段。访问日志和后台线程不在这个范围内。本实验中的 Werkzeug 访问日志没有 trace 上下文,因此无法用于关联分析。

  • 使用结构化字段,而不是格式化字符串。 将 error.kind 作为字段,可以统计不同的故障模式。而将相同的词写在句子中则无法做到这一点。

  • 在错误中标明上游依赖。 当依赖发生故障时,你的服务记录错误日志,只需在其中增加一个键 upstream.service 即可。

本实验复现的模式在生产环境的故障事件中很常见:吸收上游故障所产生的溢出流量的组件,记录的错误往往比真正引发故障的组件更多。同样的排序问题也适用于自动化调查,在这种场景中,trace 范围内的证据可以让根因结论能够通过具体的记录和查询进行验证。

结论:日志证明了什么,而指标无法证明什么

指标报告三个服务都以 19.1% 的故障率发生故障。错误量则报告 catalog-api 存在问题,但实际上它运行正常。将相同的 4,688 条日志记录按 trace.id 分组后,我们隔离出 956 条级联故障,确定 ledger-service 是它们的起点,并返回了解释故障原因的消息。

有两种技术可以得到这一结果。按时间戳排序无需修改代码,在 956 条 trace 中有 955 条判断正确,唯一的错误是由毫秒级时间戳并列造成的。读取声明的 upstream.service 字段则需要在错误日志中增加一个额外的键,并且在每条 trace 中都判断正确。

这两种方法都不需要服务依赖模型,而且如果日志没有 trace.id,两种方法都无法发挥作用。这个字段就是全部投入。

资源

在你自己的数据上尝试从日志进行根因分析

  • 针对你自己的 logs-* 数据运行第 2 步中的查询,并检查有多少故障 trace 包含多个发生错误的服务。

  • 检查一个服务的错误日志,确认其中是否标明了发生故障的依赖。

原文:www.elastic.co/observabili...

相关推荐
Elasticsearch1 小时前
将你自己的密钥用于现有 Elastic Cloud 部署
elasticsearch
IT大白鼠14 小时前
搜索系列 · 第 06 篇——避坑汇总:经典生产问题
elasticsearch·nosql
IT大白鼠18 小时前
搜索系列 · 第 07 篇——调优实战:写入、查询与监控
elasticsearch·nosql
Elasticsearch1 天前
检查 100 个候选项,而不是 1000 万个文档:更快速的 Elasticsearch kNN 过滤
elasticsearch
Elasticsearch1 天前
当 AI agent 群集出现时,银行能跟上吗?
elasticsearch
Sayai2 天前
Elasticsearch 日志检索 DSL 实战:时间范围查询、字段去重、分钟级统计与最新日志获取
大数据·运维·elasticsearch·搜索引擎·日志分析
阿狗童鞋2 天前
Elasticsearch实战指南
大数据·elasticsearch·搜索引擎
IT大白鼠2 天前
搜索系列 · 第 04 篇——部署实操:内网集群落地
运维·elasticsearch·nosql
Elastic 中国社区官方博客2 天前
使用 Jev 作为 search reranker:基准测试与实现方法
大数据·运维·elasticsearch·全文检索