40.3 文档与知识传递
一句话
文档的价值不是“写得全”,而是让接手的人在 N 小时内做出正确决定;能测的是 bus factor、上手阻塞时长、文档腐烂率三个数,不是文档字数。
四个误区,先拆掉
| 误区 | 现实 |
|---|---|
| 文档越多越好 | 没人维护的文档比没有文档更糟:它给出过期答案,让新人先学错再改。文档有腐烂期,过期即负资产 |
| 代码即文档 | 代码能说明“怎么做的”,说不出“为什么没选另一种”。三个月后没人记得为什么不用消息队列,只记得当时吵过架 |
| wiki 就是知识库 | wiki 是无主之地:没有 owner、没有删除规则,三年后 80% 页面没人能确认有效性 |
| 写一次管三年 | 文档跟着架构与流程变。判据不是“多久写一次”,而是“变更时有没有触发更新” |
四类文档:读者、truth source、腐烂速度都不同
不分层就必然写错——同一份文档想同时服务四种读者,结果是四种人都不满意:
| 类型 | 读者 | 唯一真相源 | 腐烂速度 | 该写什么 | 不该写什么 |
|---|---|---|---|---|---|
| 决策记录(ADR) | 未来的自己、新来的架构师 | 版本库,与代码同仓 | 慢(不腐烂,决策是历史事实) | 背景、候选方案、为什么不选、何时重审 | 实现细节、操作步骤 |
| 契约(API / 事件 Schema) | 调用方、SDK 作者 | 代码里的 schema + 生成的文档 | 中(跟着版本走) | 字段语义、兼容规则、错误码 | 实现原理、内部表结构 |
| 运行手册(runbook) | 值班人 | 运维 repo,与告警同源 | 快(依赖拓扑一变就废) | 判定条件、命令、预期输出、回滚、升级路径 | 设计理念 |
| 背景说明(架构 / 领域) | 新人、跨团队 | 与 ADR 互相引用 | 慢 | 系统边界、关键数据流、术语表 | 逐行代码解释 |
🔑 判据:一条信息只能有一个真相源。 同一个事实出现在 wiki、README、Confluence、注释里各一份,等于四份都会过期,且没有人负责删。契约类的真相源必须是代码,背景类的必须挂在 ADR 上,runbook 的必须能一键被值班人找到。
知识集中度:bus factor 与单点模块
文档写得好不好,先看知识有多集中。两个人扛 90% 的关键模块,文档再多也会在某人休假时停摆:
- bus factor:最少抽掉几个人,项目就有一大半没人能维护。
- HHI(份额平方和):1 = 一人独占,接近 0 = 高度分散。
- 单点模块占比:第一责任人份额 ≥ 80% 的模块承载多少代码。
可运行的度量:模块 × 作者 + 时间账
示例数据(6 个模块、5 名工程师;真实项目把行数换成最近 12 个月的改动行数或 commit 数):
package main
import (
"fmt"
"sort"
)
// 知识集中度只看两件事:每个模块"第一名占多少",以及"抽掉前 k 个人后还剩几个模块有人能维护"。
// 代码行数只是权重的代理,真实项目可以换成 commit 数、评审次数或最近 12 个月的改动行数。
type mod struct {
name string
owner map[string]float64 // 工程师 → 贡献行数
}
func (m mod) total() float64 {
var s float64
for _, v := range m.owner {
s += v
}
return s
}
func (m mod) shares() map[string]float64 {
out := map[string]float64{}
t := m.total()
for k, v := range m.owner {
out[k] = v / t
}
return out
}
func (m mod) top() (string, float64) {
best, bv := "", 0.0
for k, v := range m.shares() {
if v > bv {
best, bv = k, v
}
}
return best, bv
}
// HHI:份额平方和。1 表示一人独占,接近 0 表示高度分散。
func (m mod) hhi() float64 {
var h float64
for _, s := range m.shares() {
h += s * s
}
return h
}
// 仍然"有人能维护"的判据:抽掉若干人之后,模块里还有人的份额 ≥ 10%
func (m mod) maintainableWithout(gone map[string]bool) bool {
for k, s := range m.shares() {
if !gone[k] && s >= 0.10 {
return true
}
}
return false
}
func main() {
mods := []mod{
{"订单", map[string]float64{"A": 8000, "B": 2000, "C": 500}},
{"支付", map[string]float64{"A": 6000, "C": 1000}},
{"结算", map[string]float64{"B": 5000}},
{"库存", map[string]float64{"C": 4000, "D": 1500}},
{"网关", map[string]float64{"D": 3000, "E": 800, "A": 400}},
{"报表", map[string]float64{"E": 2500}},
}
fmt.Println("【一】每个模块的第一责任人占比与 HHI")
fmt.Printf("%-6s %8s %8s %8s %8s\n", "模块", "总行数", "第一人", "占比", "HHI")
for _, m := range mods {
n, s := m.top()
fmt.Printf("%-6s %8.0f %8s %7.1f%% %8.2f\n", m.name, m.total(), n, s*100, m.hhi())
}
// 全局按总贡献排序,逐个抽掉
global := map[string]float64{}
for _, m := range mods {
for k, v := range m.owner {
global[k] += v
}
}
type kv struct {
k string
v float64
}
var people []kv
for k, v := range global {
people = append(people, kv{k, v})
}
sort.Slice(people, func(i, j int) bool { return people[i].v > people[j].v })
fmt.Println("\n【二】抽掉贡献最大的前 k 个人,还剩几个模块有人能维护(共 6 个)")
gone := map[string]bool{}
for k := 1; k <= 3; k++ {
gone[people[k-1].k] = true
alive := 0
var lost []string
for _, m := range mods {
if m.maintainableWithout(gone) {
alive++
} else {
lost = append(lost, m.name)
}
}
fmt.Printf("抽掉 %d 人(%v):剩 %d/6 可维护,失去 %v\n", k, keys(gone), alive, lost)
}
fmt.Println("\n【三】单点模块:第一人占比 ≥ 80%")
single := 0
var singleLines float64
var totalLines float64
for _, m := range mods {
totalLines += m.total()
if _, s := m.top(); s >= 0.80 {
single++
singleLines += m.total()
fmt.Printf(" %-6s 第一人占 %.1f%%\n", m.name, s*100)
}
}
fmt.Printf("单点模块 %d/6 个,承载 %.0f/%.0f 行 = %.1f%% 的代码\n",
single, singleLines, totalLines, singleLines/totalLines*100)
// 四、知识传递的时间账:等待成本
const (
questionsPerWeek = 5.0 // 新人前 3 个月每周的阻塞问题数(示例假设)
waitHours = 4.0 // 每个问题平均等待 owner 有空的时间(示例假设)
onboardWeeks = 12.0 // 上手期
docPerModHours = 16.0 // 给一个模块补"决策 + 运行手册 + 坑"的工时
)
waitPerNewcomer := questionsPerWeek * onboardWeeks * waitHours
fmt.Println("\n【四】一个新人前 12 周被阻塞的时间(示例假设:每周 5 问、每问等 4 小时)")
fmt.Printf("等待总时长 %.0f 小时 ≈ %.1f 个工作日(按 8 小时/天)\n", waitPerNewcomer, waitPerNewcomer/8)
fmt.Println("\n【五】补文档的 ROI:一个模块的投入 vs 省下的等待")
for _, newcomers := range []float64{1, 3, 6} {
saved := waitPerNewcomer * 0.5 * newcomers // 假设补文档后阻塞时间减半
fmt.Printf("新人 %d 人:等待从 %.0f 降到 %.0f 小时,省 %.0f 小时;投入 %.0f 小时 → 净 %+.0f 小时(盈亏平衡 %d 人)\n",
int(newcomers), waitPerNewcomer*newcomers, saved, saved, docPerModHours, saved-docPerModHours,
int(docPerModHours/(waitPerNewcomer*0.5))+1)
}
}
func keys(m map[string]bool) []string {
var out []string
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
本机运行结果(GO111MODULE=off go run main.go,Go 1.27.1 darwin/arm64):
【一】每个模块的第一责任人占比与 HHI
模块 总行数 第一人 占比 HHI
订单 10500 A 76.2% 0.62
支付 7000 A 85.7% 0.76
结算 5000 B 100.0% 1.00
库存 5500 C 72.7% 0.60
网关 4200 D 71.4% 0.56
报表 2500 E 100.0% 1.00
【二】抽掉贡献最大的前 k 个人,还剩几个模块有人能维护(共 6 个)
抽掉 1 人([A]):剩 6/6 可维护,失去 []
抽掉 2 人([A B]):剩 4/6 可维护,失去 [订单 结算]
抽掉 3 人([A B C]):剩 3/6 可维护,失去 [订单 支付 结算]
【三】单点模块:第一人占比 ≥ 80%
支付 第一人占 85.7%
结算 第一人占 100.0%
报表 第一人占 100.0%
单点模块 3/6 个,承载 14500/34700 行 = 41.8% 的代码
【四】一个新人前 12 周被阻塞的时间(示例假设:每周 5 问、每问等 4 小时)
等待总时长 240 小时 ≈ 30.0 个工作日(按 8 小时/天)
【五】补文档的 ROI:一个模块的投入 vs 省下的等待
新人 1 人:等待从 240 降到 120 小时,省 120 小时;投入 16 小时 → 净 +104 小时(盈亏平衡 1 人)
新人 3 人:等待从 720 降到 360 小时,省 360 小时;投入 16 小时 → 净 +344 小时(盈亏平衡 1 人)
新人 6 人:等待从 1440 降到 720 小时,省 720 小时;投入 16 小时 → 净 +704 小时(盈亏平衡 1 人)
逐条读这份输出:
- bus factor = 2:抽掉 A 还剩 6/6(说明单看“总贡献”会高估风险),抽掉 A+B 只剩 4/6。bus factor 必须按模块算,不能按总行数算——A 写了 21800 行(63%),但订单模块还有 B 的 19%,而支付模块抽掉 A 就没人了(C 只有 14.3%,低于 10% 判据)。
- HHI 与“第一人占比”给出不同答案:支付模块 85.7%、HHI 0.76,看着比结算的 100%/1.00 好一点,但两者都是单点:C 的 14.3% 只是接得住“改一行”,接不住“重写支付流程”。
- 单点模块 3/6 承载 41.8% 代码,这就是“某个人休假两周,一半关键链路没人敢动”的量化形态。判据不是“有没有备份人”,而是备份人能不能独立完成一次变更。
- 文档 ROI 在 1 个新人时就为正:投入 16 小时,省 120 小时等待。注意这是上限估计——它假设补文档后阻塞时间减半、且新人真的去读。实际打折后仍为正,但若只写“无 owner 的 wiki”,阻塞时间不会降,因为新人找不到、也不敢信。
文档的三个硬规则
| 规则 | 做法 | 为什么 |
|---|---|---|
| 有 owner、有期限 | 每个 runbook 标 owner 与“最后验证日期”,超 90 天进“待验证”队列 | 过期文档比没文档更危险:它让人跳过验证 |
| runbook 必须演练过 | 每季度按文档从零跑一次,记下卡住的地方(36.7 演练与验证) | 没跑过的命令,在故障当天第一次跑,等于没有 |
| ADR 只写“为什么” | 背景、候选、取舍、重审条件;实现细节留在代码与契约里 | 决策是历史事实,不会腐烂;实现细节会 |
什么时候不该写文档
- 代码能自证的部分:命名清楚、结构扁平时,“这个函数干什么”不需要注释。用注释解释 what,等于承认命名失败。
- 一次性脚本 / 迁移工具:跑完即弃,写文档的时间不如写一个
--dry-run。 - 需求随时变的探索期:需求一天三变时写设计文档是浪费;写决策日志(半页,记下这次为什么这么改)更划算。
反模式
| 反模式 | 后果 |
|---|---|
| wiki 里 300 页无 owner 的文档 | 新人先学错再改,纠正成本高于从零教 |
| runbook 只在故障时第一次执行 | 命令路径、权限、参数全过期,故障时长翻倍 |
| 用“文档已更新”结案,不写验证方式 | 无法判断是否真的更新,下一轮重犯 |
| 只让一个人写核心模块文档 | 文档与他脑子里的版本逐渐分叉,且他还是单点 |
| 把 ADR 写成会议纪要 | 只有“决定做 X”,没有候选与不选的理由,三个月后原样重开争论 |
| 文档按团队组织而不是按系统组织 | 跨团队找一个接口说明要翻三个空间,最后去问人 |
关联
- 前置:4.6 决策框架与决策记录(ADR 的主场:框架 + 记录才有用)、40.2 耦合与内聚度量(变更耦合能反推真实的知识分布)
- 并列:36.7 演练与验证(runbook 的验收方式是演练)
- 下游:56.3 on-call(值班手册是 runbook 的落地形态)、40.5 变更成本
自检
Q1 团队有 6 个模块、5 个工程师,bus factor 算出 1(抽掉 A 还剩 6/6 可维护)。为什么这个“1”是低估,而按模块算却是 2?哪个结论该拿去汇报?
▸ 答案:全局算法把 A 的总贡献(21800 行、63%)当成一个整体,抽掉 A 后每个模块都还有别人 ≥10% 的份额,所以显示 6/6——它只回答“还有没有人碰过”,不回答“能不能独立完成一次变更”。按模块算:支付模块 A 占 85.7%、C 只有 14.3%,接不住重写;结算模块 B 占 100%,抽掉 A+B 后订单(C 仅 4.8%)与结算同时失去维护者,剩 4/6。该汇报的是 2,并附上“单点模块 3/6 个、承载 41.8% 代码”这个更刺眼的数字——bus factor 是“最少几个人倒下”,不是“出现过几个人”。
Q2 补一个模块的文档投入 16 小时,算出来 1 个新人就能净省 104 小时。这个 ROI 最可能在哪里被高估?给两个具体的失效路径。
▸ 答案:高估在**“省下的等待”这个上限估计上,两条失效路径:① 文档没有 owner、没有验证日期,新人读到的是过期命令,先学错再改,等待时间反而不降;② 文档写了但不指向人**——阻塞问题往往不是“资料在哪”,而是“这个设计当时为什么这么定”,只有 ADR 能回答,写一份“接口说明”救不了决策类阻塞。所以 ROI 要按“决策类文档”算,并按“90 天未验证即失效”打折。
Q3 runbook 写得完整、命令都对、演练也通过了,为什么故障当天还是慢?
▸ 答案:因为 runbook 只覆盖“已知路径”,而故障当天的瓶颈通常是判定:现在到底该不该切、切到哪个备侧、谁有权拍板。这三件事不在“命令清单”里。慢在决策段(演练记分卡里通常占 20%–35%),不是慢在敲命令。补法是把 runbook 升级成“判定树 + 命令”:先写“看到什么指标 → 判定为哪种故障 → 走哪条分支”,每条分支再给命令。只有命令没有分支,值班人仍要在最紧张的时候自己做架构判断。
Q4 一个团队把 ADR 写成“2026-03 决定用 Kafka 做订单事件”。三个月后新人问“为什么不用 RabbitMQ”,没人答得上。缺的是哪几段?为什么这段比结论更重要?
▸ 答案:缺三段:候选方案(当时评估了哪些)、取舍依据(吞吐/顺序性/运维成本/团队熟悉度各如何权重)、重审条件(什么情况该重新考虑,比如消息量降到 1/10 或团队无人会运维 Kafka)。结论只是“当时的选择”,取舍依据才是“下次遇到同类问题能复用的判断”。没有这三段,ADR 退化成会议纪要,争论会在每次人员变动后重开——而这正是决策记录要防的唯一一件事。