预算不是“可以坏掉的额度”
季度初,SLO 定在 99.9%(示例值),第一个团队把错误预算当成 KPI:月初连续发大版本,理由都是“预算还剩 80%(示例值)”;另一个团队在预算耗尽后停掉所有发布,连修崩溃的补丁也不敢上,理由是“再发就违规”。
两种做法都把预算用错了。预算不是绩效指标,也不是免责条款,它是一个触发器:当系统消耗失败的速度超过承诺时,提醒团队把注意力从“发新东西”切到“减少伤害”。预算状态影响发布策略,但不替代判断。文中案例与未标注来源的数字均为示例值,不是真实跑出来的数据。
预算怎么算
错误预算来自 SLO 的补集。对事件型 SLI:
允许失败数 = (1 - SLO) × 可计失败事件数
剩余预算 = 允许失败数 - 已失败数
剩余比例 = 剩余预算 / 允许失败数
举例(示例值):
| SLO | 可计事件数 | 允许失败 | 已失败 | 剩余预算 | 剩余比例 |
|---|---|---|---|---|---|
| 99.9% | 10,000,000 | 10,000 | 2,000 | 8,000 | 80% |
| 99.9% | 10,000,000 | 10,000 | 12,000 | -2,000 | -20% |
负预算不是“欠债要还”,而是“承诺已经破了”。这时继续按正常节奏发布,只会让恢复更难。
窗口选择比公式更容易出错。对外承诺用 28 天滚动窗口,每天向前滑动一天,避免自然月重置带来的“月初冲预算、月末躺平”。诊断用 1 小时、6 小时、3 天等短窗口,用来发现快速恶化。用哪个窗口,必须在 SLI 文档里写死;同一张报表混用两种窗口,讨论会变成各说各话。
分母口径必须和 SLI 一致。如果某天事件管道丢数据,eligible 会突然下降,预算看起来“变多了”;这不是好消息,而是数据事故。预算报表要同时展示 eligible 的绝对量和环比,不能只展示比例。
缺失日期怎么处理也要明确。把缺失当“零失败”会让预算虚高,把缺失当“全失败”会让预算虚低。可运行实现的做法是:缺失日记为零事件,但在快照里保留 MissingDays,并把“无数据”判为黄色而不是绿色。
燃尽率:看速度,不只看余额
余额回答“还剩多少”,燃尽率回答“还能撑多久”。燃尽率的定义是 (短窗口失败数 / 短窗口可计事件数) / (1 - SLO)。
| 燃尽率 | 含义 | 典型动作 |
|---|---|---|
| 0.5 | 消耗速度是允许的一半 | 正常发布 |
| 2.0 | 两倍速度消耗 | 限制发布,查根因 |
| 4.0 以上 | 快速燃尽 | 冻结发布,进入事故流程 |
燃尽率要配合多窗口看,不能只用一个:
| 窗口 | 用途 | 注意 |
|---|---|---|
| 1 小时 | 捕捉突发故障 | 低流量时噪声大,需要最小样本 |
| 6 小时 | 捕捉持续恶化 | 对短时抖动不敏感 |
| 28 天 | 承诺与预算 | 不用于实时决策 |
一个常见误判是“预算还剩很多,所以没问题”。如果 1 小时燃尽率已经到 8,即使 28 天余额还有 90%,也应该冻结发布:按当前速度,预算会在几天内耗尽。反过来,预算只剩 10% 但燃尽率已经降到 0.2,说明系统在恢复,可以允许低风险变更,而不是一律停摆。
三档门禁
门禁输出不应该是一个布尔值,而应该是“档位 + 原因 + 有效期”。下面是一组可用的默认阈值(示例值):
| 档位 | 条件 | 发布策略 | 审批 |
|---|---|---|---|
| 绿色 | 剩余比例 >= 50%,且燃尽率 <= 1 | 正常发布,标准灰度 | 发布负责人 |
| 黄色 | 剩余比例 > 0,但低于 50%;或燃尽率 > 1 | 只允许小流量灰度,禁止 schema、支付、匹配算法变更 | 发布负责人 + 值班 |
| 红色 | 剩余比例 <= 0,或燃尽率 >= 4 | 冻结,仅允许紧急修复 | 事故指挥 + 技术负责人 |
三档之外还有两个维度要单独判断:变更类型和变更范围。配置和活动内容可以走更快的通道,但必须能回滚;服务端二进制、支付链路、账号体系的变更即使预算充足,也不应该跳过灰度。
门禁要能被发布工具读取,而不是靠人肉查报表。每次发布记录当时的档位、剩余比例、燃尽率和触发原因;发布后如果档位变化,下一次发布重新判断,不能沿用上一次的结论。
发布节奏
发布节奏和预算是两个变量。预算决定“能不能发”,节奏决定“多久发一次、每次发多大”。
- 固定发布火车适合多团队协作:每周固定窗口,变更提前上车,错过等下一班;缺点是紧急修复需要走例外。
- 持续发布适合单团队、灰度成熟的服务:每次变更小,回滚快;缺点是总变更量大,预算消耗更分散。
- 大版本和活动前设置冻结窗口:冻结是日历规则,不是预算规则;预算充足也不应该在重大活动前冒险。
| 变更类型 | 绿色 | 黄色 | 红色 |
|---|---|---|---|
| 配置/活动内容 | 正常 | 灰度 + 回滚预案 | 仅紧急修复 |
| 服务端二进制 | 正常灰度 | 小流量灰度 | 冻结 |
| 支付/账号/schema | 正常灰度 + 双人复核 | 冻结 | 冻结 |
| 安全补丁 | 正常 | 允许,记录原因 | 允许,按紧急修复 |
发布批次越大,单次失败的影响越大;批次越小,总变更次数越多,协调成本越高。预算门禁只能约束“失败带来的伤害”,不能替代灰度、回滚和容量评估,那些在卷三的发布章节里。
紧急修复的例外与记录
紧急修复是门禁的必要出口,但例外必须可审计。允许紧急发布的条件:
- 存在正在发生的 S1/S2 事故,或修复能明确减少玩家伤害;
- 没有更安全的替代方案,比如回滚、降级、关闭入口;
- 修复范围可控,且有回滚路径;
- 有明确的事故编号、批准人和有效期。
| 字段 | 普通发布 | 紧急发布 |
|---|---|---|
| 事故编号 | 不需要 | 必填 |
| 批准人 | 发布负责人 | 事故指挥 + 技术负责人 |
| 原因 | 变更说明 | 为什么不能等 |
| 有效期 | 不适用 | 必须填写,过期需重新审批 |
| 复盘 | 按常规 | 必须进入事故复盘 |
例外不会重置预算。紧急发布之后,预算仍然是消耗状态;它只是把“这次变更”从冻结里放行,而不是把“这次故障”从账本里删掉。最常见的滥用是“每次都说紧急”,或者批准人事后补签;这两种情况都应该在复盘里被指出来。
预算不是 KPI
把预算写进绩效,会得到两种坏结果:团队为了“节省预算”而不敢发布,或者为了“用满预算”而放松变更审查。预算的正确用法是触发讨论,不是排名。
- 不要奖励“预算剩余多”的团队。剩余多可能意味着 SLO 定得太低,或者系统根本没有承载真实流量。
- 不要用“预算耗尽”拒绝所有变更。修复崩溃、回滚坏版本本身就是降低伤害,应该走例外。
- 不要为了保住预算而隐瞒故障。隐瞒会让预算失真,也会让下一次决策基于错误数据。
- 如果产品确实要用稳定性换新功能,应该显式调整 SLO 和预算,而不是偷偷放宽口径。
失败路径
| 现象 | 可能原因 | 处理 |
|---|---|---|
| 预算突然变多 | 事件管道丢失,eligible 下降 | 对 eligible 量做告警,先查数据再信预算 |
| 预算突然变少 | 重复事件、重试未去重 | 按 intent_id 幂等,入口去重 |
| 日界附近数据漂移 | 时区不一致 | 统一 UTC 日界,报表注明时区 |
| 燃尽率剧烈跳动 | 短窗口样本太少 | 设最小样本,低流量改用长窗口 |
| 目标中途变更 | 为了当月达标改口径 | 目标版本化,旧口径保留 |
代码:滚动预算、双窗口燃尽率与门禁
internal/errorbudget/budget.go 把日历日、滚动窗口、长短期燃尽率和三档门禁放在一个包里。几个关键选择:
- 日期统一按 UTC 日历日归一,避免跨时区把一天切成两天;
- 长窗口算剩余预算,短窗口算燃尽率,两者分开,才能出现“余额健康但燃尽很快”的黄色状态;
- 缺失日不算失败,但会留下
MissingDays,无数据时门禁返回黄色; - 紧急发布必须带事故编号、原因、批准人和有效期,过期例外直接拒绝。
// file: internal/errorbudget/budget.go
// tier: L1
// verify: go test ./internal/errorbudget/
package errorbudget
import (
"errors"
"fmt"
"time"
)
var (
ErrInvalidConfig = errors.New("errorbudget: invalid config")
ErrDuplicateDay = errors.New("errorbudget: duplicate day")
ErrInconsistentDay = errors.New("errorbudget: bad or eligible count is invalid")
ErrApprovalRequired = errors.New("errorbudget: emergency release needs incident, reason and approver")
ErrExemptionExpired = errors.New("errorbudget: exemption has expired")
)
type Day struct {
Date time.Time
Eligible int
Bad int
}
type Config struct {
SLO float64 // promised success ratio, for example 0.999
WindowDays int // rolling budget window, 28 for a four-week promise
BurnWindowDays int // recent window used for burn rate, usually 1 or 3
TightRemaining float64 // remaining budget ratio below which releases are limited
FreezeBurn float64 // burn rate at which releases stop
}
func (c Config) normalize() Config {
if c.TightRemaining == 0 {
c.TightRemaining = 0.5
}
if c.FreezeBurn == 0 {
c.FreezeBurn = 4
}
return c
}
func (c Config) validate() error {
if c.SLO <= 0 || c.SLO >= 1 {
return fmt.Errorf("%w: SLO must be in (0,1)", ErrInvalidConfig)
}
if c.WindowDays < 1 {
return fmt.Errorf("%w: window days must be positive", ErrInvalidConfig)
}
if c.BurnWindowDays < 1 || c.BurnWindowDays > c.WindowDays {
return fmt.Errorf("%w: burn window must be within the budget window", ErrInvalidConfig)
}
if c.TightRemaining <= 0 || c.TightRemaining >= 1 {
return fmt.Errorf("%w: tight remaining must be in (0,1)", ErrInvalidConfig)
}
if c.FreezeBurn <= 0 {
return fmt.Errorf("%w: freeze burn must be positive", ErrInvalidConfig)
}
return nil
}
func dateOnly(t time.Time) time.Time {
y, m, d := t.UTC().Date()
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
}
type Snapshot struct {
Start time.Time
End time.Time
WindowDays int
ObservedDays int
MissingDays int
Eligible int
Bad int
AllowedBad float64
RemainingBad float64
RemainingRatio float64
Observed float64
BurnStart time.Time
BurnEligible int
BurnBad int
BurnRate float64
HasData bool
HasBurnData bool
}
// Rolling folds the calendar days inside [asOf-(WindowDays-1), asOf] into a
// budget snapshot. The long window decides how much budget is left; the short
// window decides how fast it is being burned. Keeping the two separate is what
// lets a team freeze a release during a fast burn while the monthly budget
// still looks healthy.
func Rolling(days []Day, asOf time.Time, cfg Config) (Snapshot, error) {
cfg = cfg.normalize()
if err := cfg.validate(); err != nil {
return Snapshot{}, err
}
end := dateOnly(asOf).AddDate(0, 0, 1)
start := end.AddDate(0, 0, -cfg.WindowDays)
burnStart := end.AddDate(0, 0, -cfg.BurnWindowDays)
s := Snapshot{
Start: start,
End: end,
WindowDays: cfg.WindowDays,
BurnStart: burnStart,
}
seen := make(map[string]bool, len(days))
for _, d := range days {
if d.Eligible < 0 || d.Bad < 0 || d.Bad > d.Eligible {
return Snapshot{}, ErrInconsistentDay
}
key := dateOnly(d.Date).Format("2006-01-02")
if seen[key] {
return Snapshot{}, fmt.Errorf("%w: %s", ErrDuplicateDay, key)
}
seen[key] = true
day := dateOnly(d.Date)
if day.Before(start) || !day.Before(end) {
continue
}
s.ObservedDays++
s.Eligible += d.Eligible
s.Bad += d.Bad
if !day.Before(burnStart) {
s.BurnEligible += d.Eligible
s.BurnBad += d.Bad
}
}
s.MissingDays = s.WindowDays - s.ObservedDays
if s.Eligible == 0 {
s.RemainingRatio = 1
return s, nil
}
s.HasData = true
allowedRate := 1 - cfg.SLO
s.AllowedBad = allowedRate * float64(s.Eligible)
s.RemainingBad = s.AllowedBad - float64(s.Bad)
s.RemainingRatio = s.RemainingBad / s.AllowedBad
s.Observed = float64(s.Bad) / float64(s.Eligible)
if s.BurnEligible > 0 {
s.HasBurnData = true
s.BurnRate = (float64(s.BurnBad) / float64(s.BurnEligible)) / allowedRate
}
return s, nil
}
type Tier string
const (
Green Tier = "green"
Yellow Tier = "yellow"
Red Tier = "red"
)
type Decision struct {
Tier Tier
Allowed bool
CanaryOnly bool
Reason string
Emergency bool
Incident string
Approver string
}
// Gate maps a snapshot to one of three release postures. It never silently
// allows a full release when the data is missing: no data is yellow, not green.
func Gate(s Snapshot, cfg Config) Decision {
cfg = cfg.normalize()
if !s.HasData {
return Decision{
Tier: Yellow,
Allowed: true,
CanaryOnly: true,
Reason: "no eligible events in window; canary only",
}
}
if s.RemainingRatio <= 0 {
return Decision{Tier: Red, Reason: "error budget exhausted"}
}
if s.HasBurnData && s.BurnRate >= cfg.FreezeBurn-1e-9 {
return Decision{Tier: Red, Reason: "burn rate above freeze threshold"}
}
if s.RemainingRatio < cfg.TightRemaining-1e-9 {
return Decision{Tier: Yellow, Allowed: true, CanaryOnly: true, Reason: "remaining budget below tight threshold"}
}
if s.HasBurnData && s.BurnRate > 1+1e-9 {
return Decision{Tier: Yellow, Allowed: true, CanaryOnly: true, Reason: "burning faster than the promise allows"}
}
return Decision{Tier: Green, Allowed: true, Reason: "budget and burn rate healthy"}
}
type Exemption struct {
Incident string
Reason string
Approver string
Expires time.Time
}
// EmergencyRelease is an explicit, recorded exception. It does not erase the
// red tier; it attaches the incident and approver to the decision so the next
// review can ask why the exception was needed.
func EmergencyRelease(s Snapshot, cfg Config, ex Exemption, now time.Time) (Decision, error) {
if ex.Incident == "" || ex.Reason == "" || ex.Approver == "" {
return Decision{}, ErrApprovalRequired
}
if !ex.Expires.IsZero() && !ex.Expires.After(now) {
return Decision{}, ErrExemptionExpired
}
d := Gate(s, cfg)
d.Allowed = true
d.CanaryOnly = false
d.Emergency = true
d.Incident = ex.Incident
d.Approver = ex.Approver
d.Reason = fmt.Sprintf("emergency release for %s approved by %s: %s", ex.Incident, ex.Approver, ex.Reason)
return d, nil
}
接入时建议把 Gate 的输出直接写进发布单,而不是只放在监控看板上。发布工具应该展示档位、原因和剩余比例;如果发布单上只有“允许/拒绝”,审批人看不到判断依据,久而久之又会回到拍脑袋。
检查表
- 预算公式、窗口和分母口径已经写进 SLI 文档;
-
eligible有独立监控,下降时先查数据; - 燃尽率至少使用长短两个窗口;
- 三档门禁有明确条件、策略和审批人;
- 紧急发布记录包含事故编号、原因、批准人和有效期;
- 发布单展示档位、原因和剩余比例;
- SLO 目标变更已版本化;
- 预算没有进入个人绩效排名。
小结
错误预算的价值不在于“还剩多少”,而在于它把“继续发布”和“减少伤害”之间的取舍变成了一个可讨论、可记录的动作。公式很简单,难的是窗口、分母和例外:窗口选错会误判趋势,分母漂移会让预算失真,例外滥用会让门禁形同虚设。
下一节处理预算和门禁都挡不住的情况:事故已经发生,怎么分级、叫醒谁、多久同步一次,以及什么时候可以宣布恢复。
阅读导航:上一节:6.1 游戏服务端怎么定义 SLO · 下一节:6.3 事故分级与响应 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。