PAYATHON 2026

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 来绕过问题。正确的处理顺序是:

  1. 确认服务器发送了完整的证书链;
  2. 优先由网关补齐缺失的中间证书;
  3. 必要时,将可信 CA 证书导入应用专用 truststore;
  4. 通过启动参数明确指定 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 问题,但在原因尚未确认前,不应直接将其作为永久配置。

推荐解决顺序

  1. 逐项比较 Java 7 与正常环境的完整版本、供应商、启动参数和 truststore。
  2. 启用 javax.net.debug=ssl,handshake,确认是否进入 TLS 握手,并查看具体失败原因。
  3. 使用最小 HttpsURLConnection 程序测试同一个网关地址。
  4. 如果最小程序也失败,继续处理 TLS、证书、DNS、代理或网络问题。
  5. 如果最小程序成功,检查 Play WS、AsyncHttpClient、Netty、连接池和线程池。
  6. 删除请求处理线程中的同步 Promise.get(),改用异步组合,并在失败回调中记录异常。
  7. 在测试环境确认请求成功后,再考虑调整连接、读取和请求超时。

长期来看,应升级到仍受支持的 JDK,并使用与该 JDK 匹配的 Play 和 HTTP 客户端版本。Java 7 已停止公共更新多年。即使通过启用 TLS 1.2 或补充证书暂时恢复连接,环境仍可能缺少后续的安全协议、CA 更新和漏洞修复。

示例请求中还包含卡号和 CVV。即使使用的是沙箱测试数据,也不要把完整支付报文写入普通日志、异常平台或工单。生产环境应对相关字段进行脱敏,并遵守相应的 PCI DSS 要求。

备注:内容仅供参考。