看板 99.97%,用户还在投诉
2026 年 10 月大促前,arch-lab 订单服务的可用性看板显示 99.97%,高于 99.9% 的目标;同一周客服却收到一批投诉:支付成功后订单页一直转圈,部分请求 30 秒后超时,还有用户看到“下单成功”却没有订单。
排查发现三个口径问题。看板统计的是订单服务进程内的 5xx 比例,网关超时、客户端断连、降级返回空列表都没有计入;分母把定时对账任务和重试请求也算成用户请求;请求在网关重试三次、失败一次成功一次,看板记了四次,用户只感知一次。
99.97% 没有算错,它只回答了“订单服务进程自己觉得怎么样”,没有回答“用户的下单是否成功”。SLI 的第一个决定不是选指标,而是选观察点。SLA、SLO 与容量 讨论了目标怎么定,本节只讨论一个数怎么算出来。
观察点决定指标能不能代表用户
用户视角的 SLI 要固定三个位置:起点是用户点击“提交订单”,终点是用户看到明确的成功或失败;分子是“好事件”,分母是“有效事件”。任何一环把观察点往服务内部挪,都会把用户真实承受的失败移出统计。
第一类漏计是超时。请求在网关等待 30 秒后被切断,订单服务可能还在处理,进程内没有 5xx,但用户已经失败。第二类漏计是语义失败:HTTP 200 但返回空订单列表、金额字段缺失、幂等键冲突被静默吞掉。第三类漏计是重试:一次用户操作在网关重试三次,服务端看到三个请求,用户只感知一次;按请求计数会把失败率稀释,也可能把一次故障放大成三倍流量。
| 候选指标 | 观察点 | 能回答 | 典型漏计 |
|---|---|---|---|
| 进程 5xx 比例 | 服务内部 | 服务是否抛错 | 网关超时、断连、语义错误 |
| 请求成功率 | 网关 | 请求是否拿到响应 | 响应是否正确、重试放大 |
| 端到端成功率 | 用户侧或边缘 | 用户是否成功 | 客户端取消、离线补单 |
| 端到端延迟达标率 | 用户侧或边缘 | 用户是否“及时”成功 | 慢但在阈值内、快但结果错 |
这张表没有唯一答案。订单详情可以看进程指标,下单必须看端到端;支付回调要看“回调是否被正确处理”,而不是“HTTP 是否 200”。一个服务可以有多条 SLI,但每条都要写清观察点和分母。订单链路的可观测性契约 给出了 trace 侧的字段约定,本节补的是比率定义。
窗口不是报表周期,而是决策周期
同一个事件流,换一个窗口就会得到不同的数。测试里 2026-10-31T12:00Z 这一刻,滚动 24 小时窗口覆盖 10-30T12 到 10-31T12,成功率 98.33%;日历日窗口覆盖 10-31 全天,成功率 97.50%。两个数都真实,差异来自窗口边界,而不是数据质量。
滚动窗口回答“最近一段时间怎么样”,适合发布门禁和告警;日历窗口回答“这个自然日或自然月怎么样”,适合对外汇报和结算;滑动窗口用固定步长重复计算,适合看趋势,但相邻窗口高度相关,不能当独立样本。窗口长度还要和决策频率匹配:用 28 天预算做发布决策,却用 1 小时窗口判断“能不能解冻”,两个结论会打架。
| 窗口 | 边界 | 适合 | 主要陷阱 |
|---|---|---|---|
| 滚动 1h / 24h | 随查询时刻移动 | 告警、发布门禁 | 事件跨窗口重复计入 |
| 日历日 / 月 | 自然边界 | 结算、对外报告 | 月初与周末流量结构不同 |
| 滑动 5m 步长 | 固定步长重叠 | 趋势、燃尽曲线 | 相邻点相关,不能算显著性 |
窗口还决定“多久能发现”。1 小时窗口对持续 10 分钟的故障不敏感,5 分钟窗口又会被正常抖动触发。生产上通常同时保留短窗口和长窗口:短窗口负责发现,长窗口负责确认,这正是 7.2 多窗口燃尽速率的基础。
还有一个实现细节:窗口边界必须半开。用闭区间 [start, end] 会让整点事件同时落入相邻两个窗口,24 小时窗口实际统计 24 小时零 1 秒;在高 QPS 下,这点重叠足以让两个看板对不上。本节实现统一用 [Start, End),时间戳正好等于 End 的事件属于下一个窗口。
分母是治理口径,不是技术细节
分母里放什么,决定了这个 SLI 能被谁使用。定时对账、缓存预热、内部健康检查不是用户发起的请求,混进分母会拉低或拉高成功率;把后台任务的失败算成用户失败,会让团队去优化一个用户根本感知不到的问题。反过来,把用户取消、客户端断网全部剔除,又会把真实失败洗掉。
本节实现把“是否用户发起”作为显式字段,默认排除后台流量,同时输出 Coverage:进入分母的事件占全部观察事件的比例。覆盖率突然下降,往往意味着采集链路漏了数据,而不是系统变好了。另一个必须显式处理的状态是“没有数据”:空窗口的比率返回 1,但 Sufficient 为 false,看板必须显示“无数据”,不能显示 100%。
分母还要能按入口拆开。下单、订单详情、支付回调的失败代价不同,混成一条 SLI 会让“详情页慢”掩盖“下单失败”。实现上可以按 Route 分组后分别调用 Measure,但不要在同一张图上比较不同分母的比率。
可运行实现
下面的 slo 包与 7.2、7.3 共用。Measure 只做一件事:按定义把窗口内的事件分成好、坏、被排除三类;窗口构造函数只负责边界,不负责聚合。
// file: internal/platform/slo/sli.go
// tier: L1
// verify: go test ./internal/platform/slo/ -count=1 -run 'TestSLI|TestWindow|TestNoData|TestDefinition'
package slo
import (
"errors"
"fmt"
"time"
)
// Event is one outcome observed as close to the user as possible.
type Event struct {
At time.Time
Route string
UserInitiated bool
Good bool
Latency time.Duration
}
// SLI is a ratio of good events to valid events.
type SLI struct {
Name string
LatencyThreshold time.Duration // zero means availability
IncludeBackground bool
}
func (s SLI) Validate() error {
if s.Name == "" {
return errors.New("slo: SLI name is required")
}
if s.LatencyThreshold < 0 {
return fmt.Errorf("slo: negative latency threshold %s", s.LatencyThreshold)
}
return nil
}
func (s SLI) inScope(e Event) bool { return s.IncludeBackground || e.UserInitiated }
// Valid reports whether e belongs in the denominator.
func (s SLI) Valid(e Event) bool { return s.inScope(e) }
// Good reports whether e belongs in the numerator.
func (s SLI) Good(e Event) bool {
if !s.inScope(e) || !e.Good {
return false
}
return s.LatencyThreshold == 0 || e.Latency <= s.LatencyThreshold
}
// Window is a half-open interval [Start, End).
type Window struct{ Start, End time.Time }
func (w Window) Contains(t time.Time) bool { return !t.Before(w.Start) && t.Before(w.End) }
func (w Window) Validate() error {
if !w.End.After(w.Start) {
return fmt.Errorf("slo: window end %s not after start %s", w.End, w.Start)
}
return nil
}
// Measurement is one SLI evaluated over one window.
type Measurement struct {
SLI string
Window Window
Good int
Valid int
Excluded int
MinValid int
}
// Ratio returns 1 with no valid events while Sufficient is false: a dashboard
// must show "no data", not 100%.
func (m Measurement) Ratio() float64 {
if m.Valid == 0 {
return 1
}
return float64(m.Good) / float64(m.Valid)
}
func (m Measurement) Sufficient() bool { return m.Valid >= m.MinValid }
// Coverage is the share of observed events that entered the denominator.
func (m Measurement) Coverage() float64 {
if m.Valid+m.Excluded == 0 {
return 0
}
return float64(m.Valid) / float64(m.Valid+m.Excluded)
}
// Measure evaluates s over w; excluded events never enter the numerator.
func Measure(events []Event, s SLI, w Window, minValid int) (Measurement, error) {
if err := s.Validate(); err != nil {
return Measurement{}, err
}
if err := w.Validate(); err != nil {
return Measurement{}, err
}
if minValid < 0 {
return Measurement{}, fmt.Errorf("slo: negative minimum sample %d", minValid)
}
m := Measurement{SLI: s.Name, Window: w, MinValid: minValid}
for _, e := range events {
switch {
case !w.Contains(e.At):
case !s.Valid(e):
m.Excluded++
default:
m.Valid++
if s.Good(e) {
m.Good++
}
}
}
return m, nil
}
// Rolling returns the trailing window [end-d, end).
func Rolling(end time.Time, d time.Duration) Window { return Window{end.Add(-d), end} }
// CalendarDay returns the UTC day containing at.
func CalendarDay(at time.Time) Window {
t := at.UTC()
start := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
return Window{start, start.AddDate(0, 0, 1)}
}
实现刻意保留两个反直觉行为。第一,空窗口比率是 1 而不是 0,因为 0 会被误读成“全坏”;是否可信由 Sufficient 单独回答。第二,Excluded 不计入分母但会被统计,否则无法发现“排除规则把大部分流量都排掉了”。这两条都比返回一个漂亮的百分比更重要。
本机实测
测试用固定时钟构造 72 小时合成流量:每小时 100 个用户请求加 10 个后台任务,每第 10 小时有 20% 用户可见失败。环境为 macOS darwin/arm64、Go 1.26.0,完整输出在 .local/reports/be-arch-gov/ch07-full.txt。
=== RUN TestSLIMeasure
sli_test.go:52: availability good=7040 valid=7200 excluded=720 ratio=0.977778 coverage=0.9091
sli_test.go:60: with-background good=7040 valid=7920 ratio=0.888889
sli_test.go:67: latency good=4224 valid=7200 ratio=0.586667
--- PASS: TestSLIMeasure (0.00s)
=== RUN TestWindowBoundaries
sli_test.go:80: at=2026-10-31T12:00:00Z rolling[10-30T12,10-31T12) good=2360 valid=2400 ratio=0.983333
sli_test.go:82: at=2026-10-31T12:00:00Z calendar[10-31T00,11-01T00) good=2340 valid=2400 ratio=0.975000
--- PASS: TestWindowBoundaries (0.00s)
=== RUN TestNoDataIsNotHealthy
sli_test.go:95: empty window ratio=1.00 sufficient=false coverage=0.00
--- PASS: TestNoDataIsNotHealthy (0.00s)
=== RUN TestDefinitionValidation
sli_test.go:116: missing name rejected: slo: SLI name is required
sli_test.go:116: negative latency rejected: slo: negative latency threshold -1s
sli_test.go:116: inverted window rejected: slo: window end 2026-10-29 00:00:00 +0000 UTC not after start 2026-11-01 00:00:00 +0000 UTC
sli_test.go:116: negative minimum rejected: slo: negative minimum sample -1
--- PASS: TestDefinitionValidation (0.00s)
PASS
ok archlab/internal/platform/slo 0.319s
读数分三层。可用性层,7040/7200 = 97.78%,720 个后台事件被排除,覆盖率 90.91%;把后台流量放回分母后比率降到 88.89%,说明分母口径能改变近 9 个百分点。延迟层,阈值 42ms 下只有 4224/7200 = 58.67% 的事件既成功又够快,和可用性 SLI 是两个不同的数。窗口层,同一时刻滚动窗口 98.33%、日历窗口 97.50%,差 0.83 个百分点,足以让一次发布门禁得出相反结论。
这里还要区分两个 SLI 的用途。可用性 SLI 决定预算和发布门禁,延迟 SLI 决定用户体验和容量;把 58.67% 的延迟达标率直接当成“可用性只有 58%”会引发错误的冻结。两条 SLI 各自有目标和预算,不能相加也不能取平均。
延迟 SLI 的阈值也不能随便取。42ms 是本例的示例值,真实阈值应来自用户可感知的分界或产品目标;阈值越宽,延迟 SLI 越接近可用性,越窄则越容易被正常抖动触发。阈值必须和可用性目标分开评审。
这些数字只对本机合成事件成立,不是生产订单链路的实测。真实系统还要处理时钟偏差、事件迟到和采集丢失;上线前应取一天真实边缘日志重跑,并核对 Coverage 是否稳定。
完整测试代码如下,固定时钟与合成流量都在测试文件里,可直接复现上面的数字。
// file: internal/platform/slo/sli_test.go
// tier: L1
// verify: go test ./internal/platform/slo/ -count=1 -run 'TestSLI|TestWindow|TestNoData|TestDefinition'
package slo
import (
"testing"
"time"
)
var t0 = time.Date(2026, 10, 29, 0, 0, 0, 0, time.UTC)
// sampleEvents builds 72 hours of traffic: 100 user requests and 10 background
// jobs per hour, with a 20% user-visible failure every tenth hour.
func sampleEvents() []Event {
var out []Event
for h := 0; h < 72; h++ {
at := t0.Add(time.Duration(h) * time.Hour)
for i := 0; i < 100; i++ {
out = append(out, Event{
At: at.Add(time.Duration(i) * time.Second),
Route: "/orders",
UserInitiated: true,
Good: !(h%10 == 0 && i < 20),
Latency: time.Duration(40+i%5) * time.Millisecond,
})
}
for i := 0; i < 10; i++ {
out = append(out, Event{
At: at.Add(time.Duration(i) * time.Second),
Route: "/orders/reconcile",
UserInitiated: false,
Good: false,
Latency: 2 * time.Second,
})
}
}
return out
}
func TestSLIMeasure(t *testing.T) {
events := sampleEvents()
full := Window{Start: t0, End: t0.Add(72 * time.Hour)}
m, err := Measure(events, SLI{Name: "orders-availability"}, full, 1000)
if err != nil {
t.Fatalf("Measure: %v", err)
}
if m.Good != 7040 || m.Valid != 7200 || m.Excluded != 720 {
t.Fatalf("good=%d valid=%d excluded=%d", m.Good, m.Valid, m.Excluded)
}
t.Logf("availability good=%d valid=%d excluded=%d ratio=%.6f coverage=%.4f",
m.Good, m.Valid, m.Excluded, m.Ratio(), m.Coverage())
withBackground := SLI{Name: "orders-availability", IncludeBackground: true}
mb, _ := Measure(events, withBackground, full, 1000)
if mb.Ratio() >= m.Ratio() {
t.Fatalf("background traffic raised the SLI: %.6f", mb.Ratio())
}
t.Logf("with-background good=%d valid=%d ratio=%.6f", mb.Good, mb.Valid, mb.Ratio())
latency := SLI{Name: "orders-latency", LatencyThreshold: 42 * time.Millisecond}
ml, _ := Measure(events, latency, full, 1000)
if ml.Good != 4224 || ml.Valid != 7200 {
t.Fatalf("latency good=%d valid=%d", ml.Good, ml.Valid)
}
t.Logf("latency good=%d valid=%d ratio=%.6f", ml.Good, ml.Valid, ml.Ratio())
}
func TestWindowBoundaries(t *testing.T) {
events := sampleEvents()
avail := SLI{Name: "orders-availability"}
at := t0.Add(60 * time.Hour)
roll, _ := Measure(events, avail, Rolling(at, 24*time.Hour), 100)
cal, _ := Measure(events, avail, CalendarDay(at), 100)
if roll.Ratio() == cal.Ratio() {
t.Fatalf("rolling and calendar windows should differ at %s", at)
}
t.Logf("at=%s rolling[%s,%s) good=%d valid=%d ratio=%.6f", at.Format(time.RFC3339),
roll.Window.Start.Format("01-02T15"), roll.Window.End.Format("01-02T15"), roll.Good, roll.Valid, roll.Ratio())
t.Logf("at=%s calendar[%s,%s) good=%d valid=%d ratio=%.6f", at.Format(time.RFC3339),
cal.Window.Start.Format("01-02T15"), cal.Window.End.Format("01-02T15"), cal.Good, cal.Valid, cal.Ratio())
}
func TestNoDataIsNotHealthy(t *testing.T) {
empty := Window{Start: t0.Add(-time.Hour), End: t0}
m, err := Measure(sampleEvents(), SLI{Name: "orders-availability"}, empty, 10)
if err != nil {
t.Fatalf("Measure: %v", err)
}
if m.Sufficient() || m.Ratio() != 1 || m.Coverage() != 0 {
t.Fatalf("empty window ratio=%.2f sufficient=%v coverage=%.2f", m.Ratio(), m.Sufficient(), m.Coverage())
}
t.Logf("empty window ratio=%.2f sufficient=%v coverage=%.2f", m.Ratio(), m.Sufficient(), m.Coverage())
}
func TestDefinitionValidation(t *testing.T) {
events := sampleEvents()
full := Window{Start: t0, End: t0.Add(72 * time.Hour)}
cases := []struct {
name string
sli SLI
w Window
min int
}{
{"missing name", SLI{}, full, 100},
{"negative latency", SLI{Name: "x", LatencyThreshold: -time.Second}, full, 100},
{"inverted window", SLI{Name: "x"}, Window{full.End, full.Start}, 100},
{"negative minimum", SLI{Name: "x"}, full, -1},
}
for _, c := range cases {
if _, err := Measure(events, c.sli, c.w, c.min); err == nil {
t.Fatalf("%s: expected an error", c.name)
} else {
t.Logf("%s rejected: %v", c.name, err)
}
}
}
常见错误与边界
- 用服务端 5xx 比例代表用户成功率。网关超时、断连和语义失败都不在进程指标里,用户已经失败而看板仍然健康。
- 把重试当成新请求。一次用户操作可能产生多次服务端请求,分母被放大,失败率被稀释;应按用户操作或幂等键去重。
- 窗口跟着报表走。发布门禁用 28 天预算,判断却用自然月窗口,月初和月末会给出不同结论。
- 无数据当成 100%。空窗口返回 1 只是数学约定,必须同时看
Sufficient和Coverage,否则采集断链会被当成系统变好。 - 一条 SLI 覆盖所有入口。下单、查询、回调的观察点和分母不同,混在一起会让任何一方都无法归因。
- 用闭区间切窗口。整点事件同时进入前后两个窗口,24 小时窗口实际多算一秒;高 QPS 下两个看板会对不上,必须统一半开区间。
小结
SLI 不是“选一个指标”,而是固定观察点、分子、分母和窗口四件事。观察点越靠近用户,越能覆盖超时、断连和语义失败;分母只放用户发起的有效事件,并用覆盖率证明排除规则没有吞掉大部分流量;窗口长度必须匹配决策周期,滚动窗口和日历窗口在同一时刻给出不同的数是正常现象,不是数据错误。
本节给出的 Measure 把定义变成可执行代码,空窗口、被排除事件和样本不足都有显式状态。下一节在这个比率之上引入错误预算:预算够不够只是第一层,消耗速度决定它会不会在窗口结束前烧完。
阅读导航:上一节:6.3 从信号到诊断:一次跨服务排障复盘 · 下一节:7.2 错误预算:发布门禁与冻结策略 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。