40.5 变更成本
一句话
变更成本不是“改代码的时间”,而是验证 + 发布 + 回滚 + 故障 + 协作五笔账之和;它随批量先降后升,最优批量是一个能算出来的数(本例 5 个变更/次),既不是“越小越好”,也不是“冻结更安全”。
五笔账:写代码通常是最便宜的那笔
| 账 | 内容 | 量级(示例) | 随批量怎么变 |
|---|---|---|---|
| 开发 | 理解、修改、自测 | 2–8 小时/变更 | 不变 |
| 验证 | 回归、灰度观察、人工点检 | 1–4 小时/变更 | 批量发布时只做一次,被摊薄 |
| 发布 | 值守、发布窗口、变更单审批 | 2000 元/次 | 被摊薄(c/B) |
| 故障 | 回滚、定位、通报、业务损失 | 10 万元/次(基线) | 概率随批量上升,代价也随批量上升(要回滚的东西更多) |
| 协作 | 等待评审、跨团队对齐、排队 | 4–48 小时 | 批量越大,等待越长(但同一批内可并行) |
🔑 只有“开发”这一笔与批量无关。其余四笔里,验证与发布随批量下降,故障与协作随批量上升——最优批量就是这两组力量的交点。谁只算前两笔(“合并发布省事”),或只算故障(“小批量更安全”),都会得到片面的结论。
把三条曲线写成公式
把 B 个变更合成一次发布:
- 失败概率:
p_B = 1 − (1−p)^B(独立假设;B 越大越接近 1) - 失败代价:
C(B) = C0 + k·B(要回滚的变更越多、爆炸半径越大) - 每变更期望成本:
cost(B) = (c + p_B·C(B)) / B
三个参数各有含义:c 是每次发布的固定成本,p 是单个变更引入失败的概率,k 是“每多一个变更,失败代价增加多少”。
关键一步推导:当 B 很大时 p_B → 1,于是 cost(B) → (C0 + k·B)/B → k。这说明每变更的失败次数最终会稳定在 p 附近(B 个变更里期望失败 p·B 个,摊回每个变更就是 p),所以“批量大所以更容易失败”本身不构成反对大批量的理由;真正的理由是 k > 0:一次失败要回滚的东西变多,代价随批量线性增长。k = 0 的模型会得出“批量越大越省”,那不是规律,是漏项。
可运行的模型:找最优批量
package main
import (
"fmt"
"math"
)
// 变更成本模型:把 B 个变更合成一次发布。
// 固定成本 c 被摊薄(c/B);失败概率随 B 上升(1-(1-p)^B);
// 失败代价也随 B 上升(C0 + k·B:要回滚的变更越多、爆炸半径越大)。
// 三股力量合起来,每变更期望成本才有极小点——模型里少一项,结论就会变成"批量越大越省"。
type model struct {
c float64 // 每次发布的固定成本(值守、回归、灰度观察)
p float64 // 单个变更引入失败的概率
C0 float64 // 失败的基线代价(定位、通报、单变更回滚)
k float64 // 每多一个变更,失败代价增加多少(回滚复杂度、影响面)
}
func (m model) failProb(b int) float64 { return 1 - math.Pow(1-m.p, float64(b)) }
func (m model) failCost(b int) float64 { return m.C0 + m.k*float64(b) }
func (m model) perChange(b int) float64 {
return (m.c + m.failProb(b)*m.failCost(b)) / float64(b)
}
func best(m model, cap int) (int, float64) {
bi, bc := 1, m.perChange(1)
for b := 1; b <= cap; b++ {
if c := m.perChange(b); c < bc {
bi, bc = b, c
}
}
return bi, bc
}
func main() {
m := model{c: 2000, p: 0.02, C0: 100000, k: 5000}
fmt.Println("【一】每变更期望成本 = (发布固定成本 + 失败概率 × 失败代价) / 批量")
fmt.Printf("%-6s %10s %12s %14s %12s\n", "批量", "失败概率", "失败代价", "每批总成本", "每变更成本")
for _, b := range []int{1, 2, 4, 8, 16, 32, 64} {
fmt.Printf("%-6d %9.2f%% %12.0f %14.0f %12.0f\n",
b, m.failProb(b)*100, m.failCost(b), m.c+m.failProb(b)*m.failCost(b), m.perChange(b))
}
b1, c1 := best(m, 400)
fmt.Printf("\n【二】最优批量 B* = %d,每变更成本 %.0f 元;B=1 时 %.0f 元,省 %.0f%%\n",
b1, c1, m.perChange(1), (1-c1/m.perChange(1))*100)
fmt.Println("\n【三】敏感度:失败代价里“随批量增长”的部分(k)决定曲线形状")
fmt.Printf("%-6s %8s %14s %14s %s\n", "k", "最优批量", "每变更成本", "B=64 成本", "含义")
for _, k := range []float64{0, 5000, 20000} {
mm := m
mm.k = k
b2, c2 := best(mm, 400)
meaning := "批量越大越省(没有最优,只有上限)"
if k > 0 {
meaning = "存在极小点,超过就变贵"
}
fmt.Printf("%-6.0f %8d %14.0f %14.0f %s\n", k, b2, c2, mm.perChange(64), meaning)
}
fmt.Println("\n【四】四种常见做法,折算成每变更成本(p=2%、C0=10 万、k=5000、c=2000)")
cases := []struct {
name string
b int
}{
{"每个变更单独发布", 1},
{"每周批量(约 8 个变更)", 8},
{"变更冻结后集中发布(约 32 个)", 32},
{"冻结 + 假装回滚不要钱(k=0)", 32},
}
for _, cs := range cases {
if cs.name == "冻结 + 假装回滚不要钱(k=0)" {
mm := m
mm.k = 0
fmt.Printf("%-34s 每变更成本 %.0f 元(少算一项,结论反向)\n", cs.name, mm.perChange(cs.b))
continue
}
fmt.Printf("%-34s 每变更成本 %.0f 元\n", cs.name, m.perChange(cs.b))
}
}
本机运行结果(GO111MODULE=off go run main.go,Go 1.27.1 darwin/arm64):
【一】每变更期望成本 = (发布固定成本 + 失败概率 × 失败代价) / 批量
批量 失败概率 失败代价 每批总成本 每变更成本
1 2.00% 105000 4100 4100
2 3.96% 110000 6356 3178
4 7.76% 120000 11316 2829
8 14.92% 140000 22893 2862
16 27.62% 180000 51716 3232
32 47.61% 260000 125790 3931
64 72.55% 420000 306730 4793
【二】最优批量 B* = 5,每变更成本 2802 元;B=1 时 4100 元,省 32%
【三】敏感度:失败代价里“随批量增长”的部分(k)决定曲线形状
k 最优批量 每变更成本 B=64 成本 含义
0 400 255 1165 批量越大越省(没有最优,只有上限)
5000 5 2802 4793 存在极小点,超过就变贵
20000 2 3772 15676 存在极小点,超过就变贵
【四】四种常见做法,折算成每变更成本(p=2%、C0=10 万、k=5000、c=2000)
每个变更单独发布 每变更成本 4100 元
每周批量(约 8 个变更) 每变更成本 2862 元
变更冻结后集中发布(约 32 个) 每变更成本 3931 元
冻结 + 假装回滚不要钱(k=0) 每变更成本 1550 元(少算一项,结论反向)
逐条读这份输出:
- 最优批量是 5,不是 1 也不是 32:单变更发布 4100 元,5 个一批 2802 元(省 32%),32 个一批又回到 3931 元。曲线两头高、中间低——“小批量最好”和“批量省事”都只说对了半边。
- 失败概率从 2% 涨到 47.6%(B=32)看起来吓人,但它不是主要成本项:B=32 时失败代价期望是 12.6 万,但摊到 32 个变更上每变更 3931 元,仍低于单变更发布的 4100 元。真正把 32 拉贵的是失败代价从 10.5 万涨到 26 万(k 项)——即“一次失败要回滚 32 个变更”。
- k 决定曲线形状:
k=0时最优批量顶到搜索上限(400),成本单调降到 255 元——这就是“变更冻结”的算术借口:只要把回滚代价算成 0,冻结永远是“最省的”。反过来k=20000(一次失败要回滚三十多个变更、影响面大)时,最优批量掉到 2。 - 冻结(32 个一批)比每周批量(8 个一批)贵 37.4%(3931 vs 2862)。冻结没有消灭成本,只是把它从“发布次数”搬到了“单次故障的回滚代价”上,并且顺带把反馈周期拉长到一个月。
用什么指标盯住它
变更成本不可直接读表,用四个可采集的指标反推(DORA 的四项,口径见 59.1 DORA 四指标):
| 指标 | 采集方式 | 变差说明 |
|---|---|---|
| 部署频率 | 发布系统计数 | 频率骤降 = 批量在变大,成本正向故障端移动 |
| 变更前置时间 | 首个 commit → 上线 | 变长 = 协作/审批成本上升 |
| 变更失败率 | 导致回滚或热修的比例 | 上升 = 验证不足或批量过大 |
| 恢复时长(MTTR) | 故障开始 → 恢复 | 上升 = 回滚成本 k 在变大 |
判据:四个指标要一起看。部署频率升、失败率也升,说明在“带病快跑”;频率降、MTTR 升,说明进入了“冻结期”——此时变更成本没降,只是被记到了下个月的故障账上。
什么时候该接受昂贵的变更
- 不可逆的数据库迁移(删列、改类型):批量天然只能是 1,且要有双写与回滚窗口,见 55.4 数据库变更管理。
- 强审批行业(金融、医疗):审批成本 c 天然高,最优批量会右移,但不要右移到“一个月一次”——那时 k 已经涨上来了。
- 爆炸半径不可分割的系统(单体核心账务):先做可回滚设计(开关、影子表),把 k 降下来,再去调批量,顺序不能反。
反模式
| 反模式 | 后果 |
|---|---|
| 用“变更冻结”降风险 | k 变大、反馈周期变长,成本延后爆发;本例冻结比每周批量贵 37.4% |
| 只算发布次数与 CI 分钟数 | 漏掉故障与回滚两笔,得出“合并发布永远更省” |
| 把回滚成本当成 0 | 模型退化成单调递减,结论变成“批量越大越好”,这正是多数变更事故的组织成因 |
| 用平均失败率掩盖长尾 | 一次 26 万的回滚摊进 32 个变更,账面好看,实际是那一批在承担风险 |
| 只降开发时间(招人、加 IDE) | 开发只占五笔账的一小部分,验证与故障才是大头 |
| 大批量发布 + 无灰度 | 失败概率接近 1 且影响面全量,k 与 p 同时拉满 |
关联
- 前置:55.1 发布策略(滚动/蓝绿/金丝雀决定了 p 与 k)、40.2 耦合与内聚度量(耦合高 → 一次变更牵连更多模块 → k 变大)
- 并列:55.3 回滚设计(k 的主要杠杆)、55.2 特性开关(把 k 拆小:出问题只关开关,不回滚整批)
- 下游:56.5 事故响应(失败代价 C0 怎么被实际消耗)、40.4 代码可读性与一致性(可读性差 → 开发与验证两笔同时上涨)
自检
Q1 老板要求“以后每月集中发布一次,减少故障”。用本节的模型说明这个决定在什么条件下成立、在什么条件下反而更贵。
▸ 答案:每月约 32 个变更,每变更成本 3931 元,比每周批量(8 个,2862 元)贵 37.4%。它成立只有一种情况:k 足够小,即回滚几乎无代价(有完善开关、数据库变更全部可逆、影响面可隔离),此时曲线右移、大批量才可能更省。它更贵的情况是常态:回滚要动 32 个变更、爆炸半径扩大、反馈周期从 1 周拉到 1 个月。判据是 k,不是“发布次数少所以风险低”。
Q2 团队把 p 从 2% 降到 1%(加强测试),最优点会怎么移动?为什么不是“p 越低越好,所以批量越该大”?
▸ 答案:p 减半后 p_B 整体下移,失败代价期望变小,曲线最低点会右移(可以承受更大批量),但每变更成本的下限由 k 决定(B 大时 cost → k),所以 p 再低也不会让成本低于 k 附近。反过来说:p 降到 0 才可能让“批量无限大”成立,而 p=0 在真实系统里不存在。降低 p 的价值在于“把曲线整体压低”,不在于“可以无限合并”。
Q3 上线后部署频率从每周 5 次降到每月 2 次,变更失败率从 8% 降到 3%,MTTR 从 40 分钟升到 4 小时。整体变更成本是升是降?先动哪个指标?
▸ 答案:大概率是升的。失败率降 62% 看着很好,但 MTTR 涨 6 倍说明 k 在变大(一批要回滚的东西更多),而部署频率骤降说明批量从约 8 个涨到约 30 个。按本节参数,30 个一批的每变更成本已经越过最优点。先动 k:把不可逆变更拆出来(数据库变更走 55.4 数据库变更管理 的双写窗口)、给新功能加开关(55.2 特性开关),让“回滚”变成“关开关”而不是“回滚整批”。k 降下来之后,批量自然会回到 5–8 的区间。
Q4 为什么“每个变更单独发布”也不是最优?它不是反馈最快、回滚最小吗?
▸ 答案:因为它把固定成本 c 全额承担了:4100 元/变更,其中 2000 元是发布值守、2100 元是 2% × 10.5 万的失败期望。反馈快、回滚小的收益在这个模型里体现为 k 小,但 c 是实打实的重复支出。最优解是在 k 允许的范围内合并到 5 个左右:既让 c 摊薄到 400 元/变更,又没让 k 涨到主导。这也解释了为什么小团队常感觉“小批量更累”——他们的 c 里有很多人工步骤(审批、手点回归),c 越大,最优点越靠右。