写在前面

通过前面的学习,我们已经可以搭起一个基础的微服务架构系统用于实现业务需求,但是随着业务的发展,系统的规模变得越来越大,各服务间的调用关系也变得错综复杂。一般来说,客户端发起一个请求后,在后端系统中会经过多个不同的微服务调用来协同产生最后的请求结果。在复杂的微服务架构系统中,几乎每一个前端请求都会形成一条复杂的分布式服务调用链路,在每条链路中任何一个依赖服务出现延迟过高或者错误的时候,都有可能导致请求最后的失败。此时对于每一个请求,全链路调用的跟踪就显得尤为重要,通过对请求的调用跟踪可以帮助我们快速发现错误根源以及监控分析每条请求链路上的性能瓶颈等。

对于分布式服务的跟踪问题,Spring Cloud Sleuth提供了一套完整的解决方案,接下来就学习如何使用Spring Cloud Sleuth。

入门演示

项目准备

为了后续学习Spring Cloud Sleuth,这里需要先做一些准备工作,来构建一些基础的设施和应用。

第一步,构建一个服务注册中心当然可以使用之前的eureka-server项目;

第二步,构建两个微服务应用,名称为trace-one和trace-two。其中trace-one项目需要提供一个REST风格的接口/trace-one,然后调用该接口后需要触发对trace-two项目的调用。

创建trace-one项目的步骤如下:

(1)新建一个普通的SpringBoot工程,名称为trace-one,注意使用默认的依赖。

(2)添加项目依赖。Spring Boot工程创建完成后,修改pom.xml文件,添加如下依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<spring-cloud.version>Hoxton.SR7</spring-cloud.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
</dependencies>

(3)在trace-one项目入口类上添加开启服务注册发现注解@EnableDiscoveryClient,并提供一个生成RestTemplate对象的方法,注意该方法上面必须使用@LoadBalanced注解,相应的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@SpringBootApplication
@EnableDiscoveryClient
public class TraceOneApplication {
@Bean
@LoadBalanced
RestTemplate restTemplate(){
return new RestTemplate();
}

public static void main(String[] args) {
SpringApplication.run(TraceOneApplication.class, args);
}
}

(4)在trace-one项目目录下新建controller包,并在里面新建HelloController类,它需要提供一个REST风格的接口/trace-one,然后调用该接口后需要触发对trace-two项目的调用。这里没有使用Feign而是直接使用Ribbon,其目的就是让大家对这个过程有较为深刻的认识。既然需要调用trace-two项目,那么就需要通过服务注册中心来调用,可以使用RestTemplate对象来进行操作,相应的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
public class HelloController {
private static Logger logger = LoggerFactory.getLogger(HelloController.class);

@Autowired
private RestTemplate restTemplate;

@GetMapping(value = "/trace-one")
public String traceOne(){
logger.info("********正在调用traceOne******");
return restTemplate.getForEntity("http://trace-two/trace-two",String.class).getBody();
}
}

(5)修改trace-one项目的application.properties配置文件,添加服务注册中心地址,项目名称和端口号等信息:

1
2
3
4
5
6
# 应用名称
spring.application.name=trace-one
# 端口号
server.port=5001
# 服务注册中心地址
eureka.client.service-url.defaultZone=http://localhost:1111/eureka/

这样关于trace-one项目的创建工作就完成了,接下来开始进行trace-two项目的创建工作。


trace-two项目比trace-one项目的创建流程更简单些,因为它不需要调用前者,相应的步骤如下:
(1)新建一个普通的SpringBoot工程,名称为trace-two,注意使用默认的依赖。
(2)添加项目依赖。Spring Boot工程创建完成后,修改pom.xml文件,添加如下依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<spring-cloud.version>Hoxton.SR7</spring-cloud.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
</dependencies>

(3)在trace-two项目入口类上添加开启服务注册发现注解@EnableDiscoveryClient,相应的代码如下:

1
2
3
4
5
6
7
@SpringBootApplication
@EnableDiscoveryClient
public class TraceTwoApplication {
public static void main(String[] args) {
SpringApplication.run(TraceTwoApplication.class, args);
}
}

(4)在trace-two项目目录下新建controller包,并在里面新建WorldController类,它需要提供一个REST风格的接口/trace-two,然后/trace-one接口被调用后,需要触发对/trace-two接口调用,相应的代码如下:

1
2
3
4
5
6
7
8
9
10
@RestController
public class WorldController {
private static Logger logger = LoggerFactory.getLogger(WorldController.class);

@GetMapping(value = "/trace-two")
public String traceTwo(){
logger.info("********正在调用traceTwo******");
return "This is the trace two!";
}
}

(5)修改trace-two项目的application.properties配置文件,添加服务注册中心地址,项目名称和端口号等信息:

1
2
3
4
5
6
# 应用名称
spring.application.name=trace-two
# 端口号
server.port=5002
# 服务注册中心地址
eureka.client.service-url.defaultZone=http://localhost:1111/eureka/

这样关于trace-two项目的创建工作就完成了。

第三步,启动测试。将eureka-server项目,trace-one项目,trace-two项目都启动起来,然后通过使用POSTMAN或者curl工具来完成对trace-one项目的/trace-one接口的测试,或者直接在浏览器地址栏中输入http://localhost:5001/trace-one连接得到返回值"This is the trace two!";,如下所示:

同时可以得到trace-one项目和trace-two项目的控制台都会输出调用相应方法的信息。

跟踪实现

前面项目启动正常之后,接下来开始改造项目,使其拥有服务跟踪功能。通过Spring Cloud Sleuth的封装,我们为应用增加服务跟踪功能非常简单,只需在项目的pom.xml文件中增加spring-cloud-starter-sleuth依赖,相应的代码如下:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>

既然这样就在trace-one项目和trace-two项目的pom.xml文件中添加spring-cloud-starter-sleuth依赖,然后重启上述三个项目,在浏览器地址栏中输入http://localhost:5001/trace-one,接着观察trace-one项目的控制台,可以得到如下所示的信息:

可以注意到图中圈红的黄色内容是添加Sleuth依赖之后才有的,形如[trace-one,31406450af9d124c,533509b73bc9fa9b,true]的日志信息,而这些元素正是实现分布式服务跟踪的重要组成部分,接下来对上述日志信息进行分析:

  • 第一个值trace-one,它记录了应用的名称,也就是application.properties配置文件中的spring.application.name参数的值。
  • 第二个值31406450af9d124c,它是Spring Cloud Sleuth生成的一个ID,称为Trace ID,它用来标识一条请求链路。请注意,一条请求链路中包含一个Trace ID,多个Span ID。
  • 第三个值533509b73bc9fa9b,它是Spring Cloud Sleuth生成的另一个ID,称为Span ID,它表示一个基本的工作单元,如发送一个HTTP请求。
  • 第四个值true,表示是否需要将该信息输出到Zipkin等服务,用于收集和展示。

接下来查看一下trace-two项目的控制台输出情况:

其实可以发现上面介绍的四个值中的Trace ID和Span ID,它们是Spring Cloud Sleuth实现分布式服务跟踪的核心。在一次服务请求链路的调用过程中,会保持并传递同一个Trace ID,进而将整个分布于不同微服务进程中的请求跟踪信息串联起来。上面的输出信息可以说明,trace-one和trace-two同属于一个前端服务请求来源,因此它们的Trace ID是相同的,处于同一条请求链路中。

跟踪原理

接下来开始学习分布式系统中的服务跟踪原理,其实这个原理非常简单就是借助于前面所介绍的2个ID:Trace ID和Span ID。

为了实现请求跟踪,当请求发送到分布式系统的入口端点时,只需要服务跟踪框架为该请求创建一个唯一的跟踪标识,同时在分布式系统内部流转的时候,框架始终保持传递该唯一标识,直到返回给请求方为止,而这个标识就是Trace ID。这样通过Trace ID的记录,我们就能将所有请求过程的日志关联起来,这就完成了全过程的跟踪,但是当我们还需要查看各个服务处理请求的详细时间呢?此时可以使用Span ID。

为了统计各处理单元的时间延迟,这就要求当请求到达各个服务组件时,或是处理逻辑到达某个状态时,也通过一个唯一的标识来标识它的开始,具体过程以及结束,而这个标识就是Span ID。对于每个Span ID来说,它必须有开始和结束这两个节点,通过记录开始Span和结束Span的时间戳,开发者就可以统计出该Span的时间延迟,当然除了时间戳记录之外,它还可以包含一些其他的元数据,如事件名称、请求信息等。

前面我们在Spring Boot工程中通过引入spring-cloud-starter-sleuth依赖后,它就会自动为当前应用构建起各通信通道的跟踪机制,如以下几种:(1)通过诸如RabbitMQ和Kafka(或者其他任何Spring Cloud Stream绑定器实现的消息中间件)等消息中间件传递的请求;(2)通过Zuul代理传递的请求;(3)通过使用RestTemplate发起的请求。这样就实现了日志级别的跟踪信息。

在前面的入门demo中,我们设置了trace-one对trace-two发起的请求是通过RestTemplate来实现的,因此Spring Cloud Sleuth组件会对该请求进行跟踪处理。实际上在请求发送到trace-two之前,Sleuth会在该请求的Header中增加实现跟踪需要的重要信息,那么你可能要问了,为什么我们之前没看到呢?那是因为你没将那些信息进行输出,下面列举5个用的较多的属性:
(1)X-B3-TraceId:一条请求链路(Trace)的唯一标识,必需值;
(2)X-B3-SpanId:一个工作单元(Span)的唯一标识,必需值;
(3)X-B3-ParentSpanId:标识当前工作单元所属的上一个工作单元,Root Span(请求链路的第一个工作单元)的该值为空;
(4)X-B3-Sampled:是否被抽样输出的标志,1表示需要被输出,0表示不需要被输出;
(5)X-Span-Name:工作单元的名称。

举个例子,我们尝试输出trace-two项目的TraceId和SpanId信息,此时需要的操作如下:

第一步,在trace-two项目的application.properties配置文件中添加如下信息:

1
2
# 设置SpringMVC的请求分发日志级别为DEBUG级别,以便看到更多的跟踪信息
logging.level.org.springframework.web.servlet.DispatcherServlet=DEBUG

第二步,修改trace-two项目WorldController类的traceTwo方法,用于从当前请求中获取TraceId和SpanId信息,相应的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
@RestController
public class WorldController {
private static Logger logger = LoggerFactory.getLogger(WorldController.class);

@GetMapping(value = "/trace-two")
public String traceTwo(HttpServletRequest request){
logger.info("********正在调用traceTwo******");
logger.info("TraceId={}, SpanId={} ",request.getHeader("X-B3-TraceId"),request.getHeader("X-B3-SpanId"));
return "This is the trace two!";
}
}

第三步,启动测试。将eureka-server项目,trace-one项目,trace-two项目都启动起来,然后通过使用POSTMAN或者curl工具来完成对trace-one项目的/trace-one接口的测试,或者直接在浏览器地址栏中输入http://localhost:5001/trace-one链接,然后观察trace-two项目的控制台输出信息,如下所示:

这说明我们获取TraceId和SpanId信息的功能就实现了。

抽样收集

通过TraceId和SpanId已经实现了对分布式系统中的请求跟踪,而记录的跟踪信息最终会被分析系统收集起来,并用于实现对分布式系统的监控和分析功能,如预警延迟过长的请求链路、查询请求链路的调用明细等。现在有一个问题,就是分析系统在收集跟踪信息的时候,收集多少跟踪信息才合适呢?理论上是收集的跟踪信息越多就越能反映出系统的实际运行情况,以便给出较为精准的预警和分析,但是在高并发的分布式系统运行时,大量的请求调用会产生海量的跟踪日志信息,收集过多的跟踪信息也将对整个分布式系统的性能造成一定的影响,同时保存大量的日志信息也需要大量的存储开销。鉴于此,Sleuth采用抽象收集的方式来为跟踪信息打上收集标记,也就是之前我们在日志信息中看到的第4个布尔类型的值,它代表了该信息是否需要被后续的跟踪信息收集器获取和存储。

在Sleuth中,抽样收集策略是通过Sampler抽象类来实现的,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public abstract class Sampler {
public static final Sampler ALWAYS_SAMPLE = new Sampler() {
public boolean isSampled(long traceId) {
return true;
}

public String toString() {
return "AlwaysSample";
}
};
public static final Sampler NEVER_SAMPLE = new Sampler() {
public boolean isSampled(long traceId) {
return false;
}

public String toString() {
return "NeverSample";
}
};

public Sampler() {
}

public abstract boolean isSampled(long var1);

public static Sampler create(float probability) {
return CountingSampler.create(probability);
}
}

可以看到里面有一个名为isSampled的抽象方法,Spring Cloud Sleuth组件会在产生跟踪信息的时候调用它来为跟踪信息生成是否需要被收集的标志。请注意,即使isSampled方法返回了false,它只是代表该跟踪信息不被输出到后续对接的远程分析系统(如Zipkin中),但是对于请求的跟踪活动依然会进行,所以我们在日志中还是能看到收集标识为false的记录。

在默认情况下,Sleuth会使用PercentageBasedSampler实现的抽象策略,以请求百分比的方式来配置和收集跟踪信息。开发者可以在application.properties配置文件中通过使用下面的参数来设置百分比,它的默认值是10,表示收集10%的请求跟踪信息:

1
2
# 收集10%的请求跟踪信息
spring.sleuth.sampler.rate=10

一般来说在开发调试期间通常会将其设置为100,表示收集全部跟踪信息并输出到远程仓库。当然了开发者也可以通过代码来实现相同的配置,只需创建AlwaysSampler的Bean(它实现的isSampled方法始终返回true)来覆盖默认的PercentageBasedSampler策略即可:

1
2
3
4
@Bean
public AlwaysSampler defaultSampler(){
return new AlwaysSampler();
}

在实际使用时,通过与Span对象中存储信息的配合,我们可以根据实际情况作出更符合需求的抽样策略,如实现一个仅仅包含指定Tag的抽样策略:

1
2
3
4
5
6
7
8
9
public class TagSampler extends Sampler{
private String tag;
public TagSampler(String tag){
this.tag =tag;
};
public boolean isSampled(Span span) {
return span.tags().get(tag)!=null;
}
}

由于跟踪日志信息数据的价值往往仅在最近的一段时间内非常有效,因此在设计抽样策略时,主要考虑在不对系统造成明显性能影响的情况下,以在日志保留时间窗内充分利用存储空间的原则来实现抽样策略。

这样关于Spring Cloud Sleuth分布式服务跟踪组件的基础使用就学习到这里,后续开始学习如何与ELK整合等知识。