优质博文:IT-BLOG-CN
环境:jdk8/tomcat7
一、问题现象
应用系统再一次发布回退时,cpu util
突然徒增到100%
以上,与此同时,服务响应时间大幅度增加,依赖该服务的应用接连抛出超时异常,发生熔断。
二、问题分析
根据实际监控情况,发现除了cpu
负载和超时引起的错误突然提高外,剩余指标mem
并无明显变化,所以排除掉OOM
的情况,推测可能与回退时生产的并发请求相关。
根据上述分析,在cat
上对堡垒机进行ThreadDump
(一个选项栏),发现如下异常:
【1】http
的线程数在blacking
时出现大幅度增加,增大到线程池最大值1024
。blocking
的线程数也是大幅度增长,由于业务代码中没有加锁的操作,所以第一反应是很不合理。
【2】http
线程出现大量锁竞争,并且都是在相同的方法中获取锁时产生的block
。
当我们点击block
中的线程时,可以看到如下详细信息:
当点击 锁竞争详情 按钮时,可以看到具体的错误代码位置。
三、问题排查
代码分析: 点击锁竞争详情可以看到在Excutable
类中的declaredAnnotations()
方法存在临界区,该方法是一个同步方法,存在加锁的情况。
结合调用栈可知是服务获取城市基础数据时,调用了ROC
框架中的标有@Cacheable
注解的getData()
缓存方法,其中AOP
代码中会调用方法Method
对象的getAnnotation()
方法,进而调用了Excutable
类的declaredAnnotations()
来解析注解数据。
Executable.class
private transient volatile Map<Class<? extends Annotation>, Annotation> declaredAnnotations;
private synchronized Map<Class<? extends Annotation>, Annotation> declaredAnnotations() {if (declaredAnnotations == null) {// 获取declaredAnnotations}return declaredAnnotations;
}
CityCacheRepository.class
@Cacheable(cacheNames = Cache.BASIC_DATA_CACHE_NAME, key = "#key")
public CityDataWrapper getData(String key) {// 计算返回CityDataWrapper
}
原因分析:
【1】分析业务代码可知,代码中存在三处调用CityRepository.getData()
方法,但由于业务代码中存在不合理的无效调用和多层嵌套循环,使得一次http
请求会产生多达200
次的getData()
方法调用。
【2】服务的单台机器的tps
可达80
,所以计算下来每秒getData()
方法调用次数可达16000
次,这使得接入流量的瞬间在declaredAnnotations()
方法上产生线程阻塞,并迅速消费完线程池中的所有线程,还导致cpu
负载变高,响应变量的现象。
四、问题总结
解决办法: 减少getData()
方法的调用次数,优化后的一次http
请求,平均只需要调用7
次getData()
方法,并通过压测发现cpu
利用率不再异常升高,也不再出现线程block
的情况。
思考总结:
【1】在业务代码中,尤其是在高qps
场景下,需要注意服务调用过程中的临界区,压测作为一种重要的测试手段,通过并发测试可以提高暴露线程安全的问题。
【2】在业务多层嵌套的场景中,任何一种方法的不当调用可能就会产生令人难以置信的调用风暴,尤其是在高qps
情况下更需要注意,合理减少嵌套层数或者减少调用次数。
五、深入拓展
对于@Cacheable
注解,由于declaredAnnotations()
方法存在临界区,所以这里需要测量它对于普通服务一次请求调用的影响范围。
环境: 1c2g
的docker
机器,order jdk8/tomcat7
,其线程池保持默认的1024
个。应用中定义一个使用@cacheable
注解的方法,并暴露出rest
接口。
压测过程:
【1】使用jmeter
请求rest
接口,并调用@Cacheable
注解的方法,并保证一次http
请求会调用一次@Cacheable
注解的方法。
【2】通过不断提升qps
,观察服务响应结果错误率、响应时间RT
以及机器cpu
状态,确保在结果错误率在0.1%
以下时,观察各项指标数据。
压测结果:
【1】通过观察,在0.1%
的错误率下,压测的qps
最高可达800qps
,此时的平均RT
为35ms
,根据c=qps*RT
,可以计算出该应用的最大并发数c=30
。
【2】此时观察机器状态,发现cpu
利用率达到74%
,并且没有出现线程block
状态。
压测结论: 一次http
请求调用一次@Cacheable
注解方法场景下,服务在接收所能承受的最大qps
时,其并没有出现线程block
的情况,说明在此场景下,并不需要关注@Cacheable
带来的limit
。
一次请求调用多次Cacheable
方法时对服务的影响
环境: 与上述一致
压测过程: 这里使用jmeter
请求rest
接口,与上述不同的是http
请求传递一个数值参数,保证一次请求可以循环调用指定数量的@Cacheable
方法。
压测结果:
【1】通过观察qps=100
的时候,当循环次数达到700
左右的时候,也会出现线程block
的情况。
【2】此时cpu
利用率超过100%
,响应时间会大幅度增加,但由于qps
没有达到最大值,所以错误率仍在0.1
下。
压测结果:
【1】此时可以计算出在qps=100
时,@Cacheable
注解方法调用qps
达到7w
左右,此时会出现线程阻塞现象,所以在一次请求中如果多次调用@Cacheable
方法需要注意可能存在线程阻塞影响。
【2】由于在不同qps
下,其线程并发情况不一样,所以在不同的qps
下其线程block
的循环次数也是不一样的,其都需要根据具体情况而定。
【3】不管是一次请求调用一次@Cacheable
方法还是一次请求调用多次@Cacheable
方法,都无法精确计算出由于@Cacheable
导致阻塞的并发线程数,所以这里只能通过qps
的维度来衡量线程阻塞的阈值。
Excutable 类
【1】在jdk1.8
中declaredAnnotations()
方法加锁其实是通过一个单例实现的,使得线程在调用该方法时,无论单例字段是否有数据,都需要先拿锁,所以在多线程调用的情况下,会出现线程阻塞现象。
【2】@Cacheable
注解代表的是一大类使用注解的方法,因此对于运行时的注解,一般都需要解析注解里的数据,也就需要调用declaredAnnotations()
方法,所以在调用服务时,如果存在高频使用包括但不限于@Cacheable
注解的时候,防止出现线程阻塞,最好的方式就是做压测。
jdk8
中Executable.class
类
// 单例字段
private transient volatile Map<Class<? extends Annotation>, Annotation> declaredAnnotations;
// 线程进来先拿锁,这里会出现线程阻塞
private synchronized Map<Class<? extends Annotation>, Annotation> declaredAnnotations() {if (declaredAnnotations == null) {// 获取declaredAnnotations}return declaredAnnotations;
}
jdk11
中对该方法的实现进行了优化,使用了懒加载的线程安全的单例模式,并不再方法上加锁,所以这块不在是瓶颈,不会长时间出现线程阻塞现象。
// 单例字段
private transient volatile Map<Class<? extends Annotation>, Annotation> declaredAnnotations;
// 线程进来先观察字段是否为null,所以只有在第一次初始化字段时,才会出现部分线程的阻塞,之后不再存在阻塞现象
private Map<Class<? extends Annotation>, Annotation> declaredAnnotations() {Map<Class<? extends Annotation>, Annotation> declAnnos;if ((declAnnos = declaredAnnotations) == null) {synchronized (this) {if ((declAnnos = declaredAnnotations) == null) {// 获取declaredAnnotations}}}return declAnnos;
}