
接到这个报错的时候我第一反应是老朋友又来了。javax.net.ssl.SSLHandshakeException: Received fatal alert: handshake_failure 这行日志在Java后端、爬虫、第三方接口对接、老系统迁移时出现的频率极高。很多同学看到handshake_failure就慌其实它翻译过来就一句话客户端和服务端TLS握手时没能在协议版本或密码套件上达成一致或者证书链没通过验证连接直接被对方掐断。这篇文章不绕弯子直接把这报错的全部原因和对应的排查解决路径摊开讲。我踩过这个坑也在生产环境里帮人排过不少类似的故障里面大部分是JVM默认配置跟服务器不匹配这类低级但隐蔽的问题少数是代码写得太糙。无论你是新手还是老手按下面的链路走一遍基本能把这个报错从根上解决。1. 先把报错发生的位置和链路搞清楚1.1 确认是客户端报的还是服务端报的这个报错文本本身是客户端视角看到的。服务端收到无法协商的ClientHello后会发一个TLS Alert记录然后断开连接客户端在读取SSL记录时抛出SSLHandshakeException: Received fatal alert: handshake_failure。所以第一步永远是定位这个异常的堆栈是在发送请求的代码处抛出来的还是在你自己的HTTPS服务端接收请求时抛出来的。最常见的场景是Java程序作为HTTP客户端去访问外部HTTPS接口比如调用微信支付、阿里云OSS、第三方开放平台或者内网其他部门部署的服务。如果你是被调用方服务端抛这个异常那问题就变成我提供的HTTPS服务为什么拒绝了这个客户端的握手排查方向要反过来看向自己的证书配置和TLS策略。有一个快速区分的小技巧看完整堆栈。如果异常发生在org.apache.http.conn.ssl.SSLConnectionSocketFactory.createSocket或者sun.security.ssl.TransportContext.fatal这类类里肯定是出站连接。如果发生在sun.security.ssl.SSLEngineImpl配合Netty或者Tomcat的NIO线程栈中大概率是入站连接。1.2 列出请求链路中所有可能参与TLS协商的节点不要只盯着Java代码。实际生产环境中一次HTTPS请求会经历Java进程内的SSLContext配置JVM全局的java.security安全属性JDK版本本身支持的TLS协议和密码套件范围操作系统层面的openssl版本如果你用的底层库是native实现反向代理Nginx、HAProxy或网关的TLS策略防火墙或负载均衡设备是否拦截或篡改TLS握手包我之前遇到过一个典型的幽灵问题测试环境一切正常生产环境必现handshake_failure。查了半天最后发现是生产环境前面挂了一层老旧的安全设备只支持到TLS 1.1而JDK 8默认情况下从某个补丁版本开始禁用了TLS 1.1。这就是为什么排查时必须把链路里每一个节点都过一遍而不是只盯着代码。2.握手失败的四个根本原因拆解2.1 协议版本对不上TLS握手第一件事就是双方互相亮明支持的协议版本。客户端在ClientHello里列出自己支持的版本列表服务端从中选择一个自己也能支持的最高版本。如果完全没有交集服务端直接返回handshake_failure。JDK不同版本默认开启的TLS协议范围差异很大JDK 8早期版本默认启用TLS 1.0部分版本还开着SSLv3JDK 8u261之后TLS 1.0和1.1默认关闭JDK 11及以上默认只启用TLS 1.2和1.3如果你服务端是Nginx配置了ssl_protocols TLSv1.2 TLSv1.3;而客户端JDK还停在旧版本只支持TLS 1.0那肯定是握手失败。反过来也一样很老的服务器比如Windows Server 2008上的IIS只支持TLS 1.0而你的JDK新版本默认已经关掉了TLS 1.0也会报同样的错。判断方法很简单用openssl s_client -connect 目标地址:443 -tls1_2分别指定不同协议版本去连看哪个版本能通。哪边都不一样就知道谁不支持了。2.2 密码套件没有交集协议版本协商成功后紧接着是密码套件协商。客户端在ClientHello里也带上了自己支持的密码套件列表服务端挑一个自己也支持且优先的套件返回。如果列表里一个共同项都没有同样直接失败。这里有个很隐蔽的坑某些JDK版本在FIPS模式下会大大缩减可用密码套件列表或者你手动给SSLContext设置了受限的密码套件。我见过有同事在代码里写死SSL_RSA_WITH_RC4_128_SHA这种老古董去调只支持TLS_AES_128_GCM_SHA256的现代服务结果自然是握手失败。JDK 8上默认可用的密码套件受java.security文件里的jdk.tls.disabledAlgorithms控制。这个属性从JDK 8u161开始逐步增强默认禁用了一堆不安全的算法比如jdk.tls.disabledAlgorithmsSSLv3, RC4, DES, MD5withRSA, DH keySize 1024, EC keySize 256, ...如果你的服务端证书是1024位RSA现在很少见了或者服务端强制的密钥交换算法恰好命中了禁用列表握手就会失败。2.3 证书链不完整或校验失败严格来说证书校验失败通常会抛出PKIX path building failed或SunCertPathBuilderException而不是纯handshake_failure但在某些场景下证书问题也会表现为handshake_failure。典型情况包括服务端返回的证书链不完整缺少中间证书客户端本地信任库又只有根证书客户端信任库cacerts里没有服务端证书链的根证书服务端证书过期或尚未生效客户端启用了主机名校验hostname verification但服务端证书的SAN不匹配第三种常见情况是JVM的cacerts文件被玩坏了。有同学为了装某个软件把$JAVA_HOME/lib/security/cacerts覆盖成了一个只有一两个证书的迷你库所有HTTPS访问一律失败。这种问题不看JVM版本和启动参数根本看不出原因。2.4 SNI服务器名称指示问题现代HTTPS服务普遍用SNI区分同一IP上的不同虚拟主机。Java在TLS握手时默认会发送SNI扩展但如果你用IP地址直连而非域名访问某些服务端可能拿不到SNI里的hostname导致选错证书握手失败。另外一个比较特殊的情况老的JDK 8版本在连接某些只支持TLS 1.3的服务器时如果服务端恰好需要SNI匹配而你的Java代码里用InetSocketAddress创建连接没有设置hostname握手会异常失败。后面会给出代码层面的解决办法。3. 一步步定位问题根因的排查流程3.1 先复现并抓包确认失败位置复现是第一步但别急着上抓包工具。先确认是不是偶发问题。有些报错是单线程偶发有些是必现两者的排查路径完全不同。如果方便的话在目标机器上用-Djavax.net.debugssl:handshake启动Java进程这是我最常用的第一板斧。这个参数能打出完整的握手日志里面包含客户端支持的协议版本列表客户端发送的密码套件列表服务端返回的ServerHello中选择的协议和套件证书链的加载情况和校验过程Alert的详细信息输出里如果出现No available cipher suite或者The server selected protocol version x is not accepted by client preferences之类的行原因基本就锁定了。如果要看得更细就在客户端和服务端之间用tcpdump抓包然后用Wireshark过滤SSL协议。重点看ClientHello里的Supported Versions和Cipher Suites以及ServerHello之前是否直接回了Alert。3.2 用openssl命令快速验证服务端支持情况在出问题的客户端机器上执行openssl s_client -connect api.example.com:443 -tls1_2 -servername api.example.com如果报错no protocols available说明客户端openssl版本太老不支持TLS 1.2如果握手成功输出里会带出Cipher is TLS_AES_256_GCM_SHA384类似字样说明服务端TLS 1.2没问题如果连接后立即关闭且没有任何证书信息说明服务端可能在这个端口上根本没启用TLS再用-tls1_1和-tls1分别试试就能确认服务端到底支持哪些协议版本。openssl s_client -connect api.example.com:443 -tls1_1 -servername api.example.com如果只有TLS 1.2能通而Java客户端报handshake_failure那问题几乎可以断定在Java进程自身的SSLContext配置或JDK版本上。3.3 检查JDK默认启用的协议和密码套件用一个小程序打印当前JDK支持的协议和套件import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; public class TLSInfo { public static void main(String[] args) throws Exception { SSLContext context SSLContext.getInstance(TLS); context.init(null, null, null); SSLSocketFactory factory context.getSocketFactory(); try (SSLSocket socket (SSLSocket) factory.createSocket()) { System.out.println(Supported Protocols:); for (String p : socket.getSupportedProtocols()) { System.out.println( p); } System.out.println(Enabled Protocols:); for (String p : socket.getEnabledProtocols()) { System.out.println( p); } System.out.println(Enabled Cipher Suites:); for (String c : socket.getEnabledCipherSuites()) { System.out.println( c); } } } }跑完之后重点确认Enabled Protocols里有没有TLS 1.2如果只有TLSv1和TLSv1.1说明JVM安全配置被改过了Enabled Cipher Suites里是否有现代套件如果只剩几个带CBC或RC4的多半是手动改过3.4 查看java.security中的禁用算法列表打开$JAVA_HOME/lib/security/java.security重点看这两项jdk.tls.disabledAlgorithmsSSLv3, RC4, DES, MD5withRSA, \ DH keySize 1024, EC keySize 256, 3DES_EDE_CBC, anon, \ NULL, include jdk.disabled.namedCurves jdk.tls.legacyAlgorithms...如果你的目标服务器强制使用ECDHE密钥交换但配置里EC keySize 256把对方的椭圆曲线给禁掉了就会握手失败。把该行临时注释掉重启进程测试如果能通就说明命中了禁用算法。注意java.security是JVM全局配置生产环境修改要按变更流程走改完一定记得备份。临时验证可以用-Djdk.tls.disabledAlgorithms启动参数覆盖。4. 代码层面的解决方案和安全配置4.1 正确配置HTTP客户端的SSLContext以Apache HttpClient为例一个能兼容多数场景的写法SSLContext sslContext SSLContexts.custom() .setProtocol(TLS) .build(); SSLConnectionSocketFactory socketFactory SSLConnectionSocketFactory.builder() .setSslContext(sslContext) .setHostnameVerifier(NoopHostnameVerifier.INSTANCE) // 仅测试用生产环境不要这样 .setTlsVersions(new String[] {TLSv1.2, TLSv1.3}) .build(); CloseableHttpClient httpClient HttpClients.custom() .setSSLSocketFactory(socketFactory) .build();注意setHostnameVerifier(NoopHostnameVerifier.INSTANCE)这行。生产环境千万不要这么写它等于关掉了证书域名校验中间人攻击时可以伪装成任意域名。它只适合在测试环境对接自签名证书或内网测试服务时使用。正确定义见下HostnameVerifier hostnameVerifier new HostnameVerifier() { Override public boolean verify(String hostname, SSLSession session) { return hostname.equalsIgnoreCase(api.example.com); } };4.2 Spring RestTemplate的TLS配置Spring Boot项目中自定义RestTemplate非常常见很多人不知道默认的RestTemplate会继承JDK全局TLS配置。如果全局有问题RestTemplate自然失败。需要自定义时可以这样Bean public RestTemplate restTemplate() throws Exception { SSLContext sslContext SSLContext.getInstance(TLSv1.2); sslContext.init(null, null, null); HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory()); CloseableHttpClient httpClient HttpClients.custom() .setSSLSocketFactory(new SSLConnectionSocketFactory(sslContext, new String[]{TLSv1.2}, null, NoopHostnameVerifier.INSTANCE)) .build(); HttpComponentsClientHttpRequestFactory factory new HttpComponentsClientHttpRequestFactory(); factory.setHttpClient(httpClient); return new RestTemplate(factory); }这里有个容易忽略的问题SSLContext.getInstance(TLSv1.2)和SSLContext.getInstance(TLS)有一点细微差别。前者返回的上下文会相对严格地锁定TLS 1.2实际要看Provider实现后者是通用协议抽象实际协商版本由握手决定。建议优先使用通用TLS再额外通过SocketFactory限制版本灵活性更高。4.3 OkHttp 的TLS配置OkHttp 3.x之后配置方式有变化但核心思路一样OkHttpClient client new OkHttpClient.Builder() .connectionSpecs(Arrays.asList(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS)) .hostnameVerifier(new HostnameVerifier() { Override public boolean verify(String hostname, SSLSession session) { return true; // 仅测试用生产环境禁止 } }) .build();ConnectionSpec.MODERN_TLS在OkHttp里默认支持TLS 1.2和1.3适配上较新。COMPATIBLE_TLS兼容性更好但安全性略低。如果服务端很老只支持TLS 1.0可以临时用new ConnectionSpec.Builder(ConnectionSpec.COMPATIBLE_TLS).tlsVersions(TlsVersion.TLS_1_0).build()这种方式指定更低的协议版本。4.4 手动指定SNI遇到SNI问题的场景使用HttpClient或者原生Socket时要确保可以显式设置SNISSLParameters params sslContext.getDefaultSSLParameters(); params.setServerNames(Collections.singletonList(new SNIHostName(api.example.com))); SSLSocketFactory factory sslContext.getSocketFactory(); try (SSLSocket socket (SSLSocket) factory.createSocket(api.example.com, 443)) { socket.setSSLParameters(params); socket.startHandshake(); // ... }一般用域名而不是IP地址去连接时JDK会自动设置SNI。但如果你代码里必须先用IP连接再在HTTP头里带Host那就得手动塞SNI。经验不要为了省事把setServerNames写死成错误域名某些服务器会对SNI和证书域名做严格匹配写错了反而触发新的握手失败。5. 常见环境定制问题与陷阱实录5.1 JDK版本不一致导致的多环境差异我处理过的最经典案例开发环境Windows上用的JDK 8u301测试环境Linux上用的JDK 8u192生产环境是JDK 11。同一套代码三处表现截然不同。原因就在于JDK 8u261之后TLS 1.0/1.1默认被禁用而JDK 11直接砍掉了TLS 1.0。如果对接的老服务商只支持TLS 1.0就会出现开发能跑、测试偶尔通、生产必炸的灵异现象。解决办法很简单全环境统一JDK版本或者统一在JVM启动参数里指定-Djdk.tls.client.protocolsTLSv1.2。还有一个容易忽略的点如果你用了Spring Boot的-Djava.security.properties参数自定义安全属性这个参数会完全覆盖默认的java.security文件而不是追加。很多人不知道这一点想加一条自定义禁用算法结果把整个默认配置搞丢了导致大量原本支持的算法全部被禁用。5.2 构建工具自带JDK的坑Gradle和Maven在跑测试或者依赖下载时用的不是你系统PATH里的Java而是JAVA_HOME或者Gradle自身下载的Toolchain。如果你用Gradle的Toolchain指定了一个JDK 14而运行时用的JDK 8两边TLS策略完全不一样。测试代码跑出来的握手行为和最终部署时JVM的握手行为可能根本是两码事。排查时先确认./gradlew --version echo $JAVA_HOME java -version三者的JDK版本必须保持一致。这个坑坑了不少人尤其是从同事电脑上clone项目后本地Gradle自动下载了不同版本的JDK。5.3 Windows上TLS 1.2握手失败的专项问题Windows平台还有个特有的坑跟schannel有关。由于Java调用操作系统根证书和本地TLS实现受Schannel影响Windows Server 2012之前的系统如果没打补丁系统层面就不支持TLS 1.2。你代码怎么写都没用因为socket层面就被限制住了。解决方法就是在系统注册表启用TLS 1.2[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2] [HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client] DisabledByDefaultdword:00000000 [HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server] DisabledByDefaultdword:00000000某些Java应用使用的Native SSL库例如Netty的OpenSSL不走Schannel表现会不一样。这也是为什么同一套代码在Windows和Linux上行为不同的原因之一。5.4 添加BouncyCastle后出现的奇异行为为了某个特殊算法引入BouncyCastle Provider后有时会导致所有TLS握手行为变化。原因是BouncyCastle的Provider注册了它自己的SSLContext算法实现优先级可能比SunJSSE高。如果你在代码里直接Security.insertProviderAt(new BouncyCastleProvider(), 1)某些场景下SSLContext会选到BC的实现它的默认协议版本和密码套件范围跟SunJSSE完全不同。排查时查看启动时Provider列表java -XshowSettings:properties -version 21 | grep -A 20 java.security.properties或者在代码里for (Provider p : Security.getProviders()) { System.out.println(p.getName() p.getVersion()); }如果确实是BC接管了SSL实现可以显式指定使用SunJSSESSLContext context SSLContext.getInstance(TLS, SunJSSE);5.5 排除Nginx和网关的干扰如果你请求的设备上装了Nginx或HAProxy做Local代理很多TLS握手会先走到代理而不是直连目标服务器。代理的TLS策略和上游服务器可能完全不同。用curl测试时加--resolve绕过代理curl --resolve api.example.com:443:127.0.0.1 https://api.example.com -v如果这样能通说明问题出在代理层配置的TLS策略上跟Java代码无关。Java程序也建议在调试时用socksProxyHost或https.proxyHost参数排除代理干扰确认原始链路是否正常。6. 一套亲测有效的完整排查清单6.1 由浅入深的排查顺序按照下面的顺序来基本能覆盖90%以上的场景步骤操作目的1确认报错是出站连接还是入站连接明确排查方向2用openssl s_client分别指定tls1、tls1_1、tls1_2测试目标端口确认服务端支持的协议范围3在Java进程加-Djavax.net.debugssl:handshake看详细握手日志确认客户端实际发送和收到的内容4打印Java进程实际启用协议和密码套件对照双方支持项找交集5查看JDK版本和java.security里的禁用算法排除全局配置干扰6检查目标域名证书链和本地cacerts信任库排除证书因素7检查本地代理、网关、负载均衡排除中间设备干扰8用代码显式配置SSLContext做最小化验证定位是配置问题还是环境问题6.2 一个最容易踩的坑JDK 8升级之后的老接口很多公司还停留在对接银联、税务或某些政务接口的阶段对方服务可能部署在很老的中间件上只支持TLS 1.0。你本地JDK 8新版本一升级这些接口全部报handshake_failure。这种场景的正确做法不是去改java.security禁用算法列表而是单独给对接这些老服务的那段代码创建一个专用SSLContext限制为TLS 1.0或TLS 1.1。既不影响整体安全策略也能跑通老接口。SSLContext sslContext SSLContext.getInstance(TLSv1); sslContext.init(null, null, null);TLSv1只代表TLS 1.0TLSv1.1则代表TLS 1.1。老接口大多支持TLS 1.0用TLSv1创建上下文兼容性最高。6.3 关于百分百解决这件事我必须说实话尾巴网上有人声称一份配置走天下百分百解决所有SSLHandshakeException这不现实。Received fatal alert: handshake_failure只是握手层失败的泛化报错对应的是一个协商机制原因分散在协议、套件、证书、SNI、设备等各个层面。真正解决它靠的不是一个万能开关而是上面这套定位链路。我个人在实际排查中80%以上的情况最终都落在三件事上服务端只支持TLS 1.2以上客户端JDK旧版本默认没开启双方密码套件列表没有交集多半是JDK全局禁用算法覆盖了走代理或网关时中间一层把TLS版本或SNI弄坏了最后再分享一个小技巧排查这类问题时把每次修改的前后行为记录到一个文档里特别记录JVM参数、JDK版本、目标服务器TLS配置这三个变量。很多看似神秘的握手失败其实就是三个变量中一个发生了变化。把这些基线记录好下次遇到相似问题十分钟之内准能定位。