分布式跟踪系统
背景
当代的互联网的服务,通常都是用复杂的、大规模分布式集群来实现的。互联网应用构建在不同的软件模块集上,这些软件模块,有可能是由不同的团队开发、可能使用不同的编程语言来实现、有可能布在了几千台服务器,横跨多个不同的数据中心。
问题:
- 请求整体耗时较长,具体慢在哪个服务?
- 请求过程中出错了,具体是哪个服务报错?
- 某个服务的请求量如何,接口成功率如何?
比如一个前端服务可能对上百台查询服务器发起了一个 Web 查询,这个查询可能会被发送到多个的子系统,这些子系统分别用来处理广告、进行拼写检查或是查找一些像图片、视频或新闻这样的特殊结果。根据每个子系统的查询结果进行筛选,得到最终结果,最后汇总到页面上。我们把这种搜索模型称为“全局搜索”(universal search)。
总的来说,这一次全局搜索有可能调用上千台服务器,涉及各种服务。而且,用户对搜索的耗时是很敏感的,而任何一个子系统的低效都导致最终的搜索耗时。如果一个工程师只能知道这个查询耗时不正常,但是他无从知晓这个问题到底是由哪个服务调用造成的,或者为什么这个调用性能差强人意。
首先,这个工程师可能无法准确的定位到这次全局搜索是调用了哪些服务,因为新的服务、乃至服务上的某个片段,都有可能在任何时间上过线或修改过,有可能是面向用户功能,也有可能是一些例如针对性能或安全认证方面的功能改进。
其次,你不能苛求这个工程师对所有参与这次全局搜索的服务都了如指掌,每一个服务都有可能是由不同的团队开发或维护的。
再次,这些暴露出来的服务或服务器有可能同时还被其他客户端使用着,所以这次全局搜索的性能问题甚至有可能是由其他应用造成的。
面对解耦成大量的分布式微服务时,监控、预警、定位故障就变困难了。因此,就需要一些可以帮助理解系统行为、用于分析性能问题的工具。
思考:没有任何额外工具,如何排查系统故障
当我们的系统发生故障时,我们需要登录到各个服务器上,使用 grep / sed / awk 等 Linux 脚本工具去日志里查找故障原因。
在没有日志系统的情况下,首先需要定位处理请求的服务器,如果这台服务器部署了多个实例,则需要去每个应用实例的日志目录下去找日志文件。
每个应用实例还会设置日志滚动策略(如:每天生成一个文件),还有日志压缩归档策略等,等数据过期之后,日志也就永久性的消失了。
综上所述,我们可能会考虑需要有一个日志集中收集和检索系统,这个日志系统就是ELK。
ELK / EFK 日志管理系统
ELK,也就是 Elasticsearch、Logstash、Kibana三者的结合,是一套开源的分布式日志管理方案.
- Elasticsearch:负责日志存储、检索和分析
- LogStash:负责日志的收集、处理
- Kibana:负责日志的可视化
总之, ELK可以帮助我们实时的将各个服务的日志统一收集存储,并给我们提供可视化的日志检索页面。
Kubernetes官方提供了 EFK 的日志收集解决方案, Logstash是基于JDK的,Logstash 致命的问题是它的性能以及资源消耗(默认的堆大小是 1GB)。尽管它的性能在近几年已经有很大提升,与它的替代者们相比还是要慢很多的。在没有产生日志的情况单纯启动Logstash就大概要消耗500M内存,在每个Pod中都启动一个日志收集组件的情况下,使用Logstash有点浪费系统资源, 使用Fluentd替代比起Logstash相当轻量级。
不足
传统的ELK方案需要开发者在编写代码时尽可能全地打印日志,再通过关键字段从ES中搜集筛选出与业务逻辑相关的日志数据,进而拼凑出业务执行的现场信息。
存在如下的痛点:
- 日志搜集繁琐:虽然ES提供了日志检索的能力,但是日志数据往往是缺乏结构性的文本段,很难快速完整地搜集到全部相关的日志。
- 日志筛选困难:不同业务场景、业务逻辑之间存在重叠,重叠逻辑打印的业务日志可能相互干扰,难以从中筛选出正确的关联日志。
- 日志分析耗时:搜集到的日志只是一条条离散的数据,只能阅读代码,再结合逻辑,由人工对日志进行串联分析,尽可能地还原出现场。
综上所述,随着业务逻辑和系统复杂度的攀升,传统的ELK方案在日志搜集、日志筛选和日志分析方面愈加的耗时耗力。
思考:在离散的日志中排查,有没有更好的方式
- 日志串联:traceid
- 日志聚类:Clustering Intrusion Detection Alarms to Support Root Cause Analysis [KLAUS JULISCH, 2002]
Sci-Hub | Clustering intrusion detection alarms to support root cause analysis. ACM Transactions on Information and System Security, 6(4), 443–471 | 10.1145/950191.950192
Dapper —— 分布式会话跟踪系统
1. 概述
市面上的分布式会话跟踪系统框架几乎都是以 Google Dapper 论文为基础进行实现,整体大同小异。
核心思想:在用户一次请求服务的调⽤过程中,无论请求被分发到多少个子系统中,子系统又调用了更多的子系统,我们把系统信息和系统间调用关系都追踪记录下来。最终把数据集中起来可视化展示。
设计目标
- 低消耗:跟踪系统对在线服务的影响应该做到足够小。在一些对性能非常敏感的服务中,即使一点点损耗也会很容易察觉到,而且有可能迫使在线服务的部署团队不得不将跟踪系统关停。
- 应用级的透明:对于应用的程序员来说,是不需要知道有跟踪系统这回事的。如果一个跟踪系统想生效,就必须需要依赖应用的开发者主动配合,那么这个跟踪系统也太脆弱了,往往由于跟踪系统在应用中植入代码的 bug 或疏忽导致应用出问题,这样才是无法满足对跟踪系统“无所不在的部署”这个需求。面对快节奏的开发环境来说,尤其重要。
- 延展性:Google至少在未来几年的服务和集群的规模,监控系统都应该能完全把控住。
2. Dapper的分布式跟踪
图1:这个路径由用户的X请求发起,穿过一个简单的服务系统。用字母标识的节点代表分布式系统中的不同处理过程。
将所有记录条目与一个给定的发起者关联上并记录所有信息,有两种解决方案,黑盒(black-box)和基于标注(annotation-based)的监控方案。
黑盒方案假定需要跟踪的除了上述信息之外没有额外的信息,使用统计回归技术来推断两者之间的关系。
基于标注的方案依赖于应用程序或中间件明确地标记一个全局ID,从而连接每一条记录和发起者的请求。
虽然黑盒方案比标注方案更轻便,他们需要更多的数据,以获得足够的精度,因为他们依赖于统计推论。
基于标注的方案最主要的缺点是需要代码植入。在 google 的生产环境中,因为所有的应用程序都使用相同的线程模型,控制流和RPC系统,可以把代码植入限制在一个很小的通用组件库中,从而实现了监测系统的应用对开发人员是有效地透明。
Dapper 的跟踪架构像是内嵌在 RPC 调用的树形结构。但核心数据模型不只局限于特定的RPC框架,还能跟踪其他行为,例如 SMTP sessions, HTTP request,和外部对 SQL 服务器的查询等。
Dapper 跟踪模型使用 trace, span, 和 annotation。
2.1 Trace and span
Trace的含义比较直观,就是链路,指一个请求经过所有服务的路径,可以用下面树状的图形表示。
图2:5个span在Dapper跟踪树种短暂的关联关系
Dapper 记录了 span 名称,以及每个span的ID和父ID,以重建在一次追踪过程中不同span之间的关系。如果一个span没有父ID被称为root span。所有 span 都挂在一个特定的跟踪上,也共用一个 traceid。所有这些ID用全局唯一的64位整数标示( snowflake 算法)。在一个典型的Dapper跟踪中,每一个RPC对应到一个 span。
span 表示跨服务的一次调用,通过计算开始时间和结束时间的时间差,就能明确调用过程在这个 span 上产生的时间差。
简单的说就是,通过 trace 串联调用链路,再通过 span 反映出链路调用关系。
图3:在图2中所示的一个单独的span的细节图
如果应用程序开发者选择在跟踪中增加他们自己的 annotation (例如:业务数据),这些信息也会和其他span信息一样记录下来。
2.2 Instrumentation points
Dapper基于少量通用组件库的改造,可以以近乎零侵入的成本进行跟踪。如下:
- 当一个线程在处理跟踪控制路径的过程中,Dapper把这次跟踪的上下文的在ThreadLocal中进行存储。追踪上下文是一个小而且容易复制的容器,其中了 traceid 和 spanid。
- 当计算过程是延迟调用的或是异步的,大多数Google开发者通过线程池或其他执行器,使用一个通用的控制流库来回调。Dapper确保所有这样的回调可以存储这次跟踪的上下文,而当回调函数被触发时,这次跟踪的上下文会与适当的线程关联上。在这种方式下,Dapper可以使用trace ID和span ID来辅助构建异步调用的路径。
- 几乎所有的Google的进程间通信是建立在一个用C++和Java开发的RPC框架上。我们把跟踪植入该框架来定义RPC中所有的span。span的ID和跟踪的ID会从客户端发送到服务端。像那样的基于RPC的系统被广泛使用在Google中,这是一个重要的植入点。当那些非RPC通信框架发展成熟并找到了自己的用户群之后,我们会计划对RPC通信框架进行植入。
2.3 Sampling
思考:请求这么多,全部采集会不会损耗过高
低损耗的是Dapper的一个关键的设计目标,因为如果这个工具价值未被证实但又对性能有影响的话,你可以理解服务运营人员为什么不愿意部署它。
某些类型的Web服务对植入带来的性能损耗确实非常敏感。因此,除了把Dapper的收集工作对基本组件的性能损耗限制的尽可能小之外,还需要进一步控制损耗的办法,那就是遇到大量请求时只记录其中的一小部分。
任何给定进程的Dapper的消耗和每个进程单位时间的跟踪的采样率成正比。Dapper的第一个生产版本在Google内部的所有进程上使用统一的采样率,为1/1024。这个简单的方案是对高吞吐量的线上服务来说是非常有用。
新的Dapper用户往往觉得低采样率(在高吞吐量的服务下经常低至0.01%)将会不利于他们的分析。对于高吞吐量服务,如果一个显着的操作在系统中出现一次,他就会出现上千次。低吞吐量的服务(也许是每秒请求几十次,而不是几十万)可以负担得起跟踪每一个请求,这是促使使用自适应采样率的原因。
然而,在较低的采样率和较低的传输负载下可能会导致错过重要事件,而想用较高的采样率就需要能接受的性能损耗。对于这样的系统的解决方案就是覆盖默认的采样率,这需要手动干预的,这种情况是我们试图避免在dapper中出现的。在部署可变采样的过程中,参数化配置采样率时,不是使用一个统一的采样方案,而是使用一个采样期望率来标识单位时间内采样的追踪。这样一来,低流量低负载自动提高采样率,而在高流量高负载的情况下会降低采样率,使损耗一直保持在控制之下。实际使用的采样率会随着跟踪本身记录下来,这有利于从Dapper的跟踪数据中准确的分析。
2.5 Trace collection
图4:Dapper 收集管道的总览
Dapper的跟踪记录和收集管道的过程分为三个阶段。
首先,span数据写入(1)本地日志文件中。
然后Dapper的守护进程和收集组件把这些数据从生产环境的主机中拉出来(2)。
最终写到(3)Dapper的 Bigtable 仓库中。一次跟踪被设计成Bigtable中的一行,每一列相当于一个span。
Dapper使用了BigTable数据仓库,常用的存储还有ElasticSearch, HBase, In-memory DB等。
3. Go 的侵入式会话跟踪实现思路
OpenTelemetry 的 go sdk 实现调用链拦截的基本思路是:基于 AOP 的思想采用装饰器模式,通过包装替换目标包(如 net/http)的核心接口或组件,实现在核心调用过程前后添加 Span 相关逻辑。
3.1 HttpServer Handler 生成 Span 过程
对于 httpserver 其核心就是 http.Handler 这个接口。因此,可以通过实现一个针对 http.Handler 接口的拦截器,来负责 Span 的生成和传播。
package http
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
http.ListenAndServe(":8090", http.DefaultServeMux)
import (
"net/http"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
wrappedHttpHandler := otelhttp.NewHandler(http.DefaultServeMux, ...)
http.ListenAndServe(":8090", wrappedHttpHandler)
func ServeHTTP(ResponseWriter, *Request) {
ctx := tracer.Extract(r.ctx, r.Header)
ctx, span := tracer.Start(ctx, genOperation(r))
r.WithContext(ctx)
// original ServeHTTP
span.End()
}
wrppedHttpHandler 中将主要实现如下逻辑(伪代码):
ctx := tracer.Extract(r.ctx, r.Header)
:从请求的 header 中提取 TraceId和 SpanId,进而构建 SpanContext 对象,并最终存储在 ctx 中;ctx, span := tracer.Start(ctx, genOperation(r))
:生成跟踪当前请求处理过程的 Span(即前文所述的Span1),并记录开始时间,这时会从 ctx 中读取 SpanContext,将 SpanContext.TraceId 作为当前 Span 的TraceId,将 SpanContext.SpanId 作为当前 Span的ParentSpanId,然后将自己作为新的 SpanContext 写入返回的 ctx 中;r.WithContext(ctx)
:将新生成的 SpanContext 添加到请求 r 的 context 中,以便被拦截的 handler 内部在处理过程中,可以从 r.ctx 中拿到 Span1 的 SpanId 作为其 ParentSpanId 属性,从而建立 Span 之间的父子关系;span.End()
:记录处理完成的时间,然后将它发送给 exporter 上报到服务端
3.2 HttpClient 请求生成 Span 过程
httpclient 发送请求的关键操作是 http.RoundTriper 接口, 包装一下 httpclient 原来使用的 RoundTripper 实现 span 的生成。
package http
type RoundTripper interface {
RoundTrip(*Request) (*Response, error)
}
import (
"net/http"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
wrappedTransport := otelhttp.NewTransport(http.DefaultTransport)
client := http.Client{Transport: wrappedTransport}
func RoundTrip ( r *http.Request) (*Response, error){
ctx, span := tracer.Start(r.Context(), url)
tracer.Inject(ctx, r.Header)
// original RoundTrip
span.End()
}
如图所示,wrappedTransport 将主要完成以下任务(伪代码):
req, _ := http.NewRequestWithContext(r.ctx, “GET”,url, nil)
:这里我们将上一步 http.Handler 的请求的 ctx,传递到 httpclient 要发出的 request 中,这样在之后我们就可以从 request.Context() 中提取出 Span1 的信息,来建立 Span 之间的关联;ctx, span := tracer.Start(r.Context(), url)
:执行 client.Do() 之后,将首先进入 WrappedTransport.RoundTrip() 方法,这里生成新的 Span(Span2),开始记录 httpclient 请求的耗时情况,与前文一样,Start 方法内部会从 r.Context() 中提取出 Span1 的 SpanContext,并将其 spanid 作为当前 Span(Span2)的 ParentSpanId,从而建立了 Span 之间的嵌套关系,同时返回的 ctx 中保存的 SpanContext 将是新生成的 Span(Span2)的信息;tracer.Inject(ctx, r.Header)
:这一步的目的是将当前 SpanContext 中的 TraceId 和 SpanId 等信息写入到 r.Header 中,以便能够随着 http 请求发送到 serverB,之后在 serverB 中与当前 Span 建立关联;span.End()
:等待 httpclient 请求发送到 serverB 并收到响应以后,标记当前 Span 跟踪结束,设置 EndTime 并提交给 exporter 以上报到服务端。
4. 参数透传
数据传播按照场景分为两类:进程内传播、跨进程间传播( Cross-Process-Tracing)。
进程内传播是指 Trace 在一个服务内部传递,监控了服务内部相互调用情况,相当比较简单。追踪系统最困难的部分就是在分布式的应用环境下保持追踪的正常工作。任何一个追踪系统,都需要理解多个跨进程调用间的因果关系,无论他们是通过 RPC 框架、发布-订阅机制、通用消息队列、HTTP 请求调用、UDP 传输或者其他传输模式。所以业界谈起 Tracing 技术 往往说的是跨进程间的分布式链路追踪(Distrubute Tracing)。
在 Trace 传递中有一个核心的概念 Carrier。它在 Trace 中把 Span 信息传递下去,在 HTTP 调用场景中,会有 HttpCarrier,在 RPC 的调用场景中会有 RpcCarrier 来搬运 SpanContext。Trace 通过 Carrier 可以把链路追踪状态从一个进程“搬运”到另一个进程里。
链路数据为了进行网络传输,需要数据进行序列化和反序列化。这个过程 Trace 通过一个负责数据序列化反序列化上下文的 Formatter 接口实现的。例如在 HttpCarrier 使用中通常就会有一个对应的 HttpFormatter。所以 Inject 注入是委托给 Formatter 将 SpanContext 进行序列化写入 Carrier。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OCScq7qC-1681700005519)(https://s3-us-west-2.amazonaws.com/secure.notion-static.com/6bd3cb35-42c2-418b-97d6-f4254caae089/Untitled.png)]
W3C 组织为 HTTP 支持链路追踪专门在 Headers 中定义了 Trace 标准:
Trace Context
5. 性能损耗
跟踪系统的成本由两部分组成:
- 正在被监控的系统在生成 trace 和收集 trace 数据的消耗导致系统性能下降。
- 需要使用一部分资源来存储和分析跟踪数据。
5.1 生成 trace 的损耗
生成跟踪的开销是Dapper性能影响中最关键的部分,因为收集和分析可以更容易在紧急情况下被关闭。Dapper运行库中最重要的跟踪生成消耗在于创建和销毁 span 和 annotation,并记录到本地磁盘供后续的收集。
根span的创建和销毁需要损耗平均204纳秒的时间,而同样的操作在其他span上需要消耗176纳秒。时间上的差别主要在于需要在跟span上给这次跟踪分配一个全局唯一的ID。
在Dapper运行期写入到本地磁盘是最昂贵的操作,但是他们的可见损耗大大减少,因为写入日志文件和操作相对于被跟踪的应用系统来说都是异步的。
5.2 收集 trace 的消耗
读出跟踪数据也会对正在被监控的负载产生干扰。
下表展示的是最坏情况下,Dapper收集日志的守护进程在高于实际情况的负载基准下进行测试时的cpu使用率。在生产环境下,跟踪数据处理中,这个守护进程从来没有超过0.3%的单核cpu使用率,而且只有很少量的内存使用(以及堆碎片的噪音)。
还限制了Dapper守护进程为内核scheduler最低的优先级,以防在一台高负载的服务器上发生cpu竞争。
Dapper也是一个带宽资源的轻量级的消费者,每一个span传输只占用了平均426的byte。作为网络行为中的极小部分,Dapper的数据收集在Google的生产环境中的只占用了0.01%的网络资源。
6. 不足
分布式会话跟踪的主要作用是分析分布式系统的调用行为,并不能很好地应用于业务逻辑的追踪。下图是一个审核业务场景的追踪案例,业务系统对外提供审核能力,待审对象的审核需要经过“初审”和“复审”两个环节(两个环节关联相同的taskId),因此整个审核环节的执行调用了两次审核接口。如图左侧所示,完整的审核场景涉及众多“业务逻辑”的执行,而分布式会话跟踪只是根据两次RPC调用生成了右侧的两条调用链路,并没有办法准确地描述审核场景业务逻辑的执行,问题主要体现在以下几个方面:
(1) 无法同时追踪多条调用链路
分布式会话跟踪仅支持单个请求的调用追踪,当业务场景包含了多个调用时,将生成多条调用链路;由于调用链路通过traceId串联,不同链路之间相互独立,因此给完整的业务追踪增加了难度。
例如当排查审核场景的业务问题时,由于初审和复审是不同的RPC请求,所以无法直接同时获取到2条调用链路,通常需要额外存储2个traceId的映射关系。
(2) 无法准确描述业务逻辑的全景
分布式会话跟踪生成的调用链路,只包含单次请求的实际调用情况,部分未执行的调用以及本地逻辑无法体现在链路中,导致无法准确描述业务逻辑的全景。
例如同样是审核接口,初审链路1包含了服务b的调用,而复审链路2却并没有包含,这是因为审核场景中存在“判断逻辑”,而该逻辑无法体现在调用链路中,还是需要人工结合代码进行分析。
(3) 无法聚焦于当前业务系统的逻辑执行
分布式会话跟踪覆盖了单个请求流经的所有服务、组件、机器等等,不仅包含当前业务系统,还涉及了众多的下游服务,当接口内部逻辑复杂时,调用链路的深度和复杂度都会明显增加,而业务追踪其实仅需要聚焦于当前业务系统的逻辑执行情况。
例如审核场景生成的调用链路,就涉及了众多下游服务的内部调用情况,反而给当前业务系统的问题排查增加了复杂度。
聚焦于业务的分布式链路跟踪系统
1. 背景
1.1 业务系统日益复杂
随着互联网产品的快速发展,商业环境和用户需求不断变化,带来了繁多复杂的业务需求。业务系统需要支持越来越广泛的业务场景,涵盖越来越多的业务逻辑,因此系统的复杂度也快速提升。同时,微服务架构的演进也导致业务逻辑的实现往往需要多个服务之间的共同协作。总之,业务系统的日益复杂已经成为一种常态。
1.2 业务追踪面临挑战
业务系统往往面临着多样的日常客诉和突发问题,“业务追踪”就成为了关键的应对手段。业务追踪可以看做一次业务执行的现场还原过程,通过执行中的各种记录还原出原始现场,可用于业务逻辑执行情况的分析和问题的定位,是整个系统建设中重要的一环。
随着业务逻辑的日益复杂,上述方案越来越不适用于当下的业务系统。
传统的ELK方案是一种滞后的业务追踪,需要事后从大量离散的日志中搜集和筛选出需要的日志,并人工进行日志的串联分析,其过程必然耗时耗力。
分布式会话跟踪方案则是在调用执行的同时,实时地完成了链路的动态串联,但由于是会话级别且仅关注于调用关系等问题,导致其无法很好地应用于业务追踪。
实现聚焦于业务逻辑追踪的高效解决方案,将业务执行的日志以业务链路为载体进行高效组织和串联,并支持业务执行现场的还原和可视化查看,从而提升定位问题的效率,即可视化全链路日志追踪。
2. 可视化全链路日志追踪
2.1 设计思路
可视化全链路日志追踪考虑在前置阶段,即业务执行的同时实现业务日志的高效组织和动态串联,如下图所示,此时离散的日志数据将会根据业务逻辑进行组织,绘制出执行现场,从而可以实现高效的业务追踪。
新方案需要回答两个关键问题:如何高效组织业务日志,以及如何动态串联业务日志。下文将逐一进行回答。
问题1:如何高效组织业务日志?
为了实现高效的业务追踪,首先需要准确完整地描述出业务逻辑,形成业务逻辑的全景图,而业务追踪其实就是通过执行时的日志数据,在全景图中还原出业务执行的现场。
新方案对业务逻辑进行了抽象,定义出业务逻辑链路,下面还是以“审核业务场景”为例,来说明业务逻辑链路的抽象过程:
- 逻辑节点:业务系统的众多逻辑可以按照业务功能进行拆分,形成一个个相互独立的业务逻辑单元,即逻辑节点,可以是本地方法(如下图的“判断逻辑”节点)也可以是RPC等远程调用方法(如下图的“逻辑A”节点)。
- 逻辑链路:业务系统对外支撑着众多的业务场景,每个业务场景对应一个完整的业务流程,可以抽象为由逻辑节点组合而成的逻辑链路,如下图5中的逻辑链路就准确完整地描述了“审核业务场景”。
一次业务追踪就是逻辑链路的某一次执行情况的还原,逻辑链路完整准确地描述了业务逻辑全景,同时作为载体可以实现业务日志的高效组织。
问题2:如何动态串联业务日志?
业务逻辑执行时的日志数据原本是离散存储的,而此时需要实现的是,随着业务逻辑的执行动态串联各个逻辑节点的日志,进而还原出完整的业务逻辑执行现场。
由于逻辑节点之间、逻辑节点内部往往通过MQ或者RPC等进行交互,采用分布式会话跟踪提供的分布式参数透传能力实现业务日志的动态串联:
- 通过在执行线程和网络通信中持续地透传参数,实现在业务逻辑执行的同时,不中断地传递链路和节点的标识,实现离散日志的染色。
- 基于标识,染色的离散日志会被动态串联至正在执行的节点,逐渐汇聚出完整的逻辑链路,最终实现业务执行现场的高效组织和可视化展示。
与分布式会话跟踪方案不同的是,当同时串联多次分布式调用时,新方案需要结合业务逻辑选取一个公共id作为标识,例如审核场景涉及2次RPC调用,为了保证2次执行被串联至同一条逻辑链路,此时结合审核业务场景,选择初审和复审相同的“任务id”作为标识,完整地实现审核场景的逻辑链路串联和执行现场还原。
2.2 通用方案
明确日志的高效组织和动态串联这两个基本问题后,本文选取图4业务系统中的“逻辑链路1”进行通用方案的详细说明,方案可以拆解为以下步骤:
2.2.1 链路定义
“链路定义”的含义为:使用特定语言,静态描述完整的逻辑链路,链路通常由多个逻辑节点,按照一定的业务规则组合而成,业务规则即各个逻辑节点之间存在的执行关系,包括串行、并行、条件分支。
DSL(Domain Specific Language)是为了解决某一类任务而专门设计的计算机语言,可以通过JSON或XML定义出一系列节点(逻辑节点)的组合关系(业务规则)。因此,本方案选择使用DSL描述逻辑链路,实现逻辑链路从抽象定义到具体实现。
逻辑链路1-DSL
[
{
"nodeName": "A",
"nodeType": "rpc"
},
{
"nodeName": "Fork",
"nodeType": "fork",
"forkNodes": [
[
{
"nodeName": "B",
"nodeType": "rpc"
}
],
[
{
"nodeName": "C",
"nodeType": "local"
}
]
]
},
{
"nodeName": "Join",
"nodeType": "join",
"joinOnList": [
"B",
"C"
]
},
{
"nodeName": "D",
"nodeType": "decision",
"decisionCases": {
"true": [
{
"nodeName": "E",
"nodeType": "rpc"
}
]
},
"defaultCase": [
{
"nodeName": "F",
"nodeType": "rpc"
}
]
}
]
2.2.2 链路染色
“链路染色”的含义为:在链路执行过程中,通过透传串联标识,明确具体是哪条链路在执行,执行到了哪个节点。
链路染色包括两个步骤:
-
步骤一:确定串联标识
,当逻辑链路开启时,确定唯一标识,能够明确后续待执行的链路和节点。
- 链路唯一标识 = 业务标识 + 场景标识 + 执行标识 (三个标识共同决定“某个业务场景下的某次执行”)
- 业务标识:赋予链路业务含义,例如“用户id”、“活动id”等等。
- 场景标识:赋予链路场景含义,例如当前场景是“逻辑链路1”。
- 执行标识:赋予链路执行含义,例如只涉及单次调用时,可以直接选择“traceId”;涉及多次调用时则,根据业务逻辑选取多次调用相同的“公共id”。
- 节点唯一标识 = 链路唯一标识 + 节点名称 (两个标识共同决定“某个业务场景下的某次执行中的某个逻辑节点”)
- 节点名称:DSL中预设的节点唯一名称,如“A”。
-
步骤二:传递串联标识
,当逻辑链路执行时,在分布式的完整链路中透传串联标识,动态串联链路中已执行的节点,实现链路的染色。例如在“逻辑链路1”中:
- 当“A”节点触发执行,则开始在后续链路和节点中传递串联标识,随着业务流程的执行,逐步完成整个链路的染色。
- 当标识传递至“E”节点时,则表示“D”条件分支的判断结果是“true”,同时动态地将“E”节点串联至已执行的链路中。
2.2.3 链路上报
“链路上报”的含义为:在链路执行过程中,将日志以链路的组织形式进行上报,实现业务现场的准确保存。
如上图8所示,上报的日志数据包括:节点日志和业务日志。其中节点日志的作用是绘制链路中的已执行节点,记录了节点的开始、结束、输入、输出;业务日志的作用是展示链路节点具体业务逻辑的执行情况,记录了任何对业务逻辑起到解释作用的数据,包括与上下游交互的入参出参、复杂逻辑的中间变量、逻辑执行抛出的异常。
2.2.4 链路存储
“链路存储”的含义为:将链路执行中上报的日志落地存储,并用于后续的“现场还原”。上报日志可以拆分为链路日志、节点日志和业务日志三类:
- 链路日志:链路单次执行中,从开始节点和结束节点的日志中提取的链路基本信息,包含链路类型、链路元信息、链路开始/结束时间等。
- 节点日志:链路单次执行中,已执行节点的基本信息,包含节点名称、节点状态、节点开始/结束时间等。
- 业务日志:链路单次执行中,已执行节点中的业务日志信息,包含日志级别、日志时间、日志数据等。
下图就是链路存储的存储模型,包含了链路日志,节点日志,业务日志、链路元数据(配置数据),并且是如下图所示的树状结构,其中业务标识作为根节点,用于后续的链路查询。
3. 大众点评内容平台实践
3.1支持大数据量日志的上报和存储
支持众多服务统一的日志收集、处理和存储,能够很好地支撑大数据量下的日志追踪建设。
日志收集:各应用服务通过机器上部署的 log_agent 收集异步上报的日志数据,并统一传输至Kafka通道中,此外针对少量不支持log_agent的服务,搭建了如图所示的中转应用。
日志解析:收集的日志通过Kafka接入到Flink中,统一进行解析和处理,根据日志类型对日志进行分类和聚合,解析为链路日志、节点日志和业务日志。
日志存储:完成日志解析后,日志会按照树状的存储模型进行落地存储,结合存储的需求分析以及各个存储选项的特点,点评内容平台最终选择HBase作为存储选型。
需求分析 | 选型优点 |
---|---|
OLTP业务:逻辑链路的实时读写数据量很大:海量的记录数,且未来会持续增长写密集、读较少:日志上报峰值QPS较高业务场景简单:简单读写即可满足需求 | 存储特性:支持横向扩展、快速扩充字段查询特性:支持精确和前缀匹配查询,且支持快速随机的访问经济成本:存储介质成本低廉 |
整体而言,log_agent + Kafka + Flink + HBase的日志上报和存储架构能够很好地支持复杂的业务系统,天然支持分布式场景下众多应用的日志上报,同时适用于高流量的数据写入。
3.2. 实现众多后端服务的低成本改造
点评内容平台实现了“自定义日志工具包”(即下图13的TraceLogger工具包),屏蔽链路追踪中的上报细节,实现众多服务改造的成本最小化。TraceLogger工具包的功能包括:
- 模仿slf4j-api:工具包的实现在slf4j框架之上,并模仿slf4j-api对外提供相同的API,因此使用方无学习成本。
- 屏蔽内部细节,内部封装一系列的链路日志上报逻辑,屏蔽染色等细节,降低使用方的开发成本。
- 上报判断:
- 判断链路标识:无标识时,进行兜底的日志上报,防止日志丢失。
- 判断上报方式:有标识时,支持日志和RPC中转两种上报方式。
- 日志组装:实现参数占位、异常堆栈输出等功能,并将相关数据组装为Trace对象,便于进行统一的收集和处理。
- 异常上报:通过ErrorAPI主动上报异常,兼容原日志上报中ErrorAppender。
- 日志上报:适配Log4j2日志框架实现最终的日志上报。
- 上报判断:
下面是TraceLogger工具包分别进行业务日志和节点日志上报的使用案例,整体的改造成本较低。
业务日志上报:无学习成本,基本无改造成本。
// 替换前:原日志上报
LOGGER.error("updatestructfailed, param:{}", GsonUtils.toJson(structRequest), e);
// 替换后:全链路日志上报
TraceLogger.error("updatestructfailed, param:{}", GsonUtils.toJson(structRequest), e);
节点日志上报:支持API、AOP两种上报方式,灵活且成本低。
public ResponserealTimeInputLink(long contentId) {
// 链路开始:传递串联标识(业务标识 + 场景标识 + 执行标识)
TraceUtils.passLinkMark("contentId_type_uuid");
// ...// 本地调用(API上报节点日志)
TraceUtils.reportNode("contentStore", contentId, StatusEnums.RUNNING)
contentStore(contentId);
TraceUtils.reportNode("contentStore", structResp, StatusEnums.COMPLETED)
// ...// 远程调用
Response processResp = picProcess(contentId);
// ...
}
// AOP上报节点日志@TraceNode(nodeName="picProcess")
public ResponsepicProcess(long contentId) {
// 图片处理业务逻辑// 业务日志数据上报
TraceLogger.warn("picProcess failed, contentId:{}", contentId);
}
3.2 成果
基于上述实践,点评内容平台实现了可视化全链路日志追踪,能够一键追踪任意一条内容所有业务场景的执行,并通过可视化的链路进行执行现场的还原,追踪效果如下图所示:
【链路查询功能】:根据内容id实时查询该内容所有的逻辑链路执行,覆盖所有的业务场景。
【链路展示功能】:通过链路图可视化展示业务逻辑的全景,同时展示各个节点的执行情况。
【节点详情查询功能】:支持展示任意已执行节点的详情,包括节点输入、输出,以及节点执行过程中的关键业务日志。
目前,可视化全链路日志追踪系统已经成为点评内容平台的“问题排查工具”,我们可以将问题排查耗时从小时级降低到5分钟内;同时也是“测试辅助工具”,利用可视化的日志串联和展示,明显提升了RD自测、QA测试的效率。最后总结一下可视化全链路日志追踪的优点:
- 接入成本低:DSL配置配合简单的日志上报改造,即可快速接入。
- 追踪范围广:任意一条内容的所有逻辑链路,均可被追踪。
- 使用效率高:管理后台支持链路和日志的可视化查询展示,简单快捷。