1. 报错出现的第一现场这个异常到底在哪儿蹦出来的说实话干后端开发或者运维的几乎没人没遇到过这个报错区别只是遇到几次和最后怎么解决的。我先直接把报错原文贴出来因为很多人搜到这个错误的时候日志里的完整堆栈其实是这样的javax.net.ssl.SSLHandshakeException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target Caused by: java.security.cert.CertPathValidatorException: validity check failed注意这行非常关键的话validity check failed。这是整个问题链条里最具体、最坑的一个环节。它翻译成人话就是JVM在验证目标服务器证书的时候发现这张证书不在它的有效期内——要么还没生效要么已经过期了。很多人一看这报错就慌以为是证书链没配全或者JDK的cacerts信任库缺根证书于是去折腾导入证书。但如果你仔细看这里写的是validity check failed不是unable to find valid certification path。这俩的报错位置差一个字排查方向天差地别。unable to find valid certification path是整条证书链里某个环节找不到信任锚点而validity check failed是证书本身时间有效期校验就没通过压根还没走到信任链验证那一步。这个报错最常出现在下面几个场景里你可以对照看看自己是不是这几个情况Java程序用HttpURLConnection、HttpClient、OkHttp、RestTemplate去访问某个HTTPS接口突然一天炸出来这个异常项目里配置了定时任务每天凌晨去同步某家第三方平台的数据某天早上邮件告警就收到了这个报错测试环境连某个内网服务时一直是好的部署到生产环境之后立马报这个错很多人第一反应是证书配置错了本地开发跑得好好的放到Docker容器里就报这个错很多人以为镜像问题实际上是容器里的系统时间和宿主机的差了几天。这几种场景我全踩过每一种根因都可能不一样。文章后面我会把排查思路和解决手段全部铺开讲。2. 证书有效期校验的底层逻辑JVM是怎么判断证书没过期的要彻底搞明白为什么报validity check failed得先把证书有效期校验这层机制弄清楚。很多教程喜欢直接给禁用SSL验证的代码那是误人子弟生产环境这么搞等于裸奔。2.1 证书结构里的两个时间戳一张X.509证书里最核心的三个字段是签发者Issuer、使用者Subject、有效期Validity。其中有效期是一个区间包含两个时间notBefore证书生效的起始时间在这个时间之前证书一律视为无效notAfter证书失效的结束时间过了这个时间点证书视为过期。JVM在验证证书时会取当前时间然后判断当前时间是否落在[notBefore, notAfter]这个闭区间内。如果不在直接抛CertPathValidatorException: validity check failed。这里有一个绝大部分人都会忽略的细节JVM判断当前时间用的是运行JVM的操作系统的系统时间而不是某个外部NTP服务器的时间也不是调用接口那台远端服务器的时间。也就是说只要你这台机器的时间不对这个校验就一定会出问题。2.2 PKIX校验的完整流程PKIXPublic Key Infrastructure with X.509是Java里默认的证书链验证机制它校验的流程大致是这几步建立证书链从服务器证书开始逐级向上查找签发它的CA证书直到找到自签名的根证书检查有效期对证书链中的每一张证书分别做notBefore和notAfter校验检查签名逐级验证父证书对子证书的签名是否合法检查信任锚确认根证书是否在JDK的cacerts信任库里检查扩展项包括KeyUsage、ExtendedKeyUsage、BasicConstraints等关键扩展是否满足使用场景。validity check failed发生在第2步。所以哪怕你证书链完整、根证书也在cacerts里、签名完全合法只要有一张证书的时间有问题整个握手照样失败。2.3 为什么证书时间问题如此隐蔽证书有效期问题之所以隐蔽是因为它不像证书域名不匹配那样一眼就能看出来。证书域名不匹配你在浏览器里打开直接红色警告证书过期了也一样浏览器会拦你。但很多场景下JVM访问HTTPS接口是后端到后端没有浏览器的友好提示只有一堆堆栈日志。更隐蔽的是证书链里往往不止一张证书。服务器证书是一个时间区间中间CA证书又是一个时间区间根证书还有一个时间区间。只要链里任何一张证书的时间校验失败JVM就报这个错但日志里通常不明确告诉你具体是哪一张证书出了问题。你需要自己动手去查。3. 定位问题根因三条命令锁定凶手收到报错别急着去搜代码先做这几步定位。我用过的所有方法里下面这三条命令组合是最快、最准的。3.1 查看目标服务器证书的当前有效期如果你能直接连到目标HTTPS服务或者你有目标服务器的证书文件用openssl查看证书的有效期信息。在Linux终端或Git Bash里执行# 方式一直接连接目标主机查看证书443是HTTPS默认端口 openssl s_client -connect api.example.com:443 -servername api.example.com 2/dev/null | openssl x509 -noout -dates # 方式二查看已有证书文件 openssl x509 -in server.crt -noout -dates输出大概是这样的notBeforeApr 10 00:00:00 2024 GMT notAfterMay 20 00:00:00 2025 GMT看到这两个时间再去比对一下当前的系统时间问题已经明确了一半。3.2 检查本地系统时间这一步很多人会忽略但恰恰是最常见的根因。别用眼睛看电脑右下角的时间要用命令行查。Windows上执行date /tLinux或macOS上执行date。但是这里有个坑显示的时间格式和精度都不一样最稳妥的做法是直接对比UTC时间# 查看当前UTC时间 date -u %Y-%m-%d %H:%M:%S # 查看当前系统时间含时区 date %Y-%m-%d %H:%M:%S %Z如果系统时间比真实时间快了一天、慢了一个月或者时区配置错了那证书时间校验必挂无疑。我遇到过最离谱的一次是一台新装的服务器BIOS电池没电了系统时间一直停在2018年结果所有调用外部HTTPS接口的程序全部报validity check failed因为2024年签发的证书在2018年这个时间点还没到notBefore等于证书尚未生效。3.3 完整校验证书链上每一张证书的时间目标服务器不仅是返回一张证书它通常会返回完整的证书链。想看到完整的证书链时间信息用这条命令openssl s_client -connect api.example.com:443 -showcerts -servername api.example.com 2/dev/null | grep -E s:|i:|Not Before|Not After输出会包含当前证书和签发者信息每一张证书的Not Before和Not After都会列出来。逐张检查看哪一张落在了当前时间范围之外。注意-servername参数是开启SNIServer Name Indication有些服务器没有这个参数会拒绝返回证书或返回默认证书。访问有多个域名共用同一IP的服务时这个参数尤其重要。3.4 在Java层面直接看JVM信任库的时间如果服务器侧证书都没问题那问题可能出在本地JVM信任库里的根证书或中间证书上。用keytool查看# 列出cacerts里的所有证书默认密码是changeit keytool -list -keystore $JAVA_HOME/lib/security/cacerts -storepass changeit | grep -i entry # 查看具体某张证书的详细信息 keytool -list -v -keystore $JAVA_HOME/lib/security/cacerts -storepass changeit -alias digicert注意$JAVA_HOME路径不同的JDK版本路径略有差异。JDK 8及以前是jre/lib/security/cacertsJDK 9以后是lib/security/cacerts。4. 分场景解决方案不同根因对应的正确处理定位完根因之后就该动手解决了。我按根因分类给出对应的解决方案你直接对照自己的情况选方案就行。4.1 场景一系统时间不对这是最常踩的坑某公司在做一次年终活动时所有依赖外部支付接口的服务突然同时报validity check failed排查到后面发现是运维在迁移服务器时没有同步NTP配置集群里部分机器的时间慢了两个小时。同步系统时间的方法很简单各平台通用的做法如下。Linux服务器如果装了systemd-timesyncd# 立即同步一次时间 sudo timedatectl set-ntp true sudo systemctl restart systemd-timesyncd如果用的是ntpdatesudo ntpdate -u ntp.aliyun.comWindows服务器# 管理员权限PowerShell执行 w32tm /config /manualpeerlist:ntp.aliyun.com /syncfromflags:manual /reliable:yes /update w32tm /resync同步完之后再执行一次date -u确认时间正确然后重新跑Java程序。这个场景下改完时间问题立刻消失不需要重启JVM。这里要特别提一个细节Docker容器里的时间是共享宿主机的内核时间的。所以如果你宿主机时间错了容器里无论怎么改都无法持久化。必须在宿主机上解决时间问题或者启动容器时挂载/etc/localtime。4.2 场景二目标服务器证书确实过期了或还没生效这种场景不是你的问题是服务器那边的问题。常见于你对接的第三方平台没有及时更新证书。这时候你只能等对方更新证书后再调用联系对方商务或技术支持反馈证书过期的问题如果对方提供了新的证书下载地址手动更新到你本地的信任库。在这个场景里我给程序代码层面的一般性建议是不要因为这个报错就去改程序禁用SSL校验。如果你是甲方完全没有必要为对方的错误买单用临时绕过手段反而会埋下更大的安全隐患。4.3 场景三JDK自带的cacerts信任库太旧这种场景是这样的你的目标服务器证书没问题系统时间也没问题但JDK的cacerts里缺了签发这张证书的根证书。你可能会奇怪上面不是说了validity check failed是时间问题吗为什么信任库也会导致这个报错因为比较老的JDK版本比如JDK 7、JDK 8的早期更新版自带的信任库没有包含近年新加入的根证书。如果目标证书链里中间证书或根证书是近几年新换的旧JDK识别不了校验就会失败。虽然这种情况更常报unable to find valid certification path但在某些特定位的根证书交叉签名场景里也会先触发validity check failed。解决方案很简单升级JDK到最新版本或者手动把根证书导入cacerts。# 第一步下载目标CA的根证书存为 root.crt # 第二步导入cacertsJDK8及以前路径JDK9以后路径请按实际调整 keytool -import -trustcacerts -alias your_ca_alias -file root.crt -keystore $JAVA_HOME/jre/lib/security/cacerts -storepass changeit # 第三步确认导入成功 keytool -list -v -keystore $JAVA_HOME/jre/lib/security/cacerts -storepass changeit -alias your_ca_alias注意cacerts是系统全局信任库影响的是这台机器上所有Java程序。如果你的修改只希望影响单个应用建议不要动cacerts而是为你的应用单独创建一份信任库然后在启动参数里指定。4.4 场景四应用使用了独立的信任库但里面没有目标证书很多大型项目不会用默认的cacerts而是配置了独立的truststore。Spring Boot项目里常见这样的配置spring: datasource: url: jdbc:mysql://... # 有些项目会为HTTP客户端配置SSL信任库如果你不确定应用用的是哪份信任库用-D启动参数看一下JVM实际指向的路径。添加启动参数打印系统属性java -Djavax.net.debugssl:handshake -jar your-app.jar这样JVM会输出SSL握手全程日志包括它加载的信任库路径和证书校验明细。比如日志里会显示trustStore is: /path/to/your/custom/truststore.jks trustStore type is: jks trustStore provider is:看到这个路径后用keytool检查这份信任库里是否有目标服务器证书链的根证书keytool -list -v -keystore /path/to/your/custom/truststore.jks -storepass your-password | grep -A 5 your-cert-alias如果没有对应证书参照4.3的导入方法把根证书导入到这份自定义信任库即可。4.5 场景五目标服务器返回的证书链不完整还有一种很恶心的情况目标服务器在TLS握手时只返回了服务端证书没有返回中间CA证书。客户端本地又没有这份中间证书于是从服务端证书到根证书的链断了校验失败。这种情况在浏览器里通常不报错因为浏览器有内置的中间证书缓存和自动补齐机制但Java的cacerts里一般不会预装第三方中间证书所以JVM直接报validity check failed。解决方案有两个方向。方向一让目标服务器配置完整的证书链。这个需要联系服务器管理员在Nginx、Apache或云负载均衡上把中间证书和服务端证书合并在同一个文件里下发。方向二在本地手动补全证书链。先用openssl查出服务器返回的证书链缺失的部分然后从CA官网下载缺失的中间证书导入自定义信任库。# 下载中间证书到本地命名为 intermediate.crt # 导入到应用使用的信任库 keytool -import -alias intermediate_ca -file intermediate.crt -keystore /path/to/truststore.jks -storepass your-password提示不建议把中间证书导入cacerts全局信任库。中间证书的有效期一般比根证书短很多后续每次续期都要重新导一次维护负担很大。放在应用的自定义信任库里升级迭代时可控性更强。5. 绕过方案到底能不能用怎么用才安全网上搜索这个问题排名靠前的答案一定有禁用SSL校验的代码。这种方案我强烈不推荐直接用在生产环境但有些内网测试环境确实可以临时用一下多团队协作时也能减少联调的沟通成本。下面给出可行的临时绕过方案和绝对要避免的写法。5.1 临时信任所有证书的写法仅限测试环境以HttpsURLConnection为例写一个绕过证书校验的工具类// 仅限本地开发或测试环境使用严禁用于生产环境 import javax.net.ssl.*; import java.security.cert.X509Certificate; public class TrustAllCerts { public static void disableSslVerification() { try { TrustManager[] trustAllCerts new TrustManager[] { new X509TrustManager() { public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } public void checkClientTrusted(X509Certificate[] certs, String authType) {} public void checkServerTrusted(X509Certificate[] certs, String authType) {} } }; SSLContext sc SSLContext.getInstance(TLS); sc.init(null, trustAllCerts, new java.security.SecureRandom()); HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory()); HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) - true); } catch (Exception e) { e.printStackTrace(); } } }这个是网上流传最广的代码。但是如果你细看我的标题这个绕过代码其实没有解决validity check failed。因为你把TrustManager全部接管了JVM的PKIX校验整个过程都被跳过了包括有效期校验。这样确实不会报错了但代价是你不再验证服务器是不是真的那台服务器等于把HTTPS降级成了明文HTTP。中间人攻击的防线完全失守。5.2 不要用Apache HttpClient的旧版绕过写法早年的httpclient4.x做法是设置SSLSocketFactory为ALLOW_ALL_HOSTNAME_VERIFIER。新版本的httpclient已经废弃了这些API改用SSLConnectionSocketFactory配合自定义TrustStrategy。网上很多旧代码直接复制下来是编译不过的别浪费时间。5.3 更推荐的临时方案仅绕过有效期校验如果你的问题只是有效期校验失败但你确信目标服务器是可信的有一个更精准的绕过方案只跳过有效期校验其他PKIX校验照常执行。写一个自定义的X509TrustManager在checkServerTrusted里不做有效期判断即可// 参考思路代码 import javax.net.ssl.*; import java.security.cert.*; public class SkipValidityTrustManager implements X509TrustManager { private final X509TrustManager defaultTrustManager; public SkipValidityTrustManager() throws Exception { TrustManagerFactory tmf TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); tmf.init((KeyStore) null); TrustManager[] tms tmf.getTrustManagers(); defaultTrustManager (X509TrustManager) tms[0]; } Override public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { // 复制有效期内校验逻辑以外的部分 // 不检查有效期但保留对证书链和签名等部分的校验 for (X509Certificate cert : chain) { cert.checkValidity(); // 如果还不想校验有效期这行也可以去掉 } defaultTrustManager.checkServerTrusted(chain, authType); } }这个方案比较中庸保留了证书链和签名校验只放过了有效期。但我依然不建议在正式环境使用只作为临时的应急措施。核心原则真正到了生产环境不要想着绕要去解决根本原因。清理过期证书、同步时间、升级JDK、补全证书链每一条做起来都不难难的是愿意花时间排查。6. 生产环境实战一个完整的事故排查过程记录为了让大家更直观地理解我把之前遇到的一个真实项目案例完整复盘一下。这是一个企业内部管理系统某天测试环境调用第三方物流接口突然大面积报这个错。那天的情况是这样的早上10点左右仓库那边的同事反馈说运单打印不出电子面单了。查应用日志满屏都是javax.net.ssl.SSLHandshakeException: PKIX path building failed ... Caused by: java.security.cert.CertPathValidatorException: validity check failed。当时我第一反应是问运维有没有改过服务器时间运维说没有。然后检查了应用本身没人动过代码和配置。那就奇怪了好端端的为什么突然报这个错。接着我用openssl直接连物流接口服务器查证书有效期openssl s_client -connect log.api.example.com:443 -servername log.api.example.com 2/dev/null | openssl x509 -noout -dates输出是notBeforeJul 29 00:00:00 2023 GMT notAfterJul 29 00:00:00 2024 GMT当时是8月中旬证书确实已过期。但奇怪的是为什么过期当天没有立刻爆出来原来这个第三方平台的证书在过期前其实做过一次自动续期但只更新了部分CDN节点我们连到的那个边缘节点还持有旧证书。这种问题太隐蔽了如果不去一条条查证书根本猜不到。然后我本地模拟请求也拿到同样的结果。联系对方技术支持对方刷新节点缓存后接口恢复正常。全程没动我们应用任何一行代码。从这个案例能学到什么第一报错信息里的validity check failed是最明确的指针优先怀疑证书时间第二涉及多个节点和缓存时同样的域名可能拿到不同的证书要反复多查几次第三和第三方平台对接时最好在代码里监控HTTPS证书有效期提前预警而不是等报错打过来再排查。7. 高频问题速查表我把平时群里和社区里被问得最多的问题整理成一个速查表方便你遇到问题直接对着查。问题可能原因优先排查点报错validity check failed系统时间显示正常证书链中某张证书确实过期或未生效用openssl逐一查看每张证书的时间区间本地访问正常Docker容器里报错容器共享宿主机时间宿主机时间不准在宿主机执行date命令换一台服务器就报错新服务器的cacerts版本过旧升级JDK或手动导入根证书同一个域名一会儿好一会儿坏服务器有多节点负载均衡部分节点证书过期多次执行openssl s_client看返回证书的日期报错validity check failed但浏览器访问正常浏览器恰好有中间证书缓存JVM没有检查证书链是否完整用自定义truststore的应用报错自定义truststore缺少对应根证书检查应用启动参数里javax.net.ssl.trustStore指定的文件应用之前正常突然某天报错且代码没动过目标服务器证书恰好当天过期或续期异常立刻用openssl确认证书有效期自签证书访问抛这个错自签证书时间设置错误或未导入信任库检查证书有效期和JVM信任库8. 一劳永逸的预防方案把手动排查变成自动化巡检既然这个错误的高发场景是证书过期那最好的办法就是在证书过期之前就发现它而不是等系统崩了再排查。这些年我总结了一套低成本、高价值的预防措施。8.1 定期巡检证书有效期脚本用openssl可以写一个简单的巡检脚本在Linux服务器上配置crontab每天执行一次。核心代码如下#!/bin/bash # cert_check.sh SITES(api.example.com:443 openapi.another-example.com:443 third.example.net:443) THRESHOLD_DAYS30 for site in ${SITES[]}; do host$(echo $site | cut -d: -f1) port$(echo $site | cut -d: -f2) expire_date$(echo | openssl s_client -connect $site -servername $host 2/dev/null | openssl x509 -noout -enddate | cut -d -f2) expire_epoch$(date -d $expire_date %s) now_epoch$(date %s) days_left$(( (expire_epoch - now_epoch) / 86400 )) if [ $days_left -lt $THRESHOLD_DAYS ]; then echo 证书将在 $days_left 天后过期: $site fi done配合crontab# 每天早8点执行一次 0 8 * * * /home/ops/cert_check.sh /var/log/cert_check.log 21脚本输出里如果有提醒就提前去做证书续期。从根子上杜绝了因为证书过期导致的validity check failed。8.2 在应用代码里监听证书过期预警实际上在Java代码中也可以用HttpsURLConnection连接目标地址然后取出对方的证书有效期做成一个健康检查接口。每次发布前自动调用一次如果有异常直接中断CI流程。// 伪代码示意思路 public MapString, Object checkCertValid(String host, int port) { try { SSLSocketFactory factory HttpsURLConnection.getDefaultSSLSocketFactory(); SSLSocket socket (SSLSocket) factory.createSocket(host, port); socket.startHandshake(); SSLSession session socket.getSession(); Certificate[] certs session.getPeerCertificates(); if (certs.length 0 certs[0] instanceof X509Certificate) { X509Certificate cert (X509Certificate) certs[0]; long daysLeft (cert.getNotAfter().getTime() - System.currentTimeMillis()) / 86400000; return Map.of(expiredInDays, daysLeft); } socket.close(); } catch (Exception e) { return Map.of(error, e.getMessage()); } }这个巡检接口可以暴露给运维监控系统设置阈值告警。提前30天、7天、1天分别告警一次绝对比事后排查来得从容。8.3 定期升级JDK版本JDK版本太旧会导致cacerts信任库里的根证书不完整或者算法不支持。目前最通用的做法是保持JDK 8至少更新到较新的发布版或者直接考虑JDK 11/17的LTS版本。每次JDK大版本升级时cacerts也会同步更新很多证书校验问题会在升级过程中自动解决。9. 最后再分享一个排查小众问题的小技巧很多资料讲到这里就结束了但有一个场景特别容易被忽略我最后专门提一下。如果你排查了很久证书时间没问题、系统时间没问题、信任库也没问题但依然报validity check failed那大概率是JVM的安全随机数问题。某些环境里NativePRNG阻塞或者/dev/random熵源不足会导致JVM在生成握手随机数时异常进而触发奇怪的证书校验错误。这种问题在新创建的空闲容器里偶发。排查方式在启动参数里加-Djava.security.egdfile:/dev/urandom如果错误消失就是熵源问题。这个参数在老版本Spring Boot项目里几乎是标配新版本默认已经修复了这个逻辑。从实际操作经验来说validity check failed这个报错80%以上的根因就是服务器时间不对或者证书过期。先查时间再查证书最后查信任库按这个顺序来效率最高。别一开始就怀疑代码、怀疑网络把最基础的东西确认好了再往深了查。遇到这种报错别火大它本质上是在提醒你你的程序正在试图连接一个不被信任的、或者时间上有问题的服务。到底是服务端的问题还是客户端的问题用上面提到的排查方法十分钟内一定能定位到。真正解决之后记得把巡检脚本配上别等下次它再炸你。