晚高峰的一次抖动,把“优化”打回原形
「回声」是一个以关注关系为核心的图文社区,案例假设日活 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。平均值会被大量快请求稀释,长尾用户的体验完全看不见。
- 混用冷热流量。缓存命中的请求和未命中的请求不是同一个系统,必须分开报告。
- 扇出只算代码里的调用点。重试、补偿、批量拆分都会放大实际调用量。
- 成本只算在线计算。预计算、存储膨胀、跨区回源往往才是改造后新增的大头。
- 把扇出下降自动当成改善。它可能意味着覆盖度下降,必须配合覆盖度指标一起看。
- 接受负数或缺失值。负数通常意味着计算错误,缺失值会让比较悄悄跳过一项。
- 改造前后使用不同口径。基线、灰度、验收必须是同一套代码路径,否则数字不可比。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。