AI 工程实践
OpenTelemetry 存储上限实验:Queue 显示 6/20,重启为什么只恢复 4 批
把 Collector queue capacity 固定为 20,却把 bbolt max_size 限制为 65,536 bytes;实际验证前四批 HTTP 200、后两批 503、queue 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...001 至 b400...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 有两种解释:
- 六批都在磁盘,只是 receiver counter 错了。
- 只有四批持久化,失败的两次 enqueue 让内存 gauge 漂移。
文件恰好 65,536 bytes 仍不能直接数出 item。runner 因此停止 Collector,保留 named volume,删除旧容器,再以 max_size=262,144 创建新 Collector。新进程的 Loaded queue metadata 与 accepted=0 / sent=4 可以回答磁盘里究竟有几批。
固定 persistent_queue.go也解释了为什么会出现这类差异:Offer 在调用存储 transaction 前先增加 ItemsSize、BytesSize 与 WriteIndex;若 client.Batch 返回错误,源码注释明确写着不直接回滚内存 metadata,而依赖后续 drain 修复。本文不把源码阅读当结果,真正的验证仍是当前进程 gauge=6、重启 metadata=4 与四个可查询 IDs。
恢复与重放严格分两步
实验顺序如下:
- 从空 volumes 启动 Jaeger 与 65,536-byte Collector。
- 停止 Jaeger,发送 batches 1 至 6。
- 保存六个 HTTP responses、fault metrics、storage log 和只读 file stat。
- 停止 Collector,删除两个业务容器但保留 queue volume。
- 重启 Jaeger;以 262,144-byte 上限创建 replacement Collector。
- 等待新 Collector 在 accepted=0 时 sent=4,Jaeger query 必须只有 IDs 1 至 4。
- 明确确认 IDs 5、6 仍 missing,再只重放 batches 5、6。
- 最终 Jaeger 必须返回 6 unique、0 duplicate、0 missing。
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 可用,本机端口 31318、31133、31888、32686、32788 未占用:
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
关联可复用项目
本文已经进入以下工程项目;项目页提供固定版本、运行命令和结果工件。
- 已独立复现
本文验证 bbolt max_size 先于 queue capacity 拒绝写入,并证明 queue gauge 可高估可恢复 batch 数。
Source ledger
来源账本
以下来源用于核对事实、日期与当时可用范围。厂商自报性能不视为本站独立复现。
- Collector Contrib file storage extension README at v0.156.0 commit
- Collector Contrib file storage max-size configuration at v0.156.0 commit
- Collector Contrib bbolt max-size and storage-full normalization at v0.156.0 commit
- Collector persistent queue offer and failed storage transaction behavior at v0.156.0 commit
- OpenTelemetry Collector exporter helper persistent queue documentation at v0.156.0 commit
- Jaeger v2.19.0 query extension documentation
讨论