learn-tech/专栏/高并发系统实战课/11链路追踪:如何定制一个分布式链路跟踪系统?.md
2024-10-16 11:38:31 +08:00

25 KiB
Raw Permalink Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        11 链路追踪:如何定制一个分布式链路跟踪系统 
                        你好,我是徐长龙,这节课我们讲一讲如何实现分布式链路跟踪。

分布式链路跟踪服务属于写多读少的服务,是我们线上排查问题的重要支撑。我经历过的一个系统,同时支持着多条业务线,实际用上的服务器有两百台左右,这种量级的系统想排查故障,难度可想而知。

因此我结合ELK特性设计了一套十分简单的全量日志分布式链路跟踪把日志串了起来大大降低了系统排查难度。

目前市面上开源提供的分布式链路跟踪都很抽象,当业务复杂到一定程度的时候,为核心系统定制一个符合自己业务需要的链路跟踪,还是很有必要的。

事实上,实现一个分布式链路跟踪并不难,而是难在埋点、数据传输、存储、分析上,如果你的团队拥有这些能力,也可以很快制作出一个链路跟踪系统。所以下面我们一起看看,如何实现一个简单的定制化分布式链路跟踪。

监控行业发展现状

在学习如何制作一个简单的分布式链路跟踪之前,为了更好了解这个链路跟踪的设计特点,我们先简单了解一下监控行业的现状。

最近监控行业有一次大革新现代的链路跟踪标准已经不拘泥于请求的链路跟踪目前已经开始进行融合新的标准和我们定制化的分布式链路跟踪的设计思路很相似即Trace、Metrics、日志合并成一套系统进行建设。

在此之前常见监控系统主要有三种类型Metrics、Tracing和Logging。

常见的开源Metrics有Zabbix、Nagios、Prometheus、InfluxDb、OpenFalcon主要做各种量化指标汇总统计比如监控系统的容量剩余、每秒请求量、平均响应速度、某个时段请求量多少。

常见的开源链路跟踪有Jaeger、Zipkin、Pinpoint、Skywalking主要是通过分析每次请求链路监控分析的系统我么可以通过TraceID查找一次请求的依赖及调用链路分析故障点和传导过程的耗时。

而常见的开源Logging有ELK、Loki、Loggly主要是对文本日志的收集归类整理可以对错误日志进行汇总、警告并分析系统错误异常等情况。

这三种监控系统可以说是大服务集群监控的主要支柱,它们各有优点,但一直是分别建设的。这让我们的系统监控存在一些割裂和功能重复,而且每一个标准都需要独立建设一个系统,然后在不同界面对同一个故障进行分析,排查问题时十分不便。

随着行业发展,三位一体的标准应运而生,这就是 OpenTelemetry 标准集成了OpenCensus、OpenTracing标准。这个标准将Metrics+Tracing+Logging集成一体这样我们监控系统的时候就可以通过三个维度综合观测系统运转情况。

常见OpenTelemetry开源项目中的Prometheus、Jaeger正在遵循这个标准逐步改进实现OpenTelemetry 实现的结构如下图所示:

事实上,分布式链路跟踪系统及监控主要提供了以下支撑服务:

监控日志标准 埋点SDKAOP或侵入式 日志收集 分布式日志传输 分布式日志存储 分布式检索计算 分布式实时分析 个性化定制指标盘 系统警告

我建议使用ELK提供的功能去实现分布式链路跟踪系统因为它已经完整提供了如下功能

日志收集Filebeat 日志传输Kafka+Logstash 日志存储Elasticsearch 检索计算Elasticsearch + Kibana 实时分析Kibana 个性定制表格查询Kibana

这样一来我只需要制定日志格式、埋点SDK即可实现一个具有分布式链路跟踪、Metrics、日志分析系统。

事实上Log、Metrics、trace三种监控体系最大的区别就是日志格式标准底层实现其实是很相似的。既然ELK已提供我们需要的分布式相关服务下面我简单讲讲日志格式和SDK埋点通过这两个点我们就可以窥见分布式链路跟踪的全貌。

TraceID单次请求标识

可以说要想构建一个简单的Trace系统我们首先要做的就是生成并传递TraceID。

分布式链路跟踪的原理其实很简单就是在请求发起方发送请求时或服务被请求时生成一个UUID被请求期间的业务产生的任何日志Warning、Info、Debug、Error、任何依赖资源请求MySQL、Kafka、Redis、任何内部接口调用Restful、Http、RPC都会带上这个UUID。

这样当我们把所有拥有同样UUID的日志收集起来时就可以根据时间有误差、RPCID后续会介绍RPCID或SpanID将它们按依赖请求顺序串起来。

只要日志足够详细我们就能监控到系统大部分的工作状态比如用户请求一个服务会调用多少个接口每个数据查询的SQL以及具体耗时调用的内网请求参数是什么、调用的内网请求返回是什么、内网被请求的接口又做了哪些操作、产生了哪些异常信息等等。

同时,我们可以通过对这些日志做归类分析,分析项目之间的调用关系、项目整体健康程度、对链路深挖自动识别出故障点等,帮助我们主动、快速地查找问题。

“RPCID” VS “SpanID 链路标识”

那么如何将汇总起来的日志串联起来呢有两种方式span链式记录依赖和RPCID层级计数器。我们在记录日志带上UUID的同时也带上RPCID这个信息通过它帮我们把日志关联关系串联起来那么这两种方式有什么区别呢

我们先看看span实现具体如下图

结合上图我们分析一下span的链式依赖记录方式。对于代码来说写的很多功能会被封装成功能模块Service、Model我们通过组合不同的模块实现业务功能并且记录这两个模块、两个服务间或是资源的调用依赖关系。

span这个设计会通过记录自己上游依赖服务的SpanID实现上下游关系关联放在Parent ID中通过整理span之间的依赖关系就能组合成一个调用链路树。

那RPCID方式是什么样的呢RPCID也叫层级计数器我在微博和好未来时都用过为了方便理解我们来看下面这张图

你看RPCID的层级计数器实现很简单第一个接口生成RPCID为 1.1 RPCID的前缀是1计数器是1日志记录为 1.1)。

当所在接口请求其他接口或数据服务MySQL、Redis、API、Kafka计数器+1并在请求当中带上1.2这个数值(因为当前的前缀 + “.” + 计数器值 = 1.2),等到返回结果后,继续请求下一个资源时继续+1期间产生的任何日志都会记录当前 前缀+“.”+计数器值。

每一层收到了前缀后,都在后面加了一个累加的计数器,实际效果如下图所示:

而被请求的接口收到请求时如果请求传递了TraceID那么被请求的服务会继续使用传递过来的TraceID如果请求没有TraceID则自己生成一个。同样地如果传递了RPCID那么被请求的服务会将传递来的RPCID当作前缀计数器从1开始计数。

相对于span通过这个层级计数器做出来的RPCID有两个优点。

第一个优点是我们可以记录请求方日志如果被请求方没有记录日志那么还可以通过请求方日志观测分析被调用方性能MySQL、Redis

另一个优点是哪怕日志收集得不全丢失了一些我们还可以通过前缀有几个分隔符判断出日志所在层级进行渲染。举个例子假设我们不知道上图的1.5.1是谁调用的但是根据它的UUID和层级1.5.1这些信息,渲染的时候,我们仍旧可以渲染它大概的链路位置。

除此之外我们可以利用AOP顺便将各个模块做一个Metrics性能统计分析分析各个模块的耗时、调用次数做周期统计。

同时通过这个维度采样统计数据能够帮助我们分析这个模块的性能和错误率。由于Metrics 这个方式产生的日志量很小有些统计是每10秒才会产生一条Metrics统计日志统计的数值很方便对比很有参考价值。

但是你要注意对于一个模块内有多个分支逻辑时Metrics很多时候取的是平均数偶发的超时在平均数上看不出来所以我们需要另外记录一下最大最小的延迟才可以更好地展现。同时这种统计只是让我们知道这个模块是否有性能问题但是无法帮助我们分析具体的原因。

回到之前的话题我们前面提到请求和被请求方通过传递TraceID和RPCID或SpanID来实现链路的跟踪我列举几个常见的方式供你参考

HTTP协议放在Header RPC协议放在meta中传递 队列可以放在消息体的Header中或直接在消息体中传递 其他特殊情况下可以通过网址请求参数传递。

那么应用内多线程和多协程之间如何传递TraceID呢一般来说我们会通过复制一份Context传递进入线程或协程并且如果它们之前是并行关系我们复制之后需要对下发之前的RPCID计数器加1并把前缀和计数器合并成新的前缀以此区分并行的链路。

除此之外我们还做了一些特殊设计当我们的请求中带一个特殊的密语并且设置类似X-DEBUG Header等于1时我们可以开启在线debug模式在被调用接口及所有依赖的服务都会输出debug级别的日志这样我们临时排查线上问题会更方便。

日志类型定义

可以说只要让日志输出当前的TraceId和RPCIDSpanID并在请求所有依赖资源时把计数传递给它们就完成了大部分的分布式链路跟踪。下面是我定制的一些日志类型和日志格式供你参考

日志类型

  • request.info 当前被请求接口的相关信息,如被请求接口,耗时,参数,返回值,客户端信息
  • mysql.connect mysql连接时长
  • mysql.connect.error mysql链接错误信息
  • mysql.request mysql执行查询命令时长及相关信息
  • mysql.request.error mysql操作时报错的相关信息
  • redis.connect redis 链接时长
  • redis.connect.error redis链接错误信息
  • redis.request redis执行命令
  • redis.request.error redis操作时错误
  • memcache.connect
  • memcache.connect.error
  • memcache.request.error
  • http.get 另外可以支持restful操作get put delete
  • http.post
  • http.*.error

Metric日志类型

  • metric.counter ...略

分级日志类型

  • log.debug: debug log
  • log.trace: trace log
  • log.notice: notice log
  • log.info: info log
  • log.error: application error log
  • log.alarm: alarm log
  • log.exception: exception log

你会发现所有对依赖资源的请求都有相关日志这样可以帮助我们分析所有依赖资源的耗时及返回内容。此外我们的分级日志也在trace跟踪范围内通过日志信息可以更好地分析问题。而且如果我们监控的是静态语言还可以像之前说的那样对一些模块做Metrics定期产生日志。

日志格式样例

日志建议使用JSON格式所有字段除了标注为string的都建议保存为字符串类型每个字段必须是固定数据类型选填内容如果没有内容就直接不输出。

这样设计其实是为了适配Elasticsearch+KibanaKibana提供了日志的聚合、检索、条件检索和数值聚合但是对字段格式很敏感不是数值类型就无法聚合对比。

下面我给你举一个例子用于链路跟踪和监控,你主要关注它的类型和字段用途。

{ "name": "string:全量字段介绍,必填,用于区分日志类型,上面的日志列表内容写这里", "trace_id": "string:traceid必填", "rpc_id": "string:RPCID服务端链路必填客户端非必填", "department":"部门缩写如client_frontend 必填", "version": "string:当前服务版本 cpp-client-1.1 php-baseserver-1.4 java-rti-1.9,建议都填", "timestamp": "int:日志记录时间,单位秒,必填",

"duration": "float:消耗时间,浮点数 单位秒,能填就填",
"module": "string:模块路径建议格式应用名称_模块名称_函数名称_动作必填",
"source": "string:请求来源 如果是网页可以记录ref page选填",
"uid": "string:当前用户uid如果没有则填写为 0长度字符串可选填能够帮助分析用户一段时间行为",
"pid": "string:进程pid如果没有填写为 0长度字符串如果有线程可以为pid-tid格式可选填",
"server_ip": "string 当前服务器ip必填",
"client_ip": "string 客户端ip选填",
"user_agent": "string curl/7.29.0 选填",
"host": "string 链接目标的ip及端口号用于区分环境12.123.23.1:3306选填",
"instance_name": "string 数据库连接配置的标识比如rti的数据库连接选填",
"db": "string 数据库名称如peiyou_stastic选填",
"code": "string:各种驱动或错误或服务的错误码,选填,报错误必填",
"msg": "string 错误信息或其他提示信息,选填,报错误必填",
"backtrace": "string 错误的backtrace信息选填报错误必填",
"action": "string 可以是url、sql、redis命令、所有让远程执行的命令必填",
"param": "string 通用参数模板用于和script配合记录所有请求参数必填",
"file": "string userinfo.php选填",
"line": "string 232选填",
"response": "string:请求返回的结果,可以是本接口或其他资源返回的数据,如果数据太长会影响性能,选填",
"response_length": "int:相应内容结果的长度,选填",
"dns_duration": "float dns解析时间一般http mysql请求域名的时候会出现此选项选填",
"extra": "json 放什么都可以,用户所有附加数据都扔这里"

}

样例

被请求日志 { "x_name": "request.info", "x_trace_id": "123jiojfdsao", "x_rpc_id": "0.1", "x_version": "php-baseserver-4.0", "x_department":"tal_client_frontend", "x_timestamp": 1506480162, "x_duration": 0.021, "x_uid": "9527", "x_pid": "123", "x_module": "js_game1_start", "x_user_agent": "string curl/7.29.0", "x_action": "http://testapi.speiyou.com/v3/user/getinfo?id=9527", "x_server_ip": "192.168.1.1:80", "x_client_ip": "192.168.1.123", "x_param": "json string", "x_source": "www.baidu.com", "x_code": "200", "x_response": "json:api result", "x_response_len": 12324 }

mysql 链接性能日志

{ "x_name": "mysql.connect", "x_trace_id": "123jiojfdsao", "x_rpc_id": "0.2", "x_version": "php-baseserver-4", "x_department":"tal_client_frontend", "x_timestamp": 1506480162, "x_duration": 0.024, "x_uid": "9527", "x_pid": "123", "x_module": "js_mysql_connect", "x_instance_name": "default", "x_host": "12.123.23.1:3306", "x_db": "tal_game_round", "x_msg": "ok", "x_code": "1", "x_response": "json:****" }

Mysql 请求日志

{ "x_name": "mysql.request", "x_trace_id": "123jiojfdsao", "x_rpc_id": "0.2", "x_version": "php-4", "x_department":"tal_client_frontend", "x_timestamp": 1506480162, "x_duration": 0.024, "x_uid": "9527", "x_pid": "123", "x_module": "js_game1_round_sigup", "x_instance_name": "default", "x_host": "12.123.23.1:3306", "x_db": "tal_game_round", "x_action": "select * from xxx where xxxx", "x_param": "json string", "x_code": "1", "x_msg": "ok", "x_response": "json:****" }

http 请求日志

{ "x_name": "http.post", "x_trace_id": "123jiojfdsao", "x_department":"tal_client_frontend", "x_rpc_id": "0.3", "x_version": "php-4", "x_timestamp": 1506480162, "x_duration": 0.214, "x_uid": "9527", "x_pid": "123", "x_module": "js_game1_round_win_report", "x_action": "http://testapi.speiyou.com/v3/game/report", "x_param": "json:", "x_server_ip": "192.168.1.1", "x_msg": "ok", "x_code": "200", "x_response_len": 12324, "x_response": "json:responsexxxx", "x_dns_duration": 0.001 }

level log info日志

{ "x_name": "log.info", "x_trace_id": "123jiojfdsao", "x_department":"tal_client_frontend", "x_rpc_id": "0.3", "x_version": "php-4", "x_timestamp": 1506480162, "x_duration": 0.214, "x_uid": "9527", "x_pid": "123", "x_module": "game1_round_win_round_end", "x_file": "userinfo.php", "x_line": "232", "x_msg": "ok", "x_code": "201", "extra": "json game_id lesson_num xxxxx" }

exception 异常日志

{ "x_name": "log.exception", "x_trace_id": "123jiojfdsao", "x_department":"tal_client_frontend", "x_rpc_id": "0.3", "x_version": "php-4", "x_timestamp": 1506480162, "x_duration": 0.214, "x_uid": "9527", "x_pid": "123", "x_module": "game1_round_win", "x_file": "userinfo.php", "x_line": "232", "x_msg": "exception:xxxxx call stack", "x_code": "hy20001", "x_backtrace": "xxxxx.php(123) gotError:..." }

业务自发告警日志

{ "x_name": "log.alarm", "x_trace_id": "123jiojfdsao", "x_department":"tal_client_frontend", "x_rpc_id": "0.3", "x_version": "php-4", "x_timestamp": 1506480162, "x_duration": 0.214, "x_uid": "9527", "x_pid": "123", "x_module": "game1_round_win_round_report", "x_file": "game_win_notify.php", "x_line": "123", "x_msg": "game report request fail! retryed three time..", "x_code": "201", "x_extra": "json game_id lesson_num xxxxx" }

matrics 计数器

{ "x_name": "metrix.count", "x_trace_id": "123jiojfdsao", "x_department":"tal_client_frontend", "x_rpc_id": "0.3", "x_version": "php-4", "x_timestamp": 1506480162, "x_uid": "9527", "x_pid": "123", "x_module": "game1_round_win_click", "x_extra": "json curl invoke count" }

这个日志不仅可以用在服务端还可以用在客户端。客户端每次被点击或被触发时都可以自行生成一个新的TraceID在请求服务端时就会带上它。通过这个日志我们可以分析不同地域访问服务的性能也可以用作用户行为日志仅仅需添加我们的日志类型即可。

上面的日志例子基本把我们依赖的资源情况描述得很清楚了。另外,我补充一个技巧,性能记录日志可以将被请求的接口也记录成一个日志,记录自己的耗时等信息,方便之后跟请求方的请求日志对照,这样可分析出两者之间是否有网络延迟等问题。

除此之外这个设计还有一个核心要点研发并不一定完全遵守如上字段规则生成日志业务只要保证项目范围内输出的日志输出所有必填项目TraceIDRPCID/SpanIDTimeStamp同时保证数值型字段功能及类型稳定即可实现trace。

我们完全可以汇总日志后再对不同的日志字段做自行解释定制出不同业务所需的统计分析这正是ELK最强大的地方。

为什么大部分设计都是记录依赖资源的日志呢原因在于在没有IO的情况下程序大部分都是可控的侧重计算的服务除外。只有IO类操作容易出现不稳定因素并且日志记录过多也会影响系统性能通过记录对数据源的操作能帮助我们排查业务逻辑的错误。

我们刚才提到日志如果过多会影响接口性能,那如何提高日志的写吞吐能力呢?这里我为你归纳了几个注意事项和技巧:

1.提高写线程的个数一个线程写一个日志也可以每个日志文件单独放一个磁盘但是你要注意控制系统的IOPS不要超过100

2.当写入日志长度超过1kb时不要使用多个线程高并发写同一个文件。原因参考 append is not Atomic简单来说就是文件的append操作对于写入长度超过缓冲区长度的操作不是原子性的多线程并发写长内容到同一个文件会导致日志乱序

3.日志可以通过内存暂存汇总达到一定数据量或缓存超过2秒后再落盘这样可以减少过小日志写磁盘系统的调用次数但是代价是被强杀时会丢日志

4.日志缓存要提前malloc使用固定长度缓存不要频繁分配回收否则会导致系统整体缓慢

5.服务被kill时记得拦截信号快速fsync内存中日志到磁盘以此减少日志丢失的可能。

“侵入式埋点SDK”VS“AOP方式埋点”

最后我们再说说SDK。事实上使用“ELK+自定义的标准”基本上已经能实现大多数的分布式链路跟踪系统使用Kibana可以很快速地对各种日志进行聚合分析统计。

虽然行业中出现过很多链路跟踪系统服务公司做了很多APM等类似产品但是能真正推广开的服务实际占少数究其原因我认为是以下几点

分布式链路跟踪的日志吞吐很大,需要耗费大量的资源,成本高昂; 通用分布式链路跟踪服务很难做贴近业务的个性化,不能定制的第三方服务不如用开源; 分布式链路跟踪的埋点库对代码的侵入性大,需要研发手动植入到业务代码里,操作很麻烦,而且不够灵活。 另外这种做法对语言也有相关的限制因为目前只有Java通过动态启动注入agent才实现了静态语言AOP注入。我之前推广时也是统一了内网项目的开源框架才实现了统一的链路跟踪。

那么如果底层代码不能更新,如何简单暴力地实现链路跟踪呢?

这时候我们可以改造分级日志让它每次在落地的时候都把TraceId和RPCID或SpanID带上就会有很好的效果。如果数据底层做了良好的封装我们可以在发起请求部分中写一些符合标准性能的日志在框架的统一异常处理中也注入我们的标准跟踪即可实现关键点的监控。

当然如果条件允许我们最好提供一个标准的SDK让业务研发伙伴按需调用这能帮助我们统一日志结构。毕竟手写很容易格式错乱需要人工梳理不过即使混乱也仍旧有规律可言这是ELK架构的强大之处它的全文检索功能其实不在乎你的输入格式但是数据统计类却需要我们确保各个字段用途固定。

最后再讲点其他日志的注意事项可能你已经注意到了这个设计日志是全量的。很多链路跟踪其实都是做的采样方式比如Jaeger在应用本地会部署一个Agent对数据暂存汇总统计出每个接口的平均响应时间对具有同样特征的请求进行归类汇总这样可以大大降低服务端压力。

但这么做也有缺点,当我们有一些小概率的业务逻辑错误,在采样中会被遗漏。所以很多核心系统会记录全量日志,周边业务记录采样日志。

由于我们日志结构很简单如有需要可以自行实现一个类似Agent的功能降低我们存储计算压力。甚至我们可以在服务端本地保存原始日志7天当我们查找某个Trace日志的时候直接请求所有服务器在本地查找。事实上在写多读少的情况下为了追一个Trace详细过程而去请求200个服务器这时候即使等十秒钟都是可以接受的。

最后为了方便理解这里给你提供一个我之前写的laravel框架的Aop trace SDK 例子 laravel-aop-trace 供你参考

总结

系统监控一直是服务端重点关注的功能,我们常常会根据链路跟踪和过程日志,去分析排查线上问题。也就是说,监控越是贴近业务、越定制化,我们对线上业务运转情况的了解就越直观。

不过,实现一个更符合业务的监控系统并不容易,因为基础运维监控只会监控线上请求流量、响应速度、系统报错、系统资源等基础监控指标,当我们要监控业务时,还需要人工在业务系统中嵌入大量代码。而且,因为这些服务属于开源,还要求我们必须对监控有较深的了解,投入大量精力才可以。

好在技术逐渐成熟通用的简单日志传输索引统计服务开始流行其中最强的组合就是ELK。通过这类分布式日志技术能让我们轻松实现个性化监控需求。日志格式很杂乱也没关系只要将TraceID和RPCID或SpanID在请求依赖资源时传递下去并将沿途的日志都记录对应的字段即可。也正因如此ELK流行起来很多公司的核心业务都会依托ELK自定义一套自己的监控系统。

不过这么做只能让我们建立起一个粗旷的跟踪系统后续分析的难度和投入成本依然很大因为ELK需要投入大量硬件资源来帮我们处理海量数据相关知识我们后续章节再探讨

思考题

请你思考一下既然我们通过ELK实现Trace那么简单为什么会在当年那么难实现

欢迎你在评论区与我交流讨论,我们下节课见!