前言
对于程序员来说,我们时常会遇到一些暂时难以理解的问题。这常常会让我们感到无比困扰和无奈。"我的代码哪里出错了?""我的程序运行得那么稳定,我并没有做出任何更改,怎么可能会出问题?""为何问题频发?我的本地环境是完全正常的!"我们相信,大家在日常编程中,肯定都遇到过类似的困扰,或者听到过这样的叹息。难道问题真的会无缘无故出现吗?答案当然是否定的。这就像是"冰山效应"一样,我们如果只看到水面上的部分,自然会感到困惑,只有当我们深入探索,看清冰山的全貌,我们才能豁然开朗。
在实际的编程过程中,对问题的定位实际上就是一次深入冰山探索的过程。对于我们编程者来说,其实并不是多么陌生。此刻,小编就想分享一段关于线上问题定位的高潮部分,请跟着小编一起来看看我们是如何一步步剖析冰山的真面目的。
一、场景说明
- 时间:某天清晨8点24分
- 现象 :业务系统出现大量RejectedExecutionException告警,异常主要集中在(172.xx.84.113)节点上
- 近期操作:无业务代码变更,发布等动作,存在服务pod重启动作(因资源问题被驱逐)
二、根因总结
经过我们研发一系列的排查,最终发现问题出现在KMSClient懒加载的实现代码上,我们一起看下具体的代码和原因:
2.1 问题代码
从下面代码我们可以看到程序员小哥哥是期望使用使用is_init①和synchronized③来实现加密client的懒加载的,主要为了避免kmsClient带来的高昂的资源消耗,但是is_init①和synchronized③使用不当,引发了一系列连锁反应
Java
@Slf4j
public class EncryptUtil {
// ① 懒加载标识
private static final StringBuffer is_init = new StringBuffer("");
/**数据加密*/
public static String encrypt(String plaintext){
try {
if(StringUtils.isEmpty(plaintext)){
return null;
}
// ② 判断懒加载标识
if(!"1".equals(is_init.toString())){ // 1 初始化入口
init();
}
return Util.encryptDataForVersion(plaintext, "logic_sharding", "v1");
} catch (Exception e) {
log.error("数据加密失败", e);
return null;
}
}
/**数据解密*/
public static String decrypt(String cipherText){...}
/** 客户端初始化 */
private static void init(){
// ③ 客户端初始化,使用synchronized控制并发
synchronized (EncryptUtil.class){
KMSClient.initSecurity(Arrays.asList("logic_sharding"));
is_init.append("1");
}
}
}
2.2 问题根因
根据上面代码我们知道程序员小哥想要的kmsClient懒加载能力不仅没有达成,还引发了线程池阻塞,接下来让我一起来看下线程池阻塞是怎么产生的:
1、数据加密的代码通过一个① StringBuffer 类型的状态标识is_init
,判断是否要初始化 KMSClient(is_init
为 "1" 则表示已经初始化)。设计出发点可能是对 KMSClient 进行懒加载,并且只初始化一次。但代码设计欠妥,在并发场景下,状态的初始值会设置错误,从而导致后续每次加密都需要对 KMSClient 进行初始化。
思维模拟
- 假设服务初始化,此时
is_init
为空字符串;- T1、T2、T3 三个线程同时调用
EncryptUtil.encrypt(text)
对数据进行加密,由于此时is_init
为空字符串,②if(!"1".equals(is_init.toString()))
结果都是 true,T1、T2、T3 都尝试执行init()
方法;- T1、T2、T3 竞争监视器锁,假设 T1 获得锁,T2、T3 则 Blocked;
- T1 顺利完成 KMSClient 初始化,并
append("1")
到is_init
中,随后释放锁;- T2、T3 依次获得锁,并依次
append("1")
到is_init
中,此时is_init
实际变成了字符串 "111";- T4、T5、T6...后续线程接踵而至,都尝试对数据加密,但是由于
is_init
已经是 "111" 了,第二步中的 if条件 总是true
,因此后续的所有线程都要尝试获取锁、初始化 KMSClient;
2、KMSClient 的初始化是一个相对比较 "重量" 的操作,其中甚至还包含网络调用,耗时在 2-3s,因此,一旦某个线程调用 init()
方法,就会持有 2-3s 的监视器锁,后续线程都只能等待;
3、分流的很多关键接口内部都需要保存数据,并对数据加密/解密;
4、大部分业务处理逻辑都共享了 bizAsyncExecutor 这个线程池,导致线程池快速耗尽;
三、根因分析
追踪过程比较有趣也比较曲折,特别是这种需要在一定条件下才会触达的问题,排期起来更有挑战和成就感。实际上,如果当时保留现场,排查起来会比较容易,但因为情况紧急、当时大家都在路上,应急后现场没了,再排查起来就比较困难了。这里分享一下我们一步步靠近真相的过程:
证据#1: RejectedExecutionException 飙升 在早上应急的过程中,我们直观认识到的问题是RejectedExecutionException
,这显然是线程池的 AbortPolicy
,表示线程池耗尽,无法处理新的任务。而且经过后续排查,异常只在一个 Pod 实例 (172.xx.84.113) 上出现,其他实例正常。
图1
分析 线程池用尽,直接能够联想到的原因有:
1、正常的用尽:任务执行变慢,例如下游依赖耗时增加,线程池数量配置不足;
2、非正常的用尽:工作线程 Hang 住,主动等待(Waiting、Timed-Waiting )、被动等待(Blocked);
其中第一个原因,在应急处理时结合下游依赖耗时监控以及此前压测情况的评估,已经基本排除。因此,排查的重点放到第二个原因。
证据#2:Blocked 状态线程数飙升
图2
分析:佐证了前面推测的第二种原因,时间点提前了约20分钟。而 Java 线程的 Blocked 状态又比较特殊,只有一种可能,即监视器锁等待,也就是阻塞在了某一段 synchronized 代码块。
图3
线程池的 Remaining/Used 监控看,也是从 08:01 左右开始,工作线程被逐步耗尽的。
证据#3:有嫌疑的代码块
图4
分析 :搜索业务代码,发现共有 4 处业务代码使用到 synchronized
关键字。其中 MonitorUtil
和 IdxxxUtil
中的同步代码比较简单,不符合 "锁等待" 的特征,因此很容易排除。排查重点,自然就放到了 EncryptUtil
和 BizServiceImpl
中的同步代码上。
证据#4:排除法,排除BizServiceImpl
中同步代码的嫌疑
图5
分析 : 实际上,起初排查的重点嫌疑人就是 BizServiceImpl
中的这个load()
方法。 主要原因是:
1、大部分分流的核心接口都需要进行规则决策,都需要加载规则、匹配规则,而监控显示,这些接口都变慢了;
2、load 过程相对比较复杂,可能会有耗时的地方;
但最终还是排除了它的嫌疑,原因也很简单:
图中代码可以看到,load 并不是直接调用的,而是包装在一个 get()
方法里,且日志非常丰富。通过日志确认,早晨的请求基本上都是从内存直接获取了规则,并没有真正调用 load。
证据#5:代码的坏味道
分析 :于是最大的嫌疑就只剩下EncryptUtil
中加密的代码了,也就是文档一开头给出的那段代码。当第一眼看到这个代码的时候,应该就会有一种不太好的预感。
证据#6:变慢的接口都执行了加密,而有一个变慢的接口却没有执行规则决策
分析 :我们又过了一遍早晨故障发生时受影响的接口,发现这些变慢的接口(获取规则
、接收订单
、更新订单
)基本都执行了规则决策,唯独有一个特例 "回调推单"。这个方法也变慢了,但是内部没有决策逻辑。而再次分析他们的共同点,发现包括 "推单"
,所有的这些接口内部都有加密逻辑。
于是我们分析了 "获取规则"
的耗时:
图6
发现每一次 "获取规则"
调用都有一个规律,就是在查 Redis 获取规则版本号之前,都有一个 2-3s 的等待。Trace 链路中操作 Redis、MySQL 的代码都封装在了 分单
方法中:
图7
而在这之前,唯一有可能有 2-3s 耗时的地方就只有加密这个动作:
图8
相同的方式,分析 pushOrder
调用的耗时,进一步证明了有问题的地方就是加密逻辑这里。
实际上,到这里已经找到了问题所在。也很快明确了代码设计的确切问题。但根据我们在上一小节的分析,如果服务没有变更,初始值的并发问题是怎么产生的呢?一旦第一次正确的将is_init
设置为 "1",就不会有后续锁等待的问题。而要导致现在的问题,则一定是在服务刚启动时,接受的第一批请求,就并发修改了is_init
。最终我们找到了 #7 证据,将拼图的最后一角补齐。
证据#7:问题出现的时机
图9
分析:昨晚(07/10)20:50 因为宿主机清理,导致源节点被驱逐,重新拉起了新 Pod,正式今天出问题的这个 Pod。实际上,从昨晚 21:04 起,就已经出现了线程 Block 的问题,只是当时流量小,没有导致线程池耗尽,将问题引爆。
除此以外,我们也尝试寻找一些其他的证据,例如 KMSClient 初始化时,会请求一个 "/kms/init" 的 Url Path,但很遗憾业务日志似乎并没有记录下来,KMSClient 本身也缺少必要的埋点。如果有这样的埋点,理论上我们可以很容易的识别到异常的初始化流量。
四、PoC(Proof of Concept)代码
由追踪过程我们怀疑是加密模块并发导致KMSClient重复初始化带来高昂的RT,最终导致线程池阻塞,为了简化验证过程,我们只需要验证在高并发情况下每次都每次都初始化KMSClient会不会导致多线程队列阻塞即可,由下图结果在并发情况下队列很快就满载了,线上问题得到验证。
图10
五、改进项
5.1 代码修复
对加密模块做如下改造:
1、改用原子类
AtomicBoolean
替换StringBuffer
做加密模块的初始化标识2、加密模块初始化的同步代码块里做双重检测
Java
@Slf4j
public class EncryptUtil {
/**初始化标记*/
private static final AtomicBoolean is_init= new AtomicBoolean ( false );
/**数据加密*/
public static String encrypt(String plaintext){
try {
if(StringUtils.isEmpty(plaintext)){
return null;
}
//初始化加解密KMSClient
init();
return AESUtil.encryptDataForVersion(plaintext, "logic_sharding", "v1");
} catch (Exception e) {
log.error("数据加密失败", e);
return null;
}
}
/**数据解密*/
public static String decrypt(String cipherText){...}
/** 加解密客户端初始化*/
private static void init () {
if ( is_init .get()){ return ; }
synchronized (EncryptUtil.class) {
// 双重判断
if ( is_init .get()){ return ; }
log .info( "加解密客户端初始化 begin" );
KMSClient. initSecurity (Arrays. asList ( "logic_sharding" ));
log .info( "加解密客户端初始化 end" );
is_init .set( true );
}
}
}
5.2 监控告警
动态线程池开启告警并配置告警接收人动态线程池告警配置
当天未触发队列阻塞告警是因为CI动态线程池相关告警还未上线,后续已经针对应用中所有动态线程池做了核心指标的告警配置并验证通过。
图11
六、我们是否可以做的更好
应急过程怎么做到恢复更快?
- 快速发现,精准定位,稳定恢复 针对该场景如何降低故障影响的范围?
- 合理设计线程池隔离,避免单一场景拖垮整个服务,造成单点故障
6.1 应急处理
故障应急核心分为三个阶段:第一阶段故障感知要快,第二阶段故障定位要准,避免做无用功;第三阶段故障恢复要稳,避免带来二次伤害。
图12 故障应急
6.1.1 故障发现 - 快
故障发生后,时间就是金钱,越快恢复造成的影响越小,一般会遵循5/15/25的时间法则来评判应用有效性
第一步:故障发现
- 技术手段 :精细化的监控(图13 ),业务告警(图14未飞书应用内告警)支持核心场景短消息及电话告警;
图13 异常监控
图14 业务告警
- 非技术手段:值班同学巡检,NOC同学对监控大盘的巡检
第二步:应急响应
- 第一时间反馈给NOC,并同步给稳定性指挥官
- 拉应急群,确认关键应急人员,并开启飞书线上会议
- 线上同步当前现状及影响,指挥官及服务负责人做出应对安排
当前CASE分析: 问题出现后我们有第一时间收到了电话告警,并对其做出正常响应,快速拉起飞书显示语音会议,进行问题定位。
6.1.2 故障定位 - 准
当线上出现问题时,时间比较紧急,加上线上会议有很多大佬都会比较关注,在这种紧张的氛围中一线定位的同学压力是比较大的,也大大增加了出错的概率,在这种情况下我们一些标准的排障流程来辅助处理不同场景下的故障。
- 第一:故障来源判定
分析现状可以尝试快速分析以下问题,来找准故障定位的方向:
(1)问题系统最近是否进行了上线操作?
(2)依赖的基础平台和资源是否进行了上线或者升级?
(3)依赖的系统最近是否进行了上线?
(4)运营是否在系统里面做过运营变更?
(5)网络是否有波动,联系运维人员协助排查?
(6)最近的业务访问量是否正常,是否有异常流量?
(7)服务的业务方是否有活动?
- 第二:故障范围及影响判定
可以结合监控分析故障的范围,当前case明显是一个单节点触发了一个特殊场景的case,那我们可以结合monitor监控的单机曲线(图15)就可以看到只有单个节点有该异常,当然也有一些bug会全局的,针对不同的情况我们可以采取不同的治愈方案。
图15 单机异常曲线
- 第三:恢复方案制定
-
- 优先考虑线上预案,如降级,熔断,限流,业务开关等
- 对全局的故障应考虑回滚该服务及上下游的变更操作
- 对单点故障优先考虑新增服务节点来替换故障节点,保留现场有助于后续root case定位
问题的定位速度一方面来自核心人员对自己业务及代码的熟练程度,另一方面则可以借助合适的工具,同样可以协助我们有效的定位到问题,例如我们当前的case,可以使用Arthes对线程进行分析则可以比较清晰的看到线程的cpu占用情况
1、可以通过thread -b来查看哪些线程阻塞了其他线程
2、通过thread pid 查看繁忙线程的堆栈信息来分析
3、还可以通过thread -n x 来查看前x位繁忙的线程
arthes thread 使用
6.1.3 故障恢复 - 稳
- 方案审核:由团队负责人及核心人员复核恢复方案
- 方案执行:有核心人员执行恢复方案,
- 及时沟通:及时反馈方案执行情况及故障恢复情况
6.2 线程池隔离
线程池隔离的目的是为了防止因为单个场景的故障导致线程池阻塞,进而影响到其他场景;我们当前应用有10几种业务场景,每种场景的流程不外乎图16,所以每个场景的执行流程都是经过pipline来控制的,起初偷懒直接封装了统一的处理器可以根据不同的场景选择不同的pipline运行,同时也为处理器引入了第一个线程池;
图16 设计分析
当前case也给我们敲响了一个警钟,虽然只是一个pod出现线程池队列阻塞,但是这个节点上所有场景都被阻塞了,导致流转到该pod的流量大量超时,所以事后我们针对业务进行了梳理分为了"新增","变更","取消","履约"四类场景,采用"职责单一原则"为每个场景独立创建一个线程池(图17),职责清晰的同时避免单点故障。
图17