《Java 服务端通信工程》7.1 重试预算与退避

从连接 drain 之后的下游抖动讲起,把重试拆成可重试码判定、指数退避与 ±20% 抖动、总 deadline 扣减和全局重试预算;给出 RetryPolicy、RetryBudget 与保留幂等键的 ClientCall 实现,并算清三层重试叠加时的放大倍数。

本节目标:让重试只发生在「重试确实可能成功」的地方,并且每一次等待都从同一个总 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 次尝试31 + 1 + 1,最朴素的上界
两层、每层 3 次尝试93 × 3,网关重试 × 服务重试
三层、每层 3 次尝试273 × 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 熔断状态机

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收