
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 的超时决定,不是三个耗时相加。超时的错误是 TimeoutError,instanceof DOMException 和 instanceof 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 的问题。