在“分布式链路跟踪系统(一):Dapper 介绍”里提到过链路跟踪系统要解决的难题之一是“链路梳理难:需求迭代很快,系统之间调用关系变化频繁,靠人工很难梳理清楚系统链路拓扑”。
讲拓扑计算之前,先看一下分布式链路跟踪的数据模型,可以参考文章 OpenTracing 数据模型。一条 Trace(调用链)可以被认为是一个由多个 Span 组成的有向无环图,比如下面的 Trace 就是由 6 个 Span 组成的:
[Span A] ←←←(the root span)
|
+------+------+
| |
[Span B] [Span C] ←←←(Span C 是 Span A 的孩子节点, ChildOf)
| |
[Span D] +---+-------+
| |
[Span E] [Span F]
看上面的图,能够直接看到各个 Span 之间的调用关系,进一步抽象出系统之间的拓扑关系。单个 Trace 的数据未必是完整的,所以链路拓扑的计算需要汇总大量或者全部 Trace 的数据才算完整。链路拓扑可以分为两种:接口级别、应用级别,应用级别的拓扑可以在接口拓扑的基础上生成,下面先讲应用级别的拓扑计算。
接口拓扑计算主要是对大量 Trace 数据进行分析、汇总,得出接口之间的关系。比如,在上图的链路中 A 的下游是 B、C,B 的下游是 D,上游是 A,C 的上游是 A,C 的下游是 E、F。不同 Trace 可能走不同的接口调用链路,可能在另一条 Trace 里,A 的下游是 C、H,那么经过汇总以后 A 的直接下游就是 B、C、F,依次类推就可以计算出拓扑直接的调用关系。
实际中,拓扑计算可以做成离线任务,也可以做成实时流计算任务。离线任务容易理解,在计算拓扑时,整个 Trace 的数据已经被保存到了数据仓库中,可以拿到完整的数据进行计算。要实时计算拓扑的话,是比较麻烦的,因为各个应用中的 Trace 数据是单独上报到消息队列里的。要想根据单个 Span 拿到其上下游关系,就需要在上报时把这些信息写到该 Span 里,可以在 Span 里只记录其上游 Span 的接口信息,比如 Span C 里记录其上游是接口 A,那么只根据 Span C 的数据也可以计算出调用关系 A -> C。
在计算接口拓扑时,第一步计算出的是接口对应的直接上下游,第二步还需要级联计算出完整的拓扑图,也就是包含间接的上下游关系的完整链路。为了增加计算速度,可以对接口的拓扑计算结果进行缓存,假设先计算出了接口 C 的拓扑,那么在计算接口 A 的拓扑之时,就可以直接利用缓存的 C 的拓扑了。为了提高缓存效率,可以按照拓扑深度从小到大来进行计算,比如下面拓扑中 A 的拓扑深度为 3(还有 2 层下游),C 的拓扑深度为 3,E、F 的深度为 1,G、H 拓扑深度为 0,那么可以先按照深度从 0 到 3 来计算拓扑深度,并且每次计算完毕,重新更新深度值。
[Span A] ←←←(deep=3)
|
+------+------+
| |
[Span B] [Span C] ←←←(deep=2)
| |
[Span D] +---+-------+
| |
[Span E] [Span F] (deep=1)
| |
[Span G] [Span H] (deep=0)
前面讲的拓扑计算都是比较粗略的,不能满足某些特殊场景下的精确拓扑计算,加上有一个接口 C,有两个上游 A、B,两个下游 D、E。如果根节点为 A 的调用量里不会出现 E,根节点为 B 的调用量里不会出现 E,那么之前分别计算各个接口上下游,再汇总的拓扑就不能精确描述接口直接的关系了。
A->C->D
B->C->E
如何计算精确的拓扑关系呢?简单拓扑计算里,一个节点的标识信息只需要接口本身的信息,需要精确计算拓扑时,就需要再度拆分把节点 C 拆分成两个 A-C、B-C,分别表示 A 调用的接口 C、B 调用的接口 C,也就是以更细的粒度来计算拓扑。
应用拓扑可以在接口拓扑的基础之上进行计算,比如一个应用 A 有接口 M1、M2、M3 时,先分别计算接口拓扑,再汇总即可得到应用拓扑。
拓扑数据如何展示呢?d3js 网站提供了很多种图形展示方式,然而实际应用时,发现应用节点太多时,展示在页面上的拓扑图往往是一团乱麻,还是直接像展示链路详情那样展示拓扑更加直观易懂。
链路拓扑有哪些应用场景呢?
- 全链路压测时,帮助了解压测流量可能经过的系统。另外,链路数据带上接口调用比例的话,还可以用于容量评估。
- 系统故障时,帮助定位故障的根本原因。比如上游系统异常,可能是因为下游某个系统异常导致的。