一份永远排不上期的清单
某客户端团队有一个技术债表格,从两年前开始维护。表格里有七十多条记录,每一条都标了「高」「中」「低」,每一条都写了「建议尽快处理」。每个季度规划会,产品经理看一眼说「先做需求」,表格被关掉,下一个季度重复同样的对话。
两年后,表格里最老的条目还在,描述已经过时,负责人早已转岗,代码却还在生产环境里运行。团队不是没有治理意愿,而是没有节奏:债务被当成一个可以无限推迟的待办列表,而不是需要按容量排期的投资。
技术债台账要解决三个问题:债务怎么被记录、先还哪一笔、每个季度到底还多少。三者缺一,台账都会退化成许愿池。
一条债务记录包含什么
债务条目不是一句「这里需要重构」。它需要足够的字段,才能在排期时被比较,在执行时被追踪。
| 字段 | 含义 | 约束 |
|---|---|---|
id | 稳定编号 | 全表唯一,便于引用 |
title | 一句话描述债务 | 具体到位置与症状 |
severity | 严重度 | low / medium / high / critical |
category | 债务类型 | 架构、质量、依赖、测试、文档 |
impact | 影响面 | 1 到 5,表示受影响的范围 |
effortDays | 预计偿还工时 | 以人日计,用于容量排期 |
createdOn | 登记日期 | 用于计算账龄与排序 |
dueOn | 期望完成日期 | 可空,用于逾期标记 |
owner | 负责人 | 团队标识,不写个人昵称 |
status | 当前状态 | open / scheduled / paid / accepted |
impact 与 severity 必须分开。严重度描述「问题本身有多严重」,影响面描述「一旦出问题会波及多少用户或模块」。一个只影响内部工具的严重 bug,严重度高但影响面小;一个影响所有端的轻微体验问题,严重度低但影响面大。两者相乘,才能得到可比较的优先级。
status 里的 accepted 经常被误解。它不是「已修复」,而是「明确决定不修」。技术债不是所有都要还,有些债务的偿还成本高于收益,接受它是理性选择。但接受必须留下理由与复审日期,否则它会和「忘记修」混为一谈。
effortDays 是排期的硬约束。没有工时估算,所谓「本季度还债」只是一句口号。估算不必精确到半天,但必须有一个数量级,否则容量规划无法进行。
严重度与影响面如何合成优先级
优先级分数用严重度权重乘以影响面:
| 严重度 | 权重 | 影响面 1 | 影响面 3 | 影响面 5 |
|---|---|---|---|---|
low | 1 | 1 | 3 | 5 |
medium | 2 | 2 | 6 | 10 |
high | 4 | 4 | 12 | 20 |
critical | 8 | 8 | 24 | 40 |
这张表的作用不是精确打分,而是提供稳定的相对顺序。没有它,排期会退化成「谁喊得响先做谁」;有了它,至少每次讨论都从同一组数字开始。
权重采用 1/2/4/8 的倍增,而不是 1/2/3/4。原因是严重度的差异通常不是线性的:从 low 到 medium 是「可以等」到「应该排期」,从 high 到 critical 是「影响体验」到「可能造成资金或数据问题」。倍增能拉开这个差距,避免大量 high 条目挤在同一个分数段。
影响面要做边界裁剪。传入 0 或 9 时,分别按 1 和 5 处理。这不是为了容忍脏数据,而是为了让排序函数永远有定义。边界裁剪之后,坏数据会得到一个可解释的分数,而不是 NaN 或负数,从而在评审时暴露出来。
工时不应该进入分数。工时高不代表优先级低,只代表它需要更多容量。把工时混进优先级,会让小修小补永远排在前面,真正重要的结构性债务被无限推迟。
偿还排期是容量问题
排期的输入是「按优先级排序的待办」和「本季度可投入的工时」。输出是「本季度选哪些、推迟哪些、跳过哪些」。
| 输出 | 含义 | 后续动作 |
|---|---|---|
selected | 本季度排入的条目 | 进入迭代计划 |
deferred | 容量不足而推迟的条目 | 保留在原优先级队列 |
skipped | 已偿还或已接受的条目 | 不再参与排期 |
usedDays | 已占用工时 | 与团队容量核对 |
remainingDays | 剩余容量 | 可用于插入高优条目 |
overdue | 逾期条目编号 | 在评审会上单独说明 |
排序规则有三层。第一层,逾期条目优先,因为它们已经错过承诺日期。第二层,优先级分数从高到低。第三层,登记日期从早到晚,同日按编号排序。三层之后仍然并列的情况极少,即使出现,编号也能保证确定性。
容量分配采用贪心:按顺序尝试放入,放得下就选,放不下就推迟,然后继续尝试后面的小条目。这不是最优装箱,但足够可解释。团队能看懂「为什么这笔没排上」,比追求数学最优更重要。
贪心有一个已知代价:一个很大的条目可能被反复推迟,因为每个季度都装不下。治理上要单独处理这类条目,要么拆分,要么预留专项容量。代码不替团队做这个决定,但排期结果会把问题暴露出来。
skipped 必须显式返回,而不是静默过滤。评审时看到「本季度跳过了 12 条已接受债务」,比看到「待办减少了 12 条」更有信息量。
逾期标记与季度节奏
逾期判据只有一条:有 dueOn、日期早于今天、且状态仍是待办(open 或 scheduled)。三个条件缺一不可。
dueOn 等于今天不算逾期。这看起来是细节,但它决定了评审会当天的口径:如果按「小于等于」计算,所有今天到期的条目一早就变红,团队会逐渐忽略红色标记。严格小于,才能让逾期信号保持稀缺。
已偿还和已接受的条目不参与逾期统计。前者已经完成,后者已经明确不修,把它们标红只会制造噪音。
季度是治理的天然节奏。季度太短,排期频繁变动;季度太长,债务容易被遗忘。按季度做一次排期与复盘,既能匹配大多数团队的规划周期,也能让容量约束保持真实。
季度工具要能回答四个问题:这个季度从哪天到哪天、下个季度怎么走、本季度选了什么、当前台账的健康度如何。这些都可以用纯函数算出来,不需要引入日期库。
季度边界要注意闰年与月末。三月、六月、九月、十二月是季度末,最后一天分别是 31、30、30、31,但二月在闰年是 29 天。用 Date.UTC(year, month, 0) 取上个月最后一天,可以避免手写月份天数表。
把台账写成代码
下面的模块实现优先级评分、账龄、逾期判断、季度计算、容量排期与台账摘要。所有日期都用 ISO 字符串,所有函数都是纯函数,方便在季度规划脚本或看板生成器里复用。
// file: src/governance/tech-debt.ts
// tier: L1
// verify: node --test test/governance/tech-debt.test.ts
export type DebtSeverity = "low" | "medium" | "high" | "critical";
export type DebtStatus = "open" | "scheduled" | "paid" | "accepted";
export type DebtCategory = "architecture" | "quality" | "dependency" | "test" | "docs";
export type DebtItem = {
id: string;
title: string;
severity: DebtSeverity;
category: DebtCategory;
impact: number;
effortDays: number;
createdOn: string;
dueOn?: string;
owner: string;
status: DebtStatus;
};
export type QuarterOptions = {
quarter: string;
capacityDays: number;
today: string;
};
export type QuarterPlan = {
quarter: string;
selected: DebtItem[];
deferred: DebtItem[];
skipped: DebtItem[];
usedDays: number;
remainingDays: number;
overdue: string[];
};
export type DebtSummary = {
open: number;
scheduled: number;
paid: number;
accepted: number;
overdue: number;
openEffortDays: number;
bySeverity: Record<DebtSeverity, number>;
};
export const DEBT_SEVERITIES: DebtSeverity[] = ["low", "medium", "high", "critical"];
export const DEBT_STATUSES: DebtStatus[] = ["open", "scheduled", "paid", "accepted"];
const SEVERITY_WEIGHTS: Record<DebtSeverity, number> = {
low: 1,
medium: 2,
high: 4,
critical: 8,
};
const DAY_MS = 24 * 60 * 60 * 1000;
export function isDebtSeverity(value: unknown): value is DebtSeverity {
return (DEBT_SEVERITIES as unknown[]).includes(value);
}
export function isDebtStatus(value: unknown): value is DebtStatus {
return (DEBT_STATUSES as unknown[]).includes(value);
}
export function isBacklog(item: DebtItem): boolean {
return item.status === "open" || item.status === "scheduled";
}
export function priorityScore(item: DebtItem): number {
const impact = Math.max(1, Math.min(5, item.impact));
return SEVERITY_WEIGHTS[item.severity] * impact;
}
export function ageDays(createdOn: string, today: string): number {
const start = Date.parse(`${createdOn}T00:00:00Z`);
const end = Date.parse(`${today}T00:00:00Z`);
if (Number.isNaN(start) || Number.isNaN(end)) {
return 0;
}
return Math.max(0, Math.round((end - start) / DAY_MS));
}
export function isOverdue(item: DebtItem, today: string): boolean {
return isBacklog(item) && item.dueOn !== undefined && item.dueOn < today;
}
export function overdueItems(items: DebtItem[], today: string): DebtItem[] {
return items
.filter((item) => isOverdue(item, today))
.sort((a, b) => (a.dueOn ?? "").localeCompare(b.dueOn ?? "") || a.id.localeCompare(b.id));
}
export function quarterOf(date: string): string | undefined {
const match = /^(\d{4})-(\d{2})-\d{2}$/.exec(date);
if (match === null) {
return undefined;
}
const month = Number(match[2]);
if (month < 1 || month > 12) {
return undefined;
}
return `${match[1]}-Q${Math.floor((month - 1) / 3) + 1}`;
}
export function nextQuarter(quarter: string): string | undefined {
const match = /^(\d{4})-Q([1-4])$/.exec(quarter);
if (match === null) {
return undefined;
}
const year = Number(match[1]);
const period = Number(match[2]);
return period === 4 ? `${year + 1}-Q1` : `${year}-Q${period + 1}`;
}
function pad2(value: number): string {
return value < 10 ? `0${value}` : String(value);
}
export function quarterBounds(quarter: string): { start: string; end: string } | undefined {
const match = /^(\d{4})-Q([1-4])$/.exec(quarter);
if (match === null) {
return undefined;
}
const year = Number(match[1]);
const period = Number(match[2]);
const startMonth = (period - 1) * 3 + 1;
const endMonth = startMonth + 2;
const lastDay = new Date(Date.UTC(year, endMonth, 0)).getUTCDate();
return {
start: `${year}-${pad2(startMonth)}-01`,
end: `${year}-${pad2(endMonth)}-${pad2(lastDay)}`,
};
}
function compareBacklog(a: DebtItem, b: DebtItem, today: string): number {
const aOverdue = isOverdue(a, today) ? 0 : 1;
const bOverdue = isOverdue(b, today) ? 0 : 1;
if (aOverdue !== bOverdue) {
return aOverdue - bOverdue;
}
const score = priorityScore(b) - priorityScore(a);
if (score !== 0) {
return score;
}
if (a.createdOn !== b.createdOn) {
return a.createdOn < b.createdOn ? -1 : 1;
}
return a.id.localeCompare(b.id);
}
export function planQuarter(items: DebtItem[], options: QuarterOptions): QuarterPlan {
const skipped = items.filter((item) => !isBacklog(item));
const backlog = items.filter(isBacklog).sort((a, b) => compareBacklog(a, b, options.today));
const selected: DebtItem[] = [];
const deferred: DebtItem[] = [];
let usedDays = 0;
for (const item of backlog) {
if (item.effortDays <= options.capacityDays - usedDays) {
selected.push(item);
usedDays += item.effortDays;
} else {
deferred.push(item);
}
}
return {
quarter: options.quarter,
selected,
deferred,
skipped,
usedDays,
remainingDays: options.capacityDays - usedDays,
overdue: overdueItems(items, options.today).map((item) => item.id),
};
}
export function debtSummary(items: DebtItem[], today: string): DebtSummary {
const bySeverity: Record<DebtSeverity, number> = {
low: 0,
medium: 0,
high: 0,
critical: 0,
};
let open = 0;
let scheduled = 0;
let paid = 0;
let accepted = 0;
let openEffortDays = 0;
for (const item of items) {
bySeverity[item.severity] += 1;
if (item.status === "open") {
open += 1;
} else if (item.status === "scheduled") {
scheduled += 1;
} else if (item.status === "paid") {
paid += 1;
} else {
accepted += 1;
}
if (isBacklog(item)) {
openEffortDays += item.effortDays;
}
}
return {
open,
scheduled,
paid,
accepted,
overdue: overdueItems(items, today).length,
openEffortDays,
bySeverity,
};
}
实现里有三个取舍值得说明。
第一,priorityScore 对影响面做裁剪,而不是拒绝非法输入。排期脚本经常读取人工维护的表格,脏数据不可避免。裁剪让函数始终有定义,同时把异常值压到边界,评审时更容易发现。
第二,planQuarter 先跳过非待办条目,再对剩余条目排序。这样 skipped 的语义是「不参与本季度排期」,而不是「容量不够」。两者在评审会上是完全不同的问题,不能混在一个列表里。
第三,debtSummary 的 bySeverity 统计所有条目,而 openEffortDays 只统计待办条目。前者回答「台账里有什么」,后者回答「还要投入多少」。两个口径分开,避免用一个数字同时承担两种解释。
判据与反例
一套技术债治理是否有效,不看台账有多少条,而看下面几条。
| 判据 | 合格表现 | 反例 |
|---|---|---|
| 可比较 | 优先级有稳定公式 | 按「感觉」排序 |
| 有容量 | 每季度有明确的工时上限 | 排期不考虑人力 |
| 有逾期 | 到期未完成能被标出 | 所有条目都「进行中」 |
| 有接受 | 不修的债务有理由与复审日 | 不修等于忘记 |
| 有节奏 | 每季度固定排期与复盘 | 想起来才治理 |
| 口径分离 | 台账规模与待办工时分开统计 | 用一个数字汇报 |
一个常见反例是把技术债当成「有空再做」的杂项。没有容量承诺,债务永远不会被排入迭代,因为需求总是更紧急。正确做法是在每个季度预留固定比例的容量,比如 15% 到 20%,由架构负责人决定怎么花。比例不必高,但必须写进规划,而不是靠临时争取。
另一个反例是「只登记不关闭」。条目修完了不更新状态,台账越来越长,团队逐渐不再相信它。状态更新要成为合并流程的一部分:修复债务的合并请求必须带上条目编号,合并后自动转成 paid。人工维护的台账,三个月内一定会失真。
还有一种反例是「所有债务都标 critical」。严重度失去区分度,优先级分数全部挤在高位,排期退化成按登记顺序。严重度应该有分布:多数是 medium 和 low,少数是 high,critical 只在真的可能造成资金、数据或大面积不可用时使用。
最后一种反例是「用逾期数量考核团队」。逾期标记的目的是暴露排期问题,不是追责。一旦和绩效挂钩,团队会把 dueOn 填得很远,或者干脆不填,逾期统计瞬间归零,治理信息也一起消失。
小结
技术债治理的核心是节奏,不是清单。
- 债务条目要能比较:严重度、影响面、工时、账龄分开记录。
- 优先级用严重度权重乘以影响面,工时只约束容量,不参与排序。
- 排期是容量问题,逾期优先、分数其次、登记日期兜底。
- 逾期判据严格小于今天,且只统计待办状态。
- 季度提供稳定节奏,边界用日期计算而不是手写月份表。
台账只有接进季度规划、有固定容量、能自动更新状态,才不会变成一份没人打开的许愿池。
阅读导航:上一节:11.2 代码所有权与评审 · 下一节:12.1 绞杀者与渐进式替换 。
常见错误与边界
- 把工时混进优先级。小修小补永远排前面,结构性债务被无限推迟。
- 所有条目都标
critical。严重度失去区分度,排序退化成登记顺序。 - 排期不考虑容量。所谓「本季度还债」没有人力支撑,只是口号。
- 逾期判据用小于等于。当天到期全部标红,团队很快忽略红色。
- 已接受条目参与逾期统计。明确不修的债务被反复标红,制造噪音。
- 台账只增不减。修完不更新状态,团队逐渐不再相信它。
- 用逾期数量考核团队。团队会把日期填远或不填,治理信息消失。
- 手写季度月份天数。闰年二月与大小月容易算错,应该用日期计算。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。