AI 工程实践

OpenTelemetry 存储上限实验:Queue 显示 6/20,重启为什么只恢复 4 批

把 Collector queue capacity 固定为 20,却把 bbolt max_size 限制为 65,536 bytes;实际验证前四批 HTTP 200、后两批 503、queue gauge 多计两批,以及提高上限后恢复与定向重放。

发布:2026/07/15更新:2026/07/15
六批 span 进入容量二十的 Collector 持久队列,六十五千字节 bbolt 上限只保存前四批并拒绝后两批;当前进程 queue gauge 显示六,重启实际只加载四,定向重放后 Jaeger 收齐六批
图 1:2026-07-15 file-storage max-size 实验。storage-full 后当前进程 queue gauge 为 6/20,但 accepted 与重启 metadata 都是 4;只看 gauge 会把未持久化的两批误认为仍可恢复。

时间与证据

上一篇 OpenTelemetry 持久队列排空实验验证了 12 batches 在单 consumer 下从 queue 12 排到 0,并观察到 bbolt 文件排空后仍保持峰值。它说明 queue items 与 file bytes 不是同一个量,但当时 volume 空间充足,没有触发存储写入拒绝。

2026 年 7 月 15 日,我新增 labs/otel-storage-full/,把两个容量边界刻意分开:

file_storage/queue:
  max_size: 65536
  fsync: true

sending_queue:
  queue_size: 20
  num_consumers: 1

queue_size=20 是 exporter requests 的逻辑容量;max_size=65,536 是每个 bbolt database file 的字节上限。实验让 Jaeger 停止,再顺序发送 6 个单-span OTLP/HTTP JSON requests,每批包含 4,096-byte synthetic padding。按逻辑 item 数看,6 远低于 20;按 bbolt 文件增长看,第五批已经需要越过 65,536 bytes。

固定运行结果不是简单的“第五批失败”:前四批 HTTP 200,第五与第六批 HTTP 503;receiver accepted/refused 为 4/2,enqueue failed 为 2,但同一时刻 queue_size gauge 显示 6/20。保留原 volume、把 max_size 提高到 262,144 并重建 Collector 后,启动日志只加载 4 items。这个差异证明当前进程 gauge 多计了两次失败入队,不能把 6 解释为磁盘里有 6 批可恢复数据。

固定工件位于 labs/otel-storage-full/results/2026-07-15-results.json,共 13,873 bytes,文件 SHA-256 为 319b1bbaee0458ee1e4b7a164c76ba32a38439230d722da56a089d5474d155be,加入自引用字段前的 payload SHA-256 为 a968b750f3ca6879cec402ed87077febdadc4b1b86a1deced781454fdbb658cd。10/10 工件断言覆盖镜像与输入身份、双容量合同、HTTP 200/503 分界、counter/gauge 差异、volume 与容器替换、重启恢复、拒绝 ID 缺失、定向重放、Jaeger 终态和工件隐私。

证据等级保持 reproduced。真实运行的是 Collector v0.156.0 persistent queue、bbolt max-size、Jaeger Badger 与 query API;65,536 bytes 是实验配置的应用级存储上限,不是宿主物理磁盘真正返回的 ENOSPC

实验设计

六个稳定 Identity,只改变存储上限

实验固定一条 trace b400...0001,span IDs 为 b400...001b400...006。每次应用调用只发送一条 span,不自动重试:

batch 稳定 span ID 初始预期角色
1 b400...001 在上限内持久化
2 b400...002 在上限内持久化
3 b400...003 在上限内持久化
4 b400...004 在上限内持久化
5 b400...005 触发 storage-full
6 b400...006 上限未释放,继续拒绝

padding 让边界在六次串行请求内稳定出现。它不是生产 trace 大小模型,公开工件也不保存 padding 原文;每个 request 只保留 batch、稳定 IDs、HTTP response、耗时和输入合同。

max_size 不是 Queue Capacity,也不是物理 ENOSPC

固定 file storage README说明:max_size 是每个 bbolt database file 的最大 on-disk bytes;写入若需要越过上限,会以 storage-full error 拒绝;若已有空闲页足够,即使文件已经到上限仍可写入。

client.go把该值传给 bbolt Options.MaxSize,并把 bbolt.ErrMaxSizeReached 归一化为 storage.ErrStorageFull。因此本轮构造的是 Collector file-storage 的确定性 storage-full 路径,而不是 Docker volume、宿主文件系统或 SSD 故障。

queue capacity 仍是 20。若第五批失败时 gauge 或日志只写“queue full”,就无法区分逻辑 item 上限与持久介质上限;runner 因此同时保存:

HTTP response
receiver accepted/refused
exporter enqueue_failed
queue size/capacity gauge
bbolt logical/allocated bytes
storage error log
replacement startup metadata
Jaeger stable identity set

为什么必须重建 Collector 验证 Gauge

在同一进程里,metrics 显示 queue_size=6。单看 gauge 有两种解释:

  1. 六批都在磁盘,只是 receiver counter 错了。
  2. 只有四批持久化,失败的两次 enqueue 让内存 gauge 漂移。

文件恰好 65,536 bytes 仍不能直接数出 item。runner 因此停止 Collector,保留 named volume,删除旧容器,再以 max_size=262,144 创建新 Collector。新进程的 Loaded queue metadataaccepted=0 / sent=4 可以回答磁盘里究竟有几批。

固定 persistent_queue.go也解释了为什么会出现这类差异:Offer 在调用存储 transaction 前先增加 ItemsSizeBytesSizeWriteIndex;若 client.Batch 返回错误,源码注释明确写着不直接回滚内存 metadata,而依赖后续 drain 修复。本文不把源码阅读当结果,真正的验证仍是当前进程 gauge=6、重启 metadata=4 与四个可查询 IDs。

恢复与重放严格分两步

实验顺序如下:

  1. 从空 volumes 启动 Jaeger 与 65,536-byte Collector。
  2. 停止 Jaeger,发送 batches 1 至 6。
  3. 保存六个 HTTP responses、fault metrics、storage log 和只读 file stat。
  4. 停止 Collector,删除两个业务容器但保留 queue volume。
  5. 重启 Jaeger;以 262,144-byte 上限创建 replacement Collector。
  6. 等待新 Collector 在 accepted=0 时 sent=4,Jaeger query 必须只有 IDs 1 至 4。
  7. 明确确认 IDs 5、6 仍 missing,再只重放 batches 5、6。
  8. 最终 Jaeger 必须返回 6 unique、0 duplicate、0 missing。
  9. finally 删除容器、network 与 volumes。

若一开始就重放全部六批,最终 6/6 不能证明哪些来自磁盘恢复、哪些来自应用补偿;分步查询把两条路径分开。

结果

四个 200,两个明确的 503

前四个应用请求返回:

{ "partialSuccess": {} }

第五、第六个都返回 HTTP 503:

{
  "code": 14,
  "message": "the storage extension has run out of available space"
}

sender 的 automaticRetries=0,所以这六条 response 对应六次唯一应用请求。503 不是 Jaeger 返回的下游失败;Jaeger 当时已经停止,错误发生在 Collector 尝试把入口数据加入 persistent queue 时。

Counter 说 4/2,Gauge 却说 6/20

storage-full 时的 Collector metrics:

receiver accepted       4
receiver refused        2
exporter enqueue failed 2
exporter sent           0
exporter failed         0
queue size/capacity     6 / 20

accepted 4 与 refused 2 和六个 HTTP response 闭合。enqueue_failed=2 指向 queue storage 写入失败;send_failed=0 不矛盾,因为这两批从未成功进入 exporter queue,自然也没有成为下游发送失败。

异常在 queue_size=6:它比 accepted/persisted 多 2,恰好等于 enqueue failures。此时若告警只计算 queue_size / capacity = 30%,会认为六批仍可恢复;实际上 IDs 5、6 已经通过 503 返回调用方,必须由上层处理。

bbolt 文件状态为:

指标 storage-full 值
configured max size 65,536 bytes
logical file size 65,536 bytes
allocated bytes 65,536 bytes
reported queue gauge 6 batches
receiver accepted 4 spans

文件到达 configured max,但 allocated bytes 小于 logical bytes,再次说明 file stat、live item 和 queue gauge 是不同观察面。

重启只加载四批,Gauge 漂移被证伪

提高上限并重建 Collector 后,启动日志报告:

Loaded queue metadata
itemsSize              4
bytesSize         17,928
dispatchedItems         1

新 Collector 随后给出:

accepted 0 · sent 4 · queue 0/20

没有新应用 ingress,却发送四批;Jaeger query 正好返回 IDs 1、2、3、4。IDs 5、6 仍在 missing 集合。queue_size=6 因此不是六批持久化的证据,replacement process 从磁盘权威 metadata 只恢复四批。

恢复写操作让 bbolt logical size 增长到 131,072 bytes,allocated 为 81,920 bytes;排空和后续两次 replay 后仍保持该尺寸。提高 max-size 只放宽允许增长的上限,不会让现有文件自动缩小。

只重放被拒绝 IDs,最终六条闭合

runner 只再次发送 batches 5、6,二者都获得 HTTP 200。replacement Collector 最终 metrics:

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

这里 sent 6 包含从旧 volume 恢复的四批和新入口的两批。Jaeger receiver/storage exporter 都计 6,query 返回 6 unique、0 duplicate、0 missing。

本轮补偿没有重复前四批,是因为应用保存了被 503 拒绝的稳定 batch identities。生产若只知道“某时段 storage full”而不知道哪些业务事件被拒绝,就只能全量重发或接受缺口,两者都会扩大风险。

Storage-Full 告警应该看什么

这次结果表明,storage-full 期间不能单独信任 queue gauge。最小告警合同应为:

信号组合 判断
enqueue_failed > 0 已有 item 未进入 queue,立即告警
receiver refused 增长 入口已向 producer 返回非成功
storage available-space error 区分介质/上限故障与逻辑 queue full
queue gauge 与 accepted/refused 不闭合 标记 gauge 可能漂移,不推导可恢复量
bbolt bytes 接近 max-size 提前扩容、降采样或启动恢复流程
replacement loaded items 重启后确认真实持久集合
backend missing stable IDs 只补偿确实缺失的业务事件

对于高价值 Agent 事件,producer 需要保留可重建的稳定 key 与拒绝 ledger。OTLP trace 不是业务 outbox;如果授权、工具副作用或 reconciliation 结果必须可追责,应先写权威事务/事件存储,再异步派生 telemetry。

复现与验证

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

npm ci
npm run lab:otel-storage-full:run
npm run lab:otel-storage-full:test

默认 replay 输出到 /tmp/younis-ai-lab-otel-storage-full.json。固定工件保存 compose、两份配置、sender 与 runner 五个输入 SHA-256。raw padding、container ID、mountpoint 与 volume 物理路径不进入报告。

runner 使用 STORAGE_MAX_SIZE 只改变 replacement Collector 的同一配置字段;初始为 65,536,恢复为 262,144。queue capacity、consumer、fsync、payload、Jaeger 和镜像 digest 均不变。磁盘 stat 通过固定 BusyBox image 对同一 volume 只读采集。

失败与边界

第一,这不是物理 ENOSPC。宿主磁盘和 Docker volume 一直健康;失败来自 file storage max_size,只能代表 Collector 应用级 storage-full 路径。

第二,没有注入只读或 I/O error。permission change、filesystem remount、device error、fsync timeout、bbolt corruption 和 volume 丢失仍未覆盖。

第三,gauge 漂移固定到 v0.156.0。未来 Collector 可能回滚失败 metadata 或调整 metrics;升级必须重放,不能把多计两批写成永久 API 合同。

第四,只有六个串行 requests。每批约 4 KiB synthetic padding,没有并发、batch processor、压缩、多 signal 或真实大小分布。

第五,padding 决定了本轮边界位置。换成更小 payload,达到 65,536 前可容纳更多 requests;不能把“四批”当通用容量公式。

第六,提高上限需要重建 Collector。没有验证在线热更新,也没有证明所有部署平台能安全替换配置并保持同一 volume。

第七,只在单 host 上保留 volume。节点丢失、跨节点 persistent volume、备份和异机恢复不在本轮范围。

第八,定向重放依赖稳定拒绝 ledger。本实验天然知道 batches 5、6;普通 telemetry producer 未必保留这种业务级补偿信息。

第九,最终 0 duplicate 不代表 exactly-once。没有提交后响应丢失、第二次 payload 变化或乱序 delivery。

第十,没有真实 Agent 流量。真实的是 storage-full 与恢复路径,证据等级不升级为 field-tested

商业价值

持久 queue 最危险的误区是“没到 20/20 就不会拒绝”。实际系统同时受 item capacity、bbolt max-size、volume free space、I/O latency、process memory 与下游吞吐约束,任何一个先到边界都会改变数据终态。

愿意为这项能力付费的团队通常把 telemetry 用于事故复盘、SLA 归因、Agent 审计或合规调查。若 storage-full 后 gauge 把拒绝项算成仍在队列,on-call 可能等待一个永远不会发生的自动恢复;真正缺失的业务 IDs 直到事故复盘才暴露。

可交付方案应包含:

queue item 与 byte 双容量模型
  -> enqueue failure / refused / storage error 联合告警
  -> producer rejection ledger 与稳定 key
  -> replacement loaded-items 验证
  -> backend query reconciliation
  -> 定向重放与重复检查
  -> max-size、volume 和 compaction runbook

主要成本包括磁盘 headroom、拒绝 ledger、告警归因、补偿 API、重放审核、敏感 telemetry 落盘治理和版本回归。低价值 debug trace 可以在边界前主动采样;高风险 Agent 副作用不能把 trace queue 当唯一审计存储。

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

全栈基础 本实验对应能力
HTTP 入口 200/503 与 structured error body
消息队列 item capacity、enqueue failure 与恢复集合
数据库 bbolt transaction、max-size 与 persisted metadata
可观测性 counter、gauge、log、file stat、startup metadata 与 query
SRE storage-full 告警、扩容、替换与定向重放
数据完整性 accepted/refused IDs 与 backend missing set
Agent 审计 权威 outbox、稳定业务 key 与 reconciliation
测试 空 volume、单变量配置、输入哈希和清理断言

AI 系统不会改变队列的基本约束,却会提高单条 trace 大小、敏感度和审计价值。工程能力体现在能区分“指标看见六批”“磁盘恢复四批”和“最终后端缺两批”,并为每个状态定义正确动作。

面试表达

**30 秒版本:**我把 OpenTelemetry Collector queue capacity 设为 20,但把 bbolt max_size 限制为 65,536 bytes。前四个单-span requests 返回 200,第五、第六个返回 storage available-space 503;metrics 是 accepted 4、refused 2、enqueue failed 2,但 queue gauge 显示 6/20。保留 volume、提高上限并重建 Collector 后,启动日志只加载 4 items,新进程 accepted=0、sent=4。只重放被拒绝的 5、6 后,Jaeger 最终 6 unique、0 missing。

**3 分钟版本:**实验固定 Collector v0.156.0、Jaeger v2.19.0、单 consumer、fsync 与六个稳定 IDs,只改变 file storage max-size。bbolt 文件在 65,536 bytes 触发 ErrStorageFull,HTTP body 和日志都明确报告 available space。固定源码显示 persistent queue 在存储 transaction 前递增内存 WriteIndex,失败后不直接回滚;运行时因此 gauge 多计两批。replacement 从同一 volume 只加载 4 items / 17,928 bytes,Jaeger 也只出现前四个 IDs,证明拒绝项不在磁盘。生产必须联合 enqueue failure、refused、storage error、loaded items 与 backend query,不能只看 queue gauge。

复盘

第一个发现是逻辑 queue capacity 没满,存储仍然会先满。6/20 看起来只有 30%,但 65,536-byte bbolt 已到上限;item 与 byte 必须同时预算。

第二个发现是错误后的 gauge 不是权威恢复集合。当前进程显示 6,replacement 只加载 4。若没有容器替换与 query 验收,文章很容易把两个拒绝项误写成“仍在磁盘等待恢复”。

第三个发现是补偿必须按稳定 identity。只重放 5、6 后最终完整且无观察到重复;全量重发会把已经恢复的 1 至 4 再投递一次,并重新进入提交歧义问题。

下一步应注入真实的只读目录、物理空间耗尽或可控 I/O error,检查这些错误是否同样返回 503、是否产生不同日志/metrics、旧 queue 是否仍可读,以及 replacement 能否在不扩大数据损坏的情况下恢复。

方法披露

本文由 AI 工具协助设计 storage-full runner、审查固定源码、组织文字与绘制原创 SVG。六个 HTTP responses、Collector counter/gauge/log、bbolt stat、replacement identity、volume identity、loaded metadata、Jaeger query、工件哈希和资源清理均由本站对照实际运行逐项核验。

实验没有调用模型、向量数据库、外部 Agent 或客户系统。synthetic padding 用于稳定触发 max-size;公开工件删除 padding tag,只保留长度、稳定 identity 与结构化结果。固定源码用于解释 max-size 和内存 metadata 更新顺序;4/2、6/20、loaded 4 与最终 6/6 只依据本仓库实际 capture。

桌面 SVG、独立移动 SVG 与 PNG 依据固定结果绘制,不把 configured storage-full 描述成宿主 ENOSPC,也不伪造生产丢失率、吞吐或告警能力。

修订记录

  • 2026-07-15:初版发布;完成 65,536-byte bbolt storage-full、4/2 HTTP 分界、queue gauge 多计、replacement 四批恢复、拒绝 ID 定向重放与 Jaeger 6-identity 终态验证。

Reusable projects

关联可复用项目

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

Source ledger

来源账本

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

  1. Collector Contrib file storage extension README at v0.156.0 commit
    OpenTelemetry官方文档文章资料来源发布:2026/07/07本站核验:2026/07/15
  2. Collector Contrib file storage max-size configuration at v0.156.0 commit
    OpenTelemetry官方仓库文章资料来源发布:2026/07/07本站核验:2026/07/15
  3. Collector Contrib bbolt max-size and storage-full normalization at v0.156.0 commit
    OpenTelemetry官方仓库文章资料来源发布:2026/07/07本站核验:2026/07/15
  4. Collector persistent queue offer and failed storage transaction behavior at v0.156.0 commit
    OpenTelemetry官方仓库文章资料来源发布:2026/07/06本站核验:2026/07/15
  5. OpenTelemetry Collector exporter helper persistent queue documentation at v0.156.0 commit
    OpenTelemetry官方文档文章资料来源发布:2026/07/06本站核验:2026/07/15
  6. Jaeger v2.19.0 query extension documentation
    Jaeger官方文档文章资料来源发布:2026/06/03本站核验:2026/07/15

讨论

正在加载评论...