《互联网产品的服务端架构·产品、流量与基础能力》5.3 端上体验与服务端取舍

从「回声」为省一次请求把 2MB 数据塞进首屏接口的事故写起,拆解首屏时间、流量、电量和弱网四个端上约束,说明服务端能做什么、不该做什么,并用一个按优先级裁剪的 Go 预算函数把取舍变成可执行代码,避免首屏被一个大响应拖垮并显著放大故障半径。

首屏 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 应该报编码错误,而不是被当成预算足够。
  • 在服务端替客户端决定渲染。服务端负责字节和失败语义,渲染顺序应该由端上按体验决定。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查