接口合并与数据聚合:BFF 层设计实践
去年接手一个电商项目,商品详情页在 App 端要连续发 9 个请求:商品基础信息、库存、价格、可用优惠券、店铺信息、评价摘要、物流模板、推荐商品、猜你喜欢。首屏 P95 接近 3 秒。产品的反馈是"太慢",前端的反馈是"接口太碎",后端的反馈是"接口都是现成的,你们自己拼一下"。
三方扯皮两周之后,我们花了三周给这个项目加了一层 BFF:请求数从 9 个降到 1 个,首屏 P95 落到 1.2 秒上下。这篇文章不讲概念史,只把过程里真正有用的东西整理出来,三个重点:
- BFF 到底解决什么问题(以及它不解决什么);
- 聚合接口怎么设计才不踩坑,附 FastAPI + httpx 的可运行代码;
- 我们后来用 GraphQL(Strawberry)重做一版的经验和教训,含 DataLoader 批量加载的实现。
代码都是 Python,基于 FastAPI,删掉了项目里的业务字段,核心逻辑原样保留。
一、先想清楚:BFF 解决的是什么问题
BFF(Backend for Frontend)这个词是 Sam Newman 在 2015 年前后带火的。一句话概括:给每一类前端配一个专属的"后端门面",由它来做接口的裁剪、合并和聚合。
结构大概是这样:
┌────────┐ ┌────────┐ ┌────────┐
│ App │ │ H5 │ │ PC 端 │
└───┬────┘ └───┬────┘ └───┬────┘
│ │ │
┌───▼────────────▼────────────▼────┐
│ BFF 层(可按端拆分) │
│ 裁剪字段 / 合并请求 / 聚合数据 │
└───┬───────────┬───────────┬─────┘
│ │ │
┌───▼───┐ ┌───▼────┐ ┌──▼─────┐
│商品服务│ │库存服务 │ │推荐服务 │
└───────┘ └────────┘ └────────┘
很多人会把 BFF 和 API 网关混为一谈,其实两者管的事情完全不同:
- 网关管横切关注点:鉴权、限流、路由、协议转换。它不理解业务,也不应该理解。
- BFF 管业务组装:详情页需要哪几个服务的数据、App 端要哪些字段、H5 端要哪些字段。它非常懂业务,而且只为某一类前端服务。
还有一个常见误解是把 BFF 当成"又一个通用 API 层"。不是的。BFF 的精髓恰恰在于不通用——App 的 BFF 返回 App 要的精确结构,H5 的 BFF 返回 H5 要的精确结构,谁也别迁就谁。一旦你开始把 BFF 做成"所有端共用的聚合层",它就退化成那个你本来想逃离的臃肿中台。
什么时候不要上 BFF?我的经验是两条:
- 前端就调一两个接口,硬加一层纯属给自己找活干;
- 团队里没有明确归属。BFF 最大的风险从来不是技术,是归属——这层代码谁来写、谁来改、挂了谁背锅。我们当时定的规矩是 BFF 归前端同学维护(用 Python 是因为组里前端都会写),后端只对下游服务的 SLA 负责。没有这个共识,BFF 迟早变成三不管的泥潭。
二、聚合接口设计:难点全在"失败的时候"
聚合接口本身十分钟就能写完,真正要花心思的是这些:
1. 必须真并行,不能假并行。 串行调 4 个各耗时 200ms 的服务,总耗时 800ms;并行调,总耗时取决于最慢的那个,200ms 出头。这是聚合接口存在的意义,写成串行就白干了。
2. 每个下游单独设超时,别用一个全局值糊弄。 商品、库存是核心数据,可以给到 1.5s;推荐、优惠券是增强数据,800ms 不返回就算了,页面少一块比整个页面打不开好得多。
3. 允许部分失败,并且把失败显式告诉前端。 这是最容易被忽略的一点。聚合接口不能是"一个挂了全挂",也不能是"悄悄把失败那块吞掉返回个 null"——前端分不清"没有优惠券"和"优惠券服务挂了",UI 就会渲染出误导性的内容。
4. 把异常收口在分片函数内部。 这样 asyncio.gather 永远拿到干净的结果类型,不用到处写 isinstance(r, Exception) 的判断。
下面是核心代码。先是分片调用部分(services.py):
# services.py
import logging
from dataclasses import dataclass, field
import httpx
logger = logging.getLogger("bff")
# 每个下游服务单独设超时,别用一个全局值糊弄
SERVICE_TIMEOUTS = {
"product": 1.5, # 核心数据,多等一会儿
"stock": 1.0,
"recommend": 0.8, # 增强数据,给少点,慢了就降级
"coupon": 0.8,
}
@dataclass
class Section:
"""聚合结果的一个分片。ok=False 时前端走兜底 UI。"""
ok: bool
data: dict = field(default_factory=dict)
error: str | None = None
async def call_service(
client: httpx.AsyncClient, name: str, path: str
) -> Section:
"""调一个下游服务,所有异常都在这里收口,返回值类型永远是 Section。"""
try:
resp = await client.get(path, timeout=SERVICE_TIMEOUTS[name])
resp.raise_for_status()
return Section(ok=True, data=resp.json())
except Exception as exc: # 超时、5xx、JSON 解析失败,统一处理
logger.warning("bff upstream failed: %s %s, err=%r", name, path, exc)
return Section(ok=False, error=f"{name}_unavailable")
然后是聚合入口(main.py):
# main.py
import asyncio
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI, HTTPException, Request
from services import call_service
CORE_SECTIONS = {
"product", "stock"} # 挂了就直接 502
EXTRA_SECTIONS = {
"recommend", "coupon"} # 挂了降级,页面照常出
@asynccontextmanager
async def lifespan(app: FastAPI):
# 复用连接池。每次请求新建 client 是压测里最常见的翻车点
limits = httpx.Limits(max_connections=200, max_keepalive_connections=50)
async with httpx.AsyncClient(
base_url="http://internal-api", limits=limits
) as client:
app.state.client = client
yield
app = FastAPI(lifespan=lifespan)
@app.get("/bff/app/product/{sku_id}")
async def product_detail(sku_id: str, request: Request):
client = request.app.state.client
tasks = {
"product": call_service(client, "product", f"/product/{sku_id}"),
"stock": call_service(client, "stock", f"/stock/{sku_id}"),
"recommend": call_service(client, "recommend", f"/recommend?sku={sku_id}"),
"coupon": call_service(client, "coupon", f"/coupon/usable?sku={sku_id}"),
}
# 异常已在分片内部收口,gather 拿到的全是 Section,不用 return_exceptions
results = dict(zip(tasks.keys(), await asyncio.gather(*tasks.values())))
# 核心分片失败 → 整体失败,没必要返回一个空壳页面让前端猜
for name in CORE_SECTIONS:
if not results[name].ok:
raise HTTPException(status_code=502, detail=f"{name} unavailable")
return {
"sku_id": sku_id,
"product": results["product"].data,
"stock": results["stock"].data,
# 增强分片:失败返回 None,由前端渲染兜底模块
"recommend": results["recommend"].data if results["recommend"].ok else None,
"coupon": results["coupon"].data if results["coupon"].ok else None,
# 关键设计:显式告诉前端哪几块挂了
"degraded": [n for n, r in results.items() if not r.ok],
}
几个值得多说一句的地方:
degraded字段是协议的正式成员,不是临时补丁。 前端拿到它可以做两件很有用的事:给兜底模块打上"数据加载失败,点击重试"的标记;把降级情况上报埋点,这样 BFF 的可用性数据在前端也有交叉验证。- 监控要按分片维度打。 "聚合接口成功率 99%"是没有意义的数字,必须拆成 product 成功率、recommend 成功率分别看。推荐服务挂三天,主接口成功率可能一动不动。
- 警惕"假并行"。 如果你在分片函数里用了任何同步阻塞调用(比如
requests、没加await的 redis 客户端),asyncio.gather会安静地退化成串行,而且本地自测根本发现不了,压测才现形。上线前用asyncio的 debug 模式或慢回调日志扫一遍。 - 别让 BFF 调 BFF。 链路里出现第二跳聚合时,超时预算就没人算得清了。
这套结构上线后,详情页接口的 P95 稳定在 300ms 左右(最慢的下游决定的),推荐服务抖动时对主流程零影响。
三、GraphQL 做 BFF:香,但别神化
REST 聚合跑顺之后,新的问题来了:端越来越多,字段差异越来越大。App 的详情页要 20 个字段,H5 只要 8 个,运营后台又要另一组。按第一节的思路,就得给每个端、每个页面维护一个聚合接口,接口数量开始失控,而且任何一个端加字段都要发版。
这正是 GraphQL 的适用场景:让前端自己声明要什么,BFF 只负责把"取数能力"标准化地暴露出去。 我们用 Strawberry + FastAPI 重写了一版,核心代码如下。
先定义 schema 和批量加载函数(schema.py):
# schema.py
import strawberry
# 这两个函数由 services 层实现,这里只示意签名:
# 批量接口是 GraphQL BFF 的命脉,没有它们 DataLoader 就是摆设
from services import batch_get_products, batch_get_stocks
@strawberry.type
class Product:
sku_id: str
title: str
price: str
@strawberry.type
class Stock:
sku_id: str
available: int
locked: int
@strawberry.type
class Query:
@strawberry.field
async def product(self, info, sku_id: str) -> Product | None:
# 注意:这里不是直接单查,而是走 DataLoader 合并批量请求
return await info.context["product_loader"].load(sku_id)
@strawberry.field
async def stock(self, info, sku_id: str) -> Stock | None:
return await info.context["stock_loader"].load(sku_id)
schema = strawberry.Schema(query=Query)
挂载到 FastAPI(main.py):
# main.py
from fastapi import FastAPI
from strawberry.dataloader import DataLoader
from strawberry.fastapi import GraphQLRouter
from schema import schema
from services import batch_get_products, batch_get_stocks
async def get_context():
# 关键:DataLoader 必须按请求新建。
# 它内置缓存,跨请求复用会把用户 A 的数据喂给用户 B
return {
"product_loader": DataLoader(load_fn=batch_get_products),
"stock_loader": DataLoader(load_fn=batch_get_stocks),
}
app = FastAPI()
app.include_router(GraphQLRouter(schema, context_getter=get_context), prefix="/graphql")
前端一个查询拿走自己要的字段,App 和 H5 各取所需,BFF 零改动:
query ProductDetail($skuId: String!) {
product(skuId: $skuId) {
title
price
}
stock(skuId: $skuId) {
available
}
}
DataLoader 是这套方案里唯一不能省的东西。 没有它,列表页渲染 20 个商品、每个商品再查一次库存,就是 1 + 20 次下游调用(经典的 N+1 问题)。DataLoader 把同一 tick 里的 .load() 调用合并成一次批量请求,batch_get_stocks 收到 20 个 key 一次查完,下游压力和串行单查完全不是一个量级。前提是下游服务得提供批量接口——所以我说批量接口是命脉,没有批量接口的下游,套 DataLoader 也救不了。
用了一年多,GraphQL 版 BFF 的教训也攒了几条,都是踩过坑的:
- HTTP 缓存基本告别。 请求全走 POST
/graphql,CDN 和浏览器缓存都用不上,缓存要么下沉到数据层,要么用 persisted query(把查询预注册成 ID,走 GET)绕回来。我们是后者,顺带还解决了"前端乱写查询"的管控问题。 - 必须限制查询深度和复杂度。 不限制的话,一个嵌套十层的查询能把下游打穿。Strawberry 生态里有现成的 query depth limiter 扩展,上线第一天就该配上。
- 错误模型其实和 REST 聚合是同一个思路。 GraphQL 天生支持"部分数据 + errors 数组"——product 取到了、recommend 挂了,响应里两者都如实呈现。第二节里
degraded字段想解决的问题,在 GraphQL 里是协议自带的,这点确实优雅。 - schema 即合同,治理要趁早。 字段废弃用
@deprecated标记而不是直接删,给前端留迁移期。我们第一版没立这条规矩,后来收拾旧字段花了两个月。
收尾:怎么选
| 场景 | 建议 |
|---|---|
| 端少、接口稳定 | 前端直连或极简聚合,别急着上 BFF |
| 多端、字段差异大、前端迭代快 | BFF + REST 聚合(本文第二节的方案) |
| 聚合接口开始爆炸、前端要自助取数 | BFF + GraphQL + DataLoader + persisted query |
最后说句实话:BFF 不是什么高深技术,它本质上就是"把拼装数据的工作从前端挪到一个离前端更近的地方"。真正决定成败的从来不是选 REST 还是 GraphQL,而是超时预算、部分失败、按分片监控这些脏活有没有做到位。概念一天就能讲完,细节要踩一个季度——希望这篇文章能帮你少踩几个。
本文所有代码基于 Python 3.11+ / FastAPI / Strawberry GraphQL,已脱敏,可直接运行验证。