技术债清单为什么永远排不进迭代
「回声」的技术债清单有 47 项,按季度更新。每次规划会,主持人都会打开清单说“这些我们得排一下”,然后业务需求插进来,清单被放到最后一页。两个季度过去,清单上的项目只少了三项,新增的却有十九项。
团队并不是不知道这些债的存在。排在最前面的几项,每个人都能说出它的风险:商品价格缓存与数据库双写没有补偿,缓存失效失败时价格会一直 stale;订单状态同步任务没有重试,网络抖动会让订单卡在中间态;搜索索引的重建脚本硬编码了集群地址,换集群必须改代码。
问题在于,这些风险都是“可能发生”,而业务需求是“确定要交付”。在没有统一排序口径的情况下,确定的事情永远赢过可能的事情。技术债清单于是变成一张“知道但不动”的列表,每次事故之后被翻出来加几条,事故热度过去之后再次沉底。
要让技术债真正进入迭代,需要回答三个问题:哪一项最值得先做,为什么是现在,做完能释放多少容量。这三个问题不能靠感觉回答,需要一个可比较的分数和一条受容量约束的排期规则。
用利息、成本、风险排优先级
技术债的“利息”是它持续造成的损耗:每天多花的排查时间、每周重复的临时补丁、每次发布前额外的回滚准备。利息通常以人日/年为单位估算,不要求精确到小时,但要求写下估算依据。“偿还成本”是修掉它需要的人日,包括开发、测试、灰度和回滚预案。“风险”是它演变成事故的可能性和后果,用 1 到 5 分表示。
排序分数用 利息 × 风险 ÷ 偿还成本。这个公式的直觉是:每年造成更多损耗、后果更严重、修起来更便宜的项目,应该排在前面。它不是精确的财务模型,而是一个把三类信息压成一个可比较数字的工具。
| 技术债 | 年利息(人日) | 偿还成本(人日) | 风险分 | 分数 | 排期判断 |
|---|---|---|---|---|---|
| 价格缓存双写无补偿 | 12 | 2 | 5 | 30 | 立即做 |
| 订单同步任务无重试 | 8 | 3 | 4 | 10 | 本季度 |
| 搜索重建脚本硬编码 | 2 | 1 | 4 | 8 | 本季度 |
| 旧版报表服务未下线 | 6 | 10 | 2 | 1 | 暂缓 |
| 测试环境数据陈旧 | 3 | 8 | 2 | 0 | 暂缓 |
表里的数字是案例假设值,不是行业基准。真实估算应该来自值班记录、故障工单和发布耗时,而不是拍脑袋。估算的意义不在于精确,而在于把“这项债很烦”翻译成“它每年吃掉多少人日、修它要多少人日”,让不同项目之间可以比较。
分数相同或相近时,优先级由风险分决定,其次由利息决定。不要让“修起来便宜”单独成为理由:一个每年只造成 0.5 人日损耗的低风险问题,修得再便宜也不值得插队。反过来,一个修起来很贵但风险极高的问题,即使分数不高,也应该在路线图里保留一个明确的观察点。
分数不是唯一输入
有些债不适合用这个公式排:合规期限是硬约束,安全漏洞需要立即处理,正在扩散的架构腐化可能随时间指数级变差。这些应该在打分之前就被标记为“强制项”,直接进入当前迭代,而不是和普通项目一起排队。
同样,有些债的利息会随规模增长。一个在十万用户时每年损耗 2 人日的设计,到一千万用户时可能变成 200 人日。估算利息时应该问一句“如果明年规模翻倍,这个数字会怎么变”,把增长趋势写进备注,而不是只填当前值。
容量是排期的前提
优先级只回答“先做哪个”,不回答“这个迭代能做几个”。团队每个迭代能投入技术债的容量是有限的,假设是 6 人日(案例假设值),超过这个数的项目只能排到下一个迭代。没有容量约束的路线图,最后会变成一张“全部都要做”的愿望清单。
容量还应该留出缓冲。把 6 人日全部排满,一次线上故障就会让整个计划顺延;更稳的做法是排 4 到 5 人日,留 1 到 2 人日应对插入事项。缓冲不是浪费,它是让路线图在真实环境里能被执行的部分。
一次“再等等”拖成的事故
2026 年第一季度,商品团队在排查一次缓存不一致时发现了根因:价格更新先写数据库、再删缓存,删缓存失败时没有重试,也没有补偿任务。缓存里的旧价格会一直保留到过期,最长可达六小时。
修复方案并不复杂:加一个删除失败的重试队列,再加一个定时对账任务,估算两个人日。团队把它写进了技术债清单,风险分打了 5,但那个季度的业务需求已经排满,规划会上的结论是“下个迭代一定做”。
第二季度,同一个问题又引发了一次小范围客诉,团队把修复方案重新评估了一遍,结论还是“下个迭代”。第三季度大促前,容量评估占用了所有工程时间,技术债再次让位。修复方案在文档里放了六个月,代码一行没动。
大促第一天,价格服务的一次发布导致缓存删除接口超时。重试机制不存在,补偿任务也不存在,缓存里的旧价格持续生效了四十分钟。期间约有 3% 的下单请求按旧价格成交(案例假设值),其中一部分是低于成本价的商品。事后补救包括人工拦截订单、逐个联系用户、财务调账,直接成本大约是修复成本的二十倍,还不包括客服和品牌损耗。
复盘时最刺眼的一条是:这项债的风险分一直是 5,分数一直排在清单第一,但它从来没有进入过任何一个迭代。排序是对的,排期是错的。团队有优先级,却没有把优先级转换成“本迭代必须占多少容量”的机制;每次规划会都能看到它,每次都能找到理由推迟。
事故之后,团队改了规则:分数排名前三的技术债,每个迭代必须至少安排一项,除非有书面的例外说明。这个规则不完美,但它把“永远排最后”变成了“必须有意识地向后推”,而后者至少会在记录里留下痕迹。
排序与排期的实现
配套代码 internal/org/debt.go 把排序和排期拆成两个函数:Rank 只负责按分数稳定排序,Roadmap 负责在容量约束下按优先级装入项目。拆开的原因是排序规则和容量策略会独立变化,混在一起会让测试难以定位问题。
// file: internal/org/debt.go
// tier: L1
// verify: go test ./internal/org/
package org
import (
"fmt"
"sort"
)
// Item 是技术债清单中的一项。Interest 是每年利息(人日),
// Effort 是偿还成本(人日),Risk 是风险分(1-5)。
type Item struct {
Name string
Interest int
Effort int
Risk int
}
func score(it Item) int64 {
effort := it.Effort
if effort <= 0 {
effort = 1
}
return int64(it.Interest) * int64(it.Risk) / int64(effort)
}
// Rank 按 Interest*Risk/Effort 降序返回新切片,分数相同保持输入顺序。
// 它不修改入参,Effort 为 0 时按 1 参与排序以免除零。
func Rank(items []Item) []Item {
out := make([]Item, len(items))
copy(out, items)
sort.SliceStable(out, func(i, j int) bool {
return score(out[i]) > score(out[j])
})
return out
}
// Roadmap 按优先级把项目装入容量(人日),装不下的跳过。
// 容量或任一项目字段为负数时返回错误,避免静默产生错误的排期。
func Roadmap(items []Item, capacity int) ([]Item, error) {
if capacity < 0 {
return nil, fmt.Errorf("org: capacity must be non-negative, got %d", capacity)
}
for _, it := range items {
if it.Interest < 0 || it.Effort < 0 || it.Risk < 0 {
return nil, fmt.Errorf("org: item %q has negative field", it.Name)
}
}
ranked := Rank(items)
out := make([]Item, 0, len(ranked))
left := capacity
for _, it := range ranked {
if it.Effort > left {
continue
}
out = append(out, it)
left -= it.Effort
}
return out, nil
}
score 用 int64 计算,避免 Interest * Risk 在极端值下溢出 int。除法是整数除法,分数会向下取整;这意味着大量低分项目可能都得到 0 分,排序时依赖稳定排序保持输入顺序。精确到小数没有必要,因为利息和风险本身就是估算值,多一位小数只会制造虚假的精确感。
Rank 先复制输入再排序,不修改调用方的切片。这个选择看起来多余,但技术债清单经常被多个地方引用:规划会看板、季度报告、事故复盘。如果排序函数原地修改切片,调用方拿到的顺序会悄悄变化,排查起来非常困难。复制的成本可以忽略,换来的是可预测性。
Effort 为 0 时,score 按 1 计算,避免除零。Roadmap 则允许 0 成本项目直接装入,因为确实存在“改一行配置就能消除”的债。但负数字段一律报错:负利息、负成本、负风险通常意味着录入错误,静默接受会产出荒谬的路线图。
Roadmap 采用贪心装入:按优先级遍历,装得下就放,装不下就跳过,继续看后面的项目。这不是最优解,背包问题的最优解需要动态规划,而且要求所有项目的价值可以相加。技术债的收益很难相加,贪心的可解释性比最优性更重要:团队能看懂“为什么这个项目被跳过了”,才能讨论是否调整容量。
跳过的项目不会从清单里消失,它们会留在下一轮排期的输入里。Roadmap 只返回本次装入的项目,调用方应该保留完整清单,并在下一轮重新计算。把“没排上”当成“不需要”,是技术债管理里最常见的误读。
测试:稳定排序与容量边界
测试锁住四类行为:分数排序正确、相同分数保持输入顺序、容量装满时跳过装不下的项目、负数输入被拒绝。再加一条零成本项目的边界,确保除零保护不会把合法项目挡在外面。
// file: internal/org/debt_test.go
// tier: L1
// verify: go test ./internal/org/
package org
import (
"reflect"
"testing"
)
func names(items []Item) []string {
out := make([]string, len(items))
for i, it := range items {
out[i] = it.Name
}
return out
}
func TestRankByInterestRiskOverEffort(t *testing.T) {
items := []Item{
{Name: "slow-query", Interest: 4, Effort: 4, Risk: 2},
{Name: "no-backup", Interest: 10, Effort: 2, Risk: 5},
{Name: "dead-code", Interest: 1, Effort: 3, Risk: 1},
}
got := names(Rank(items))
want := []string{"no-backup", "slow-query", "dead-code"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("Rank = %v, want %v", got, want)
}
if items[0].Name != "slow-query" {
t.Fatal("Rank must not mutate input")
}
}
func TestRankStableOnTies(t *testing.T) {
items := []Item{
{Name: "first", Interest: 2, Effort: 1, Risk: 2},
{Name: "second", Interest: 2, Effort: 1, Risk: 2},
{Name: "third", Interest: 2, Effort: 1, Risk: 2},
}
got := names(Rank(items))
want := []string{"first", "second", "third"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("Rank = %v, want %v", got, want)
}
}
func TestRoadmapFillsCapacityByPriority(t *testing.T) {
items := []Item{
{Name: "high", Interest: 9, Effort: 3, Risk: 5},
{Name: "low", Interest: 1, Effort: 2, Risk: 1},
{Name: "mid", Interest: 4, Effort: 2, Risk: 3},
}
got, err := Roadmap(items, 6)
if err != nil {
t.Fatalf("Roadmap: %v", err)
}
if want := []string{"high", "mid"}; !reflect.DeepEqual(names(got), want) {
t.Fatalf("Roadmap = %v, want %v", names(got), want)
}
}
func TestRoadmapRejectsNegative(t *testing.T) {
if _, err := Roadmap(nil, -1); err == nil {
t.Fatal("want error for negative capacity")
}
if _, err := Roadmap([]Item{{Name: "x", Interest: -1}}, 10); err == nil {
t.Fatal("want error for negative field")
}
}
func TestRoadmapZeroEffort(t *testing.T) {
items := []Item{{Name: "config", Interest: 5, Effort: 0, Risk: 5}}
got, err := Roadmap(items, 0)
if err != nil {
t.Fatalf("Roadmap: %v", err)
}
if len(got) != 1 || got[0].Name != "config" {
t.Fatalf("Roadmap = %v, want config", names(got))
}
}
TestRankByInterestRiskOverEffort 用三个分数差异明显的项目验证排序,并额外断言输入切片没有被修改。后面这条断言比排序本身更容易被忽略:很多实现会用 sort.Slice 直接排入参,测试只检查返回值时看不出问题。
TestRankStableOnTies 用三个完全相同的项目验证稳定性。稳定排序在这里不是学术要求,而是业务要求:分数相同的项目应该按录入顺序排,否则每次运行路线图,顺序都可能变化,评审时无法对比。
TestRoadmapFillsCapacityByPriority 的容量是 6,优先级最高的项目占 3,第二项占 2,剩下 1 装不下第三项。期望结果是前两项,验证了“装不下就跳过、继续看后面”的行为。如果实现改成“装不下就停止”,这个用例会失败。
TestRoadmapRejectsNegative 覆盖负容量和负字段。错误信息里带上项目名,是为了让调用方能直接定位到脏数据;如果只返回“invalid input”,面对几十项清单仍然要人工排查。
TestRoadmapZeroEffort 验证零成本项目在容量为 0 时也能装入。这个边界容易被“容量必须大于 0”的校验误伤,而现实中确实存在只改配置、不改代码的债。
测试没有覆盖超大数值和浮点精度,因为接口用的是 int,排序分数用 int64,溢出边界由类型系统兜底。也没有测试“最优装箱”,因为实现明确采用贪心,测试应该锁住设计意图,而不是要求实现变成另一个算法。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/org/ -run 'TestRank|TestRoadmap' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestRankByInterestRiskOverEffort
--- PASS: TestRankByInterestRiskOverEffort (0.00s)
=== RUN TestRankStableOnTies
--- PASS: TestRankStableOnTies (0.00s)
=== RUN TestRoadmapFillsCapacityByPriority
--- PASS: TestRoadmapFillsCapacityByPriority (0.00s)
=== RUN TestRoadmapRejectsNegative
--- PASS: TestRoadmapRejectsNegative (0.00s)
=== RUN TestRoadmapZeroEffort
--- PASS: TestRoadmapZeroEffort (0.00s)
PASS
ok webapp/internal/org 0.190s
五个用例全部通过,退出码为 0。-run 'TestRank|TestRoadmap' 只筛选了技术债相关用例,同包的归属检测与决策分级测试没有被执行;需要全量确认时去掉 -run 再跑一次。
小结
技术债排不进迭代,通常不是因为团队不重视,而是因为缺少统一的排序口径和容量约束。利息、成本、风险三个输入把“这项债很烦”变成可比较的分数;稳定排序保证同一份清单每次得到同样的顺序;容量约束把“先做哪个”变成“这个迭代具体做哪几项”。
分数只解决排序,不解决承诺。真正让技术债进入迭代的,是一条明确的规则:排名靠前的项目每个迭代必须占多少容量,推迟需要什么理由。规则可以调整,但不能没有;没有规则的清单,最终只会在大促前夜被想起。
下一章从组织与决策回到技术演进本身,讨论从 MVP 到规模化的路线图如何分阶段设计。
阅读导航:上一节:12.2 决策权与评审 · 下一节:13.1 从 MVP 到规模化的路线图 。
常见错误与边界
- 只按“修起来便宜”排序。低成本但低收益的项目会挤占容量,真正高风险的债继续排队。
- 利息估算没有依据。拍脑袋的数字经不起追问,也无法在规模变化时重新评估。
- 排序结果不稳定。相同分数的项目每次顺序不同,路线图无法对比,评审时反复争论。
- 路线图没有容量约束。所有项目都标“本季度”,实际执行时只能临时砍,计划失去可信度。
- 把“没排上”当成“不需要”。被跳过的项目应该保留在清单里,下一轮重新参与排序。
- 负数或脏数据静默通过。负成本、负利息会产出荒谬的优先级,入口校验比事后排查便宜。
- 容量排满不留缓冲。一次线上故障就会让整个路线图顺延,预留缓冲是执行的一部分。
- 用技术债分数替代强制项。合规、安全和正在扩散的架构腐化应该先于打分被标记为必须处理。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。