首屏 2MB 的“优化”
「回声」的首页原本要发四个请求:用户资料、关注流、推荐位和运营配置。产品觉得四个请求太多,要求服务端合并成一个,理由是“少一次往返就少一次等待”。服务端照做,把四块数据全部塞进 /api/home,一次返回 2MB JSON。
上线后首屏时间没有下降,反而在弱网下变得更差。假设 4G 有效带宽 2 Mbps,2MB 数据仅传输就要约 8 秒;即使 gzip 压到 400KB,低端 Android 解析这么大的 JSON 也要几百毫秒,内存峰值还会触发频繁 GC。更糟的是,四个模块的缓存周期完全不同:运营配置一天变一次,推荐位一分钟变一次,合并之后整包只能按最短周期失效,缓存命中率大幅下降。
团队原本想优化“请求数”,结果优化掉的是缓存、并发和渐进展示。这个案例说明,端上体验不是服务端单方面能决定的,但服务端可以通过响应体形状显著影响它。取舍的关键不是“合并还是不合并”,而是“哪些数据必须在首屏、哪些可以晚一点、哪些根本不该由这个接口承担”。
端上体验的四个约束
端上体验可以拆成四个约束:首屏时间、流量、电量和弱网。它们互相影响,但服务端能施加的影响并不相同。下面这张表把“端上表现”和“服务端能做什么、不该做什么”放在一起。
| 约束 | 端上表现 | 服务端能做什么 | 服务端不该做什么 |
|---|---|---|---|
| 首屏时间 | 网络传输、解析、渲染、布局 | 裁剪字段、压缩、分页、缓存、优先级 | 把首屏不需要的模块也塞进来 |
| 流量 | 用户套餐消耗、运营商计费 | 字段选择、增量更新、ETag/304 | 为了“字段稳定”补大量 null |
| 电量 | 无线唤醒、CPU 解析、后台任务 | 合并小请求、推送替代轮询、缓存 | 让客户端高频轮询来保实时 |
| 弱网 | 超时、重传、连接中断 | 幂等、可恢复、合理超时、降级 | 无上限重试、返回超大响应 |
先看首屏时间。它不只是“下载完”的时间,还包括解析、渲染和布局。一个 2MB 的 JSON 即使网络很快,解析和对象分配也会在低端设备上产生明显卡顿。服务端能做的是按首屏可见区域裁剪字段,把非首屏内容放到后续请求;不该做的是把“以后可能会用到”的字段一次性塞进来。
流量和电量经常被忽略,因为它们不直接体现在服务端指标里。假设一个用户每天打开首页 20 次,每次多传 500KB,一个月就是 300MB 的额外流量(案例假设值)。电量方面,频繁的小请求会让无线模块反复唤醒,合并请求确实能省电;但合并成一个巨大响应又会延长传输和解析时间,省下的唤醒可能被更长的 CPU 占用抵消。
弱网是最能暴露取舍是否合理的场景。弱网下丢包和超时更常见,服务端如果返回一个“全有或全无”的大响应,客户端只能整包重试;如果响应可以分块、可以降级、可以用 ETag 复用,客户端就能在有限带宽下先拿到最重要的部分。
服务端能做的归纳起来是三件事:决定返回多少、决定失败时返回什么、决定缓存多久。服务端不该做的是替客户端决定渲染顺序,也不该为了减少请求数牺牲渐进展示。端上体验是两端共同的产品,不是服务端把字节发出去就结束了。
失败场景:为省一次请求塞进 2MB
回到首页合并的例子。团队把用户资料、关注流、推荐位、运营配置、评论区预览全部塞进 /api/home,理由是“用户可能都会看到”。第一版在办公室 Wi-Fi 下测试,首屏 300 毫秒,大家觉得成功;上线后低端机和弱网用户的反馈完全不同。
假设一个低端 Android 用户在地铁里打开首页,网络下行 1 Mbps,响应 2MB,理论传输 16 秒;实际因为丢包和重传,可能 25 秒才拿到完整响应。客户端在等待期间只能显示骨架屏,用户以为卡死,连续杀掉应用重开三次,每次都重新发起 2MB 请求。服务端看到的是首页 QPS 上涨,但成功率和留存下降。
第二个后果是缓存失效。运营配置的缓存周期是一天,推荐位是一分钟,合并后整包只能按一分钟失效。假设首页每天 100 万次请求(案例假设值),原本 99% 的运营配置可以从缓存命中,现在每分钟都要重新组装整包,缓存层和下游的压力都被放大。
第三个后果是故障半径。评论区预览的下游超时,整包返回 500;用户连最基本的关注流都看不到。为了“少一次请求”,团队把五个模块的可用性绑在了一起,任何一个模块抖动都会变成首页不可用。
修复方案不是简单地拆回四个请求,而是按首屏价值分层:
- 首屏只保留用户身份、关注流第一页和未读数,响应体预算 200KB 以内。
- 推荐位和运营配置放到首屏之后的第二个请求,允许失败和空占位。
- 评论区预览按需加载,不进入首页契约。
- 每个接口独立缓存键和 TTL,运营配置长缓存,推荐位短缓存。
- 弱网下客户端先渲染已缓存的首屏,再后台刷新;服务端用 ETag 支持 304。
分层之后,请求数可能从 1 个变成 3 个,但首屏可用的时间从“等 2MB”变成“先拿 200KB”。在弱网下,这个差别比一次往返的几十毫秒大得多。
用预算把取舍变成可执行代码
“首屏预算 200KB”如果只写在文档里,下一次需求评审就会被“再加一个字段”慢慢突破。更可靠的做法是把预算写成函数:给定 payload 和预算,按字段优先级裁剪,核心字段绝不静默丢弃。
下面的实现来自 webapp-lab 的 internal/bff 包。Budget 同时限制字节数和顶层字段数,Trim 先删 Optional,再删 Secondary;如果只剩 Core 仍然超预算,返回错误而不是继续删。字节数按 JSON 序列化后的长度计算,因为那才是客户端真正要解析的字节。
// file: internal/bff/tradeoff.go
// tier: L1
// verify: go test ./internal/bff/
package bff
import (
"encoding/json"
"fmt"
"sort"
)
// Priority 是预算不足时的裁剪顺序。
type Priority int
const (
Core Priority = iota
Secondary
Optional
)
// fieldPriority 是示例契约。真实项目应由 BFF 契约文件生成;
// 未登记的字段按 Core 处理,避免新增字段被静默丢弃。
var fieldPriority = map[string]Priority{
"user": Core,
"unread": Core,
"updated_at": Core,
"feed": Secondary,
"summary": Secondary,
"recommend": Optional,
"debug": Optional,
}
func priorityOf(field string) Priority {
if p, ok := fieldPriority[field]; ok {
return p
}
return Core
}
// Budget 是单次响应的硬预算,两个上限都必须为正。
type Budget struct {
MaxBytes int
MaxFields int
}
// Trim 在预算内裁剪 payload:先丢 Optional,再丢 Secondary。
// Core 字段不会被静默删除;只剩 Core 仍超预算时返回错误。
func Trim(payload map[string]any, budget Budget) (map[string]any, error) {
if budget.MaxBytes <= 0 || budget.MaxFields <= 0 {
return nil, fmt.Errorf("bff: budget must be positive")
}
out := clone(payload)
if ok, err := fits(out, budget); err != nil {
return nil, err
} else if ok {
return out, nil
}
for _, level := range []Priority{Optional, Secondary} {
for _, name := range fieldsAt(out, level) {
delete(out, name)
ok, err := fits(out, budget)
if err != nil {
return nil, err
}
if ok {
return out, nil
}
}
}
raw, _ := json.Marshal(out)
return nil, fmt.Errorf(
"bff: core fields exceed budget: bytes=%d/%d fields=%d/%d",
len(raw), budget.MaxBytes, len(out), budget.MaxFields,
)
}
func clone(payload map[string]any) map[string]any {
out := make(map[string]any, len(payload))
for k, v := range payload {
out[k] = v
}
return out
}
func fieldsAt(payload map[string]any, level Priority) []string {
var names []string
for name := range payload {
if priorityOf(name) == level {
names = append(names, name)
}
}
sort.Strings(names)
return names
}
func fits(payload map[string]any, budget Budget) (bool, error) {
if len(payload) > budget.MaxFields {
return false, nil
}
raw, err := json.Marshal(payload)
if err != nil {
return false, fmt.Errorf("bff: encode payload: %w", err)
}
return len(raw) <= budget.MaxBytes, nil
}
这段代码有几个需要说明的边界。
第一,字段优先级由 fieldPriority 显式登记,未登记字段按 Core 处理。这是保守选择:新增字段如果忘了登记,最坏结果是预算超限报错,而不是被悄悄删掉。真实项目里这张表应该由 BFF 契约生成,示例为了自包含写成包级映射。
第二,裁剪顺序是 Optional 再到 Secondary,并且每删一个字段就重新计算一次预算。这样可以在满足预算的前提下尽量少删字段,而不是“先把所有 Optional 删光再看”。字段名在每一层内部按字典序排序,保证同样的输入得到同样的输出,便于测试和缓存。
第三,fits 先检查字段数,再序列化检查字节数。字段数超限时不需要序列化,省一次分配;字节数检查用 json.Marshal,对包含 chan、func 等不可序列化值的 payload 会返回错误,而不是把它当成“预算足够”。
第四,Core 超预算时返回错误,错误信息带上当前字节数、字段数和预算。这个错误应该被监控,因为它意味着契约设计和实际数据发生了冲突,不能靠客户端“多等一会儿”解决。
代码没有做嵌套字段的裁剪。首页 payload 的顶层字段通常对应模块,模块内部再裁剪会让“删了哪个字段”难以解释;如果确实需要,应该在模块自己的契约里定义预算,而不是让一个通用函数递归猜。
测试:核心字段不能被静默裁掉
预算逻辑最重要的不是“能删字段”,而是“什么时候不能删”。下面的测试用固定长度的字符串构造 payload,分别覆盖 Optional 优先、Secondary 兜底、字段数限制、Core 报错、非法预算和不可序列化值。
// file: internal/bff/tradeoff_test.go
// tier: L1
// verify: go test ./internal/bff/
package bff
import (
"strings"
"testing"
)
func samplePayload() map[string]any {
return map[string]any{
"user": "u1",
"unread": 3,
"feed": strings.Repeat("f", 100),
"recommend": strings.Repeat("r", 100),
}
}
func TestTrimKeepsPayloadWithinBudget(t *testing.T) {
payload := map[string]any{"user": "u1", "unread": 3}
got, err := Trim(payload, Budget{MaxBytes: 1024, MaxFields: 4})
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got["user"] != "u1" {
t.Fatalf("payload changed: %+v", got)
}
}
func TestTrimDropsOptionalBeforeSecondary(t *testing.T) {
got, err := Trim(samplePayload(), Budget{MaxBytes: 150, MaxFields: 4})
if err != nil {
t.Fatal(err)
}
if _, ok := got["recommend"]; ok {
t.Fatal("optional field was kept")
}
if got["feed"] == nil {
t.Fatal("secondary field was dropped too early")
}
if got["user"] != "u1" || got["unread"] != 3 {
t.Fatalf("core fields changed: %+v", got)
}
}
func TestTrimDropsSecondaryWhenStillOver(t *testing.T) {
got, err := Trim(samplePayload(), Budget{MaxBytes: 100, MaxFields: 4})
if err != nil {
t.Fatal(err)
}
if _, ok := got["recommend"]; ok {
t.Fatal("optional field was kept")
}
if _, ok := got["feed"]; ok {
t.Fatal("secondary field was kept")
}
if len(got) != 2 {
t.Fatalf("got %d fields, want 2 core fields", len(got))
}
}
func TestTrimHonorsFieldCount(t *testing.T) {
got, err := Trim(samplePayload(), Budget{MaxBytes: 10000, MaxFields: 2})
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got["user"] != "u1" || got["unread"] != 3 {
t.Fatalf("core fields changed: %+v", got)
}
}
func TestTrimRefusesToDropCore(t *testing.T) {
payload := map[string]any{"user": "u1", "unread": 3}
_, err := Trim(payload, Budget{MaxBytes: 1000, MaxFields: 1})
if err == nil || !strings.Contains(err.Error(), "core") {
t.Fatalf("err = %v, want core budget error", err)
}
_, err = Trim(payload, Budget{MaxBytes: 5, MaxFields: 10})
if err == nil || !strings.Contains(err.Error(), "core") {
t.Fatalf("err = %v, want core budget error", err)
}
}
func TestTrimRejectsInvalidBudget(t *testing.T) {
for _, budget := range []Budget{{MaxBytes: 0, MaxFields: 1}, {MaxBytes: 10, MaxFields: -1}} {
if _, err := Trim(nil, budget); err == nil {
t.Fatalf("budget %+v: want error", budget)
}
}
}
func TestTrimRejectsUnencodableValue(t *testing.T) {
payload := map[string]any{"user": make(chan int)}
if _, err := Trim(payload, Budget{MaxBytes: 100, MaxFields: 3}); err == nil {
t.Fatal("want encode error")
}
}
func TestTrimDoesNotMutateInput(t *testing.T) {
payload := samplePayload()
if _, err := Trim(payload, Budget{MaxBytes: 100, MaxFields: 4}); err != nil {
t.Fatal(err)
}
if len(payload) != 4 {
t.Fatalf("input mutated: %+v", payload)
}
}
samplePayload 里两个 100 字符的字段让字节预算可控:完整 JSON 约 250 字节,去掉 Optional 后约 134 字节,再去掉 Secondary 只剩约 26 字节的核心数据。TestTrimDropsOptionalBeforeSecondary 用 150 字节预算,证明 Optional 被删掉后 Secondary 仍然保留;TestTrimDropsSecondaryWhenStillOver 用 100 字节预算,证明预算不够时才会继续删 Secondary。
TestTrimRefusesToDropCore 是整组测试的核心。它分别用字段数超限和字节数超限两种方式触发错误,并检查错误信息里包含 core。如果实现为了“让请求成功”把 Core 也删掉,这个测试会失败;如果没有返回错误而是返回空对象,测试同样会失败。
TestTrimRejectsInvalidBudget 覆盖 0 和负数。预算为 0 时任何响应都不可能通过,负数则会让比较逻辑失去意义,两者都应该在入口被拒绝。TestTrimRejectsUnencodableValue 用 chan 构造不可序列化的值,确保错误不会被误判成“预算不足”。
TestTrimDoesNotMutateInput 验证裁剪在副本上进行。如果 Trim 直接修改传入的 map,调用方后续的日志、缓存或审计会看到被删过的数据,排查问题时会产生误导。
这些测试不验证 200KB 是否适合某个端,也不验证字段优先级表是否符合产品。那些需要产品、客户端和服务端一起评审;单元测试只保证“预算函数不会静默丢核心字段”这条底线。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/bff/ -count=1 -run 'TestTrim' -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestTrimKeepsPayloadWithinBudget
--- PASS: TestTrimKeepsPayloadWithinBudget (0.00s)
=== RUN TestTrimDropsOptionalBeforeSecondary
--- PASS: TestTrimDropsOptionalBeforeSecondary (0.00s)
=== RUN TestTrimDropsSecondaryWhenStillOver
--- PASS: TestTrimDropsSecondaryWhenStillOver (0.00s)
=== RUN TestTrimHonorsFieldCount
--- PASS: TestTrimHonorsFieldCount (0.00s)
=== RUN TestTrimRefusesToDropCore
--- PASS: TestTrimRefusesToDropCore (0.00s)
=== RUN TestTrimRejectsInvalidBudget
--- PASS: TestTrimRejectsInvalidBudget (0.00s)
=== RUN TestTrimRejectsUnencodableValue
--- PASS: TestTrimRejectsUnencodableValue (0.00s)
=== RUN TestTrimDoesNotMutateInput
--- PASS: TestTrimDoesNotMutateInput (0.00s)
PASS
ok webapp/internal/bff 0.251s
-run TestTrim 只跑预算相关的八个测试。输出里的耗时是一次本机运行的结果,会随机器变化;判定标准仍然是退出码为 0 且没有 FAIL。
小结
端上体验由首屏时间、流量、电量和弱网共同决定,服务端能影响的是返回多少、失败时返回什么、缓存多久。为了省一次请求把 2MB 塞进首屏接口,通常会把传输、解析、缓存和故障半径一起放大。
合理的做法是按首屏价值分层:首屏只保留必需模块,其他内容延迟加载;每个接口独立缓存;服务端用预算函数按优先级裁剪,核心字段超预算时明确报错。预算不是一次性的评审结论,而是需要写进代码、能被测试回归的约束。
下一章会从资源和命令的语义出发,讨论接口契约怎样表达“创建、修改、取消”这些产品动作,以及为什么 HTTP 方法本身不足以承载业务语义。
阅读导航:上一节:5.2 BFF 的聚合、裁剪与契约 · 下一节:6.1 资源、命令与产品语义 。
常见错误与边界
- 把“少一次请求”当成目标。请求数只是成本之一,传输体积、解析时间和缓存命中同样影响首屏。
- 用平均网速做预算。弱网和低端机才是首屏失败的高发场景,预算要按较差分位设计。
- 合并不同 TTL 的数据。整包按最短周期失效,长缓存模块的命中率会被短缓存模块拖垮。
- 为了字段稳定补 null。大量 null 增加字节数,也让“缺失”和“空值”无法区分。
- 裁剪时先删核心字段。核心字段缺失会让页面不可用,正确顺序是 Optional、Secondary,最后报错。
- 预算只写在文档里。没有代码和测试,预算会在一次次需求评审中被慢慢突破。
- 忽略不可序列化的值。包含 chan、func 的 payload 应该报编码错误,而不是被当成预算足够。
- 在服务端替客户端决定渲染。服务端负责字节和失败语义,渲染顺序应该由端上按体验决定。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。