深蓝黑背景品牌封面:左侧大标题「一个接口并发调 5 个下游」,副标题「扇出并发与结果合并的设计」;右侧原创装饰——左侧一个请求节点向五个下游服务方块扇出箭头,五个结果再汇聚进一个合并节点,其中一路标注超时降级;左上标注「历史题目复盘 · 系统设计」,右上标注「Day 07 · 10-05」 图 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 的超时预算截断。并发加超时预算,把接口延迟从「求和」变成「取上限」——这正是扇出-汇聚的全部收益。

架构图:聚合接口收到请求后,用 CompletableFuture 或线程池把对 5 个下游的调用并行发出(扇出),全部完成或超时后在主线程合并结果返回(汇聚)。设计要点一:并发工具——5 个调用互不依赖就全部并行,有依赖的先分层再并行;用 CompletableFuture.allOf 等待全部,不要让主线程串行 join。设计要点二:超时预算——接口总预算 500ms,分给每路下游 300ms,单路超时立即取消,绝不让最慢的下游拖垮整个接口。设计要点三:降级策略——核心下游(如商品主信息)失败则整单失败返回错误;非核心下游(如推荐、评论数)失败用缓存兜底或直接留空,接口照常返回。底部给出串行与并发的耗时对比:串行 5×200ms=1000ms,并行后总耗时约等于最慢一路 200ms 级,吞吐与延迟全面占优 图 2:扇出并发、超时预算与降级合并的完整结构。

可能的追问。

「一路很慢但很重要怎么办?」单独给它更长的预算,或者先返回主体、慢路结果异步推送补齐。「线程池怎么配?」按下游耗时和 QPS 估算,聚合调用的线程池要和下游隔离,避免一个慢下游占满所有线程。「结果要求按固定顺序返回呢?」allOf 拿到的结果数组按提交顺序归位,谁先完成不影响合并顺序。

常见误区。

一,串行调 5 个下游还觉得够用,延迟直接五倍。二,只并发不设超时,最慢的下游决定整单生死。三,所有下游一视同仁,核心与非核心不分级,非核心故障把整单打挂。四,降级返回 null 却不标注,上游把「降级留空」当成「真的没有」,引发线上误判。

回到面试现场,建议的答题节奏:先画扇出-汇聚的骨架,再给并发工具(CompletableFuture 或线程池加 CountDownLatch),然后主动补超时预算与分级降级,最后落到线程池隔离与监控告警。设计题的高分在「你想到了故障」,不在「你会开线程」。

你写过的聚合接口里,最难缠的下游是哪一类?慢、抖还是爱挂?欢迎分享。