很多人似乎认为分布式追踪没什么用,或者至少需要付出极大的努力,对于比Facebook小的公司来说不值得。例如,这里和这里有一些公开讨论,听起来就像我和许多人私下聊过的那样。当然,追踪确实有价值,但解锁它的成本太高了。
我认为这高估了从追踪中获得大量价值所需的工作量。在Twitter,Rebecca Isaacs能够提出一个从追踪中获取价值的愿景,并(在Jonathan Simms、Yuri Vishnevsky、Ruben Oanta、Dave Rusek、Hamdi Allam等多人的帮助下1)将其付诸实践,使得这项工作轻松地实现了自我回报。这篇文章将描述我们构建的追踪“基础设施”,并介绍一些我们发现它很有价值的用例。在进入正题之前,我们先了解一下Rebecca的愿景实现之前的背景情况。
从宏观层面看,我们可以说我们有一个基于追踪视图的系统,并遇到了所有可能由此引发的问题。这些问题在Cindy Sridharan的这篇文章中有更详细的讨论。不过,我想更详细地讨论我们遇到的具体问题,因为我认为审视哪些具体因素导致了问题是有用的。
总的来说,这些问题严重到追踪系统多年来一直缺乏管理,甚至可以说无人管理。有些人在业余时间做了一些工作来维持系统运行或改进它,但追踪缺乏明显价值导致了一个恶性循环:从追踪中获取价值的高门槛使得组织上难以获得资金支持,进而难以让追踪变得更易用。
导致追踪投资回报率低的一些问题包括:
- 模式使得无法“就地”运行简单查询
- 没有真正的方法来聚合信息
- 无法找到有趣或具有代表性的追踪
- 无法知道实际的采样率,采样高度不具代表性
- 时间
模式
模式实际上是一组追踪,每个追踪由一组跨度组成,每个跨度由一组注释组成。每个非根跨度都有一个指向其父跨度的指针,从而可以确定追踪的图结构。
为了本文的目的,我们可以将每个追踪视为一个外部请求(包括所有子RPC),或者一个请求的子集,其根位于下游而非请求的顶部。我们还追踪一些非请求的内容,比如构建和git操作,但为了简化,本文中我们将忽略这些,尽管我们将讨论的技术也适用于它们。
每个跨度对应一个RPC,每条注解是开发者选择在跨度上记录的数据(例如RPC负载大小、跨度发生时系统中各队列的深度,或中断RPC的GC暂停时长)。
采用“集合的集合(即包)”这种模式带来了一些问题,包括:
- 执行任何利用跟踪中固有图结构信息的查询时,都需要读取跟踪中的每个跨度并重建图结构
- 由于没有按跟踪维度的索引或摘要信息,任何针对跟踪的查询都必须读取该跟踪中的每个跨度
- 实际上,由于上述两点在查询时临时执行成本过高,人们唯一运行的查询就是某种变体的“给我一些符合简单过滤条件的跨度”
聚合
直到大约一年半前,查看跟踪的唯一支持方式是通过UI,从组合搜索框+下拉菜单中按服务名称过滤,然后查看最近跟踪的列表,点击任意跟踪即可进入“跟踪视图”。每次搜索返回最近N条结果,这些结果不一定能代表所有最近结果(原因见下文“采样”部分),更不用说代表其他任何时间范围内的所有结果了。
根据上述“模式”部分讨论的问题,由于在大量跟踪上运行查询成本过高,因此无法提出诸如“我看到的这些跟踪是否代表常见情况,还是我在看奇怪的边缘案例?”或“显示特定尾部事件的跟踪,例如服务A到服务B的请求超时,或服务A到某个后端数据库的写入放大超过3倍”,甚至“只显示完整跟踪,即没有丢弃跨度的跟踪”等问题。
此外,如果你点击了一个“过大”的跟踪,查询会超时,导致无法查看该跟踪——这是缺乏任何限流逻辑以及模式带来的另一个常见副作用。
采样
在多个地方会做出是否采样的决定。没有文档列出所有这些位置,因此如果不审计所有代码来找出采样决策点,甚至无法猜测采样率。
此外,由于实现方式,存在多处可能无意中做出采样决策的环节。启用了追踪的服务会将跨度(span)发送至本地代理,随后传输至“采集器”服务,再由采集器服务写入后端数据库。跨度可能在以下环节被丢弃:本地代理中;采集器中(其节点可能频繁宕机并丢失所有数据);以及后端数据库中(因热键或整体高负载而拒绝写入)。
这种以追踪ID作为数据库键的设计,且缺乏任何写入节奏控制逻辑,意味着一个包含100万跨度的追踪(我们确实遇到过)会在数秒内对同一键发起100万次写入。另一个问题是具有数千扇出(fanout)的请求(我任职过的每家科技公司都存在此类情况),这可能在数毫秒内对同一键发起数千次写入。
另一个采样异常在于:为避免遗漏未从内部前端发起的追踪,系统设计了逻辑,使每个RPC独立进行采样决策。若对此进行数学推算,在类似我们的面向服务架构中,即便采用看似适中的采样率(例如),最终绝大多数跨度将起始于叶子RPC,形成单跨度追踪。而在非叶子RPC中,绝大多数会起始于叶子层的第二级,依此类推。我们绝大部分负载与存储成本都消耗在这些近乎无用的、起始于叶子层或附近的追踪上。若想通过跨跨度分析理解整个系统的行为,则必须在考虑所有其他独立采样决策的基础上,额外应对这种采样偏差。
时间
系统几乎未对时钟偏差进行任何调整(虽存在某种机制,但仅尝试局部成对调整,不仅未改善问题,反而使合理处理时钟偏差变得更加困难)。
若仅简单计算跨度耗时——即便使用单台主机的时间戳(这能消除许多可能的时钟偏差来源)——仍会得到大量负值持续时间的跨度。这显然不可能,因为结果不可能在请求创建前返回。而若跨主机比较时间,结果则更为糟糕。
解决方案
这些问题的解决方案可以分为两类。第一类问题,比如采集节点宕机或后端数据库拒绝请求导致的跨度丢失,可以通过成熟且广泛使用的工程技术解决。针对这两个具体问题,短期应急方案是调整GC参数,将采集节点宕机率降低约100倍——这仅花了两分钟。随后我们用真正的队列替换了采集节点,既能吸收流量突发峰值,又能控制数据库写入节奏。至于因每次RPC都独立采样导致叶子级跨度过度采样的问题,这属于面试中多数人能答对但大型系统中容易被忽略的小问题,有多种解决方案:例如每个跨度都有父指针,我们可以在相关位置判断RPC是否有父节点,仅当跨度无父指针时才生成traceid并做采样决策,这样每个跨度被采样的概率均等,且每个采样轨迹都是完整轨迹。
第二类问题是构建数据集和工具(并添加注释),让用户能自主查询所需信息。这并非新思路,Dapper论文第5节早在2010年就已讨论过。
当然,一个显著差异是:谷歌在Dapper之上构建工具投入的精力至少比我们在追踪基础设施上多两个数量级,因此我们的工具相对粗糙。例如Dapper论文图6展示的轨迹视图包含相关直方图,便于理解轨迹上下文。我们尚未完成这类UI开发,类似功能需要运行简单SQL查询。虽然操作不难,但直接呈现数据比让用户自行查询的体验更好。
在已完成的工作中,最高ROI的显然是构建了包含用户可能查询信息的表格集合,其结构设计确保常见查询(无需大量计算)能高效执行。
我们按天分区维护了以下表格:
- trace_index
- 高级跟踪级别信息,例如:跟踪是否有根节点;根节点是什么;如果相关,命中了哪个请求端点等。
- span_index
- 客户端和服务端的信息
- anno_index
- 人们经常查询的“标准”注解,例如请求和响应的负载大小、客户端/服务端发送/接收的时间戳等。
- span_metrics
- 计算得出的指标,例如跨度持续时间
- flat_annotation
- 所有注解,用于查询 anno_index 中未包含的内容
- trace_graph
- 对于每个跟踪,包含一个跟踪的图形表示,用于需要图形结构的查询
仅凭这一组表格,通过 SQL 查询(或在 Presto SQL 不理想的情况下,例如进行某些图形查询时,使用 Scalding 或 Spark 作业)即可查询,就足以让跟踪系统实现自我价值,从难以证明其合理性转变为明显具有高价值。
我们通过这组表格解答过的一些问题包括:
- 对于这个出现问题的服务,给我一组具有代表性的跟踪数据
- 对于这个负载升高的服务,显示是哪个上游服务导致了负载
- 列出所有对下游服务 X 存在异常写入放大的服务
- 流量是否来自某个特定服务或特定端点,导致了异常写入放大?例如,在某些情况下,我们看到从 B 到 C 的总写入放大并无异常,但当 B 被 A 调用时,从 B 到 C 的放大却非常高。
- 显示我们在各种请求中,花费在序列化与“实际工作”上的时间对比
- 显示不同类型请求在后端工作方面的成本
- 对于由移动客户端检测到的高延迟请求,显示后端发生了什么
- 显示此请求端点的延迟关键路径集合(根据我们现有的注解,这存在一些问题,可能值得单独写一篇文章)
- 显示此服务所依赖服务的累积分布函数
- 这是一个分布,因为特定服务是否调用另一个服务取决于数据;一个服务平均每 1000 次调用才调用另一个服务的情况并不罕见
我们已经构建并正在构建其他工具,但仅凭能够对近期和历史的跟踪数据运行查询和聚合,就足以轻松回报我们想要做的所有其他工作。这类似于我们在处理指标数据时看到的情况:将已有的数据以一种允许人们运行任意查询的方式暴露出来,立即就带来了回报。对跟踪数据这样做比对指标数据更复杂,因为数据更丰富,但根本思路并无不同。
我认为,对于追踪而言,拥有比原始数据更直观的呈现方式比指标更重要。因为指标的原始“追踪视图”等价物——即直接查看图表的“仪表盘视图”——显然具有直观的实用性。如果指标只有这种视图,没人会说指标基础设施不值得投入,因为仪表盘确实非常有用!然而,从原始追踪数据中获取价值要困难得多,这正是许多关于追踪无用的评论的根源。指标数据与追踪数据在复杂性上的差异,使得追踪的高层视图带来的附加值远大于指标。
将数据以非NoSQL数据库中的简单二进制块形式存储,也让我们能更轻松地在追踪数据基础上构建工具,让不想运行SQL查询的用户也能从追踪数据中获益。服务依赖关系探索器(SDE)就是这样一个例子,它主要由Yuri Vishnevsky、Rebecca Isaacs和Jonathan Simms开发,Yihong Chen提供了协助。当我们查看单个请求的RPC调用图时,会得到相当庞大的结果。在某些情况下,调用树的深度可达数百层,且某些层级出现20个以上的扇出也很常见,这使得简单的可视化难以解读。
为了理解SDE的工作原理,我们先看一个相对简单的例子。假设有8个服务A到H,它们的调用关系如下图所示:服务A被调用10次,共调用服务B10次;B分别调用D、D和E各50次、20次和10次(两个D虽属同一服务但为不同RPC端点),以此类推:

从节点E的角度查看SDE,会看到以下内容:
我们可以直接看到调用者和被调用者,E 的 100% 调用来自 C,而 E 的 100% 调用也调用了 C,并且调用 C 时存在 20 倍的负载放大(200/10 = 20),这与我们查看上述 RPC 树时看到的结果一致。如果查看间接被调用者,可以看到 D 存在 4 倍的负载放大(40 / 10 = 4)。
如果我们想查看 C 在 E 下游直接调用了什么,可以选择它,然后会看到指向 C 直接后代的箭头,这些后代恰好是 E 的所有间接被调用者。

以一个更复杂的例子为例,我们可以查看服务 D,它在上述原始树中以橙色显示。
在这种情况下,我们的摘要框显示:
- 2020 年 5 月 28 日,共有…
- 10 条以 TFE 为根的完整追踪
- 110 条追踪到的指向 D 的 RPC 调用
- 2.1 千条由 D 引起的追踪到的 RPC 调用
- 从 TFE 端点到 D 端点的 3 条唯一调用路径
D 在树中出现三次的事实已在摘要框中指出,其中显示从前端 TFE 到 D 有 3 条唯一调用路径。
我们可以展开对 D 的调用,在这种情况下,可以看到两个调用以及每个调用所占的流量比例。

如果点击其中一个调用,可以看到特定调用 call4 的上游和下游依赖关系,如下所示。可以看到它从未命中下游的服务 C、H 和 G,而服务 D 在 call3 中却命中了这些服务。同样,可以看到其上游依赖关系包括被 C 直接调用,以及被 B 和 E 间接调用,但不包括 A 和 C:

通过 SDE 可以轻松看到的一些内容包括:
- 服务或 RPC 调用引起的负载
- 负载放大异常的位置,以及这是服务普遍存在的问题还是仅出现在某些调用路径上
- 导致服务或 RPC 调用负载的原因
- 循环出现的位置和原因(在 Strato 中非常常见,以及其他情况)
- 导致异常深层追踪的原因
这些都是用户可以通过查询我们存储的数据来获取的信息,但拥有一个带有用户界面的工具,允许实时点击探索,降低了发现这些信息的门槛。
在上面的示例中,只有少量服务,因此你可以从更常用的节点海洋视图中获取类似信息——每个节点代表一个服务,并带有一些可视化注释。但当我们查看真实追踪数据时,面对数千个服务和全局视图,很难看清实际情况。丽贝卡早期的部分分析就使用了这种视图,但我们发现,要有效利用这种视图,需要大量隐性知识。而一种丢弃更多信息、突出少数关键点的视图,能让不具备相关隐性知识的用户更容易从追踪数据中获取价值。
虽然我们在此演示的是RPC请求数/负载的视图,但也可以展示其他指标,比如延迟、错误、数据包大小等。
结论
更广泛地说,这只是对我们在基本分布式追踪数据基础上构建的部分功能的简要描述。你很可能不需要完全照搬我们的做法,因为你们面临的问题可能有所不同,而且几乎不可能遇到与我们追踪基础设施完全相同的困境。根据与其他公司同行的私下交流,我认为我们遇到的问题并非独一无二;相反,我们的追踪基础设施状态优于许多或大多数同行公司(不包括Facebook/谷歌/亚马逊这类巨头),因为它基本能正常工作,人们也确实能利用我们现有的追踪视图调试实际生产问题。但正如常言所说,不幸福的系统各有各的不幸。
与我们之前对指标分析的探讨类似,这项工作也是逐步推进的。由于追踪数据比指标数据丰富得多,在编写生成本文所述表格的Scalding(MapReduce)任务之前,我们花了大量时间对数据进行临时分析。但这些独立分析的价值足够高,以至于在最初几周清理最严重的数据质量问题、并利用现有基础设施进行(极其痛苦的)临时分析之后,这个项目集很快就实现了自我回报。
回顾关于是否值得投入追踪基础设施建设的讨论时,人们常以各公司的诸多失败案例来论证“购买而非自建”的决策。我认为这并非毫无道理——类似项目的基础失败率确实不应被忽视。但另一方面,本文描述的大部分工作并非极其复杂,关键在于获得组织支持,并清晰认识到追踪技术能带来的价值。
有一件事稍微超出了本文的范围,可能值得单独写一篇文章:追踪(tracing)和指标(metrics)虽然并非完全正交,但它们是互补的,只拥有其中一种会让你对许多问题视而不见。你将在多个方面为此付出高昂代价:不必要的故障、调试故障时额外花费的时间、因基础设施运行效率低下而导致的普遍更高成本等。此外,虽然指标和追踪各自单独使用能比两者都没有提供更好的可见性,但有些问题需要同时查看两者;我做过的一些最有趣的分析涉及将追踪数据与指标数据进行关联(通常使用字面上的 SQL 连接)。
具体来说,一个用追踪很容易看到但用日志却很难看到(除非你专门添加日志来查找这个问题——你可以为单个案例这样做,但可能不想为追踪能揭示的成千上万件事都这样做)的例子是我们在上面提到的:“找出从负载均衡器到 A 的特定调用路径导致某个服务 B 上出现高负载放大的情况,而 B 在调用图中可能距离 A 有多跳的距离。在某些情况下,这很明显,因为 A 通常会导致 B 上的高负载放大,但如果这只发生在某些情况下,用追踪仍然很容易处理,但如果你只看指标,就会非常麻烦。”
另一个需要将追踪和指标数据结合起来的例子是,当查看类似不良主机对延迟的性能影响时。通常,你无法将经过该主机的相关跨度(span)标记为不良,因为如果你在跨度发生时就知道主机有问题,那台主机就不会在生产环境中了。但有时,你可以通过历史数据找到一组不良主机,然后查找经过这些主机的延迟关键路径,以确定不良主机对端到端的影响。
每个人对追踪都有自己的偏见,我的偏见来自于通常致力于直接改善成本、可靠性和延迟的工作,因此例子都集中在这上面,但追踪还有很多其他用途。你可以查看《分布式追踪实践》或《掌握分布式追踪》来了解其他视角。
致谢
感谢 Rebecca Isaacs、Leah Hanson、Yao Yue 和 Yuri Vishnevsky 提供的评论、更正和讨论。
- 这几乎肯定是一个不完整的列表,但其他参与贡献的人包括 Moses、Tiina、Rich、Rahul、Ben、Mike、Mary、Arash、Feng、Jenny、Andy、Yao、Yihong、Vinu 和我自己。
请注意,这份相对较长的贡献者名单并不与这项工作的“高投资回报率”相矛盾。我估计,本文讨论的所有内容所投入的工作量总计不到两人年。举个例子,虽然我花了不少时间使用追踪基础设施进行分析,但我在该基础设施本身上花费的时间大约只有一周。
如果以上内容不够明显,那么需要说明的是,尽管由我执笔撰写,但我对这项工作的贡献其实相当有限。之所以由我来写,是因为这项工作开展时我就坐在丽贝卡旁边,她的工作流程和最终成果都令我印象深刻。