《互联网产品的客户端架构·规模化与架构演进》11.3 技术债台账与治理节奏

从一份永远排不上期的技术债清单出发,把债务治理拆成条目字段、严重度与影响面、偿还排期、逾期标记与季度节奏五部分,给出可运行的 TypeScript 模块与测试。文中说明优先级分数、容量约束、逾期判据与季度摘要,并解释为什么台账不能变成许愿池。

一份永远排不上期的清单

某客户端团队有一个技术债表格,从两年前开始维护。表格里有七十多条记录,每一条都标了「高」「中」「低」,每一条都写了「建议尽快处理」。每个季度规划会,产品经理看一眼说「先做需求」,表格被关掉,下一个季度重复同样的对话。

两年后,表格里最老的条目还在,描述已经过时,负责人早已转岗,代码却还在生产环境里运行。团队不是没有治理意愿,而是没有节奏:债务被当成一个可以无限推迟的待办列表,而不是需要按容量排期的投资。

技术债台账要解决三个问题:债务怎么被记录、先还哪一笔、每个季度到底还多少。三者缺一,台账都会退化成许愿池。

一条债务记录包含什么

债务条目不是一句「这里需要重构」。它需要足够的字段,才能在排期时被比较,在执行时被追踪。

字段含义约束
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
low1135
medium22610
high441220
critical882440

这张表的作用不是精确打分,而是提供稳定的相对顺序。没有它,排期会退化成「谁喊得响先做谁」;有了它,至少每次讨论都从同一组数字开始。

权重采用 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。严重度失去区分度,排序退化成登记顺序。
  • 排期不考虑容量。所谓「本季度还债」没有人力支撑,只是口号。
  • 逾期判据用小于等于。当天到期全部标红,团队很快忽略红色。
  • 已接受条目参与逾期统计。明确不修的债务被反复标红,制造噪音。
  • 台账只增不减。修完不更新状态,团队逐渐不再相信它。
  • 用逾期数量考核团队。团队会把日期填远或不填,治理信息消失。
  • 手写季度月份天数。闰年二月与大小月容易算错,应该用日期计算。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查