《互联网产品的服务端架构·产品、流量与基础能力》11.1 用户视角的 SLI

监控面板全绿,用户却反馈首页打不开,问题出在 SLI 的采集口径。文章从用户动作出发,区分请求可用性与用户可用性,说明首屏、可交互与接口成功率的差别,给出按请求计算的可用性与最近秩 P95 实现,并列出样本采集与校验边界,帮助团队在事故前把口径定清楚。

监控全绿,用户却说首页打不开

「回声」是一个内容社区,首页由静态 HTML、一个个性化推荐接口和前端渲染共同组成。周一下午,客服转来十几条反馈:首页一直白屏,刷新也没用。值班同学打开监控面板:CPU 35%,5xx 比例 0.08%,接口 p95 只有 120 毫秒,所有曲线都在阈值内。他回复“服务端正常,可能是用户网络问题”。

半小时后反馈增加到上百条,才有人发现:新发布的 JS 分片在部分 CDN 节点返回 404;同时个性化接口在弱网下要 7 秒才返回。服务端记录的是“200,120ms”,因为 404 由 CDN 返回,7 秒的等待发生在用户浏览器里。监控没有撒谎,它只是在回答另一个问题。

这个场景里,“首页能打开”是用户视角的事实,“CPU 不高”是服务器视角的事实。两者不矛盾,但不能互相替代。SLI 的起点必须是用户动作,而不是能轻松采到的机器指标。

很多团队把 SLI 写成“接口成功率”和“服务端耗时”,然后在事故复盘时发现,这两个数字都无法解释用户为什么用不了产品。原因不复杂:用户完成一个动作,往往要经过 CDN、静态资源、多个接口和浏览器渲染,任何一段失败,用户看到的都是同一个结果。

用户动作决定 SLI 口径

定义 SLI 时先写一句话:用户在什么场景下,做什么动作,什么算成功,从什么时候开始计时。对首页来说,可以写成“用户打开首页后 2.5 秒内看到可交互的首屏,且没有阻断性错误”。这句话同时规定了成功条件、时间起点和延迟预算,后面的采集与计算都围绕它展开。

服务器指标适合定位原因,不适合直接当 SLI。下面的对照表说明两者的分工,表中的数字都是案例假设值,不是实测结果。

指标视角能回答的问题盲区
CPU、内存、连接数机器资源是否接近上限用户是否真的完成了动作
HTTP 5xx 比例服务端服务端是否返回错误CDN、静态资源、客户端异常
接口 p95 耗时服务端处理逻辑慢不慢排队、网络、渲染与等待
首屏可见时间用户用户多久看到内容看到后能否操作
关键动作成功率用户用户能否完成目标失败发生在哪一段

可用性还要决定按请求算还是按用户算。按请求算,一次失败就是一次失败,适合容量与错误预算;按用户算,一个用户刷新十次都失败只算一个受影响用户,适合判断事故影响面。两者不能混用:按请求算出来的 99.9% 可能对应 5% 的用户完全打不开,因为少数用户贡献了大量重试请求。

本节的 Compute 选择按请求计,重复用户不特殊处理。这不是唯一正确答案,而是把口径固定下来,让同一份样本在任何机器上得到同一个数字。错误预算与 burn rate 的推导已经在 1.3 可用性、体验与合规的底线 讲过,本节不再重复,只讨论 SLI 怎么采、怎么算、样本从哪里来。

样本来源至少有三类,各有偏差,不能只信一类:

  • RUM(真实用户监控):在浏览器或客户端埋点,最接近用户感知,但会被广告拦截、旧版本客户端和隐私设置影响,覆盖率永远小于 100%。
  • 合成探测:固定地区、固定网络、固定脚本,适合发现区域性故障和 CDN 问题,但无法代表真实用户的设备与网络分布。
  • 服务端日志与链路:覆盖全量请求,适合定位与对账,但看不到客户端渲染、静态资源失败和用户实际等待。
  • 业务事件:下单、发布、支付等动作的最终结果,适合校验“接口成功但业务失败”的情况。

一个可用的组合是:用 RUM 算主 SLI,用合成探测做区域兜底,用服务端日志做分母校验。三者对不上时,先查采集口径,不要急着改阈值。

失败场景:200 状态码掩盖了首屏失败

回到那次首页白屏。把时间线展开,可以看到四个独立的缺口。

第一个缺口是静态资源没有被纳入 SLI。新版本发布后,HTML 引用了新的 JS 分片,部分 CDN 节点还没拿到文件,返回 404。服务端只统计自己的接口,CDN 的 404 不在分母里,所以 5xx 曲线没有任何变化。

第二个缺口是延迟只统计了服务端处理时间。个性化接口在弱网下要 7 秒,服务端从收到请求到返回可能只有 120 毫秒,剩下 6.8 秒耗在连接、传输和浏览器排队上。服务端 p95 很漂亮,用户已经关掉页面。

第三个缺口是成功条件只看 HTTP 状态码。接口返回 200,但响应体是空列表,前端渲染不出内容;或者 JS 执行报错,首屏一直停在骨架屏。这些情况在服务端看来都是成功。

第四个缺口是样本只来自办公网。值班同学用公司网络打开首页一切正常,因为办公网命中了一个已经更新的 CDN 节点。真实用户分布在多个地区、多种运营商,问题只出现在其中一部分人身上。

四个缺口叠加,形成的结果是:监控全绿,客服爆炸,团队在最初半小时里把责任推给用户网络。真正需要补的不是更多仪表盘,而是一条从用户动作出发、能覆盖静态资源与客户端结果的 SLI。

修复时做了三件事。前端上报首屏可见时间和未捕获异常,作为 RUM 样本;合成探测增加多地区、多运营商的首页检查,覆盖 CDN;服务端日志补上请求 ID,把接口结果与前端样本按请求关联。上线一周后,同类问题的发现时间从三十分钟降到三分钟。

用 Compute 把样本变成可比较的 SLI

有了样本,还需要一个稳定的计算口径。下面的实现把一次请求抽象成 Sample:用户标识、是否成功、用户侧延迟。Compute 返回可用性、P95 和样本数,Meets 负责与目标比较。

// file: internal/obs/sli.go
// tier: L1
// verify: go test ./internal/obs/

package obs

import (
	"errors"
	"fmt"
	"sort"
)

// Sample 是一次用户请求的观测样本。
// OK 表示用户动作是否成功,LatencyMs 是从用户发起动作到看到结果的耗时。
type Sample struct {
	UserID    string
	OK        bool
	LatencyMs int
}

// SLI 是一段时间内按请求汇总的用户视角指标。
type SLI struct {
	Availability float64
	P95Ms        int
	Samples      int
}

// ErrNoSamples 表示统计窗口内没有任何请求,不能计算 SLI。
var ErrNoSamples = errors.New("obs: no samples")

// Compute 计算可用性与 P95 延迟。
// 可用性按请求计数,同一用户发两次请求就算两个样本;
// P95 使用最近秩法(nearest rank),并且不修改入参顺序。
func Compute(samples []Sample) (SLI, error) {
	if len(samples) == 0 {
		return SLI{}, ErrNoSamples
	}

	ok := 0
	latencies := make([]int, 0, len(samples))
	for i, s := range samples {
		if s.LatencyMs < 0 {
			return SLI{}, fmt.Errorf("obs: sample %d has negative latency %d", i, s.LatencyMs)
		}
		if s.OK {
			ok++
		}
		latencies = append(latencies, s.LatencyMs)
	}

	sort.Ints(latencies)

	// 最近秩:第 ceil(95% * n) 小的值,下标从 0 开始。
	rank := (95*len(latencies) + 99) / 100
	if rank < 1 {
		rank = 1
	}
	p95 := latencies[rank-1]

	return SLI{
		Availability: float64(ok) / float64(len(samples)),
		P95Ms:        p95,
		Samples:      len(samples),
	}, nil
}

// Meets 判断 SLI 是否同时满足可用性目标和延迟预算。
// 两个边界都按“达到即满足”处理;没有样本时一律不满足。
func Meets(s SLI, availabilityTarget float64, p95BudgetMs int) bool {
	if s.Samples <= 0 {
		return false
	}
	return s.Availability >= availabilityTarget && s.P95Ms <= p95BudgetMs
}

Availability 的分母是样本数,分子是 OK 为真的样本数。这里有意不按用户去重:同一用户刷新十次,十次都计入。这样算出来的数字回答的是“请求成功率”,适合与错误预算配合;如果要回答“多少用户受影响”,应该在采集侧另外聚合,而不是在 Compute 里偷偷改口径。

P95Ms 使用最近秩法:把延迟升序排列,取第 ceil(0.95 * n) 个值。它不插值,所以结果一定是样本中真实出现过的延迟,便于和用户反馈对照。样本很少时,最近秩也不会给出“比最慢还慢”的数;例如只有一个样本时,P95 就是这个样本本身。

实现先复制延迟再排序,入参顺序保持不变。这一点看似多余,实际很重要:调用方可能还要用原始顺序做逐条排查,如果函数悄悄排序,后续日志和样本会对不上。复制一份的代价是 O(n) 内存,相对于采集与存储的成本可以忽略。

负延迟直接报错,而不是取绝对值或跳过。负值通常意味着时钟回拨、单位换算错误或埋点字段错位,静默修正会把一个数据质量问题藏起来。空样本返回 ErrNoSamples,让调用方明确区分“没有流量”和“全部失败”,这两种情况的处理方式完全不同。

Meets 把两个边界都按“达到即满足”处理:可用性等于目标、P95 等于预算都算通过。没有样本时返回 false,因为无法证明服务满足目标;把空窗口当成 100% 可用,是监控里最常见的假绿。

测试:边界、顺序与重复用户

测试围绕四个容易出错的点:重复用户是否按请求计数、P95 是否取到正确位置、排序是否改动入参、非法样本是否被拦住。

// file: internal/obs/sli_test.go
// tier: L1
// verify: go test ./internal/obs/

package obs

import (
	"errors"
	"math"
	"reflect"
	"testing"
)

func TestComputeAvailabilityCountsRequests(t *testing.T) {
	samples := []Sample{
		{UserID: "u-1", OK: true, LatencyMs: 40},
		{UserID: "u-1", OK: false, LatencyMs: 80},
		{UserID: "u-2", OK: true, LatencyMs: 60},
		{UserID: "u-2", OK: true, LatencyMs: 70},
	}

	got, err := Compute(samples)
	if err != nil {
		t.Fatal(err)
	}
	if math.Abs(got.Availability-0.75) > 1e-9 {
		t.Fatalf("availability = %v, want 0.75", got.Availability)
	}
	if got.Samples != 4 {
		t.Fatalf("samples = %d, want 4", got.Samples)
	}
}

func TestComputeNearestRank(t *testing.T) {
	samples := make([]Sample, 0, 100)
	for i := 1; i <= 100; i++ {
		samples = append(samples, Sample{UserID: "u", OK: true, LatencyMs: i})
	}

	got, err := Compute(samples)
	if err != nil {
		t.Fatal(err)
	}
	if got.P95Ms != 95 {
		t.Fatalf("p95 = %d, want 95", got.P95Ms)
	}
}

func TestComputeDoesNotMutateInput(t *testing.T) {
	samples := []Sample{
		{UserID: "u-1", OK: true, LatencyMs: 300},
		{UserID: "u-2", OK: true, LatencyMs: 100},
		{UserID: "u-3", OK: true, LatencyMs: 200},
	}
	before := append([]Sample(nil), samples...)

	if _, err := Compute(samples); err != nil {
		t.Fatal(err)
	}
	if !reflect.DeepEqual(samples, before) {
		t.Fatalf("input changed: got %v, want %v", samples, before)
	}
}

func TestComputeRejectsBadSamples(t *testing.T) {
	if _, err := Compute(nil); !errors.Is(err, ErrNoSamples) {
		t.Fatalf("empty input error = %v, want ErrNoSamples", err)
	}

	_, err := Compute([]Sample{{UserID: "u-1", OK: true, LatencyMs: -1}})
	if err == nil {
		t.Fatal("want error for negative latency")
	}
}

func TestMeetsIncludesBoundaries(t *testing.T) {
	s := SLI{Availability: 0.99, P95Ms: 800, Samples: 10}
	if !Meets(s, 0.99, 800) {
		t.Fatal("boundary values should meet the target")
	}
	if Meets(s, 0.995, 800) {
		t.Fatal("availability below target should not meet")
	}
	if Meets(s, 0.99, 799) {
		t.Fatal("latency above budget should not meet")
	}
	if Meets(SLI{}, 0.99, 800) {
		t.Fatal("empty SLI should not meet")
	}
}

TestComputeAvailabilityCountsRequests 用同一个用户的一成一败,再加另一个用户的两个成功样本,确认可用性是 0.75 而不是按用户去重后的 0.5。这个测试把“按请求计”的口径固定下来,防止以后有人为了“更合理”改成按用户去重。

TestComputeNearestRank 构造 1 到 100 的延迟,期望 P95 是 95。最近秩的下标计算是 (95*n + 99) / 100 - 1,整数运算避免了浮点误差;如果改成 int(0.95*n)-1,在部分 n 上会少取一个位置。测试用完整的 100 个样本,把这个边界钉住。

TestComputeDoesNotMutateInput 在调用前复制一份样本,调用后用 reflect.DeepEqual 比较。它防的不是当前实现,而是未来某次“优化”:比如为了省内存直接在入参上排序,功能测试仍然通过,但调用方的数据被改了。

TestComputeRejectsBadSamples 覆盖空样本和负延迟。TestMeetsIncludesBoundaries 覆盖可用性边界、延迟边界、低于目标和空 SLI 四种情况。边界测试看起来琐碎,但它们决定了告警是在 99.9% 触发还是在 99.89% 触发,差一位小数就是不同的值班体验。

这些测试不需要真实流量,也不需要时钟或外部依赖,适合放在每次提交的 CI 里。真正需要集成环境验证的是样本采集链路:埋点是否上报、CDN 日志是否入库、RUM 是否被广告拦截。两者分开测试,失败时更容易判断是计算逻辑还是采集链路的问题。

Compute 的接口刻意保持窄:只接收样本,不读配置、不发网络请求、不写日志。这样它可以被复用到批处理、实时流和离线对账里,同一份样本在不同场景得到同一个结果。把采集逻辑塞进计算函数,会让测试依赖环境,也会让口径随着调用方变化。

运行命令与输出

在 lab 目录执行本章的测试命令:

cd .local/checks/webapp-lab
go test ./internal/obs/ -run 'TestCompute|TestMeets' -count=1 -v

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

=== RUN   TestComputeAvailabilityCountsRequests
--- PASS: TestComputeAvailabilityCountsRequests (0.00s)
=== RUN   TestComputeNearestRank
--- PASS: TestComputeNearestRank (0.00s)
=== RUN   TestComputeDoesNotMutateInput
--- PASS: TestComputeDoesNotMutateInput (0.00s)
=== RUN   TestComputeRejectsBadSamples
--- PASS: TestComputeRejectsBadSamples (0.00s)
=== RUN   TestMeetsIncludesBoundaries
--- PASS: TestMeetsIncludesBoundaries (0.00s)
PASS
ok  	webapp/internal/obs	0.220s

输出里的 ok 表示包级测试通过,前面的 PASS 是每个测试函数的结论。真正上线前还应跑一次不带 -run 的全包测试,确认其他文件的测试没有被本次改动影响。

小结

用户视角的 SLI 不是把服务器指标换个名字,而是重新回答“用户在做什么、什么算成功、从什么时候开始计时”。首页场景里,首屏可见、静态资源加载、接口结果和客户端异常缺一不可。

按请求计与按用户计是两种口径,分别服务容量与影响面,不能在同一个数字里混用。Compute 固定了按请求计、最近秩 P95、不改入参和负值报错四个行为,让同一份样本得到可复现的结果。

样本要至少覆盖真实用户、合成探测和服务端日志三类,任何单一来源都有偏差。三者对不上时先查口径,再查阈值。

阅读导航:上一节:目录与学习路径 · 下一节:11.2 日志、指标与追踪的最小集 。

常见错误与边界

  • 把 CPU、内存、5xx 直接当 SLI:这些是原因指标,不是用户结果,容易在 CDN 或客户端故障时全绿。
  • 只统计服务端处理耗时:排队、传输、渲染都不在分母里,用户等待被系统性低估。
  • 按用户去重后计算可用性:重试多的用户被压成一个样本,影响面看起来比实际小。
  • P95 用平均值或线性插值:平均值掩盖长尾,插值可能给出样本中不存在的延迟。
  • 在 Compute 里直接排序入参:调用方后续按原始顺序排查时会对不上日志。
  • 负延迟静默跳过:数据质量问题被藏起来,P95 反而变好看。
  • 空窗口当成 100% 可用:没有流量不等于没有故障,也可能意味着采集链路断了。
  • 只信单一 RUM 来源:广告拦截、旧客户端和隐私设置会让样本长期偏向某类用户。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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