引入 spring-cloud-starter-zipkin 组件之后,启动项目卡住(死锁)

背景

项目是基于 spring cloud 搭建的微服务框架,在 gateway 网关上打算引入分布式链路跟踪的能力,经过调研之后决定使用 Spring Cloud Sleuth + zipkin 框架,当项目引入这两个框架的jar之后,问题就此发生了。

问题描述

当在pom.xml中引入了 sleuth + zipkin的依赖如下:

<dependency>
	<groupId>org.springframework.cloudgroupId>
	<artifactId>spring-cloud-starter-sleuthartifactId>
dependency>
<dependency>
	<groupId>org.springframework.cloudgroupId>
	<artifactId>spring-cloud-starter-zipkinartifactId>
dependency>

在没有做其他额外的配置,然后启动项目,发现项目启动到一半之后就卡住了,控制台打印日志显示正在初始化Redis,如下图:

引入 spring-cloud-starter-zipkin 组件之后,启动项目卡住(死锁)_第1张图片

然后项目就一直卡在这里了,启动过程中也没有任何的报错信息,就只是卡在这里。

但是当我们注释掉 pom.xml 文件中的 spring-cloud-starter-zipkin 依赖之后,项目又可以正常运行了。

初步排查原因

  1. 首先确定了问题是因为我们项目中引入了spring-cloud-starter-zipkin 依赖产生的,当时首先想到的是版本号的问题,怀疑是Spring Cloud 版本号与我们引入的依赖不兼容导致的,或者是内部有版本冲突。然后我们使用 maven 分析了项目依赖,并没有问题,而且通常版本出问题,启动应该是会报错,而不是卡住,所以排除了这个可能。

  2. 启动中途卡住了很像是线程阻塞了,所以就从线程的角度去排查问题。我们使用 jstack 打印了一下线程的堆栈信息,果然发现了问题,打印的线程信息如下图,为了方便查看,我做了简化,删除了正常的线程和一些堆栈信息,只保留两个有问题的线程:
    引入 spring-cloud-starter-zipkin 组件之后,启动项目卡住(死锁)_第2张图片

  3. 从上图中可以看到 main 线程在 WAITING 状态,这也就是我们的项目为何启动到一半之后卡住了。

  4. 从上图中还可以看到另一个线程 lettuce-nioEventLoop-7-1 处于 BLOCKED 状态,该线程是 Redis 客户端 lettuce 开启的一个线程,用于执行 Redis 身份认证的线程,这就是为什么我们的日志中显示卡在了初始化 Redis 的时候。

  5. 还可以发现 lettuce-nioEventLoop-7-1 线程是因为在等待 main 线程持有的锁 0x00000006c268a358 ,而 main 线程也在等待 lettuce-nioEventLoop-7-1 线程执行完。

  6. 此时,我们已经可以定位到问题了,是 spirng boot 项目在启动过程中发生了死锁。

进一步排查死锁的原因

现在我们就进一步通过 Spring 源码,来排查为什么会发生这个死锁。还是从线程堆栈信息出发,堆栈信息中包含了整个调用链路,可以给我们提供很多的信息,lettuce-nioEventLoop-7-1完整的堆栈如下图:

引入 spring-cloud-starter-zipkin 组件之后,启动项目卡住(死锁)_第3张图片

  1. 从上图中就可以看出来 lettuce-nioEventLoop-7-1 线程在等待 DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:216) 这里的锁,这个方法的主要逻辑是从容器中获取单例Bean,getSingleton方法内容如下:

    // 创建一个容器,用来存放单例 Bean
    private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
    
    // 该方法的主要做用就是根据 beanName 从容器中获取单例 bean
    // 如果容器中没有该单例 bean,就通过 singletonFactory 创建一个,并放到容器中
    public Object getSingleton(String beanName, ObjectFactory<?> singletonFactory) {
    		// 对容器加锁,也正是这个锁,造成了死锁。
    		synchronized (this.singletonObjects) {
    			// 根据 beanName 从容器中获取这个 bean
    			Object singletonObject = this.singletonObjects.get(beanName);
    			// 如果没有获取到,就根据 singletonFactory 创建一个新的 bean 放在容器里。
    			if (singletonObject == null) {	
    				// 创建bean之前的回调
    				beforeSingletonCreation(beanName);
    				// 一个标记位,用来判断是否是新创建的bean
    				boolean newSingleton = false;				
    				try {
    					// 通过 singletonFactory 创建一个bean
    					singletonObject = singletonFactory.getObject();
    					// 标记位置为true
    					newSingleton = true;
    				}
    				catch (IllegalStateException ex) {	
                        // 再次尝试获取
    					singletonObject = this.singletonObjects.get(beanName);
    					if (singletonObject == null) {
    						throw ex;
    					}
    				}				
    				finally {			
    					// 创建bean之后的回调方法
    					afterSingletonCreation(beanName);
    				}
    				if (newSingleton) {
    					// 新创建的 bean 放到容器中。
    					addSingleton(beanName, singletonObject);
    				}
    			}
    			return singletonObject;
    		}
    }
    
    1. 通过堆栈信息,我们知道是因为 main 线程和 lettuce-nioEventLoop-7-1 同时抢占 singletonObjects 对象的锁导致的,main 线程是因为启动时需要初始化项目中所有的单例 bean 所以需要持有这个锁,合情合理。但是lettuce-nioEventLoop-7-1 线程为什么也要抢这个锁呢,这里面有问题,我们一起接着往下看:

    2. 我们首先看一下 lettuce-nioEventLoop-7-1 线程,是做什么的线程,以及他为什么要抢占锁, 还是接着从堆栈信息中出发,可以看到该线程,是 Redis 客户端 Lettuce 启动的一个线程,用于操作redis。

    3. 因为是引入了 zipkin 之后,才出现的死锁,所以我们主要从堆栈信息中关注 zipkin 的相关代码逻辑,上图中红框中的内容就是zipkin 的调用链路,我们一起深入看一下源码。

      // 标记是否执行跟踪,默认为false,当我们引入了zipkin之后,这里通过自动配置设置为true
      private final boolean tracingEnabled;
      // 这个方法主要的做用就是执行一条redis命令
      private void writeSingleCommand(ChannelHandlerContext ctx, RedisCommand<?, ?, ?> command, ChannelPromise promise) {
      
              addToStack(command, promise);
      		//  因为引入zipkin 开启了跟踪,所以会执行这个if的逻辑
              if (tracingEnabled && command instanceof CompleteableCommand) {
      
                  TracedCommand<?, ?, ?> provider = CommandWrapper.unwrap(command, TracedCommand.class);
                  Tracer tracer = clientResources.tracing().getTracerProvider().getTracer();
                  TraceContext context = (provider == null ? clientResources.tracing().initialTraceContextProvider() : provider)
                          .getTraceContext();
      			// 从这里经过几次调用之后,会进入到spring逻辑中, 去抢占锁
                  Tracer.Span span = tracer.nextSpan(context);
                  // 省略无关代码... 
              }
      		//  执行redis命令
              ctx.write(command, promise);
      }
      
    4. 从上面代码里可以看到因为引入了 zipkin ,所以会走进 if 逻辑中,后面的代码调用就是些zipkin的逻辑。

    5. 后续的逻辑大概就是 zipkin 想要使用 spring 的 @RefreshScope 机制来创建 Sampler Bean(采样器)如下,此时 spring 尚未初始化完成,因此形成了死锁。

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ej1zxy3j-1624616423863)(C:\Users\yu109\AppData\Roaming\Typora\typora-user-images\image-20210625174542141.png)]
      引入 spring-cloud-starter-zipkin 组件之后,启动项目卡住(死锁)_第4张图片

    6. 主要的问题还是在于 zipkin 过早的调用了 @RefreshScope 从而形成死锁。

    解决办法

    因为涉及到 zipkin 的源码,我们没有进行过多的分析,这里直接说解决办法可能有些突兀,但是还是在这一节直接公布答案吧,因为我懒。。。 。

    在 zipkin 的 Github 中也有说到这个问题,并且给出了解决方案

    自己声明配置一个zipkin的采样器,而不是使用 spring boot 自动配置的 代码如下:

    @Configuration
    public class SleuthSamplerConfiguration {
        
        @Bean
        public Sampler defaultSampler() throws Exception {
            // 这个值是采样率,设置为1就是100%采样,每一条请求都采,0.1就是10%采样率
            Float f = new Float(0.1f);
            SamplerProperties samplerProperties = new SamplerProperties();
            samplerProperties.setProbability(f);
            ProbabilityBasedSampler sampler = new ProbabilityBasedSampler(samplerProperties);
            return sampler;
        }
    }
    

你可能感兴趣的:(Spring,Cloud实战,spring,cloud,sleuth,zipkin)