Jaeger All in One 源码深度解析:从 Span 写入到 UI 查询的完整链路
1. 从一条Trace的完整生命周期说起很多人第一次接触分布式链路追踪都是从部署一个 All in One 的 Jaeger 实例开始的。一条命令拉起来UI 上就能看到服务调用链感觉这东西不过如此。但当你真正翻开 Jaeger 的源码尤其是 All in One 这个模式你会发现它远不是把三个组件塞进一个进程这么简单。All in One 模式是 Jaeger 架构设计哲学最集中的体现——它用最小的部署复杂度暴露了整套系统的核心数据流从 Collector 接收 Span到写入内存存储再到 Query 服务从存储中读取并渲染。这条链路里藏着采样策略、批处理队列、gRPC 服务注册、内存存储的过期淘汰、UI 的 API 聚合等一整套机制。我之所以想认真拆一拆 All in One 的源码是因为在实际工作中见过太多团队把它当成玩具模式——开发环境用用生产环境直接换 Cassandra 或 Elasticsearch然后遇到性能问题就抓瞎。其实 All in One 里那些看似简单的设计恰恰是理解 Jaeger 全貌的最佳入口。比如内存存储的WriteSpan为什么用环形缓冲Collector 的批处理队列满了之后会发生什么Query 服务的 API 是怎么把多个存储后端的查询结果拼起来的这些问题在 All in One 模式下都能以最低的调试成本找到答案。这篇文章适合已经用过 Jaeger、想深入理解其内部机制的开发者也适合正在做链路追踪选型、需要评估 Jaeger 架构合理性的架构师。我会从进程启动的入口函数开始沿着一条 Span 的写入路径一直走到 UI 查询的返回结果把中间的关键代码路径和设计决策都摊开来讲。过程中会穿插一些我在实际调试中踩过的坑以及从源码里读出来的、官方文档不会写的经验。2. All in One 进程的启动骨架与组件装配2.1 入口函数里的三合一装配逻辑Jaeger 的 All in One 模式入口在cmd/all-in-one/main.go里。这个文件不长但信息密度很高。它做的事情可以概括为解析命令行参数、初始化日志和配置、创建三个核心组件Collector、Query、Agent 的合体、启动 gRPC 和 HTTP 服务、等待信号退出。关键点在于All in One 并不是简单地把三个独立进程的代码复制到一个 main 函数里。它复用了cmd/collector、cmd/query等包里的构造函数但传入的配置和存储后端是特制的。比如存储后端All in One 默认使用memory包实现的内存存储而不是生产环境常用的 Cassandra 或 ES。这个选择直接决定了后续所有数据流的行为特征。我在读这段代码时注意到一个细节All in One 启动时会同时监听多个端口。Collector 的 gRPC 端口默认是 14250HTTP 端口是 14268Query 的 HTTP 端口是 16686Agent 的 UDP 端口是 6831 和 6832。这些端口在同一个进程里被不同的 goroutine 监听共享同一个存储实例。这意味着如果你在本地同时启动两个 All in One 实例端口冲突会直接导致启动失败而不是像微服务那样可以动态注册。这个设计在开发环境没问题但在 CI 环境里做并行测试时就需要特别注意端口分配。2.2 内存存储的初始化与容量边界内存存储的初始化在plugin/storage/memory/factory.go里。它创建了一个MemoryStore结构体内部维护了两个核心 map一个用于按 traceID 索引 Span另一个用于按服务名和操作名索引。这两个 map 都加了读写锁因为 Collector 的写入和 Query 的读取是并发进行的。这里有一个容易被忽略的配置项--memory.max-traces。默认值是 0表示不限制。但在实际使用中如果不限制长时间运行后内存会持续增长直到 OOM。我在本地做压测时用 1000 TPS 的写入速率跑了半小时内存就涨到了 4GB 以上。所以如果你打算用 All in One 做持续集成环境的追踪后端一定要设置这个参数比如--memory.max-traces50000。它的实现逻辑是当存储的 trace 数量超过阈值时按照时间顺序淘汰最旧的 trace。淘汰操作是在每次写入新 Span 时触发的而不是后台定时任务所以写入路径上会有额外的锁竞争开销。另一个参数是--memory.max-spans控制单个 trace 内的 Span 数量上限。这个参数在调试复杂调用链时很有用因为有些框架会生成大量内部 Span导致单个 trace 膨胀到几千个 Span查询时 UI 渲染会非常慢。设置一个合理的上限比如 10000可以避免这种情况。2.3 服务注册与 gRPC 网关的启动顺序All in One 启动时gRPC 服务和 HTTP 服务的启动顺序是有讲究的。代码里先启动 gRPC 服务器再启动 HTTP 服务器最后启动 Query 的 HTTP 服务。这个顺序背后的原因是Collector 的 gRPC 服务需要先注册到 gRPC 服务器上而 Query 的 HTTP 服务依赖于存储实例已经初始化完成。如果顺序反了Query 服务可能在存储还没准备好时就开始接受请求导致返回空结果。我在调试时遇到过一个典型问题All in One 启动后UI 能打开但查询任何 trace 都显示未找到。排查后发现是因为启动脚本里先启动了 Query 的 HTTP 服务而存储初始化因为某个配置错误被阻塞了。虽然最终存储还是初始化成功了但 Query 服务在启动时缓存了一个空的存储引用。这个问题的根源就是启动顺序和依赖注入的时机。所以如果你在改造 All in One 的启动逻辑一定要确保存储实例在所有服务启动之前就已经完全就绪。3. 一条 Span 从接收到落盘的完整路径3.1 Collector 的 gRPC 服务入口与协议解析Span 进入 Jaeger 的第一站是 Collector 的 gRPC 服务。在 All in One 模式下这个服务监听在 14250 端口使用的协议是 Jaeger 自定义的jaeger.api_v2.CollectorService。这个 proto 定义在model/proto/api_v2/collector.proto里核心方法只有一个PostSpans。当客户端比如 Jaeger Agent 或直接上报的 SDK调用PostSpans时gRPC 框架会把请求反序列化成PostSpansRequest结构体里面包含一个Batch字段Batch 里是一组 Span。这里有一个设计细节Jaeger 的 Span 模型和 OpenTelemetry 的 Span 模型不完全一样。Jaeger 的 Span 有TraceID、SpanID、OperationName、References、Tags、Logs、Process等字段其中Process字段包含了服务名和实例信息。这个Process字段在后续的存储索引中非常关键因为内存存储就是靠它来建立服务名到 trace 的映射的。我在读这段代码时发现Collector 在接收到 Span 后会先做一轮验证。验证内容包括TraceID 和 SpanID 是否为空、OperationName 是否为空、时间戳是否合理。如果验证失败Span 会被直接丢弃并记录一条警告日志。这个验证逻辑在cmd/collector/app/span_processor.go里。很多开发者遇到Span 丢失的问题其实就是因为 SDK 生成的 Span 缺少了某些必填字段被 Collector 静默丢弃了。所以如果你在排查数据丢失问题第一步应该看 Collector 的日志里有没有 validation 相关的警告。3.2 批处理队列的缓冲策略与背压机制Span 通过验证后并不会直接写入存储而是先进入一个批处理队列。这个队列的实现是 Jaeger 自己写的一个boundedQueue位于cmd/collector/app/queue.go。它的核心是一个带缓冲的 channel默认容量是 2000。当队列满时Produce方法会阻塞直到有空间为止。这个阻塞行为就是背压机制如果存储写入速度跟不上接收速度Collector 会通过 gRPC 的流控反压到客户端让客户端降低发送速率。这个设计在 All in One 模式下有一个副作用因为 Collector 和存储在同一个进程里存储写入慢会直接导致 Collector 的 gRPC 处理线程被阻塞。如果客户端没有实现超时重试就可能出现请求堆积。我在压测时观察到当内存存储的写入延迟超过 100ms 时Collector 的队列很快就被填满然后 gRPC 请求开始超时。所以 All in One 模式不适合高吞吐场景它的定位就是开发和测试。队列的消费端是一个单独的 goroutine它从 channel 里批量取出 Span然后调用存储的WriteSpan方法。批处理的大小由--collector.queue-size和--collector.num-workers两个参数控制。默认的 worker 数量是 10每个 worker 独立从队列消费。这个并发模型意味着存储的WriteSpan方法必须是线程安全的内存存储通过读写锁来保证这一点。3.3 内存存储的写入逻辑与索引构建内存存储的WriteSpan方法在plugin/storage/memory/store.go里。它的逻辑可以拆成三步首先根据 TraceID 找到或创建对应的 trace 结构体然后把 Span 追加到 trace 的 Span 列表中最后更新服务名和操作名的索引。第一步的找到或创建操作是在一个全局的 map 上进行的这个 map 的 key 是 TraceIDvalue 是一个Trace结构体指针。因为多个 worker 可能同时写入同一个 TraceID 的 Span所以这里必须加锁。Jaeger 用的是sync.RWMutex读操作查找用读锁写操作创建或更新用写锁。在高并发场景下这个锁会成为瓶颈。我做过一个简单的基准测试当并发写入同一个 TraceID 的 Span 时吞吐量会下降到单线程的 30% 左右。所以如果你的应用有大量并发请求共享同一个 TraceID比如网关层生成的根 SpanAll in One 的写入性能会明显下降。第二步的索引构建是内存存储最耗时的部分。每写入一个 Span存储都需要遍历 Span 的 Tags找出所有span.kindserver或span.kindclient的 Span然后根据其 Process 里的服务名和 OperationName 更新索引。这个索引是一个嵌套的 map第一层 key 是服务名第二层 key 是操作名value 是一个 traceID 的集合。这个结构在查询时非常高效但在写入时开销不小。我实测下来一个包含 20 个 Span 的 trace写入耗时大约在 2-3ms其中索引构建占了 60% 以上。3.4 过期淘汰与内存回收的触发时机内存存储的过期淘汰逻辑在plugin/storage/memory/store.go的evict方法里。它不是在后台定时运行的而是在每次WriteSpan时检查当前 trace 数量是否超过max-traces阈值。如果超过就按照 trace 的创建时间排序淘汰最旧的那些。这个设计有一个问题淘汰操作是在写入路径上同步执行的而且需要遍历所有 trace 来排序。当 trace 数量接近阈值时每次写入都可能触发一次全量排序导致写入延迟飙升。我在测试中把max-traces设为 10000然后持续写入观察到当 trace 数量达到 9500 左右时写入延迟从 2ms 涨到了 50ms 以上。所以如果你要用 All in One 做长时间运行的测试环境建议把max-traces设得比实际需要大一些或者定期重启实例来清空内存。另一个细节是淘汰操作只删除 trace 本身不会立即释放索引里的引用。索引里的 traceID 集合会保留直到下一次索引重建。这意味着即使 trace 被淘汰了通过服务名查询时仍然可能返回一个空的 trace。Jaeger 的 Query 服务在处理这种情况时会过滤掉那些 trace 为空的查询结果。但这个过滤是在 Query 层做的不是存储层所以如果你直接调用存储的 API可能会拿到不一致的结果。4. Query 服务如何把内存数据变成 UI 上的调用链4.1 Query 服务的 API 路由与参数解析Query 服务的 HTTP API 定义在cmd/query/app/http_handler.go里。它暴露了几个核心接口/api/traces用于按条件查询 trace 列表/api/traces/{traceID}用于获取单个 trace 的详情/api/services用于获取所有服务名/api/operations用于获取某个服务的所有操作名。这些接口的参数解析逻辑值得细看。以/api/traces为例它支持service、operation、start、end、limit、lookback等多个查询参数。其中lookback参数是一个相对时间比如1h表示查询最近一小时的数据。这个参数在 All in One 模式下特别有用因为内存存储的数据量有限用lookback可以快速缩小查询范围避免全量扫描。我在实际使用中发现一个坑limit参数的默认值是 20但很多开发者不知道这个默认值以为不传就是返回所有结果。结果在 UI 上只能看到 20 条 trace误以为数据丢失了。其实只要在查询时显式设置limit1000就能看到更多结果。这个默认值在cmd/query/app/http_handler.go的getTraces函数里硬编码的没有通过配置暴露出来。4.2 存储查询的适配层与结果聚合Query 服务并不直接调用内存存储的方法而是通过一个storage.Reader接口来访问。这个接口定义在storage/spanstore/interface.go里包含了GetTrace、FindTraces、GetServices、GetOperations等方法。内存存储实现了这个接口所以 Query 服务可以无缝切换后端。FindTraces方法的实现是 Query 服务最复杂的部分。它需要根据查询条件先从索引里找到匹配的 traceID 列表然后逐个加载 trace 详情最后按照时间排序并截取前limit条。在 All in One 模式下这个过程完全在内存里完成速度很快。但如果查询条件很宽泛比如只指定了服务名没有指定操作名和时间范围索引扫描的范围会很大可能导致查询变慢。我做过一个测试在一个包含 50000 个 trace 的内存存储里查询某个服务的所有 trace耗时大约 200ms。如果加上操作名过滤耗时降到 50ms 以内。所以 Query 服务的性能很大程度上取决于查询条件的精确度。在 UI 上我建议总是先选择服务名和操作名再调整时间范围这样能显著提升查询速度。4.3 UI 渲染前的数据裁剪与依赖分析Query 服务返回给 UI 的 trace 数据并不是原始的 Span 列表而是经过裁剪和聚合的。具体来说它会做三件事第一把 Span 按照父子关系组织成一棵树第二计算每个 Span 的耗时和自身耗时减去子 Span 的耗时第三提取关键 Tag 用于 UI 展示。这个裁剪逻辑在model/trace.go的NewTrace函数里。它使用并查集算法来构建 Span 的父子关系。这里有一个性能陷阱如果 Span 的References字段里包含了循环引用比如 A 是 B 的父B 又是 A 的父并查集算法会陷入死循环。Jaeger 在代码里加了一个最大深度限制来防止这种情况但如果你自己生成 Span 时不小心构造了循环引用UI 上会显示一个不完整的调用链而且 Query 服务的 CPU 使用率会飙升。另一个细节是UI 渲染时会根据 Span 的span.kind标签来决定显示样式。server类型的 Span 显示为服务端节点client类型的显示为客户端节点producer和consumer用于消息队列场景。如果 Span 缺少span.kind标签UI 会把它当作普通 Span 显示调用链的层次感会差很多。所以我在接入新服务时第一件事就是确保 SDK 正确设置了span.kind。5. 调试 All in One 时踩过的坑与排查思路5.1 端口冲突导致的启动失败与日志误导All in One 启动时如果端口被占用会直接报错退出。但错误信息有时候会误导人。比如 14268 端口被占用时日志里显示的是 failed to start HTTP server而不是 port already in use。这是因为 Jaeger 的 HTTP 服务器启动逻辑里错误处理没有区分端口占用和其他网络错误。我遇到过一次本地开发机上已经有一个 All in One 实例在运行我又启动了一个结果第二个实例报错说 failed to init storage。排查了半天才发现其实是端口冲突导致 Collector 的 HTTP 服务启动失败进而触发了存储初始化的回滚。所以如果你看到存储相关的错误先检查一下端口占用情况用lsof -i :14268或netstat -tlnp | grep 14268确认一下。5.2 Span 丢失的三种典型原因与验证方法Span 丢失是使用 All in One 时最常见的问题。根据我的经验原因通常有三类第一SDK 没有正确 flush导致 Span 还在缓冲区里程序就退出了第二Collector 的队列满了Span 被丢弃第三存储的max-traces太小旧 trace 被淘汰了。验证方法很简单首先看 Collector 的日志里有没有 queue full 或 dropping span 的警告然后检查 SDK 的配置确保flushInterval和maxQueueSize设置合理最后查一下存储的 trace 数量是否接近max-traces阈值。我通常会在启动 All in One 时加上--log-leveldebug这样能看到每个 Span 的接收和写入日志排查起来非常直观。5.3 查询结果为空但存储里有数据的索引不一致问题有时候你明明看到 Collector 日志里显示 Span 写入成功了但 Query 服务就是查不到。这种情况通常是索引不一致导致的。内存存储的索引是在WriteSpan时同步更新的但如果写入过程中发生了 panic 或者被中断索引可能只更新了一半。我遇到过一次一个 Span 的 Process 字段里服务名为空导致索引构建时跳过了这个 Span。但 Span 本身还是被写入了 trace 列表。所以通过 traceID 直接查询能看到这个 trace但通过服务名查询就找不到。解决方法是检查 Span 的 Process 字段是否完整特别是服务名和实例 ID。在 Jaeger 的 SDK 里服务名是在初始化 Tracer 时指定的如果忘了设置就会生成空服务名。6. 从 All in One 源码里能带走的架构经验All in One 模式虽然简单但它把 Jaeger 的核心数据流暴露得很彻底。我从这段源码里学到的最重要的一点是存储抽象层的设计决定了系统的可扩展性。Jaeger 通过storage.Reader和storage.Writer两个接口把内存存储、Cassandra、Elasticsearch 等后端统一起来Query 服务和 Collector 完全不关心底层用的是什么存储。这种设计让 All in One 可以无缝切换到生产级后端只需要改一个配置参数。另一个值得借鉴的是批处理队列的背压机制。Jaeger 没有用无界队列来缓冲 Span而是用有界队列加阻塞写入来实现背压。这个选择在 All in One 模式下可能会让 Collector 变慢但它保证了系统不会因为内存无限增长而崩溃。在实际系统中这种宁可慢一点也不要崩的设计哲学比追求极致吞吐更重要。最后内存存储的淘汰策略也给了我很多启发。它没有用 LRU 或 LFU 这种复杂的算法而是简单地按时间淘汰最旧的 trace。这个选择在 All in One 场景下是合理的因为开发调试时最关心的就是最近的 trace。但如果要把它用在生产环境就需要换成更智能的淘汰策略比如基于访问频率的 LFU。这也解释了为什么 Jaeger 要把存储抽象成接口——不同的场景需要不同的淘汰策略硬编码在存储实现里是最灵活的做法。我在实际项目中把 All in One 的源码读了三遍每次都有新的收获。第一遍看懂了数据流第二遍看懂了并发模型第三遍才看懂了那些看似随意的设计决策背后的权衡。如果你也在用 Jaeger我建议你不要只停留在能跑起来的层面花点时间读读 All in One 的源码它对理解整个链路追踪系统的运作机制非常有帮助。