一次 HTTP 调用失败,并不等于对方没有执行。本文从 Java HttpClient 出发,梳理连接超时、请求超时、重试边界和幂等键的关系,并给出一个可落地的微服务调用实现。

问题背景:HTTP 失败不代表业务没有发生

在微服务系统中,一个服务调用另一个服务,表面上只是发送一次 HTTP 请求,实际却跨越了 DNS、TCP 连接、连接池、负载均衡、网关、服务线程池、数据库等多个环节。任何一个环节变慢,都可能让调用方看到超时或连接异常。

真正棘手的地方在于:调用方认为失败,不代表被调用方一定没有执行。

例如,订单服务调用支付服务创建支付单:

  1. 支付服务已经写入数据库;
  2. 响应已经生成,但还没有返回到订单服务;
  3. 中间网络断开,订单服务收到 SocketTimeoutException
  4. 订单服务进行重试;
  5. 支付服务再次创建一笔支付单。

如果没有幂等控制,重试就可能把一次业务请求变成两次扣款、两张订单或两条重复消息。因此,微服务 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);
        }
    }
}

这段代码有几个关键点。

首先,connectTimeoutHttpRequest.timeout 不是一回事。前者控制建立连接的时间,后者限制这一次请求等待完成的时间。其次,重试次数是明确的,示例中通过构造参数传入,不能让失败请求无限放大。最后,写请求每次重试都使用同一个 Idempotency-Key,而不是每次重新生成一个键。

示例中的 JSON 拼接只用于展示调用流程。真实项目中应使用 Jackson 等 JSON 库序列化请求对象,以避免特殊字符、数字格式和字段转义问题。

三、哪些情况可以重试

重试不是异常捕获后的默认动作,而是一种有条件的流量放大行为。通常可以从三个维度判断。

网络异常

IOException、连接被关闭、连接重置等异常说明调用没有得到正常响应,但无法直接证明服务端没有处理。对于幂等查询可以重试;对于写操作则必须结合幂等键或业务状态查询。

HTTP 状态码

408 Request Timeout429 Too Many Requests502 Bad Gateway503 Service Unavailable504 Gateway Timeout 往往具有临时性,可以在有限次数内重试。

400401403404 等通常是请求本身或权限问题,继续重试没有意义。500 也不能简单地全部重试,因为它可能是确定性的业务错误,也可能已经完成了部分写入。是否重试需要结合接口契约和服务端错误分类。

对于 429,如果响应包含 Retry-After,应优先遵守服务端给出的等待时间,而不是机械地使用本地退避策略。

业务结果

HTTP 状态码为 200,也不代表业务一定成功。很多接口会返回业务码、状态字段或异步任务编号。调用方必须解析业务响应,并明确区分“成功”“确定失败”和“结果未知”。

四、幂等键必须由业务请求贯穿始终

幂等键不是一个普通的随机请求 ID。它应该代表一次业务意图,并在重试、网关转发、服务内部调用过程中保持不变。

服务端通常需要做如下处理:

  1. 接收到 Idempotency-Key 后,先查询幂等记录;
  2. 如果记录已经成功,直接返回上次保存的结果;
  3. 如果记录正在处理中,根据接口约定返回处理中状态或短暂等待;
  4. 如果没有记录,以幂等键建立唯一约束,再执行实际业务;
  5. 将最终状态和响应结果一起保存。

数据库层最好为幂等键建立唯一索引,不能只依赖应用层的“先查询再插入”。因为两个并发请求可能同时通过查询,最终都开始执行。

还要注意幂等键的作用范围。通常应把租户、用户或业务类型纳入唯一约束,避免不同业务误用同一个键。幂等记录也不能无限期保存,需要根据业务补偿窗口设置过期策略;但对于支付、退款等不可逆操作,保留时间应足够覆盖客户端重试和人工补偿周期。

五、常见坑

1. 所有请求统一重试三次

这会把原本一次流量变成最多三次,服务已经过载时反而进一步恶化故障。重试应该设置总时间预算、最大次数和退避等待,并且在系统过载时通过熔断或限流停止重试。

2. POST 请求直接重试

POST 是否安全不能只看 HTTP 方法名,而要看业务语义。创建订单、扣款、发货这类操作如果没有服务端幂等能力,客户端不能因为超时就直接再次提交。

3. 在统一异常处理器里重试

统一异常处理器通常不知道请求是否已经产生业务副作用,也不一定知道原始超时原因。重试策略应该靠近具体的客户端调用层,并由接口契约决定。

4. 忽略线程池和连接池

超时设置正确,也可能因为调用线程池耗尽导致请求排队。同步 HTTP 调用尤其要关注业务线程池大小、连接复用、目标服务并发上限和队列长度。一个服务的超时配置还必须纳入整体调用链预算:如果上游只给当前服务 1 秒,那么当前服务不应配置 2 秒的下游超时。

5. 只记录异常,不记录请求上下文

排查超时至少需要记录目标服务、接口、请求 ID、幂等键、尝试次数、耗时、状态码和最终结果。敏感请求体不能直接写入日志,应记录经过脱敏的业务标识。

六、实践建议

可以把一套可靠的 HTTP 调用策略归纳为以下原则:

  • 为连接建立和请求完成设置明确超时;
  • 只对明确的临时性错误重试;
  • 使用指数退避并加入随机抖动,避免多个实例同时重试;
  • 写操作必须设计幂等键、唯一约束或状态查询接口;
  • 对“结果未知”单独建模,不要直接当成失败;
  • 设置最大尝试次数和总耗时预算;
  • 在重试之外配合限流、熔断、舱壁隔离和降级;
  • 用指标观察超时率、重试率、最终失败率和幂等命中率。

总结

HTTP 调用的可靠性,核心不是让每个请求都成功,而是在失败发生时仍然保持业务状态可控。连接超时解决的是“多久不建立连接”,请求超时解决的是“多久不再等待”;重试解决的是部分临时故障,幂等解决的是“重复执行会不会造成副作用”。

尤其要牢记:超时之后,结果可能未知。 当接口涉及创建、扣款、库存、发货等写操作时,必须先设计服务端幂等和状态查询,再讨论客户端重试。只有把超时、重试、幂等、限流和观测放在同一个调用链里考虑,微服务之间的 HTTP 通信才不会在故障时从“偶尔失败”变成“重复执行和雪崩”。

最后修改:2026 年 08 月 10 日
如果觉得我的文章对你有用,请随意赞赏