PAYATHON 2026

如何在 Linux 上模拟 HTTP POST 调用延迟?

支付阿杰

结论

可以使用 Linux 内核自带的 tc netem,在网络层延迟银行返回的数据包,也可以使用 Toxiproxy 一类的 TCP 故障代理。若要在自动化测试中稳定复现 SocketTimeoutException,更合适的做法是使用可控的模拟支付网关:先完整接收 POST 请求,再故意延迟响应。

需要注意的是,对于 HTTPS,普通网络工具无法看到 URL、POST 方法或请求体,只能根据目标 IP、端口等信息匹配流量。要精确控制某一笔业务请求,必须使用测试网关、应用层代理,或者在测试代码中加入同步控制。

方案一:用 tc netem 延迟银行返回的数据包

tc 是 Linux 的流量控制工具,netem 可以模拟延迟、丢包、乱序等网络故障。下面通过 IFB 虚拟接口处理入站流量,只延迟来自指定银行 IP 和端口的响应。

假设:

  • 应用使用的网卡是 eth0
  • 银行测试网关 IP 是 203.0.113.10
  • HTTPS 端口是 443
  • 需要增加 10 秒响应延迟

先确认实际网卡名称:

ip route get 203.0.113.10

加载并启用 IFB:

sudo modprobe ifb numifbs=1
sudo ip link set dev ifb0 up

将符合条件的入站响应重定向到 IFB:

sudo tc qdisc add dev eth0 handle ffff: ingress

sudo tc filter add dev eth0 parent ffff: protocol ip u32 \
    match ip src 203.0.113.10/32 \
    match ip sport 443 0xffff \
    action mirred egress redirect dev ifb0

在 IFB 上增加 10 秒延迟:

sudo tc qdisc add dev ifb0 root netem delay 10s

如果 Java 的读取超时时间是 2 秒:

urlConnection.setReadTimeout(2000);

等待响应时,如果 2 秒内没有读到数据,通常会抛出 SocketTimeoutException。

测试完成后清理规则:

sudo tc qdisc del dev eth0 ingress
sudo tc qdisc del dev ifb0 root
sudo ip link set dev ifb0 down

如果要模拟完全无响应,可以把延迟规则改为丢弃全部匹配流量:

sudo tc qdisc replace dev ifb0 root netem loss 100%

也可以模拟不稳定网络:

sudo tc qdisc replace dev ifb0 root netem \
    delay 1500ms 500ms distribution normal \
    loss 10%

这种方式的限制

规则在 TCP/IP 层生效,无法判断某个数据包是否属于“POST 已经发送之后的响应”。如果在连接建立前启用规则,以下入站数据也可能被延迟:

  • TCP 握手数据包
  • TLS 握手数据包
  • HTTP 响应头
  • HTTP 响应正文

这样一来,异常可能出现在 getOutputStream() 或 TLS 握手期间,而不是预期的 getInputStream() 中。

银行域名还可能解析到多个 IPv4 地址,或者使用 IPv6。此时需要为每个实际地址添加规则。不要直接对整个 eth0 设置全局延迟,否则 SSH、监控和其他业务流量也会受到影响。

方案二:使用 Toxiproxy 控制下行延迟

Toxiproxy 是一种 TCP 故障代理,适合在 Linux shell 和自动化测试中使用。它可以分别控制:

  • upstream:客户端发往服务端的方向
  • downstream:服务端返回客户端的方向

下面是一个典型配置。具体安装方式取决于使用的发行版和内部软件源。

创建代理:

curl -X POST http://127.0.0.1:8474/proxies \
    -H 'Content-Type: application/json' \
    -d '{
          "name": "bank-gateway",
          "listen": "0.0.0.0:8666",
          "upstream": "203.0.113.10:443"
        }'

为服务端返回方向增加 10 秒延迟:

curl -X POST \
    http://127.0.0.1:8474/proxies/bank-gateway/toxics \
    -H 'Content-Type: application/json' \
    -d '{
          "name": "slow-bank-response",
          "type": "latency",
          "stream": "downstream",
          "toxicity": 1.0,
          "attributes": {
            "latency": 10000,
            "jitter": 0
          }
        }'

删除延迟规则:

curl -X DELETE \
    http://127.0.0.1:8474/proxies/bank-gateway/toxics/slow-bank-response

使用 HTTPS 时,代理只需转发原始 TCP/TLS 数据,不必解密内容。不过,仍需正确处理以下问题:

  • 客户端 URL 中的主机名必须符合服务器证书。
  • 不要因为修改 /etc/hosts,导致代理连接上游时再次指向自身并形成循环。
  • 如果使用证书固定、双向 TLS 或银行专用网络,代理方案可能需要额外配置。
  • 如果下行延迟从连接开始时就已启用,TLS 握手也会被延迟。若必须保证 POST 发出后才启用延迟,应在测试代码中设置同步点,再调用 Toxiproxy API 动态添加 toxic。

方案三:使用模拟支付网关稳定触发读取超时

如果测试目标是验证应用如何处理“服务端已经收到请求,但迟迟没有返回响应”的情况,模拟网关最为可靠。它可以先读取完整的 POST 请求,等待一段时间,然后返回结果。

下面是一个兼容 Java 8 的简单 HTTP 测试服务:

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Executors;

public class DelayedPaymentGateway {

    public static void main(String[] args) throws Exception {
        HttpServer server = HttpServer.create(
                new InetSocketAddress("0.0.0.0", 8080), 0);

        server.createContext("/payment", DelayedPaymentGateway::handlePayment);
        server.setExecutor(Executors.newCachedThreadPool());
        server.start();

        System.out.println("Listening on port 8080");
    }

    private static void handlePayment(HttpExchange exchange) {
        try {
            ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
            InputStream input = exchange.getRequestBody();

            byte[] buffer = new byte[4096];
            int length;

            while ((length = input.read(buffer)) != -1) {
                requestBody.write(buffer, 0, length);
            }

            input.close();

            System.out.println(
                    new String(requestBody.toByteArray(), StandardCharsets.UTF_8));

            // 请求已完整接收,现在故意延迟响应。
            Thread.sleep(10_000);

            byte[] response =
                    "{\"status\":\"approved\"}".getBytes(StandardCharsets.UTF_8);

            exchange.getResponseHeaders()
                    .set("Content-Type", "application/json; charset=utf-8");

            exchange.sendResponseHeaders(200, response.length);

            try (OutputStream output = exchange.getResponseBody()) {
                output.write(response);
            }
        } catch (Exception exception) {
            exception.printStackTrace();
        } finally {
            exchange.close();
        }
    }
}

将应用的测试网关地址改为:

http://127.0.0.1:8080/payment

如果客户端将读取超时设为 2 秒,而模拟服务在读完请求后等待 10 秒,getInputStream() 就会稳定触发读取超时。服务端稍后写入响应时可能遇到 Broken pipe。这是因为客户端已经超时并关闭连接,属于正常的测试现象。

生产环境使用 HTTPS 时,可以为模拟服务配置测试证书,也可以把它放在 Nginx、HAProxy 等 TLS 终止代理后面。不要为了测试而关闭生产代码中的证书校验。

Java 客户端代码需要调整的地方

应在任何可能建立连接或执行 TLS 握手的操作之前设置连接超时和读取超时:

URL url = new URL(getParameterGatewayUrl());
HttpURLConnection connection =
        (HttpURLConnection) url.openConnection();

connection.setConnectTimeout(getTimeoutSeconds() * 1000);
connection.setReadTimeout(getTimeoutSeconds() * 1000);
connection.setDoOutput(true);
connection.setRequestMethod("POST");

try {
    byte[] requestBody = postVars.getBytes(StandardCharsets.UTF_8);

    try (OutputStream output = connection.getOutputStream()) {
        output.write(requestBody);
    }

    StringBuilder responseText = new StringBuilder();

    try (InputStream input = connection.getInputStream();
         InputStreamReader reader =
                 new InputStreamReader(input, StandardCharsets.UTF_8)) {

        char[] buffer = new char[4096];
        int length;

        while ((length = reader.read(buffer)) != -1) {
            responseText.append(buffer, 0, length);
        }
    }
} catch (SocketTimeoutException exception) {
    // 可能是连接、TLS 握手或响应读取超时;
    // 应结合当前执行阶段和日志判断。
} catch (IOException exception) {
    // 处理拒绝连接、连接重置、DNS 失败等其他网络异常。
}

网络连接超时通常也会抛出 SocketTimeoutException,而不是 java.util.concurrent.TimeoutException。getOutputStream() 还可能隐式建立 TCP 连接并执行 TLS 握手,所以只捕获 TimeoutException 往往无法处理实际发生的网络超时。

setReadTimeout() 限制的是单次阻塞读取可以等待的最长时间,并非整个 HTTP 调用的总时限。如果服务器持续以小块数据返回内容,而且每次读取都没有超过该时间,整个请求仍可能运行很久。如果业务需要绝对截止时间,还要在异步任务或上层 HTTP 客户端中设置总调用期限。

支付场景的注意事项

客户端超时不代表银行没有处理请求。实际情况可能是:

  1. 银行已经收到并成功处理支付请求。
  2. 响应在网络中被延迟或丢失。
  3. 客户端触发 SocketTimeoutException。
  4. 客户端误以为支付失败并重新提交。
  5. 最终产生重复扣款。

因此,真实支付请求不能仅凭读取超时就判定失败或自动重试。支付接口通常还需要:

  • 全局唯一的交易号或幂等键
  • 状态为“结果未知”的中间状态
  • 主动支付结果查询
  • 异步通知
  • 对账与补偿机制
  • 明确的重试策略

如果只是验证客户端的超时处理,应优先使用银行沙箱或本地模拟网关。tc netem 更适合集成环境中的网络故障测试,不适合通过真实支付交易反复制造响应丢失。

备注:内容仅供参考。