告警不是阈值,是判断
10.1 产出了 span、日志和指标,但“5xx 超过 5%”本身不是告警,只是一个瞬时事实。告警是带时间语义的判断:条件要持续多久才算数、什么情况下不该吵人、同一次故障只通知一次、通知之后值班的人按哪份手册行动。缺任何一项,团队就会在“狼来了”和“没人看”之间来回摆动。
告警只是 SLO 的触发器,不是 SLO 本身。错误预算、可用性目标和容量口径见 SLA、SLO 与容量规划 ,本节只实现 arch-lab 的规则引擎与四条订单信号。
规则形状:窗口、持续、动作
| 字段 | 含义 | arch-lab 取值 |
|---|---|---|
Signal | 窗口聚合后的信号名 | 5xx 比例、P99、outbox 年龄、预留失败率 |
Threshold + Above | 上界或下界 | 5xx 5%、P99 500ms、积压 120s、失败率 10% |
For | 连续越线时长 | 5xx 60s、P99 120s |
Severity | 分级路由 | page 呼叫值班,ticket 建工单 |
Runbook | 必须绑定的动作手册 | RB-ORDER-5XX 等,加载期校验 |
Inhibits | 抑制哪些上游告警 | outbox 故障压制 5xx 告警 |
For 不是“窗口长度”,而是“条件连续成立的时长”。引擎每 10 秒求值一次,60 秒的 For 要求连续 6 个 tick 越线;中间任何一个 tick 回到阈值内,计数清零。这正是尖刺与持续故障的分界线。
// file: internal/platform/alert/alert.go
// tier: L1
// verify: go test ./internal/platform/alert/
package alert
import (
"errors"
"fmt"
"math"
"sort"
"strings"
"time"
)
type Rule struct {
ID string
Signal string
Above bool
Threshold float64
For time.Duration
Severity string
Runbook string
Inhibits []string
}
type Notification struct {
RuleID string
Signal string
Severity string
Runbook string
Kind string
Value float64
At time.Time
SuppressedBy string
}
type state struct {
breach int
good int
firing bool
}
type Engine struct {
Rules []Rule
Tick time.Duration
state map[string]*state
}
// New 在加载期拒绝“没有 runbook 的告警”:告警如果不能导向动作,就只是噪音。
func New(rules []Rule, tick time.Duration) (*Engine, error) {
if tick <= 0 {
return nil, errors.New("tick must be positive")
}
seen := map[string]bool{}
for _, r := range rules {
switch {
case r.ID == "" || r.Signal == "":
return nil, errors.New("rule id and signal are required")
case seen[r.ID]:
return nil, fmt.Errorf("duplicate rule id %q", r.ID)
case !strings.HasPrefix(r.Runbook, "RB-"):
return nil, fmt.Errorf("rule %s must bind a RB-* runbook", r.ID)
case r.Severity != "page" && r.Severity != "ticket":
return nil, fmt.Errorf("rule %s has unknown severity %q", r.ID, r.Severity)
case r.For <= 0:
return nil, fmt.Errorf("rule %s must have a positive for duration", r.ID)
}
seen[r.ID] = true
}
for _, r := range rules {
for _, id := range r.Inhibits {
if !seen[id] {
return nil, fmt.Errorf("rule %s inhibits unknown rule %s", r.ID, id)
}
}
}
return &Engine{Rules: rules, Tick: tick, state: map[string]*state{}}, nil
}
func (e *Engine) ticks(r Rule) int {
n := int(math.Ceil(float64(r.For) / float64(e.Tick)))
if n < 1 {
n = 1
}
return n
}
// Observe 每 Tick 调用一次。只有连续越线达到 For 才触发;恢复需要连续两个
// 正常窗口,避免边界抖动造成“告警-恢复”刷屏。
func (e *Engine) Observe(at time.Time, values map[string]float64) []Notification {
var out []Notification
firing := map[string]bool{}
for _, r := range e.Rules {
v, ok := values[r.Signal]
if !ok {
continue // 指标缺失不等于健康,保持原状态,等待显式恢复或缺失告警。
}
st := e.state[r.ID]
if st == nil {
st = &state{}
e.state[r.ID] = st
}
breach := v >= r.Threshold
if !r.Above {
breach = v <= r.Threshold
}
if breach {
st.breach++
st.good = 0
} else {
st.breach = 0
st.good++
if st.firing && st.good >= 2 {
st.firing = false
out = append(out, Notification{RuleID: r.ID, Signal: r.Signal, Severity: r.Severity,
Runbook: r.Runbook, Kind: "resolved", Value: v, At: at})
}
}
if breach && !st.firing && st.breach >= e.ticks(r) {
st.firing = true
out = append(out, Notification{RuleID: r.ID, Signal: r.Signal, Severity: r.Severity,
Runbook: r.Runbook, Kind: "firing", Value: v, At: at})
}
if st.firing {
firing[r.ID] = true
}
}
// 抑制只影响“是否送达”,不改变规则自身的 firing 状态,
// 这样下游恢复后上游可以自然重新评估,而不是丢掉状态。
for i := range out {
if out[i].Kind != "firing" {
continue
}
for _, r := range e.Rules {
if firing[r.ID] && contains(r.Inhibits, out[i].RuleID) {
out[i].SuppressedBy = r.ID
break
}
}
}
return out
}
func contains(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
return false
}
// Histogram 是 P99 的来源。分桶必须有界且有序,跨实例不能直接平均 P99。
type Histogram struct {
bounds []float64
counts []uint64
total uint64
}
func NewHistogram() *Histogram {
return &Histogram{
bounds: []float64{5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000},
counts: make([]uint64, 11),
}
}
func (h *Histogram) Observe(ms float64) {
i := sort.SearchFloat64s(h.bounds, ms)
h.counts[i]++
h.total++
}
func (h *Histogram) Quantile(q float64) float64 {
if h.total == 0 {
return 0
}
target := uint64(math.Ceil(q * float64(h.total)))
var seen uint64
for i, c := range h.counts {
seen += c
if seen >= target {
if i < len(h.bounds) {
return h.bounds[i]
}
return h.bounds[len(h.bounds)-1]
}
}
return h.bounds[len(h.bounds)-1]
}
type Window struct {
Requests, ServerErrors uint64
ReserveAttempts, ReserveFailures uint64
Latency *Histogram
OldestOutbox time.Duration
}
func (w *Window) Snapshots() map[string]float64 {
s := map[string]float64{
"outbox.backlog.age_seconds": w.OldestOutbox.Seconds(),
"order.http.latency.p99_ms": w.Latency.Quantile(0.99),
}
if w.Requests > 0 {
s["order.http.5xx.ratio"] = float64(w.ServerErrors) / float64(w.Requests)
}
if w.ReserveAttempts > 0 {
s["inventory.reserve.failure_ratio"] = float64(w.ReserveFailures) / float64(w.ReserveAttempts)
}
return s
}
持续、抑制、去重
去重靠状态机:firing 为真时不再发 firing 通知,只有连续两个正常窗口后才置回 false 并发一次 resolved。测试里持续越线 8 个 tick 只产生 1 条通知,之后继续越线 3 个 tick 也没有重复。
抑制的方向必须写清楚:是“下游故障时,上游症状告警不再单独送达”。outbox 积压 300 秒说明异步链路已经堵住,此时订单 5xx 只是它的后果;规则 outbox.age 在 Inhibits 里声明 order.5xx,引擎在评估后统一抑制。被抑制的通知仍然保留 SuppressedBy,方便事后统计“有多少告警其实来自同一个根因”。
测试:尖刺、持续、抑制
测试用 10 秒 tick 驱动。第一条:5xx 比例冲到 0.30 只持续 3 个 tick(30 秒),低于 60 秒的 For,通知数为 0。第二条:0.12 持续 8 个 tick,第 6 个 tick(第 50 秒)触发一次,之后 3 个 tick 不重复。第三条:构造 1000 次请求、120 次 5xx、P99 落在 1000ms 桶、outbox 300 秒、预留失败 30/200,13 个 tick 后四条规则全部进入 firing,但 order.5xx 被 outbox.age 抑制,实际送达 3 条。第四条:没有 RB- 前缀 runbook 或抑制了不存在的规则,加载直接失败。
$ go test -v ./... -count=1
=== RUN TestSpikeAndSustained
--- PASS: TestSpikeAndSustained (0.00s)
=== RUN TestInhibitionAndRouting
alert_test.go:86: p99_ms=1000 5xx=0.12 outbox_s=300 reserve=0.15 rules=4 suppressed=1
--- PASS: TestInhibitionAndRouting (0.00s)
=== RUN TestRuleMustBindRunbook
--- PASS: TestRuleMustBindRunbook (0.00s)
PASS
ok archlab/internal/platform/alert 0.448s
以上为本机 macOS(darwin/arm64,Go 1.26.0)单次运行,命令 go test -v ./... -count=1,原始输出在 .local/reports/be-arch/be-arch-perf-10-2-alert-symptom-action-loop-tests.txt。p99_ms=1000 是直方图分桶的上界,不是精确分位数;900 次 5ms 加 100 次 700ms 的构造数据里,P99 落在 1000ms 桶。跨实例汇总时必须合并桶计数再算分位,不能把各实例的 P99 求平均。
分级路由与 runbook
page 走值班呼叫,只给“用户已经受影响且需要人工立即介入”的信号:5xx 比例、outbox 积压。ticket 走工单,给“需要优化但不必凌晨叫人”的信号:P99、库存预留失败率。分级不是按指标重要性拍的,而是按“延迟一小时处理的代价”划分。
每条通知都带 Runbook,内容至少包括:确认影响面的查询、区分根因的三个检查、可安全执行的止血动作、升级路径。没有 runbook 的规则在 New 阶段就被拒绝,避免上线后才补文档。
常见错误与边界
第一,只看瞬时值。一次 10 秒的尖刺就呼叫值班,会把正常抖动变成事故;For 是必须的。
第二,抑制关系写反。应该是下游根因抑制上游症状,而不是上游抑制下游;写反会让真正的根因永远不出现。
第三,去重只看规则 ID。同一个规则对不同标签组应分别计数,arch-lab 目前按规则 ID 去重,扩展到多实例时必须把标签组纳入 key。
第四,告警没有动作。只写“CPU 高”而没有“先看哪个面板、再执行哪条命令”的告警,最终会被静音。
第五,把缺失当健康。指标断流时引擎保持原状态而不是自动恢复;生产环境还需要一条独立的“指标缺失”告警,否则采集挂掉会表现为“一切正常”。
小结
告警的价值在于把症状变成可执行的判断:For 过滤尖刺,状态机去重,抑制把同一根因的噪声收敛,分级路由决定谁在多长时间内响应,runbook 决定响应之后做什么。arch-lab 的四条规则在测试里给出了明确结果:30 秒尖刺 0 条通知,持续越线只提醒一次,下游 outbox 故障时 5xx 告警被抑制,最终送达 3 条。下一节把一次告警之后的复盘做成可校验的记录,确保改进项真的落地。
阅读导航:上一节:10.1 订单链路的最小观测契约 · 下一节:10.3 故障复盘与改进闭环 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。