AI 工程实践

OpenTelemetry 重复投递实验:Jaeger 已写入但响应丢失之后

在 Collector 与 Jaeger 之间注入提交后断连:第一次 Jaeger 200 且 trace 已可查询,响应随后被强制断开,Collector 重投同一 payload;对比 Collector、Jaeger 和 Badger 查询三个证据面。

发布:2026/07/15更新:2026/07/15
应用只发送一次三 span trace,Jaeger 第一次返回 200 且查询可见后响应被断开,Collector 以相同 payload 重试;Collector 计三、Jaeger 接收六、最终查询三个唯一 span
图 1:2026-07-15 提交后响应丢失实验。Jaeger 的接收计数证明重复 delivery,Badger 查询的 3 unique 只是本版本、同一稳定 ID 下的观察结果,不是 exactly-once 合同。

时间与证据

上一篇 OpenTelemetry 队列容量实验验证了一个相对清楚的失败:queue 满时第三个请求收到 503,Collector 的 accepted 不增加,Jaeger 恢复后 query 仍为 404。调用方知道请求没有被接受,可以在预算内重试。

更危险的是另一个经典窗口:后端已经提交,成功响应却在返回途中丢失。 Collector 看到的是网络错误,不知道 Jaeger 是否写入;若不重试可能丢数据,若重试又可能重复。这不是 OpenTelemetry 特有问题,而是所有异步队列、Webhook、支付回调、Agent 工具写操作都会遇到的提交歧义。

2026 年 7 月 15 日,我新增 labs/otel-commit-ambiguity/。它在 Collector 与 Jaeger 之间启动一个最小故障代理:第一次收到 Collector 的 OTLP 请求后完整转发,等待 Jaeger 返回 HTTP 200,再把 Collector 连接保持 3 秒。在这 3 秒内,runner 必须先从 Jaeger query API 查到 3 个 spans;随后代理才销毁 socket。Collector 因未收到响应而自动重试,代理第二次正常转发响应。

固定结果位于 labs/otel-commit-ambiguity/results/2026-07-15-results.json,文件 SHA-256 为 0f467578e373ff928791e46cd6870f4cd98ffabf2de9833d28d59efe380aeab3,加入自引用字段前的 payload SHA-256 为 509ef30d8ad788d78595dc42dfa23d8483b25fb88db2d137e3e86a5fc0b7a8ba。10/10 工件断言覆盖镜像与输入身份、单次应用请求、查询先于 reset、相同 payload 重试、Collector 与 Jaeger counter 差异、最终 span identity、父子树和工件隐私。

证据等级保持 reproduced。真实运行的是 Collector persistent queue、retry sender、OTLP/HTTP、故障代理、Jaeger receiver、Badger storage exporter 与 query API;输入仍是三条 deterministic synthetic spans,没有真实 Agent 或客户流量。

为什么“先查询,再断开”是决定性证据

若代理只是把第一次响应丢掉,然后最终看到 Collector 重试,仍无法证明第一次已经写入。它可能在 Jaeger 接收前断开,也可能 Jaeger 处理失败。本文把第一次响应拆成四个可观察阶段:

Collector -> fault proxy -> Jaeger
                         <- HTTP 200
runner -> Jaeger query   -> 3 spans
fault proxy -X-> Collector response socket
Collector -> fault proxy -> Jaeger retry

代理只在完整读取 Jaeger 200 与两字节 protobuf response 后记录第一条事件。它随后等待 3000 ms,不向 Collector 返回任何字节。runner 在这段时间查询固定 trace ID,必须得到一根两子的 3-span tree;工件此时保存 resetIssued=false。只有这个负载已经可查,代理才执行 socket reset。

这条顺序排除了“第一次根本没提交”的解释。它也没有把 Jaeger 200 单独当作持久化证明:最终结论同时依赖 query API 的可见对象。

实验设计

应用只发送一次

上游 sender 使用原生 fetch,只向 Collector 发一次 OTLP/HTTP JSON:

输入 固定值
trace f100...0001
root span 8100...0001
children 8200...0001, 8300...0001
application requests 1
application automatic retries 0

应用收到 HTTP 200 与 {"partialSuccess":{}}。这表示 Collector receiver 与 persistent queue 接受 3 spans,不表示 Jaeger 已经完成。后续第二次 delivery 完全来自 Collector retry,不是应用重发。

Collector 固定 10-batch bbolt queue、fsync=true、单 consumer,并给 exporter 设置 10 秒 timeout。retry 初始间隔 300 ms、最大 500 ms、总窗口 10 秒。3 秒 hold 小于 exporter timeout,因此第一次失败由代理主动 reset 触发,不是 timeout 到期。

代理只保存摘要,不保存遥测正文

Collector exporter 实际发出的是 gzip 压缩的 application/x-protobuf。代理把请求当作 opaque bytes,只记录:

attempt
requestBytes
requestSha256
contentType / contentEncoding
backendStatus
backendResponseBytes
responseMode
resetIssued

它不解析或写入 raw protobuf。两次请求均为 329 bytes,SHA-256 都是 260e905831963abc83d1de9c5105e6f58fe0c44ba93d4fa261b19169cfd607d6。这比仅比较 trace ID 更强:Collector 重试的是逐字节相同 payload,不是重新生成了一个语义近似请求。

三个 metrics 面不能合并

实验同时读取:

  1. Collector 自身 metrics:入口接受、逻辑 exporter 成功、queue 终态。
  2. Jaeger 进程内 OTLP receiver 与 storage exporter metrics:实际到达后端的 span 次数。
  3. Jaeger query API:Badger 最终返回的对象与唯一 span ID。

Collector 与 Jaeger 都使用 otelcol_* 指标名,但它们属于不同进程。若 Prometheus 抓取后丢掉 service/instance 维度,两个证据面会被错误相加,反而无法解释。

结果

同一 payload 确实到达 Jaeger 两次

代理的两条事件:

attempt bytes SHA-256 Jaeger response 对 Collector
1 329 260e...07d6 200 hold 3 秒后 reset
2 329 260e...07d6 200 正常 relay

第一条事件写入时 resetIssued=false,同时 query 已返回 3 spans;最终事件中第一条变为 resetIssued=true,第二条 responseMode=relayattemptCount=2requestPayloadsIdentical=true

这证明重复 delivery 已发生。不能因为最终 query 没有重复,就把传输层描述成 exactly-once。

Collector 只报告一次逻辑成功

Collector 最终 metrics:

accepted 3 · refused 0
sent 3 · failed 0
queue 0/10 · enqueue failed 0

sent 3 表示 queued request 最终成功完成,不是底层 HTTP 只发送了一次。第一次网络错误被 retry sender 吸收,第二次成功后逻辑 batch 完成,因此 failed=0。如果只看 Collector dashboard,系统看起来完全正常:无失败、无积压、发送量等于接收量。

这正是提交歧义的可观测性陷阱。生产需要在 downstream 或代理层记录 attempt/delivery,不能把 exporter sent 当作线上的传输尝试数。

Jaeger 计 6,最终查询只有 3

Jaeger 进程的 metrics:

receiver accepted 6
storage exporter sent 6

即 Jaeger 侧是 accepted 6 · sent 6,而不是 Collector 面板中的 3。

同一个 3-span payload 到达两次,所以 receiver 与 storage pipeline 均处理 6 spans。最终 query API 却返回:

final spans    = 3
unique span ID = 3
duplicate      = 0
tree           = 1 root + 2 direct children

按封面中的同一口径,最终查询为 3 / 3 unique0 duplicate

在固定 Jaeger 2.19.0、Badger、同一 trace/span ID、相同时间与相同 payload 的条件下,第二次写入没有让查询结果出现六条。这可以描述为“本轮最终查询按稳定身份保持三条”,不能描述为通用去重保证。

上游 Badger 数据模型用于解释固定后端的存储边界,但本文不把实现细节升级为跨版本合同。换成 Elasticsearch、Kafka、ClickHouse、另一个 Jaeger 版本,或让第二次 payload 的时间、属性、span ID 发生变化,都可能产生不同终态。

为什么这仍然不是 exactly-once

“后端接收两次、查询三条”至多说明本轮后端最终对象没有重复。Exactly-once 需要定义对象与副作用边界:

  • 如果对象是 Jaeger trace/span identity,本轮观察到稳定终态。
  • 如果对象是 OTLP delivery,明确发生了两次,不是 exactly-once。
  • 如果对象是计费 counter,Jaeger metrics 已经计了 6,不是 3。
  • 如果对象是告警、Webhook、流式消费或业务写入,下游可能在去重前触发两次副作用。
  • 如果第二次内容不同,同一 ID 是覆盖、冲突还是保留两个版本,需要后端合同决定。

因此工程表述应是:传输采用可重试的至少一次路径,最终对象是否幂等由稳定身份、后端键和副作用设计共同决定。

对 Agent 工具调用更不能把 trace 去重当业务幂等。一次付款、工单更新或代码合并的权威 key 应存在业务系统;trace ID 只用于关联。若业务操作在响应丢失后被自动重放,必须先 query/reconcile 权威对象,而不是因为观测后端能去重就假定副作用安全。

复现与验证

要求 Docker Engine 与 Compose 可用,并允许容器通过 host.docker.internal 访问宿主机故障代理。本机端口 27318261332997827418294182968629788 未占用:

npm ci
npm run lab:otel-ambiguity:run
npm run lab:otel-ambiguity:test

默认 replay 输出到 /tmp/younis-ai-lab-otel-commit-ambiguity.json。runner 每次删除旧 volumes,启动 host Node proxy 与固定 digest 容器,执行 query-before-reset 合同;无论成功或失败,finally 都停止代理并删除实验容器、network 与 volumes。

固定工件保存 compose、Collector config、Jaeger config、fault proxy、sender 与 runner 六个输入 SHA-256。原始 OTLP bytes、container ID、mountpoint、host gateway 地址和临时路径不进入报告。

失败与边界

第一,只有一次完整 3-span batch 重试。没有部分 batch 接受、逐 span 拒绝或多个并发 batch 乱序。

第二,代理模拟响应丢失,不模拟 Jaeger 进程崩溃。Jaeger 在两次 delivery 之间持续健康,Badger volume 与 host 全程存在。

第三,query-before-reset 使用 3 秒证据窗口。它是确定顺序的测试工具,不是延迟 Benchmark;不能把该值当生产 timeout 建议。

第四,只验证相同 bytes。如果 retry 中 resource attributes、时间戳或身份变化,最终行为可能不同。

第五,Jaeger 2.19.0 Badger 不是所有后端。本轮 3 unique 不适用于其他 storage、版本、索引或消费副作用。

第六,Collector counter 隐藏尝试次数。本文用代理与 Jaeger metrics 补证;生产若没有 downstream attempt telemetry,无法从 sent=3 反推出两次 delivery。

第七,没有多租户公平与容量压力。queue 为 10 batches,只处理一个 item,没有与上一实验的 queue full 组合。

第八,没有跨 Collector 重启。persistent queue 已启用,但本轮 retry 在同一进程完成;上一篇覆盖 SIGKILL 恢复。

第九,host fault proxy 是实验组件。它不是生产 sidecar、service mesh 或网络故障平台,未做 TLS、认证、限流或高可用。

第十,没有真实业务副作用。三条 synthetic spans 不能证明支付、工单、审批或代码写操作幂等。

商业价值

提交后响应丢失是高价值 Agent 最危险的运行状态之一。模型可以生成正确计划,工具也可能成功执行,但网关超时后 Agent 只看到“失败”。若它盲目重试,可能重复付款、重复建单或覆盖新版本;若它不重试,用户又会认为任务没完成。

可交付的控制面应把操作分成三类:

状态 证据 动作
known not committed 调用前失败或权威查询确认不存在 在预算内重试
known committed 权威对象存在且意图一致 返回已有结果,不重放
unknown 请求已发出但无可靠终态 query/reconcile,必要时人工接管

OpenTelemetry trace 可以解释每次尝试与时间线,却不应成为业务终态。商业系统需要稳定 idempotency key、预期版本、权威查询 API、冲突处理、人工接管队列和独立审计。愿意付费的通常是允许 Agent 操作外部系统、又必须控制重复副作用与合规风险的团队。

成本主要来自持久 queue、下游 attempt telemetry、幂等存储、reconciliation worker、人工处理和故障演练。收益不是“让所有调用成功”,而是把未知终态从不可见事故变成可定位、可核对、可收敛的状态。

从全栈工程迁移到 AI 系统工程

全栈基础 本实验对应能力
HTTP 与网络 backend 200、response reset 与调用方未知终态
消息队列 persistent request、retry sender 与逻辑成功计数
数据库 稳定键、重复写入与最终对象核对
可观测性 Collector、Jaeger receiver、storage、query 四层证据
幂等接口 相同 payload、稳定 trace/span ID 与业务 idempotency key
事务设计 commit 与 acknowledgement 分离
故障注入 query-before-reset 的决定性时序
Agent 安全 未知工具终态、reconciliation 与人工接管

Agent 系统没有消除分布式事务;它只是让重试决策可能由模型触发。模型不能根据一句 timeout 文本判断副作用是否发生,控制面必须提供结构化状态与权威核对路径。

面试表达

**30 秒版本:**我在 OpenTelemetry Collector 与 Jaeger 之间做了提交后断连实验。应用只发送一次 3-span trace;代理第一次收到 Jaeger 200 后保持响应,runner 先用 query API 证明 3 spans 已可查,再 reset Collector socket。Collector 用逐字节相同的 329-byte gzip protobuf 重试。Collector 最终只显示 accepted 3、sent 3、failed 0,但 Jaeger 实际 accepted/sent 都是 6;Badger query 返回 3 unique。它证明重复 delivery 已发生,也证明最终无重复不等于 exactly-once。

**3 分钟版本:**实验固定 Collector、Jaeger 与 init image digest,bbolt queue 开启 fsync,单 consumer。故障代理不解析 payload,只保存 bytes、SHA、content encoding 与 backend status。第一次 backend 200 后给 3 秒窗口,必须 query 到一根两子的 trace,再断开响应;第二次正常 relay。两次 payload SHA 完全一致。跨层计数揭示 Collector sent 是逻辑 item 成功,不是 wire attempts;Jaeger receiver 计 6,最终 query 计 3。生产要用稳定业务 key、权威 query、reconciliation 和人工接管处理 unknown,而不是依赖 trace 后端去重。

复盘

本轮最重要的设计是让查询发生在 reset 之前。没有这个顺序,只能证明“Collector 重试了”,不能证明第一次已经提交。把故障窗口暂停 3 秒,才得到明确的时间证据:后端对象存在,确认尚未到达调用方。

第二个发现是健康的 Collector dashboard 会隐藏重复尝试。accepted=3 / sent=3 / failed=0 / queue=0 看起来完美,但 Jaeger 已处理 6 spans。业务上若计费、告警或副作用发生在去重之前,这个差异会直接产生损失。

第三个发现是稳定 ID 有价值,但不能被宣传成 exactly-once。固定 Jaeger Badger 最终只返回三条,说明同身份重放在本轮收敛;下一步仍要测试部分 batch 提交、第二次 payload 变化、多个 consumer 与另一种后端,并把业务对象的幂等终态纳入同一证据链。

方法披露

本文由 AI 工具协助设计故障时序、审查代码、组织文字与绘制原创 SVG。镜像 digest、非 root 用户、proxy hold/reset 顺序、query-before-reset、两次 payload bytes 与 SHA、Collector/Jaeger metrics、Badger 查询、工件哈希和 Docker 清理均由本站对照实际运行逐项核验。

实验没有调用模型、向量数据库、外部 Agent、客户系统或生产遥测后端。固定 trace 不含 prompt、用户信息、凭据或真实 transaction。故障代理只保存 payload 哈希和长度,不保留 protobuf 正文。

上游完整 commit 用于解释 queue、retry、metrics 与 Badger 边界;应用 1 次、代理 2 次、Jaeger 6 spans、查询 3 unique 只依据本仓库实际 capture。桌面 SVG、独立移动 SVG 与 PNG 只表达固定状态链,不伪造吞吐、SLO 或生产 exactly-once 能力。

修订记录

  • 2026-07-15:初版发布;完成 Jaeger 提交后响应断连、query-before-reset、byte-identical Collector retry、三层 counter 对照与 10/10 工件验证。

Reusable projects

关联可复用项目

本文已经进入以下工程项目;项目页提供固定版本、运行命令和结果工件。

Source ledger

来源账本

以下来源用于核对事实、日期与当时可用范围。厂商自报性能不视为本站独立复现。

  1. OpenTelemetry Collector exporter helper retry and persistent queue documentation at v0.156.0 commit
    OpenTelemetry官方文档文章资料来源发布:2026/07/06本站核验:2026/07/15
  2. Collector exporter helper queue sender implementation at v0.156.0 commit
    OpenTelemetry官方仓库文章资料来源发布:2026/07/06本站核验:2026/07/15
  3. Collector exporter helper retry sender implementation at v0.156.0 commit
    OpenTelemetry官方仓库文章资料来源发布:2026/07/06本站核验:2026/07/15
  4. OpenTelemetry Collector internal telemetry documentation at v0.156.0 commit
    OpenTelemetry官方文档文章资料来源发布:2026/07/06本站核验:2026/07/15
  5. Jaeger Badger storage data model at v2.19.0 commit
    Jaeger官方文档文章资料来源发布:2026/06/03本站核验:2026/07/15
  6. Jaeger Badger configuration source including sync writes
    Jaeger官方仓库文章资料来源发布:2026/06/03本站核验:2026/07/15
  7. Jaeger v2.19.0 query extension documentation
    Jaeger官方文档文章资料来源发布:2026/06/03本站核验:2026/07/15

讨论

正在加载评论...