《Java 服务端通信工程》15.1 链路架构与边界

把前十四章的能力装进一条真实链路:HTTP 客户端 → 9094 网关 → 9093 gRPC Library → 8080 libraryd。本节给出超时预算 800/300 ms 的逐段分配、失败传播图,以及重试、熔断、限流分别由谁负责的边界,并附两段可编译代码。

本节目标:在写验收清单之前,先明确每个组件的职责边界,避免「重试叠加」「熔断错位」「超时预算超支」这三类系统性问题。
端口:libraryd 8080,linkd-v3 gRPC 9093,HTTP 网关 9094,指标与管理 9095。

承接的问题

前十四章分别讲了协议、序列化、调用模型、拦截器、服务发现、连接池、重试熔断、限流、网关、安全、可观测性、灰度、混沌、容量与成本。单独看每一章都成立,合到一条链路上却经常出现职责重叠:

  • 客户端重试 2 次,网关也重试 2 次,下游实际承受 9 倍流量。
  • 网关熔断打开后,客户端还在重试,故障被延长。
  • 每一层都设 800 ms 超时,端到端实际可能超过 2 秒。
  • 服务端收到已经过期的请求,仍然查库、组装、返回,浪费资源。

这些问题不是代码 bug,是边界没定义。本节先把链路画清楚,再把超时、重试、熔断、限流的归属写死;15.2 按这份边界做验收,15.3 按它写运行手册。

三层链路与端口

┌──────────────┐   HTTP/1.1 or HTTP/2    ┌──────────────────┐
│ HTTP client  │ ──────────────────────> │ linkd-v3 gateway │
│ (browser/app)│ <────────────────────── │ :9094            │
└──────────────┘                         └────────┬─────────┘
                                                  │ gRPC over HTTP/2
                                                  │ Library.GetBook / SearchBooks
                                                  v
                                         ┌──────────────────┐
                                         │ comm-server      │
                                         │ Library :9093    │
                                         └────────┬─────────┘
                                                  │ HTTP/1.1 (JDK HttpClient)
                                                  v
                                         ┌──────────────────┐
                                         │ libraryd :8080   │
                                         │ (已有服务)        │
                                         └──────────────────┘

四段职责:

组件对外协议核心职责不负责
HTTP 客户端HTTP用户请求、展示、有限重试服务发现、熔断、下游限流
gateway 9094HTTP → gRPC协议转换、鉴权、限流、熔断、错误映射业务查询逻辑、数据库访问
comm-server 9093gRPCLibrary 服务实现、并发控制、deadline 检查重试下游、跨服务编排
libraryd 8080HTTP图书数据与库存感知调用方 deadline、重试上游

管理端口 9095 只提供健康检查、指标和演练开关,不承载业务流量。它必须独立于 9094,否则网关过载时连健康检查都拿不到。

链路只有一条主路径,但调用模式有四种:GetBook 是一元,SearchBooks 是服务端流,WatchStock 是服务端流,Chat 是双向流。超时与重试的规则主要针对一元调用;流式调用通常只设总 deadline,不做自动重试,因为重放流的语义复杂。

超时预算:800 ms 怎么分

客户端总 deadline 800 ms,单跳 libraryd 300 ms。这两个数不是随便定的,要能逐段加回去:

区段预算说明
客户端 → 网关网络20 ms同区域,含 TLS 复用后的往返
网关接入与鉴权30 ms解析、token 校验、限流判断
第一次 gRPC 调用300 ms含 comm-server 处理与 libraryd 单跳
重试退避20 ms基础退避 20 ms,抖动 ±20%
第二次 gRPC 调用300 ms仅在预算足够时发起
网关响应序列化30 msJSON 编码、压缩
余量100 ms调度抖动、GC、网络抖动
合计800 ms20 + 30 + 300 + 20 + 300 + 30 + 100

这张表说明两件事:

第一,800 ms 只够一次重试。默认重试策略是最多 2 次重试(总 3 次尝试),但 3 × 300 = 900 ms 已经超过总预算。第三次尝试只有在前面快速失败(例如 5 ms 内返回 UNAVAILABLE)时才可能发生。策略里的 maxAttempts 是上限,deadline 才是真正的约束。

第二,单跳 300 ms 必须小于总预算的一半。如果单跳就占 500 ms,一次失败后没有第二次机会,重试策略形同虚设。

实现上,每次重试前要检查剩余预算:

remaining = deadline.timeRemaining(MILLISECONDS)
canRetry = remaining > singleHopBudget + backoff + safetyMargin

safetyMargin 建议 50 ms,留给序列化和网络。只看 remaining > 0 就重试,最后一个请求会在返回途中超时,客户端看到的是超时而不是重试结果。

服务端也要检查 deadline。客户端已经放弃的请求,服务端继续查库没有意义:

package com.plume.linkd.v3.server;

import com.plume.linkd.v3.api.Book;
import com.plume.linkd.v3.api.GetBookRequest;
import com.plume.linkd.v3.api.LibraryGrpc;
import io.grpc.Context;
import io.grpc.Deadline;
import io.grpc.Status;
import io.grpc.stub.StreamObserver;

import java.util.concurrent.TimeUnit;

public final class LibraryService extends LibraryGrpc.LibraryImplBase {

    private final BookRepository repository;

    public LibraryService(BookRepository repository) {
        this.repository = repository;
    }

    @Override
    public void getBook(GetBookRequest request, StreamObserver<Book> observer) {
        Deadline deadline = Context.current().getDeadline();
        if (deadline != null && deadline.isExpired()) {
            observer.onError(Status.DEADLINE_EXCEEDED
                    .withDescription("deadline expired before handling")
                    .asRuntimeException());
            return;
        }
        if (Context.current().isCancelled()) {
            observer.onError(Status.CANCELLED
                    .withDescription("client cancelled")
                    .asRuntimeException());
            return;
        }

        long budgetMs = deadline == null
                ? 250
                : Math.min(250, deadline.timeRemaining(TimeUnit.MILLISECONDS) - 50);
        if (budgetMs <= 0) {
            observer.onError(Status.DEADLINE_EXCEEDED
                    .withDescription("insufficient budget for downstream call")
                    .asRuntimeException());
            return;
        }

        repository.find(request.getIsbn(), budgetMs)
                .whenComplete((book, error) -> {
                    if (error != null) {
                        observer.onError(Status.INTERNAL
                                .withDescription("repository failure")
                                .withCause(error)
                                .asRuntimeException());
                        return;
                    }
                    observer.onNext(book);
                    observer.onCompleted();
                });
    }
}

Math.min(250, remaining - 50) 是预算传递:服务端给 libraryd 的单跳不能超过剩余时间减 50 ms,否则即使 libraryd 正常返回,响应也已经在路上超时。BookRepository.find(isbn, budgetMs) 把预算继续传给 HTTP 客户端。接口本身只声明一个方法,便于测试替身和真实实现互换:

package com.plume.linkd.v3.server;

import com.plume.linkd.v3.api.Book;

import java.util.concurrent.CompletableFuture;

public interface BookRepository {

    CompletableFuture<Book> find(String isbn, long budgetMs);
}

这段代码省略了 BookRepository 的实现,但签名表达了关键约束:deadline 要作为参数往下传,而不是在每一层重新设一个固定值。 每层都设 300 ms,三层串起来就是 900 ms,总预算必然超支。

失败传播图

故障从下往上传播时,每一层要把它翻译成上层能理解的语义:

libraryd 拒绝连接
  → comm-server: UNAVAILABLE(gRPC 状态)
  → gateway: 503 + reason=UPSTREAM_UNAVAILABLE
  → client: 可重试的 5xx(仅幂等请求)

libraryd 超过 300 ms
  → comm-server: DEADLINE_EXCEEDED
  → gateway: 504 + reason=DEADLINE_EXCEEDED
  → client: 展示超时,不自动重试或只重试一次

comm-server 并发满
  → RESOURCE_EXHAUSTED
  → gateway: 429 + reason=RATE_LIMITED + Retry-After
  → client: 退避后重试

租户无权限
  → PERMISSION_DENIED
  → gateway: 403 + reason=TENANT_FORBIDDEN
  → client: 不重试

ISBN 不存在
  → NOT_FOUND
  → gateway: 404 + reason=BOOK_NOT_FOUND
  → client: 不重试

映射表要固定,不能每个接口各写一套。第 9.3 节讲过错误映射,这里补充两条链路级规则:

  • 原因码比 HTTP 状态码更稳定。 503 可能是依赖不可用,也可能是网关自身过载;reason=UPSTREAM_UNAVAILABLE 才能指导客户端是否重试。
  • 不要把内部细节透传给客户端。 响应体里不要出现 libraryd 的 host、端口、异常类名;这些写进日志,用 requestId 关联。

边界:谁重试、谁熔断、谁限流

这是整章最重要的一张表。每个能力只能有一个主要归属,其他层最多做「兜底」,不能叠加。

能力主要归属规则其他层做什么
重试客户端(幂等请求)最多 2 次,预算内才发起网关只对 UNAVAILABLE 做 1 次预算内重试
熔断网关(按上游)20 次窗口、50% 失败率、打开 5 s客户端不熔断,服务端不熔断自己
限流网关(按租户/路由)200 rps、burst 400服务端做并发上限 64,不用令牌桶
并发隔离comm-serverBulkhead 64 并发、128 队列网关用连接池上限保护下游
超时每一层传递 deadline客户端 800 ms,单跳 300 ms服务端用剩余预算调下游
降级网关熔断打开后返回缓存或兜底服务端不感知降级

几个容易搞反的点:

客户端重试,网关也重试。 两层各重试 2 次,最坏 3 × 3 = 9 次下游调用。正确做法是客户端负责用户可见的重试,网关只在「客户端没有重试能力」时做一次预算内重试;两者不能同时全开。

服务端熔断自己。 熔断器保护的是调用方,不是被调用方。comm-server 不应该对「自己的数据库」做熔断后再返回 UNAVAILABLE,它应该用并发上限和超时保护自己;熔断由网关对 comm-server 做。

限流和并发上限混用。 令牌桶控制「每秒进来多少」,Bulkhead 控制「同时处理多少」。前者防突发,后者防慢请求占满线程。两个都要有,但不能用其中一个替代另一个。

每一层都设固定超时。 固定超时不会传递预算,只会叠加。正确做法是从客户端 deadline 出发,每层减去自己的开销,把剩余时间往下传。

在 linkd-v3 里落地

网关调用 9093 的代码要同时处理 deadline、元数据传播和错误映射:

package com.plume.linkd.v3.gateway;

import com.plume.linkd.v3.api.Book;
import com.plume.linkd.v3.api.GetBookRequest;
import com.plume.linkd.v3.api.LibraryGrpc;
import com.plume.linkd.v3.core.RpcError;
import io.grpc.CallOptions;
import io.grpc.Channel;
import io.grpc.ClientCall;
import io.grpc.ClientInterceptor;
import io.grpc.ForwardingClientCall;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.Metadata;
import io.grpc.MethodDescriptor;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;

import java.util.concurrent.TimeUnit;

public final class GatewayLibraryClient implements AutoCloseable {

    private static final Metadata.Key<String> REQUEST_ID =
            Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER);
    private static final Metadata.Key<String> TENANT =
            Metadata.Key.of("x-tenant-id", Metadata.ASCII_STRING_MARSHALLER);

    private final ManagedChannel channel;
    private final LibraryGrpc.LibraryBlockingStub stub;

    public GatewayLibraryClient(String host, int port) {
        this.channel = ManagedChannelBuilder.forAddress(host, port)
                .usePlaintext()
                .build();
        this.stub = LibraryGrpc.newBlockingStub(channel);
    }

    public Book getBook(String isbn, String requestId, String tenantId) {
        LibraryGrpc.LibraryBlockingStub callStub = stub
                .withInterceptors(new HeaderInterceptor(requestId, tenantId))
                .withDeadlineAfter(300, TimeUnit.MILLISECONDS);
        try {
            return callStub.getBook(GetBookRequest.newBuilder().setIsbn(isbn).build());
        } catch (StatusRuntimeException e) {
            throw toRpcError(e).asRuntimeException();
        }
    }

    private static RpcError toRpcError(StatusRuntimeException e) {
        Status.Code code = e.getStatus().getCode();
        String reason = switch (code) {
            case NOT_FOUND -> "BOOK_NOT_FOUND";
            case DEADLINE_EXCEEDED -> "DEADLINE_EXCEEDED";
            case UNAVAILABLE -> "UPSTREAM_UNAVAILABLE";
            case RESOURCE_EXHAUSTED -> "RATE_LIMITED";
            case PERMISSION_DENIED -> "TENANT_FORBIDDEN";
            case ABORTED -> "STOCK_CONFLICT";
            default -> "INTERNAL";
        };
        boolean retryable = code == Status.Code.UNAVAILABLE
                || code == Status.Code.DEADLINE_EXCEEDED;
        return new RpcError(code, reason,
                e.getStatus().getDescription(), retryable);
    }

    @Override
    public void close() throws InterruptedException {
        channel.shutdown().awaitTermination(5, TimeUnit.SECONDS);
    }

    private static final class HeaderInterceptor implements ClientInterceptor {
        private final String requestId;
        private final String tenantId;

        private HeaderInterceptor(String requestId, String tenantId) {
            this.requestId = requestId;
            this.tenantId = tenantId;
        }

        @Override
        public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
                MethodDescriptor<ReqT, RespT> method,
                CallOptions options,
                Channel next) {
            return new ForwardingClientCall.SimpleForwardingClientCall<>(
                    next.newCall(method, options)) {
                @Override
                public void start(Listener<RespT> listener, Metadata headers) {
                    headers.put(REQUEST_ID, requestId);
                    headers.put(TENANT, tenantId);
                    super.start(listener, headers);
                }
            };
        }
    }
}

这段代码只做三件事:带元数据、设 300 ms deadline、把状态映射成项目内的 RpcError。重试和熔断不在这里,它们属于网关的上层组件(第 7 章),由调用方决定是否重试。把重试塞进客户端封装,会导致调用方无法控制总次数。

RpcError.asRuntimeException() 是项目自定义方法,内部把 RpcError 转成带 ErrorInfo 的 StatusRuntimeException,15.2 会给出契约测试。

常见错误与边界

错误一:每层重试。 客户端、网关、服务端都重试,流量按乘积放大。重试只能有一个主要归属。

错误二:固定超时不传递。 每层设 300 ms,三层就是 900 ms。要用剩余预算,不是固定值。

错误三:把熔断放在服务端。 熔断器保护调用方;服务端用并发上限和队列保护自己。

错误四:健康检查放在业务端口。 9095 必须独立;9094 过载时,健康检查仍要能回答进程是否存活。

错误五:流式调用自动重试。 SearchBooks 重放会产生重复数据,Chat 重放会打乱消息顺序。流式调用只设总 deadline,重试由业务决定。

错误六:错误码只映射 HTTP 状态。 503 的来源不同,客户端处置也不同;必须带原因码。

边界:链路图是逻辑视图。 真实部署可能有多副本、负载均衡、服务网格;边界原则不变,但超时预算要加上额外的网络跳数。

边界:libraryd 是已有服务,不能改。 它的超时、连接数、错误格式由现有实现决定;网关必须适配它,而不是假设它会按 v3 的约定返回。

小结

  • 链路是 HTTP 客户端 → 9094 网关 → 9093 gRPC Library → 8080 libraryd,9095 独立管理。
  • 800 ms 预算逐段分配:20 + 30 + 300 + 20 + 300 + 30 + 100;单跳 300 ms,最多一次重试。
  • maxAttempts 是上限,deadline 才是约束;重试前检查剩余预算。
  • 失败传播要带原因码:UNAVAILABLE→503、DEADLINE_EXCEEDED→504、RESOURCE_EXHAUSTED→429。
  • 重试归客户端、熔断归网关、并发隔离归服务端、限流归网关,能力不叠加。
  • deadline 作为参数逐层传递,每层减去自己的开销,不能用固定超时。

阅读导航:上一节 14.3 成本、扩缩容与预算 | 下一节 15.2 联调、验收清单与故障注入

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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