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 人日,利息虽高但单位投入回报低;它该走“演进路线图”,不是季度还债。

三点解读:

  1. 高息小债优先:支付回调风险本金 8 人日、月利息 3 人日,还完不到 3 个月回本,排第一。
  2. 大重构不是季度还债:单体模块本金 60 人日,虽然利息 6 人日/月,但单位投入回报低,应放进 58.4 演进路线图 分波做。
  3. 还完前 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 重构三式 (有的放矢 / 合纵连横 / 运筹帷幄)。

关联