本节目标:把「等多久」拆成连接超时、读超时和请求期限三个量,用带抖动的退避控制重试节奏,并用幂等键和安全方法判断哪些失败可以重试。
适用版本: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 的所有连接同时超时、同时重试,第二批请求在第一批还没结束时到达,把队列推得更长。没有抖动的固定退避会让所有客户端在同一时刻醒来,形成同步的重试波。
退避要满足三个条件:
- 指数增长:第 n 次重试的基准是
base × 2^(n-1),给下游恢复时间。 - 有上限:封顶到
maxDelay,否则第 10 次重试要等几分钟,请求期限早就过了。 - 带抖动:在基准附近随机偏移,打散同时重试的客户端。
linkd 用「对称抖动」:在 [base×(1-j), base×(1+j)] 区间取值,j = 0.5。以 base=200ms、max=2000ms 为例:
| 第几次重试 | 指数基准 | 抖动后范围 | 封顶后 |
|---|---|---|---|
| 1 | 200 ms | 100–300 ms | 100–300 ms |
| 2 | 400 ms | 200–600 ms | 200–600 ms |
| 3 | 800 ms | 400–1200 ms | 400–1200 ms |
| 4 | 1600 ms | 800–2400 ms | 800–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 优雅关闭与连接排空 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。