三个月后,没人说得清是快了还是慢
「回声」在三个月里加了评论、点赞、消息通知和推荐位。团队每周都发版,监控面板换过两次,告警阈值调过几次。季度复盘时,产品经理问了一个简单的问题:现在的首页比三个月前快还是慢?
有人说“感觉快了”,因为首页接口加了缓存;有人说“肯定慢了”,因为最近用户反馈加载久;运维翻出两张不同时间的截图,一张 p95 是 210 毫秒,一张是 280 毫秒,但采样口径、机器规格和流量结构都不一样。讨论最后停在“再观察观察”。
成本问题更模糊。有人记得上个月云账单涨了,但涨的是带宽、存储还是数据库,没人能拆开;推荐位上线的同时,异步任务也加了一轮重试,单请求成本到底变了多少,没有对照。于是“优化”变成了谁声音大谁先做,做完也无法证明有效。
这不是监控不够,而是缺少一份被固定下来的基线:在某个明确的时间点,用明确的口径记录一组指标,之后的每次改动都跟它比。基线的意义不是数字好看,而是下一次改动有对照。
基线是与下次改动对照的锚点
一条可用的基线要同时固定四件事:指标定义、采集口径、记录时间和容忍范围。少了任何一件,比较都会变成讲故事。
| 指标 | 定义 | 单位 | 案例假设值 | 容忍范围 | 采集口径 |
|---|---|---|---|---|---|
| 可用性 | 成功请求数 / 总请求数 | % | 99.90 | 相对变化 0.1% | 网关按 5 分钟窗口汇总 |
| p95 延迟 | 服务端处理耗时的 95 分位 | ms | 240 | 相对变化 10% | 只统计非静态请求 |
| 单请求成本 | 当日基础设施成本 / 当日请求数 | 美元/请求 | 0.00042 | 相对变化 10% | 含计算、存储、带宽,不含人力 |
表中的数值是案例假设值,不是本机实测,也不是任何真实产品的承诺。它们的作用是展示口径:可用性按百分比记录,延迟按毫秒记录,成本按“每请求”而不是“每月总额”记录,否则流量涨了就无法判断是规模效应还是效率下降。
“相对变化”是这套基线最容易被忽略的部分。可用性从 99.90% 掉到 99.80%,绝对差只有 0.1 个百分点,但失败请求比例翻了一倍;延迟从 240 毫秒涨到 264 毫秒,是 10% 的相对变化。用统一的比例表达,不同量纲的指标才能放进同一个发布闸门。
单请求成本要特别小心分母。用“月总成本 / 月总请求”会掩盖峰值:白天流量高、机器利用率高,夜间流量低、机器空转,月平均看起来正常,夜间的单位成本可能已经翻倍。至少同时看日均和峰值两个口径,或者把固定成本与可变成本拆开。
基线还要写清楚“什么时候记录”。大促前、架构迁移前、一次重大功能上线前,都是值得留基线的时刻。记录之后不要随手改口径;口径变了,旧基线就作废,需要重新采一份并注明原因。
失败场景:没有基线,优化变成故事
回到那次季度复盘。假设团队后来真的做了一次“性能优化”:给首页加了缓存,接口 p95 从 280 毫秒降到 230 毫秒,大家认为问题解决了。但同一季度还上线了推荐位,推荐服务在缓存未命中时会同步调用三个下游,尾延迟被放大。平均值好看了,p99 可能更差;首页快了,详情页慢了。
成本也是同样的故事。缓存减少了数据库读,但增加了内存和回源带宽;异步重试减少了用户等待,却让失败请求被放大执行。没有基线时,这些取舍只能靠感觉。更糟的是,下一次故障复盘会重复讨论同一个问题,因为上一次的结论没有被量化下来。
没有基线还会让发布闸门失效。团队可以规定“p95 不能超过 300 毫秒”,但 300 毫秒是拍脑袋定的,和当前系统的真实水平无关;也可以规定“成本不能涨”,但没有人知道昨天的成本是多少。闸门最后变成一句口号,越界了也能解释过去。
最实际的后果是资源分配。一个团队说“登录接口慢”,另一个团队说“首页更慢”,没有同一口径的对照,排期只能按职级和音量决定。三个月后再问同样的问题,答案仍然是一堆截图和感觉。
用 Compare 与 Gate 固定基线对比
下面的实现把基线定义成指标集合,Compare 计算每项的相对变化,Gate 根据容忍度决定是否放行。基线为 0 时比率没有定义,代码改用绝对差值并把 Ratio 记为 0,强制这类指标进入人工复核。
// file: internal/baseline/exit.go
// tier: L1
// verify: go test ./internal/baseline/
package baseline
import (
"fmt"
"math"
"sort"
"strings"
)
// Metric 是一项可比较的基线指标。Unit 只用于报告展示,
// 参与比较的是 Name 和 Value。
type Metric struct {
Name string
Value float64
Unit string
}
// Baseline 是一组指标的集合。同一份基线里名字不能重复。
type Baseline struct {
Items []Metric
}
// Drift 是单项指标相对基线的变化。Ratio 是有符号的相对变化,
// 基线为 0 时无法定义比率,因此记 0,只用 Delta 表达绝对变化。
type Drift struct {
Metric string
Delta float64
Ratio float64
}
// Compare 逐个指标计算变化。两边的指标集合必须完全一致:
// 缺项意味着数据采集或配置已经漂移,不能靠 0 值悄悄补齐。
func Compare(current Baseline, base Baseline, tolerance float64) ([]Drift, error) {
if tolerance < 0 {
return nil, fmt.Errorf("baseline: tolerance must not be negative")
}
cur, err := indexMetrics(current.Items)
if err != nil {
return nil, err
}
old, err := indexMetrics(base.Items)
if err != nil {
return nil, err
}
var missing []string
for name := range old {
if _, ok := cur[name]; !ok {
missing = append(missing, name)
}
}
for name := range cur {
if _, ok := old[name]; !ok {
missing = append(missing, name)
}
}
if len(missing) > 0 {
sort.Strings(missing)
return nil, fmt.Errorf("baseline: metric set mismatch, missing: %s", strings.Join(missing, ", "))
}
out := make([]Drift, 0, len(cur))
for name, m := range cur {
b := old[name]
d := Drift{Metric: name, Delta: m.Value - b.Value}
if b.Value == 0 {
d.Ratio = 0
} else {
d.Ratio = d.Delta / b.Value
}
out = append(out, d)
}
sort.Slice(out, func(i, j int) bool { return out[i].Metric < out[j].Metric })
return out, nil
}
// Gate 是发布闸门:任何指标的相对变化超过 tolerance 都拒绝放行,
// 错误信息按名字排序列出全部越界指标,方便一次修完。
// 基线为 0 的指标 Ratio 恒为 0,闸门不会拦截,必须人工复核。
func Gate(drifts []Drift, tolerance float64) error {
if tolerance < 0 {
return fmt.Errorf("baseline: tolerance must not be negative")
}
var bad []string
for _, d := range drifts {
if math.Abs(d.Ratio) > tolerance {
bad = append(bad, d.Metric)
}
}
if len(bad) == 0 {
return nil
}
sort.Strings(bad)
return fmt.Errorf("baseline: %d metric(s) exceed tolerance %g: %s",
len(bad), tolerance, strings.Join(bad, ", "))
}
// indexMetrics 校验名字非空、值有限且不重复。NaN 和 Inf 会让比较结果
// 永远为假,必须在入口拒绝,而不是等闸门静默放行。
func indexMetrics(items []Metric) (map[string]Metric, error) {
out := make(map[string]Metric, len(items))
for _, m := range items {
if strings.TrimSpace(m.Name) == "" {
return nil, fmt.Errorf("baseline: metric name is required")
}
if math.IsNaN(m.Value) || math.IsInf(m.Value, 0) {
return nil, fmt.Errorf("baseline: metric %q has non-finite value %v", m.Name, m.Value)
}
if _, dup := out[m.Name]; dup {
return nil, fmt.Errorf("baseline: duplicate metric %q", m.Name)
}
out[m.Name] = m
}
return out, nil
}
Compare 要求两边的指标集合完全一致,而不是只比较交集。采集口径变化、指标改名、某个面板漏了一项,都会表现为“缺项”;如果代码只比较交集,这些漂移会被静默忽略,基线看起来仍然有效。
Compare 返回全部指标的变化,Gate 只负责判断越界。这样报告里能看到“哪些指标接近阈值”,而不是只看到被拦截的那几项;下一次调整容忍度时,也有完整的数据可参考。
基线为 0 的指标是一个明确的边界。新业务上线第一个月,错误数、投诉数可能都是 0;第二个月出现 3 次,相对变化无法计算。Ratio 记 0 会让 Gate 放行,所以这类指标必须在发布检查单里单独列出,由人判断绝对值是否可以接受,不能假装闸门已经覆盖。
测试:零基线、缺项与越界名单
测试要固定三类行为:相对变化怎么算,异常输入怎么拒绝,越界时错误信息是否完整。
// file: internal/baseline/exit_test.go
// tier: L1
// verify: go test ./internal/baseline/
package baseline
import (
"math"
"strings"
"testing"
)
func TestCompareComputesDeltaAndRatio(t *testing.T) {
current := Baseline{Items: []Metric{
{Name: "p95", Value: 1200, Unit: "ms"},
{Name: "cost", Value: 0.12, Unit: "usd"},
}}
base := Baseline{Items: []Metric{
{Name: "p95", Value: 1000, Unit: "ms"},
{Name: "cost", Value: 0.10, Unit: "usd"},
}}
got, err := Compare(current, base, 0.10)
if err != nil {
t.Fatalf("Compare() error = %v", err)
}
if len(got) != 2 || got[0].Metric != "cost" || got[1].Metric != "p95" {
t.Fatalf("Compare() = %+v, want sorted cost and p95", got)
}
if math.Abs(got[0].Delta-0.02) > 1e-9 || math.Abs(got[0].Ratio-0.2) > 1e-9 {
t.Fatalf("cost drift = %+v, want delta 0.02 ratio 0.2", got[0])
}
if math.Abs(got[1].Delta-200) > 1e-9 || math.Abs(got[1].Ratio-0.2) > 1e-9 {
t.Fatalf("p95 drift = %+v, want delta 200 ratio 0.2", got[1])
}
}
func TestCompareZeroBaselineUsesDelta(t *testing.T) {
got, err := Compare(
Baseline{Items: []Metric{{Name: "incidents", Value: 3, Unit: "count"}}},
Baseline{Items: []Metric{{Name: "incidents", Value: 0, Unit: "count"}}},
0.05,
)
if err != nil {
t.Fatalf("Compare() error = %v", err)
}
if len(got) != 1 {
t.Fatalf("Compare() = %+v, want one drift", got)
}
if got[0].Delta != 3 || got[0].Ratio != 0 {
t.Fatalf("drift = %+v, want delta 3 and ratio 0", got[0])
}
}
func TestCompareRejectsMissingMetric(t *testing.T) {
current := Baseline{Items: []Metric{{Name: "p95", Value: 1}}}
base := Baseline{Items: []Metric{{Name: "p95", Value: 1}, {Name: "cost", Value: 1}}}
_, err := Compare(current, base, 0.1)
if err == nil || !strings.Contains(err.Error(), "cost") {
t.Fatalf("Compare() error = %v, want missing cost", err)
}
}
func TestCompareRejectsDuplicateMetric(t *testing.T) {
current := Baseline{Items: []Metric{
{Name: "p95", Value: 1},
{Name: "p95", Value: 2},
}}
base := Baseline{Items: []Metric{{Name: "p95", Value: 1}}}
_, err := Compare(current, base, 0.1)
if err == nil || !strings.Contains(err.Error(), "duplicate metric") {
t.Fatalf("Compare() error = %v, want duplicate metric", err)
}
}
func TestCompareRejectsNonFiniteAndNegativeTolerance(t *testing.T) {
base := Baseline{Items: []Metric{{Name: "p95", Value: 1}}}
if _, err := Compare(base, base, -0.1); err == nil || !strings.Contains(err.Error(), "tolerance") {
t.Fatalf("negative tolerance error = %v", err)
}
nan := Baseline{Items: []Metric{{Name: "p95", Value: math.NaN()}}}
if _, err := Compare(nan, base, 0.1); err == nil || !strings.Contains(err.Error(), "non-finite") {
t.Fatalf("NaN error = %v, want non-finite", err)
}
}
func TestGateListsEveryMetricOverTolerance(t *testing.T) {
drifts := []Drift{
{Metric: "p95", Delta: 50, Ratio: 0.05},
{Metric: "cost", Delta: -0.4, Ratio: -0.4},
{Metric: "errors", Delta: 0.2, Ratio: 0.2},
}
err := Gate(drifts, 0.1)
if err == nil {
t.Fatal("Gate() error = nil, want failure")
}
for _, name := range []string{"cost", "errors"} {
if !strings.Contains(err.Error(), name) {
t.Fatalf("Gate() error %q does not mention %q", err, name)
}
}
if strings.Contains(err.Error(), "p95") {
t.Fatalf("Gate() error %q mentions p95, which is within tolerance", err)
}
}
func TestGateAcceptsWithinTolerance(t *testing.T) {
drifts := []Drift{{Metric: "p95", Ratio: 0.1}, {Metric: "cost", Ratio: -0.05}}
if err := Gate(drifts, 0.1); err != nil {
t.Fatalf("Gate() error = %v, want nil", err)
}
if err := Gate(nil, 0.1); err != nil {
t.Fatalf("Gate(nil) error = %v, want nil", err)
}
if err := Gate(drifts, -1); err == nil || !strings.Contains(err.Error(), "tolerance") {
t.Fatalf("Gate(-1) error = %v, want tolerance", err)
}
}
TestGateListsEveryMetricOverTolerance 刻意放了三个指标:一个在阈值内,一个正向越界,一个负向越界。闸门必须同时报告 cost 和 errors,并且不能把 p95 写进错误信息。只报第一个越界项的实现会让团队修一个、发一次、再被拦一次。
TestCompareRejectsNonFiniteAndNegativeTolerance 固定了两个容易静默放行的输入。NaN 与任何数比较都为假,math.Abs(NaN) > tolerance 也是假;如果不拒绝,坏数据会被闸门当成“没有漂移”。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/baseline/ -count=1 -v -run 'TestCompare|TestGate'
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestCompareComputesDeltaAndRatio
--- PASS: TestCompareComputesDeltaAndRatio (0.00s)
=== RUN TestCompareZeroBaselineUsesDelta
--- PASS: TestCompareZeroBaselineUsesDelta (0.00s)
=== RUN TestCompareRejectsMissingMetric
--- PASS: TestCompareRejectsMissingMetric (0.00s)
=== RUN TestCompareRejectsDuplicateMetric
--- PASS: TestCompareRejectsDuplicateMetric (0.00s)
=== RUN TestCompareRejectsNonFiniteAndNegativeTolerance
--- PASS: TestCompareRejectsNonFiniteAndNegativeTolerance (0.00s)
=== RUN TestGateListsEveryMetricOverTolerance
--- PASS: TestGateListsEveryMetricOverTolerance (0.00s)
=== RUN TestGateAcceptsWithinTolerance
--- PASS: TestGateAcceptsWithinTolerance (0.00s)
PASS
ok webapp/internal/baseline 0.249s
0.249s 是一次本机运行的耗时,会随机器和负载变化。-run 'TestCompare|TestGate' 只匹配基线对比与闸门测试,不执行模块图和端到端运行器。
本卷小结与进入卷二
这一卷从产品约束走到可运行的服务端骨架,交付的不是一张架构图,而是一组可以执行、可以回归的产物:
internal/account:外部身份绑定、会话与设备上限、多端互踢。internal/profile:字段可见性、个人信息分级与最小化、注销与导出。internal/bff:按端裁剪与聚合、字段白名单、端能力对照。internal/api:错误码契约、校验与版本兼容窗口。internal/edge、internal/cache、internal/async:静态动态分界、缓存一致性、异步重试与对账。internal/baseline:模块图校验、端到端场景运行器、基线对比与发布闸门。
这些包共同回答了一个问题:一个互联网产品的服务端,最小可运行、可测试、可发布的形状是什么。答案不是某个框架,而是几条可检查的约束:数据有唯一所有者,模块依赖无环,用户旅程有端到端断言,关键指标有基线,越界有闸门。
基线把这一卷和之后的改动连起来。今天的 240 毫秒、99.90% 和 0.00042 美元不是荣誉数字,而是下一次发布要对照的锚点。没有它们,三个月后的复盘只能靠感觉;有了它们,快慢、贵贱和是否值得回滚才有共同的讨论基础。
卷二《内容、社交与实时互动》先讲内容模型,而不是先讲 feed 或聊天。原因是这一卷的账号、资料和 BFF 只解决了“人”的身份与展示,而内容平台的核心事实是“一条内容是谁的、对谁可见、能否被引用和删除”。内容模型不确定,社交关系、feed 分发、评论点赞和实时通知都会返工。先把内容的身份、所有权和生命周期定下来,后面的分发与互动才有稳定的地基。
阅读导航:上一节:13.2 端到端用例与测试 · 下一节:目录与学习路径 。
常见错误与边界
- 把基线当成绩单。只挑好看的指标记录,下一次改动仍然没有可比的对照。
- 不固定采集口径。采样窗口、机器规格和流量结构一变,比较就没有意义。
- 用月总成本除以月总请求。峰值和夜间的单位成本被平均掉,扩容决策会失真。
- 只比较交集。指标改名或漏采会被静默忽略,基线看起来一直有效。
- 让
NaN进入比较。所有比较都为假,闸门会把坏数据当成没有漂移。 - 基线为 0 时假装比率有效。
Ratio记 0 会放行,必须人工复核绝对差值。 - 闸门只报第一个越界项。团队要修一个发一次,回归轮次被无限拉长。
- 把基线写死在代码里。基线是运营数据,应该版本化保存并记录采集时间。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。