超大流量分布式系统架构解决方案:人人都是架构师2.0
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

1.3 服务治理之调用链

如图1-21所示,在大规模服务调用场景下,服务之间的依赖关系可谓是错综复杂,甚至连架构师们都无法在短时间内梳理清楚服务之间的依赖关系和调用顺序。

图1-21 错综复杂的服务依赖关系

除此之外,在业务系统未实施服务化之前,各个子系统内部一定会存在较多的共享业务,尽管不利于维护,但是当某一个业务子系统出现故障时,开发人员只需要登录到指定的机器上即可快速根据错误异常日志定位问题和解决问题。随着这些共享业务被拆分成独立的服务,一次用户请求可能涉及后端多个服务之间的调用,那些分散在各个服务器上的孤立日志对于排查问题显然是非常不利的,因此到了这个阶段,企业构建分布式调用跟踪系统(即调用链)已迫在眉睫。

分布式调用跟踪系统其实就是一个监控平台,能够以可视化的方式展现跟踪到的每一个请求的完整调用链,以及采集调用链上每个服务的执行耗时、整合孤立日志等。目前,一些大型的互联网企业内部都构建有适用于自身业务特点的分布式调用跟踪系统,比如淘宝的鹰眼(EagleEye)、Twitter的Zipkin、新浪的Watchman,以及京东的Hydra、华为的SkyWalking(现为Apache开源项目)等。本节笔者先为大家介绍Google的Dapper论文中涉及的关于分布式调用跟踪系统的一些关键设计目标,再重点演示如何在Dubbo的基础上构建具备低侵入性的分布式调用跟踪系统。

1.3.1 Google的Dapper论文简介

目前众所周知的一些分布式调用跟踪系统大都脱胎于Google的论文Dapper,A Large Scale Distributed Systems Tracing Infrastructure。这篇论文中存在一些非常有价值的参考信息,如果大家希望在企业内部构建一个适用于自身业务场景的分布式调用跟踪系统,那么笔者强烈建议仔细阅读这篇论文。

笔者从这篇论文中整理出了分布式调用跟踪系统的四个关键设计目标,如下所示:

● 服务性能低损耗;

● 业务代码低侵入;

● 监控界面可视化;

● 数据分析准实时。

首先,分布式调用跟踪系统对生产环境中服务的性能损耗应该做到尽可能忽略不计,否则在一些特别注重性能的场景下,会严重影响系统整体的吞吐量,直接导致用户流失。其次,低侵入性也是一个非常重要的设计目标,分布式调用跟踪系统对于开发人员而言应该做到透明化,可以想象的是,在实际的开发过程中,业务团队每天只是应付大量的新增需求或者完善现有功能缺陷就已经应接不暇了,如果还需要在业务代码中进行大量的埋点上报工作,那么这样的监控系统未免也太脆弱了。再次,如图1-22所示,可视化的结果展现对于一个监控系统而言似乎是一个并不过分的要求,如果监控系统不具备可视化界面,那么后期推广时遇到的阻力可想而知。最后,数据的采集、运算和最终的结果展现应该做到快捷迅速,如果能够达到准实时级别的结果展现,开发人员就能够在服务异常的情况下及时做出反应和调整。

图1-22 SkyWalking监控界面

接下来我们来看看分布式调用跟踪系统的主要实现流程。

首先我们应该考虑的是如何将一次请求涉及的所有后端服务串联起来,这不仅是核心问题也是重点问题,只有将服务调用过程全部串联起来,才能够清楚一次请求的完整调用链。在这篇论文中,Trace表示对一次请求的完整调用链追踪,而Span则可以理解为Trace的组成结构,比如服务A和服务B的请求/响应过程就是一次Span。在生产环境中,一次用户请求可能涉及后端多个服务之间的调用,那么Span就用于体现服务之间具体的依赖关系。每一次请求都应该被分配一个全局唯一的TraceID,并且整个调用链中所有的Span过程都应该获取到同一个TraceID,以表示这些服务调用过程是发生在同一个Trace上的。

接下来我们来思考如何在服务或服务之间的调用过程中进行埋点和数据采集上报工作。通常来说,嵌入在RPC框架中进行埋点上报是最适合不过的(笔者所在企业采用的方案是嵌入到Dubbo中进行数据埋点和上报),因为这样就可以避免将埋点逻辑侵入业务代码中。而且,开发人员应重点关注的是自身的业务逻辑,而非埋点逻辑,甚至绝大多数开发人员并不愿意让这些非必需的耦合“污染”业务代码。埋点位置明确后,需要采集并上报哪些数据呢?由于上报的数据对后续的运算起了决定性作用,少采集和无用采集都会影响后续的结果展现和问题定位分析。因此需要采集的常规数据信息一般都包括TraceID、SpanID、ParentSpanID和RpcContext中包含的数据信息、服务执行异常时的堆栈信息,以及Trace中各个Span过程的开始时间和结束时间,并且这些数据信息都是需要在Trace的上下文信息中进行传递的。

笔者之前也强调过,跟踪系统一定不能对业务系统的执行性能产生较大影响,否则运维同学不愿意在生产环境中部署这样的监控系统也在情理之中,因此采样率便显得至关重要。根据流量的大小,动态对采样阈值进行调整,可以有效降低服务损耗,也可以避免丢失采样等一些较有价值的监控数据信息。

1.3.2 调用链的实现方案

笔者在前面小节为大家简单介绍了关于分布式调用跟踪系统的一些基础概念,接下来笔者就为大家演示如何基于Dubbo实现分布式调用跟踪系统。

Dubbo预留了足够多的接口,开发人员可以非常方便地对其进行二次开发,在Dubbo框架上实现调用跟踪就显得顺理成章(比如:Twitter的Zipkin嵌入Finagle框架中,而淘宝的EagleEye则嵌入HSF框架中)。除此之外,嵌入在Dubbo中可以有效降低对业务逻辑的侵入,不需要在业务代码中硬编码实现数据采集上报工作,从而满足Google的Dapper论文的设计目标,对开发人员做到尽可能透明化。

尽管市面上不同的服务调用跟踪产品在实现上或多或少都会存在一些差异,但是它们都具备以下两个基础功能:

● 跟踪每个请求的完整调用链;

● 采集调用链上每个服务的执行耗时,以及整合孤立日志。

既然是嵌入在Dubbo框架中实现服务的调用跟踪,那么方案已经明确了,但是具体应该如何实施,或者说,Dubbo为开发人员提供了哪些扩展接口来实现调用跟踪需求呢?一般,在开发过程中使用Filter来拦截HTTP请求,Dubbo也提供了专门用于拦截RPC请求的Filter接口,以便开发人员实现服务的调用跟踪和数据采集上报等功能扩展。

先来看看如何使用Dubbo的Filter接口对RPC请求进行拦截,如下所示:

实现Filter接口后,开发人员还需要重写其invoke(Invoker<?>invoker,Invocation invocation)方法。该方法中包含两个参数,其中Invoker接口提供用于执行目标服务方法的invoke()方法,Invocation接口可以向服务提供方传递当前Trace的上下文信息,其派生为RpcInvocation类。

成功编写好Filter后,还需要在Spring配置信息中对Filter进行配置,如下所示:

在此需要注意,由于Dubbo的Filter并没有纳入Spring的IoC容器中进行管理,因此我们需要在/resources目录下创建/META-INF/Dubbo/com.alibaba.Dubbo.rpc.Filter文件,并在文件中通过键值对的方式指定Filter类的全限定名,如下所示:

成功配置好Filter后,当服务调用方向服务提供方发起RPC请求时,Filter将会对其进行拦截,开发人员便可以在远程服务方法的执行前后实现自定义的埋点上报逻辑。Dubbo提供的Filter既可以对服务提供方进行拦截,也可以拦截服务调用方,只要在服务调用方和服务提供方的Spring配置信息中配置好Filter即可。

在Filter中可以使用Dubbo提供的一个临时状态记录器RpcContext类,通过调用RpcContext提供的一系列方法,可以非常方便地采集到当前Span过程中包含的一些非常有价值的数据信息,比如被Filter拦截的到底是服务调用方还是服务提供方、Host、Port、被调用的服务接口名称、被调用的服务方法名称,以及用户的一些自定义参数等。在分布式调用跟踪系统中,采集这些关键数据非常重要,因为如果没有这些数据信息作为支撑,那么后续跟踪系统将无法顺利进行数据运算和结果展现。使用RpcContext来获取当前Span过程的状态记录信息,如下所示:

既然一次请求可能会涉及后端多个服务之间的调用,并且在并发环境下同一时刻肯定会有许多用户请求进来,那么如何区分不同的服务调用究竟属于哪一个Trace呢?为Trace中的每一个Span过程都分配同一个全局唯一的TraceID是一个不错的方案,这样一来,一次请求中涉及的所有后端服务调用都会被完整地串联起来。那么TraceID应该如何生成呢?在单机环境中,生成一个全局唯一的ID似乎是一件非常简单的事情,但是在分布式环境中,生成一个既要考虑唯一性,又要兼顾连续性的ID就变得非常困难。关于在分布式环境中生成唯一ID的解决方案,大家可以直接阅读5.2.6节。

实现调用跟踪,首先需要根据TraceID将一次请求中涉及的所有后端服务调用完整地串联起来形成一个Trace,然后再考虑如何明确各个服务之间的调用顺序和依赖关系等问题。如图1-23所示,既然可以为Trace分配TraceID,那么自然也可以为Trace中的每一个Span过程分配一个SpanID和ParentSpanID。

图1-23 通过TraceID串联起来的Trace

SpanID用于标记每一个Span过程,代表着服务的调用顺序。而ParentSpanID则用于明确Trace中服务的依赖关系。SpanID的值会随每一次服务调用递增,而ParentSpanID的值则来源上一个Span过程的SpanID,如下所示:

● Consumer和服务A的请求/响应过程为一次Span,由于是根调用,那么SpanID为0,没有ParentSpanID;

● 服务A和服务B的请求/响应过程为一次Span,SpanID为1,ParentSpanID为0;

● 服务B和服务D的请求/响应过程为一次Span,SpanID为2,ParentSpanID为1;

● 服务A和服务C的请求/响应过程为一次Span,SpanID为3,ParentSpanID为0。

通过TraceID、SpanID和ParentSpanID,便能够快速地梳理不同的Trace中服务之间的调用顺序和依赖关系,但是服务调用方如何将这些Trace上下文信息向下传递给服务提供方呢?如图1-24所示,当Filter对服务调用方进行拦截时,可以将Trace上下文信息Set进由Dubbo提供的RpcInvocation接口中;当Filter对服务提供方进行拦截时,再从中获取出之前由服务调用方传递过来的Trace上下文信息即可。关于RpcInvocation的使用,如下所示:

图1-24 在RPC请求中传递的Trace上下文信息

除了需要采集TraceID、SpanID、ParentSpanID和RpcContext中包含的数据信息,以及服务执行异常时的堆栈信息,还需要采集Trace中各个Span过程的开始时间和结束时间,以便跟踪系统后续根据这些采集到的时间参数进行服务的执行耗时运算。简单来说,当Filter拦截到RPC请求时,需要记录一个开始时间,当服务调用完成后还需要记录一个结束时间。那么服务调用方和服务提供方就是四个不同维度的时间戳,如下所示:

● Client Send Time(CS,客户端发送时间);

● Client Receive Time(CR,客户端接收时间);

● Server Receive Time(SR,服务端接收时间);

● Server Send Time(SS,服务端发送时间)。

如图1-25所示,通过采集这四个不同维度的时间戳,便可以在一次请求完成后计算出整个Trace的执行耗时、网络耗时,以及Trace中每个Span过程的执行耗时、网络耗时等结果数据。关于服务执行耗时的运算规则,如下所示:

图1-25 服务执行耗时的采集

● 服务调用耗时=CR-CS;

● 服务处理耗时=SS-SR;

● 网络耗时=服务调用耗时-服务处理耗时;

● 前置网络耗时=SR-CS;

● 后置网络耗时=CR-SS。

或许有些同学会对服务调用耗时和服务处理耗时这两者的定义区分不清楚。简单来说,服务调用耗时表示一次RPC请求/响应所花费的总时间,而服务处理耗时则表示服务提供方执行服务方法所花费的时间,用前者减去后者就可以得出服务调用消耗在网络上的时间。网络耗时其实是一个非常重要的性能监控指标,因为在带宽紧张的情况下,网络访问速度的快慢将直接影响系统整体的吞吐量,当然网络耗时我们还可以进一步细分为前置网络耗时和后置网络耗时。

在Filter中实现调用跟踪和数据采集上报的伪代码,如下所示:

当服务调用方向服务提供方发起RPC请求时,Filter会对服务调用方进行拦截,然后试图从ThreadLocal中获取当前线程的Trace上下文信息,如果不存在则说明这是一次根调用,需要生成TraceID,然后将生成的TraceID、SpanID设置在Invocation中传递给服务提供方,接着执行前置数据采集上报(开始时间维度为Client Send Time)。当调用Invoker接口的invoke()方法执行完远程服务方法后,再执行后置数据采集上报(结束时间维度为Client Receive Time)。

当RPC请求到达服务提供方后,Filter会对其进行拦截,然后从Invocation中获取由服务调用方传递过来的Trace上下文信息,并将其存储到当前线程的ThreadLocal中,然后执行前置数据采集上报(开始时间维度为Server Receive Time)。当调用Invoker接口的invoke()方法执行完服务方法后,再执行后置数据采集上报(结束时间维度为Server Send Time),最后还需要删除存储在ThreadLocal中当前线程的Trace上下文信息。

如果服务提供方内部还调用了其他服务,Filter会对调用方进行拦截,然后从ThreadLocal中获取当前线程的Trace上下文信息,修改SpanID和设置ParentSpanID后,再将其设置到Invocation中传递给服务提供方,接着执行前置数据采集上报(开始时间维度为Client Send Time)。当调用Invoker接口的invoke()方法执行完远程服务方法后,再执行后置数据采集上报(结束时间维度为Client Receive Time)。

在此大家需要注意,如果将所有数据信息都直接写入数据库中将会给数据库带来较大的负载压力,因此笔者建议将消息优先写入消息队列中,待消费端消费到消息后,再写入数据库,以实现削峰效果。如果数据采集量较大,笔者建议优先考虑Elasticsearch落盘,反之使用MySQL则比较实惠。由于监控数据的时效性较高,长期保存的意义不大,因此定期清理数据库中的历史数据也是非常必要的。

关于后续分布式调用跟踪系统的实时计算、离线分析,以及可视化结果呈现等相关逻辑,本书就不再一一进行讲解了。如果大家感兴趣,可以参考其他的文献或书籍。

1.3.3 基于非侵入式运行期AOP方案实现数据采集上报

在上一个小节中,笔者为大家演示了如何使用Dubbo提供的Filter技术来实现调用链的数据采集上报任务,当然有些同学可能会产生疑问,如果使用的是其他RPC框架时该怎么办呢?而且,基于Filter技术对业务系统来说仍然是存在一定侵入性的,开发人员除了需要修改Spring的Bean定义信息外,还需要在项目的/resources目录下新建/META-INF/Dubbo/com.alibaba.Dubbo.rpc.Filter文件。那么是否还有更优的解决方案可以做到零侵入性呢?答案是肯定的,本小节笔者会重点为大家讲解如何使用非侵入式运行期AOP方案来优化上一小节的数据采集上报任务。

在正式开始对本小节的内容进行讲解之前,我们不妨先回顾下AOP,以及在实际的开发过程中为什么需要用到AOP?简单来说,AOP(Aspect OrientedProgramming,面向切面编程)指的是在编译期或运行期通过字节码增强技术来实现以不改变源码为前提,通过前后横切的方式,动态为程序添加新功能的一种思想。相信大家都知道,OOP针对的是业务处理过程的实体及其属性和行为进行抽象封装,以获得更加清晰高效的逻辑单元划分,那么程序中各个组件、模块之间必然会存在着依赖性,也就是通常我们所说的耦合,在追求高内聚、低耦合的当下,想要完全消除程序中的耦合是不现实的,但过分的、不必要的耦合又往往容易导致我们的代码很难被复用,甚至还需为此付出高昂的维护成本。一般来说,一个成熟的系统中往往都会包含但不限于如下6点通用逻辑:

● 日志记录;

● 异常处理;

● 事务处理;

● 权限检查;

● 性能统计;

● 流控管理。

在AOP术语中,我们把上述这些通用逻辑称之为切面(Aspect)。试想一下,如果在系统的各个业务模块中都充斥着上述这些与自身逻辑毫无瓜葛的共享代码会产生什么问题?很明显,当被依赖方发生改变时,避免不了需要修改程序中所有依赖方的逻辑代码,着实不利于维护。想要解决这个痛点,就必须将这些共享代码从逻辑代码中剥离出来,让其形成一个独立的模块,以一种声明式的、可插拔式的方式来应用到具体的逻辑代码中去,以此消除程序中那些不必要的依赖、提升开发效率和代码的可重用性,这就是我们使用AOP的目的和意义,如图1-26所示。

图1-26 使用AOP进行解耦

之前为大家演示的Dubbo Filter技术,实际上就是属于AOP思想的一种体现,在此大家需要注意,AOP和具体的实现技术无关,只要是符合AOP的思想,我们都可以将其称之为AOP的实现。目前市面上AOP框架的实现方案通常都是基于如下2种形式的:

● 静态编织;

● 动态编织。

静态编织选择在编译期就将AOP增强逻辑插入到目标类的方法中,以AspectJ为例,当成功定义好目标类和代理类后,通过命令“ajc-d.”进行编译后调用执行时即会触发增强逻辑;对字节码文件进行反编译后,大家会发现目标类中多出来了一些代码,这些多出来的代码实际上就是AspectJ在编译期就往目标类中插入的AOP字节码。而动态编织选择在运行期以动态代理的形式对目标类的方法进行AOP增强,大家非常熟悉的Cglib、Javassist,以及ASM等字节码生成工具都可用于支撑这一方案的实现;当然,笔者最常用的还是Javassist,因为它很好地屏蔽了诸多底层技术细节,使得开发人员在即使不懂JVM指令的情况下也能够正确地操作字节码,既简单又高效(Dubbo的动态代理生成使用的就是Javassist技术);使用Javassist创建动态代理有2种方式:一种是基于ProxyFactory的方式,而另一种则是基于动态代码的实现方式,一般来说,选择后者可以获得更好的执行性能。

当然,无论是选择静态编织还是动态编织方案来实现AOP增强,都会面临着侵入性和固化性2个问题。侵入性问题之前已经讲解过了,那么固化性问题又该如何理解呢?其实,所谓固化性指的就是程序在启动后,增强逻辑只会对之前约定好的那些方法生效,而无法做到在运行时重新对一个已有的方法进行增强。大家思考下,由于JVM是允许开发人员在运行时对存储在方法区内的字节码内容进行某些修改操作的,那么是否可以对同一个类进行多次加载来解决固化性问题?接下来,笔者就为大家演示在程序中直接使用sun.misc.Unsafe的defineClass()方法指定同一个ClassLoader对目标类进行多次加载,看看会发生什么,伪代码如下所示:

当上述程序示例运行时,必然会触发java.lang.LinkageError异常,从堆栈信息的描述来看,同一个ClassLoader不允许对一个类进行多次加载。既然此路不通,那么是否还有别的办法可以做到动态对方法区内的字节码内容进行修改呢?从JDK1.5开始,Java的设计者们在java.lang.instrument包下为开发人员提供了基于JVMTI(Java Virtual Machine Tool Interface,Java虚拟机工具接口)规范的Instrumentation-API,使得我们能够使用Instrumentation来构建一个独立于应用程序的Agent程序,以便于监测和协助运行在JVM上的程序,当然最重要的是,使用Instrumentation可以在运行时对类定义进行修改和替换,换句话来说,相当于我们可以在运行时以插桩的方式来实现对目标类的方法进行AOP增强。

在JDK1.5版本中使用Instrumentation时,我们需要在应用的启动脚本中加入“-javaagent”参数,由应用的启动来同步带动Agent程序的启动。仔细想想看,其实这样的交互方式似乎并不是特别友好,假设线上环境某个接口的访问流量突然飙升,那么为了避免系统产生宕机风险,我们往往需要立即采取限流保护措施,但这时你却告诉你的老板,需要让运维同学把线上所有的相关服务都全部重启一遍,并在启动脚本中加上“-javaagent”参数才能确保限流逻辑生效,不过当你在讲这句话的时候,一定要确保你的老板并没有在喝水。Java的设计者们似乎也意识到了这个问题,所以在JDK1.6版本中,Instrumentation再次迎来了新生,被注入了更加灵活和强大的动态能力,可以允许我们的Agent程序在应用启动之后再通过Attach的方式动态附着到目标进程上,以达到Instrumentation的目的。

接下来,笔者就为大家简单演示下如何使用Instrumentation在运行时修改一个类的类定义。首先我们需要编写一个Agent-Class类,伪代码如下所示:

上述程序示例中,笔者首先通过Instrumentation.addTransformer()方法添加了一个实现了ClassFileTransformer接口的转换器,并重写其transform()方法,用于返回增强后的字节码内容(实际上,几乎没有人会选择直接在Agent-Class中实现增强逻辑,因为需要考虑类污染、冲突等问题,所以通常Agent-Class扮演的是一个Launcher的角色);接着再调用Instrumentation.retransformClasses()方法对目标类的类定义进行转换。最后执行Maven的“package”命令将其打包之前,我们还需要在pom.xml文件中定义<Agent-Class/>和<Can-Retransform-Classes/>标签指定Agent-Class和允许对类定义做修改。

接下来我们需要使用com.sun.tools.attach包下的Attach-API,根据目标进程的PID动态附着到目标进程上,如下所示:

VirtualMachine代表着一个Java虚拟机,也就是“Attach”的目标虚拟机,调用其attach()方法后即可实现对目标进程的动态附着。成功附着后还需要调用VirtualMachine.loadAgent()方法加载Agent包触发AOP增强。在此大家需要注意,虽然Instrumentation允许开发人员在运行时对类定义进行替换和修改,但必须遵守如下3点基本原则:

● 不允许新增、修改,以及删除成员变量;

● 不允许新增和删除方法;

● 不允许修改方法签名。

尽管通过Instrumentation可以实现非侵入式运行期AOP增强,但如果直接将其应用到生产环境中则还有许多问题需要解决和考虑。比如由负责ClassPath路径的AppClassLoader来加载Agent包中的相关类时,就会对业务系统造成“污染”,因为谁也无法保证编写业务代码和Agent代码的开发人员一定是来自同一个团队的,那么自然也就无法避免Agent包和业务包中同时依赖有一些相同的第三方构件,当版本存在差异时,就会导致出现类冲突问题,系统的稳定性自然会受到影响。

相信大家都清楚,JVM中的类加载器缺省基于的是双亲委派模型(Parents Delegation Mode)的类加载机制。也就是说,当类加载器在对目标类进行加载的时候,首先会委派给它的父类加载器,然后再由它的父类继续向上委派,直至委派给最顶层的Bootstrap ClassLoader。当委派结束后,Bootstrap ClassLoader会尝试发起加载,加载失败时则回源给它的子类加载器,如图1-27所示。

图1-27 双亲委派模型

实际上双亲委派模型就是一种自下而上委派,自上而下加载的类加载机制,通过它能够很好地确立类在整个虚拟机中的全局唯一性和统一性。但大家需要明确的是,双亲委派模型并不是一种强制性约束,在某些特定的业务场景下,只有破坏双亲委派模型才能解决具体问题。以Tomcat为例,在一个Web容器中是可以允许开发人员部署多个应用包的,那么在对这些应用包进行加载时也面临着和Instrumentation场景下相同的问题。Tomcat的解决方案是通过自定义类加载器,并对双亲委派模型进行破坏来达到目的,这样一来,每个应用都会由一个独立的类加载器来负责加载,由于每个类加载器都拥有各自独立的类命名空间,不同命名空间中的类由于彼此之间不能交互,自然也就形成了隔离,从而避免了类冲突问题。

在程序中定义一个自定义类加载器,无非就是实现一个ClassLoader接口,但应该如何破坏双亲委派模型呢?其实很简单,我们只需对其loadClass()方法进行重写,将sun.*或java.*包中的类交由父类加载器去加载,其他类全部由当前类加载器自行加载即可,如下所示:

在使用Instrumentation时,为了避免产生类污染、冲突等问题,Advice则只能由自定义类加载器来负责加载,这时就会面临一个问题,由子类加载器负责加载的类对父类加载器而言是不可见的,那么业务代码中应该如何调用Advice的代码呢?出于对效率等多方面因素的考虑,业界通常采用的做法是在顶层的类加载器Bootstrap ClassLoader中注册一个对虚拟机中所有类加载器都具备可见性的间谍类Spy,然后增强目标类的方法时,会在其前后插入一段调用间谍类静态方法的代码(间谍类中会持有对隔离类的引用),以此来打通类隔离后的“通信”操作。

在实际的开发过程中,笔者并不建议直接在程序中使用Instrumentation,而是鼓励大家尽量使用一些成熟的非侵入式运行期AOP框架,以便于开发人员能够更专注于自身的业务逻辑。以阿里开源的JVM-Sandbox为例,在拥有良好类隔离策略的保护下,大家完全不用担心类污染、冲突等问题,关于JVM-Sandbox的使用方式,本书就不再一一进行讲解了,大家可以自行参考JVM-Sandbox的官方使用手册,而关于JVM-Sandbox的设计原理和实现细节,大家可以直接阅读本书的附录。

1.3.4 调配采样率

笔者在介绍Google的Dapper论文时,曾经提过关于监控系统的4个关键设计目标,其中服务性能低损耗这个设计目标非常重要,因为在生产环境中如果监控系统对核心业务的性能影响较大,那么我们不得不将其关停。因此架构师在设计和落地监控系统时,必然需要仔细权衡,否则得不偿失。

调用链对服务的损耗主要在调用跟踪和数据采集上,除了开发人员自身的代码优化,还可以结合采样率在最大程度上控制损耗。在大促场景下,由于峰值流量较大,通常只需要采样其中很小的一部分请求即可(比如1/1000的采样率,监控系统只会在1000次请求中采样其中的某一次),从而降低监控系统给服务性能带来的损耗。采样控制最简单的做法是为所有的服务进程配置相同且固定的采样率,但这种做法显得极其不灵活,如果采样率阈值被设置得较低,用户流量较小就会错失很多重要的监控数据,因此笔者建议大家将采样率配置在配置中心内,以便运维同学动态调整,这样既能兼顾服务性能又可以做到不丢失重要的监控数据。