首页一次请求背后的五个下游
「回声」的首页要展示五块内容:当前用户、关注流、未读数、推荐位和运营配置。第一版客户端很直接:进首页并发五个请求,哪个失败就显示哪个模块的错误占位。产品觉得体验割裂,要求服务端提供一个 /api/home,一次把五块数据都返回。
服务端团队花了两天把五个下游调用串起来,用一个大 map[string]any 拼结果。上线后首页确实只发一个请求了,但故障开始变得奇怪:推荐服务偶尔超时,整个首页返回 500;运营配置改了一个字段,所有端的缓存一起失效;Web 需要的 SEO 字段被塞进 App 响应,小程序又抱怨首屏太大。
团队最初的判断是“下游不稳定”,于是给每个调用加了重试。重试让推荐服务的超时从偶发变成高频,因为超时的请求没有取消,继续占着连接;首页的 p99 从 400 毫秒涨到 3 秒,最后连用户服务也开始超时。
问题不在重试本身,而在于团队把 BFF 理解成了“把五个接口拼成一个接口”。聚合真正的难点是契约:每个字段从哪来、失败时算什么、缓存键怎么组成、每个端该看到多少。没有这四件事,聚合只是把五个故障点绑在一起。
聚合不是把接口拼成一个
一个可维护的 BFF 聚合接口,至少要在契约里回答六个问题。下面这张表把问题和常见反例放在一起,评审时可以直接逐行对照。
| 契约项 | 要写清的问题 | 常见反例 |
|---|---|---|
| 字段来源 | 每个字段的权威服务是谁,是否允许 BFF 计算 | BFF 自己算库存,和库存服务对不上 |
| 失败语义 | 哪些字段必需,哪些可降级,超时多久 | 任意下游失败就整页 500 |
| 缓存键 | 端、语言、登录态、实验分组、契约版本 | 所有端共用一个 home 键 |
| 裁剪规则 | 每个端允许哪些字段,上限多少 | Web 的 SEO 字段泄漏给小程序 |
| 版本策略 | 字段新增、废弃、改名如何兼容 | 直接删字段,老客户端白屏 |
| 可观测性 | 每个下游的耗时、降级、缓存命中 | 只记录总耗时,故障时无法定位 |
先看字段来源。BFF 的职责是编排和裁剪,不是重新定义业务。用户信息以用户服务为准,未读数以消息服务为准,推荐位以推荐服务为准。BFF 可以做格式转换、字段合并和默认值,但不能自己计算“库存还剩几件”这种有权威所有者的数据。一旦 BFF 开始计算,它就从读模型变成了第二个写模型,迟早会和源服务对不上。
失败语义比字段来源更容易被忽略。首页的五个模块不应该同等重要:用户身份和未读数是核心,推荐位和运营配置可以降级。契约要明确每个下游的超时预算、是否必需、失败时返回空数组还是错误标记。最糟的做法是“全部必需”,因为它把五个服务的可用性相乘,任何一个抖动都会变成首页不可用。
缓存键是聚合接口的第二条生命线。/api/home 的响应至少和端类型、语言、登录用户、实验分组、契约版本有关。如果所有端共用一个键,Web 先写入的 SEO 字段会被 App 读到;如果键里没有契约版本,灰度期间新旧字段会互相覆盖。缓存键不是“加个 user_id”这么简单,它应该和裁剪规则一起设计。
裁剪规则回答“每个端看多少”。同一个首页,App 可以拿完整的关注流和推荐理由,Web 需要额外的 SEO 字段,小程序只能拿首屏必需的少量字段。裁剪不是把字段删掉就完事,还要保证被删字段的缺失语义:客户端不能把“没返回”误判成“值为空”。
版本策略和可观测性是长期成本。字段只能加不能随便删,废弃要先标记再观察调用方;每个下游的耗时、超时、降级和缓存命中要单独记录,否则总耗时正常但某个模块一直降级,线上看起来“没故障”,用户体验已经在退化。
失败场景:一个下游超时,整个首页空白
假设推荐服务的 p99 从 200 毫秒涨到 2 秒。BFF 的实现用了一个 errgroup,五个调用里任意一个返回错误就取消其他调用并返回 500。首页因此从“推荐位空着”变成“整页打不开”。
后果分三层。第一层是用户可见:首页白屏,用户反复下拉重试,每次重试又打满推荐服务,形成重试风暴。第二层是缓存:500 响应没有被缓存,所有请求都穿透到下游;等到推荐服务恢复,流量已经在最高点,恢复过程又被压垮一次。第三层是排障:日志里只有“home failed”,没有记录是哪个下游、超时多久、其他四个调用是否成功,团队只能逐个服务排查。
更隐蔽的问题是超时预算没有分层。BFF 给五个下游都设了 3 秒超时,但首页的首屏预算只有 1 秒。于是最坏情况下用户等 3 秒才看到错误页,而这 3 秒里连接、线程和内存都被占着。并发一高,BFF 自己的资源先耗尽,故障从推荐服务扩散到整个首页。
修复方案是把聚合从“全有或全无”改成“核心必需 + 可选降级”:
- 用户身份和未读数是核心,超时 300 毫秒;失败返回明确的 503,并带
Retry-After,不返回空对象假装成功。 - 关注流、推荐位、运营配置是可选项,各自 200 毫秒;失败返回空列表,并在响应里标记
degraded: ["recommend"]。 - 所有下游调用共享一个总的截止时间,而不是各自 3 秒;总预算到点就取消未完成的调用。
- 降级响应使用更短的缓存 TTL,避免把“推荐为空”缓存一整天;核心失败不写缓存。
- 日志按下游记录耗时、状态和是否降级,首页总耗时之外还能看到每个模块的贡献。
这套改动的关键不是引入什么框架,而是把“哪些字段必需”从代码里的偶然顺序变成契约里的显式声明。没有这层声明,重试、超时和降级都只能靠猜。
用 allowlist 与 Merge 表达契约
契约落到代码,最基础的两个动作是“按端裁剪字段”和“按优先级合并数据”。下面的实现来自 webapp-lab 的 internal/bff 包。Aggregate 负责前者:它保留 Item.ID,只输出 allowlist 里的字段,缺失字段不补 null。Merge 负责后者:参数从左到右合并,后面的覆盖前面的,但 nil 不覆盖已有值。
// file: internal/bff/aggregate.go
// tier: L1
// verify: go test ./internal/bff/
package bff
// Item 是 BFF 输出列表中的一项。ID 是定位字段,不参与裁剪。
type Item struct {
ID string
Fields map[string]any
}
// Allowlist 是本端契约允许出现的业务字段名。
type Allowlist []string
// allows 使用线性查找。BFF 的字段表通常只有几十项,
// 这比每个请求构造一次 map 更直接,也更容易审查。
func (a Allowlist) allows(field string) bool {
for _, f := range a {
if f == field {
return true
}
}
return false
}
// Aggregate 按 allowlist 裁剪字段并保持输入顺序。
// 缺失的字段不会被补成 null;ID 始终保留。
func Aggregate(items []Item, allow Allowlist) []Item {
out := make([]Item, 0, len(items))
for _, item := range items {
fields := make(map[string]any)
for k, v := range item.Fields {
if allow.allows(k) {
fields[k] = v
}
}
out = append(out, Item{ID: item.ID, Fields: fields})
}
return out
}
// Merge 按参数顺序合并,后者覆盖前者。
// nil 表示“没有值”:既不覆盖已有字段,也不进入结果。
func Merge(parts ...map[string]any) map[string]any {
out := make(map[string]any)
for _, part := range parts {
for k, v := range part {
if v == nil {
continue
}
out[k] = v
}
}
return out
}
Aggregate 的第一个设计点是 ID 不参与裁剪。列表项的 ID 是定位和去重的基础,如果 allowlist 忘了写 id 就把 ID 删掉,客户端连“这一项是谁”都不知道。把 ID 放在 Fields 之外,等于在类型层面声明“它不是业务字段”。
第二个设计点是缺失字段不补 null。很多 BFF 为了让客户端“字段稳定”,会把 allowlist 里所有字段都填上,缺的写 null。这看起来友好,实际上把“服务端没有这个数据”和“这个字段的值就是空”混成一件事。客户端用 if field != null 判断时,永远分不清是降级、无权限还是数据真的为空。更好的做法是让字段缺席,契约里明确“缺席表示本次不可用”。
第三个设计点是 Allowlist 用切片而不是 map。BFF 的字段表通常只有几十项,线性查找足够快,而且切片字面量在代码评审时更容易读。如果字段表大到几百项,可以在包初始化时转成 set,但那是性能优化,不应该改变契约的表达方式。
Merge 的语义同样需要刻意设计。后面的 map 覆盖前面的,符合“默认配置 + 用户覆盖 + 实验覆盖”的组合顺序;nil 被跳过,是因为 nil 在 Go 里经常表示“这个来源没有提供值”,如果让它覆盖,前面的有效值会被无声抹掉。注意 false、0 和空字符串会正常覆盖,它们是有意义的值,不能和 nil 混为一谈。
这段代码没有处理嵌套 map 的深合并。首页的 payload 通常是扁平的模块字段,深合并会让“谁覆盖谁”变得难以推理;如果确实需要,应该在契约里显式定义路径,而不是让一个通用函数猜。
测试:缺失字段不补 null,nil 不覆盖
聚合逻辑的 bug 往往不在正常路径,而在“字段缺失”和“输入被修改”这两个边界。下面的测试把这两类边界固定下来,同时验证 allowlist 为空时仍然返回非 nil 的字段 map。
// file: internal/bff/aggregate_test.go
// tier: L1
// verify: go test ./internal/bff/
package bff
import "testing"
func TestAggregateTrimsToAllowlist(t *testing.T) {
items := []Item{
{ID: "u1", Fields: map[string]any{"name": "Ada", "email": "ada@example.com", "internal_note": "vip"}},
{ID: "u2", Fields: map[string]any{"name": "Lin"}},
}
got := Aggregate(items, Allowlist{"name", "email", "avatar"})
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got[0].ID != "u1" || got[1].ID != "u2" {
t.Fatalf("order changed: %+v", got)
}
if got[0].Fields["name"] != "Ada" || got[0].Fields["email"] != "ada@example.com" {
t.Fatalf("allowed fields missing: %+v", got[0].Fields)
}
if _, ok := got[0].Fields["internal_note"]; ok {
t.Fatal("disallowed field leaked")
}
if _, ok := got[1].Fields["avatar"]; ok {
t.Fatal("missing field was padded")
}
if _, ok := got[1].Fields["email"]; ok {
t.Fatal("missing field was padded with a value")
}
if _, ok := items[0].Fields["internal_note"]; !ok {
t.Fatal("Aggregate mutated the input")
}
}
func TestAggregateEmptyAllowlist(t *testing.T) {
got := Aggregate([]Item{{ID: "u1", Fields: map[string]any{"name": "Ada"}}}, nil)
if len(got) != 1 || got[0].ID != "u1" {
t.Fatalf("unexpected item: %+v", got)
}
if got[0].Fields == nil {
t.Fatal("Fields must be a non-nil empty map")
}
if len(got[0].Fields) != 0 {
t.Fatalf("Fields = %+v, want empty", got[0].Fields)
}
}
func TestMergeLaterWinsButNilDoesNot(t *testing.T) {
base := map[string]any{"a": 1, "b": 2, "keep": "old"}
override := map[string]any{"b": 3, "keep": nil, "c": 4}
got := Merge(base, override)
if got["a"] != 1 || got["b"] != 3 || got["keep"] != "old" || got["c"] != 4 {
t.Fatalf("merged = %+v", got)
}
if base["b"] != 2 {
t.Fatalf("Merge mutated base: %+v", base)
}
}
func TestMergeDropsNilOnlyFields(t *testing.T) {
got := Merge(nil, map[string]any{"x": nil})
if got == nil {
t.Fatal("Merge returned nil map")
}
if _, ok := got["x"]; ok {
t.Fatal("nil value entered the result")
}
}
TestAggregateTrimsToAllowlist 同时检查了四件事:输出顺序不变、允许字段保留、禁止字段不泄漏、缺失字段不被补值。最后一行还验证输入没有被修改,因为 Aggregate 如果直接复用 item.Fields 再删除字段,调用方手里的原始数据会被悄悄改掉,后续的缓存或审计就会拿到错误内容。
TestAggregateEmptyAllowlist 关注的是空值语义。返回 nil map 和返回空 map 在 JSON 序列化后可能一样,但在 Go 调用方眼里不同:前者需要额外判空,后者可以直接 range。契约选择“始终返回非 nil 的 Fields”,让调用方少一个分支。
TestMergeLaterWinsButNilDoesNot 用三个键覆盖了三种情况:普通覆盖、nil 不覆盖、只存在于前者的键保留。它还检查 base 没有被修改,防止 Merge 为了省一次分配直接写进第一个参数。
TestMergeDropsNilOnlyFields 是边界中的边界:如果所有来源都只提供 nil,结果应该是空 map,而不是带一个 nil 值的 map。这个区别会影响 JSON 输出和字段计数,也会让“字段是否存在”的判断失真。
这些测试不验证字段来源是否权威,也不验证缓存键是否正确。那些属于契约评审和集成测试的范围;单元测试只负责把聚合函数的语义钉死,让上层可以放心地组合。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/bff/ -count=1 -run 'TestAggregate|TestMerge' -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestAggregateTrimsToAllowlist
--- PASS: TestAggregateTrimsToAllowlist (0.00s)
=== RUN TestAggregateEmptyAllowlist
--- PASS: TestAggregateEmptyAllowlist (0.00s)
=== RUN TestMergeLaterWinsButNilDoesNot
--- PASS: TestMergeLaterWinsButNilDoesNot (0.00s)
=== RUN TestMergeDropsNilOnlyFields
--- PASS: TestMergeDropsNilOnlyFields (0.00s)
PASS
ok webapp/internal/bff 0.312s
-run 的正则同时匹配 Aggregate 和 Merge 两组测试,避免把端能力和预算测试混进本节的输出。输出里的 0.312s 是一次本机运行的耗时,会随机器和负载变化,不能当成性能结论。
小结
BFF 的聚合不是把五个接口拼成一个 JSON,而是维护一份契约:字段来源、失败语义、缓存键、裁剪规则、版本策略和可观测性。缺了任何一项,聚合都会把多个下游的故障绑成一个更大的故障。
代码层面,Aggregate 用 allowlist 做按端裁剪,保留 ID、不补 null、不修改输入;Merge 用从左到右的覆盖顺序表达优先级,并让 nil 保持“没有值”的语义。两者都很小,但它们的边界决定了上层能不能安全地组合数据。
下一节会从端上体验出发,讨论首屏时间、流量、电量和弱网这些约束,看看服务端应该承担什么、又不该承担什么。
阅读导航:上一节:5.1 App、Web 与小程序的差异 · 下一节:5.3 端上体验与服务端取舍 。
常见错误与边界
- 把聚合写成字符串拼接。没有字段来源和失败语义,任何下游改动都会变成首页故障。
- 所有下游都设为必需。可用性相乘,一个推荐服务抖动就能让整页不可用。
- 降级时返回空对象。客户端无法区分“没有数据”和“服务失败”,会把降级当成真实结果。
- 所有端共用一个缓存键。Web 的字段会泄漏给 App,灰度字段会互相覆盖。
- 给每个下游独立设 3 秒超时。总预算被悄悄放大,用户等到的只是更慢的错误页。
- 降级响应使用正常 TTL。推荐为空被缓存一整天,恢复后用户仍然看不到内容。
- BFF 自己计算权威数据。库存、价格这类字段必须以源服务为准,否则会出现第二个写模型。
- 用 nil 覆盖有效值。Go 里 nil 常表示“没有提供”,合并时应跳过,而不是抹掉前面的值。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。