如何在 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 客户端中设置总调用期限。
支付场景的注意事项
客户端超时不代表银行没有处理请求。实际情况可能是:
- 银行已经收到并成功处理支付请求。
- 响应在网络中被延迟或丢失。
- 客户端触发
SocketTimeoutException。 - 客户端误以为支付失败并重新提交。
- 最终产生重复扣款。
因此,真实支付请求不能仅凭读取超时就判定失败或自动重试。支付接口通常还需要:
- 全局唯一的交易号或幂等键
- 状态为“结果未知”的中间状态
- 主动支付结果查询
- 异步通知
- 对账与补偿机制
- 明确的重试策略
如果只是验证客户端的超时处理,应优先使用银行沙箱或本地模拟网关。tc netem 更适合集成环境中的网络故障测试,不适合通过真实支付交易反复制造响应丢失。
备注:内容仅供参考。
