本节目标:在写验收清单之前,先明确每个组件的职责边界,避免「重试叠加」「熔断错位」「超时预算超支」这三类系统性问题。
端口: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 9094 | HTTP → gRPC | 协议转换、鉴权、限流、熔断、错误映射 | 业务查询逻辑、数据库访问 |
| comm-server 9093 | gRPC | Library 服务实现、并发控制、deadline 检查 | 重试下游、跨服务编排 |
| libraryd 8080 | HTTP | 图书数据与库存 | 感知调用方 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 ms | JSON 编码、压缩 |
| 余量 | 100 ms | 调度抖动、GC、网络抖动 |
| 合计 | 800 ms | 20 + 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-server | Bulkhead 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 联调、验收清单与故障注入
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。