随着互联网架构的扩张,分布式系统变得日趋复杂,越来越多的组件开始走向分布式化,如微服务、消息收发、分布式数据库、分布式缓存、分布式对象存储、跨域调用,这些组件共同构成了繁杂的分布式网络,很容易出现以下问题:
通过调用链跟踪,一次请求的逻辑轨迹可以用完整清晰的展示出来。开发中可以在业务日志中添加调用链ID,可以通过调用链结合业务日志快速定位错误信息。
在调用链的各个环节分别添加调用时延,可以分析系统的性能瓶颈,进行针对性的优化。通过分析各个环节的平均时延,QPS等信息,可以找到系统的薄弱环节,对一些模块做调整,如数据冗余等。
调用链绑定业务后查看具体每条业务数据对应的链路问题,可以得到用户的行为路径,经过了哪些服务器上的哪个服务,汇总分析应用在很多业务场景。
通过可视化分布式系统的模块和他们之间的相互联系来理解系统拓扑。点击某个节点会展示这个模块的详情,比如它当前的状态和请求数量。
埋点即系统在当前节点的上下文信息,可以分为客户端埋点、服务端埋点,以及客户端和服务端双向型埋点。埋点日志通常要包含以下内容:
TraceId、RPCId、调用的开始时间,调用类型,协议类型,调用方ip和端口,请求的服务名等信息;
调用耗时,调用结果,异常信息,消息报文等;
预留可扩展字段,为下一步扩展做准备;
日志的采集和存储有许多开源的工具可以选择,一般来说,会使用离线+实时的方式去存储日志,主要是分布式日志采集的方式。典型的解决方案如Flume结合Kafka等MQ。
一条调用链的日志散落在调用经过的各个服务器上,首先需要按 TraceId 汇总日志,然后按照RpcId 对调用链进行顺序整理。用链数据不要求百分之百准确,可以允许中间的部分日志丢失。
汇总得到各个应用节点的调用链日志后,可以针对性的对各个业务线进行分析。需要对具体日志进行整理,进一步储存在HBase或者关系型数据库中,可以进行可视化的查询。
Agent也就是常说的探针埋点,Agent可以理解为虚拟机级别的AOP,可以利用Agent实现热部署等功能
代码侵入式埋点
代码侵入式埋点是指提供应用开发的SDK,或者提供集成埋点代码的框架供应用开发者调用。像Google这类具备框架研发能力的企业将植入点选在开发框架或通信框架中,确保基于统一框架开发或通信的应用天然具备埋点能力,开发团队除框架外无需关注埋点实现和调用方式。这种埋点方式优势在于使用框架后无需额外关注埋点能力,变相降低了埋点的成本,Twitter的Zipkin、淘宝的鹰眼、大众点评的CAT等都属于这类埋点方式。
代码侵入式埋点具有较好的扩展性,方便用户自定义采集的数据类型与层次。但无论提供框架埋点还是提供装备库、SDK的方式都需要侵入代码,在应用开发及框架升级等场景下,应用需要重新修改代码。同时,对于应用开发人员来说,精准地识别埋点位置也有难度,且基于代码侵入的埋点跟踪级别较低,无法获取足够详细的运行动态信息。
字节码增强式埋点
字节码增强式埋点方式无需修改代码,不同的编程语言通过不同的技术在语言运行环境或基础库上植入。利用字节码增强技术,Java应用在启动JVM时通过不同的埋点插件覆盖不同的通信协议、中间件和开发框架,对Java基础调用代码进行函数级埋点。**这种埋点方式的优势在于能够采集到堆栈级的调用信息及其他更多运行态信息,帮助使用者无需日志等辅助手段即可快速完成问题定位。**代表性的开源产品PinPoint及大部分商业化产品都采用这类方式。
使用字节码增强技术进行APM数据采集时,通过在应用启动时配置Java Agent探针的方式主动干预应用代码行为,应用开发者无需进行代码修改,由APM产品来决定在哪些API进行数据埋点。理论上来说字节码增强技术能够在任意位置进行埋点。
互联数据(Wire Data)是指连接客户端和服务器之间的网络光纤所承载的全量、双向的通讯数据。它是被处理过的、高价值的业务可用数据源,是实时、直观了解系统业务运行状况最全面客观的参考数据资源。
通过实时地将网络中传输的海量数据重组而成的结构化数据,可以帮助IT运维人员创建行为基线、检测异常行为,进行实时地性能故障定位和排除等;同时,让运维团队贴近业务,可以用数据来监测业务的交易量、成功率、失败率等,为业务和运营团队提供数据支撑,让IT基础设施更好地提供澎湃动力,为科技引领业务创新创造可能。
低消耗
dapper本质是用来发现性能消耗问题,如果dapper本身很消耗性能,没人愿意使用,因此低消耗是必须的,dapper使用一系列创新方法确保低消耗,比如使用采样方法。
应用级透明
应用级透明的意思是程序员可以不需要在自己的代码中嵌入dapper相关的代码就能达到分布式追踪日志记录的目的。每一个工程师都希望自己的代码是纯粹的,如果需要嵌入dapper相关代码,那么既影响代码维护,又影响bug定位。
扩展性好
对于一个快速发展的互联网公司而言,用户规模快速增长导致着服务以及机器数量越来越多,因此dapper需要适应相应的发展,扩展性要好。
dapper日志记录的格式
dapper用span来表示一个服务调用开始和结束的时间,也就是时间区间(图2对应着图1b的调用图)。dapper记录了span的名称以及每个span的ID和父ID,如果一个span没有父ID被称之为root span。所有的span都挂在一个特定得追踪上,共用一个跟踪ID,这些ID用全局64位整数标示,也就是下图的traceID。
如何实现应用级透明?
在google的环境中,所有的应用程序使用相同的线程模型、控制流和RPC系统,既然不能让工程师写代码记录日志,那么就只能让这些线程模型、控制流和RPC系统来自动帮助工程师记录日志了。
举个例子,几乎所有的google进程间通信是建立在一个用C++和JAVA开发的RPC框架上,dapper把跟踪植入这个框架,span的ID和跟踪的ID会从客户端发送到服务端,这样工程师也就不需要关心。
dapper跟踪收集的流程
各个服务将span数据写到本机日志上
dapper守护进程进行拉取,将数据读到dapper收集器里
dapper收集器将结果写到bigtable中,一次跟踪被记录为一行
如何尽可能降低开销?
作为一个分布式追踪系统,dapper希望尽可能降低性能开销。如果对每一次的请求都进行追踪收集,开销还是有点大的。一个比较好的方式是通过统计采样的方法,抽样追踪一些请求,从而达到性能开销与精度的折中。
dapper的第一个版本设置了一个统一的采样率1/1024,也就是1024个请求才追踪一次。后来发现对一些高吞吐的服务来说是可以的,比如每秒几十万的请求,但是对一些低吞吐量的服务,比如每秒几十个请求的服务,如果采样率设置为1/1024,很多性能问题可能不会被追踪到。因此在第二版本dapper提供了自适应的采样率,在低吞吐量时候提高采样率,在高吞吐量时降低采样率。
上面的采样是在第一个阶段,此外在收集器将span数据写到bigtable时,还可以使用第二次采样,即不一定都将数据写入到bigtable中。
在整个CAT从开发至今,一直秉承着简单的架构就是最好的架构原则,整个CAT主要分为三个模块,cat-client,cat-consumer,cat-home。
在实际开发和部署中,cat-consumer和cat-home是部署在一个jvm内部,每个CAT服务端都可以作为consumer也可以作为home,这样既能减少整个CAT层级结构,也可以增加整个系统稳定性。
上图是CAT目前多机房的整体结构图:
CAT主要支持以下四种监控模型:
CAT监控系统将每次URL、Service的请求内部执行情况都封装为一个完整的消息树、消息树可能包括Transaction、Event、Heartbeat、Metric等信息。
CAT客户端是java实现的,客户端在收集端数据方面使用ThreadLocal,是线程本地变量,也可以称之为线程本地存储。线程局部变量(ThreadLocal)其实的功用非常简单,就是为每一个使用该变量的线程都提供一个变量值的副本,是Java中一种较为特殊的线程绑定机制,是每一个线程都可以独立地改变自己的副本,而不会和其它线程的副本冲突。
在监控场景下,为用户提供服务都是web容器,web容器比如Tomcat或者Jetty,后端的rpc服务端比如dubbo或者点评自研的服务框架pigeon,也都是基于线程池来实现的。业务方在处理业务逻辑时基本都是在一个线程内部调用后端服务,数据库,缓存等,将这些数据拿回来在进行业务逻辑逻辑封装,最后将结果展示给用户。所以将所有的监控请求作为一个监控上下文存入于线程变量就非常合适。
上图理解起来不太直观,可以看一下cat-client源码进行理解,就是创建一个ThreadLocal,在使用ThreadLocal中会有内存泄漏问题
当设计者对监控以及性能分析有足够深度的理解下,才能定义好监控的API,监控和性能分析所针对的场景有如下几种
在如上的领域模型的基础上,CAT设计自己核心的几个监控对象 Transaction、Event、Heartbeat、Metric
序列化和通信是整个客户端包括服务端性能里面很关键的一个环节
日志埋点是监控活动的最重要环节之一,日志质量决定着监控质量和效率。CAT的埋点目标是以问题为中心,像程序抛出exception就是典型问题。我个人对问题的定义是:不符合预期的就可以算问题。比如请求未完成,响应时间快了慢了,请求TPS多了少了,时间分布不均匀等等。
在互联网环境中,典型的突出的容易出问题的场景,包括跨模块调用,跨公司调用等。比如
服务端单机cat-consumer的整体架构如下:
如上图,CAT服务端在整个实时处理中,基本上实现了全异步化处理。
当某个报表处理器处理来不及时候,比如Transaction报表处理比较慢,可以通过配置支持开启多个Transaction处理线程,并发消费消息。
CAT服务端实时报表分析是整个监控系统的核心,CAT重客户端采集的是是原始的Logview,目前一天大约有3000亿的消息,所以需要在这些消息基础上实现丰富报表,以支持业务问题以及性能分析的需要。
CAT根据日志消息的特点(比如只读特性)和问题场景,量身定做的。CAT将所有的报表按消息的创建时间,一小时为单位分片,那么每小时就产生一个报表。当前小时报表的所有计算都是基于内存的,用户每次请求即时报表得到的都是最新的实时结果。对于历史报表,因为它是不变的,所以就实时不实时也就无所谓了。
CAT基本上所有的报表模型都可以增量计算,它可以分为:计数、计时和关系处理三种。计数又可以分为两类:算术计数和集合计数。典型的算术计数如:总个数(count),总和(sum),均值(avg),最大/最小(max/min),吞吐(tps)和标准差(std)等,其他都比较直观,标准差稍微复杂一点,大家自己可以推演一下怎么做增量计算。那集合运算,比如95线(表示95%请求的完成时间),999线(表示99.9%请求的完成时间),则稍微复杂一些,系统开销也更大一点。
CAT每个报表往往有多个维度,以transaction报表为例,它有5个维度,分别是应用、机器、Type、Name和分钟级分布情况。如果全维度建模,虽然灵活,但开销将会非常之大。CAT选择固定维度建模,可以理解成将这5个维度组织成深度为5的树,访问时总是从根开始,逐层往下进行。
CAT服务端为每个报表单独分配一个线程,所以不会有锁的问题,所有报表模型都是非线程安全的,其数据是可变的。这样带来的好处是简单且低开销。
CAT报表建模是使用自研的maven plugin自动生成的。所有报表是可合并和裁剪的,可以轻易地将2个或多个报表合并成一个报表。在报表处理代码中,CAT大量使用访问者模式(visitor pattern)。
CAT系统的存储主要有两块
报表是根据logview实时运算出来的给业务分析用的报表,默认报表有小时模式,天模式,周模式以及月模式。CAT实时处理报表都是产生小时级别统计,小时级报表中会带有最低分钟级别粒度的统计。天、周、月等报表都是在小时级别报表合并的结果报表。
原始logview存储一天大约300TB的数据量,因为数据量比较大所以存储必须要要压缩,原始logview需要根据messageId读取。在这样的情况下,存储整体要求就是批量压缩以及随机读。在当时场景下,并没有特别合适成熟的系统以支持这样的特性,所以我们开发了一种基于文件的存储以支持CAT的场景,在存储上一直是最难的问题,我们一直在这块持续的改进和优化。
CAT每个消息都有一个唯一的ID,这个ID在客户端生成,后续CAT都通过这个ID在进行消息内容的查找。比如在分布式调用里面,RPC消息需要串起来,比如A调用B的时候,在A这端生成一个MessageId,在A调用B的过程中,将MessageId作为调用传递到B端,在B执行过程中,B用context传递的MessageId作为当前监控消息的MessageId。
CAT消息的MessageId格式ShopWeb-0a010680-375030-2,CAT消息一共分为四段
消息存储是CAT最有挑战的部分。关键问题是消息数量多且大,目前美团点评每天处理消息3000亿左右,大小大约300TB,单物理机每秒要处理200MB左右的流量。CAT服务端基于此流量做实时计算,还需要将这些数据压缩后写入磁盘。
整体存储结构如下图
CAT数据文件分为两种,一类是index文件,一类是Data文件
CAT在分布式实时方面,主要归结于以下几点因素:
在Google Dapper中分布式事务追踪是如何工作的
当一个消息从Node1发送到Node2时,分布式追踪系统的核心是在分布式系统中识别在Node1中处理的消息和在Node2中处理的消息之间的关系。
问题在于无法在消息之间识别关系。例如,我们无法识别从Node1发送的第N个消息和Node2接收到的N’消息之间的关系。换句话说,当Node1发送完第X个消息时,是无法在Node2接收到的N的消息里面识别出第X个消息的。有一种方式试图在TCP或者操作系统层面追踪消息。但是,实现很复杂而且性能低下,而且需要为每个协议单独实现。另外,很难精确追踪消息。
不过,Google dapper实现了一个简单的解决方案来解决这个问题。这个解决方案通过在发送消息时添加应用级别的标签作为消息之间的关联。例如,在HTTP请求中的HTTP header中为消息添加一个标签信息并使用这个标签跟踪消息。
Pinpoint中,核心数据结构由Span, Trace, 和 TraceId组成。
下图描述TraceId的行为,在4个节点之间执行了3次的RPC调用:
TransactionId (TxId) 体现了三次不同的RPC作为单个事务被相互关联。但是,TransactionId 本身不能精确描述PRC之间的关系。为了识别PRC之间的关系,需要SpanId 和 ParentSpanId (pSpanId). 假设一个节点是Tomcat,可以将SpanId想象为处理HTTP请求的线程,ParentSpanId代表发起这个RPC调用的SpanId.
使用TransactionId,Pinpoint可以发现关联的n个Span,并使用SpanId和ParentSpanId将这n个span排列为继承树结构。
SpanId 和 ParentSpanId 是 64位长度的整型。可能发生冲突,因为这个数字是任意生成的,但是考虑到值的范围可以从-9223372036854775808到9223372036854775807,不太可能发生冲突. 如果key之间出现冲突,Pinpoint和Google Dapper系统,会让开发人员知道发生了什么,而不是解决冲突。
TransactionId 由 AgentIds, JVM (java虚拟机)启动时间, 和 SequenceNumbers/序列号组成.
Dapper 和 Zipkin, Twitter的一个分布式系统跟踪平台, 生成随机TraceIds (Pinpoint是TransactionIds) 并将冲突情况视为正常。然而, 在Pinpiont中我们想避免冲突的可能,因此实现了上面描述的系统。有两种选择:一是数据量小但是冲突的可能性高,二是数据量大但是冲突的可能性低。我们选择了第二种。
可能有更好的方式来处理transaction。我们起先有一个想法,通过中央key服务器来生成key。如果实现这个模式,可能导致性能问题和网络错误。因此,大量生成key被考虑作为备选。后面这个方法可能被开发。现在采用简单方法。在pinpoint中,TransactionId被当成可变数据来对待。
前面我们解释了分布式事务跟踪。实现的方法之一是开发人员自己修改代码。当发生RPC调用时容许开发人员添加标签信息。但是,修改代码会成为包袱,即使这样的功能对开发人员非常有用。
Twitter的 Zipkin 使用修改过的类库和它自己的容器(Finagle)来提供分布式事务跟踪的功能。但是,它要求在需要时修改代码。我们期望功能可以不修改代码就工作并希望得到代码级别的可见性。为了解决这个问题,pinpoint中使用了字节码增强技术。Pinpoint agent干预发起RPC的代码以此来自动处理标签信息。
字节码增强在手工方法和自动方法两者之间属于自动方法。
下面是每个方法的优点和缺点:
优点 | 缺点 | |
---|---|---|
手工跟踪 | 1. 要求更少开发资源 2. API可以更简单并最终减少bug的数量 | 1. 开发人员必须修改代码 2. 跟踪级别低 |
自动跟踪 | 1. 开发人员不需要修改代码 2. 可以收集到更多精确的数据因为有字节码中的更多信息 | 1. 在开发pinpoint时,和实现一个手工方法相比,需要10倍开销来实现一个自动方法 2. 需要更高能力的开发人员,可以立即识别需要跟踪的类库代码并决定跟踪点 3. 增加bug发生的可能性,因为使用了如字节码增强这样的高级开发技巧 |
我们选择字节码增强的理由,除了前面描述的那些外,还有下面的强有力的观点:
一旦API被暴露给开发人员使用,我们作为API的提供者,就不能随意的修改API。这样的限制会给我们增加压力。
我们可能修改API来纠正错误设计或者添加新的功能。但是,如果做这些受到限制,对我们来说很难改进API。解决这个问题的最好的答案是一个可升级的系统设计,而每个人都知道这不是一个容易的选择。如果我们不能掌控未来,就不可能创建完美的API设计。
而使用字节码增强技术,我们就不必担心暴露跟踪API而可以持续改进设计,不用考虑依赖关系。对于那些计划使用pinpoint开发应用的人,换一句话说,这代表对于pinpoint开发人员,API是可变的。现在,我们将保留隐藏API的想法,因为改进性能和设计是我们的第一优先级。
使用字节码增强的缺点是当Pinpoint自身类库的采样代码出现问题时可能影响应用。不过,可以通过启用或者禁用pinpoint来解决问题,很简单,因为不需要修改代码。
通过增加下面三行到JVM启动脚本中就可以轻易的为应用启用pinpoint:
-javaagent:$AGENT_PATH/pinpoint-bootstrap-$VERSION.jar
-Dpinpoint.agentId=<Agent's UniqueId>
-Dpinpoint.applicationName=<The name indicating a same service (AgentId collection)>
如果因为pinpoint发生问题,只需要在JVM启动脚本中删除这些配置数据。
由于字节码增强技术处理java字节码, 有增加开发风险的趋势,同时会降低效率。另外,开发人员更容易犯错。在pinpoint,我们通过抽象出拦截器(interceptor)来改进效率和可达性(accessibility)。pinpoint在类装载时通过介入应用代码为分布式事务和性能信息注入必要的跟踪代码。这会提升性能,因为代码注入是在应用代码中直接实施的。
在pinpoint中,拦截器API在性能数据被记录的地方分开(separated)。为了跟踪,我们添加拦截器到目标方法使得before()方法和after()方法被调用,并在before()方法和after()方法中实现了部分性能数据的记录。使用字节码增强,pinpoint agent可以记录需要方法的数据,只有这样采样数据的大小才能变小。
通过使用二进制格式(thrift)可以提高编码速度,虽然它使用和调试要难一些。也有利于减少网络使用,因为生成的数据比较小。
如果将一个长整型转换为固定长度的字符串, 数据大小一般是8个字节。然而,如果你用变长编码,数据大小可以是从1到10个字符,取决于给定数字的大小。为了减小数据大小,pinpoint使用thrift的CompactProtocol协议(压缩协议)来编码数据,因为变长字符串和记录数据可以为编码格式做优化。pinpoint agent通过基于跟踪的根方法的时间开始来转换其他的时间来减少数据大小。
为了得到关于三个不同方法被调用时间的数据,不得不在6个不同的点上测量时间,用固定长度编码这需要48个字节(6 * 8)。
以此同时,pinpoint agent 使用可变长度编码并根据对应的格式记录数据。然后在其他时间点通过和参考点比较来计算时间值(在vector中),根方法的起点被确认为参考点。这只需要占用少量的字节,因为vector使用小数字。图中消耗了13个字节。
如果执行方法花费了更多时间,即使使用可变长度编码也会增加字节数量。但是,依然比固定长度编码更有效率。
cat | pinpoint | |
---|---|---|
依赖 | Java 6,7,8 Maven3.2.3+ mysql5.6+ Linxu内核2.6+ |
Java 6,7,8 maven3+ Hbase0.94+ |
实现方式 | 代码埋点(拦截器,注解,过滤器等) | java探针,字节码增强 |
存储选择 | mysql , hdfs | HBase |
通信方式 | - | thrift |
MQ监控 | 不支持 | 不支持 |
全局调用统计 | 支持 | 支持 |
trace查询 | 不支持 | 不支持 |
报警 | 支持 | 支持 |
JVM监控 | 不支持 | 支持 |
star数 | 4.5K | 5.6K |
优点 | 功能完善。 | 完全无侵入, 仅需修改启动方式,界面完善,功能细致。 |
缺点 | 代码侵入性较强,需要埋点 文档比较混乱,文档与发布版本的符合性较低,需要依赖点评私服 (或者需要把他私服上的jar手动下载下来,然后上传到我们的私服上去)。 |
不支持查询单个调用链, 对外表现的是整个应用的调用生态。 二次开发难度较高 |
文档 | 网上资料较少,仅官网提供的文档,比较乱 | 文档完善 |
开发者 | 大众点评 | naver |
使用公司 | 大众点评, 携程, 陆金所,同程旅游,猎聘网 | naver |
未完待续……