《Go 语言编程实战》16.3 告警与 on-call

发布流程的最后一环是「坏版本由谁第一时间发现」。本节把 TaskHub 的 SLO 落成会响的告警:用本机真算的月度错误预算 43.2 分钟与 14x/4x/1.5x/0.8x 四档燃烧率讲清多窗口告警怎么既不漏报也不吵人,再给出值班手册、升级路径与不追责复盘的落地模板。

16.3 告警与 on-call

有了流水线、有了灰度门禁,还缺最后一环:当坏版本真的上线了,谁在多久之内知道? 如果答案是「用户投诉之后」,那么前面所有的自动化都只是在缩小损失,而不是在阻止损失。告警系统的目标不是「出事就响」,而是「在用户明显受影响之前响,且只在需要人介入时响」。

本节把 TaskHub 推进到「用 SLO 驱动告警、用值班手册承接告警、用不追责复盘消化告警」:先用本机真实计算的错误预算与燃烧率讲清多窗口告警,再给出告警规则与值班清单。Prometheus 告警规则本机无法真跑,会在正文里标注。

16.3.1 告警分两类:症状型与原因型

一个反复被踩的坑是把原因当告警。例如「CPU 使用率 > 80%」——CPU 高但服务正常,你半夜被叫醒却什么也做不了。正确的做法是只对症状告警,用原因做排查:

类型例子是否该叫人用途
症状型5xx 比例超标、P99 延迟超标是直接反映用户受影响
原因型CPU 高、内存涨、磁盘满否(做看板/低优告警)排查线索
变更型刚发了新版本、刚改了配置否关联上下文

判断标准很简单:这条告警响了,值班的人有没有明确的动作? 没有,它就不该是 page(呼叫),最多是 ticket(工单)。把 CPU 高设成 page,结果就是告警疲劳——大家学会忽略它,真正的故障也一起被忽略。

16.3.2 SLO 与错误预算:把「可用性」变成数字

SLO(Service Level Objective)是一句可度量、可判定的承诺,例如「TaskHub 的创建任务接口,月度可用性 ≥ 99.9%」。由它派生出错误预算(error budget):一个月里允许「不达标」的总时长。

设 SLO = 0.999、月度 30 天,本机真算:

SLO=0.999  月度错误预算=43.2 分钟

也就是说,一个月里 TaskHub 可以累计坏 43.2 分钟。这个数字是后面一切的基础:告警不是「感觉变慢了就响」,而是「按当前速率,43.2 分钟预算会被烧完吗」。

要算燃烧率,先得有「好请求 / 总请求」这个原始计数。它必须在应用里埋点暴露出来——这正是第 10 章指标部分要接的东西。一个最小中间件:

type metrics struct {
	total *prometheus.CounterVec
}

func (m *metrics) instrument(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		sw := &statusWriter{ResponseWriter: w, code: 200}
		next.ServeHTTP(sw, r)
		// 按「接口 + 状态码」两个标签计数,SLO 只统计服务端错误
		m.total.WithLabelValues(r.URL.Path, strconv.Itoa(sw.code)).Inc()
	})
}

注意标签的基数(cardinality)问题:把 user_id、task_id 这种高基数字段做成标签,会让时序数据库爆炸。SLO 指标只需要 path(且要归一到路由模板而非真实 URL,否则 /tasks/123 和 /tasks/456 会变成两条时间线)和 code。

SLO 的取值本身也要诚实:不要定一个自己都达不到的目标。99.9% 意味着每月只能坏 43.2 分钟,这对一个还在快速迭代、每周发好几次版的服务是相当高的门槛。把 SLO 定得过高,结果不是服务变好,而是大家学会无视它。合理的起点是「过去三个月的实际可用性」向上取一个够得着但需要努力的值。

16.3.3 多窗口燃烧率:既不漏报也不吵人

燃烧率(burn rate) 定义为「当前错误率 ÷ 允许错误率」。燃烧率 = 1 表示按这个速率刚好在一个月内烧完预算;= 14 表示按这个速率 30 天 ÷ 14 ≈ 2.1 天就烧光。用本机真跑的一段程序,对四个窗口各算一次:

func (w Window) BurnRate() float64 {
	return w.BadRatio / (1 - w.SLO)
}

func (w Window) TimeToExhaust(days float64) float64 {
	br := w.BurnRate()
	if br == 0 {
		return 1e9
	}
	return days * 24 * 60 / br // 窗口总分钟数 ÷ 燃烧率
}

本机真实输出:

SLO=0.999  月度错误预算=43.2 分钟
窗口=1h  错误率=0.0140 燃烧率= 14.00x 按此速率耗尽预算=51.4 小时(2.1 天)
窗口=6h  错误率=0.0040 燃烧率=  4.00x 按此速率耗尽预算=180.0 小时(7.5 天)
窗口=1d  错误率=0.0015 燃烧率=  1.50x 按此速率耗尽预算=480.0 小时(20.0 天)
窗口=3d  错误率=0.0008 燃烧率=  0.80x 按此速率耗尽预算=900.0 小时(37.5 天,超出月度窗口)

这张表读起来很有意思:1 小时窗口里 1.4% 的错误率,燃烧率高达 14x,意味着按这个速率约 2.1 天就会烧光整月预算——必须立刻 page。而 3 天窗口里 0.08% 的错误率,燃烧率 0.8x,按这个速率预算根本烧不完(超出 30 天窗口),完全不用叫人。

多窗口多燃烧率的精髓就在这里:

窗口对燃烧率阈值预算消耗动作
1h + 5m14.42%page(快速烧)
6h + 30m65%page(中速烧)
3d + 6h110%ticket(慢速烧)

「长窗口 + 短窗口」同时超标才告警,是为了避免抖动误报:短窗口负责「反应快」,长窗口负责「确认不是瞬时毛刺」。只用短窗口会太吵,只用长窗口会太慢——两个一起用,才能既快又稳。

16.3.4 告警规则(未在真实 Prometheus 上验证)

以下 Prometheus 规则未在本机真跑(本机无 Prometheus / Alertmanager 实例),仅作结构与表达式参考。用 sli:errors:ratio 这类记录规则预先算好错误率,再对燃烧率设阈值:

groups:
  - name: taskhub-slo
    rules:
      - record: sli:errors:ratio_5m
        expr: |
          sum(rate(http_requests_total{job="taskhub",code=~"5.."}[5m]))
            / sum(rate(http_requests_total{job="taskhub"}[5m]))
      - alert: TaskHubErrorBudgetFastBurn
        expr: |
          sli:errors:ratio_5m > (14.4 * 0.001)
          and
          sli:errors:ratio_1h > (14.4 * 0.001)
        for: 2m
        labels:
          severity: page
        annotations:
          summary: "TaskHub 错误预算快速燃烧"
          runbook: "https://wiki.internal/runbooks/taskhub-error-budget"

三处值得注意:for: 2m 让规则必须持续成立 2 分钟才触发,滤掉瞬时尖峰;severity: page 是分级标签,路由到不同接收渠道;runbook 直接给处置链接——没有 runbook 的告警等于把难题丢给半夜的人。

16.3.5 值班手册:告警响了之后做什么

值班手册(runbook)要写清「看到这条告警,按顺序做这几件事」。以「错误预算快速燃烧」为例:

  • 确认影响面:看 5xx 比例、受影响接口、受影响租户数
  • 关联近期变更:过去 1 小时有没有发布/配置变更/DB 迁移
  • 若有近期变更,优先执行回滚(见 16.2.6 的回滚清单)
  • 若无关变更,看依赖健康:数据库、Redis、下游服务
  • 5 分钟内无法定位,升级到二级 on-call
  • 恢复后:确认指标回到基线,再关闭告警

手册里最有用的一条往往是「优先回滚」。人在压力下容易想「再查一查原因」,但恢复服务优先级永远高于定位根因——根因可以事后慢慢查。

16.3.6 升级路径与轮值

单人 on-call 是反模式:他会疲劳、会漏看、会在关键时候不在。TaskHub 用两级升级:

一级 on-call(15 分钟响应)
   └─ 15 分钟未确认 → 二级 on-call(资深工程师)
        └─ 30 分钟未确认 → 值班负责人 + 拉事故群

每一级的响应时限与职责:

级别响应时限职责
一级 on-call15 分钟确认告警、执行 runbook、必要时回滚
二级 on-call15 分钟一级搞不定时介入,可跨服务排查
值班负责人30 分钟对外沟通、决定是否拉全员、宣布事故等级

「确认」不是「看到」,而是在告警渠道里回复「已接手」——这样系统才知道有人在处理,不会继续升级。

轮值的基本原则:谁写坏的谁更容易被叫醒是有意为之——它让每个人在写代码时就想着可观测性。但轮值不能是惩罚,否则大家会倾向于隐瞒故障。配套的是 16.3.8 的复盘文化。

衡量轮值健不健康,有一个很朴素的指标:每人每周被 page 的次数。业界经验值是不超过 2 次/周;超过这个数,团队会开始「告警疲劳」,也就是看见告警先划掉、过一会儿再看——那时告警系统实际上已经失效了。一旦超过,就该回头看哪些告警是原因型误报,把它们降级。

16.3.7 静默与维护窗口

发布、迁移、演练期间指标会短暂变差,如果不管就会触发一堆告警。Alertmanager 提供了**静默(silence)**机制,按标签临时屏蔽:

# 一次计划发布的静默:匹配 job=taskhub 且 severity=page
matchers:
  - name: job
    value: taskhub
  - name: severity
    value: page
startsAt: "2026-10-07T03:00:00Z"
endsAt: "2026-10-07T03:30:00Z"
createdBy: "release-bot"
comment: "canary rollout v1.5.0"

本节这段静默配置未在本机验证(无 Alertmanager 实例)。但纪律是明确的:静默必须有时限、有理由、有创建者,且发布完成后自动过期。手动的、无限期的静默是事故的温床——它会把真实故障一起静音,而且没人记得去关掉它。

与静默相对的是维护窗口:对「计划内停机」这类可预期的事件,应让监控知道「现在是预期的」,而不是靠静默去掩盖。

16.3.8 不追责复盘(blameless postmortem)

复盘的目标是改进系统,不是找人。一份合格的复盘模板包含:

部分内容
时间线从第一次异常到恢复,逐条带时间戳
影响受影响租户数、时长、消耗的预算比例
根因技术根因 + 为什么没被更早发现
处置实际做了什么,哪些有效哪些无效
行动项可验证、有负责人、有截止日期的改进

关键在最后一行:行动项必须可验证。「加强监控」不是行动项,「为 /tasks 接口增加 5xx 燃烧率告警,负责人 X,截止 Y」才是。没有行动项的复盘,等于把同一个故障预约给下个月。

16.3.9 告警自检清单

  • 只对症状告警,原因类做看板或工单
  • 每条 page 告警都有明确动作和 runbook 链接
  • SLO 与错误预算已量化(TaskHub:99.9% → 43.2 分钟/月)
  • 用多窗口多燃烧率,长窗口确认、短窗口加速
  • 告警有分级(page / ticket)与升级路径
  • 复盘不追责,行动项可验证、有负责人、有期限

16.3.10 常见误区

  • 把原因当告警:CPU/内存超标就叫醒人,却没定义「用户受了什么影响」。
  • 没有 runbook 的 page:把人叫醒却不告诉他做什么。
  • 只用短窗口:流量抖动导致误报,久而久之没人信告警。
  • 只用长窗口:故障烧了半小时才响,预算已经烧掉大半。
  • 无限期静默:掩盖了发布期间的真实故障,还常忘了关。
  • 把复盘写成追责会:结果是下次没人敢如实汇报时间线。
  • 高基数标签:user_id 进指标标签,时序库被撑爆。

小结

  • 只对症状告警(5xx、延迟),原因类(CPU、内存)做排查线索而非呼叫依据;判据是「响了有没有明确动作」。
  • SLO 派生出错误预算:TaskHub 99.9% 对应月度 43.2 分钟,本机实算。
  • 燃烧率 = 当前错误率 ÷ 允许错误率;本机实测 1h 窗口 1.4% 错误率对应 14x 燃烧率,约 2.1 天烧光预算。
  • 多窗口多燃烧率(1h+5m / 6h+30m / 3d+6h)同时超标才告警,兼顾快与稳。
  • 本节 Prometheus 告警规则未在本机真跑(无 Prometheus 实例),仅作结构与表达式参考。
  • 值班要有手册、有升级路径、有轮值;复盘不追责,行动项必须可验证。
  • 静默必须有时限、有理由、有创建者,发布结束自动过期,避免掩盖真实故障。

到这里第 16 章结束:TaskHub 的发布链路已经完整——提交即验证、灰度放量、门禁回滚、告警值班。下一章把视角从「发布」转到「防御」:17.1 输入校验与注入防护 会用一段真跑的实验演示 SQL 注入如何越权读到别的租户,以及参数化查询如何挡下它。

阅读导航:上一节:16.2 灰度/蓝绿与回滚 · 下一节:17.1 输入校验与注入防护 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练