接口合并与数据聚合:BFF 层设计实践

去年接手一个电商项目,商品详情页在 App 端要连续发 9 个请求:商品基础信息、库存、价格、可用优惠券、店铺信息、评价摘要、物流模板、推荐商品、猜你喜欢。首屏 P95 接近 3 秒。产品的反馈是"太慢",前端的反馈是"接口太碎",后端的反馈是"接口都是现成的,你们自己拼一下"。

三方扯皮两周之后,我们花了三周给这个项目加了一层 BFF:请求数从 9 个降到 1 个,首屏 P95 落到 1.2 秒上下。这篇文章不讲概念史,只把过程里真正有用的东西整理出来,三个重点:

  1. BFF 到底解决什么问题(以及它不解决什么);
  2. 聚合接口怎么设计才不踩坑,附 FastAPI + httpx 的可运行代码;
  3. 我们后来用 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,已脱敏,可直接运行验证。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。