承接的问题
3.2 把时间预算算清楚了,但预算耗尽后要不要重试、重试会不会产生重复副作用,还没有回答。很多「重试导致库存扣两次」的事故,根因不是重试本身,而是重试的请求没有幂等标识,服务端也无法判断「这是新请求还是上一次的重发」。
这一节把三件事放在一起:投递语义、幂等键、退避策略。它们必须一起设计,缺一个都不成立:
- 只有重试没有幂等键,是 at-least-once,可能重复执行。
- 只有幂等键没有重试,浪费了容错能力。
- 有重试有幂等键但没有退避,会把下游打得更惨。
三种投递语义
| 语义 | 保证 | 实现方式 | 代价 |
|---|---|---|---|
| at-most-once | 最多一次,可能丢 | 不重试,失败即返回 | 可用性差 |
| at-least-once | 至少一次,可能重复 | 失败重试 | 需要幂等或去重 |
| effectively-once | 业务效果只发生一次 | at-least-once + 幂等键 + 去重 | 存储与复杂度 |
严格意义上的 exactly-once 需要生产者和消费者在同一个事务里提交,跨服务很难做到。工程上追求的是 effectively-once:允许消息重复到达,但业务副作用只发生一次。
GetBook 是只读的,天然幂等,重试没有副作用。CreateOrder、DeductStock 不是,必须靠幂等键把「重复请求」映射到「同一次操作」。
幂等键的设计
幂等键放在 metadata 的 x-idempotency-key,规则如下:
| 问题 | 答案 | 原因 |
|---|---|---|
| 谁生成 | 客户端,在业务操作开始时生成一次 | 服务端生成的话,重试时会拿到新键,去重失效 |
| 用什么值 | UUID 或「业务主键 + 操作类型」的稳定哈希 | 要能跨进程、跨重试保持一致 |
| 作用域 | 租户 + 方法 + 键 | 不同租户可以用相同的键,不能互相干扰 |
| 什么时候带 | 所有非幂等写操作 | 读操作不需要,带了也不会有坏处 |
| 重试时变不变 | 不变 | 变了就等于新请求 |
| 服务端存多久 | 大于最大重试窗口,通常 24 小时 | 太短会让迟到的重试重新执行 |
| 键重复但参数不同 | 返回 FAILED_PRECONDITION | 同一个键不能表达两种操作 |
一个常见的错误是「每次 HTTP 请求生成一个 UUID」。用户点两次按钮,或者网关重试一次,都会生成新键,服务端看到的是三个不同的操作。正确的粒度是「一次业务意图一个键」:下单按钮点击时生成,后续所有重试复用。
键的作用域必须是 租户 + 方法 + 键。只按键去重,两个租户恰好用了同一个 UUID,会互相拿到对方的结果;只按方法去重,同一用户的两个不同订单会被合并。
哪些错误可以重试
重试的前提是「重试可能成功,且不会产生额外副作用」。按 1.2 的原因码分类:
| 原因码 / 状态 | 可重试 | 条件 |
|---|---|---|
UPSTREAM_UNAVAILABLE / UNAVAILABLE | 是 | 有剩余预算,且请求幂等或带幂等键 |
RATE_LIMITED / RESOURCE_EXHAUSTED | 是 | 按 RetryInfo 或本地退避,不要立刻重发 |
DEADLINE_EXCEEDED | 有条件 | 只在还有足够剩余预算时;总预算耗尽则不再重试 |
STOCK_CONFLICT / ABORTED | 有条件 | 重新读取版本后再试,不能原样重发 |
INTERNAL / UNKNOWN | 否 | 除非能证明是瞬时故障且操作幂等 |
INVALID_ARGUMENT、NOT_FOUND、PERMISSION_DENIED、UNAUTHENTICATED | 否 | 重试结果一样 |
FAILED_PRECONDITION、UNIMPLEMENTED | 否 | 状态或版本问题,重试不会变 |
DATA_LOSS | 否 | 数据损坏,重试可能扩大问题 |
「连接在发送后断开」是一个特殊场景:客户端不知道服务端有没有执行。这时不能假设失败,也不能假设成功;唯一安全的做法是带同一个幂等键重试,让服务端去重。
退避:指数、上限与抖动
固定间隔重试会让所有失败客户端在同一时刻再次打向下游,形成「重试同步」。退避要同时具备三个要素:
- 指数增长。 第 1 次等 20 ms,第 2 次等 40 ms,给下游恢复时间。
- 上限。 等待不超过
maxDelay,否则尾延迟不可控。 - 抖动。 在计算值上乘
1 ± 20%,把同时失败的请求打散。
全书的默认策略是:最多 2 次重试(总 3 次尝试),基础 20 ms,指数 2,抖动 ±20%。RetryPolicy 的 maxAttempts 指总尝试次数,包含首次:
package com.plume.linkd.v3.core.retry;
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.ofSeconds(1), 0.2);
public int retries() {
return Math.max(0, maxAttempts - 1);
}
public Duration delayAfter(int failedAttempt) {
long base = baseDelay.toMillis() << Math.min(failedAttempt - 1, 20);
long capped = Math.min(base, maxDelay.toMillis());
double factor = 1.0 + ThreadLocalRandom.current().nextDouble(-jitter, jitter);
return Duration.ofMillis(Math.max(0, Math.round(capped * factor)));
}
}
failedAttempt 从 1 开始:第 1 次失败后等约 20 ms,第 2 次失败后等约 40 ms。<< 的位移上限设为 20,防止 failedAttempt 很大时溢出;超过上限后由 maxDelay 截断。ThreadLocalRandom 比 Random 更适合并发场景,不需要额外同步。
重试循环要判断三件事:次数、错误类型、剩余预算:
package com.plume.linkd.v3.core.retry;
import com.plume.linkd.v3.core.RequestContext;
import com.plume.linkd.v3.core.error.RpcError;
import java.util.function.Supplier;
public final class Retrier {
public static <T> T call(Supplier<T> action, RequestContext context, RetryPolicy policy) {
RuntimeException last = null;
for (int attempt = 1; attempt <= policy.maxAttempts(); attempt++) {
try {
return action.get();
} catch (RuntimeException e) {
last = e;
RpcError error = RpcError.from(e);
boolean canRetry = error.retryable()
&& attempt < policy.maxAttempts()
&& context.hasBudget(50);
if (!canRetry) {
throw e;
}
sleep(policy.delayAfter(attempt));
}
}
throw last;
}
private static void sleep(java.time.Duration delay) {
try {
Thread.sleep(delay.toMillis());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("retry interrupted", e);
}
}
}
三个判断缺一不可:
error.retryable()过滤掉参数错误、权限错误和永久失败。attempt < maxAttempts保证不会无限重试。context.hasBudget(50)保证重试不会把总预算吃穿;50 ms 是一次最小尝试的估计值,低于它就没有意义。
Thread.sleep 会阻塞当前线程,只适合在业务线程池里用。不要在 gRPC 的调用线程或事件循环里 sleep;异步场景应该用调度器延迟执行。
在 linkd-v3 里落地:服务端去重
客户端带键只解决「键稳定」,服务端还要真的去重。最小实现用 ConcurrentHashMap 保存「进行中」和「已完成」两种状态:
package com.plume.linkd.v3.server;
import com.plume.linkd.v3.api.Book;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
public final class IdempotencyStore {
private record Stored(Book value, Instant expiresAt) {
}
private final ConcurrentHashMap<String, CompletableFuture<Book>> inFlight = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Stored> completed = new ConcurrentHashMap<>();
private final Duration ttl;
public IdempotencyStore(Duration ttl) {
this.ttl = ttl;
}
public Book execute(String key, Supplier<Book> action) {
Stored stored = completed.get(key);
if (stored != null && stored.expiresAt().isAfter(Instant.now())) {
return stored.value();
}
CompletableFuture<Book> mine = new CompletableFuture<>();
CompletableFuture<Book> existing = inFlight.putIfAbsent(key, mine);
if (existing != null) {
return existing.join();
}
try {
Book value = action.get();
mine.complete(value);
completed.put(key, new Stored(value, Instant.now().plus(ttl)));
return value;
} catch (RuntimeException e) {
mine.completeExceptionally(e);
throw e;
} finally {
inFlight.remove(key, mine);
}
}
public int inFlightCount() {
return inFlight.size();
}
}
关键点:
putIfAbsent让并发的同键请求只有一个能执行action,其余等待同一个CompletableFuture。只用completed表挡不住并发,因为第一个请求还没完成时,第二个请求查不到结果。- 失败时
completeExceptionally并移除 in-flight,让后续重试可以重新执行。永久失败是否缓存,要按业务决定:缓存可以防止重复打下游,但会挡住用户修正参数后的重试。 completed带 TTL,过期后允许同一个键重新执行。TTL 要大于最大重试窗口,24 小时是一个常见起点。- 过期的
completed条目要定期清理,否则 Map 会随键的数量无限增长。清理可以按时间扫描,也可以在读取时顺手删除当前键。 - 这个实现是单实例的。多实例部署时,
inFlight和completed要么放到共享存储,要么用「数据库唯一键 + 状态机」保证跨实例去重;单机 Map 只能挡住打到同一实例的重试。
服务端从 metadata 读取键:
Metadata.Key<String> IDEMPOTENCY =
Metadata.Key.of("x-idempotency-key", Metadata.ASCII_STRING_MARSHALLER);
String key = headers.get(IDEMPOTENCY);
if (key == null) {
throw Status.INVALID_ARGUMENT
.withDescription("write operations require x-idempotency-key")
.asRuntimeException();
}
String scoped = tenantId + "/plume.linkd.v3.Library/CreateOrder/" + key;
Book result = store.execute(scoped, () -> doCreate(request));
scoped 把租户、方法、键拼在一起。不要用分隔符拼接用户输入而不做转义:如果键里允许 /,不同租户可能拼出相同的 scoped 字符串。键的格式应该限制为 UUID 或 [A-Za-z0-9_-]{8,64}。
重试预算与重试风暴
单次调用的重试次数可控,不代表系统级可控。三层调用各重试两次,最坏放大 27 倍。控制手段有三个:
- 只在一层重试。 通常放在最外层(网关或客户端入口),内层失败直接上报。内层也重试会让放大倍数相乘。
- 重试预算。 统计最近一段时间「重试请求 / 总请求」的比例,超过阈值(例如 10%)就停止重试,直接失败。阈值是策略值,不是实测值。
- 熔断与限流。 下游持续失败时,熔断器快速失败,根本不给重试机会;限流器控制入口速率,防止重试把队列打满。
重试预算和熔断、限流的关系在 7.1、7.2、8.1 展开。这里只需要记住:重试是「用额外请求换成功率」,必须有一个全局上限,否则故障时它会变成放大器。
常见错误与边界
错误一:每次重试生成新的幂等键。 去重完全失效。键必须在业务操作开始时生成,重试复用。
错误二:只按幂等键去重,不带租户和方法。 不同租户、不同方法的键会碰撞。作用域至少是「租户 + 方法 + 键」。
错误三:只查完成表,不挡并发。 两个请求同时到达,都查不到结果,都执行。必须用 putIfAbsent 或数据库唯一键挡住进行中的请求。
错误四:缓存所有失败。 参数错误缓存后,用户修正参数再试仍然失败。只有永久性、确定性的失败才适合缓存,且要能手动清理。
错误五:重试没有抖动。 大量客户端在同一时刻失败,又在同一时刻重试,形成脉冲。抖动是必须的,不是优化。
错误六:在没有预算的情况下重试。 总 deadline 已经耗尽还重试,只会让调用方等更久,最后仍然失败。
边界:幂等键不能替代数据库约束。 去重表本身也可能丢数据或过期。资金、库存这类操作仍然需要唯一索引或乐观锁兜底。
边界:响应结果可能很大。 去重表缓存完整响应会占内存。大响应可以只缓存「操作 ID + 状态」,让客户端用操作 ID 查询结果。
边界:跨实例去重需要共享状态。 单机 Map 在滚动发布、扩容后会失效。多实例部署要么共享存储,要么接受「同一键可能执行两次」,再用业务约束兜底。
小结
- at-most-once 可能丢,at-least-once 可能重,effectively-once 靠「至少一次 + 幂等键 + 去重」实现。
x-idempotency-key由客户端在业务操作开始时生成,重试时保持不变,作用域是「租户 + 方法 + 键」。- 只重试
UNAVAILABLE、RATE_LIMITED、有预算的DEADLINE_EXCEEDED和重新读取后的ABORTED;参数、权限、未实现类错误不重试。 - 退避默认 20 ms 起、指数 2、上限 1 s、抖动 ±20%,
maxAttempts=3指总尝试次数。 - 重试前同时判断错误类型、剩余次数和剩余预算,三者缺一不可。
- 服务端去重要用
putIfAbsent挡住并发,完成结果带 TTL;单机 Map 不能跨实例。 - 三层各重试两次会放大 27 倍;只在一层重试,并用重试预算、熔断和限流兜底。
第 3 章结束,调用侧的地基已经齐了:契约、编码、调用模式、时间预算、幂等与重试。第 4 章进入 gRPC-Java 的实现细节,从 Stub 与 Channel 的生命周期开始。
阅读导航:上一节 3.2 Deadline 传播与取消 | 下一节 4.1 Stub 与 Channel 生命周期
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。