技术债清单列了八十条,一年下来一条都没还。上一轮已经谈过"定价"——怎么给一条债报出价。这篇只谈它之后的两个动作:怎么记,以及按什么顺序还。
因为清单不可执行的真正原因往往不在价格,而在账本:条目混着"重写鉴权"和"删个无用常量",没有年龄、没有影响面、没有偿还成本估算,于是排序只能靠嗓门。账本一改,偿还顺序就能算出来。
1. 八十条一条没还:账本的四个缺陷
| 缺陷 | 表现 | 后果 |
|---|---|---|
| 粒度不一 | 大重构与一行清理并列 | 无法比较,只能挑小的 |
| 无年龄 | 不知道谁先欠的、欠了多久 | 老债永远排在后面 |
| 无影响面 | 不知道哪些改动会碰到它 | 无法判断阻塞关系 |
| 无偿还成本 | 不知道要几天 | 排期时必然输给需求 |
一张只有标题与描述的清单,本质上是一份抱怨记录,不是待办队列。
2. 记账:一条债的最小字段
// 账本条目:字段缺失即视为不可排序
id: DEBT-0142
title: 订单状态机散落在三个服务中
type: coupling // coupling | correctness | operability | knowledge
scope: [order-service, payment-service] // 影响面:哪些模块的改动会碰到
interest_signals: // 利息的可观测代理指标
change_lead_time: order-service // 该模块变更时长
defect_density: payment-service
age_since: 2025-03
repay_cost_days: 8 // 粗估即可,量级对就够
blocks: [DEBT-0088] // 阻塞关系
last_touched: 2026-06 // 最近一次被改动触及
字段里最容易被省略、又最决定排序的是 scope 与 last_touched:前者决定它是否阻塞别人,后者决定它是否还活着。
3. 利息不是抽象概念,是四类具体成本
| 利息形态 | 具体表现 | 观测口径 |
|---|---|---|
| 变更变慢 | 同一需求要改多处、评审轮次变多 | 该模块从提交到上线的时长趋势 |
| 缺陷变多 | 改动区域反复出回归问题 | 该模块缺陷密度与复发率 |
| 新人上手慢 | 需要口头传授才能动这块代码 | 新人首次独立提交的时间 |
| 故障恢复慢 | 定位要跨多个服务与仓库 | 同类故障的平均定位时长 |
四类里只有"缺陷变多"通常已有数据;其余三类要专门取数(见第 9 节)。不取数就只能凭感觉说"这块很烂",而感觉无法排期。
4. 偿还顺序:三个判据相乘,而不是相加
| 判据 | 含义 | 取值方向 |
|---|---|---|
| 利息大小 | 不还会持续损失多少 | 越大越优先 |
| 偿还成本 | 要几个人天 | 越小越优先 |
| 是否阻塞 | 是否卡住其他已知工作 | 阻塞即插队 |
// 排序分只用于拉出候选,不用来做最终决策
score = interest * block_factor / max(repay_cost_days, 1)
// interest 取四类成本的归一化值;block_factor:阻塞=2,其余=1
顺序上有一条硬规则优先于算分:被它阻塞的活已经排进本迭代,就先还它。反之,一条利息很高但谁也不碰的债,排进去只是自我感动。
5. 三类偿还方式与各自适用条件
| 方式 | 做法 | 适用条件 | 主要风险 |
|---|---|---|---|
| 顺手还 | 在本来就要改的区域附近一并整理 | 改动半径小、可当场验证 | 扩大变更范围,评审失控 |
| 专项还 | 单独立项、独立验收 | 利息可量化、影响面跨模块 | 立项后指标说不清 |
| 绕过去 | 加隔离层或新路径,不动旧代码 | 旧模块调用量在降、即将废弃 | 双路径长期并存 |
| 拆出去 | 把债务模块整体替换为托管服务 | 该能力非核心、维护成本高于采购 | 迁移期与依赖成本 |
三者不是优劣关系,而是成本结构不同:顺手还摊薄在需求里,专项还占用排期,绕过去把成本推给未来。
6. "顺手还"的边界:只在改动半径内
顺手还失控的典型现场:改一个字段校验,顺手把整个 service 的分层重排,PR 从 80 行变成 1200 行,评审放弃,回归问题三周后才暴露。
| 判据 | 允许顺手还 | 应该另开一项 |
|---|---|---|
| 改动文件数 | 与本需求重叠 | 出现无关文件 |
| 是否改变对外行为 | 不改变 | 改变返回值、错误码 |
| 能否被现有测试覆盖 | 已覆盖 | 需要新增测试体系 |
| 回滚粒度 | 可与需求一起回滚 | 需要独立回滚 |
// PR 提交前检查:顺手还的半径是否越界
git diff --stat main | tail -1 // 行数与文件数
git diff --name-only main | grep -v PATH_PREFIX // 出现无关模块即停
判据一句话:顺手还只做"读者看不出这是两件事"的改动;读者能看出,就该拆成两项。
7. 专项偿还的立项要求:必须能说清哪个指标会变
| 立项要素 | 不合格写法 | 合格写法 |
|---|---|---|
| 目标 | "重构订单模块" | "订单模块变更时长从当前量级降到一半" |
| 范围 | "顺便优化" | 明确的模块与调用方清单 |
| 验收 | "代码更清晰" | 同一需求的改动文件数与评审轮次 |
| 回滚 | 未提 | 灰度范围与回退开关 |
| 结束条件 | 未定义 | 指标达成或到期即止 |
说不清指标变化的重构,最后只能靠"感觉好多了"结项,下一轮排期时它没有说服力。
// 立项卡:五行,缺任何一行都不该占用排期
goal: 订单模块变更时长 p50 减半
scope: [order-service, payment-service]
verify: pr_metrics(module=order-service).p50 // 与基线同口径对比
exit: 连续两个迭代达标或到期即止
rollback: 按服务灰度,开关 ORDER_SVC_NEW_PATH
没有 verify 这一行,偿还就退化成一次"代码重排",无法证明它降低了利息。
8. 不还也是合法决策,但要写理由
| 理由 | 判定依据 | 记录方式 |
|---|---|---|
| 代码即将废弃 | 有明确下线日与替代路径 | 账本标 sunset: DATE |
| 影响面小于成本 | 调用量已归零或极低 | 附调用量数据 |
| 改动频率极低 | 近一年 last_touched 为空 |
降级为观察项 |
| 已被新路径替代 | 双路径中旧路径流量占比趋零 | 标 bypassed_by |
"不还"必须留下判据,否则半年后同样的问题会被重新提一遍,再花一次讨论成本。
9. 利息的观测:按模块取趋势,不取绝对值
| 观测项 | 取数方式 | 判读 |
|---|---|---|
| 变更时长 | 该模块 PR 从首提到合并的中位时长,按月 | 连续三个月上升即为利息 |
| 缺陷密度 | 该模块每千行改动引发的缺陷数 | 高于全局均值两倍以上 |
| 上手时间 | 新人首次在该模块独立提交所需天数 | 逐版本变长 |
| 恢复时长 | 该模块相关故障的平均定位时间 | 与故障总数脱钩上升 |
// 变更时长按月趋势:只看趋势,不看单点
SELECT module, date_trunc('month', created_at) AS m,
percentile_cont(0.5) WITHIN GROUP (ORDER BY lead_hours) AS p50
FROM pr_metrics WHERE module IN (SELECT unnest(:scope))
GROUP BY 1, 2 ORDER BY 1, 2;
绝对值没有意义(不同模块基线不同),同一模块的时间序列才有意义。
10. 还债的人往往不是欠债的人
| 情形 | 组织后果 | 缓解 |
|---|---|---|
| 原作者已转岗/离职 | 无人认领,只能外行改 | 交接时把判据写进账本 |
| 债务由架构决策产生 | 团队背成本,决策者不受影响 | 决策记录关联债务条目 |
| 还债不产出可见功能 | 绩效上不可见 | 用指标变化作为交付物 |
| 债务跨团队 | 谁都不愿先动 | 明确 owner 与共同验收 |
这是组织问题,不是技术问题:不解决它,任何排序算法都会被现实绕开。
11. "重写"与"还债"的分界
| 维度 | 还债 | 重写 |
|---|---|---|
| 边界 | 沿用现有对外契约 | 常顺带改契约 |
| 风险 | 局部、可回滚 | 全局、切换点集中 |
| 新债 | 一般不产生 | 几乎必然产生(新结构、新缺文档) |
| 验收 | 指标变化 | 需要并行运行与数据比对 |
| 适合时机 | 利息可观测且持续 | 结构已无法承载新需求 |
重写不是还债的加强版:它同时产生新债,且新债在头半年利息最高。把它当还债写进账本,会让偿还率指标失真。
12. 度量:年龄、可观测比例、偿还率、复发率
| 指标 | 口径 | 异常信号 |
|---|---|---|
| 条目年龄 | 从 age_since 到今天的时长 |
中位数单调上升 |
| 利息可观测比例 | 带 interest_signals 的条目占比 |
低,说明账本仍是抱怨记录 |
| 偿还率 | 本季度关闭条目 / 到期应还条目 | 长期为零 |
| 复发率 | 同一 scope 半年内重复建条目 | 高,说明只治了症状 |
其中"利息可观测比例"最能预测后续:不可观测的债无法被排期保护。
13. 排错:五种"还了但没变好"
| 现象 | 原因 | 处理 |
|---|---|---|
| 还完指标没动 | 立项时未定义指标 | 补验收口径,再判断是否真还完 |
| PR 越还越大 | 顺手还半径失控 | 按文件数与行为变更拆分 |
| 同一处反复建条目 | 只改了症状 | 追到契约或数据模型层 |
| 老债永远排不上 | 无阻塞关系、无年龄权重 | 补 scope 与 blocks 字段 |
| 重写后半年更慢 | 新结构未稳定、双路径并存 | 把切换与下线写进验收 |
14. 边界:什么时候偿还应让位于其他工作
| 情形 | 处置 | 理由 |
|---|---|---|
| 系统有明确下线日 | 只做维持性修复 | 投入无法回收 |
| 模块调用量已归零 | 直接删除或归档 | 利息为零 |
| 代码长期不被修改 | 降级为观察项 | 改动频率低,利息不累积 |
| 团队正在救火期 | 只做阻塞型偿还 | 注意力是最稀缺资源 |
| 债务位于第三方依赖内部 | 用隔离层绕开 | 无法直接偿还 |
15. 自测
- 一张只有标题与描述的债务清单,缺哪四个字段导致它无法排序?
- 四类利息里,哪一类通常已有现成数据,哪三类需要专门取数?
- 排序时哪一条硬规则优先于算分结果?
- "顺手还"越界的判据是什么?
- 专项偿立项时,为什么必须说清"哪个指标会变"?
- 重写为什么会同时产生新债,这对偿还率指标有什么影响?