Jaeger All in One 源码深度解析:从启动到查询的完整链路

发布时间:2026/10/17 17:31:14
Jaeger All in One 源码深度解析:从启动到查询的完整链路
1. 为什么值得花时间读 All in One 模式的源码分布式链路追踪这个领域Jaeger 算是绕不开的一个项目。大多数团队第一次接触它都是从 All in One 模式开始的——一条docker run命令就能把整套追踪后端跑起来UI、收集器、查询服务、存储全在一个进程里。用起来确实爽但很多人用完就停在“能跑就行”的层面一旦线上出问题或者要做二次开发就抓瞎了。我自己第一次读 Jaeger 源码的时候也是从 All in One 模式切入的。原因很简单它是整个项目里依赖最少、启动路径最短、代码分支最收敛的一种部署形态。你不需要先去理解 Kafka 的消费者组怎么工作也不用管 Cassandra 的 schema 怎么迁移所有组件都在一个进程里调用关系一目了然。把这条主线捋清楚再去看生产环境的分布式部署基本就是“把同一个东西拆到不同进程”的差别。这篇文章面向的是已经用过 Jaeger、想进一步搞清楚它内部怎么运转的开发者。我会从启动入口一路拆到数据落盘和查询返回把 All in One 模式下的关键代码路径、组件协作方式、以及几个容易踩坑的细节讲透。读完之后你应该能自己回答这些问题为什么 All in One 默认用内存存储采样策略是在哪一层生效的UI 上的 trace 是怎么从存储里被捞出来的以及如果我想把它改造成一个自定义的存储后端应该从哪个接口下手。需要提前说明的是下面涉及的代码结构和调用关系是基于我对该项目常见版本组织方式的梳理具体行号和文件路径可能随版本演进有差异但核心设计思路是稳定的。你对照自己手上的版本读的时候重点关注组件边界和数据流向而不是死记某个函数名。2. All in One 的整体架构与启动主线2.1 一个进程里到底塞了哪些组件很多人以为 All in One 就是“简化版”其实它一点都不简化只是把原本分散部署的组件合并到了同一个进程内通过内存队列和直接函数调用代替了网络通信。拆开看它内部至少包含这么几块Collector收集器接收来自客户端的追踪数据做校验、采样决策、格式转换。Agent代理在标准部署里 Agent 是独立进程负责接收 UDP 上报并批量转发给 Collector。All in One 模式下这块被弱化或内联处理。Query Service查询服务对外提供 HTTP API供 UI 查询 trace 和依赖关系。Storage存储默认是内存实现也可以配置成其他后端。UI静态资源加一层 API 代理打包在同一个二进制里。这五块东西在 All in One 里共享同一个进程空间所以它们之间的“通信”很多时候就是一次方法调用而不是 gRPC 或 HTTP 请求。这一点非常关键因为它决定了你读源码时的断点该打在哪里——你不需要去追网络包直接在内存里跟数据流就行。从设计动机上讲这种合并是为了降低上手门槛和本地开发成本。一个刚接触链路追踪的团队不可能一上来就搭一套完整的分布式后端。All in One 让他们能在笔记本上五分钟跑起来先感受价值再决定要不要上生产级部署。所以它的代码组织天然带着“可裁剪”的特征每个组件都实现了标准接口合并只是组装方式的不同。2.2 启动入口与组件装配顺序All in One 的启动入口通常是一个main函数里面做的事情可以概括成三步解析配置、初始化存储、装配并启动各服务。第一步解析配置会读取命令行参数和环境变量决定用哪种存储、监听哪些端口、采样策略从哪加载。这里有个细节值得注意All in One 的配置项和分布式部署是共用一套结构体的只是很多字段在单进程模式下用不到。所以你读配置代码的时候会看到大量“这个字段只在某某模式下生效”的注释别被绕晕。第二步初始化存储是整个启动流程里最关键的一环。因为存储实现决定了后面 Collector 和 Query 能不能正常工作。默认走的是内存存储初始化时就是创建几个 map 和锁几乎没有外部依赖。如果配置成其他后端这一步会去建立连接、检查 schema失败就直接启动不了。第三步装配服务会按照依赖顺序把 Collector、Query、UI 依次拉起。这里体现了一个很朴素的设计原则谁被依赖谁先启动。Query 依赖 StorageCollector 也依赖 Storage所以 Storage 必须最先就绪。UI 依赖 Query 的 API所以排在 Query 后面。这种顺序在单进程里靠代码的先后调用保证在分布式部署里则靠健康检查和重试来保证。提示读启动代码时建议先把main函数完整读一遍画出组件初始化的先后顺序图。这张图是你后面理解数据流的骨架比任何文档都管用。2.3 为什么默认存储是内存而不是别的这个问题我被问过很多次。答案其实不复杂内存存储是唯一一个零外部依赖、零配置、启动即用的实现。All in One 的定位是“快速体验和本地开发”如果默认存储需要依赖一个外部数据库那“五分钟跑起来”就成了空话。但内存存储有个致命特点——进程一重启数据全没。这在体验阶段无所谓但在任何稍微认真一点的使用场景里都是不可接受的。所以源码里内存存储的实现非常“克制”它只保证接口语义正确不追求性能优化也不做持久化。你能从它的代码里看到大量“够用就行”的取舍比如查询就是遍历所有 trace 做过滤没有索引没有分片。理解这一点很重要因为它解释了为什么很多人用 All in One 跑了一段时间后会发现 UI 越来越慢、内存越来越高。不是代码有 bug而是内存存储本来就不是为长期运行设计的。真要长期用必须换后端。而换后端的切入点就是存储接口的实现替换。3. 数据写入链路从客户端上报到落盘3.1 Collector 接收数据的两种协议Collector 对外暴露的接收端点常见的有两类协议一类是基于 HTTP 的 JSON 上报另一类是基于 gRPC 的二进制上报。客户端 SDK 通常会优先用 gRPC因为序列化效率高、体积小HTTP 那套更多是给不方便引入 gRPC 依赖的场景兜底。在 All in One 模式下这两类端点会同时监听在不同端口上。你读代码的时候会发现它们最终都会把数据转换成同一套内部数据结构然后交给同一个处理管线。这个设计很聪明协议解析层和处理逻辑层解耦新增一种协议只需要写一个解析适配器后面的采样、存储逻辑完全不用动。具体到代码里gRPC 那条路径会先经过一个 protobuf 定义的消息结构然后被转换成本地的 span 模型。HTTP 那条路径则是先解析 JSON再做同样的转换。转换过程中会做一些基础校验比如 trace ID 和 span ID 是否为空、时间戳是否合理。校验不通过的数据会被直接丢弃并记录指标不会进入后续流程。注意如果你在做自定义客户端对接最容易出问题的地方就是字段命名和类型。JSON 上报时时间戳单位是微秒还是纳秒、ID 是十六进制字符串还是字节数组这些细节对不上数据就会在转换阶段被静默丢弃。排查时先看 Collector 的日志和丢弃指标别一上来就怀疑存储。3.2 采样决策发生在哪一层采样是链路追踪里最容易被误解的一环。很多人以为采样是在客户端做的其实在 Jaeger 的体系里采样决策可以在多个层次发生而 All in One 模式下最关键的决策点在 Collector 这一侧。客户端 SDK 通常会带一个采样标记表示这条 trace 是否被采样。但 Collector 收到数据后还会根据服务端配置的采样策略做一次判断。这两层的关系是客户端采样负责在源头减少上报量服务端采样负责在入口做最终裁决。如果服务端策略说“不采”那即使客户端标了“采”数据也会被丢弃。源码里这块逻辑通常封装在一个策略接口后面All in One 默认加载的是基于配置文件的策略。策略的匹配维度一般包括服务名、操作名、以及一些标签。匹配到之后按配置的速率或概率决定是否保留。这里有个实现细节采样决策是按 trace 维度做的不是按 span 维度。也就是说同一条 trace 里的所有 span 要么全留要么全丢否则链路就断了。这个设计直接影响了存储的写入模式。因为一条 trace 的 span 可能来自不同服务、在不同时间到达 Collector所以 Collector 需要维护一个短期的“决策缓存”保证同一条 trace 的后续 span 能拿到一致的决策结果。这个缓存的生命周期和容量是调优时值得关注的参数。3.3 从 span 到存储记录的转换过程数据通过采样这一关之后就进入写入存储的环节。这一步的核心工作是把内部的 span 模型转换成存储层能理解的记录格式。不同存储后端的记录格式差异很大。内存存储就是往 map 里塞对象几乎不做转换而像列式存储那种后端可能需要把 span 拆成多个列族分别写入不同的键空间。所以源码里这一层通常有一个存储接口定义了几个核心方法写入 span、按 trace ID 查询、按条件搜索、写入依赖关系等。每种后端实现这个接口Collector 只依赖接口不依赖具体实现。在 All in One 的内存实现里写入逻辑大致是先按 trace ID 找到对应的 trace 对象没有就新建一个然后把 span 追加进去同时更新一些用于查询的辅助索引比如按服务名、按时间范围的倒排结构。这些索引是内存存储能支持基本查询的前提但也是内存占用的主要来源。这里有个容易被忽略的点写入是批量还是单条。在分布式部署里Collector 通常会攒一批 span 再批量写存储以减少网络往返。但在 All in One 的内存实现里批量写入的意义不大因为根本没有网络开销。所以你可能会看到内存实现里批量接口就是循环调用单条写入。这不是偷懒而是场景决定的合理取舍。4. 数据查询链路UI 上的 trace 是怎么被捞出来的4.1 Query Service 的 API 设计Query Service 对外提供的 API 大致分三类按 trace ID 精确查询、按条件搜索 trace、查询服务依赖关系。这三类 API 对应了 UI 上最常见的三种操作点开某条具体链路、按服务和时间筛选链路列表、看服务之间的调用拓扑。按 trace ID 查询是最简单的直接拿 ID 去存储里取。搜索就复杂多了因为要支持服务名、操作名、时间范围、持续时间、标签等多个维度的组合过滤。依赖关系查询则是另一套逻辑它需要基于已有的 span 数据做聚合计算得出“服务 A 调用了服务 B 多少次”这样的结果。源码里这三类 API 通常分在不同的 handler 里但它们共享同一个存储接口。这意味着存储实现的能力直接决定了 API 的能力。如果某个存储后端不支持标签过滤那对应的 API 就只能返回空或者报错。这也是为什么换存储后端时一定要先确认它支持哪些查询能力。提示读 Query 代码时建议从 API 路由注册的地方入手先搞清楚有哪些端点再逐个看 handler 的实现。这样比从头顺着读效率高得多因为 handler 之间的共性很强。4.2 内存存储的查询实现与性能边界内存存储的查询实现说白了就是遍历加过滤。按 trace ID 查是 map 查找O(1)按条件搜索就是遍历所有 trace逐个判断是否满足条件复杂度跟数据量线性相关。这种实现在数据量小的时候完全没问题几百上千条 trace 的搜索是毫秒级的。但数据量一上来问题就暴露了每次搜索都要全量遍历CPU 打满响应时间飙升。而且因为所有数据都在内存里进程的内存占用会随着 trace 数量持续增长最终触发 OOM 或者被系统杀掉。源码里其实做了一些缓解措施比如限制返回条数、对时间范围做预过滤。但这些都治标不治本。真正的解法是换一个带索引的存储后端。理解内存存储的性能边界能帮你在它“变慢”之前就做出判断而不是等到线上告警了才手忙脚乱。这里有个实操经验如果你只是本地开发调试内存存储完全够用但建议定期重启 All in One 进程把积累的数据清掉。或者配置一个较小的数据保留窗口让老数据自动淘汰。别让它一直跑着不管否则迟早会遇到内存问题。4.3 依赖关系图是怎么算出来的依赖关系图是 UI 上一个很受欢迎的功能它把服务之间的调用关系画成一张有向图。这张图的数据来源是对一段时间内所有 span 的父子关系聚合。具体算法思路是遍历指定时间范围内的 span对每个 span 找到它的父 span如果父子分属不同服务就记一笔“服务 A 到服务 B 的调用”。遍历完成后把所有调用关系按服务对聚合算出调用次数再输出成图数据。这个计算在数据量大时开销不小所以源码里通常会有缓存机制避免每次刷新 UI 都重算一遍。缓存的有效期和刷新策略是影响依赖图实时性的关键参数。如果你发现依赖图更新不及时先去看缓存配置而不是怀疑数据没写进去。另外要注意依赖关系的准确性依赖于父子 span 的完整上报。如果客户端只上报了子 span 没上报父 span或者跨服务的上下文传递断了算出来的图就会有缺失或错误。这类问题在排查时往往要回到客户端 SDK 的埋点逻辑去找原因而不是在后端代码里纠结。5. 实操中容易踩的坑与排查思路5.1 数据写不进去的常见原因数据写不进去表现是 UI 上查不到 trace但客户端明明上报了。这种情况我遇到过好几次原因基本集中在几个地方。第一是端口没对上。客户端配的上报地址和 All in One 实际监听的端口不一致数据发到了一个没人听的端口上自然就丢了。排查方法很简单看 All in One 启动日志里打印的监听地址再核对客户端配置。第二是协议不匹配。客户端用 gRPC 上报但 All in One 只开了 HTTP 端口或者反过来。这种情况通常会有连接错误但有些客户端会静默重试导致你以为发出去了其实没成功。第三是采样策略把数据全丢了。如果服务端配置的采样率是 0或者策略匹配规则写错了所有数据都会在采样环节被丢弃。这时候客户端看起来一切正常但后端就是没数据。排查时去看采样相关的指标能快速定位。第四是存储初始化失败但进程没退出。这种情况比较隐蔽进程活着端口也监听着但存储层其实是坏的写入全部失败。看日志里有没有存储相关的错误是关键。5.2 查询变慢的排查路径查询变慢通常不是突然发生的而是随着数据积累逐渐恶化。排查时按这个顺序走先确认数据量。内存存储下trace 总数直接决定了搜索的耗时。如果已经积累了几十万条那慢是必然的不是 bug。再看查询条件。不带时间范围的搜索是最慢的因为它要遍历全部数据。加上时间范围能大幅缩小扫描量。如果 UI 上没设时间范围默认可能是最近一小时但如果数据都集中在更早的时间就会查不到或者很慢。然后看返回条数限制。如果一次请求要返回几千条 trace序列化和传输本身就要花不少时间。适当调小返回条数能明显改善响应速度。最后才去怀疑代码层面的性能问题。内存存储的实现很简单不太可能有复杂的性能陷阱。真到了这一步多半是数据量已经超出了内存存储的设计承载能力该换后端了。5.3 内存占用持续增长的应对内存持续增长是 All in One 长期运行的典型症状。根因就是内存存储不淘汰数据来一条存一条只进不出。应对方式有几种按推荐程度排序换存储后端最彻底但成本最高。适合已经决定长期使用、且数据量确实大的场景。配置数据保留窗口让内存存储定期清理超过一定时间的 trace。这需要存储实现支持 TTL不是所有版本都有。定期重启进程最简单粗暴适合开发和测试环境。重启前记得确认数据可以丢。限制写入速率在采样层把采样率调低从源头减少数据量。这是治标但见效快。我个人的做法是本地开发环境直接用内存存储加定期重启不折腾一旦要给别人演示或者做压测就换成带持久化的后端避免演示到一半进程挂了。5.4 常见问题速查表现象可能原因排查方向UI 查不到任何 trace端口/协议不匹配、采样率为 0核对监听地址、检查采样配置部分 trace 缺失 span客户端上下文传递断裂检查 SDK 埋点和跨服务传递逻辑查询响应越来越慢数据量超出内存存储承载确认 trace 总数、加时间范围、考虑换后端进程内存持续上涨内存存储不淘汰数据配置保留窗口或定期重启依赖图不更新缓存未过期或数据不完整检查缓存配置、确认父子 span 完整启动后立即退出存储初始化失败看启动日志中的存储错误信息注意这张表里的排查方向是经验性的实际遇到问题时永远以日志和指标为准。链路追踪系统本身就应该被追踪别让排查工具本身成了黑盒。6. 从 All in One 走向生产部署的改造切入点6.1 存储接口是第一个要动的地方如果你打算把基于 All in One 验证过的方案推向生产第一个要改的就是存储。内存存储在生产环境没有任何生存空间必须换成持久化后端。改造的切入点就是那个存储接口。你需要做的是找到一个已经实现了该接口的后端配置好连接信息然后确认它支持的查询能力满足你的需求。如果现成后端都不满足那就自己实现这个接口。实现时重点关注几个方法写入 span、按 trace ID 查询、条件搜索、依赖关系写入。把这几个方法的语义搞清楚实现起来并不复杂。这里有个经验先跑通写入再调查询。写入链路简单直接容易验证查询涉及索引和过滤坑更多。分阶段推进出问题时容易定位是哪一层的锅。6.2 采样策略的重新设计All in One 默认的采样策略通常很简单可能就是一个固定概率。生产环境需要更精细的策略比如按服务分级、按错误率动态调整、对关键业务全采。重新设计采样策略时要平衡两个目标可观测性和成本。全采当然可观测性最好但存储和计算成本扛不住。采得太少出问题时又找不到足够的样本。常见的做法是对核心服务提高采样率对边缘服务降低采样率同时对包含错误的 trace 强制采样。策略的加载方式也要考虑。All in One 里策略可能是启动时加载一次生产环境需要支持动态更新否则调整采样率就得重启服务。这块通常需要配合一个配置中心或者策略下发服务来实现。6.3 组件拆分时的注意事项从单进程拆成分布式部署不是简单地把代码复制到不同机器上。有几个点必须提前想清楚。网络通信的可靠性。单进程里方法调用不会失败拆开后网络会抖动、会超时、会丢包。Collector 和存储之间、Query 和存储之间都需要重试和熔断机制。数据一致性。单进程里写入和查询共享同一份内存天然一致。拆开后写入和查询可能连的是不同副本会有延迟。UI 上刚上报的 trace 查不到可能只是还没同步过去不一定是 bug。配置管理。单进程只有一份配置拆开后每个组件都有自己的配置而且有些配置需要保持一致比如采样策略。配置的版本管理和下发是拆分后必须解决的问题。监控和告警。单进程只需要监控一个进程拆开后组件多了每个都要监控。而且组件之间的调用关系也需要监控否则出了问题很难定位是哪个环节卡住了。我见过不少团队All in One 用得好好的一拆分布式就各种问题。根因往往不是代码不行而是运维复杂度被低估了。拆分之前先把监控、配置、容错这几块的基础设施准备好再动手拆会顺利很多。6.4 一个务实的演进路线如果你现在还在 All in One 阶段想往生产走我建议按这个顺序演进第一步换持久化存储但保持单进程部署。这一步验证存储后端的可用性和性能风险可控。第二步把 Collector 和 Query 拆开。这两个组件的负载特征不同Collector 是写入密集Query 是读取密集拆开后可以独立扩缩容。第三步引入消息队列做缓冲。在 Collector 和存储之间加一层队列能削峰填谷避免写入压力直接打到存储上。第四步完善监控和告警。到这一步系统已经有一定复杂度了没有完善的监控就是裸奔。每一步都只改一个变量出问题时容易回滚和定位。别想着一步到位那通常意味着一步到坑里。7. 我个人读源码的一点方法最后聊点方法论层面的东西。读 Jaeger 这类项目的源码我最大的体会是别从第一行读到最后一行。代码量摆在那里顺着读很容易迷失在细节里。我的做法是先找数据流的起点和终点。起点是客户端上报终点是 UI 展示。把这两个端点找到然后顺着数据流走一遍中间经过哪些组件、每个组件做了什么转换自然就清楚了。All in One 模式特别适合这种方法因为数据流没有被网络切断是一条连续的线。第二个体会是带着问题读。比如你想知道采样在哪做的就专门去找采样相关的代码别管其他部分。这种目标导向的读法效率比通读高得多而且读到的知识是跟具体问题绑定的不容易忘。第三个体会是动手改一改。看懂了不代表真懂改一个参数、加一行日志、换一个存储实现跑起来看看效果才能真正理解代码的行为。All in One 模式改起来成本很低是绝佳的实验场。源码这东西读一遍有一遍的收获。第一遍看个大概第二遍抠细节第三遍就能看出设计者的取舍和权衡了。别指望一遍就全懂那不现实。