《Java 服务端通信工程》3.2 Deadline 传播与取消

把 800 ms 总预算拆成每次尝试的可用时间,解释 grpc-timeout 为什么传相对时间、服务端为什么常看到 CANCELLED 而不是 DEADLINE_EXCEEDED,并给出 withDeadlineAfter、Context 剩余时间读取、setOnCancelHandler 清理的完整代码。

承接的问题

3.1 的调用模式解决「一次调用能发几条消息」,但没有回答「这次调用最多能花多久」。没有 deadline 的调用在正常时看不出问题,一旦下游变慢,调用方线程、连接和内存会被一批永远不返回的请求占住。

gRPC 的 deadline 不是简单的 socket 超时。它是调用级的时间预算,会随请求传播到服务端,服务端可以继续传给下游,也可以在预算耗尽时主动取消。这一节先把 800 ms 和 300 ms 两个数字算清楚,再看传播、取消和重试之间的相互作用。

两个预算:800 与 300

全书统一两个默认值:

预算值归属含义
客户端总 deadline800 mslinkd-v3 入口一次用户请求从进入到返回的上限
单跳 deadline300 ms调 libraryd一次下游调用最多等多久

这两个数不是随便定的。800 ms 覆盖「网关解析 + 一次下游调用 + 少量排队 + 序列化」,不把用户拖过 1 秒;300 ms 给 libraryd 的数据库查询留出余量,同时保证失败后还有时间重试。

矛盾在于:3 次尝试 × 300 ms = 900 ms,超过 800 ms。所以 300 ms 是单跳上限,不是每次都能拿满。实际每次尝试的预算要按剩余时间分配:

perAttempt = min(单跳上限, 剩余总预算 / 剩余尝试次数)

按 800 ms、3 次尝试算:

尝试剩余预算剩余次数单次预算尝试后剩余(假设用满)
18003min(300, 266) = 266534
25342min(300, 267) = 267267
32671min(300, 267) = 2670

三次加起来正好 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()、关闭 ChannelCANCELLED,可能带 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 和取消放进一次真实调用,顺序是:

  1. 入口拦截器读取 x-request-id、x-tenant-id,构造 RequestContext。
  2. 如果请求没有 deadline,补 800 ms 服务端上限。
  3. 业务方法调 libraryd 前,计算单跳预算,用 withDeadlineAfter 设置。
  4. 捕获 StatusRuntimeException,区分 DEADLINE_EXCEEDED、CANCELLED、UNAVAILABLE。
  5. 服务端在流式方法里注册取消清理。

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 幂等、重试与请求去重

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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