排查定点外包系统对接时,日志里冒出一句:
javax.net.ssl.SSLException: closing inbound before receiving peer's close_notify说实话,这个报错很像网络问题,但实际查起来百分之八十都跟网络没关系。它在生产环境里出现得极其隐蔽:明明接口返回了数据,但日志里就是挂着一堆异常堆栈,甚至偶发触发连接池回收时把整个服务端线程拖垮。我最早遇到时也以为是目标服务器强制断开了TLS连接,折腾了一晚上抓包,最后发现根子在自己代码的读取姿势上。
这篇文章想把我排查这个错误的全过程、背后的机制、以及各种场景下的修复方案一次性讲清楚,附带踩坑经验。如果你正在被这个异常折磨,照着下面的思路走,大概率能省下半天时间。
1. 先弄明白这个异常到底在说什么
1.1 TLS关闭握手的核心机制
这个异常的关键字有三个:closing inbound、peer、close_notify。要理解它,得先知道TLS连接正常关闭时是什么样子。
TLS协议里,连接关闭不是直接把TCP连接断掉就完事。通信双方在断开前,需要互相发送一个close_notify消息,告诉对方“我这边要关了,你那边准备好了没”。这是一个优雅关闭的过程,类似两个人打电话,挂电话前都得说一句“我先挂了,你那边还有事吗”。
具体流程是这样:
- 一方决定关闭连接时,发送
close_notify通知对端。 - 对端收到通知后,回复自己的
close_notify。 - 两边都确认关闭后,TCP层才真正断开连接。
close_notify实际上是TLS记录层里的一个特殊类型消息。它在RFC 5246里有明确要求:每个端点收到close_notify后,必须发送自己的close_notify作为回应,同时不应再发送任何数据。
把这个逻辑投影到Java的SSLSocket实现上,就很好理解这个异常的含义了:你的程序或者你的框架在收到对端发来的close_notify之前,先主动关闭了inbound方向的通道。
更直白点说:对端还没来得及跟你说“再见”,你已经把电话挂了。
1.2 Java层面对这个行为的严格处置
Java的SSLSocket实现跟普通的Socket实现不太一样。普通Socket你关掉InputStream或者OutputStream,TCP层顶多发个RST或者正常FIN,不一定会抛什么结构性异常。但SSLSocket封装了TLS状态机,它对协议状态是有严格校验的。
当你调用SSLSocket.close()时,Java内部会尝试发送close_notify,然后等待对端返回close_notify。但是,如果你通过某种操作让底层连接处于“半关闭”状态,比如只关闭了输入流、或者读到了EOF却没有完整处理TLS记录,状态机就会卡住。
异常里的closing inbound指的就是代码主动关闭了输入侧,可对端的close_notify还没到达,SSLEngine或者SSLSocket内部在做状态转换时发现这是不合法的TLS状态迁移,于是抛出SSLException。
理论上这个过程不该被上层感知到,因为这是一个协议细节。但问题是,很多Java生态的组件在实现时没有完全处理好这个状态机,尤其涉及到连接池、异步IO、流式读取时,异常就从底层泄漏到了业务层。
1.3 为什么这个异常的影响如此隐蔽
最让人头疼的不是异常本身,而是它的表现方式太飘忽了:
- 业务上接口明明返回了正常数据,日志里却夹着异常堆栈。因为很多情况下异常是在连接释放阶段抛出的,此时请求已经处理完,但连接池在回收入库时触发了底层校验。
- 偶发性极强,可能跑一整天没报错,高峰期突然刷屏。这跟连接复用的时机、数据包到达的顺序、JVM GC暂停导致的处理延迟都有关系。
- 堆栈指向的代码位置经常是无辜的。异常堆栈往往指向
SSLSocketImpl.startHandshake或者SocketInputStream.read,看起来像是在握手阶段失败,但实际业务代码已经走了老远。
我见过不少人把这个报错当作“对端SSL配置不对”,调了半天加密套件、证书协议版本,最后毫无效果。方向错了,越查越远。
2. 哪些场景最容易翻车
2.1 流式读取未消费完整响应体
这是最常见的一种触发方式,尤其在用Java标准库的HttpURLConnection或者某些轻量级HTTP客户端时最容易踩中。
举个典型的操作:
InputStream in = connection.getInputStream(); byte[] data = new byte[1024]; int len = in.read(data); // 业务上只需要前一部分数据 in.close(); connection.disconnect();代码里只读取了部分数据就把流关了。对于普通HTTP响应,连接关闭后TCP层会发FIN,服务端收到FIN后也关闭连接,看起来没什么问题。但如果是HTTPS,情况立刻不一样。
TLS层需要完整地消费完所有记录,才能安全关闭。当你只读了一部分就调用close()时,Java会尝试发送close_notify,但TLS记录层里还有未读取的数据,协议栈还没来得及解析完对端发来的内容。这个时候对端的close_notify很可能还在半路上,Java内部的状态机就已经开始关闭inbound侧,异常随之产生。
这个场景如果在本地测试,甚至很难复现,因为本地回环网络延迟极低,数据包几乎瞬间到达,对端的close_notify总是能在关闭操作前送达。但在真实网络上,延迟一高,状态机就钻空子了。
2.2 HTTP连接池中的连接复用
用Apache HttpClient或者OkHttp时,这个异常也时有发生。原因大多出在连接池对“过期连接”的清理逻辑上。
HTTP持久连接有一个约定:如果服务端想要关闭连接,会在响应头里带Connection: close,或者通过TLS的close_notify优雅关闭。但有些服务端实现并不规范,它可能在发送完最后一个响应字节后,直接关闭TCP连接,连close_notify都不发。
客户端这边呢,连接池在归还连接时,会判断这个连接是否还“新鲜”。判断方式通常是看keep-alive的过期时间,或者看socket的isClosed()、isInputShutdown()状态。但isClosed()只能感知TCP层的FIN,感知不到TLS层还欠着一个close_notify。
于是在下一次从池里取出这个连接时,代码兴致勃勃地往socket里写请求数据,却不知道对端已经物理断开了。写操作可能成功,因为TCP缓冲区还在;等到读响应时,收到的不是正常的应用数据,而是RST或者EOF。此时候选操作触发底层关闭,closing inbound before receiving peer's close_notify就在连接池内部被抛出来。
2.3 数据库驱动与JDBC底层连接
另一个高频触发场景是数据库连接,尤其是一些老版本的MySQL JDBC驱动在配合SSL连接使用时。
MySQL 8.0开始默认开启了SSL,如果JDBC URL里加上了sslMode=VERIFY_CA或者requireSSL=true,驱动在建立连接时会走TLS握手。正常情况下,连接关闭时驱动应该发送close_notify优雅退出。但某些版本的驱动在连接池驱逐空闲连接时,会直接粗暴关闭底层socket。
此时如果MySQL服务端恰好有延迟,没来得及在socket关闭前收到并处理close_notify,客户端这边就会出现这个SSLException。更麻烦的是,这个异常还经常被包装进Spring事务框架里,导致你看到的是:
org.springframework.transaction.CannotCreateTransactionException: Could not open JDBC Connection for transaction Caused by: javax.net.ssl.SSLException: closing inbound before receiving peer's close_notify第一次看到这个堆栈以为数据库挂了,实际上数据库活得好好的,纯粹是连接关闭姿势不对。
2.4 响应数据被压缩或分块传输时的边界情况
HTTP响应用Content-Encoding: gzip压缩时,有些客户端库会做透明解压。解压过程会先读取整个压缩流,再解出业务数据。而分块传输(chunked)模式下,块与块之间有边界标志。
如果服务端返回的是一个被压缩过的PDF文件或者视频流,客户端只读取了解压后的一部分就调用close(),底层的原始输入流其实还没读完。对TLS层来说,整个TLS记录都还没消费完,此时直接关inbound,又是同一个异常。
我遇到过一个实际案例:某支付渠道的对账单接口返回的是一个gzip压缩的CSV文件,文件大概20MB。我们用InputStreamResource接收,然后只想解析前100行确认表头格式。解析完就把Resource丢掉了,结果连接池里每一条连接都堆积了未消费的数据,跑一段时间后整个连接池被异常塞满,接口响应从50ms飙到3秒。
3. 针对不同场景的具体修复方案
3.1 消费完整响应体再释放连接
如果代码用的是HttpURLConnection,最直接的修复方式就是把响应体完整读完再关闭。
InputStream in = null; try { HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setConnectTimeout(3000); conn.setReadTimeout(10000); in = conn.getInputStream(); ByteArrayOutputStream buffer = new ByteArrayOutputStream(); byte[] temp = new byte[4096]; int n; while ((n = in.read(temp)) != -1) { buffer.write(temp, 0, n); } // 业务处理 byte[] response = buffer.toByteArray(); } finally { // 读完后再关闭 if (in != null) { in.close(); } }注意两点:
- 一定要循环读取直到
read()返回-1,这才代表底层TLS流被完整消费。 close()要放在finally里,但必须保证读取循环完整执行之后再调用。
如果业务上确实只想取部分数据,那也别走HttpURLConnection这种底层库,直接换RestTemplate或OkHttp,由它们内部管理连接释放。
3.2 用HttpClient库时配置正确的连接清理策略
换用Apache HttpClient或者OkHttp,正确配置连接池的过期检查和清理策略,能从根上避开大部分问题。
Apache HttpClient的老写法是:
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(); cm.setMaxTotal(200); cm.setDefaultMaxPerRoute(20); // 定期清理失效连接 new Timer(true).schedule(new TimerTask() { @Override public void run() { cm.closeExpiredConnections(); cm.closeIdleConnections(30, TimeUnit.SECONDS); } }, 0, 30000); CloseableHttpClient client = HttpClients.custom() .setConnectionManager(cm) .build();这里有两个关键点容易被忽略:
closeExpiredConnections()只会关闭超过keep-alive时限的连接,但不会主动发送TLS层的close_notify。如果服务端本身就不规范地断连,调用这个方法时反而可能触发SSLException。closeIdleConnections(30, TimeUnit.SECONDS)清理的是空闲超过30秒的连接。对空闲连接,Apache HttpClient 4.5版本以后会先检查TLS连接是否还可用,再进行关闭,但仍存在极小概率把未消费的TLS记录留在缓冲区。
如果遇到连接池清理时反复刷这个异常,可以考虑换用Connection: close策略,即每请求都用短连接,不启用连接复用:
RequestConfig config = RequestConfig.custom() .setConnectionRequestTimeout(3000) .setConnectTimeout(3000) .setSocketTimeout(10000) .build(); HttpGet get = new HttpGet(url); get.setConfig(config); get.setHeader("Connection", "close");代价是多了一些TCP握手开销,但换来的是彻底消灭这个异常。对于内部调用链,这个代价完全可接受;对于公网高并发场景,建议还是继续用连接池,然后抓包确认服务端断开方式,再对症处理。
3.3 JDBC与数据库连接池的专用参数配置
数据库场景,核心方案不是改代码,而是调整连接池的两个参数。
HikariCP是目前Spring Boot的默认连接池,它有一个maxLifetime参数,默认是1800000毫秒(30分钟)。如果数据库服务端的wait_timeout或interactive_timeout值小于这个时间,数据库就会先把连接断掉,但连接池还傻乎乎地保留着这个连接。
推荐配置是让连接池的maxLifetime比数据库wait_timeout短至少10到15秒。以MySQL为例,如果wait_timeout是28800秒(8小时),maxLifetime保持默认即可;但如果是云数据库,经常有厂商把wait_timeout调成4小时甚至2小时,那maxLifetime必须跟着降。
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 max-lifetime: 1200000 idle-timeout: 600000 connection-test-query: SELECT 1另一个重要参数是connection-test-query。设置后,HikariCP在从池里拿连接时,会先执行一次轻量查询来验证连接是否还活着。如果连接已经被数据库强制断开了,这个验证查询会先失败,连接池就会把这个坏连接剔除掉,重新创建新连接。这个机制避免了“拿了个僵尸连接去做业务”的尴尬。
如果你的应用用的是Druid,对应的参数是testWhileIdle和testOnBorrow,原理大同小异。注意,开启测试连接会多一次网络往返,对极高TPS场景会有轻微影响,可以考虑用keepaliveBetweenTimeMillis的长连接保活代替。
3.4 使用SSLEngine的直接场景
如果你是在写Netty或者自研通信框架,遇到这个异常时,需要检查的是SSLEngine的关闭流程。
Netty中正确处理TLS关闭后退出的方式是:
ChannelFuture closeFuture = channel.closeFuture(); closeFuture.addListener(future -> { // 先确保 TLS 握手状态已经结束 SslHandler sslHandler = channel.pipeline().get(SslHandler.class); if (sslHandler != null) { sslHandler.close(); } });但更常见的错误是ctx.close()被调用得太早。Netty的SslHandler内部有close()和closeOutbound()两个方法,区别很大:
close()是标准关闭,会发送close_notify并等待对端响应。closeOutbound()只关闭发送方向,不等待对端响应。
如果在业务Handler里调用了ctx.channel().close(),这个操作会走Socket层面的关闭,绕过了SslHandler的优雅关闭逻辑。TLS对端可能还没收到close_notify,连接就被TCP层强行断开了。
正确的Netty关闭姿势是,在编码完最后一个数据包后,调用ctx.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE),让Netty先把缓冲区的数据全部冲出去,再执行连接关闭。或者显式调用sslHandler.close()来触发TLS层的关闭握手。
另外一个我踩过的坑是Netty的AUTO_READ配置。如果autoRead是false,并且代码在收到半包数据时提前调用了close(),底层TLS记录可能还没组装完整,同样会触发closing inbound异常。所以业务上要注意,只有等SslHandler把整条TLS记录都解析完毕,才能安全关闭。
4. 问题排查方法与避坑要点
4.1 定位异常来源的内外网区分法
遇到这个异常,第一步不是去翻代码,而是先判断异常产生在网络层还是业务层。
方法很简单:把服务端日志和客户端日志放在一起看时间戳。如果服务端在异常时间点没有收到任何连接断开记录,说明真正发起断开的是客户端这边;反过来,如果服务端有断开记录,而且断开发生在客户端发起请求之前,那就是服务端先掐的,客户端只是被动受害。
查日志的同时,顺手看下网络连接的状态:
ss -ant | grep <服务端端口>看到大量TIME_WAIT连接是正常的,如果看到CLOSE_WAIT堆积,说明客户端没有正确关闭连接,这个异常很可能就是连接泄漏的外在表现。
用tcpdump抓包更直观:
tcpdump -i eth0 -w /tmp/ssl.pcap host <ip> and port <port> tcpdump -r /tmp/ssl.pcap -A | grep -i "close_notify\|FIN\|RST"抓包结果里,如果只看到FIN没有看到close_notify,那基本可以确定是某一方绕过了TLS关闭流程。
4.2 用JVM参数辅助定位
JDK 8及以后版本,可以加下面这个参数开启SSL调试日志:
-Djavax.net.debug=ssl:handshake跑一次请求,日志会详细打印TLS握手和关闭过程。重点关注最后几行:
main, READ: TLSv1.2 alert, length=2 main, RECEIVED TLSv1.2 alert: close_notify main, called closeSocket()如果日志里在socket closed之前没有出现RECEIVED TLSv1.2 alert: close_notify,说明关闭时确实没有收到对端的close_notify,问题实锤。
javax.net.debug的输出量极大,强烈建议只在小流量测试环境开,别在生产上开着跑。
4.3 连接池设置与异常之间的隐藏关系
很多人不知道,连接池的大小和这个异常也有间接关系。
当连接池的maxTotal较小时,同一时刻复用的连接频率更高,连接创建和销毁更频繁,异常出现的概率也就更大。当maxTotal很大时,连接存活时间长,反而容易堆积“僵尸连接”。这里有个矛盾,需要根据实际业务做权衡。
我一般会把这些参数组合起来调整:
maxTotal:不追求过大的值,够用即可。通常按“高峰期TPS / 单连接每秒请求数”来估算,再加30%余量。maxIdleTime:不要超过服务端keep-alive超时时间的一半。maxLifeTime:不要超过服务端最大连接存活时间。
如果你的服务端是Nginx,keepalive_timeout默认是75秒,那客户端连接池的idleTime建议设成50秒以内。如果服务端是Tomcat,connectionTimeout默认20秒,连接池至少需要在这个时间内把空闲连接清掉。
4.4 一个典型的完整排查流程
把上面所有方法串起来,一次完整的排查是这样的:
第一步,复现问题,拿到完整异常堆栈和触发时机。如果无法稳定复现,就加监控,看这个异常在什么时间段、什么接口、什么并发量下出现。
第二步,抓包确认断开方向。是客户端先关闭,还是服务端先关闭。这一步基本决定了后续排查的代码范围。
第三步,根据客户端类型选择修复方案。标准库的,重点看有没有完整消费响应体。连接池的,检查连接保活配置。数据库的,调连接池生命周期参数。Netty的自研框架,检查关闭顺序。
第四步,修复后做压力测试,用比生产环境更高的并发去跑,观察异常是否回归。
第五步,把这个异常加进监控告警,后续再出现自动通知,而不是等用户反馈。
5. 预防性编码建议
5.1 统一封装HTTP调用层
企业应用里,我最推荐的做法是所有HTTP调用都走统一封装层,对外只暴露业务方法,内部管理连接策略。
@Component public class HttpClientWrapper { private final RestTemplate restTemplate; public HttpClientWrapper(RestTemplate restTemplate) { this.restTemplate = restTemplate; } public String getForString(String url) { try { ResponseEntity<String> response = restTemplate.exchange( url, HttpMethod.GET, null, String.class ); return response.getBody(); } catch (ResourceAccessException e) { throw new BusinessException("外部服务连接异常", e); } } }这样做的好处是,一旦遇到SSL相关的坑,只需要改一个类,不需要全局搜索几十个调用点逐一修复。
特别强调一下,用RestTemplate时,不要在Controller层直接把ResponseEntity<InputStreamResource>返回给前端。我之前见过一个项目,为了省内存,把外部接口的InputStreamResource直接透传给前端下载,结果RestTemplate的连接释放时机完全交给Servlet容器的异步处理,导致连接池和Tomcat线程池互相等待,最终连接池里的所有连接都处于“半开”状态。
正确做法是先下载到本地临时文件,然后让前端从文件读取,或者至少用ByteArrayResource把流内容完整读入内存再释放底层连接。
5.2 监控和告警兜底
无论代码写得再严谨,线上环境总有出乎意料的情况。建议在日志采集平台里专门为这个异常配置一条告警规则。
不要求一出现就告警,因为偶发情况下这个异常可能只出现一两次,对业务毫无影响。可以按“5分钟内出现超过N次”这种阈值来配置。但如果异常每分钟都在刷屏,那说明连接管理策略已经出了问题,需要立即处理。
我自己常用的告警阈值是:5分钟内异常次数超过10次就触发P2级别告警,超过50次触发P1级别告警。设置这个方法既能过滤偶发噪音,又能抓住真正的问题。
5.3 私有协议的连接超时设计
如果是在自定义的TCP长连接应用中遇到这个错误,比如游戏服务器、门禁系统、物联网设备端,那除了关注TLS关闭,更得关注协议层的“心跳”机制。
很多IoT设备使用MQTT或者私有TCP协议,设备端网络环境差,经常断网或者断电,根本没有机会发送close_notify。服务端保持大量半开连接,等连接池耗尽后,新连接握手时就容易触发各种异常。
解决思路是服务端设置合理的keepAlive探测时间和idleTimeout,主动回收超过一定时间没有心跳的连接。这个“一定时间”一般设置为心跳周期的三倍左右,既保证有效连接不被误杀,又能及时清理死连接。
6. 兜底的通用解法:把异常吞掉的可行性与代价
网上很多帖子教你在捕获这个异常后直接忽略,理由是“影响的只是连接关闭的礼貌性动作,反正TCP连接都要关了”。
这个说法在特定场景下是成立的。比如用OkHttp时,如果有Callback或者拦截器做了统一异常处理,把不是业务层的SSLException吃掉,确实能减少一堆噪音日志。但吞之前要想清楚三点:
- 这个异常如果是连接池复用阶段抛出的,吞掉会导致连接池内部状态错乱,后续操作可能连环报错。
- 如果这是你排查出的真正根因(比如服务端根本不发
close_notify),吞掉只是掩盖症状,连接泄漏仍然在。 - 如果异常是间歇性的,吞掉后很难再通过日志回溯到触发场景,不利于长期治理。
所以我的建议是,这个异常只能算一个“二级症状”,背后真正要处理的是连接生命周期管理。临时吞掉可以,但一定要在吞掉之前把异常日志打到单独的ERR日志文件里,保留时间戳、URL、请求ID等关键信息。后续还是要通过监控看频率,判断是否真的需要深入研究。
7. 分享一个诊断实用技巧
最后给个我屡试不爽的技巧。如果你用的是Spring Boot项目,并且是RestTemplate触发的这个异常,排查顺序最强的一条路径是查Header。
Connection: close这个Header在客户端和服务端之间存在认知不一致时,最容易产生TLS关闭异常。我见过某外部接口,服务端明明支持HTTP keep-alive,但它内部在某些响应码(比如403、429)时,会自动加上Connection: close头。客户端这边呢,框架还是按照标准方式去复用连接,于是就把异常藏进了下一次请求的握手里。
排查时可以临时在ClientHttpRequestFactory上配置:
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setBufferRequestBody(false);这个配置会让RestTemplate在请求时逐块发送数据,不缓存整个请求体,从而避免请求重发时连接已死导致的异常。
如果这个配置加上后异常消失,就要往服务端“带着Connection: close头”这个方向去查了。我之前查了整整两天才找到这个点,回看数据包时,异常响应头里确实明晃晃写着Connection: close,而我们的连接池并没有看这个Header来判死连接。
这个案例提醒我一件事:排查SSL相关的怪异报错,第一反应永远是看连接生命周期,而不是看证书算法。方向对了,问题就解决了一半。