AI 工程实践

OpenTelemetry 队列容量实验:第 3 个 Span 为什么收到 503

把 Collector persistent queue 缩到 2 batches,实际验证 Jaeger 停机时前两个 OTLP 请求被接受、第三个被明确拒绝,以及恢复后用稳定 trace/span ID 重放的最终查询状态。

发布:2026/07/15更新:2026/07/15
两个单 span 请求以 HTTP 200 填满两批 OpenTelemetry 持久队列,第三个请求收到 503,Jaeger 恢复后只查询到前两条,重放第三条稳定标识后最终三条均可查询
图 1:2026-07-15 persistent queue 容量实验。容量按 exporter requests/batches 计算;本文刻意让每个请求只含一个 span,不能把生产 batch 数直接等同于 span 数。

时间与证据

上一篇 OpenTelemetry 持久恢复实验证明了 7 个 span 可以在 Jaeger 与 Collector 都被 SIGKILL 后,从 100-batch bbolt queue 恢复到 Jaeger。但那次队列只用了 2/100,没有触发容量边界。生产系统真正危险的问题不是“磁盘队列存在吗”,而是:磁盘队列已经满时,新的遥测会等待、返回成功、被静默丢弃,还是明确拒绝?

2026 年 7 月 15 日,我新增 labs/otel-queue-saturation/,把 Collector sending_queue.queue_size 固定为 2,保持 file_storagefsync: true,让 Jaeger 停机,再按顺序发送三个单-span OTLP/HTTP JSON 请求。发送器使用原生 fetch,不自动重试;每个请求都有固定且互不相同的 trace ID 和 span ID。这样可以把客户端观察到的 HTTP、Collector metrics、队列容量与 Jaeger 最终查询逐一对齐。

固定结果位于 labs/otel-queue-saturation/results/2026-07-15-results.json,文件 SHA-256 为 002a85a03d15555fd36105149038977a43d92c158f1efe62bdd9400feb2ae13a,加入自引用字段前的 payload SHA-256 为 7cc70a457c6229f34972826e1f6b5701826e90b8389a9b7ea71cd44de0a522ae。8/8 工件断言检查镜像身份、非 root 用户、输入哈希、容量合同、HTTP 结果、拒绝 metrics、查询负例、稳定 ID 重放和工件隐私。

证据等级仍是 reproduced。真实运行的是 Collector v0.156.0、bbolt file storage、Jaeger v2.19.0 Badger、OTLP/HTTP 与 query API;负载只有三个 synthetic spans,没有真实客户流量,也没有证明生产磁盘、网络或多副本行为。

实验设计

用一请求一 Span 消除 batch 歧义

Collector 配置没有 batch processor:

exporters:
  otlp_http/jaeger:
    endpoint: http://jaeger:4318
    retry_on_failure:
      enabled: true
      initial_interval: 500ms
      max_interval: 2s
      max_elapsed_time: 0s
    sending_queue:
      enabled: true
      num_consumers: 1
      queue_size: 2
      storage: file_storage/queue

queue_size: 2 的单位是 exporter requests/batches,不是 spans。生产中一个 request 可能含 1、5、512 或更多 spans,容量不能只写成“可存 2 条数据”。本实验刻意让每个 OTLP 请求只有一个 span,所以只在本轮配置中形成 1 request = 1 batch = 1 span 的可解释关系。

三个固定身份分别是:

请求 trace ID 前缀 span ID 预期用途
first e100...0001 7100...0001 占用第一个 queue slot
second e200...0001 7200...0001 占用第二个 queue slot
overflow e300...0001 7300...0001 触发容量拒绝并用于后续重放

每次发送都等待 HTTP 完成后才进入下一次,没有并发竞争,也没有客户端自动重试。它不能代表高并发吞吐,却能精确回答第三个请求的归属。

从传输结果追到权威查询

runner 每次从 docker compose down --volumes 开始:

  1. 用固定 digest 启动 storage init、Collector 与 Jaeger。
  2. Jaeger query API 和 Collector health endpoint 就绪后,对 Jaeger 执行 SIGKILL
  3. 顺序发送 first、second,等待 queue_size=2
  4. 发送 overflow,等待 enqueue_failed_spans>=1
  5. 启动原 Jaeger 容器,让 Collector 排空两个已接受请求。
  6. 分别查询三条 trace;overflow 必须为 404。
  7. 只重放 overflow 的同一 trace/span ID,再次查询。
  8. finally 删除实验容器、network 与两个 named volumes。

本轮没有强杀 Collector,也没有验证跨进程 queue recovery;那是上一篇的范围。本轮只改变 queue capacity,避免把 crash recovery、容量拒绝和重复投递混在同一个无法归因的结果里。

结果

第三个请求收到明确的 503

实际 HTTP 结果:

请求 HTTP response body queue 归属
first 200 {"partialSuccess":{}} 已接受
second 200 {"partialSuccess":{}} 已接受
overflow 503 {"code":14, "message":"sending queue is full"} 未接受

第三条不是“返回 200 后在后台丢掉”,而是由 OTLP receiver 向直接客户端返回 503。对应 metrics 从:

accepted 2 · refused 0 · sent 0
queue 2/2 · enqueue failed 0

变成:

accepted 2 · refused 1 · sent 0
queue 2/2 · enqueue failed 1

queue 没有增长到 3,accepted 也没有增长到 3。refused_spans=1enqueue_failed_spans=1 指向同一个被拒绝的单-span request。send_failed_spans 保持 0,因为这里失败发生在 enqueue,不是一个已入队 batch 最终耗尽 exporter retry。

这个区分直接影响告警。只看 send_failed 会漏掉容量拒绝;只看 receiver accepted 又看不到客户端已经收到 503。至少需要同时观察 queue utilization、enqueue failure、receiver refusal 与客户端非 2xx。

恢复后只有已接受的两条可查询

Jaeger 恢复后,Collector 把两个 queue items 送出:

accepted 2 · refused 1 · sent 2
queue 0/2 · enqueue failed 1

Jaeger query 的权威终态为:

trace 恢复后结果
first 1 span
second 1 span
overflow 404

这证明第三条没有因为 Collector 后续恢复而神奇出现。503 是需要调用方处理的明确未接受终态;如果上游忽略响应、只记录“调用过 exporter”,该 span 就会形成永久观测空洞。

只重放被拒绝请求,最终 3/3

发送器随后只重放 overflow,并复用原 trace ID e300...0001 与 span ID 7300...0001。重放返回 HTTP 200,Jaeger 查询得到一条同 span ID 的记录;最终 metrics 为:

accepted 3 · refused 1 · sent 3
queue 0/2 · enqueue failed 1

这里的计数没有抹掉历史拒绝:accepted 和 sent 增加到 3,refused 与 enqueue failed 仍保留 1。运营面板不应把最终 queue=0 解释为“期间没有丢失风险”;容量事件需要单调 counter、日志或事件系统保留历史。

稳定 ID 让重放对象可以关联,却不自动提供 exactly-once。本文只重放了明确收到 503、且查询确认不存在的一条。若后端已经提交但响应丢失,调用方无法仅凭 HTTP 区分“未写入”和“已写入但未确认”;同 ID 重放在不同后端可能覆盖、合并或产生重复。下一类实验必须注入提交后断连,检查后端对象而不是只看 exporter counter。

容量规划应从时间预算反推

固定 queue_size 只是数量,不是恢复目标。生产至少需要三个速率:正常 ingress R_in、故障期间积压速度 R_backlog、恢复 drain R_drain。在 batch 大小相对稳定时,可用下式做第一版预算:

可承受中断时间 ≈ queue_capacity / backlog_batches_per_second
恢复净排空速度 = drain_batches_per_second - ingress_batches_per_second
恢复时间 ≈ queued_batches / 恢复净排空速度

如果 batch 大小波动很大,只看 batch count 会隐藏字节量与 span 数变化;还要记录队列磁盘 bytes、每批 item 数、信号类型和租户分布。本实验没有采集 queue oldest age、磁盘增长曲线、稳定吞吐或 drain P95,因此不会用 2-batch 结果推导生产容量。

一个可操作的告警分层是:

信号 含义 初始动作
queue/capacity 持续上升 下游慢于入口 检查后端延迟与网络,限制低价值信号
queue 接近 100% 剩余中断预算很小 提升事件等级,准备负载降级
enqueue_failed 增长 已出现明确拒绝 核对客户端重试、丢弃量与租户影响
queue 下降但 oldest age 不降 新旧数据可能不公平 检查消费顺序与积压分区
drain rate <= ingress 永远排不空 临时扩容后端或降低入口速率

本工件没有 oldest-age 指标,生产不能因此把它省略。可以在进入遥测管线前保存低基数 enqueue timestamp、用独立队列观测组件计算年龄,或选择原生提供该指标的后端;实现方式要避免把高基数 trace ID 直接变成 metric label。

复现与验证

要求 Docker Engine 与 Compose 可用,本机端口 26318251332998828686 未占用:

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

默认 replay 输出到 /tmp/younis-ai-lab-otel-queue-saturation.json,固定工件保留在仓库 results/。runner 会验证 health/query 状态、等待 metrics 进入目标状态,并在成功或失败时清理自己的容器、network 与 volumes。

结果固定 compose.yaml、两份配置、sender 与 runner 五个输入 SHA-256。报告不保存 raw OTLP payload、container ID、Docker mountpoint 或 queue bytes;它只保留固定 trace/span ID、HTTP 结果、聚合 metrics 与 canonical query 结果。

失败与边界

第一,只有三个串行请求。它验证状态转换,不是吞吐 Benchmark;18 至 27 ms 的本机请求耗时不用于性能结论。

第二,容量等于两批,不等于两个 spans。本文的一请求一 span 是实验控制条件,生产 batch processor 会改变换算关系。

第三,没有 block-on-overflow。本轮固定行为是返回 503;不同 Collector 版本、queue 类型、配置和客户端可能实现等待或重试,必须重新验证。

第四,没有客户端自动重试。sender 明确设置为零自动重试,避免一次调用隐式变成多次尝试。生产重试必须有最大次数、退避、jitter、总时长和丢弃终态。

第五,没有磁盘满或 I/O error。queue capacity 是逻辑上限;ENOSPC、只读文件系统、fsync 超时、bbolt 损坏和 volume 丢失是另一组失败面。

第六,没有提交后响应丢失。本文的 503 对应 query 404,不证明所有 503 都未写入,也不证明同 ID 重放永远无重复。

第七,恢复的是同一 Jaeger 容器。Badger volume 保持,但本轮不验证 Jaeger 或 Collector replacement;上一篇已经覆盖两个容器替换。

第八,单 Collector、单 consumer、单 host。没有水平扩容、租户公平、跨节点 volume、共享 writer 或网络分区。

第九,没有真实 Agent 流量。三个 spans 不含 prompt、token、用户、交易或工具参数,因此只能标记 reproduced

第十,trace 不是业务审计账本。即使 3/3 trace 最终可查,也不能代替工具副作用、审批和业务对象的权威状态。

商业价值

队列容量拒绝最容易在真正需要证据的事故窗口发生:模型或工具服务变慢,错误 trace 增多,后端同时过载,观测管线先被高峰填满。若应用忽略 exporter 的 503,团队看到的恰好是一个被系统性筛掉失败样本的健康面板。

值得付费解决这个问题的团队,通常需要可追责的 Agent 操作、稳定 incident response 与租户 SLA。可交付能力不是“启用 persistent queue”这一项配置,而是完整控制面:

容量预算 -> 水位与年龄告警 -> 客户端有限重试
        -> 低价值信号降级 -> 拒绝量归因
        -> 后端查询核对 -> 事故后 reconciliation

成本包括磁盘与同步写、后端冗余、重试放大、指标与日志、on-call、数据保留和敏感遥测治理。最重要的商业边界是优先级:高风险工具动作、审批和交易关联 metadata 应获得更强耐久与容量;低价值 debug trace 应允许采样或降级,避免它们挤占审计信号。

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

全栈基础 本实验对应能力
HTTP 状态码 200 接受与 503 拒绝的调用方合同
消息队列 capacity、enqueue、backpressure 与 drain
数据库 bbolt fsync 与 Jaeger Badger 最终查询
重试设计 稳定 ID、退避、重试预算与不确定提交窗口
SRE 水位、拒绝 counter、oldest age 与恢复时间
多租户 高价值信号优先级与公平容量
业务一致性 trace 终态与权威业务对象分离
回归测试 HTTP、metrics、404 负例、重放与工件 hash

Agent 系统让遥测量、错误峰值与敏感内容更复杂,但队列满仍是经典背压问题。区别在于丢掉的 trace 可能对应一次代码修改、审批建议或外部工具操作,因此容量、重试和最终核对必须与业务风险绑定。

面试表达

**30 秒版本:**我把 OpenTelemetry Collector persistent queue 固定为 2 batches,Jaeger 停机后串行发送三个单-span OTLP 请求。前两个返回 200 并填满 queue,第三个返回 503 sending queue is full;Collector 同时报告 accepted 2、refused 1、enqueue failed 1。Jaeger 恢复后只有前两条可查,第三条仍是 404;我再用同一 trace/span ID 显式重放,最终 3/3 可查。它证明容量拒绝和恢复终态,不证明 exactly-once。

**3 分钟版本:**实验固定 Collector、Jaeger 和 init image digest,file storage 开启 fsync,移除 batch processor 让一个请求对应一个 queue item。sender 用原生 fetch 且零自动重试,避免隐式尝试污染计数。容量满时 queue 保持 2/2、accepted 不增长、refused 与 enqueue-failed 各增 1;恢复后 sent=2 且 overflow query=404,说明 503 请求未入后端。只重放拒绝项后 accepted=3、sent=3,但历史 refusal counter 保留。生产还要补 queue age、磁盘 bytes、drain rate、租户公平、提交后断连和业务 reconciliation。

复盘

最重要的结果不是“队列能装两个”,而是拒绝在三个层面一致:客户端收到 503,Collector refusal/enqueue-failure counter 增长,Jaeger 恢复后查询仍为 404。只观察其中一层都可能误判;三层闭环才能说明第三条没有被接受。

第二个结果是 queue=0 不代表历史健康。排空后 dashboard 看起来恢复正常,但 refused=1 和 enqueue_failed=1 仍说明事故期间存在缺口。容量事件需要单调 counter、日志或事件记录,不能只看当前水位。

第三个结果是重放必须缩窄表述。稳定 trace/span ID 提供关联键,本轮也确实把 404 变成一条可查询 span;它没有覆盖“后端已提交但响应丢失”的不确定窗口。下一步应在 Collector 与后端之间注入提交后断连和部分 batch 响应,再检查 Jaeger 最终对象与重复语义。

方法披露

本文由 AI 工具协助设计状态矩阵、审查脚本、组织文字与绘制原创 SVG。镜像 digest、非 root 用户、queue 配置、HTTP 状态与正文、Prometheus metrics、Jaeger 查询、稳定 ID 重放、结果哈希、Docker 清理和最终表述均由本站对照实际运行逐项核验。

实验没有调用模型、向量数据库、外部 Agent、客户系统或生产遥测后端。三个 deterministic spans 不含 prompt、用户信息、凭据或真实 transaction。固定 JSON 不保存 raw payload、container ID、host mountpoint 或 queue bytes。

上游完整 commit 只用于解释 persistent queue、file storage、metrics 与 Jaeger 配置;HTTP 200/503、accepted 2、refused 1、enqueue failed 1、恢复后 404 和最终 3/3 只依据本仓库实际 capture。桌面 SVG、独立移动 SVG 与 PNG 只表达该固定状态链,不伪造吞吐、延迟或生产规模。

修订记录

  • 2026-07-15:初版发布;完成 2-batch persistent queue 容量拒绝、HTTP/metrics/query 三层核对、稳定 trace/span ID 重放与 8/8 工件验证。

Reusable projects

关联可复用项目

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

Source ledger

来源账本

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

  1. OpenTelemetry Collector exporter helper persistent queue documentation at v0.156.0 commit
    OpenTelemetry官方文档文章资料来源发布:2026/07/06本站核验:2026/07/15
  2. Collector exporter helper queue metrics implementation at v0.156.0 commit
    OpenTelemetry官方仓库文章资料来源发布:2026/07/06本站核验:2026/07/15
  3. Collector Contrib file storage extension README at v0.156.0 commit
    OpenTelemetry官方文档文章资料来源发布:2026/07/07本站核验:2026/07/15
  4. OpenTelemetry Collector internal telemetry and exporter metrics documentation at v0.156.0 commit
    OpenTelemetry官方文档文章资料来源发布:2026/07/06本站核验:2026/07/15
  5. Jaeger v2.19.0 Badger deployment configuration
    Jaeger官方仓库文章资料来源发布:2026/06/03本站核验:2026/07/15
  6. Jaeger v2.19.0 query extension documentation
    Jaeger官方文档文章资料来源发布:2026/06/03本站核验:2026/07/15

讨论

正在加载评论...