图 1:本文封面,点明扇出并发与合并的设计主题。
先说明属性:这是一篇历史题目复盘,属于开放式设计题,出自本地历史面试资料,与任何公司的当前招聘无关。题面:一个接口内部要调用另外 5 个下游接口,每个下游都返回数据,本接口把数据合并后返回给上层。问:可能用到并发包下的哪些类?你会怎么实现?
面试现场,面试官期待你从「串行调 5 次」的朴素答案跳出来。串行意味着接口延迟是 5 个下游之和——每个 200ms 就是整整 1 秒,没有任何一个用户能接受。
核心结论:扇出并发 + 超时预算 + 分级降级,三件事缺一不可。
只答「用线程池并发一下」只能拿及格分。这道题的区分度全在后两件事。
第一,并发工具。
5 个调用互不依赖,就全部并行发出,等待全部完成再合并——这就是扇出-汇聚(fan-out/fan-in)模型。Java 里最顺手的是 CompletableFuture:supplyAsync 把每路调用提交进线程池,allOf 等全部完成;按 JDK 官方文档,它支持显式完成、异常完成与链式组合,单路失败可以用 exceptionally 就地兜底。如果调用之间有依赖(B 依赖 A 的结果),就先分层、层内并行。老一代写法是线程池加 CountDownLatch,语义就是「等 5 件事全部完成再放行」,同样成立,只是异常处理和结果收集要手写。切记不要让主线程串行 join:先等第一个再发起第二个,等于把并发退化回串行。
第二,超时预算。
聚合接口的总时延由最慢的一路决定。给接口定总预算,比如 500ms,分给每路下游 300ms:单路超时立即放弃,绝不允许最慢的下游拖垮整单。Java 9 起 CompletableFuture 有 orTimeout 可以直接声明超时;工程里更常见的做法是给每路调用包一层「超时即取消」的守护。
第三,降级策略。
下游必须分两类。核心下游(商品主信息、订单主数据)失败,合并没有意义,整单返回错误;非核心下游(推荐、评论数、广告位)失败,用缓存兜底或直接留空,接口照常返回。降级不是可选项:没有兜底的聚合接口,可用性约等于 5 个下游可用性的乘积,越聚合越脆弱。
可运行的模拟。
下面用 Node.js 22.15.0 模拟这套设计(node fanout.js):5 路下游并发发出,其中推荐接口必超时、广告接口必故障,观察降级与总耗时:
'use strict';
// 模拟 5 个下游:各自耗时不同,其中两路出问题
function downstream(name, ms, shouldFail = false) {
return new Promise((resolve, reject) =>
setTimeout(() => (shouldFail ? reject(new Error(name + ' 故障')) : resolve(name + ' 数据')), ms));
}
// 单路包装:超时即放弃,失败后用兜底数据降级
function withGuard(promise, timeoutMs, fallback) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('超时')), timeoutMs)),
]).catch((err) => ({ degraded: true, reason: err.message, data: fallback }));
}
async function aggregate() {
const start = Date.now();
const tasks = [
withGuard(downstream('商品主信息', 120), 300, null), // 核心下游
withGuard(downstream('库存', 180), 300, '未知'),
withGuard(downstream('推荐', 500), 300, []), // 必超时 → 降级
withGuard(downstream('评论数', 90), 300, 0),
withGuard(downstream('广告', 150, true), 300, []), // 必失败 → 降级
];
const [core, ...rest] = await Promise.all(tasks);
if (core && typeof core === 'object' && core.degraded) {
return console.log('核心下游失败,整单返回错误,总耗时', Date.now() - start, 'ms');
}
console.log('核心数据:', core);
console.log('降级明细:', rest.filter((r) => typeof r === 'object' && r.degraded)
.map((r) => r.reason + ',兜底 ' + JSON.stringify(r.data)).join(';'));
console.log('接口正常返回,总耗时', Date.now() - start, 'ms');
}
aggregate();
实测输出:
核心数据: 商品主信息 数据
降级明细: 超时,兜底 [];广告 故障,兜底 []
接口正常返回,总耗时 308 ms
注意总耗时 300 毫秒出头:不是 5 路之和约 1 秒,而是被 300ms 的超时预算截断。并发加超时预算,把接口延迟从「求和」变成「取上限」——这正是扇出-汇聚的全部收益。
图 2:扇出并发、超时预算与降级合并的完整结构。
可能的追问。
「一路很慢但很重要怎么办?」单独给它更长的预算,或者先返回主体、慢路结果异步推送补齐。「线程池怎么配?」按下游耗时和 QPS 估算,聚合调用的线程池要和下游隔离,避免一个慢下游占满所有线程。「结果要求按固定顺序返回呢?」allOf 拿到的结果数组按提交顺序归位,谁先完成不影响合并顺序。
常见误区。
一,串行调 5 个下游还觉得够用,延迟直接五倍。二,只并发不设超时,最慢的下游决定整单生死。三,所有下游一视同仁,核心与非核心不分级,非核心故障把整单打挂。四,降级返回 null 却不标注,上游把「降级留空」当成「真的没有」,引发线上误判。
回到面试现场,建议的答题节奏:先画扇出-汇聚的骨架,再给并发工具(CompletableFuture 或线程池加 CountDownLatch),然后主动补超时预算与分级降级,最后落到线程池隔离与监控告警。设计题的高分在「你想到了故障」,不在「你会开线程」。
你写过的聚合接口里,最难缠的下游是哪一类?慢、抖还是爱挂?欢迎分享。