AI 工程实践
OpenTelemetry 持久队列排空实验:12 批积压清空后,磁盘为什么没有缩小
让 Collector 在下游不可达时积压 12 个单-span batches,记录 oldest age 与 bbolt 文件增长,再用单 consumer 和 250 ms 固定响应延迟测量排空曲线、最终吞吐和 Jaeger 完整性。
时间与证据
上一篇 OpenTelemetry 队列容量实验把 persistent queue 缩到 2 batches,证明第三个请求会收到 503,且被拒绝的稳定 identity 不会在后端自动出现。它回答了“队列什么时候满”,却没有回答另一个同样重要的问题:队列还没满时,积压已经有多老、占了多少磁盘、后端恢复后多久才能追平。
2026 年 7 月 15 日,我新增 labs/otel-drain-rate/。Collector v0.156.0 使用 bbolt file_storage、fsync: true、20-batch persistent queue 与单 consumer。下游代理最初完全不存在,应用顺序发送 12 个单-span OTLP/HTTP JSON 请求;每个请求带 4,096-byte synthetic padding,让小样本也能形成可观察的文件增长。12 个应用请求全部收到 Collector HTTP 200,Collector 此时为 accepted 12、sent 0、queue 12/20。
积压保持 1,200 ms 后,runner 启动 host proxy。代理把每个请求转发给 Jaeger;Jaeger 返回成功后,代理固定再等待 250 ms 才回复 Collector。这样单 consumer 的出口服务时间被控制在约 250 ms 加协议与存储开销,runner 可以采样 queue depth 的下降过程,而不是让本机后端瞬间清空队列。
固定结果位于 labs/otel-drain-rate/results/2026-07-15-results.json,共 17,744 bytes,文件 SHA-256 为 169580ecc1c700f6a551143bb3f15b190b8a31f689838a5426a31a859310e236,加入自引用字段前的 payload SHA-256 为 27acb05d26b1292e7874b1431feef0552d8d6d28348935369c9e5effcf76b614。9/9 工件断言覆盖镜像与输入身份、队列合同、12 次入口接受、五个磁盘快照、单 consumer 排空、queue transitions、Jaeger 稳定 IDs 和工件隐私。
证据等级保持 reproduced。真实运行的是 Collector、bbolt file storage、retry、OTLP exporter、Jaeger Badger 与 query API;输入是 synthetic trace,250 ms 是故障代理施加的实验延迟,不是任何生产后端的性能数据。
实验设计
一个请求严格对应一个 Queue Item
本轮移除 Collector batch processor,让每次应用 OTLP 请求直接成为一个 exporter queue item。输入合同固定为:
| 参数 | 固定值 |
|---|---|
| trace | a300...0001 |
| 应用请求 | 12 |
| 每请求 spans | 1 |
| 每 span padding | 4,096 bytes |
| queue capacity | 20 batches |
| consumers | 1 |
| storage | bbolt file_storage, fsync=true |
| proxy response delay | 250 ms |
queue_size 的单位是 exporter requests,不是 bytes 或 spans。本实验刻意使用 1 request = 1 item = 1 span,所以三者数量暂时相等;生产 batch processor、SDK batching 和压缩配置一变,这个等式就不成立。
12 个 span 共享一个 trace ID,但拥有从 a300...0001 到 a300...000c 的固定 span ID。代理解析 JSON,只记录 trace/span identity、request bytes、SHA-256、开始/完成偏移与后端 status,不保存 raw body。最终 Jaeger query 必须返回同一组 12 个 IDs,不能只依赖 exporter sent counter。
五个磁盘快照不读取数据库内容
runner 在以下状态挂载同一 named volume 为只读,并用固定 BusyBox digest 对 queue 文件执行 stat:
empty -> queued 4 -> queued 8 -> queued 12 -> drained 0
报告只保存 logical bytes 与 allocated bytes,不复制 bbolt 原始页。logical bytes 是文件名义长度;allocated bytes 来自文件系统已分配 block 数。两者都不是 live queue payload bytes,也不能替代 queue metric。
空 queue 已有 32,768-byte bbolt 文件,因此“文件存在”不表示有积压。排空后文件是否缩小同样只能作为观察值,不能单独证明 item 是否仍在数据库中。权威逻辑状态来自 queue metric、sent counter、代理完成事件与 Jaeger query 的交叉验证。
Oldest Age 是 Runner 计算值,不是 Collector 原生指标
Collector 本轮暴露 queue size/capacity,却没有直接给出最老 item 年龄。runner 保存每个应用请求的 acceptedAt,再用最早接受时间与 proxy 启动时间之差计算:
oldestAgeAtProxyReady = proxyStartedAt - firstAcceptedAt
固定运行得到 2,269 ms。它包含顺序发送 12 个请求的耗时和额外 1,200 ms hold,不是每个 item 在 bbolt 内部提交时刻的精确年龄,也不是 Prometheus 原生 metric。生产需要在低基数 ledger、队列实现或独立 observer 中持续计算 oldest age,不能把本实验字段直接复制成 Collector 指标名。
从空 Volume 到最终查询
每次 replay 都先执行 docker compose down --volumes,然后:
- 启动 storage init、Jaeger 与 Collector,代理保持不存在。
- 依次发送 12 个请求,在 4、8、12 批时等待 queue metric 精确到达目标并读磁盘 stat。
- 确认 backlog 为 accepted 12、sent 0、queue 12/20,再保持 1,200 ms。
- 启动 250 ms delayed proxy,约每 35 ms 采样一次 metrics,只在 queue depth 变化时保存 transition。
- 等待 queue 0、sent 12、12 个 proxy events 全部完成。
- 再读 drained 文件 stat,并要求 Jaeger query 返回 12 个唯一 span IDs。
finally终止 host proxy并删除容器、network 与 volumes。
结果
应用先成功,后端仍为零
代理不存在时,12 个应用请求都从 Collector receiver 获得 HTTP 200。积压稳定状态是:
accepted 12 · refused 0
sent 0 · failed 0
queue 12 / 20 · enqueue failed 0
这与前几轮实验结论一致:入口 HTTP 200 表示 Collector 接受并入队,不表示 Jaeger 已写入。sent=0 与 queue=12 才描述了下游交付状态。若应用把 receiver 200 命名为 trace_stored,它会把至少 2.269 秒的未交付窗口错误地记成已完成。
bbolt 文件按阶梯增长,排空后没有缩小
五个固定快照如下:
| 状态 | queue depth | logical bytes | allocated bytes |
|---|---|---|---|
| empty | 0 | 32,768 | 20,480 |
| queued 4 | 4 | 65,536 | 65,536 |
| queued 8 | 8 | 131,072 | 94,208 |
| queued 12 | 12 | 262,144 | 139,264 |
| drained | 0 | 262,144 | 139,264 |
从 empty 到 queued 12,logical size 增加 229,376 bytes,allocated size 增加 118,784 bytes。12 个 OTLP JSON requests 合计并没有 229 KB raw payload;bbolt 页、metadata、空闲页与文件增长策略都会影响文件尺寸,所以不能用 file growth / span count 推导通用单条存储成本。
更重要的是,queue 从 12 降到 0 后,两个文件尺寸都没有变化。本轮没有在运行中配置 compaction;这只证明固定版本、固定配置和本次进程生命周期内没有自动缩小,不证明空间永远不能复用或重启 compaction 无效。
生产面板若只看文件 bytes,会在排空后继续显示 262,144,误判“仍有积压”;若只看 queue=0,又可能忽略 volume 已经扩张并需要容量与 compaction 策略。两个信号解释不同问题,不能互相替代。
12 批用 3.129727 秒排空
代理 12 次收到的 request SHA 全部不同,span IDs 按发送顺序从 ...001 到 ...00c。每次先拿到 Jaeger HTTP 200,再固定等待 250 ms。固定结果:
drain window 3,129.727 ms
observed throughput 3.8342 spans/s
median response time 256.654 ms
queue transition 12 -> 11 -> ... -> 1 -> 0
吞吐略低于理论 4 requests/s,因为 250 ms 之外仍有 JSON、HTTP、Jaeger Badger、事件循环与采样开销。它不是 Collector 极限吞吐 Benchmark;它只验证在“单 consumer + 固定响应延迟 + 12 个约 4.9 KB requests + 无新 ingress”合同下的排空时间。
容量规划的核心不是 R_drain 本身,而是净排空速度:
R_net = R_drain - R_ingress
T_clear = backlog / R_net 仅当 R_net > 0
本轮排空期间 R_ingress=0,所以 12 / 3.8342 与实测 3.129727 秒闭合。生产若持续有正常流量,必须在同一资源限制下做 mixed-load replay;不能直接拿 3.8342 减去估计入口就当容量承诺。
Collector 与 Jaeger 最终闭合
排空完成后 Collector 为:
accepted 12 · sent 12 · failed 0
queue 0 / 20 · enqueue failed 0
Jaeger receiver accepted 12,storage exporter sent 12。最终 query 返回 12 spans,identity 集合大小也是 12,duplicate=0、missing=[]。工件 canonicalization 删除了 4 KiB padding tag,只保留 batch number、stable key、scope 与 status,避免把无意义填充复制进公开结果。
0 duplicate 仍不代表 exactly-once。本轮代理对每批只回复一次且没有制造提交后断连;重复投递边界已经由 后端提交响应丢失实验单独验证。
如何建立 Queue Recovery SLO
一个可执行的 persistent queue 面板至少需要:
| 信号 | 回答的问题 | 常见误读 |
|---|---|---|
| queue size / capacity | 还剩多少 items、离拒绝多远 | 当成 spans 或 bytes |
| oldest item age | 最坏交付延迟有多大 | 只看平均 age |
| enqueue failures | 是否已有入口数据被拒绝 | 只看 exporter failed |
| drain rate 与 ingress rate | backlog 是否真的在收敛 | 只看 queue 正在下降 |
| volume used / free bytes | 介质还有多少 headroom | 用文件大小代替 live items |
| downstream query | 数据是否最终可用 | 用 sent counter 代替查询 |
告警优先级应按“时间 + 容量 + 终态”组合,而不是单阈值:
oldest age 超 SLO
OR queue utilization 持续上升
OR enqueue_failed > 0
OR R_drain <= R_ingress
OR disk free 低于恢复与 compaction headroom
OR sent 与 backend query 长期不闭合
对 Agent 审计链,还应按租户与业务风险区分队列。低价值 debug trace 可以采样或允许短窗口损失;授权、工具副作用与 reconciliation 事件需要更严格的 age 和完整性目标。不要把 tenant ID 直接做高基数 metric label,可以在日志或低基数分区 ledger 中归因。
复现与验证
要求 Docker Engine 与 Compose 可用,并允许容器访问 host.docker.internal。本机端口 30318、30133、30888、30418、30518、31686、31788 未占用:
npm ci
npm run lab:otel-drain:run
npm run lab:otel-drain:test
默认 replay 输出到 /tmp/younis-ai-lab-otel-drain-rate.json。固定工件保存 compose、两份配置、delayed proxy、sender 与 runner 六个输入 SHA-256;不保存 raw OTLP body、4 KiB padding、container ID、mountpoint 或 Docker volume 物理路径。
磁盘快照通过独立 docker run --rm 把 queue volume 只读挂载到固定 BusyBox image。runner 无论成功或失败都会停止 proxy;非 --keep-stack 模式还会删除实验容器、network 与 volumes。
失败与边界
第一,没有注入磁盘写入失败。volume 始终可写且有足够空间,没有覆盖 ENOSPC、permission change、I/O error、bbolt corruption 或 enqueue rollback。
第二,没有测量真实 fsync latency。配置固定 fsync=true,但 runner 没有 block-device latency、IOPS、flush trace 或 fsync 开关对照组,不能把 256.654 ms 解释成磁盘同步耗时。
第三,250 ms 是代理延迟。它发生在 Jaeger 已返回之后,用来控制单 consumer service time,不代表真实 Jaeger、云盘或网络 P95。
第四,只有 12 个小 batches。padding 人为扩大到 4 KiB;没有并发、压缩、大 batch、多 resource、logs、metrics 或生产 trace 分布。
第五,排空期间没有新 ingress。因此只测得 gross drain rate,没有测 R_drain - R_ingress、入口争用或 recovery 期间的 P95。
第六,oldest age 是外部近似值。它从应用 accepted timestamp 算到 proxy started timestamp,不是 bbolt item 的内部 commit time,也没有持续输出排空中的 oldest curve。
第七,文件未缩小只是一轮观察。没有重启 Collector、触发 file storage compaction、验证页复用、写入第二轮 backlog 或测 volume free-space recovery。
第八,单 host、单 Collector、单 consumer。没有多副本、共享存储、跨节点调度或租户公平。
第九,最终无重复不代表 exactly-once。没有提交后响应丢失、payload 变化或乱序 delivery。
第十,没有真实 Agent 流量。真实的是 telemetry queue path,不是客户任务,因此不升级为 field-tested。
商业价值
持久队列不是“打开后就不会丢数据”的复选框。它把短时后端故障转化为可管理的时间与容量预算:能积压多久、最老数据延迟多少、恢复后多久追平、磁盘是否还有 headroom、最终查询是否完整。
愿意为这项能力付费的团队通常依赖 trace 做事故复盘、Agent 工具审计、SLA 归因或合规证据。若后端恢复后 drain rate 不高于持续 ingress,queue 即使没有立即溢出也永远清不完;如果文件增长和 compaction 没有 owner,下一次 outage 可能直接撞上磁盘边界。
可交付能力应包括:
负载回放与 batch 分布
-> queue age / depth / bytes 基线
-> outage budget 与净排空模型
-> 磁盘 headroom 和 compaction 演练
-> enqueue failure 与 query completeness 告警
-> 版本升级回归与恢复 runbook
成本来自持久 volume、同步写、额外指标、日志归因、容量预留、恢复演练和敏感遥测治理。对于低价值高吞吐 debug 数据,采样和丢弃策略可能比全量持久化更合理;对于高风险 Agent 副作用,应把业务审计事件放在权威存储,trace 用于解释链路,两者用稳定 transaction key 关联。
从全栈工程迁移到 AI 系统工程
| 全栈基础 | 本实验对应能力 |
|---|---|
| HTTP | receiver 200、downstream 200 与延迟响应边界 |
| 消息队列 | capacity、oldest age、consumer service time 与 drain |
| 数据库 | bbolt 文件增长、逻辑 item 与已分配页的区别 |
| 可观测性 | queue transitions、counter、disk stat 与 query 闭合 |
| 容量规划 | gross drain、持续 ingress 与净排空公式 |
| SRE | outage budget、disk headroom、enqueue failure 与 runbook |
| Agent 审计 | 高价值事件的交付延迟与最终完整性 |
| 测试 | 稳定 identity、输入哈希、清理和隐私断言 |
AI 系统会增加 trace 内容和工具副作用,但容量问题仍服从队列基本规律。真正的能力迁移不是会写 Collector YAML,而是能从应用接受、持久介质、恢复速率到权威查询构建一条可证伪证据链。
面试表达
**30 秒版本:**我让 OpenTelemetry Collector 在下游不可达时接收 12 个单-span requests,全部返回入口 HTTP 200,并形成 queue 12/20。bbolt 文件从空队列 32,768 增到 262,144 bytes。启动一个每次后端成功后延迟 250 ms 的代理,单 consumer 用 3,129.727 ms 排空,约 3.8342 spans/s;queue 归零后文件仍为 262,144,所以文件大小不能代替积压指标。Jaeger 最终 12 unique、0 missing。
**3 分钟版本:**实验固定 Collector v0.156.0、Jaeger v2.19.0 和镜像 digest,移除 batch processor,保证一个请求对应一个 queue item。12 个 requests 各含 4 KiB synthetic padding,runner 在 0、4、8、12、drained 五个状态只读统计 bbolt logical/allocated bytes,并从最早 acceptedAt 计算 proxy ready 时的 2,269 ms oldest age。排空期间采样 12 到 0 的每个 queue transition,最终 counter 为 accepted 12、sent 12、failed 0,Jaeger query 精确返回 12 个稳定 IDs。边界是没有磁盘故障、真实 fsync 延迟、持续 ingress 或 compaction 对照,证据只标 reproduced。
复盘
第一个发现是 queue depth 与磁盘 bytes 不同步归零。bbolt 文件在排空后保持峰值尺寸,说明生产不能用单个文件大小判断当前 backlog;同时也不能因为 queue=0 就忽略 volume headroom 与 compaction 生命周期。
第二个发现是固定 250 ms 延迟让排空曲线变得可解释。queue 每次下降一批,最终 12 次后端成功与 12 个 query identities 闭合;如果只记录开始和结束两个点,就无法验证中间是否跳批、并发或重复。
第三个发现是 oldest age 必须显式建模。queue utilization 只有 60%,看起来离满还远,但最老数据已经延迟 2.269 秒。对于要求秒级审计可见性的 Agent 系统,年龄可能比容量更早触发事故。
下一步应把实验拆成两个独立故障面:一篇注入磁盘只读、空间耗尽或 I/O error,核对入口拒绝与数据终态;另一篇用可控块设备或存储代理比较 fsync 开关、写入 P95、吞吐和 crash recovery,避免把代理延迟冒充磁盘延迟。
方法披露
本文由 AI 工具协助设计故障代理、审查 runner、组织文字与绘制原创 SVG。镜像 digest、12 次 HTTP response、queue metrics、五个 disk stat、proxy events、Jaeger query、文件哈希和清理状态均由本站对照实际运行逐项核验。
实验没有调用模型、向量数据库、外部 Agent 或客户系统。synthetic padding 用于形成可观察的 bbolt 增长;公开结果删除 padding 内容,只保留 4,096-byte 合同、request hash 与稳定 identity。上游 commit 只解释 persistent queue、file storage 和 query 合同;2,269 ms、262,144 bytes、3.8342 spans/s 与 12/12 终态只依据固定工件。
桌面 SVG、独立移动 SVG 与 PNG 依据实测结果绘制,不伪造生产吞吐、磁盘成本或 SLO。所有数值都限定在单次固定环境,不作为 Collector 或 Jaeger 的通用 Benchmark。
修订记录
2026-07-15:初版发布;完成 12-batch persistent queue 积压、oldest-age 近似、五阶段磁盘快照、250 ms 单 consumer 排空曲线与 Jaeger 12-identity 终态验证。
Reusable projects
关联可复用项目
本文已经进入以下工程项目;项目页提供固定版本、运行命令和结果工件。
- 已独立复现
本文记录 12 批积压的排空时间、顺序和 bbolt 文件增长;在固定版本、配置与本次进程生命周期内,queue 归零后文件尺寸保持不变。
Source ledger
来源账本
以下来源用于核对事实、日期与当时可用范围。厂商自报性能不视为本站独立复现。
- OpenTelemetry Collector exporter helper persistent queue documentation at v0.156.0 commit
- Collector Contrib file storage extension README at v0.156.0 commit
- Collector Contrib file storage configuration source at v0.156.0 commit
- OpenTelemetry Collector internal telemetry documentation at v0.156.0 commit
- Jaeger Badger storage data model at v2.19.0 commit
- Jaeger v2.19.0 query extension documentation
讨论