资讯动态

SSLException close_notify 异常:根因与修复

发布时间:2026/10/21 9:22:46 来源:尧图企业网站定制
排查定点外包系统对接时日志里冒出一句javax.net.ssl.SSLException: closing inbound before receiving peers 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或者OutputStreamTCP层顶多发个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 peers close_notify就在连接池内部被抛出来。2.3 数据库驱动与JDBC底层连接另一个高频触发场景是数据库连接尤其是一些老版本的MySQL JDBC驱动在配合SSL连接使用时。MySQL 8.0开始默认开启了SSL如果JDBC URL里加上了sslModeVERIFY_CA或者requireSSLtrue驱动在建立连接时会走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 peers 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.debugssl:handshake跑一次请求日志会详细打印TLS握手和关闭过程。重点关注最后几行main, READ: TLSv1.2 alert, length2 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不要超过服务端最大连接存活时间。如果你的服务端是Nginxkeepalive_timeout默认是75秒那客户端连接池的idleTime建议设成50秒以内。如果服务端是TomcatconnectionTimeout默认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 { ResponseEntityString response restTemplate.exchange( url, HttpMethod.GET, null, String.class ); return response.getBody(); } catch (ResourceAccessException e) { throw new BusinessException(外部服务连接异常, e); } } }这样做的好处是一旦遇到SSL相关的坑只需要改一个类不需要全局搜索几十个调用点逐一修复。特别强调一下用RestTemplate时不要在Controller层直接把ResponseEntityInputStreamResource返回给前端。我之前见过一个项目为了省内存把外部接口的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相关的怪异报错第一反应永远是看连接生命周期而不是看证书算法。方向对了问题就解决了一半。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑