本节目标:让重试只发生在「重试确实可能成功」的地方,并且每一次等待都从同一个总 deadline 里扣,而不是把故障放大成流量风暴。
适用版本:JDK 25 + gRPC-Java 1.76.0(grpc-netty-shaded),不引入外部重试库。
承接的问题
6.3 把连接的生命周期收尾做完了:drain 期间不再接新请求,等在途请求结束再关闭。连接层面干净了,调用层面还有一件更常见的事没处理——下游偶发失败。libraryd 滚动重启时,linkd-v3 会在一两秒内连续拿到 UNAVAILABLE;数据库瞬时锁冲突时,GetBook 可能返回 ABORTED。一次失败就把错误抛给用户,可用性会被几秒钟的抖动吃掉;不加区分地重试,又会把一次超时变成三次、九次甚至更多次下游请求。
3.3 回答的是「什么请求可以重试」。7.1 回答工程实现:重试码怎么分类、退避怎么算、总 deadline 怎么扣、全局预算怎么设。四件事缺一件,重试就会从保护机制变成故障放大器。先看一个典型错误写法:
// 反例:不分类、不扣预算、不保留幂等键
for (int i = 0; i < 3; i++) {
try {
return stub.getBook(request);
} catch (StatusRuntimeException e) {
sleep(100);
}
}
它会把 NOT_FOUND、PERMISSION_DENIED 也重试三遍;退避固定 100 ms,不看总 deadline;三次调用各自生成新的幂等键,服务端会当成三个不同的写请求。
核心机制一:先把可重试的错误圈出来
判断依据不是「错误看起来严重不严重」,而是「同样的请求再发一次,是否可能得到不同结果」。
| 状态码 | 默认重试 | 理由 |
|---|---|---|
UNAVAILABLE | 是 | 连接被拒、进程重启、临时过载,换时间点可能成功 |
DEADLINE_EXCEEDED | 有条件 | 单跳超时可以换实例再试,但总 deadline 必须还有余量 |
RESOURCE_EXHAUSTED | 有条件 | 只有服务端附带 RetryInfo 时才重试,否则是明确的配额拒绝 |
ABORTED | 否 | 常见于库存冲突等业务并发,重试会重复冲突 |
NOT_FOUND / INVALID_ARGUMENT | 否 | 请求本身有问题,重发不会改变结果 |
PERMISSION_DENIED / UNAUTHENTICATED | 否 | 凭证或权限问题,重试只增加审计噪声 |
UNIMPLEMENTED / INTERNAL | 否 | 契约或代码缺陷,重试会掩盖问题 |
ABORTED 最容易误判。STOCK_CONFLICT 在项目契约里映射到 ABORTED,它表示「库存版本冲突,请重新读取后再提交」,需要业务层重新决策,不是传输层重试能解决的。把它放进可重试集合,会在高并发下制造一串注定失败的请求。
RESOURCE_EXHAUSTED 也要分两种:RATE_LIMITED 是明确的超限,若带 RetryInfo,可以按服务端给的时间重试;队列满、内存保护触发则没有时间提示,重试只会继续撞墙。判断 RetryInfo 是否存在,比判断状态码更可靠。
package com.plume.linkd.v3.core;
import com.google.protobuf.Any;
import com.google.protobuf.InvalidProtocolBufferException;
import com.google.rpc.RetryInfo;
import io.grpc.Status;
import io.grpc.protobuf.StatusProto;
import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;
public record RetryPolicy(
int maxAttempts, Duration baseDelay, Duration maxDelay, double jitter) {
public static final RetryPolicy DEFAULT = new RetryPolicy(
3, Duration.ofMillis(20), Duration.ofMillis(500), 0.20);
public RetryPolicy {
if (maxAttempts < 1 || maxAttempts > 5) {
throw new IllegalArgumentException("maxAttempts 必须在 1..5");
}
if (baseDelay.isNegative() || maxDelay.compareTo(baseDelay) < 0) {
throw new IllegalArgumentException("退避区间非法");
}
if (jitter < 0 || jitter > 1) {
throw new IllegalArgumentException("jitter 必须在 0..1");
}
}
public boolean retryable(Throwable failure, int nextAttempt) {
if (nextAttempt > maxAttempts) {
return false;
}
Status status = Status.fromThrowable(failure);
return switch (status.getCode()) {
case UNAVAILABLE, DEADLINE_EXCEEDED -> true;
case RESOURCE_EXHAUSTED -> detail(failure, RetryInfo.class) != null;
default -> false;
};
}
public Duration delayBefore(int retryIndex) {
long base = baseDelay.toNanos();
long cap = maxDelay.toNanos();
int shift = Math.min(retryIndex - 1, 20);
long raw = base > (Long.MAX_VALUE >> shift) ? cap : Math.min(base << shift, cap);
double factor = 1.0 + ThreadLocalRandom.current().nextDouble(-jitter, jitter);
return Duration.ofNanos(Math.max(0L, (long) (raw * factor)));
}
static <T extends com.google.protobuf.Message> T detail(
Throwable failure, Class<T> type) {
com.google.rpc.Status status = StatusProto.fromThrowable(failure);
if (status == null) {
return null;
}
for (Any any : status.getDetailsList()) {
if (!any.is(type)) {
continue;
}
try {
return any.unpack(type);
} catch (InvalidProtocolBufferException e) {
return null;
}
}
return null;
}
}
maxAttempts 表示总尝试次数,默认 3,也就是最多重试 2 次。循环边界写成 attempt <= maxAttempts 比 maxRetries 更不容易差一。delayBefore 的 retryIndex 从 1 开始:第一次重试等 base * 2^0,第二次等 base * 2^1。抖动取 [-0.2, +0.2],20 ms 的基础退避实际落在 16–24 ms。抖动不能省:大量客户端同时失败时,确定性退避会让它们在同一毫秒再次涌向下游。
核心机制二:总 deadline 是唯一的预算账户
重试最容易出的问题是「每次尝试都拿一个新的 800 ms」。三次尝试就是 2.4 秒,用户早已超时离开,服务端还在替他重试。正确做法是调用入口创建一个 Deadline,所有尝试共享它,每次只拿剩余时间。
total = 800 ms
attempt 1: deadline = total
fail at 120 ms -> remaining 680 ms
backoff 20 ms -> remaining 660 ms
attempt 2: deadline = remaining 660 ms
fail at 300 ms -> remaining 360 ms
backoff 40 ms -> remaining 320 ms
attempt 3: deadline = remaining 320 ms
这个账户有两个扣减点:RPC 本身消耗的时间,以及退避睡眠消耗的时间。退避前必须检查「剩余时间是否还够一次有意义的调用」。如果只剩 5 ms,睡 40 ms 再发请求,请求一定在发出前过期,这次重试没有价值,应该直接返回 DEADLINE_EXCEEDED。实现上把 remaining <= backoff + 20 ms 作为停止条件,20 ms 是留给建流、编码和首字节响应的最低预算;低于它,重试只会产生一个必然失败的 RPC。Deadline.timeRemaining 底层用单调时钟;不要用 currentTimeMillis 自己算截止时刻,NTP 回拨会让剩余时间跳变。CallOptions.withDeadline(deadline) 把同一个绝对时刻写进每次 ClientCall。
核心机制三:重试预算与放大倍数
只限制单次调用的尝试次数,不足以保护下游。假设 linkd-v3 有 100 个客户端实例,每个实例在故障时都重试 2 次,下游瞬时看到的流量是正常值的 3 倍。如果下游自己也重试,放大是乘法关系。
| 链路形态 | 一次用户请求产生的下游调用 | 说明 |
|---|---|---|
| 单层、3 次尝试 | 3 | 1 + 1 + 1,最朴素的上界 |
| 两层、每层 3 次尝试 | 9 | 3 × 3,网关重试 × 服务重试 |
| 三层、每层 3 次尝试 | 27 | 3 × 3 × 3 |
| 三层、每层按 2 的幂扇出 | 1 + 2 + 4 = 7 | 每层失败后把整组调用重新扇出一遍的模型 |
最后一行就是常说的「1+2+4」。它不是 gRPC 单层重试的固有行为,而是多层重试叠加、且每层失败后重新扇出整组副本时的最坏上界。把这张表给业务方看,比说「我们只重试两次」更能让人理解为什么要设预算。
预算有两种:deadline 预算控制「一次调用最多花多久」;全局重试预算控制「整个进程允许多少比例的重试流量」。后者的典型做法是令牌账户:每个成功请求存入少量令牌,每次重试消耗一个,账户空了就停止重试。
package com.plume.linkd.v3.core;
import java.util.concurrent.atomic.AtomicLong;
public final class RetryBudget {
private final AtomicLong tokens;
private final long maxTokens;
private final long depositPerSuccess;
public RetryBudget(long maxTokens, long depositPerSuccess) {
if (maxTokens <= 0 || depositPerSuccess <= 0) {
throw new IllegalArgumentException("预算参数必须为正");
}
this.maxTokens = maxTokens;
this.depositPerSuccess = depositPerSuccess;
this.tokens = new AtomicLong(maxTokens / 2);
}
public boolean tryWithdraw() {
while (true) {
long current = tokens.get();
if (current <= 0) {
return false;
}
if (tokens.compareAndSet(current, current - 1)) {
return true;
}
}
}
public void deposit() {
while (true) {
long current = tokens.get();
long next = Math.min(maxTokens, current + depositPerSuccess);
if (next == current || tokens.compareAndSet(current, next)) {
return;
}
}
}
}
linkd-v3 的基线是每 10 次成功存入 1 个令牌,账户上限按 200 rps × 10 秒估算为 200。稳态下重试流量不超过总流量的 10%,突发时可以短时间借用之前的结余。参数不是实测最优,而是「先给一个能解释的上限」;上线后用 retry_budget_available 指标观察账户是否长期见底。
在 linkd-v3 里落地:RetryingLibraryClient
把四件事装进一个客户端方法:共享 deadline、分类重试、退避扣预算、幂等键不变。这里直接使用 ClientCall,因为便捷的 blockingUnaryCall 重载不方便在每次尝试上挂不同 metadata。
package com.plume.linkd.v3.core;
import com.plume.linkd.v3.api.Book;
import com.plume.linkd.v3.api.GetBookRequest;
import com.plume.linkd.v3.api.LibraryGrpc;
import io.grpc.CallOptions;
import io.grpc.Channel;
import io.grpc.ClientCall;
import io.grpc.Deadline;
import io.grpc.Metadata;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class RetryingLibraryClient {
private static final Metadata.Key<String> IDEMPOTENCY_KEY =
Metadata.Key.of("x-idempotency-key", Metadata.ASCII_STRING_MARSHALLER);
private final Channel channel;
private final RetryPolicy policy;
private final RetryBudget budget;
public RetryingLibraryClient(Channel channel, RetryPolicy policy, RetryBudget budget) {
this.channel = channel;
this.policy = policy;
this.budget = budget;
}
public Book getBook(String isbn, String idempotencyKey, Deadline deadline) {
GetBookRequest request = GetBookRequest.newBuilder().setIsbn(isbn).build();
Throwable last = null;
for (int attempt = 1; attempt <= policy.maxAttempts(); attempt++) {
if (attempt > 1) {
if (!budget.tryWithdraw()) {
throw Status.RESOURCE_EXHAUSTED
.withDescription("retry budget exhausted")
.asRuntimeException();
}
sleepBeforeRetry(policy.delayBefore(attempt - 1), deadline);
}
Metadata headers = new Metadata();
headers.put(IDEMPOTENCY_KEY, idempotencyKey);
try {
Book book = callOnce(request, headers, deadline);
budget.deposit();
return book;
} catch (StatusRuntimeException e) {
last = e;
if (!policy.retryable(e, attempt + 1)) {
throw e;
}
}
}
throw Status.fromThrowable(last).asRuntimeException();
}
private Book callOnce(GetBookRequest request, Metadata headers, Deadline deadline) {
ClientCall<GetBookRequest, Book> call = channel.newCall(
LibraryGrpc.getGetBookMethod(),
CallOptions.DEFAULT.withDeadline(deadline));
CompletableFuture<Book> result = new CompletableFuture<>();
call.start(new ClientCall.Listener<>() {
@Override
public void onMessage(Book message) {
result.complete(message);
}
@Override
public void onClose(Status status, Metadata trailers) {
if (status.isOk()) {
result.completeExceptionally(Status.INTERNAL
.withDescription("stream closed without a message")
.asRuntimeException(trailers));
} else {
result.completeExceptionally(status.asRuntimeException(trailers));
}
}
}, headers);
call.request(1);
call.sendMessage(request);
call.halfClose();
try {
return result.get(deadline.timeRemaining(TimeUnit.NANOSECONDS),
TimeUnit.NANOSECONDS);
} catch (TimeoutException e) {
call.cancel("deadline exceeded", e);
throw Status.DEADLINE_EXCEEDED.withCause(e).asRuntimeException();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
call.cancel("interrupted", e);
throw Status.CANCELLED.withCause(e).asRuntimeException();
} catch (ExecutionException e) {
throw Status.fromThrowable(e.getCause()).asRuntimeException();
}
}
private void sleepBeforeRetry(Duration backoff, Deadline deadline) {
long remaining = deadline.timeRemaining(TimeUnit.NANOSECONDS);
if (remaining <= backoff.toNanos() + 20_000_000L) {
throw Status.DEADLINE_EXCEEDED
.withDescription("no budget left for another attempt")
.asRuntimeException();
}
try {
TimeUnit.NANOSECONDS.sleep(backoff.toNanos());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw Status.CANCELLED.withCause(e).asRuntimeException();
}
}
}
idempotencyKey 由调用方在进入重试循环之前生成,整个循环复用同一个值。这一点比退避算法更重要:服务端用 x-idempotency-key 去重,键变了,三次尝试就是三个独立请求,库存可能被扣三次。读请求也要带键,因为服务端可能把读请求与写请求放进同一张去重表;键不变,重试才是安全的。
budget.deposit() 放在成功返回前,失败不存入。账户的语义是「成功流量为失败流量买单」:下游整体故障时成功数下降,重试预算自然收紧,重试次数随之减少,形成负反馈。
常见错误与边界
错误一:把 ABORTED 当可重试码。 STOCK_CONFLICT 映射到 ABORTED,它要求业务重新读取库存版本,不是换一次网络就能成功。重试它只会让冲突次数乘以尝试次数。
错误二:每次尝试重新生成幂等键。 代码能跑、日志也正常,只有对账时才发现重复扣减。键必须在重试循环外生成,并随 metadata 传给每一次 ClientCall。
错误三:退避睡眠不看 deadline。 Thread.sleep(500) 之后 deadline 已经过期,下一次 callOnce 会在 result.get(0) 上立刻超时。先算剩余时间,再决定睡不睡。
错误四:抖动写成固定值。 把 jitter 实现成「每次加 20%」不是抖动,是确定性偏移,所有客户端仍然会在同一时刻重试。抖动必须是每次独立采样、围绕 0 对称的随机量。
错误五:重试不区分实例。 如果 libraryd 有两个实例,重试应该尽量打到另一个实例。这个能力属于 5.3 的负载均衡策略;客户端至少要在 CallOptions 或 metadata 上留下「本次已尝试」的标记。
边界:重试不能修复慢。 下游每次都要 600 ms,而单跳 deadline 是 300 ms,三次尝试只会产生三次超时。重试适合「快速失败」,不适合「慢失败」;慢失败要靠超时分层、熔断和容量规划。
边界:重试预算不是全局精确配额。 RetryBudget 是进程内近似账户,多实例之间不共享。流量分布不均时,某个实例可能先耗尽自己的账户。要精确全局配额需要外部计数器,代价是每次请求一次远程调用,通常不值得。
边界:DEADLINE_EXCEEDED 的重试可能重复执行。 超时只说明客户端没等到响应,不说明服务端没执行。写请求的重试安全性完全依赖幂等键和服务端去重;没有去重表时,只能对读请求重试。
小结
- 可重试码只包括
UNAVAILABLE、有剩余预算的DEADLINE_EXCEEDED,以及带RetryInfo的RESOURCE_EXHAUSTED;ABORTED、NOT_FOUND、权限类错误不重试。 - 退避按
base * 2^(n-1)增长并封顶,抖动取[-20%, +20%]的独立随机量,避免同步重试脉冲。 - 总 deadline 是所有尝试共享的账户,RPC 耗时和退避睡眠都从里面扣;剩余时间不够一次有意义的调用时直接失败。
- 幂等键在重试循环外生成,循环内保持不变;这是写请求重试安全的前提。
- 三层重试叠加的最坏放大可以是 27 倍,按 2 的幂扇出时是 1+2+4=7 倍;重试预算把重试流量压到成功流量的一定比例。
RetryBudget用成功存入、重试消耗的令牌账户实现负反馈;参数先可解释,再用指标校准。
重试解决的是「这次失败可能下次成功」,它默认下游还有救。如果下游已经连续失败,继续重试只会延长故障。7.2 用滑动窗口和状态机判断「还要不要试」,把重试挡在熔断器后面。
阅读导航:上一节 6.3 连接生命周期与 drain | 下一节 7.2 熔断状态机
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。