侧边栏壁纸
博主头像
一笑痕

仙人之下我无敌,
仙人之上一换一。

  • 累计撰写 52 篇文章
  • 累计收到 7 条评论

手写 BFF 聚合接口,上游假死和 500 怎么处理

2026-9-15 / 0 评论 / 11 阅读

手写 BFF 聚合接口,上游假死和 500 怎么处理

404ms。上游假死的时候,首页 BFF 只花了这么久就把手里能拿的数据全交了出去:三块里两块有值,第三块标成 timeout。同一个服务里那个朴素版在同样条件下等到 3 秒,一个字节都没吐出来。

全文围着下面这张表转,每一节拆表里的一行。数字是本机跑的(node v26.8.1),一次运行浮动几十毫秒。

做法 实测结果 首页三块拿到几块
串行 await,上游 120ms 和 300ms 436ms 两块
并发 Promise.all 304ms 两块
不检查 res.ok,上游返回 500 错误体被当成数据合并 三块,第三块是错误信息
补上 res.ok,上游返回 500 整个接口 500 零块
不设超时,上游假死 1502ms 仍未返回 零块
Promise.all 配 10 秒超时 3 秒仍未交出响应,http_code=000 零块
allSettled 配每个 400ms 超时 404ms,第三块 rejected 两块,加一条 degraded
下游 100ms 放弃,上游 300ms 计数 3 变 4 上游照旧跑完

这八行是怎么量出来的

先起四个上游:第一个正常返回,第二个故意慢,第三个收到请求就不回应了,第四个直接回 500。下面每一段都能接着上一段跑。

import http from 'node:http';

const done = new Map(); // 上游自己的活儿跑完了没

const upstream = (name, delay, { fail = false, hang = false } = {}) =>
  new Promise((ok) => {
    const srv = http.createServer(async (req, res) => {
      if (hang) return; // 永不响应,模拟假死
      await new Promise((r) => setTimeout(r, delay));
      done.set(name, (done.get(name) || 0) + 1);
      if (fail) {
        res.writeHead(500, { 'content-type': 'application/json' });
        return res.end(JSON.stringify({ error: name + ' 挂了' }));
      }
      res.writeHead(200, { 'content-type': 'application/json' });
      res.end(JSON.stringify({ from: name, delay }));
    });
    srv.listen(0, '127.0.0.1', () => ok({ name, port: srv.address().port }));
  });

const A = await upstream('user', 120);                  // 正常,120ms
const B = await upstream('order', 300);                 // 慢,300ms
const C = await upstream('coupon', 0, { hang: true });  // 假死
const D = await upstream('points', 0, { fail: true });  // 返回 500

const url = (o, p) => `http://127.0.0.1:${o.port}${p}`;

// 只看 IO,不检查状态码
const bare = (o, p, timeout) =>
  fetch(url(o, p), timeout ? { signal: AbortSignal.timeout(timeout) } : {}).then((r) => r.json());

// 检查状态码的版本
const checked = async (o, p, timeout) => {
  const r = await fetch(url(o, p), timeout ? { signal: AbortSignal.timeout(timeout) } : {});
  if (!r.ok) throw new Error(`${o.name} 返回 ${r.status}`);
  return r.json();
};

第一、二行:436 对 304

两个上游,一个 120ms 一个 300ms:

let t0 = process.hrtime.bigint();
await bare(A, '/u');
await bare(B, '/o');
// 串行 await 两次        436ms

t0 = process.hrtime.bigint();
await Promise.all([bare(A, '/u'), bare(B, '/o')]);
// Promise.all 并发       304ms

304 不是 436 除以二,是两个里最慢那个的 300ms 加上一点点开销。上游越多,这个差距越明显。这条没什么新鲜的,但值得先量一遍,因为后面所有讨论都建立在「并行是默认选择」上。

第三行:fetch 对 500 不抛错

这一条踩得最狠。上游返回 500 的时候,fetch 不会 reject:

const r = await fetch(url(D, '/p')); // D 就是那个返回 500 的上游
console.log(r.ok, r.status);
// false 500   ← 这一行没有抛异常
console.log(await r.json());
// { error: 'points 挂了' }

fetch 只在网络层失败的时候 reject,比如域名解析不了、连接被拒。HTTP 500 是一个正常返回的响应,res.ok 是 false,res.status 是 500,res.json() 还能把上游的错误体解出来。

所以 Promise.all 里没检查 res.ok 的话,上游挂了也看不出来:它返回的 {"error":"points 挂了"} 会被当成正常数据合并进响应,前端拿到一个 points 字段,里面装的是错误信息。这个 bug 在联调阶段完全看不出来,因为上游都活着。

补上检查很容易,就是取到响应之后加一行 if (!r.ok) throw,上面那段环境里的 checked 就是这个版本。

第四行:Promise.all 太狠了

补上 res.ok 之后,Promise.all 就真的会抛了:

try {
  await Promise.all([checked(A, '/u'), checked(D, '/p')]);
} catch (e) {
  console.log(e.message);
  // points 返回 500
}

问题是这时候 user 那一份已经成功的数据也一起没了。它在内存里,只是 Promise.all 选择不把它交出来。整个 BFF 接口 500,前端首页三块全空,而实际上只有一块数据拿不到。

Promise.all 的语义是「全部成功才有意义」,这对事务型操作是对的,对首页拼数据就是错的选择。

第五、六行:假死上游比 500 难缠

那个上游接受连接之后永远不返回,fetch 就会一直挂着。不加超时是这样:

const waited = await Promise.race([
  Promise.allSettled([checked(C, '/c')]),
  new Promise((res) => setTimeout(() => res('还没返回'), 1500)),
]);
// '还没返回' (等了 1502ms)

同一个服务里另留了个朴素版做对比,它用的是 Promise.all 加 10 秒超时,撞上假死的上游之后:

curl -s -m 3 http://127.0.0.1:39200/api/home-naive
# 三秒到了还没交出任何响应,http_code=000

一个假死的上游,把 120ms 和 300ms 那两个已经准备好的数据一起扣住了。这就是 BFF 里最典型的故障形态,而且它比 500 更隐蔽,因为上游日志里什么都看不到。

第七行:allSettled 加逐个 400ms 超时

allSettled 永远会 resolve,每个任务的状态和结果都会原样返回,成功失败怎么分由调用方自己定:

const settled = await Promise.allSettled([checked(A, '/u'), checked(D, '/p')]);
console.log(settled.map((s) => s.status));
// [ 'fulfilled', 'rejected' ]

const merged = settled.map((s) =>
  s.status === 'fulfilled' ? s.value : { fallback: true, reason: s.reason.message }
);
console.log(merged);
// [ { from: 'user', delay: 120 }, { fallback: true, reason: 'points 返回 500' } ]

降级策略按业务定:优惠券拿不到就给 0 张并隐藏入口,用户信息拿不到就得整个接口报错,因为页面没它没法渲染。加上 AbortSignal.timeout 之后是这样:

const timed = await Promise.allSettled([
  checked(A, '/u', 400),
  checked(B, '/o', 400),
  checked(C, '/c', 400),
]);
// 三个都带 400ms 超时,总耗时 404ms
//   fulfilled { from: 'user', delay: 120 }
//   fulfilled { from: 'order', delay: 300 }
//   rejected  TimeoutError: The operation was aborted due to timeout

总耗时 404ms 由那个 400ms 的超时决定,不是三个耗时相加。超时的错误是 TimeoutErrorinstanceof DOMExceptioninstanceof Error 都是 true,所以在 catch 里按 e.name === 'TimeoutError' 分流是可行的,可以把它跟真报错区分开,在响应里标成 timeout 而不是 error

用 curl 打这个接口,正确版是这样的:

curl -s -w '\n  time_total=%{time_total}s\n' http://127.0.0.1:39200/api/home
{"data":{"user":{"id":1,"name":"金铭","vip":true},"order":{"count":3,"amount":"128.00"},"coupon":null},"degraded":[{"key":"coupon","reason":"timeout"}],"partial":true}
  time_total=0.407744s

第八行:超时之后,上游的活还在干

这条是测完才确认的。给一个需要 300ms 的上游挂 100ms 超时:

const before = done.get('order') || 0;
await Promise.allSettled([bare(B, '/o', 100)]);   // 下游只等 100ms
await new Promise((r) => setTimeout(r, 400));     // 等上游把自己的 300ms 跑完
console.log(before, done.get('order'));           // 我这边是 3 变 4

客户端 100ms 就放弃了,但上游那个 handler 的定时器照样走完,业务逻辑照样执行,计数从 3 变成 4。下游 abort 只是「这边不等了」,不等于「上游停下来」。node 的服务器不会因为对端断开就中断正在跑的 handler。

实际影响是:如果上游是一个很贵的查询,超时放弃它,成本照付。要真的掐掉,得让上游能拿到这个信号,往下一层层传 AbortSignal 或者自己的 cancel 机制。链路一长这件事就很难做干净,我目前的取舍是给关键上游留足够宽松的超时,不指望靠下游超时来省钱。

完整实现

import http from 'node:http';

const call = async (port, path, timeout) => {
  const r = await fetch(`http://127.0.0.1:${port}${path}`, {
    signal: AbortSignal.timeout(timeout),
  });
  if (!r.ok) throw new Error(`上游 ${path} 返回 ${r.status}`);
  return r.json();
};

http.createServer(async (req, res) => {
  if (req.url !== '/api/home') {
    res.writeHead(404, { 'content-type': 'application/json' });
    return res.end(JSON.stringify({ error: 'not_found', path: req.url }));
  }
  const tasks = [
    ['user', call(39201, '/user', 400)],
    ['order', call(39202, '/order', 400)],
    ['coupon', call(39203, '/coupon', 400)],
  ];
  const settled = await Promise.allSettled(tasks.map(([, p]) => p));
  const data = {};
  const degraded = [];
  settled.forEach((s, i) => {
    const key = tasks[i][0];
    if (s.status === 'fulfilled') {
      data[key] = s.value;
    } else {
      data[key] = null;
      degraded.push({
        key,
        reason: s.reason.name === 'TimeoutError' ? 'timeout' : s.reason.message,
      });
    }
  });
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
  res.end(JSON.stringify({ data, degraded, partial: degraded.length > 0 }));
}).listen(39200);

响应里那个 degraded 数组和 partial 标记要一起给前端。前端拿到 partial: true 才知道某块是降级数据,可以把它渲染成占位或者弱化展示,而不是把 coupon: null 当成「用户真有 0 张券」。这一步不做,BFF 的降级就白做了。

什么时候别上 BFF

如果只有前端一个消费方,上游本身也都快,那多加一层 BFF 只是多一跳网络和一个要维护的服务。三个请求放前端并发发出去,每块用骨架屏独立渲染,体验差不了多少,出问题也好定位。

会上 BFF 的场合是这三种:多个端复用同一套聚合结果;需要在服务端拿着用户 token 去调上游,不想把 token 发到浏览器;聚合结果可以缓存,或者要做统一的降级和埋点。写之前先量一遍上游耗时,如果三个上游加起来才 50ms,那是前端的问题,不是 BFF 的问题。

    🤞 分享