bigywater.tech
微服务 · 81

自己写一个最小的 trace 收集器

一个最小的 trace 收集器要做哪几件事?自己用 SQLite 存 span 能撑多少吞吐?索引对按 trace_id 的查询有多大帮助?怎么发现丢了 span 的不完整 trace?这篇用 57 行 Python 和 OpenTelemetry SDK 逐项量出来。

12194 = 12194
SDK 里的 span 总数与我写的收集器存下的 span 数;parent_id 和时间逐个相同
2,833 vs 166,775
逐个 span 提交与每个请求一个事务时,收集器每秒应答的 span 数
22.2 ms vs 0.009 ms
50 万个 span 的库里,没有索引与有索引时按 trace_id 取一个 trace 的耗时

收集器的三件事

实测 · 本机执行输出
  检查项                                     | 结果       
  ----------------------------------------+----------
  SDK 里的 span 总数(真值)                      | 12194    
  收集器里存下的 span 数                          | 12194    
  parent_id 与真值不同的                        | 0        
  开始时间或耗时与真值不同的                           | 0        
  服务名或 span 名与真值不同的                       | 0        
  随机抽 100 个 trace:还原出的 span 数等于真值且恰好 1 个根 | 100 / 100
  每个 trace 的 span 个数:最少 4、最多 8、平均 6.1;收集器的代码(parse 函数加 MiniCollector 类,含空行和注释)共 57 行
  收集器只需要做三件事:把 OTLP 请求解析成行、存下来、查询时按 parent_id 把行拼成树;和 SDK 里的内存 span 逐个比较,没有丢也没有错

解析 OTLP 请求、存下来、查询时按 parent_id 拼成树;和 SDK 里的真值逐个比较没有丢也没有错。

写入

实测 · 本机执行输出
  写法               | 存下的 span 数 | 全部请求都被应答用的时间(s) | 按这个时间算的 span/秒 | 全部落盘用的时间(s) | 按落盘时间算的 span/秒
  -----------------+------------+-----------------+----------------+-------------+---------------
  (a)每个 span 单独提交  | 4000       | 1.41            | 2,833          | 1.72        | 2,325         
  (b)每个请求一个事务,默认日志 | 100000     | 0.60            | 166,775        | 1.10        | 91,174        
  (c)每个请求一个事务,WAL  | 100000     | 0.58            | 173,029        | 1.08        | 92,791        
  (d)内存队列 + 后台批量写  | 100000     | 0.45            | 222,738        | 0.95        | 105,363       
  每个 span 单独提交的吞吐只有每秒两千多个;(b)(c)(d) 三种批量写法每秒都是几万到十几万个,彼此差别不大(都远高于单个提交),说明关键是把很多 span 合并成一次提交;内存队列加后台批量写把应答和落盘分开,应答最快,代价是崩溃时队列里还没写的 span 会丢(我没有测崩溃)。(a)只发了 8 个请求(4000 个 span),因为太慢;解析 protobuf 和 SQLite 写入在同一个进程里,这里的吞吐受 Python 的 GIL 限制

把很多 span 合并成一次提交是关键;逐个提交被落盘次数限制。

索引

实测 · 本机执行输出
  配置   | 数据库文件大小 | 平均每个 span 字节数 | 按 trace_id 取一个 trace(ms) | 耗时最长的 10 个根 span(ms) | 按服务统计 span 数(ms)
  -----+---------+---------------+--------------------------+----------------------+-----------------
  有索引  | 96 MB   | 192           | 0.009                    | 0.0                  | 108             
  没有索引 | 62 MB   | 125           | 22.2                     | 27                   | 117             
  灌入 500,000 个 span 的耗时:无索引 0.8 s;有索引的库在灌入之后再建两个索引用了 0.4 s(灌入本身 0.9 s)。按 trace_id 查一个 trace,索引让它从扫描整张表变成毫秒以内;统计类的查询(group by)用不上这两个索引,仍然要扫描全表

trace_id 索引让按 trace 查询从扫表变成毫秒以内,代价是更大的存储(96 MB 对 62 MB)。

残缺的 trace

实测 · 本机执行输出
  每个 span 被丢弃的概率 | 收集器里有记录的 trace 数 | 判定不完整的 trace 数 | 比例   
  ---------------+------------------+----------------+------
  0%             | 1000             | 0              | 0.0% 
  5%             | 1000             | 211            | 21.1%
  20%            | 1000             | 632            | 63.2%
  每个 trace 有 6 个 span,最里层的叶子 span(query)被丢不会让 trace 变成“残缺的树”(只是少了一个叶子,这种情形判定不出来),所以不完整的比例比“有 span 被丢的 trace 的比例”低;丢任何一个非叶子 span 都会让它的子 span 变成孤儿,丢根 span 则找不到根;收集器在查询时(或者等一个超时之后)检查这两个条件,就能给这类 trace 打上标记
ERRORS {}

按父 span 缺失或无根判定,丢任何非叶子 span 都会让子 span 变孤儿。

分步演示

最小的 trace 收集器
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / Go / nginx / Envoy / etcd / Consul / Nacos / OpenTelemetry / Prometheus(按各篇注明用到哪些及版本);所有服务都是本机自己起的进程,端口 9150 以上,只监听 127.0.0.1,不连任何外部服务,不碰本机已有的实例;故障用 kill -9 进程、让连接断开或在代码里注入延迟与错误来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。Python 3.14、sqlite3(Python 自带)、opentelemetry-proto 与 OpenTelemetry Python SDK 1.45.1(标准 OTLP/HTTP 导出器);吞吐受 Python 的 GIL 限制,用了 4 个客户端线程和预先编好的请求体,每种写法只测一次;索引实验的数据是随机生成的 50 万个 span(不是真实流量);没有测崩溃时内存队列里 span 的丢失
    • 没有验证:OTLP/gRPC 接收;压缩(gzip)请求;多个收集器实例与按 trace_id 的路由;数据保留与过期清理;SQLite 之外的存储(ClickHouse、Elasticsearch、对象存储)的写入与查询特性;尾部采样需要的 “等 trace 到齐” 缓冲;查询接口的分页、按属性过滤的索引
    京ICP备2026067502号-1京公网安备11010502063487号