《互联网产品的服务端架构·规模化与架构演进》9.1 旧链路的问题与基线

「回声」的 Feed 链路在日活八十万后晚高峰频繁抖动,团队却拿不出可复现的基线。本节把延迟、扇出与成本拆成统一口径,给出新旧链路对比函数的实现与测试,并复盘一次只统计成功请求、漏掉超时与跨区流量,导致改造后成本反而翻倍的基线事故与统计陷阱。

晚高峰的一次抖动,把“优化”打回原形

「回声」是一个以关注关系为核心的图文社区,案例假设日活 80 万,晚高峰集中在 20:00–22:00。Feed 首页请求从接入层进来后,服务端要读关注列表、拉取作者近期内容、做过滤与排序、补全计数与权限,再返回第一页二十条。链路里有十几个依赖,任何一个慢都会把 p99 推高。

产品团队说“首页有时候要转三秒”,工程团队的第一反应是加缓存。缓存加上去,监控上的平均延迟确实降了,但客服投诉没有减少。因为平均延迟被大量缓存命中的请求拉低,真正难受的是缓存未命中的那一小撮用户:新用户、长期未登录用户、刚关注了大量账号的用户。

于是团队决定重写 Feed 链路:预计算收件箱、把排序下沉、把计数异步化。方案评审时有人问了一句:“改造后怎么证明变好了?”会议室里给出了三个答案:延迟应该会降、机器应该会省、扇出应该会小。三个“应该”,没有一个数字。

这就是本节要解决的问题:在动手之前,先把旧链路量成一组可复现的数字。没有基线,改造就只是一次信仰迁移。而且基线不只是给改造前用的,它还是后面灰度、回退和验收的共同参照系。

基线是一组可复现的数字,不是一次感觉

基线至少要说清楚五件事:测的是哪段流量、在什么时间窗口、用什么口径统计、包含哪些失败、成本算到哪一层。少一项,数字就只能在同一个会议室里自娱自乐。

「回声」最后定下的口径如下(以下均为案例假设值,不是行业常数):

  • 流量:晚高峰 20:00–22:00 的自然流量,不剔除任何用户分层。
  • 窗口:连续 7 天,取每天同一时段,报告 p50 与 p99,而不是平均值。
  • 成功与失败:超时、降级、空结果都进入统计;只统计 200 响应会把最慢的请求删掉。
  • 冷热分层:缓存命中与未命中分别统计,首页与翻页分别统计。
  • 成本:包含计算、存储、跨可用区流量与下游调用,按“每千次请求”折算。

把旧链路的三项核心指标放进表里,问题会立刻显形:

指标旧链路观测值(案例假设)统计口径最容易被漏掉的部分
首页 p99 延迟1180 ms接入层到响应写完,含排队只测服务内部处理,漏掉网关排队
单次请求扇出64 次下游调用一次首页请求触发的 RPC 总数重试与补偿调用没有计入
每千次请求成本42 分计算、存储、跨区流量合计跨可用区回源与缓存重建流量
缓存命中率71%按请求数,不按用户数新用户与长期未登录用户几乎全 miss
超时率0.8%含客户端主动取消取消请求常被当成“未发生”

这张表最重要的不是数字,而是最后一列。基线失真几乎总是发生在“没被统计的那部分”:失败请求、重试、跨区流量、冷用户。它们数量不大,但恰好是最慢、最贵的那部分。

为什么用 p99 而不是平均值

Feed 是典型的“长尾决定体验”的场景。假设 95% 的请求 80 ms 返回,5% 的请求 3 秒返回,平均值约 226 ms,看起来还能接受;但每二十个用户就有一个在等三秒,投诉会集中在他们身上。p99 把长尾暴露出来,代价是需要更大的样本量,并且必须固定统计窗口,否则不同时间段的 p99 不可比。

为什么扇出要单独计量

扇出是 Feed 成本与稳定性的放大器。一次请求调用 64 次下游,任何一个下游抖动 1%,放大到首页就是明显的失败率;下游按调用次数计费时,扇出直接乘在账单上。更隐蔽的是重试:一次超时重试三次,账面扇出是 1,实际下游压力是 4。基线里的扇出必须按“实际发出的调用”统计,而不是按代码里写了几个 client 调用。

一次被“成功请求”美化的基线

「回声」的第一次基线报告非常漂亮:首页 p99 从 1180 ms 降到 640 ms,成本从每千次 42 分降到 31 分。团队据此批准了全量切换。切换后一周,客服工单上升了 40%,财务对账显示 Feed 相关成本反而涨了 18%。

复盘发现两个独立的统计错误。

第一,新旧两条链路的 p99 都只统计了 HTTP 200 且响应体非空的请求。旧链路的超时率是 0.8%,新链路因为预计算任务积压,超时率是 2.1%。超时的请求最慢,却被排除在直方图之外。把失败请求按超时上限计入后,新链路的真实 p99 是 2140 ms,比旧链路还差。

第二,成本只算了在线服务的 CPU 与数据库读。新链路把排序结果预计算到收件箱,写放大和存储膨胀没有进入这张账单;为了补齐冷用户,又对旧存储做了跨可用区回源。两部分加起来,每千次请求多出 9 分钱。

这个事故的教训不是“统计要仔细”,而是:基线必须和验收口径是同一套。如果改造前用一套宽松口径,改造后用另一套严格口径,比较就失去意义。更稳妥的做法是把口径写成代码里的结构体与函数,让每次比较都走同一条路径,而不是由人从三个报表里手工抄数。

把三项指标写成可比较的结构

配套代码 internal/feedmig/baseline.go 只做一件事:给定新旧两份指标,输出哪些改善了、哪些退化了。它不连接监控系统,也不负责采样,因为采样口径应该由数据平台保证,比较逻辑应该是纯函数。

// file: internal/feedmig/baseline.go
// tier: L1
// verify: go test ./internal/feedmig/

package feedmig

import "fmt"

// Metric 是一次基线测量得到的三个核心数字。
// 三个字段都用 int64,是因为成本以“分”为单位累计后会很快超过 int32。
type Metric struct {
	LatencyP99Ms int64
	Fanout       int64
	CostCents    int64
}

// Verdict 记录新旧两条链路的对比结论。
// Improved 与 Regressed 里放的是指标名,顺序固定,方便直接写进报告。
type Verdict struct {
	Name      string
	Improved  []string
	Regressed []string
}

// Compare 按固定方向比较两份基线。
// 延迟和成本下降是改善,上升是退化;fanout 上升是退化,下降不自动算改善,
// 因为 fanout 变小也可能意味着有收件人没有被覆盖到。
func Compare(old, new Metric) (Verdict, error) {
	if old.LatencyP99Ms < 0 || old.Fanout < 0 || old.CostCents < 0 ||
		new.LatencyP99Ms < 0 || new.Fanout < 0 || new.CostCents < 0 {
		return Verdict{}, fmt.Errorf(
			"feedmig: metrics must be non-negative, old=%+v new=%+v", old, new)
	}

	v := Verdict{Name: "flat"}
	if new.LatencyP99Ms < old.LatencyP99Ms {
		v.Improved = append(v.Improved, "latency")
	}
	if new.CostCents < old.CostCents {
		v.Improved = append(v.Improved, "cost")
	}
	if new.LatencyP99Ms > old.LatencyP99Ms {
		v.Regressed = append(v.Regressed, "latency")
	}
	if new.CostCents > old.CostCents {
		v.Regressed = append(v.Regressed, "cost")
	}
	if new.Fanout > old.Fanout {
		v.Regressed = append(v.Regressed, "fanout")
	}

	switch {
	case len(v.Improved) > 0 && len(v.Regressed) > 0:
		v.Name = "mixed"
	case len(v.Improved) > 0:
		v.Name = "improved"
	case len(v.Regressed) > 0:
		v.Name = "regressed"
	}
	return v, nil
}

三个设计点值得说明。

第一,字段全部用 int64。延迟毫秒、扇出次数、成本分,在单机上看都很小,但成本按天累计、按全站汇总后很容易越过 int32 上限;更麻烦的是溢出发生在生产环境而不是测试环境。用 int64 换来的是不用担心单位换算时的截断。

第二,方向是不对称的。延迟和成本下降算改善、上升算退化;扇出上升算退化,下降却不算改善。原因在 Feed 场景里很实际:扇出变小可能是合并了调用,也可能是漏发了收件人。把“扇出下降”自动当成好消息,会掩盖覆盖度问题,所以这里让它保持中性,交给覆盖度指标单独判断。

第三,负数直接报错。延迟不可能是负的,扇出和成本也不可能是负的;出现负数通常意味着上游做了 old - new 之类的错误计算,或者单位换算除了一个负系数。静默接受负数会让结论完全反转,早失败比事后对账便宜得多。

Verdict.Name 只是给报告用的摘要:全部变好是 improved,全部变差是 regressed,有好有坏是 mixed,没有变化是 flat。它不参与任何自动切流决策,切流要看 9.3 的门禁。

测试:方向、并列与坏输入

比较函数的测试重点不是“算得对”,而是“方向不会反”。下面的用例把改善、退化、混合、持平、扇出下降中性和负数输入都固定下来。

// file: internal/feedmig/baseline_test.go
// tier: L1
// verify: go test ./internal/feedmig/

package feedmig

import (
	"reflect"
	"testing"
)

func TestCompareImproved(t *testing.T) {
	old := Metric{LatencyP99Ms: 420, Fanout: 60, CostCents: 180}
	new := Metric{LatencyP99Ms: 260, Fanout: 40, CostCents: 120}

	got, err := Compare(old, new)
	if err != nil {
		t.Fatalf("Compare: %v", err)
	}
	if got.Name != "improved" {
		t.Fatalf("Name = %q, want improved", got.Name)
	}
	if want := []string{"latency", "cost"}; !reflect.DeepEqual(got.Improved, want) {
		t.Fatalf("Improved = %v, want %v", got.Improved, want)
	}
	if len(got.Regressed) != 0 {
		t.Fatalf("Regressed = %v, want none", got.Regressed)
	}
}

func TestCompareFanoutRegression(t *testing.T) {
	old := Metric{LatencyP99Ms: 300, Fanout: 50, CostCents: 150}
	new := Metric{LatencyP99Ms: 300, Fanout: 80, CostCents: 150}

	got, err := Compare(old, new)
	if err != nil {
		t.Fatalf("Compare: %v", err)
	}
	if got.Name != "regressed" {
		t.Fatalf("Name = %q, want regressed", got.Name)
	}
	if want := []string{"fanout"}; !reflect.DeepEqual(got.Regressed, want) {
		t.Fatalf("Regressed = %v, want %v", got.Regressed, want)
	}
	if len(got.Improved) != 0 {
		t.Fatalf("Improved = %v, want none", got.Improved)
	}
}

func TestCompareMixed(t *testing.T) {
	old := Metric{LatencyP99Ms: 500, Fanout: 40, CostCents: 200}
	new := Metric{LatencyP99Ms: 350, Fanout: 40, CostCents: 260}

	got, err := Compare(old, new)
	if err != nil {
		t.Fatalf("Compare: %v", err)
	}
	if got.Name != "mixed" {
		t.Fatalf("Name = %q, want mixed", got.Name)
	}
	if want := []string{"latency"}; !reflect.DeepEqual(got.Improved, want) {
		t.Fatalf("Improved = %v, want %v", got.Improved, want)
	}
	if want := []string{"cost"}; !reflect.DeepEqual(got.Regressed, want) {
		t.Fatalf("Regressed = %v, want %v", got.Regressed, want)
	}
}

func TestCompareFlat(t *testing.T) {
	m := Metric{LatencyP99Ms: 300, Fanout: 50, CostCents: 150}
	got, err := Compare(m, m)
	if err != nil {
		t.Fatalf("Compare: %v", err)
	}
	if got.Name != "flat" || len(got.Improved) != 0 || len(got.Regressed) != 0 {
		t.Fatalf("got %+v, want flat with no changes", got)
	}

	// fanout 下降不自动算改善:可能只是覆盖变少了。
	lower := m
	lower.Fanout = 20
	got, err = Compare(m, lower)
	if err != nil {
		t.Fatalf("Compare: %v", err)
	}
	if got.Name != "flat" || len(got.Improved) != 0 || len(got.Regressed) != 0 {
		t.Fatalf("fanout decrease should be neutral, got %+v", got)
	}
}

func TestCompareRejectsNegative(t *testing.T) {
	cases := []struct {
		old, new Metric
	}{
		{Metric{LatencyP99Ms: -1}, Metric{}},
		{Metric{Fanout: -2}, Metric{}},
		{Metric{CostCents: -3}, Metric{}},
		{Metric{}, Metric{LatencyP99Ms: -4}},
		{Metric{}, Metric{Fanout: -5}},
		{Metric{}, Metric{CostCents: -6}},
	}
	for _, c := range cases {
		if _, err := Compare(c.old, c.new); err == nil {
			t.Fatalf("Compare(%+v, %+v) = nil error, want error", c.old, c.new)
		}
	}
}

TestCompareFlat 里专门留了一个断言:扇出从 50 降到 20,结论仍然是 flat。这不是遗漏,而是有意的语义。如果未来产品确认“扇出下降且覆盖度不变”才算改善,应该新增一个指标,而不是偷偷改这个方向。

TestCompareRejectsNegative 覆盖了新旧两侧各三个字段。测试用表驱动写法,新增字段时只需要在表里加一行,不容易漏。

测试没有覆盖“指标单位不一致”,例如一边是毫秒、一边是微秒。这类错误无法从 Metric 内部发现,只能在采样层用明确的字段名和文档约束。LatencyP99Ms 里的 Ms 不是装饰,它是接口契约的一部分。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/feedmig/ -run 'TestCompare' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestCompareImproved
--- PASS: TestCompareImproved (0.00s)
=== RUN   TestCompareFanoutRegression
--- PASS: TestCompareFanoutRegression (0.00s)
=== RUN   TestCompareMixed
--- PASS: TestCompareMixed (0.00s)
=== RUN   TestCompareFlat
--- PASS: TestCompareFlat (0.00s)
=== RUN   TestCompareRejectsNegative
--- PASS: TestCompareRejectsNegative (0.00s)
PASS
ok  	webapp/internal/feedmig	0.250s

五个用例全部通过,退出码为 0。只跑 TestCompare 前缀是因为本节只关心基线比较;同包的 Divergence 与 Canary 会在 9.2 展开。

小结

基线的价值不在于数字好看,而在于它能被重复测量、被同一套口径比较。延迟要看 p99 并把失败请求算进去,扇出要按实际发出的调用计,成本要算到跨区流量与预计算写放大。三项指标缺一项,改造就会在另一个维度上付出代价。

把比较逻辑写成纯函数,是为了让“变好了没有”这个问题有唯一答案。函数只负责方向判断,采样口径与切流决策分别由数据平台和门禁负责,三层不要混在一起。

下一节讨论双写与灰度:有了基线,下一步不是直接切换,而是让新旧链路同时写、同时比,再用覆盖率决定什么时候切。

阅读导航:上一节:目录与学习路径 · 下一节:9.2 双写、灰度与切换 。

常见错误与边界

  • 只统计成功请求。超时与降级恰恰是最慢的一部分,剔除它们等于把基线调成“最好看的那条线”。
  • 用平均值代替 p99。平均值会被大量快请求稀释,长尾用户的体验完全看不见。
  • 混用冷热流量。缓存命中的请求和未命中的请求不是同一个系统,必须分开报告。
  • 扇出只算代码里的调用点。重试、补偿、批量拆分都会放大实际调用量。
  • 成本只算在线计算。预计算、存储膨胀、跨区回源往往才是改造后新增的大头。
  • 把扇出下降自动当成改善。它可能意味着覆盖度下降,必须配合覆盖度指标一起看。
  • 接受负数或缺失值。负数通常意味着计算错误,缺失值会让比较悄悄跳过一项。
  • 改造前后使用不同口径。基线、灰度、验收必须是同一套代码路径,否则数字不可比。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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