《后端架构设计·高性能与高可用》10.2 告警:从症状到行动的闭环

在 arch-lab 上实现按时间窗口求值的告警引擎:持续条件、for 时长、抑制、去重与分级路由,覆盖 5xx 比例、P99、outbox 积压年龄和库存预留失败率,用测试证明尖刺不告警、持续越线只告一次、下游故障抑制上游,并强制每条规则绑定 runbook。

告警不是阈值,是判断

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 故障复盘与改进闭环 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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