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 元(少算一项,结论反向)

逐条读这份输出:

  1. 最优批量是 5,不是 1 也不是 32:单变更发布 4100 元,5 个一批 2802 元(省 32%),32 个一批又回到 3931 元。曲线两头高、中间低——“小批量最好”和“批量省事”都只说对了半边。
  2. 失败概率从 2% 涨到 47.6%(B=32)看起来吓人,但它不是主要成本项:B=32 时失败代价期望是 12.6 万,但摊到 32 个变更上每变更 3931 元,仍低于单变更发布的 4100 元。真正把 32 拉贵的是失败代价从 10.5 万涨到 26 万(k 项)——即“一次失败要回滚 32 个变更”。
  3. k 决定曲线形状:k=0 时最优批量顶到搜索上限(400),成本单调降到 255 元——这就是“变更冻结”的算术借口:只要把回滚代价算成 0,冻结永远是“最省的”。反过来 k=20000(一次失败要回滚三十多个变更、影响面大)时,最优批量掉到 2。
  4. 冻结(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 同时拉满

关联

自检

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 越大,最优点越靠右。