承接的问题
3.1 的调用模式解决「一次调用能发几条消息」,但没有回答「这次调用最多能花多久」。没有 deadline 的调用在正常时看不出问题,一旦下游变慢,调用方线程、连接和内存会被一批永远不返回的请求占住。
gRPC 的 deadline 不是简单的 socket 超时。它是调用级的时间预算,会随请求传播到服务端,服务端可以继续传给下游,也可以在预算耗尽时主动取消。这一节先把 800 ms 和 300 ms 两个数字算清楚,再看传播、取消和重试之间的相互作用。
两个预算:800 与 300
全书统一两个默认值:
| 预算 | 值 | 归属 | 含义 |
|---|---|---|---|
| 客户端总 deadline | 800 ms | linkd-v3 入口 | 一次用户请求从进入到返回的上限 |
| 单跳 deadline | 300 ms | 调 libraryd | 一次下游调用最多等多久 |
这两个数不是随便定的。800 ms 覆盖「网关解析 + 一次下游调用 + 少量排队 + 序列化」,不把用户拖过 1 秒;300 ms 给 libraryd 的数据库查询留出余量,同时保证失败后还有时间重试。
矛盾在于:3 次尝试 × 300 ms = 900 ms,超过 800 ms。所以 300 ms 是单跳上限,不是每次都能拿满。实际每次尝试的预算要按剩余时间分配:
perAttempt = min(单跳上限, 剩余总预算 / 剩余尝试次数)
按 800 ms、3 次尝试算:
| 尝试 | 剩余预算 | 剩余次数 | 单次预算 | 尝试后剩余(假设用满) |
|---|---|---|---|---|
| 1 | 800 | 3 | min(300, 266) = 266 | 534 |
| 2 | 534 | 2 | min(300, 267) = 267 | 267 |
| 3 | 267 | 1 | min(300, 267) = 267 | 0 |
三次加起来正好 800 ms。第一次拿不到 300 ms,是因为要给后两次留出空间;如果第一次就花 300 ms,剩下 500 ms 分给两次,每次只有 250 ms,最后可能一次都跑不完。
把这段算术写成代码:
package com.plume.linkd.v3.core.deadline;
import java.util.ArrayList;
import java.util.List;
public final class DeadlineBudget {
public static final long TOTAL_MILLIS = 800;
public static final long SINGLE_HOP_MILLIS = 300;
public static List<Long> plan(long totalMillis, int attempts) {
List<Long> budgets = new ArrayList<>();
long remaining = totalMillis;
for (int left = attempts; left > 0; left--) {
long perAttempt = Math.min(SINGLE_HOP_MILLIS, remaining / left);
budgets.add(perAttempt);
remaining -= perAttempt;
}
return budgets;
}
public static void main(String[] args) {
List<Long> budgets = plan(TOTAL_MILLIS, 3);
long sum = 0;
for (int i = 0; i < budgets.size(); i++) {
System.out.println("attempt" + (i + 1) + "=" + budgets.get(i));
sum += budgets.get(i);
}
System.out.println("sum=" + sum);
System.out.println("capped=" + plan(10_000, 1).get(0));
}
}
预期输出:
attempt1=266
attempt2=267
attempt3=267
sum=800
capped=300
capped=300 说明单跳上限仍然生效:总预算再大,一次调 libraryd 也不超过 300 ms。这个上限保护下游,也保护调用方自己——没有它,一个慢查询会把整条链路拖住。
Deadline 的传播机制
客户端设置 deadline 有两种写法:
// 相对当前时刻
stub.withDeadlineAfter(800, TimeUnit.MILLISECONDS);
// 绝对 deadline
stub.withDeadline(Deadline.after(800, TimeUnit.MILLISECONDS));
两者效果相同,withDeadlineAfter 更常用。Deadline 内部用单调时钟计算剩余时间,不受系统时间调整影响;它转成 grpc-timeout 发到线上时,发送的是「还剩多少」,不是「截止到几点」。
这个设计解决两个问题:
- 不需要时钟同步。 客户端和服务端的墙上时间可能差几秒,但「还剩 800 毫秒」是相对量,服务端收到后按自己的时钟减。
- 网络传输时间被算进去。 请求在路上花了 20 ms,服务端拿到的剩余时间就是 780 ms 左右。这是想要的行为:用户等的总时间不变。
服务端读取剩余时间:
Deadline deadline = Context.current().getDeadline();
if (deadline == null) {
// 客户端没设 deadline,按服务端默认策略处理
} else if (deadline.isExpired()) {
// 已经过期,直接失败,不要开始干活
} else {
long remainingMillis = deadline.timeRemaining(TimeUnit.MILLISECONDS);
}
Context.current().getDeadline() 可能返回 null。不是所有客户端都会设 deadline,服务端不能假设它一定存在。生产服务应该在拦截器里给没有 deadline 的请求补一个服务端上限,否则一个不设预算的客户端可以把连接占住。
传播到下游时,不要直接复用 800 ms。调 libraryd 的单跳预算是 min(300, Context 剩余时间):
long remaining = Context.current().getDeadline() == null
? 300
: Context.current().getDeadline().timeRemaining(TimeUnit.MILLISECONDS);
long hop = Math.min(300, Math.max(1, remaining - 20));
减去 20 ms 是给「收到响应到返回客户端」留的余量。不扣这一点,下游刚好在 deadline 前返回,调用方可能已经先超时,日志里出现「下游成功、上游超时」的矛盾记录。
取消的三种来源
一次调用被取消,可能来自三个方向:
| 来源 | 触发方式 | 服务端看到 |
|---|---|---|
| 客户端 deadline 到期 | 客户端本地计时器 | 通常是 CANCELLED,不是 DEADLINE_EXCEEDED |
| 客户端主动取消 | ClientCallStreamObserver.cancel()、关闭 Channel | CANCELLED,可能带 description |
| 连接断开 | TCP 断开、代理超时 | CANCELLED 或 UNAVAILABLE |
一个容易误判的现象:客户端因为 deadline 到期收到 DEADLINE_EXCEEDED,服务端记录的却是 CANCELLED。原因是客户端到期后发送 RST_STREAM,服务端只看到「流被取消」,不知道是超时还是用户主动取消。排查时要把两端的日志用请求 ID 对上,不能只看服务端的 code。
服务端处理取消的标准写法:
package com.plume.linkd.v3.server;
import com.plume.linkd.v3.api.StockUpdate;
import com.plume.linkd.v3.api.WatchStockRequest;
import io.grpc.Context;
import io.grpc.Deadline;
import io.grpc.stub.ServerCallStreamObserver;
import io.grpc.stub.StreamObserver;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public final class StockStream {
public void watch(WatchStockRequest request, StreamObserver<StockUpdate> responseObserver) {
ServerCallStreamObserver<StockUpdate> observer =
(ServerCallStreamObserver<StockUpdate>) responseObserver;
AtomicBoolean cancelled = new AtomicBoolean(false);
observer.setOnCancelHandler(() -> cancelled.set(true));
Deadline deadline = Context.current().getDeadline();
long remaining = deadline == null ? 300 : deadline.timeRemaining(TimeUnit.MILLISECONDS);
System.out.println("server.remaining<=" + remaining);
if (cancelled.get()) {
return;
}
observer.onNext(StockUpdate.newBuilder()
.setIsbn(request.getIsbn())
.setStock(1)
.setVersion(1)
.build());
observer.onCompleted();
}
}
setOnCancelHandler 只设置一次,重复设置会抛 IllegalStateException。它应该在方法入口处设置,不要等到循环内部。handler 里只做清理,不要做耗时操作:它运行在 gRPC 的线程上,阻塞会影响其它调用。
客户端主动取消的写法:
ClientCallStreamObserver<ChatMessage> call =
(ClientCallStreamObserver<ChatMessage>) requestObserver;
call.cancel("user left", null);
cancel 之后流立即失效,后续 onNext 会抛异常。客户端不需要等待服务端确认;取消是「尽力而为」,服务端可能晚几百毫秒才看到。
在 linkd-v3 里落地
把预算、deadline 和取消放进一次真实调用,顺序是:
- 入口拦截器读取
x-request-id、x-tenant-id,构造RequestContext。 - 如果请求没有 deadline,补 800 ms 服务端上限。
- 业务方法调
libraryd前,计算单跳预算,用withDeadlineAfter设置。 - 捕获
StatusRuntimeException,区分DEADLINE_EXCEEDED、CANCELLED、UNAVAILABLE。 - 服务端在流式方法里注册取消清理。
RequestContext 持有 deadline,跨方法传递:
package com.plume.linkd.v3.core;
import io.grpc.Deadline;
public record RequestContext(String requestId, String tenantId,
TraceContext trace, Deadline deadline) {
public long remainingMillis() {
return deadline == null ? -1 : deadline.timeRemaining(java.util.concurrent.TimeUnit.MILLISECONDS);
}
public boolean hasBudget(long minimumMillis) {
return deadline == null || deadline.timeRemaining(java.util.concurrent.TimeUnit.MILLISECONDS) >= minimumMillis;
}
}
hasBudget 是重试前的门槛:剩余时间不足以完成一次最小尝试时,不要再重试,直接返回 DEADLINE_EXCEEDED。3.3 会把这条规则写进重试循环。
一个完整的客户端调用片段:
try {
Book book = LibraryGrpc.newBlockingStub(channel)
.withDeadlineAfter(hopMillis, TimeUnit.MILLISECONDS)
.getBook(GetBookRequest.newBuilder().setIsbn(isbn).build());
return book;
} catch (StatusRuntimeException e) {
Status.Code code = e.getStatus().getCode();
if (code == Status.Code.DEADLINE_EXCEEDED) {
throw new RpcError(Status.Code.DEADLINE_EXCEEDED, ErrorReasons.DEADLINE_EXCEEDED,
"libraryd budget exhausted", false).toException(Map.of("isbn", isbn));
}
throw e;
}
注意这里把下游的 DEADLINE_EXCEEDED 重新包装成项目原因码,而不是原样抛出。原样抛出会丢失「这是哪一跳超时」的信息;包装后网关可以映射成 504,并在 metadata 里带上 ISBN。
时钟、代理与重试的边界
时钟。 Deadline 用单调时钟,不受 NTP 调整影响;但跨进程传播的是相对时间,接收方按自己的单调时钟减。两端的「单调」起点不同,所以不能把两端的剩余时间直接相加。
代理。 有些代理会重写或截断 grpc-timeout。超长 deadline(例如几小时)在某些实现里会被截断到 8 位数字能表示的最大值。业务不应该依赖「deadline 精确到毫秒」,只应该依赖「大致不晚于这个时间」。
重试。 重试消耗的是同一个总预算。每次重试前重新计算 min(单跳上限, 剩余 / 剩余次数),不要用第一次的预算重复三次。7.1 会把重试预算和熔断、限流放在一起。
流式调用。 Deadline 作用于整个流,不是每条消息。一个 server streaming 调用设了 800 ms,指的是「800 ms 内流必须结束」,不是「每条消息 800 ms」。长订阅要么设更长的 deadline,要么不设 deadline 并用取消控制生命周期。
常见错误与边界
错误一:不设 deadline。 下游不返回时,调用方线程永远占住。服务端应该在拦截器里给缺失 deadline 的请求补上限,不能假设客户端都会设。
错误二:每一跳都用 800 ms。 网关 800 ms、服务 A 800 ms、服务 B 800 ms,最坏情况总耗时是三层之和,用户早就超时了。每一跳都要缩短,且要扣掉自身处理时间。
错误三:把 CANCELLED 当成客户端主动取消。 deadline 到期也会表现为 CANCELLED。判断要看客户端日志和请求 ID,不能只看服务端 code。
错误四:重试时重置 deadline。 每次重试都 withDeadlineAfter(300),总时间会变成 900 ms。正确做法是传入剩余预算,或从同一个 Deadline 派生。
错误五:在取消 handler 里做重活。 setOnCancelHandler 运行在 gRPC 线程上,里面做数据库回滚或远程调用会拖慢清理。只做标志位、关闭游标、取消定时任务。
边界:deadline 到期不保证服务端立刻停止。 服务端可能正在执行一个不可中断的操作,isCancelled() 变 true 后仍会跑完当前步骤。业务要在关键步骤之间检查取消状态。
边界:Context 不跨线程自动传播。 把任务提交到线程池后,Context.current() 在线程池里可能是空的。需要用 Context.current().wrap(runnable) 或显式传递 RequestContext。
边界:deadline 不是熔断。 它限制单次调用的时间,不阻止调用发生。下游持续超时时,仍然需要熔断器快速失败,否则每次都要等满预算。
小结
- 800 ms 是总预算,300 ms 是单跳上限;3 次尝试按
min(300, 剩余/剩余次数)分配,得到 266/267/267。 grpc-timeout传相对时间,不需要时钟同步;网络传输时间会被算进服务端剩余时间。- 服务端必须处理
getDeadline() == null,并在拦截器里补服务端上限。 - 下游 deadline 用
min(300, 剩余 - 余量),不要复用总预算。 - 客户端 deadline 到期时,服务端常看到
CANCELLED;排查要两端日志按请求 ID 对齐。 - 取消 handler 只做清理,不做耗时操作;
Context不跨线程自动传播。 - 重试消耗同一总预算,每次重试前重新计算,不要重置 deadline。
时间预算有了,下一个问题是「失败之后能不能重试、怎么保证重试不产生重复副作用」。3.3 把幂等键、请求去重和退避策略放在一起,给出一次可安全重试的完整调用。
阅读导航:上一节 3.1 四种调用模式 | 下一节 3.3 幂等、重试与请求去重
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。