《Java 网络编程基础》14.2 超时、重试与幂等边界

连接超时、读超时和请求期限常被混成一个数字,重试又会把一次失败放大成多倍流量。本节用 linkd 的 RetryPolicy 区分失败阶段、计算带抖动的退避、约束总期限,并在 libraryd 上游调用中给出幂等键复用与只对安全操作重试的判断表。

本节目标:把「等多久」拆成连接超时、读超时和请求期限三个量,用带抖动的退避控制重试节奏,并用幂等键和安全方法判断哪些失败可以重试。
适用版本:JDK 25(Temurin 25.0.5+8);SocketChannel、Selector、HttpClient、RandomGenerator。

承接的问题

14.1 让本端在写不出去时停止读取,压力传回上游。上游收到压力后要做一个决定:继续等,还是放弃这次调用?这个决定依赖超时;如果放弃后还想再试一次,就进入重试。

linkd 调用 libraryd 的 GET /books/{id} 时,一次失败可能发生在三个完全不同的阶段:TCP 三次握手没完成、请求发出去了但响应没回来、响应回来了但状态码是 503。这三个阶段的「已经发生了什么」完全不同,能不能重试也完全不同。把它们都塞进一个 catch (Exception e) { retry(); },最常见的结果是:libraryd 变慢时,linkd 的重试把它的 QPS 抬高两三倍,本来只是慢,最后变成不可用。

另一个问题是超时数字被当成装饰。connectTimeout=500ms、readTimeout=1000ms、最多重试 3 次,直觉上「最坏 4.5 秒」,实际可能远超:每次重试都重新建立连接、重新计读超时,再加上退避和排队,总时长没有上界。超时不是三个独立参数,而是一个总预算加两个阶段上限。

本节先把三种超时的覆盖范围分清,再算重试放大,最后用 RetryPolicy 把「能不能重试」变成一张可检查的表。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。

核心机制一:连接超时、读超时与请求期限

三个量的覆盖范围不同:

名称覆盖范围Java API常见误用
连接超时三次握手完成之前Socket.connect(addr, ms)、HttpClient.connectTimeout以为它能限制 DNS 解析
读超时连接建立后等待数据阻塞 Socket.setSoTimeout(ms);非阻塞用 Selector.select(ms)在 SocketChannel 上设 SO_TIMEOUT 以为生效
请求期限整个逻辑操作,含排队、重试、退避自己用 System.nanoTime() 记账每次重试把期限重置

最容易被忽略的是第三行。前两个是单次系统调用的上限,第三个是业务操作的上限。有了重试之后,只有请求期限能给出「这次调用最多占用多久」的答案。

先看阻塞 API:

Socket socket = new Socket();
socket.connect(new InetSocketAddress("127.0.0.1", 8080), 500); // 连接超时
socket.setSoTimeout(1000);                                      // 读超时

connect 的 500 ms 只覆盖握手。InetSocketAddress 如果传的是域名,解析发生在构造对象时,connect 的超时管不到它;解析卡住时,调用线程会停在 DNS 查询里。setSoTimeout 只对阻塞 InputStream.read 生效,超时抛 SocketTimeoutException,连接本身仍然可用。

非阻塞通道没有等价的读超时:

SocketChannel ch = SocketChannel.open();
ch.configureBlocking(false);
ch.connect(new InetSocketAddress("127.0.0.1", 8080));

long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(500);
try (Selector selector = Selector.open()) {
    ch.register(selector, SelectionKey.OP_CONNECT);
    while (!ch.finishConnect()) {
        long left = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime());
        if (left <= 0) throw new ConnectException("connect timeout");
        selector.select(left);
    }
}

在非阻塞 SocketChannel 上调用 socket().setSoTimeout(1000) 不会抛异常,getSoTimeout() 也能读回 1000,但它对通道的 read 没有任何作用。本机验证过这个行为:设置成功,读操作照旧立刻返回 0。读超时必须由 Selector.select(timeout) 或请求期限来提供。

请求期限的写法只有一种:在操作开始时算一个绝对时间点,之后每次重试都用 deadline - now 求剩余,而不是「再来一轮 timeout」。

long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
// 每次尝试前:remaining = deadline - now,耗尽即失败
// 单次超时 = min(800ms, remaining)

attemptTimeoutMillis 取「单次上限」和「剩余预算」的较小值。这样最后一次尝试不会拿到完整的 800 ms,把总时长顶出期限。

核心机制二:重试放大与退避

重试的第一个代价是流量放大。单层最多 3 次尝试就是 3 倍请求;如果调用链有三层都各自重试,最坏情况是 3 × 3 × 3 = 27 倍。这是算术,不是实测:放大倍数等于每层尝试次数的乘积,和具体机器无关。

放大在有共享依赖时更糟。libraryd 变慢,linkd 的所有连接同时超时、同时重试,第二批请求在第一批还没结束时到达,把队列推得更长。没有抖动的固定退避会让所有客户端在同一时刻醒来,形成同步的重试波。

退避要满足三个条件:

  1. 指数增长:第 n 次重试的基准是 base × 2^(n-1),给下游恢复时间。
  2. 有上限:封顶到 maxDelay,否则第 10 次重试要等几分钟,请求期限早就过了。
  3. 带抖动:在基准附近随机偏移,打散同时重试的客户端。

linkd 用「对称抖动」:在 [base×(1-j), base×(1+j)] 区间取值,j = 0.5。以 base=200ms、max=2000ms 为例:

第几次重试指数基准抖动后范围封顶后
1200 ms100–300 ms100–300 ms
2400 ms200–600 ms200–600 ms
3800 ms400–1200 ms400–1200 ms
41600 ms800–2400 ms800–2000 ms

第 4 行说明封顶和抖动会打架:抖动后可能超过 maxDelay,所以要再 min 一次,代价是分布堆在 2000 ms 这个点上。另一种做法是 full jitter,直接在 [0, exponential] 均匀取值,分散更好,但期望等待更短,对下游的保护弱一些。两种都可以,关键是不要用固定间隔。

还有一条不写在公式里的约束:重试要留出「这次尝试本身」的时间。如果剩余预算 300 ms,退避要 582 ms,重试还没开始期限就到了。RetryPolicy.decide 用「退避延迟必须小于剩余预算」做第一道拦截;更保守的实现会把「退避 + 单次尝试超时」一起和剩余预算比较。

核心机制三:幂等键与只对安全操作重试

重试的第二个代价是重复执行。HTTP 方法按语义分三类:

  • 安全(safe):GET、HEAD、OPTIONS、TRACE,只读,重试不改变服务端状态。
  • 幂等(idempotent):PUT、DELETE,重复执行的结果和一次相同,可以重试。
  • 既不安全也不幂等:POST、PATCH,重复执行可能创建两条订单、扣两次库存。

但「方法幂等」不等于「这次请求可以重试」。真正的判断还要看失败发生在哪个阶段:

失败阶段请求已完整发出无幂等键有幂等键
连接失败、握手超时否可重试可重试
发送中复位(未写完)否可重试可重试
读超时、发送后复位是仅安全/幂等方法可重试
503、500是仅安全/幂等方法可重试
429是/否可重试,但尊重 Retry-After可重试
400、401、403、404是不重试不重试

第一、二行是「请求没到服务端」或「没发完」,服务端不可能执行过,所以任何方法都能重试。第三、四行是「可能已经执行了」,只有幂等键能让服务端识别出重复。

幂等键的规则比生成一个 UUID 多几条:

  • 一次逻辑操作一个键,所有重试复用同一个键。 每次重试重新 UUID.randomUUID() 等于没做幂等。
  • 键要跨越进程重启。 如果客户端会把请求写入本地队列稍后重发,键必须一起持久化,不能只放内存。
  • 服务端要存「键 → 结果」一段时间,窗口至少覆盖客户端的最长重试跨度;重复请求直接返回第一次的结果,而不是再执行一次。
  • 键要按租户或调用方隔离。 不同租户生成相同 UUID 的概率极低,但显式加前缀能让排查和清理都简单。
  • 不要用请求体的哈希当键。 哈希计算有成本,而且两次语义相同、字节不同的请求(字段顺序、空白)会得到不同键,失去去重作用。

linkd v1 的业务接口是只读的 GET /books/{id},重试天然安全;下面的写接口是卷二网关要接的场景,本节标注为示意,不属于 v1 契约。

在 linkd 里落地:RetryPolicy

RetryPolicy 不负责发请求,只回答两个问题:这次失败能不能重试、下次尝试前等多久。它把失败阶段、方法语义、幂等键和剩余预算都收进参数,返回值是一个不可变的 Decision。

package com.plume.linkd.http;

import java.util.Locale;
import java.util.random.RandomGenerator;

public final class RetryPolicy {
    public enum Failure {
        CONNECT_FAILED,
        RESET_BEFORE_SEND,
        READ_TIMEOUT,
        RESET_AFTER_SEND,
        SERVER_5XX,
        TOO_MANY_REQUESTS,
        CLIENT_4XX,
        DEADLINE_EXCEEDED
    }
    public record Attempt(int number, String method, boolean idempotencyKey,
                          boolean requestFullySent, Failure failure) {
        public Attempt {
            if (number < 1 || method == null || method.isBlank() || failure == null) {
                throw new IllegalArgumentException("invalid attempt");
            }
        }
    }
    public record Decision(boolean retry, long delayMillis, String reason) {
        public static Decision stop(String reason) {
            return new Decision(false, 0, reason);
        }
    }
    private final int maxAttempts;
    private final long baseDelayMillis;
    private final long maxDelayMillis;
    private final double jitterRatio;
    private final RandomGenerator random;

    public RetryPolicy(int maxAttempts, long baseDelayMillis, long maxDelayMillis,
                       double jitterRatio, RandomGenerator random) {
        if (maxAttempts < 1) {
            throw new IllegalArgumentException("maxAttempts must be >= 1");
        }
        if (baseDelayMillis <= 0 || maxDelayMillis < baseDelayMillis) {
            throw new IllegalArgumentException("expect 0 < base <= max");
        }
        if (jitterRatio < 0 || jitterRatio > 1) {
            throw new IllegalArgumentException("jitterRatio must be within [0, 1]");
        }
        this.maxAttempts = maxAttempts;
        this.baseDelayMillis = baseDelayMillis;
        this.maxDelayMillis = maxDelayMillis;
        this.jitterRatio = jitterRatio;
        this.random = random;
    }

    public Decision decide(Attempt attempt, long remainingMillis) {
        if (attempt.failure() == Failure.DEADLINE_EXCEEDED) {
            return Decision.stop("deadline exceeded");
        }
        if (!retryable(attempt)) {
            return Decision.stop("not retryable: " + attempt.failure());
        }
        if (attempt.number() >= maxAttempts) {
            return Decision.stop("attempts exhausted");
        }
        long delay = backoffMillis(attempt.number());
        if (delay >= remainingMillis) {
            return Decision.stop("delay " + delay + "ms >= remaining " + remainingMillis + "ms");
        }
        return new Decision(true, delay, "retry " + attempt.failure());
    }

    public long attemptTimeoutMillis(long perAttemptMillis, long remainingMillis) {
        if (perAttemptMillis <= 0 || remainingMillis <= 0) {
            return 0;
        }
        return Math.min(perAttemptMillis, remainingMillis);
    }

    private boolean retryable(Attempt attempt) {
        return switch (attempt.failure()) {
            case CONNECT_FAILED, RESET_BEFORE_SEND -> true;
            case READ_TIMEOUT, RESET_AFTER_SEND, SERVER_5XX ->
                    !attempt.requestFullySent()
                            || attempt.idempotencyKey()
                            || isIdempotent(attempt.method());
            case TOO_MANY_REQUESTS -> true;
            case CLIENT_4XX, DEADLINE_EXCEEDED -> false;
        };
    }

    private static boolean isIdempotent(String method) {
        return switch (method.toUpperCase(Locale.ROOT)) {
            case "GET", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE" -> true;
            default -> false;
        };
    }

    private long backoffMillis(int attemptNumber) {
        long exponential = baseDelayMillis;
        for (int i = 1; i < attemptNumber && exponential < maxDelayMillis; i++) {
            exponential = exponential > maxDelayMillis / 2
                    ? maxDelayMillis
                    : exponential * 2;
        }
        long spread = (long) (exponential * jitterRatio);
        long jittered = exponential - spread + random.nextLong(2 * spread + 1);
        return Math.min(maxDelayMillis, Math.max(0, jittered));
    }
}

几个判断点:

  • requestFullySent 区分「可能执行」和「肯定没执行」。 同样是 RESET,发送前复位可以直接重试 POST,发送后复位不行。
  • TOO_MANY_REQUESTS 无条件可重试。 429 的语义是「服务端拒绝了这次请求」,没有执行副作用;但仍要受 maxAttempts 和预算约束,并且优先尊重 Retry-After。408、425 也属于可重试的 4xx,需要单独归类。
  • CLIENT_4XX 与 DEADLINE_EXCEEDED 直接停。 4xx 重试不会变好;期限已到,任何延迟都没有意义。
  • RandomGenerator 注入而不是用 Math.random()。 测试里传固定种子就能复现延迟序列,生产里用默认实现;RandomGenerator 是 JDK 17 起的接口,不需要第三方库。
  • 退避封顶在 maxDelayMillis,包括抖动之后。 否则 1600 × 1.5 = 2400 会突破上限。

用固定种子 L64X128MixRandom(42)、maxAttempts=4、base=200、max=2000、抖动 0.5 跑一遍本机输出:

GET connect failed #1 -> retry=true delay=250 reason=retry CONNECT_FAILED
POST read timeout #2 no key -> retry=false delay=0 reason=not retryable: READ_TIMEOUT
POST read timeout #2 with key -> retry=true delay=442 reason=retry READ_TIMEOUT
GET 500 #4 -> retry=false delay=0 reason=attempts exhausted
GET 429 #2 remaining 100 -> retry=false delay=0 reason=delay 582ms >= remaining 100ms
attemptTimeout(800, 300) = 300
attemptTimeout(800, 2000) = 800
  • 连接失败没有发出任何字节,GET 第一次重试直接放行,退避 250 ms 落在 100–300 ms 区间。
  • 同样是读超时,POST 没有幂等键就停;带键的 POST 放行,退避 442 ms 落在 200–600 ms 区间。
  • 第 4 次尝试已经达到 maxAttempts=4,即使方法是 GET 也不再重试。
  • 429 本轮退避 582 ms,但剩余预算只有 100 ms,decide 直接返回不重试,避免「等完退避再超时」。
  • 单次超时取 800 与剩余的较小值:剩余 300 时给 300,剩余 2000 时给 800。

接到 libraryd 的调用上,client 用 HttpClient.newBuilder().connectTimeout(500ms).build() 构建,期限、单次超时和重试决策在同一个循环里:

long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
String idempotencyKey = UUID.randomUUID().toString(); // 写接口才需要,重试复用
for (int attempt = 1; ; attempt++) {
    long remaining = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime());
    if (remaining <= 0) {
        throw new IOException("request deadline exceeded");
    }
    HttpRequest request = HttpRequest.newBuilder(
                    URI.create("http://127.0.0.1:8080/books/" + bookId))
            .timeout(Duration.ofMillis(policy.attemptTimeoutMillis(800, remaining)))
            .header("Idempotency-Key", idempotencyKey)
            .GET()
            .build();
    RetryPolicy.Decision decision;
    try {
        HttpResponse<String> response = client.send(request,
                HttpResponse.BodyHandlers.ofString());
        int status = response.statusCode();
        if (status < 500 && status != 429) {
            return response;
        }
        RetryPolicy.Failure failure = status == 429
                ? RetryPolicy.Failure.TOO_MANY_REQUESTS
                : RetryPolicy.Failure.SERVER_5XX;
        decision = policy.decide(new RetryPolicy.Attempt(
                attempt, "GET", true, true, failure), remaining);
    } catch (HttpConnectTimeoutException e) {
        decision = policy.decide(new RetryPolicy.Attempt(attempt, "GET", true, false,
                RetryPolicy.Failure.CONNECT_FAILED), remaining);
    } catch (HttpTimeoutException e) {
        decision = policy.decide(new RetryPolicy.Attempt(attempt, "GET", true, true,
                RetryPolicy.Failure.READ_TIMEOUT), remaining);
    } catch (IOException e) {
        decision = policy.decide(new RetryPolicy.Attempt(attempt, "GET", true, true,
                RetryPolicy.Failure.RESET_AFTER_SEND), remaining);
    }
    if (!decision.retry()) {
        throw new IOException("libraryd failed: " + decision.reason());
    }
    Thread.sleep(decision.delayMillis());
}

HttpConnectTimeoutException 是 HttpTimeoutException 的子类,catch 顺序必须连接超时在前,否则永远进不了更具体的分支。HttpClient.connectTimeout 是连接建立的上限,连接池复用已有连接时这一项不参与;每次请求真正生效的是 HttpRequest.timeout 与我们的 remaining。

常见错误与边界

错误一:catch (Exception e) 之后无脑重试。

// 反例:400 和 401 也重试,幂等键也没有
for (int i = 0; i < 3; i++) {
    try {
        return client.send(request, HttpResponse.BodyHandlers.ofString());
    } catch (Exception e) {
        // 继续
    }
}

400 是请求本身错了,重试三次只会得到三个 400;POST 在读超时后重试可能创建两条记录。重试必须按失败阶段和方法语义分流。

错误二:每次重试生成新的幂等键。

// 反例:服务端看到三个不同的键,去重完全失效
request.header("Idempotency-Key", UUID.randomUUID().toString());

键在逻辑操作开始时生成一次,所有尝试复用。键换了,服务端只会认为是三次独立的创建请求。

边界:重试预算要全局看。 单请求 maxAttempts=4 在正常情况下没问题;下游整体故障时,所有请求同时重试会把放大倍数变成真实流量。卷三会讲重试预算和熔断,本节先把「只对安全操作、只对有键的写操作重试」这条底线守住。

小结

  • 连接超时覆盖握手,读超时覆盖单次等待,请求期限覆盖包含重试的整个操作;只有期限能给出总时长上界。
  • SocketChannel 的 SO_TIMEOUT 设置成功但不生效,非阻塞读的等待要靠 Selector 和期限。
  • 重试放大是每层尝试次数的乘积,3 层各 3 次就是 27 倍;退避要指数增长、封顶、带抖动。
  • 请求没发出时可以重试任何方法;请求可能已执行时,只重试安全/幂等方法或带幂等键的写操作。
  • 幂等键一次操作生成一次、所有重试复用、服务端按窗口去重;换键等于没做幂等。
  • RetryPolicy 的本机输出验证了五种判定:连接失败放行、无键 POST 停止、有键 POST 放行、尝试次数耗尽、预算不足停止。

超时和重试决定了「什么时候放弃一次调用」,但进程本身要退出时,还有另一组时间预算:已经接受的连接怎么收尾。下一节处理停止 accept、排空写队列和关闭期限。

阅读导航:上一节:14.1 读写水位与背压信号 · 下一节:14.3 优雅关闭与连接排空 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发