一次 HTTP 调用失败,并不等于对方没有执行。本文从 Java HttpClient 出发,梳理连接超时、请求超时、重试边界和幂等键的关系,并给出一个可落地的微服务调用实现。
问题背景:HTTP 失败不代表业务没有发生
在微服务系统中,一个服务调用另一个服务,表面上只是发送一次 HTTP 请求,实际却跨越了 DNS、TCP 连接、连接池、负载均衡、网关、服务线程池、数据库等多个环节。任何一个环节变慢,都可能让调用方看到超时或连接异常。
真正棘手的地方在于:调用方认为失败,不代表被调用方一定没有执行。
例如,订单服务调用支付服务创建支付单:
- 支付服务已经写入数据库;
- 响应已经生成,但还没有返回到订单服务;
- 中间网络断开,订单服务收到
SocketTimeoutException; - 订单服务进行重试;
- 支付服务再次创建一笔支付单。
如果没有幂等控制,重试就可能把一次业务请求变成两次扣款、两张订单或两条重复消息。因此,微服务 HTTP 调用的可靠性不能只靠“把超时时间调大”,而要同时考虑超时、重试、幂等和服务端状态。
一、先区分两类超时
1. 连接超时
连接超时表示在规定时间内没有建立 TCP 连接。常见原因包括目标服务不可用、网络拥塞、连接地址错误或连接池无法及时提供连接。
连接超时通常可以快速失败,因为此时请求大概率还没有到达对方服务。
2. 请求超时
请求超时表示请求已经发出,但在规定时间内没有完成响应。此时请求可能已经被对方接收,甚至已经完成了数据库写入,只是响应没有顺利返回。
这也是最容易误判的地方:请求超时属于“结果未知”,而不是简单的“请求失败”。对于查询类接口,可以谨慎重试;对于创建、扣款、发货等写操作,必须先确认接口是否支持幂等。
在 Java 代码中,不建议无限等待,也不建议所有接口共用一个很大的超时。超时时间应该根据接口的业务目标设置,并且小于上游调用方能够容忍的总时间。
二、一个带边界的 HTTP 调用实现
下面使用 Java 11 提供的 java.net.http.HttpClient 演示一个同步调用器。它不依赖具体 Web 框架,适合用来说明核心逻辑。示例针对“创建支付单”接口,假设服务端支持通过 Idempotency-Key 保证同一个业务请求只执行一次。
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
public final class PaymentClient {
private static final Set<Integer> RETRYABLE_STATUS =
Set.of(408, 429, 502, 503, 504);
private final HttpClient httpClient;
private final URI endpoint;
private final int maxAttempts;
public PaymentClient(String endpoint, int maxAttempts) {
if (maxAttempts < 1) {
throw new IllegalArgumentException("maxAttempts must be positive");
}
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(300))
.version(HttpClient.Version.HTTP_1_1)
.build();
this.endpoint = URI.create(endpoint);
this.maxAttempts = maxAttempts;
}
public PaymentResult createPayment(String orderId, long amount) {
String idempotencyKey = UUID.randomUUID().toString();
String requestBody = "{\"orderId\":\"" + orderId
+ "\",\"amount\":" + amount + "}";
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
HttpRequest request = HttpRequest.newBuilder(endpoint)
.timeout(Duration.ofSeconds(2))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("Idempotency-Key", idempotencyKey)
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = httpClient.send(
request,
HttpResponse.BodyHandlers.ofString()
);
int status = response.statusCode();
if (status >= 200 && status < 300) {
return PaymentResult.success(response.body());
}
if (!isRetryable(status) || attempt == maxAttempts) {
return PaymentResult.failure(
"payment request failed, status=" + status
);
}
sleepBeforeRetry(attempt);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return PaymentResult.failure("request interrupted");
} catch (IOException e) {
if (attempt == maxAttempts) {
return PaymentResult.failure(
"payment result unknown: " + e.getClass().getSimpleName()
);
}
sleepBeforeRetry(attempt);
}
}
return PaymentResult.failure("unexpected retry state");
}
private boolean isRetryable(int status) {
return RETRYABLE_STATUS.contains(status);
}
private void sleepBeforeRetry(int attempt) {
long baseMillis = 100L * (1L << Math.min(attempt - 1, 5));
long jitterMillis = ThreadLocalRandom.current()
.nextLong(0, 100);
try {
Thread.sleep(baseMillis + jitterMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public record PaymentResult(boolean success, String message) {
static PaymentResult success(String body) {
return new PaymentResult(true, body);
}
static PaymentResult failure(String message) {
return new PaymentResult(false, message);
}
}
}这段代码有几个关键点。
首先,connectTimeout 和 HttpRequest.timeout 不是一回事。前者控制建立连接的时间,后者限制这一次请求等待完成的时间。其次,重试次数是明确的,示例中通过构造参数传入,不能让失败请求无限放大。最后,写请求每次重试都使用同一个 Idempotency-Key,而不是每次重新生成一个键。
示例中的 JSON 拼接只用于展示调用流程。真实项目中应使用 Jackson 等 JSON 库序列化请求对象,以避免特殊字符、数字格式和字段转义问题。
三、哪些情况可以重试
重试不是异常捕获后的默认动作,而是一种有条件的流量放大行为。通常可以从三个维度判断。
网络异常
IOException、连接被关闭、连接重置等异常说明调用没有得到正常响应,但无法直接证明服务端没有处理。对于幂等查询可以重试;对于写操作则必须结合幂等键或业务状态查询。
HTTP 状态码
408 Request Timeout、429 Too Many Requests、502 Bad Gateway、503 Service Unavailable 和 504 Gateway Timeout 往往具有临时性,可以在有限次数内重试。
400、401、403、404 等通常是请求本身或权限问题,继续重试没有意义。500 也不能简单地全部重试,因为它可能是确定性的业务错误,也可能已经完成了部分写入。是否重试需要结合接口契约和服务端错误分类。
对于 429,如果响应包含 Retry-After,应优先遵守服务端给出的等待时间,而不是机械地使用本地退避策略。
业务结果
HTTP 状态码为 200,也不代表业务一定成功。很多接口会返回业务码、状态字段或异步任务编号。调用方必须解析业务响应,并明确区分“成功”“确定失败”和“结果未知”。
四、幂等键必须由业务请求贯穿始终
幂等键不是一个普通的随机请求 ID。它应该代表一次业务意图,并在重试、网关转发、服务内部调用过程中保持不变。
服务端通常需要做如下处理:
- 接收到
Idempotency-Key后,先查询幂等记录; - 如果记录已经成功,直接返回上次保存的结果;
- 如果记录正在处理中,根据接口约定返回处理中状态或短暂等待;
- 如果没有记录,以幂等键建立唯一约束,再执行实际业务;
- 将最终状态和响应结果一起保存。
数据库层最好为幂等键建立唯一索引,不能只依赖应用层的“先查询再插入”。因为两个并发请求可能同时通过查询,最终都开始执行。
还要注意幂等键的作用范围。通常应把租户、用户或业务类型纳入唯一约束,避免不同业务误用同一个键。幂等记录也不能无限期保存,需要根据业务补偿窗口设置过期策略;但对于支付、退款等不可逆操作,保留时间应足够覆盖客户端重试和人工补偿周期。
五、常见坑
1. 所有请求统一重试三次
这会把原本一次流量变成最多三次,服务已经过载时反而进一步恶化故障。重试应该设置总时间预算、最大次数和退避等待,并且在系统过载时通过熔断或限流停止重试。
2. POST 请求直接重试
POST 是否安全不能只看 HTTP 方法名,而要看业务语义。创建订单、扣款、发货这类操作如果没有服务端幂等能力,客户端不能因为超时就直接再次提交。
3. 在统一异常处理器里重试
统一异常处理器通常不知道请求是否已经产生业务副作用,也不一定知道原始超时原因。重试策略应该靠近具体的客户端调用层,并由接口契约决定。
4. 忽略线程池和连接池
超时设置正确,也可能因为调用线程池耗尽导致请求排队。同步 HTTP 调用尤其要关注业务线程池大小、连接复用、目标服务并发上限和队列长度。一个服务的超时配置还必须纳入整体调用链预算:如果上游只给当前服务 1 秒,那么当前服务不应配置 2 秒的下游超时。
5. 只记录异常,不记录请求上下文
排查超时至少需要记录目标服务、接口、请求 ID、幂等键、尝试次数、耗时、状态码和最终结果。敏感请求体不能直接写入日志,应记录经过脱敏的业务标识。
六、实践建议
可以把一套可靠的 HTTP 调用策略归纳为以下原则:
- 为连接建立和请求完成设置明确超时;
- 只对明确的临时性错误重试;
- 使用指数退避并加入随机抖动,避免多个实例同时重试;
- 写操作必须设计幂等键、唯一约束或状态查询接口;
- 对“结果未知”单独建模,不要直接当成失败;
- 设置最大尝试次数和总耗时预算;
- 在重试之外配合限流、熔断、舱壁隔离和降级;
- 用指标观察超时率、重试率、最终失败率和幂等命中率。
总结
HTTP 调用的可靠性,核心不是让每个请求都成功,而是在失败发生时仍然保持业务状态可控。连接超时解决的是“多久不建立连接”,请求超时解决的是“多久不再等待”;重试解决的是部分临时故障,幂等解决的是“重复执行会不会造成副作用”。
尤其要牢记:超时之后,结果可能未知。 当接口涉及创建、扣款、库存、发货等写操作时,必须先设计服务端幂等和状态查询,再讨论客户端重试。只有把超时、重试、幂等、限流和观测放在同一个调用链里考虑,微服务之间的 HTTP 通信才不会在故障时从“偶尔失败”变成“重复执行和雪崩”。