什么是 BFF?用 Node.js 做中间层要注意什么?

进阶场景题系统设计约 7 分钟读完

一句话回答

BFF(Backend For Frontend)是为某一种前端专门服务的后端层:Web、App、小程序各有一个,由前端团队用 Node.js 维护。它把多个后端微服务的接口聚合成页面需要的一个接口,裁剪掉不需要和不该暴露的字段,做鉴权转换(比如把用户的 Cookie 会话换成调用内部服务的令牌),也可以承担 SSR。代价是多了一跳网络延迟和一个可能出故障的节点,还容易把业务逻辑写进 BFF。实现上要做到:下游调用并发进行,每个调用都有超时,用 Promise.allSettled 区分核心数据和可降级的数据,配合熔断和缓存,避免某个下游变慢拖垮整个 BFF。

详细解析

为什么需要 BFF

后端按业务领域拆成了用户、订单、商品、推荐等微服务,接口是通用的;而前端的页面是按场景组织的,一个"我的"页面要用到好几个服务的数据:

  • 前端直接调用:一个页面发五六个请求,移动网络下很慢;还要在前端拼数据,各端重复实现一遍
  • 让后端为每个页面写聚合接口:后端团队变成前端的"接口工厂",需求排队
  • BFF:由前端团队维护一个贴近页面的接口层,按页面需要组合下游接口,迭代节奏由前端自己掌握
文本
 Web      App      小程序
  │        │         │
Web BFF  App BFF  小程序 BFF     ← 前端团队维护,按端裁剪
  └────────┼─────────┘
     API 网关 / 服务发现
  ┌──────┬──────┬──────┐
 用户   订单   商品   推荐       ← 后端微服务,提供通用的领域接口

BFF 的职责和边界

适合放在 BFF 不适合放在 BFF
聚合多个接口、组装成页面需要的结构 核心业务规则(价格计算、库存扣减、权限判定的依据)
裁剪字段:只返回页面要的,去掉敏感字段 直接读写其他服务的数据库
鉴权转换:校验用户会话,换成内部调用的凭证 长时间运行的任务和复杂计算
按端适配:App 要小图,Web 要大图
SSR、接口级缓存、灰度和 A/B 的分流

判断标准:这段逻辑换一个端还需不需要?需要的话,它属于后端服务,否则不同 BFF 会各写一份、逐渐不一致。

风险

  • 多一跳延迟:每个请求都多经过一次 BFF。BFF 和后端服务要部署在同一个机房或内网,下游调用要复用连接(keep-alive)
  • 单点和级联故障:所有流量都经过 BFF,它挂了整个端不可用;某个下游变慢,会让 BFF 的请求和连接堆积,进而拖垮其他接口。要多实例部署,并做好超时、熔断和限流
  • 业务逻辑泄漏:为了赶需求,在 BFF 里写业务判断,最后变成另一个难以维护的"大后端"
  • 团队能力:前端团队要承担服务端的运维职责,包括监控、告警、容量规划、值班

聚合的写法

  1. 并发:互不依赖的下游调用用 Promise.all / Promise.allSettled 同时发出,总耗时约等于最慢的那个,而不是所有调用之和
  2. 区分核心和非核心:核心数据(用户信息)失败,整个接口失败;非核心数据(推荐、广告)失败,降级为空,页面照常展示。Promise.all 只要有一个失败就整体失败,这里要用 allSettled 逐个判断
  3. 每个调用都设超时:非核心数据的超时应该更短,宁可不展示,也不能拖慢整个页面
  4. 熔断:某个下游持续失败时,暂时不再调用它,直接走降级逻辑,给它恢复的时间,原理见 熔断、降级和限流
  5. 缓存:变化慢的数据(配置、商品类目)在 BFF 做短时间缓存,减少对下游的压力

GraphQL 是另一种思路:让前端在一次请求里声明需要哪些字段,由 GraphQL 服务负责聚合和裁剪,适合字段组合非常多变的场景,但要额外处理查询复杂度限制和 N+1 问题。

代码示例

JavaScript
// 带超时的下游调用:超时或非 2xx 都当作失败
async function callJson(url, timeoutMs) {
  const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
  if (!res.ok) throw new Error(`${url} 返回 ${res.status}`)
  return res.json()
}

// "我的"页面:聚合三个下游
export async function getProfilePage(userId) {
  const [user, orders, recommend] = await Promise.allSettled([
    callJson(`${USER_SVC}/users/${userId}`, 500),
    callJson(`${ORDER_SVC}/orders?userId=${userId}`, 800),
    callJson(`${RECOMMEND_SVC}/recommend?userId=${userId}`, 300), // 非核心,超时更短
  ])
  if (user.status === 'rejected') throw user.reason // 核心数据失败,整个接口失败

  return {
    user: { id: user.value.id, name: user.value.name }, // 裁剪:敏感字段不出 BFF
    orders: orders.status === 'fulfilled' ? orders.value : [],
    recommend: recommend.status === 'fulfilled' ? recommend.value : [],
    // 告诉前端哪些模块降级了,方便展示提示和统计
    degraded: [orders, recommend].some((r) => r.status === 'rejected'),
  }
}

推荐服务即使要 2 秒才返回,也会在 300ms 时被放弃、推荐模块为空;整个接口最多等待约 800ms(最长的那个超时),不会被推荐服务拖慢。

面试官可能追问

BFF 和 API 网关有什么区别?

API 网关是所有流量的统一入口,负责和业务无关的通用能力:路由转发、认证、限流、日志、TLS 终止,一般由基础架构团队维护,所有端共用。BFF 面向某一个端,负责和页面相关的聚合和裁剪,由前端团队维护。常见的部署方式是网关在前、BFF 在后,两者可以共存。

超时时间怎么定?

从页面能接受的总耗时倒推:比如接口整体要在 1 秒内返回,扣掉 BFF 自身的处理时间,再按每个下游的历史 P99 耗时分配,核心依赖给得宽一些,非核心的给得紧一些。超时之后不要立刻无限重试,重试会放大下游的压力;需要重试时限制次数,只对幂等的读请求重试,并让总耗时不超过上游给的预算。

什么时候不需要 BFF?

只有一个前端、后端是单体应用、接口本来就是按页面设计的;或者团队里没有人能承担 Node.js 服务的运维。这时 BFF 只会增加一跳延迟和一个要维护的服务。引入它的前提是:确实有多端差异或频繁的聚合需求,并且前端团队愿意对线上服务负责。

易错点

  • 串行地 await 互不依赖的下游调用,总耗时变成各个调用之和
  • 用 Promise.all 聚合,一个非核心接口失败导致整个页面报错
  • 下游调用没有超时,一个慢服务让 BFF 的连接和内存不断堆积
  • 在 BFF 中实现核心业务规则,导致多个端的逻辑不一致

AI 模拟面试官

用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。