监控全绿,用户却说首页打不开
「回声」是一个内容社区,首页由静态 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 来源:广告拦截、旧客户端和隐私设置会让样本长期偏向某类用户。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。