记一次SSL握手导致业务线程阻塞的案例分析

本文涉及的产品
服务治理 MSE Sentinel/OpenSergo,Agent数量 不受限
可观测可视化 Grafana 版,10个用户账号 1个月
简介: 记一次SSL握手导致业务线程阻塞的案例分析

问题描述

产品线反馈RocketMQ某Group ID出现消息堆积,重启后正常,一段时间后再次堆积,近期堆积问题出现的频次有些高,希望能够分析解决下。

分析过程

通过以往消息堆积的案例经验:

● MQ-消息堆积-业务代码异常导致retry队列积压

MQ-消息堆积-业务线程阻塞案例分析

一次RocketMQ ons SDK Bug导致消息不断堆积到重试队列的案例分析

MQ-消息堆积-JDK Bug导致线程阻塞案例分析

相比RocketMQ和JDK bug,应用代码出现问题的概率更高一些,所以优先从业务层面排查:

● 业务代码抛出了异常导致retry队列堆积,通过【详细信息】没有发现消费失败的情况,所以排除这种情况;

● 业务线程消费慢?或者是卡死了?

线程阻塞?

根据以往经验的总结,分析线程阻塞通常有如下几种思路:

● 根据线程CPU使用率(线程阻塞的情况下,CPU使用率通常很低)

● 通过对RocketMQ Listener执行耗时进行监控:一种检测线程阻塞的实现思路

● 通过对线程池线程执行时间进行监控(不做详细描述)

监控runWorker的退出时间

监控beforeExecute(wt, task)开始执行时间

监控afterExecute(task, thrown)开始执行时间

● 卡死线程的线程栈是不变得,可以通过不断查看线程栈进行分析

在没有任何系统建设的情况下,通过不断查看线程栈是可操作性最高的,下面是堆栈信息:

将【堆栈信息】中的线程浏览了一下,发现非常多的线程都处于上面线程的状态,相比较线程消费慢,现在更倾向于线程卡死了。

接下来分析上图中tid=354457的线程,为了方便分析线程栈,可以使用arthas thread命令,通过不断执行thread 354457来观察线程栈的变化:

通过不断执行,线程都保持上图状态,可以确定线程卡死了。

通过以上线程栈可知,业务代码使用apache httpclient框架发起的SSL请求,在SSL握手的时候发生了阻塞,接下来分析具体阻塞在SSL握手的哪个阶段。

SSL握手

在具体分析SSL握手阻塞在哪个阶段之前,我们先了解下SSL握手过程,以便更好理解代码的实现逻辑。

握手概述

TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256说明

  • TLS 是双方通信的协议;
  • ECDHERSA都是非对称加密算法,用于传递秘钥;ECDHE 是秘钥交换算法,RSA 是签名算法;
  • WITH是连接字符串;
  • AES-128-GCM 是对称加密算法,也就是后面双方交换应用信息时使用的算法;
  • SHA256是消息摘要算法

TLS协议

RSA握手过程

代码分析

业务代码

根据线程栈,使用arthas jad反编译代码,相关代码整理后如下:

SSLSocket握手

SSL握手主要流程:创建Socket、socket建立与服务端连接、SSL握手,主要代码整理如下:

由于【创建Socket】和【socket建立于服务端连接】与问题关联不大,下面主要分析创建SSLSocket和建立SSL握手的过程。

创建SSLSocket

SSLSocketFactoryImpl
public Socket createSocket(Socket s, String host, int port,
                           boolean autoClose) throws IOException {
    return new SSLSocketImpl(context, s, host, port, autoClose);
}
SSLSocketImpl
SSLSocketImpl(SSLContextImpl context, Socket sock, String host,
              int port, boolean autoClose) throws IOException {
    super(sock);
    // We always layer over a connected socket
    if (!sock.isConnected()) {
        throw new SocketException("Underlying socket is not connected");
    }
    this.host = host;
    this.serverNames =
        Utilities.addToSNIServerNameList(this.serverNames, this.host);
    init(context, false);
    this.autoClose = autoClose;
    doneConnect();
}
init
private void init(SSLContextImpl context, boolean isServer) {
    sslContext = context;
    sess = SSLSessionImpl.nullSession;
    handshakeSession = null;
    /*
    * role is as specified, state is START until after
    * the low level connection's established.
    */
    roleIsServer = isServer;
    connectionState = cs_START;
    ... ...
    enabledCipherSuites =
        sslContext.getDefaultCipherSuiteList(roleIsServer);
    enabledProtocols =
        sslContext.getDefaultProtocolList(roleIsServer);
    ... ...
    input = new AppInputStream(this);
    output = new AppOutputStream(this);
}
doneConnect
void doneConnect() throws IOException {
    /*
    * Save the input and output streams.  May be done only after
    * java.net actually connects using the socket "self", else
    * we get some pretty bizarre failure modes.
    */
    sockInput = super.getInputStream();
    sockOutput = super.getOutputStream();
    /*
    * Move to handshaking state, with pending session initialized
    * to defaults and the appropriate kind of handshaker set up.
    */
    initHandshaker();
}
private void initHandshaker() {
    ... ...
    // state is either cs_START or cs_DATA
    if (connectionState == cs_START) {
        connectionState = cs_HANDSHAKE;
    } else { // cs_DATA
        connectionState = cs_RENEGOTIATE;
    }
    ......
    handshaker = new ClientHandshaker(this, sslContext,
                                      enabledProtocols,
                                      protocolVersion, connectionState == cs_HANDSHAKE,
                                      secureRenegotiation, clientVerifyData, serverVerifyData);
    handshaker.setSNIServerNames(serverNames);
    handshaker.setEnabledCipherSuites(enabledCipherSuites);
    handshaker.setEnableSessionCreation(enableSessionCreation);
}

startHandshake:建立SSL握手

startHandshake调用performInitialHandshake,主要逻辑如下:

private void performInitialHandshake() throws IOException {
    synchronized (handshakeLock) {
        if (getConnectionState() == cs_HANDSHAKE) {
            kickstartHandshake();
            ... ...
            readRecord(inrec, false);
            inrec = null;
        }
    }
}
kickstartHandshake

发送ClientHello

private synchronized void kickstartHandshake() throws IOException {
    ... ...
    handshaker.activate(null);
    // 发送ClientHello
    handshaker.kickstart();
}
readRecord
private void readRecord(InputRecord r, boolean needAppData)throws IOException {
    int state;
    synchronized (readLock) {
        while (((state = getConnectionState()) != cs_CLOSED) &&
               (state != cs_ERROR) && (state != cs_APP_CLOSED)) {
            try {
                // 读取服务端响应
                r.setAppDataValid(false);
                r.read(sockInput, sockOutput);
            } catch (SSLProtocolException e) {
                ... ...
            } catch (EOFException eof) {
                ... ...
            }
            try {
                //解析响应报文
                r.decrypt(readAuthenticator, readCipher);
            } catch (BadPaddingException e) {
                ... ...
            }
            synchronized (this) {
                switch (r.contentType()) {
                    case Record.ct_handshake:
                        ... ...
                        handshaker.process_record(r, expectingFinished);
                        expectingFinished = false;
                        if (handshaker.invalidated) {
                            handshaker = null;
                            receivedCCS = false;
                            if (connectionState == cs_RENEGOTIATE) {
                                connectionState = cs_DATA;
                            }
                        } else if (handshaker.isDone()) {
                            secureRenegotiation =
                                handshaker.isSecureRenegotiation();
                            clientVerifyData = handshaker.getClientVerifyData();
                            serverVerifyData = handshaker.getServerVerifyData();
                            sess = handshaker.getSession();
                            handshakeSession = null;
                            handshaker = null;
                            connectionState = cs_DATA;
                            receivedCCS = false;
                            if (handshakeListeners != null) {
                                HandshakeCompletedEvent event =
                                    new HandshakeCompletedEvent(this, sess);
                                Thread t = new NotifyHandshakeThread(
                                    handshakeListeners.entrySet(), event);
                                t.start();
                            }
                        }
                        if (needAppData || connectionState != cs_DATA) {
                            continue;
                        }
                        break;
                    case Record.ct_application_data:
                        ... ...
                        break;
                    case Record.ct_alert:
                        recvAlert(r);
                        continue;
                    case Record.ct_change_cipher_spec:
                        ... ...
                        continue;
                    default:
                        ... ...
                        continue;
                }
                if (connectionState < cs_ERROR) {
                    checkSequenceNumber(readAuthenticator, r.contentType());
                }
                return;
            }
        }
        r.close();
        return;
    }
}

分析结论

通过线程栈及源码分析:SSLSocket已经将ClientHello发送给服务端,当前正阻塞在读取服务端响应,由于设置的socketTimeout没有起作用,在服务端一直没有响应的情况下,线程一直阻塞在读取服务端响应。

结论验证

验证的方式主要想到了以下几种

方式一 问题复现

通过curl模拟请求进行了问题复现

方式二 tcpdump抓包

通过分析tcpdump报文进行验证(未实施)

方式三 打开SSL debug

借助arthas在不重启应用的情况下,动态打开了SSL debug日志,方式如下:

Debug
//将sun.security.ssl.Debug静态属性设置为all
ognl '#cls=@Class@forName("sun.security.ssl.Debug"),#field=#cls.getDeclaredField("args"),#field.setAccessible(true),#field.set(#cls,"all")'
//判断设置是否生效
ognl '@sun.security.ssl.Debug@isOn("handshake")'
ClientHandshaker
//查看debug属性定义信息
ognl '#cls=@Class@forName("sun.security.ssl.Handshaker"),#field=#cls.getDeclaredField("debug"),{#field}'
//查看debug属性
ognl '@sun.security.ssl.Handshaker@debug'
//设置isReadOnly
options strict false
ognl '#cls=@Class@forName("sun.security.ssl.Handshaker"),#field=#cls.getDeclaredField("debug"),#override=#field.overrideFieldAccessor,#override.isReadOnly=false'
//设置debug属性
ognl '#deg=@sun.security.ssl.Debug@getInstance("ssl"),#cls=@Class@forName("sun.security.ssl.Handshaker"),#field=#cls.getDeclaredField("debug"),#modifiers=#field.getClass().getDeclaredField("modifiers"),#modifiers.setAccessible(true),#modifiers.setInt(#field,#field.getModifiers() & ~@java.lang.reflect.Modifier@FINAL),#field.setAccessible(true),#field.set(#cls,#deg)'
SSLSocketImpl
//设置isReadOnly
options strict false
ognl '#cls=@Class@forName("sun.security.ssl.SSLSocketImpl"),#field=#cls.getDeclaredField("debug"),#override=#field.overrideFieldAccessor,#override.isReadOnly=false'
//设置debug属性
ognl '#deg=@sun.security.ssl.Debug@getInstance("ssl"),#cls=@Class@forName("sun.security.ssl.SSLSocketImpl"),#field=#cls.getDeclaredField("debug"),#modifiers=#field.getClass().getDeclaredField("modifiers"),#modifiers.setAccessible(true),#modifiers.setInt(#field,#field.getModifiers() & ~@java.lang.reflect.Modifier@FINAL),#field.setAccessible(true),#field.set(#cls,#deg)'
//查看debug属性
ognl '@sun.security.ssl.SSLSocketImpl@debug'
InputRecord
//设置isReadOnly
options strict false
ognl '#cls=@Class@forName("sun.security.ssl.InputRecord"),#field=#cls.getDeclaredField("debug"),#override=#field.overrideFieldAccessor,#override.isReadOnly=false'
//设置debug属性
ognl '#deg=@sun.security.ssl.Debug@getInstance("ssl"),#cls=@Class@forName("sun.security.ssl.InputRecord"),#field=#cls.getDeclaredField("debug"),#modifiers=#field.getClass().getDeclaredField("modifiers"),#modifiers.setAccessible(true),#modifiers.setInt(#field,#field.getModifiers() & ~@java.lang.reflect.Modifier@FINAL),#field.setAccessible(true),#field.set(#cls,#deg)'
//查看debug属性
ognl '@sun.security.ssl.InputRecord@debug'
OutputRecord
//设置debug属性
ognl '#deg=@sun.security.ssl.Debug@getInstance("ssl"),#cls=@Class@forName("sun.security.ssl.OutputRecord"),#field=#cls.getDeclaredField("debug"),#modifiers=#field.getClass().getDeclaredField("modifiers"),#modifiers.setAccessible(true),#modifiers.setInt(#field,#field.getModifiers() & ~@java.lang.reflect.Modifier@FINAL),#field.setAccessible(true),#field.set(#cls,#deg)'
//查看debug属性
ognl '@sun.security.ssl.OutputRecord@debug'

方式四 对象状态分析

通过源码分析可知:SSL握手成功的情况下,SSLSocket实例的connectionState==cs_DATA,handshaker==null;握手进行中的时候,connectionState==cs_HANDSHAKE,handshaker==ClientHandshaker。所以通过arthas vmtool分别查看对应SSLSocket实例connectionState、handshaker变量的值就可以判断处SSLSocket处于什么状态。

找出阻塞的SSLSocket

由于业务应用每次请求都创建新的SSLSocket,所以我们只要找出创建时间比较早的SSLSocket,基本都是阻塞的。

通过socket的创建时间,可以找出阻塞的socket。

SSLSocket握手完成

SSLSocket握手未完成

具体阻塞在SSLSocket握手的哪个阶段,可以通过ClientHandshaker属性值进行判断,例如:

客户端发送了ClientHello

● 当服务端响应ServerHello的时候,属性protocolVersion不为空,svr_random不为空,cipherSuite不为默认值SSL_NULL_WITH_NULL_NULL,keyExchange不为默认值K_NULL;

● 当服务端没有响应ServerHello的时候,属性protocolVersion为空,svr_random为空,cipherSuite为默认值SSL_NULL_WITH_NULL_NULL,keyExchange为默认值K_NULL。

● 当服务端响应ServerCertificate的时候,handshaker的session属性的peerCerts字段不为空;

● 当服务端没有响应ServerCertificate的时候,handshaker的session属性的peerCerts字段为空;

● 以上可以结合handshaker的state变量值进行分析

经验总结

优化HttpClient使用方式

  • 不使用@Deprecated相关接口及实现类
  • HttpClient使用单例形式,而不是每个请求新建一个
  • SSLSessionContext参数优化,详情见:SSL Session默认设置导致线程阻塞了几十秒的案例分析

服务端为何没有响应的原因:初步判断是由于客户端频繁SSL握手,触发了服务端的安全策略(未完待续)。

SSLSocket调试

enable debug

● 方式一:JVM启动参数添加-Djavax.net.debug=all

● 方式二:通过arthas动态打开debug

handshakeListeners

通过向SSLSocket注册handshakeListeners,当SSL握手完成的时候可以获取SSL握手完成的通知。。

X509TrustManager

该类的实现类方法checkServerTrusted通常是不做实现的,不过可以在该方法中记录下SSLSocket状态信息以方便问题排查。

相关实践学习
RocketMQ一站式入门使用
从源码编译、部署broker、部署namesrv,使用java客户端首发消息等一站式入门RocketMQ。
消息队列 MNS 入门课程
1、消息队列MNS简介 本节课介绍消息队列的MNS的基础概念 2、消息队列MNS特性 本节课介绍消息队列的MNS的主要特性 3、MNS的最佳实践及场景应用 本节课介绍消息队列的MNS的最佳实践及场景应用案例 4、手把手系列:消息队列MNS实操讲 本节课介绍消息队列的MNS的实际操作演示 5、动手实验:基于MNS,0基础轻松构建 Web Client 本节课带您一起基于MNS,0基础轻松构建 Web Client
目录
相关文章
|
2月前
多线程案例-定时器(附完整代码)
多线程案例-定时器(附完整代码)
241 0
|
2月前
|
Python
Python学习之路 02 之分支结构
Python学习之路 02 之分支结构
446 0
Python学习之路 02 之分支结构
|
2月前
|
Java Python 开发者
Python 学习之路 01基础入门---【Python安装,Python程序基本组成】
线程池详解与异步任务编排使用案例-xian-cheng-chi-xiang-jie-yu-yi-bu-ren-wu-bian-pai-shi-yong-an-li
449 2
Python 学习之路 01基础入门---【Python安装,Python程序基本组成】
|
2月前
|
消息中间件 Linux 调度
【Linux 进程/线程状态 】深入理解Linux C++中的进程/线程状态:阻塞,休眠,僵死
【Linux 进程/线程状态 】深入理解Linux C++中的进程/线程状态:阻塞,休眠,僵死
73 0
|
6天前
|
SQL Dubbo Java
案例分析|线程池相关故障梳理&总结
本文作者梳理和分享了线程池类的故障,分别从故障视角和技术视角两个角度来分析总结,故障视角可以看到现象和教训,而技术视角可以透过现象看到本质更进一步可以看看如何避免。
83729 0
|
7天前
|
监控
写一个线程来监控各线程是否发生阻塞
写一个线程来监控各线程是否发生阻塞
18 0
|
2月前
|
设计模式 安全 C++
【C++ const 函数 的使用】C++ 中 const 成员函数与线程安全性:原理、案例与最佳实践
【C++ const 函数 的使用】C++ 中 const 成员函数与线程安全性:原理、案例与最佳实践
238 2
|
2月前
|
安全 Java 程序员
多线程案例-线程池
多线程案例-线程池
125 0
|
2月前
|
消息中间件 安全 Java
多线程案例-阻塞队列
多线程案例-阻塞队列
174 0
|
2月前
|
设计模式 安全 Java
多线程案例-单例模式
多线程案例-单例模式
245 0