本节目标:把「一个页面要调三个下游」从串行等待改成并行扇出,并在某个下游变慢或失败时返回一个仍然可用的部分结果。
适用版本:JDK 25 + gRPC-Java 1.76.0(grpc-netty-shaded)。
承接的问题
9.1 的网关一次只处理一个资源:GET /books/{isbn} 调一次 GetBook,把结果转成 JSON。真实页面很少这么简单。一个图书详情页通常要三块数据:书的元数据、库存快照、相关推荐。串行调用三个下游,每跳 300 ms,最坏 900 ms;并行调用,最坏 300 ms。BFF(Backend for Frontend)的第一价值就是把这三跳压成一个并行扇出。
第二价值是裁剪。GetBook 返回的 Book 有四个字段,未来可能加十几个内部字段;页面只需要 isbn、title、author、stock。如果 BFF 直接把 proto 序列化成 JSON 返回,内部字段会变成对外契约,以后想删都删不掉。BFF 应该定义自己的 DTO,只暴露页面需要的字段。
第三价值是降级。库存服务挂了,页面仍然可以显示书名和作者,只是把「有货/无货」换成「库存暂不可用」;推荐服务挂了,页面少一块「相关推荐」,不影响下单。全有或全无的聚合,会把一个可选依赖的故障放大成整个页面 503。
linkd-v3 的 BFF 约束是:单跳 deadline 300 ms,总 deadline 800 ms,三个调用并行,任何一个可选调用失败都返回 degraded: true 和 warnings 列表。
核心机制一:并行扇出与单跳预算
并行扇出的关键是「三个调用在同一时刻发出」,而不是「三个调用都用了 CompletableFuture」。下面两种写法看起来都异步,实际完全不同:
串行:meta.get() -> related.get() -> stock.get() 最坏 900 ms
并行:allOf(meta, related, stock) 最坏 300 ms
第一种写法里,第二个 future 在第一个 get() 返回后才创建,仍然是一跳接一跳。第二种写法先在主线程创建三个 future,再 allOf 等待,三个调用同时进行。
| 调用 | 下游 | 是否必需 | 失败后的处理 |
|---|---|---|---|
| 元数据 | linkd-v3 gRPC GetBook | 是 | 整个请求失败,404/503/504 |
| 相关推荐 | linkd-v3 gRPC SearchBooks | 否 | 返回空列表,degraded=true |
| 库存快照 | libraryd HTTP GET /books/{isbn} | 否 | 回退到 GetBook.stock,degraded=true |
三个调用各有自己的 300 ms deadline。gRPC 用 CallOptions.withDeadlineAfter(300, MILLISECONDS),HTTP 用 HttpRequest.timeout(Duration.ofMillis(300))。不要只在 BFF 外层加一个 800 ms 的 orTimeout,那样一个卡住的下游会占满整个预算,另外两个快速返回的结果也救不了页面。
执行器要有界。并行扇出把「一个请求」放大成「三个任务」,如果每个请求都提交到无界线程池,1000 个并发页面就是 3000 个任务,堆和线程都会被吃光。BFF 用 16 个线程、128 个等待位的 ThreadPoolExecutor,队列满时拒绝并返回 503;阻塞式 gRPC 调用占线程,所以线程数要按「并发页面数 × 3」估算,不能按 CPU 核数。
如果下游客户端本身是异步的(例如 gRPC 的 ClientCalls.asyncUnaryCall 或 HttpClient.sendAsync),可以不用线程池,直接在回调里组合 future。那样并发上限由下游的连接池和 8.x 的 Bulkhead 控制,不占平台线程。linkd-v3 的 LibraryHttpClient 是同步 API,所以这里用线程池;改成异步版本时,把 supply 换成 sendAsync 即可,组合逻辑不变。
核心机制二:allOf 与部分失败
CompletableFuture.allOf 的语义是「所有 future 都完成」;只要有一个 future 异常完成,allOf 返回的 future 也异常完成,join() 会抛 CompletionException。直接用它聚合三个调用,一个可选依赖失败就会让整个页面失败,这正是要避免的。
做法是先把每个可选调用包装成「不会失败」的 Section:成功时带值和 degraded=false,失败时带空值和失败原因。必需调用保持原样,失败就让它向上抛。这样 allOf 只会在必需调用失败时失败。
两个 gRPC 调用的客户端封装如下。单跳 deadline 通过 CallOptions.withDeadlineAfter 传给每次调用,超时由 gRPC 自己取消,不会留下一个永远等不到的流。
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.api.SearchBooksRequest;
import io.grpc.CallOptions;
import io.grpc.Channel;
import io.grpc.stub.ClientCalls;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.TimeUnit;
public final class LibraryGrpcClient {
private final Channel channel;
public LibraryGrpcClient(Channel channel) {
this.channel = channel;
}
public Book getBook(String isbn, Duration timeout) {
return ClientCalls.blockingUnaryCall(
channel,
LibraryGrpc.getGetBookMethod(),
CallOptions.DEFAULT.withDeadlineAfter(timeout.toNanos(), TimeUnit.NANOSECONDS),
GetBookRequest.newBuilder().setIsbn(isbn).build());
}
public List<Book> search(String query, int limit, Duration timeout) {
Iterator<Book> books = ClientCalls.blockingServerStreamingCall(
channel,
LibraryGrpc.getSearchBooksMethod(),
CallOptions.DEFAULT.withDeadlineAfter(timeout.toNanos(), TimeUnit.NANOSECONDS),
SearchBooksRequest.newBuilder()
.setQuery(query)
.setPageSize(limit)
.build());
List<Book> result = new ArrayList<>(limit);
while (books.hasNext() && result.size() < limit) {
result.add(books.next());
}
return result;
}
}
blockingServerStreamingCall 返回的迭代器在 hasNext() / next() 时才会拉取消息,deadline 覆盖整个流的生命周期。服务端按 page_size=5 返回 5 条后正常结束,客户端不需要手动 cancel;如果服务端不遵守 page_size,客户端在取满 5 条后停止迭代,但底层流可能还开着,要靠 deadline 收尾。生产实现应该用异步 stub 在取满后显式 cancel,这里用阻塞版是为了让聚合逻辑更直观。
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.api.SearchBooksRequest;
import io.grpc.CallOptions;
import io.grpc.Channel;
import io.grpc.stub.ClientCalls;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
public final class BookDetailBff {
private static final Duration HOP = Duration.ofMillis(300);
private static final Duration TOTAL = Duration.ofMillis(800);
private final LibraryGrpcClient grpc;
private final LibraryHttpClient http;
private final Executor executor;
public BookDetailBff(LibraryGrpcClient grpc, LibraryHttpClient http, Executor executor) {
this.grpc = grpc;
this.http = http;
this.executor = executor;
}
public CompletableFuture<BookDetail> load(String isbn) {
CompletableFuture<Book> meta = supply(() -> grpc.getBook(isbn, HOP));
CompletableFuture<Section<List<Book>>> related = supply(
() -> grpc.search(isbn, 5, HOP))
.handle((value, error) -> error == null
? new Section<>(value, false, "")
: new Section<>(List.of(), true, "related_unavailable"));
CompletableFuture<Section<Optional<Book>>> stock = supply(
() -> http.getBook(isbn))
.handle((value, error) -> error == null
? new Section<>(value, false, "")
: new Section<>(Optional.empty(), true, "stock_unavailable"));
return CompletableFuture.allOf(meta, related, stock)
.thenApply(ignored -> toDetail(meta.join(), related.join(), stock.join()))
.orTimeout(TOTAL.toNanos(), TimeUnit.NANOSECONDS);
}
private BookDetail toDetail(Book book, Section<List<Book>> related,
Section<Optional<Book>> stock) {
int stockValue = stock.value().map(Book::getStock).orElse(book.getStock());
List<String> warnings = new ArrayList<>();
if (related.degraded()) {
warnings.add(related.reason());
}
if (stock.degraded()) {
warnings.add(stock.reason());
}
return new BookDetail(
BookCard.of(book, stockValue),
related.value().stream().map(BookCard::of).toList(),
!warnings.isEmpty(),
List.copyOf(warnings));
}
private <T> CompletableFuture<T> supply(Callable<T> task) {
return CompletableFuture.supplyAsync(() -> {
try {
return task.call();
} catch (Exception e) {
throw new CompletionException(e);
}
}, executor);
}
public record Section<T>(T value, boolean degraded, String reason) {
}
public record BookCard(String isbn, String title, String author, int stock) {
static BookCard of(Book book) {
return of(book, book.getStock());
}
static BookCard of(Book book, int stock) {
return new BookCard(book.getIsbn(), book.getTitle(), book.getAuthor(), stock);
}
}
public record BookDetail(BookCard book, List<BookCard> related,
boolean degraded, List<String> warnings) {
}
}
meta 失败时,allOf 异常完成,thenApply 不执行,异常原样传给调用方;BFF 的 HTTP 层把它映射成 404/503/504。related 和 stock 失败时,handle 把异常吞掉并返回一个 degraded 的 Section,allOf 正常完成,页面拿到部分结果。
orTimeout(TOTAL) 是最后一道保险,不是主要超时机制。三个调用各自有 300 ms deadline,正常情况下 allOf 在 300 ms 内完成;orTimeout 只在某个客户端没有遵守 deadline 时触发。它触发后不会取消底层调用,所以底层调用仍然会占着执行器线程直到自己的 deadline;这就是为什么每个调用都要有自己的 deadline,不能只靠外层 orTimeout。
stock.value().map(Book::getStock).orElse(book.getStock()) 是降级逻辑:libraryd 的直连库存失败时,回退到 GetBook 返回的库存快照。快照可能过期,所以响应里带 degraded=true,前端可以显示「库存信息可能延迟」。不要把回退值伪装成实时值,那会让用户下单后才发现没货。
核心机制三:裁剪成稳定的 DTO
Book 是内部 proto,BookCard 是对外 DTO。两者字段恰好相同,但不是同一个东西:Book 可以加字段、改字段号、加内部标记,BookCard 的 JSON 字段名和类型由 BFF 的契约决定。把 proto 直接返回,等于把 protobuf 的字段演进规则暴露给前端;前端可能依赖某个默认值不出现的行为,proto 一改就坏。
裁剪有三个层次:
- 字段裁剪:只保留页面需要的字段。详情页需要
isbn/title/author/stock,不需要内部version、created_at、tenant_id。 - 数量裁剪:推荐列表只取 5 条,搜索流只读前
page_size条,避免把整个流读进内存。 - 结构裁剪:把多个下游的结果合并成一个页面形状,而不是让前端自己拼三个接口。前端不需要知道「库存来自 libraryd、元数据来自 gRPC」。
预期响应如下(未执行,字段按 BookCard 裁剪;stock 直连失败时回退到 gRPC 快照):
{
"book": {
"isbn": "978-7-111-12345-6",
"title": "服务端通信工程",
"author": "Leeting Yan",
"stock": 12
},
"related": [
{"isbn": "978-7-111-12345-7", "title": "网络编程基础", "author": "Leeting Yan", "stock": 3}
],
"degraded": true,
"warnings": ["stock_unavailable"]
}
degraded 是给前端和监控看的布尔值,warnings 是给排障看的稳定原因码。不要把异常堆栈、下游地址、SQL 放进 warnings;原因码用固定的枚举字符串,和项目契约里的 STOCK_CONFLICT、UPSTREAM_UNAVAILABLE 保持同一套命名风格。
字段裁剪要做在 BFF,不要做在下游。让 GetBook 增加一个 fields 参数,会把「页面需要什么」的知识泄漏到服务层;服务层应该返回完整的领域对象,BFF 负责投影。如果某个字段的查询代价很高(例如实时库存),可以在下游提供单独的 RPC,但不要为了一个页面把领域对象切碎。
在 linkd-v3 里落地:降级策略与指标
降级策略要按「这块数据对用户决策有多重要」分级:
| 数据块 | 失败后的页面行为 | 是否计入错误率 |
|---|---|---|
| 元数据 | 整页 404/503/504 | 是 |
| 库存 | 显示「库存暂不可用」,允许浏览 | 否,计入降级率 |
| 推荐 | 隐藏推荐区 | 否,计入降级率 |
把可选依赖的失败计入总错误率,会让可用性数字被推荐服务拖垮;不计入又会让问题被掩盖。正确做法是分开两个指标:bff_request_errors_total 统计整页失败,bff_degraded_total{section="stock"} 统计降级。告警看整页错误率,排障看降级率。
BFF 还要防止「重试放大」。如果 BFF 对每个下游都重试,一个页面请求会变成 3 个下游 × 3 次尝试 = 9 次调用;三个下游再各自重试,放大更严重。BFF 的推荐做法是:只对幂等的读调用重试,重试放在单跳客户端里(7.1),BFF 层不做额外重试;可选依赖失败就降级,不要为了凑齐数据反复打已经变慢的下游。
缓存是另一个降级手段。元数据和推荐可以缓存 30 秒,库存不能缓存太久(否则用户看到有货、下单时没货)。缓存键要带租户和 API 版本,避免不同租户看到彼此的数据;缓存击穿时用 single-flight(同一个 key 只允许一个请求回源),不要让 100 个并发请求同时打向下游。
常见错误与边界
错误一:串行调用伪装成并行。 先 meta.get() 再创建 related 的 future,三个调用仍然是一跳接一跳。future 必须在等待之前全部创建。
错误二:allOf 直接聚合可选依赖。 一个推荐服务失败就让整个页面 500。可选依赖先 handle 成 Section,把异常转成降级值,再进 allOf。
错误三:只加外层 800 ms 超时。 外层超时触发后,底层调用还在跑,占着线程和连接。每个下游都要有 300 ms 的单跳 deadline,外层超时只是兜底。
错误四:无界线程池做扇出。 一个请求三个任务,1000 并发就是 3000 个任务。用有界队列,队列满时快速失败;或者改用异步客户端,不占平台线程。
错误五:直接把 proto 返回给前端。 proto 加字段、改默认值、改字段号都会影响前端。BFF 定义自己的 DTO,字段名和类型由对外契约决定。
错误六:降级值伪装成真实值。 库存查不到时回退到快照,却不告诉前端 degraded=true,用户会以为看到的是实时库存。降级必须可见。
错误七:把异常信息放进 warnings。 下游地址、SQL、堆栈会泄漏到前端。warnings 只放稳定的原因码,详细信息写日志,用 x-request-id 关联。
边界:orTimeout 不取消底层调用。 它只让外层 future 以 TimeoutException 完成,底层线程仍然占着,直到自己的 deadline 或调用返回。要真正取消,需要保留 ClientCall 或 Future 的引用并调用 cancel。
边界:并行扇出会放大下游 QPS。 一个页面请求变成三个下游调用,BFF 的 QPS 乘以 3 才是下游看到的 QPS。容量规划要按放大后的数字算,不能按页面 QPS 算。
边界:BFF 不是领域服务。 它不应该做业务规则、不应该直接连数据库、不应该成为第二个权限系统。它只做聚合、裁剪、协议转换和降级;领域规则留在下游服务。
小结
- 并行扇出的关键是先创建所有 future 再等待;串行
get()会把 300 ms 变成 900 ms。 - 每个下游有自己的 300 ms 单跳 deadline,外层 800 ms 只是兜底;
orTimeout不取消底层调用。 - 可选依赖先
handle成Section,把异常转成降级值,再进allOf;必需依赖失败才让整页失败。 - 库存失败回退到 gRPC 快照,并用
degraded=true和稳定原因码告诉前端;不要把降级值伪装成实时值。 - BFF 定义
BookCard等 DTO,做字段、数量、结构三层裁剪;不要直接返回内部 proto。 - 执行器有界,降级率和整页错误率分开统计;BFF 层不做额外重试,避免放大。
聚合解决了「一个页面多个下游」,但每个下游失败时到底返回 400、404、429、503 还是 504,还没有统一。9.3 把 gRPC 状态码、google.rpc.Status、ErrorInfo、RetryInfo 和 HTTP 状态码接成一张表,并给出服务端和网关两侧的完整映射代码。
阅读导航:上一节 9.1 HTTP 到 gRPC 的网关 | 下一节 9.3 错误与超时映射
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。