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 端口默认是 14250,HTTP 端口是 14268;Query 的 HTTP 端口是 16686;Agent 的 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-traces=50000。它的实现逻辑是:当存储的 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 是 TraceID,value 是一个Trace结构体指针。因为多个 worker 可能同时写入同一个 TraceID 的 Span,所以这里必须加锁。Jaeger 用的是sync.RWMutex,读操作(查找)用读锁,写操作(创建或更新)用写锁。在高并发场景下,这个锁会成为瓶颈。我做过一个简单的基准测试:当并发写入同一个 TraceID 的 Span 时,吞吐量会下降到单线程的 30% 左右。所以如果你的应用有大量并发请求共享同一个 TraceID(比如网关层生成的根 Span),All in One 的写入性能会明显下降。
第二步的索引构建是内存存储最耗时的部分。每写入一个 Span,存储都需要遍历 Span 的 Tags,找出所有span.kind=server或span.kind=client的 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,误以为数据丢失了。其实只要在查询时显式设置limit=1000,就能看到更多结果。这个默认值在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-level=debug,这样能看到每个 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 的源码,它对理解整个链路追踪系统的运作机制非常有帮助。