58.2 技术债管理
一句话
技术债是用未来利息换现在速度的显式交易;管理它的关键不是“清零”,而是知道欠了什么、利息多少、什么时候还。
先破除四个误区
| 误区 | 现实 |
|---|---|
| “技术债 = 烂代码” | 烂代码是结果,债是决策:为了赶窗口用了临时方案,并约定以后还。没有约定的烂代码叫“事故”,不叫债 |
| “债要清零” | 清零既不现实也不经济。目标是把利息压到可接受,并保证高息债优先还 |
| “还债是研发的事,业务不用管” | 债的利息表现为交付变慢、故障变多、人力被吞;这是业务成本,排期必须业务一起定 |
| “重构 = 还债” | 重构可能只是搬家;还债要针对具体的利息来源,并有可验证的验收标准 |
债的四个属性
| 属性 | 含义 | 怎么量 |
|---|---|---|
| 本金(principal) | 还清需要投入的人日 | 估算 + 缓冲(×1.5) |
| 利息(interest) | 不还的话,每月损失多少(人日/故障/风险) | 变更耗时、故障频率、加班、绕行代码量 |
| 敞口(exposure) | 影响多少团队/用户/链路 | 1–5 分,按影响面 |
| 账龄(age) | 已经拖了多久 | 月;越久越难还,利息通常越高 |
🔑 债的利息本质是变更成本:同一需求,有债的团队要多花的时间、多出的故障、多写的绕行代码,就是利息(见 40.5 变更成本)。
五类债,利息来源不同
| 类型 | 例子 | 利息表现 | 常见还法 |
|---|---|---|---|
| 代码债 | 复制粘贴、超长函数、魔法数 | 改一处漏三处、评审慢 | 小步重构 + 测试护栏 |
| 架构债 | 模块边界混乱、双轨鉴权、共享数据库 | 牵一发动全身、发布耦合 | 绞杀者/分支抽象,走路线图 |
| 测试债 | flaky、覆盖缺口、手工回归 | 发布不敢发、回归靠人 | 补关键路径测试 + 隔离 flaky |
| 运维债 | 手工部署、配置漂移、无 runbook | 故障恢复慢、值班累 | 自动化 + IaC + runbook |
| 文档债 | 过时设计、缺失决策记录 | 新人上手慢、重复踩坑 | ADR + 最小文档集 |
有意欠债 vs 无意欠债
| 类型 | 场景 | 处理 |
|---|---|---|
| 有意且审慎 | 大促前先用临时方案,约定大促后还 | 写进 ADR/backlog,带 owner 与截止日期 |
| 有意且鲁莽 | 明知会炸,为了赶进度硬上,不记录 | 变成事故;需要复盘与制度约束 |
| 无意且审慎 | 当时不知道还有别的做法,后来才懂 | 学习成本,补进规范 |
| 无意且鲁莽 | 不知道有坑,也没测试 | 靠评审、静态检查、演练兜底 |
只有“有意且审慎”才是真正的债;其余三类是缺陷,需要用工程手段减少,而不是排期“还债”。
登记表:没有账本就没有管理
| 字段 | 说明 | 示例 |
|---|---|---|
| ID / 名称 | 唯一标识、一句话说清 | DEBT-014 支付回调重复扣款风险 |
| 类型 | code/arch/test/ops/doc | arch |
| 本金 | 还清需要的人日 | 8 |
| 月利息 | 每月损失的人日/风险成本 | 3 |
| 敞口 | 1–5 | 5 |
| 账龄 | 已存在月数 | 18 |
| Owner | 谁负责推进 | order-team |
| 触发条件 | 什么情况下必须还(如“再出一次重复扣款就停线”) | 事故/容量到顶 |
| 验收标准 | 怎么算还清 | 重复回调测试通过 + 对账差异 0 |
一个能跑的“还债排序器”
排序分 = 月利息 × 敞口 × (1 + 账龄/24) / 本金。含义:利息高、影响大、拖得久、且还起来不贵的,先还。
package main
import (
"fmt"
"sort"
)
type debt struct {
name string
kind string // code / arch / test / ops / doc
principal float64 // 还清需要的人日
interest float64 // 每月因它损失的人日(或等价风险成本)
exposure int // 1–5:影响多少团队/用户
age int // 已存在几个月
}
// score 越大越该先还:利息高、影响面大、拖得久,且还起来不贵。
func score(d debt) float64 {
return d.interest * float64(d.exposure) * (1 + float64(d.age)/24) / d.principal
}
func main() {
debts := []debt{
{"支付回调重复扣款风险", "arch", 8, 3, 5, 18},
{"集成测试 flaky", "test", 5, 2, 4, 12},
{"旧鉴权与新权限模型双轨", "arch", 20, 4, 4, 30},
{"配置漂移导致环境不一致", "ops", 3, 1.5, 3, 6},
{"关键服务没有 runbook", "doc", 4, 1, 2, 20},
{"单体模块边界混乱", "arch", 60, 6, 3, 40}, // 大重构,利息高但本金也高
}
sort.Slice(debts, func(i, j int) bool { return score(debts[i]) > score(debts[j]) })
budget := 40.0
fmt.Println("== 技术债优先级 ==")
fmt.Printf("%-24s %-5s %8s %8s %6s %5s %8s %s\n",
"DEBT", "KIND", "PRINCIPAL", "INTEREST", "EXPOS", "AGE", "SCORE", "QUARTERLY")
spent, saved := 0.0, 0.0
for _, d := range debts {
funded := false
if spent+d.principal <= budget {
spent += d.principal
saved += d.interest * 3
funded = true
}
mark := "—"
if funded {
mark = "funded"
}
fmt.Printf("%-24s %-5s %8.1f %8.1f %6d %4d月 %8.2f %s\n",
d.name, d.kind, d.principal, d.interest, d.exposure, d.age, score(d), mark)
}
totalInterest := 0.0
for _, d := range debts {
totalInterest += d.interest
}
fmt.Printf("\n季度预算 %.0f 人日,已排 %.0f 人日;每季度减少损失约 %.1f 人日\n", budget, spent, saved)
fmt.Printf("全部债的月利息 %.1f 人日(季度 %.1f);还完前 5 项后,剩余月利息 %.1f\n",
totalInterest, totalInterest*3, totalInterest-saved/3)
fmt.Println("注意:最后一项本金 60 人日,利息虽高但单位投入回报低;它该走“演进路线图”,不是季度还债。")
}
本机运行结果:
== 技术债优先级 ==
DEBT KIND PRINCIPAL INTEREST EXPOS AGE SCORE QUARTERLY
支付回调重复扣款风险 arch 8.0 3.0 5 18月 3.28 funded
集成测试 flaky test 5.0 2.0 4 12月 2.40 funded
配置漂移导致环境不一致 ops 3.0 1.5 3 6月 1.88 funded
旧鉴权与新权限模型双轨 arch 20.0 4.0 4 30月 1.80 funded
关键服务没有 runbook doc 4.0 1.0 2 20月 0.92 funded
单体模块边界混乱 arch 60.0 6.0 3 40月 0.80 —
季度预算 40 人日,已排 40 人日;每季度减少损失约 34.5 人日
全部债的月利息 17.5 人日(季度 52.5);还完前 5 项后,剩余月利息 6.0
注意:最后一项本金 60 人日,利息虽高但单位投入回报低;它该走“演进路线图”,不是季度还债。
三点解读:
- 高息小债优先:支付回调风险本金 8 人日、月利息 3 人日,还完不到 3 个月回本,排第一。
- 大重构不是季度还债:单体模块本金 60 人日,虽然利息 6 人日/月,但单位投入回报低,应放进 58.4 演进路线图 分波做。
- 还完前 5 项后月利息从 17.5 降到 6.0:季度投入 40 人日,季度减少损失 34.5 人日,约 1.2 个季度回本——这是能拿去和业务谈排期的数字。
还债策略:配额、优先级、防新增
| 策略 | 做法 | 注意 |
|---|---|---|
| 固定配额 | 每季度拿出 15–20% 容量还债(经验值) | 没有配额,债永远排不上;配额太高,业务不答应 |
| 利息优先 | 按上面的分数排序,先还高息小债 | 不要按“谁喊得响”排 |
| 大债走路线图 | 本金 > 30 人日的,进演进路线图分波 | 塞进季度会拖垮交付 |
| 防新增 | 完成的定义里加“无新增静态告警/无新增绕行” | 只还不防,边还边欠 |
| 触发条件 | 事故/容量到顶/关键人离职 → 立即还 | 把“什么时候必须还”写进登记表 |
防新增的具体手段:静态检查 ratchet(只降不升,见 52.2 静态检查)、评审清单(评审时把“新增了什么债”列为一项,见 58.3 架构评审)、ADR 里记录“临时方案 + 还款计划”(见 58.1 ADR 实践)。
治理节奏
| 周期 | 动作 | 产出 |
|---|---|---|
| 每月 | 更新登记表:新增、利息变化、账龄 | 债务账本 |
| 每季度 | 按分数排下季度还债项,确认预算 | 还债计划 |
| 每半年 | 回看利息估算是否准确、配额是否合理 | 调整系数/配额 |
| 事件驱动 | 事故/容量告警触发“必须还” | 停线或插入排期 |
生产落地:指名 → 可抄 → 可验 → 可退
指名:登记表用 Jira/Linear 的自定义字段或一张表格即可;关键是字段固定(本金/利息/敞口/账龄/owner/验收)。静态告警用 ratchet 工具(见 52.2 静态检查)防新增;大债并入路线图(见 58.4 演进路线图)。
可抄(登记表模板):
| ID | 名称 | 类型 | 本金(人日) | 月利息(人日) | 敞口1-5 | 账龄(月) | Owner | 触发条件 | 验收标准 |
|---|---|---|---|---|---|---|---|---|---|
| DEBT-014 | 支付回调重复扣款风险 | arch | 8 | 3 | 5 | 18 | order | 再出一次重复扣款 | 幂等测试通过 + 对账差异 0 |
可验:本节排序器真跑过,输出如上;把它接进季度排期会,输入登记表就能得到排序与预算分配。人工验证:随机抽 3 条债,问 owner“利息怎么估的、验收标准是什么”,答不上来就是没登记清楚。
可退:还债本身也可能引入故障。做法:小步合并、有测试护栏、可回滚;大债走路线图的分波与灰度,不要一次性重写。还错了方向允许关闭,但要记录“为什么这条不需要了”。
反模式
| 反模式 | 后果 |
|---|---|
| 把烂代码都叫技术债 | 债失去边界,无法排序,也无法和业务谈 |
| 只登记不排期 | 账本越来越长,利息继续涨 |
| 按“谁喊得响”排序 | 高息小债没人管,低息大债占满预算 |
| 用“重写”还债 | 风险与工期失控,往往半途而废 |
| 只还不防 | 边还边欠,利息回到原点 |
| 利息不量化 | 无法和业务讨论优先级,只能靠感觉 |
| 把债当考核指标 | 团队隐藏债、改登记口径(见 59.3 度量的陷阱) |
自检
Q1 技术债和“烂代码”的区别是什么?
▸ 答案:技术债是有意的权衡:为了某个时间窗口采用临时方案,并约定未来偿还;烂代码是没有约定的质量缺陷。区别不在代码本身,而在“是否知情、是否记录、是否有还款计划”。把烂代码都叫债,会让债失去边界,也无法和业务谈优先级。
Q2 为什么排序分要除以本金?
▸ 答案:因为要比较“单位投入的回报”。月利息 6 人日的大重构,本金 60 人日,单位回报不一定高于月利息 3 人日、本金 8 人日的小债。除以本金后,优先还的是“投入小、利息高、影响大”的债,能在同样预算下减少更多损失。
Q3 大债(如单体拆分)应该怎么还?
▸ 答案:不要塞进季度还债预算,应该进演进路线图,按波次拆分:先抽边界、再迁数据、最后下线旧模块,每波有退出标准与回滚点(见 58.4 演进路线图)。季度还债预算用来处理高息小债,两者用不同的治理节奏。
Q4 怎么防止“边还边欠”?
▸ 答案:三件事:① 完成的定义里加“不新增静态告警/不新增绕行代码”;② 静态检查 ratchet 只降不升(见 52.2 静态检查);③ 临时方案必须写 ADR,带 owner 与还款截止日期(见 58.1 ADR 实践)。只还不防,利息会回到原点。
Q5 技术债的利息怎么估,才能让业务信服?
▸ 答案:用可观测的损失估:同一类需求多花的人日、故障恢复时间、加班时长、绕行代码维护量、发布频率被拖慢的程度。至少给一个区间和估算依据,并说明“不还的话,下季度会多损失多少”。不要用“代码很烂”这种无法验证的说法。
债还不上时就要动结构:先筛问题、再排优先级、后分段落地,方法见 58.7 重构三式 (有的放矢 / 合纵连横 / 运筹帷幄)。