ACapture Payment Gateway 在 Java 7 中请求超时
结论
java.util.concurrent.TimeoutException: Futures timed out after [20000 milliseconds] 并不是 ACapture 返回的业务错误。它表示 Play/Scala 等待异步 HTTP 请求时,在 20 秒内没有收到成功或失败的完成通知。
单凭这段堆栈,无法判断 Java 7 本身有缺陷。更常见的是 Java 7 运行环境与网关存在以下兼容问题:
- TLS 协议或加密套件协商失败;
- Java 7 使用了不同的 JRE 更新版本、证书库或安全提供程序;
- DNS、代理、IPv6、SNI 或网络路由不同;
- HTTP 客户端库与 Java 7 的组合出现连接池或异步线程问题;
- 代码在 Akka dispatcher 中同步调用
Promise.get(),线程饥饿最终表现为超时。
Java 6 和 Java 8 可以成功,只能说明请求 JSON 可能没有问题,不能证明其他条件完全相同。排查时要比较实际使用的 JVM、证书库、启动参数、依赖和网络环境,不能只看 Java 的主版本号。
先确认超时发生在哪个阶段
当前异常来自:
play.libs.F$Promise.get(F.java:213)
这只能说明 Future 等待超过了 20 秒,无法判断底层停在 DNS、TCP、TLS、请求写入还是响应读取阶段。不要一开始就把超时改成 60 秒,这通常只是让异常来得更晚。
应分别在请求发起、连接完成、响应到达和异常回调处记录日志。日志至少需要包含:
- 请求 URL、HTTP 方法和开始时间;
- JVM 的完整版本和供应商;
- DNS 解析结果;
- HTTP 状态码和响应耗时;
- 异步请求的原始异常及完整 cause 链;
- 当前线程名和使用的 executor。
日志中不要记录完整卡号、CVV、Authorization 或网关密钥。
可以先输出实际运行环境:
System.out.println("java.version=" + System.getProperty("java.version"));
System.out.println("java.vendor=" + System.getProperty("java.vendor"));
System.out.println("java.home=" + System.getProperty("java.home"));
System.out.println("https.protocols=" + System.getProperty("https.protocols"));
System.out.println("javax.net.ssl.trustStore="
+ System.getProperty("javax.net.ssl.trustStore"));
System.out.println("http.proxyHost=" + System.getProperty("http.proxyHost"));
System.out.println("https.proxyHost=" + System.getProperty("https.proxyHost"));
比较时要看 1.7.0_xx 这样的完整版本,不能只记录“Java 7”。
检查 TLS 协商
Java 7 支持 TLS 1.2,但部分 Java 7 更新版或客户端实现不会默认启用它。如果 ACapture 端只接受 TLS 1.2,Java 7 客户端可能无法完成握手。
不过,Java 6 可以成功,说明三套环境还可能使用了不同的供应商、补丁、代理或网关节点。因此,不能只根据版本推断原因,必须查看实际握手日志。
在测试环境中启动应用时,可以启用 JSSE 调试:
java -Djavax.net.debug=ssl,handshake \
-Djava.net.preferIPv4Stack=true \
-jar application.jar
检查日志中是否出现以下信息:
SSLHandshakeException
handshake_failure
no cipher suites in common
protocol_version
PKIX path building failed
unable to find valid certification path
如果客户端没有发送 TLS 1.2,可先确认当前 Java 7 更新版和 HTTP 客户端是否支持,再做一次诊断性测试:
java -Dhttps.protocols=TLSv1.2 -jar application.jar
https.protocols 主要影响基于 HttpsURLConnection 的实现。Play WS 使用的底层客户端可能有单独的协议配置,具体配置项取决于 Play Framework 和 AsyncHttpClient 的版本。
还可以用一个最小 Java 程序绕过 Play/Akka,单独验证 Java 7 能否建立 HTTPS 连接:
import javax.net.ssl.HttpsURLConnection;
import java.net.URL;
public class TlsProbe {
public static void main(String[] args) throws Exception {
URL url = new URL("https://gateway.example.com/");
HttpsURLConnection connection =
(HttpsURLConnection) url.openConnection();
connection.setConnectTimeout(10000);
connection.setReadTimeout(10000);
connection.setRequestMethod("GET");
System.out.println("HTTP status: " + connection.getResponseCode());
System.out.println("Cipher suite: " + connection.getCipherSuite());
}
}
把 URL 换成 ACapture 提供的测试端点。即使返回 401、403 或 405,只要收到了 HTTP 响应,就说明 DNS、TCP 和 TLS 基本可用。如果程序仍然卡在握手阶段,问题就在应用框架之外。
检查证书库和 JRE 差异
三套环境使用的 cacerts 可能不同。Java 7 可能缺少网关证书链所需的根证书或中间证书,也可能在启动时指定了自定义 truststore。
检查服务端证书链:
openssl s_client -connect gateway.example.com:443 \
-servername gateway.example.com -showcerts
检查 Java 7 证书库:
keytool -list -keystore "$JAVA_HOME/jre/lib/security/cacerts"
不要通过关闭证书校验或安装“信任所有证书”的 TrustManager 来绕过问题。正确的处理顺序是:
- 确认服务器发送了完整的证书链;
- 优先由网关补齐缺失的中间证书;
- 必要时,将可信 CA 证书导入应用专用 truststore;
- 通过启动参数明确指定 truststore。
java \
-Djavax.net.ssl.trustStore=/path/to/acapture-truststore.jks \
-Djavax.net.ssl.trustStorePassword=changeit \
-jar application.jar
生产环境不宜直接修改系统 cacerts,否则 JRE 升级或迁移后很难追踪证书差异。
排查 Play/Akka 的阻塞问题
堆栈表明,请求处理链中调用了:
promise.get(20000);
这个调用会阻塞当前 Akka worker。如果 HTTP 回调也依赖同一个线程池,并发请求较多或线程池较小时,就可能发生线程饥饿。所有线程都在等待 Future,却没有空闲线程执行完成 Future 的回调,最后请求会陆续超时。
应尽量返回异步结果,不要在 dispatcher 中调用 get() 或 Await.ready()。不同 Play 版本的 API 并不相同,下面的代码只用于说明处理思路:
Promise<Response> responsePromise = ws.url(endpoint)
.setTimeout(20000)
.post(requestJson);
return responsePromise.map(response -> {
int status = response.getStatus();
if (status < 200 || status >= 300) {
throw new RuntimeException(
"ACapture returned HTTP " + status + ": "
+ response.getBody());
}
return ok(response.getBody());
});
失败回调中还要记录原始异常:
responsePromise.onFailure(error -> {
logger.error("ACapture request failed", error);
});
如果当前 Play 版本不支持这种写法,应改用该版本对应的 map、flatMap、recover 或异步 Action API。原则是不要在处理请求的 Akka dispatcher 上同步等待网络 Future。
如果暂时无法改为全异步处理,至少要把阻塞 I/O 放到专用 executor,并确认 HTTP 客户端的完成回调不会继续依赖同一个已被阻塞的线程池。
检查 HTTP 客户端和连接池
如果最小 HttpsURLConnection 程序可以成功连接,而 Play WS 仍然超时,应检查 Play WS、AsyncHttpClient、Netty 和 Akka 的实际依赖版本。
排查时需要确认:
- Java 7 是否加载了与 Java 6、Java 8 相同的应用包;
- 是否同时存在多个版本的 Netty 或 AsyncHttpClient;
- 连接池的最大连接数是否已经耗尽;
- 响应实体是否每次都会被读取或关闭;
- 代理、认证和重定向设置是否一致;
- HTTP 客户端版本是否明确支持当前 Java 7 更新版。
不要在没有验证兼容性的情况下单独升级某个底层依赖。Play、Akka、Scala、AsyncHttpClient 和 Netty 通常有对应的版本组合。
检查 DNS、IPv4、代理和网络
如果 TLS 日志中连 ClientHello 都没有出现,连接可能在 DNS 或 TCP 阶段就已经停止。
可以分别在三套运行环境中执行:
nslookup gateway.example.com
curl -v --connect-timeout 10 https://gateway.example.com/
还要逐项比较:
- 解析出的 IP 地址是否一致;
- Java 7 是否优先连接了无法访问的 IPv6 地址;
- 是否设置了
http.proxyHost或https.proxyHost; - 防火墙是否根据进程、JRE 路径或出口 IP 放行;
- ACapture 是否配置了来源 IP 白名单;
- 网络中是否存在透明代理或 TLS 检查设备。
-Djava.net.preferIPv4Stack=true 可以用来诊断 IPv6 问题,但在原因尚未确认前,不应直接将其作为永久配置。
推荐解决顺序
- 逐项比较 Java 7 与正常环境的完整版本、供应商、启动参数和 truststore。
- 启用
javax.net.debug=ssl,handshake,确认是否进入 TLS 握手,并查看具体失败原因。 - 使用最小
HttpsURLConnection程序测试同一个网关地址。 - 如果最小程序也失败,继续处理 TLS、证书、DNS、代理或网络问题。
- 如果最小程序成功,检查 Play WS、AsyncHttpClient、Netty、连接池和线程池。
- 删除请求处理线程中的同步
Promise.get(),改用异步组合,并在失败回调中记录异常。 - 在测试环境确认请求成功后,再考虑调整连接、读取和请求超时。
长期来看,应升级到仍受支持的 JDK,并使用与该 JDK 匹配的 Play 和 HTTP 客户端版本。Java 7 已停止公共更新多年。即使通过启用 TLS 1.2 或补充证书暂时恢复连接,环境仍可能缺少后续的安全协议、CA 更新和漏洞修复。
示例请求中还包含卡号和 CVV。即使使用的是沙箱测试数据,也不要把完整支付报文写入普通日志、异常平台或工单。生产环境应对相关字段进行脱敏,并遵守相应的 PCI DSS 要求。
备注:内容仅供参考。
