最近又在线上日志里看到这个异常刷屏:javax.net.ssl.SSLException: closing inbound before receiving peer's close_notify。第一眼很容易误判成证书过期、密码套件不匹配或者TLS握手被防火墙干扰,但排了一圈发现,它根本不在握手阶段出现,而是发生在“关闭连接”这个最容易被忽视的环节。这篇文章我会把这个异常从协议原理、线上高发场景、到代码级修复完整讲透,适合自己维护HTTPS服务端或客户端的Java开发者、Netty用户,以及被连接池死连接坑过的同学。
1. 这个异常到底在抱怨什么:TLS关闭前必须互道一声“再见”
1.1 TLS协议要求的优雅断开流程
大多数人理解TLS就是“握手加密传输”,断开连接时往往直接close(),根本没有意识到TLS层有一套独立的关闭协议。TCP关闭前有四次挥手,TLS关闭前也有一个专门的告警消息叫做close_notify。它的作用很直白:通知对端“我这边要关闭加密通道了,后续不会再发加密数据”。
正常情况下,一方想关闭TLS连接,必须先发送close_notify告警,然后才关闭底层TCP连接。对端收到close_notify后,也要回复一个自己的close_notify,再关闭TCP。这就像两个人打电话,一方说“再见”,另一方回“再见”,然后才挂断。如果一方话都不说直接“啪”地挂断,另一方就会忐忑:这个挂断是对方主动断的,还是线路被掐断了?
TLS协议在这个问题上非常较真,因为这个“被掐断”是真实存在的安全威胁,也就是所谓的截断攻击(truncation attack)。攻击者不需要破解加密,只要强行切断连接,让接收方误以为完整的消息已经结束,就可能绕过某些基于“消息完整性”的业务逻辑。所以TLS要求双方都必须发送close_notify,才算一次“完整、可信的关闭”。没走完这个流程,加密通道的结束状态就是不干净的。
1.2 这个异常到底是从哪抛出来的
这个异常的标准动作发生在JDK TLS状态机的收尾阶段。SSLEngineImpl.closeInbound()方法内部有这样一个检查:如果应用调用了closeInbound()想关闭“入站方向”,但TLS状态机里还没有收到对端的close_notify告警,就直接抛出这条异常。
很多使用SSLSocket的场景也会遇到,因为SSLSocketImpl.close()在内部也会走SSLEngine这层逻辑,对close_notify交换进行确认。如果你用的是较新的JDK版本,尤其是从8u251这个版本附近开始,JDK对close()阶段是否收到对端close_notify的检查变得更严格了,不再像以前那样静默忽略,而是直接把异常抛出。这就是为什么很多人升级JDK之后,突然发现日志里多了一大堆以前没见过的SSLException。
1.3 用一张表看四种断开方式的差异
我在排查时习惯先把断开行为分成四种,方便判断当前属于哪种情况:
| 对端行为 | 本地感知 | 会不会抛此异常 | 建议处理 |
|---|---|---|---|
| 正常发送close_notify后再FIN | 读取返回EOF,TLS inbound状态完成 | 不会 | 回复close_notify后正常关闭 |
| 直接发FIN,没有close_notify | 读取返回EOF,但TLS inbound未完成 | 会 | 按“连接已死”处理,不等close_notify |
| 直接发RST | 读写直接异常,底层连接不可用 | 可能包装成其他异常 | 立即关闭,禁止复用连接 |
| 本地主动关闭但没先发close_notify | 对端可能会抛类似的错误 | 自身不一定 | 主动关闭时先发close_notify再关TCP |
搞定这张表之后,再去看日志里的异常,思路会清晰很多:它本质上在告诉你,对端的TCP宣称“我不发了”,但TLS层没有完成合法的close_notify交接。
2. 线上高发场景排查:三类元凶和它们的定位方法
2.1 接入层/网关回源断连
这是我在生产环境里遇到最多的一类。客户端访问服务时,链路往往是“客户端 -> 接入层/负载均衡 -> 后端Java服务”。接入层负责终结客户端的连接,再通过一条内网连接回源到后端Java服务。
问题就出在这条内网连接的管理上。接入层出于空闲回收、健康检查、节点摘除等原因,经常直接关闭回源连接。很多接入层关闭时根本不管TLS层的事情,直接发一个TCP FIN甚至RST了事,连close_notify都不发。后端Java服务里的连接池毫不知情,还觉得这条TLS连接是好的。等下一个请求从连接池里拿到这条“死连接”,写数据可能失败,或者读到EOF,随后清理阶段就会把这条SSLException抛出来。
排查这类问题有个典型特征:日志里出现异常的工作线程往往是NIO清理线程、连接池管理线程,而不是真正的业务处理线程。而且异常通常集中在某个接入层节点调整、发布或者空闲时段。处理思路不是吞异常,而是让连接池的回收速度和接入层的空闲断开速度匹配起来。
2.2 空闲超时与连接池复用冲突
第二种高发场景是自己把自己坑了:服务端配置了空闲超时,比如Netty的IdleStateHandler,超过N秒没有读写就主动关闭连接。这个定时器触发时,代码里可能直接ctx.close()。如果连接在超时前其实已经被对端发了FIN,或者对端某个时刻突然不响应,那么本地这个close操作就会撞上“还没收到close_notify”的状态,异常就来了。
还有一种情况是连接池复用时,池里的连接已经被对端断开,但池自己不知道。这种死链在被取出使用后,读到EOF再关闭,一样会触发异常。更麻烦的是,这种场景下异常往往不是一条两条,而是池里有多少死连接就会刷多少条,日志瞬间爆炸。
我遇到过的一个典型案例:服务端设置了60秒空闲回收,接入层则把空闲连接保活时间设成了90秒。两边的时间差导致连接在接入层看来还是“活的”,后端却已经开始回收。回收动作并发一多,日志直接刷屏。这种问题不先检查超时配置,只在异常处理里兜底,永远是治标不治本。
2.3 业务层主动断开但顺序不对
第三种就属于代码习惯问题。有的同学在业务逻辑里判断需要断开连接时,直接调用socket.close()或者channel.close(),没有考虑TLS层还需要一次close_notify交换。如果此刻对端还处于活跃状态,本地直接关TCP会让对端那边收到异常;如果对端之前已经半关闭,本地再close就很容易触发这条SSLException。
尤其是在WebSocket、HTTP/2这类长连接场景里,浏览器或移动端客户端可能直接把网络断开,根本没有机会发送TLS层的close_notify。服务端的业务代码看到连接异常退出,进入清理流程时尝试做TLS关闭,自然就会报这个错。
2.4 定位技巧:抓包看FIN还是RST,jstack看抛错线程
别一上来就搜日志里的异常文本,先用抓包把断开类型定下来。我在排查时会直接在服务端端口上抓包,重点看三件事:对端发的最后一个TCP段是不是带RST;如果没有RST,那FIN之前有没有TLS Alert记录;Wireshark里TLS Alert记录的description是不是close_notify(它的内容类型是0x15,description是0)。
如果抓包看到只有FIN没有close_notify,那就是对端单方面放弃TLS礼仪,属于“连接已死”的处理范畴。如果直接看到RST,说明连接在任何层面都不应该再复用,异常日志只需要记录不要告警。
同时可以用jstack看一下抛出异常的线程栈。如果是Netty的NIO线程,说明是某个channel在事件循环里被清理;如果是连接池的后台线程,那就是连接池在做空闲淘汰。线程栈能直接告诉你异常是在哪个关闭路径里冒出来的,省得整天猜。
3. 解决方案:先判断连接“还活着还是已经死了”,再决定怎么关
3.1 连接还活着:按TLS标准流程先发close_notify再关TCP
如果你的业务逻辑是主动断开一条还处于活跃状态的TLS连接,正确做法是把TLS关闭流程走完,再关闭TCP连接。在SSLEngine场景下,标准顺序是:
- 调用
engine.closeOutbound(),告诉TLS状态机“我不再发送应用数据,准备发送close_notify”。 - 循环调用
engine.wrap(),把close_notify告警写成加密数据,发送给对端。 - 等待对端的
close_notify告警,也就是通过engine.unwrap()处理对端发来的数据,直到engine.isInboundDone()变为true。 - 再调用
engine.closeInbound(),这时才能干净地关闭入站方向。 - 最后才关闭底层Socket或者Channel。
对应到SSLSocket场景,直接调用socket.close()时,JDK内部会自动发送close_notify,并尝试等对端的close_notify回执。这里有个坑:如果对端一直不回,close()可能会阻塞相当长时间。所以主动关闭前务必设置一个合理的SO_TIMEOUT,别让一条断连把线程拖死。
3.2 连接已经死了:放弃close_notify交换,直接强关
如果对端已经发了FIN甚至RST,TLS层的closeInbound()永远等不到close_notify。这时候最合理的做法不是补一次完整TLS关闭,而是把异常当作“连接已经终结”的正常信号,直接在传输层强关,并把日志级别降下来。
代码层面要做的是区分“连接还有没有救”。如果当前channel已经处于inactive,或者读取已经返回EOF,那这个SSLException就是连接终结的副作用,忽略掉是安全的。但有一个前提你要心里有数:如果连接是突然被断的,而业务消息还没读完,应用层不能把这条连接上未读完的数据当成一条完整消息来处理。忽略异常只是不再刷屏,并不代表数据完整性不需要关注。
3.3 用jdk.tls.acknowledgeCloseNotify开关减少JDK层面的强等待
JDK从8u251附近版本开始,对close_notify的确认变得更严格,官方也给了一个逃生舱:系统属性jdk.tls.acknowledgeCloseNotify。把它设为false,可以让SSLSocket.close()不再等到对端的close_notify回执,直接完成关闭,从而避免抛出这条SSLException。
启动参数可以这样加:
java -Djdk.tls.acknowledgeCloseNotify=false -jar your-application.jar如果你的应用大量依赖连接池,并且接入层的断开行为短时间无法调整,这个开关能非常快地压制异常。但请记住,它只是让JDK“不要抱怨”,并没有改变“对端没有发close_notify”这个事实。从安全角度讲,如果连接真的被中间人截断,应用层依然需要自己的消息完整性机制来兜底,不能因为日志安静了就放松警惕。
还有一点要注意,这个开关主要作用于SSLSocket场景,SSLEngine场景里如果自己手动调用closeInbound(),该抛还是会抛。Netty的SslHandler底层也用了SSLEngine,所以Netty场景更多要靠异常处理逻辑来识别和降级。
3.4 日志治理:给异常分等级而不是一刀切关日志
线上日志刷屏的时候,有人会直接把这个异常从日志框架里过滤掉,这我不太推荐。更好的做法是按照“预期内的连接终结”来降级,而不是干脆不记录。
Logback里可以用自定义Filter判断异常类型和消息内容,命中就返回DENY,记录为不输出;其他SSLException继续正常走日志。这样既不会被刷屏,又保留了查看异常频率的能力。我给一个参考实现:
public class ExpectedPeerCloseFilter extends Filter<ILoggingEvent> { @Override public FilterReply decide(ILoggingEvent event) { IThrowableProxy throwableProxy = event.getThrowableProxy(); if (throwableProxy != null && throwableProxy.getClassName() != null && throwableProxy.getClassName().endsWith("SSLException") && throwableProxy.getMessage() != null && throwableProxy.getMessage().contains("closing inbound before receiving peer")) { return FilterReply.DENY; } return FilterReply.NEUTRAL; } }我在实际使用里会额外加一个计数器,用Micrometer或者简单的AtomicLong统计这个异常出现的次数,接入监控告警。这样一来,日志不刷屏,但异常突增时依然能第一时间发现。
4. 代码落地:Netty、自研连接池和SSLSocket的修法参考
4.1 Netty中在统一异常出口处理
如果你用Netty,最省事的做法是在业务Handler或者自定义的异常Handler里加一个分支,识别这个特定的SSLException,直接关掉channel并返回。参考代码如下:
@Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { if (isPeerClosedWithoutNotify(cause)) { // 对端没有发送close_notify就关闭了TCP连接 // 连接已经不可复用,直接关闭 ctx.close(); return; } // 其他异常继续向上传递 ctx.fireExceptionCaught(cause); } private boolean isPeerClosedWithoutNotify(Throwable cause) { if (!(cause instanceof SSLException)) { return false; } String message = cause.getMessage(); return message != null && message.contains("closing inbound before receiving peer's close_notify"); }这里判断条件里同时有instanceof SSLException和消息匹配,是因为有些场景下异常会被包装成SSLHandshakeException之类的子类,消息同样带着这一串文本。我用endsWith("SSLException")也能覆盖常见子类。
注意这个处理分支的关键在于:它只针对“对端没发close_notify就断TCP”的场景。如果你的业务还需要从这个channel里读出完整消息,那这个异常出现时通常意味着消息已经不完整了,不能当作成功处理。
4.2 SSLSocket主动关闭时的顺序与超时控制
如果你用的是原生SSLSocket,主动关闭时最稳妥的写法是设置SO_TIMEOUT后调用close,并对可能出现的SSLException做兜底。示例:
SSLSocket socket = (SSLSocket) sslSocketFactory.createSocket(host, port); socket.setSoTimeout(3000); // 给close_notify交换一个上限时间 try { // 业务读写... socket.close(); // JDK内部发送close_notify并等待对端close_notify } catch (SSLException e) { // 对端没有返回close_notify,连接大概率已经被对端断开 log.debug("TLS连接未完成close_notify交换,按连接结束处理: {}", e.getMessage()); } finally { socket.close(); // 确保底层Socket一定会关闭 }这里说一个小细节:socket.close()和finally里的close()不是一个概念。第一次close是在TLS层尝试优雅关闭,如果抛了SSLException说明对端没配合;finally里的close()是兜底,保证底层TCP无论如何都会释放。很多同学不知道这一点,第一次close抛异常后以为连接已经关了,导致文件描述符泄漏,后面一堆连接耗尽的问题。
4.3 自研连接池清理死连接的伪代码
如果你是自己维护TLS连接池,清理逻辑建议按这个顺序来:
public void evictDeadConnection(PooledTlsConnection conn) { // 1. 先从池中移除,避免其他线程拿到同一条连接 pool.remove(conn); // 2. 如果连接还没有被标记为dead,主动走TLS关闭流程 if (conn.isActive()) { conn.startTlsClose(); // 发送close_notify,等待对端close_notify } // 3. 兜底关闭,捕获并降级该异常 try { conn.forceClose(); } catch (SSLException e) { if (e.getMessage() != null && e.getMessage().contains("closing inbound before receiving peer")) { log.debug("清理死连接时未收到close_notify,预期内的断开"); } else { log.warn("关闭TLS连接异常", e); } } }顺序很重要:先从池里摘掉,再做TLS关闭,最后兜底。如果你先做TLS关闭,这段时间内连接还在池里,另一个线程可能恰好拿到这条连接发请求,两边就打架了。我见过一个连接池就是顺序写反了,线上偶发性出现请求卡顿,排查半天才发现是这个竞态问题。
4.4 修复后的验证清单
改完之后,不要直接看一下日志没了就说修好了,我建议按下面几条验证:
- 异常日志从ERROR/WARN降级为DEBUG,并且没有新的告警;
- 抓包确认服务端主动断开时,TCP连接关闭前确实有close_notify Alert发出;
- 压测包含短连接新建、长连接复用、空闲回收三种场景,观察RST比例没有异常升高;
- 连接池场景下,确认没有连接泄漏(/proc下或者JMX里socket数量平稳);
- 连续观察一个完整接入层发布周期,确认异常量没有突增。
验证过程里最容易忽略的是RST比例。如果修复后这台服务发出的RST明显变多,说明很多连接是在异常状态下被强行释放的,短连接场景可能无感,但长连接池的性能和稳定性都会受影响。
5. TLS 1.3与HTTP/2场景下的差异提醒
5.1 TLS 1.3让close_notify变得更“简化”,但问题还在
TLS 1.3大幅简化了握手流程,但close_notify机制依然保留。不过我在实际观察中发现,TLS 1.3的很多实现里,发送方主动断连时不再严格先等对端close_notify再关闭,因为1.3的连接恢复和0-RTT机制让短连接变得更常见,很多实现的设计者觉得“连接反正马上要抛弃了,交换close_notify成本不划算”。
这带来一个现实问题:你的服务端启用TLS 1.3后,可能反而更容易从客户端方向看到对端不发close_notify就断开的情况。如果线上大面积出现这个异常,可以用SSLContext强制指定TLSv1.2做一个小范围实验,对比一下异常量。我先说明,我并不建议长期禁TLS 1.3,这只是用来判断是不是协议版本带来的实现差异。
另外HTTP/2场景里还有一层复杂性:HTTP/2的GOAWAY帧和连接错误处理逻辑会让底层TLS连接更早地被关闭。某个请求触发了HTTP/2错误后,连接马上被标记为不可复用,TLS层却没走完关闭流程。这时候你看到的SSLException其实是HTTP/2关闭策略的连带结果,处理思路还是回到“先判断连接死活”,不用过度纠结TLS单层。
5.2 给服务端设计者的几点建议
如果你的服务要长期维护,我建议把“TLS连接关闭”当成一等公民来设计,而不是在业务代码里到处close()。成熟的模型应该是:
- 主动断开时,先发送close_notify再关TCP,这条由TLS封装层统一处理;
- 被动断开时,收到对端close_notify后回复close_notify再关TCP,这个也由框架统一处理;
- 超时或异常断开时,不再等待close_notify,直接关闭底层连接,同时把异常降级为预期内日志;
- 所有出口都区分“正常关闭”和“异常关闭”,不要混成一类ERROR。
这几个原则听起来简单,但我在看别人代码时发现,绝大多数项目都只做了第一条的一半,剩下全靠JDK兜底。把关闭逻辑收敛到框架层之后,线上遇到这个异常的概率会大幅下降,排查成本也会低很多。
最后再分享一点个人经验:我现在的习惯是,遇到SSLException先别改代码,而是用jstack加抓包确认这条连接在断开前经历了什么。只要确定了是“活连接被强关”还是“死连接被补刀”,修复方向就很明确。这个异常本身不可怕,可怕的是把它当成一个孤立bug去修,最后修了这里那里又冒出来。按连接状态去分层处理,才是真正的根治思路。