一份没人排期的技术债清单
《裂隙战场》的技术债台账最初有 60 多条,每条都写着“待优化”。半年后回看,只有三条被处理,剩下的不是不重要,而是无法比较:没人知道“重构匹配器”和“拆掉结算服务里的世界模型”哪个更值得先做。
台账变成了许愿池。每次评审都往里加一条,每次排期都跳过它,因为“优化”没有截止时间,也没有人能说清楚不做会怎样。
问题不在团队不重视技术债,而在台账缺少排序依据。描述、影响、利息、偿还计划四个字段里,只有“描述”被认真填了。没有利息,债务之间就无法比较;没有偿还计划,债务就永远排在需求后面。
这一节把技术债从“待优化清单”改造成可排序的账本,再用适应度函数把架构约束变成持续运行的指标。前者回答“先还哪笔”,后者回答“怎么知道架构正在变坏”。
四要素:描述、影响、利息、偿还计划
| 字段 | 回答的问题 | 不合格写法 | 可验收写法 |
|---|---|---|---|
| 描述 | 哪段结构、哪个约束被破坏了 | “代码太乱” | “结算服务直接读世界分片表,跨了数据所有权” |
| 影响 | 不还会怎样,影响谁 | “影响可维护性” | “每次改结算规则要同步改三个服务,去年因此出过两次口径分叉” |
| 利息 | 每次改动额外付多少成本 | “以后再说” | “每次改动多 0.5 人日,月均 40 次改动” |
| 偿还计划 | 谁在什么时间做到什么程度 | “持续优化” | “Q1 由结算组把读取切到结算服务,验收是对账差异为零” |
四个字段里,利息最容易被忽略,却最决定排序。技术债的本质不是“代码难看”,而是“每次改动都要多付一笔税”。没有利息,就无法判断这笔债值不值得现在还。
影响和利息要分开写。影响是“最坏会怎样”,利息是“平时有多疼”。一笔债可能影响很大但很少触发,也可能影响不大但每次改动都在付钱。两者的偿还策略不同。
利息怎么算
把利息定义成两个可观测量的乘积:
月利息(示例值) = 每次改动的额外成本 × 每月改动次数
| 债务 | 每次额外成本(人日,示例值) | 每月改动次数(示例值) | 月利息(人日,示例值) |
|---|---|---|---|
| 结算服务直读世界表 | 0.5 | 40 | 20 |
| 匹配器状态与房间状态混放 | 2.0 | 3 | 6 |
| 活动配置双写 | 0.2 | 20 | 4 |
| GM 工具重复鉴权 | 0.1 | 5 | 0.5 |
按这个口径,“结算服务直读世界表”排第一,不是因为它听起来最严重,而是因为它每个月吃掉 20 人日(示例值)。这个数字可以和需求排期直接比较:还这笔债花 15 人日(示例值),两个月回本,排序就有了依据。
数字不要求精确到小数。重点是同一个组织用同一套口径,让债务之间可比较。把“每次改动多半天”估成“大概挺麻烦”,台账就退回许愿池。
五类适应度函数
技术债回答“欠了什么”,适应度函数回答“架构约束有没有被守住”。不是所有约束都值得自动化,优先选那些违反后修复成本高、且能用数据判断的。
| 类型 | 度量 | 阈值示例 | 检查层级 | 违反后的表现 |
|---|---|---|---|---|
| 依赖方向 | 内层包是否反向依赖外层 | 反向依赖数 = 0 | 编译期 | 领域逻辑被框架或存储细节污染 |
| 模块耦合 | 扇出 /(扇入 + 扇出) | 核心模块 ≤ 0.3 | 测试期 | 改一个模块牵动半个仓库 |
| 接口兼容 | 新旧版本协议偏差 | minor 偏差 ≤ 1 | 测试期 | 老客户端进新房间报错 |
| 数据所有权 | 跨服务直读表的数量 | 0 | 编译期/运行期 | 同一份数据出现两个写入者 |
| 延迟预算 | P99 与预算的比值 | ≤ 1.0 | 运行期 | 单次改动叠加后突破端到端预算 |
阈值不要一次设满。新规则先以警告运行一个迭代,收集真实分布,再决定是否转为阻断。直接阻断会让团队第一周就学会加豁免,而不是修问题。
阈值:阻断还是警告
同一个指标,在不同阶段应该有不同的处理方式:
- 新代码阻断,存量警告。新提交引入反向依赖立即失败;历史遗留的反向依赖进入台账,按利息排序偿还。
- 核心链路阻断,边缘链路警告。结算、登录、匹配的耦合阈值可以阻断;运营后台的同类问题先警告。
- 趋势阻断,绝对值警告。单次超标可能只是噪声;连续三个发布周期上升才阻断。
- 没有基线不阻断。先跑两周采集分布,否则阈值是拍脑袋,规则会被当成噪音。
阻断和警告的区别不是严重程度,而是“谁必须停下来处理”。阻断意味着合并被挡住,必须有明确的修复动作;警告意味着进入报告,由 owner 在排期里处理。两者混用,团队会分不清哪些问题必须今天修。
例外必须有时限和 owner
任何阈值都会遇到合理例外。例外机制设计不好,会变成永久豁免;设计得好,它是债务的另一种记账方式。
| 字段 | 要求 | 缺失后果 |
|---|---|---|
| 规则 ID | 指向具体适应度函数 | 不知道在豁免什么 |
| 目标 | 具体模块或服务,不能写“全站” | 豁免范围失控 |
| owner | 必须是人,不能是团队名 | 到期没人负责 |
| 原因 | 说明为什么现在不能修 | 无法复核是否仍然成立 |
| 到期时间 | 必须是未来时间 | 永久豁免 |
到期后例外自动失效,违规重新变成阻断。这条规则必须由代码执行,不能靠日历提醒。人会自动忽略提醒,代码不会。
代码:阈值、例外与债务排序
下面的 internal/fitnessfunction/fitness.go 把四件事放在一个包里:债务利息与排序、指标阈值判定、例外生命周期、依赖方向检查。所有时间通过参数注入,测试可以精确控制到期点。
package fitnessfunction
import (
"errors"
"fmt"
"sort"
"time"
)
var ErrDirection = errors.New("fitnessfunction: unknown direction")
type Direction string
const (
AtMost Direction = "max" // value must not exceed the threshold
AtLeast Direction = "min" // value must not fall below the threshold
)
type Severity string
const (
Warning Severity = "warning"
Blocking Severity = "blocking"
)
// Metric is one fitness function observation. The threshold lives beside the
// metric so a change to the budget is reviewable in the same diff.
type Metric struct {
Name string
Value float64
Threshold float64
Direction Direction
Severity Severity
}
// Exception is a time-boxed permission to miss a threshold. An exception
// without an owner or a reason is invalid, and an expired one stops working
// without anyone having to remember to remove it.
type Exception struct {
Metric string
Owner string
Reason string
ExpiresAt time.Time
}
type Finding struct {
Metric string
Value float64
Threshold float64
Severity Severity
Violated bool
Exempted bool
Note string
}
// Evaluate checks every metric against its threshold. Metrics that pass and
// metrics that fail both return a Finding so a report can show the margin,
// not only the failures.
func Evaluate(metrics []Metric, exceptions []Exception, at time.Time) ([]Finding, error) {
ex := make(map[string]Exception, len(exceptions))
for _, e := range exceptions {
ex[e.Metric] = e
}
out := make([]Finding, 0, len(metrics))
for _, m := range metrics {
if m.Direction != AtMost && m.Direction != AtLeast {
return nil, fmt.Errorf("%w: %q", ErrDirection, m.Direction)
}
f := Finding{
Metric: m.Name,
Value: m.Value,
Threshold: m.Threshold,
Severity: m.Severity,
}
violated := (m.Direction == AtMost && m.Value > m.Threshold) ||
(m.Direction == AtLeast && m.Value < m.Threshold)
f.Violated = violated
if !violated {
out = append(out, f)
continue
}
if e, ok := ex[m.Name]; ok {
switch {
case e.Owner == "" || e.Reason == "":
f.Note = "exemption invalid: owner and reason are required"
case !at.Before(e.ExpiresAt):
f.Note = "exemption expired at " + e.ExpiresAt.Format(time.RFC3339)
default:
f.Exempted = true
f.Note = "exempted by " + e.Owner + " until " + e.ExpiresAt.Format(time.RFC3339)
}
}
out = append(out, f)
}
sort.Slice(out, func(i, j int) bool { return out[i].Metric < out[j].Metric })
return out, nil
}
// Debt is one entry in the technology-debt ledger. Interest is the extra cost
// a change pays because the debt exists; frequency turns it into a monthly
// rate that can be compared with other debts.
type Debt struct {
ID string
Description string
Impact int // 1..5, player or revenue exposure
InterestPerChange float64
ChangesPerMonth float64
Owner string
RepayBy time.Time
}
func (d Debt) MonthlyInterest() float64 {
return d.InterestPerChange * d.ChangesPerMonth
}
func (d Debt) Overdue(at time.Time) bool {
return !d.RepayBy.IsZero() && at.After(d.RepayBy)
}
// RankDebts puts overdue debts first, then orders by monthly interest. A debt
// with a high one-off cost but a low change frequency is not urgent; a small
// tax paid on every change is.
func RankDebts(debts []Debt, at time.Time) []Debt {
out := append([]Debt(nil), debts...)
sort.SliceStable(out, func(i, j int) bool {
oi, oj := out[i].Overdue(at), out[j].Overdue(at)
if oi != oj {
return oi
}
ii, ij := out[i].MonthlyInterest(), out[j].MonthlyInterest()
if ii != ij {
return ii > ij
}
return out[i].ID < out[j].ID
})
return out
}
// Edge is one package-level dependency. Direction matters: From depends on To.
type Edge struct {
From string
To string
}
type Coupling struct {
FanIn int
FanOut int
}
func MeasureCoupling(edges []Edge, module string) Coupling {
var c Coupling
for _, e := range edges {
if e.From == e.To {
continue
}
if e.To == module {
c.FanIn++
}
if e.From == module {
c.FanOut++
}
}
return c
}
// Instability is efferent / (afferent + efferent). A module with many
// dependents and few dependencies is stable and should change slowly.
func (c Coupling) Instability() float64 {
total := c.FanIn + c.FanOut
if total == 0 {
return 0
}
return float64(c.FanOut) / float64(total)
}
// Layer orders packages from the inside out. Imports may point inward or stay
// on the same layer; imports pointing outward are violations.
type Layer int
const (
Domain Layer = iota
Application
Adapter
)
func (l Layer) String() string {
switch l {
case Domain:
return "domain"
case Application:
return "application"
case Adapter:
return "adapter"
default:
return "unknown"
}
}
type Import struct {
FromPackage string
ToPackage string
FromLayer Layer
ToLayer Layer
}
type Violation struct {
FromPackage string
ToPackage string
FromLayer Layer
ToLayer Layer
}
func CheckDirection(imports []Import) []Violation {
var out []Violation
for _, im := range imports {
if im.ToLayer > im.FromLayer {
out = append(out, Violation{
FromPackage: im.FromPackage,
ToPackage: im.ToPackage,
FromLayer: im.FromLayer,
ToLayer: im.ToLayer,
})
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].FromPackage != out[j].FromPackage {
return out[i].FromPackage < out[j].FromPackage
}
return out[i].ToPackage < out[j].ToPackage
})
return out
}
几个设计点:
Evaluate对通过和失败的指标都返回Finding。报告需要看到余量,而不是只看到失败;只列失败会让团队误以为其他指标很安全。- 例外按指标名匹配,不按规则 ID。适应度函数本身是长期存在的,例外是对某个指标在某段时间的容忍,不是对某条规则的永久关闭。
- 到期判断用
!at.Before(e.ExpiresAt)。到期时刻本身就算过期,不留“最后一天”的模糊地带。 RankDebts先排逾期,再排月利息。逾期是承诺问题,利息是经济问题;承诺优先,但不能让高利息债务永远排在有明确截止日期的债务后面。CheckDirection只拦向外依赖。同层依赖和向内依赖都放行,避免把“领域层调领域层”这种正常情况误报。
失败路径
| 失败路径 | 表现 | 兜底 |
|---|---|---|
| 阈值拍脑袋 | 第一周告警爆炸,第二周全员静音 | 先采集两周分布,再定阈值 |
| 例外永久化 | 到期没人处理,豁免变成事实标准 | 到期自动失效,违规回到阻断 |
| 只看平均值 | 平均 P99 正常,某个区服已经崩了 | 按区服、品类、版本切片 |
| 债务只登记不排期 | 台账越来越长,没人还 | 月利息进入排期比较,按季度复核 |
| 指标不可归因 | 超标了但不知道是谁引入的 | 每个指标带 owner 和最近变更记录 |
检查表
建立台账时:
- 每条债都有描述、影响、利息、偿还计划四个字段。
- 利息用“每次额外成本 × 每月改动次数”计算,单位统一。
- 影响和利息分开写,不混成一句“很重要”。
- 偿还计划有 owner 和验收标准,不写“持续优化”。
设置适应度函数时:
- 每类约束最多选一个主指标,避免指标之间互相打架。
- 新规则先警告运行一个迭代,再决定是否阻断。
- 新代码阻断、存量警告的口径写进规则说明。
- 例外有 owner、原因和未来到期时间。
- 报告按区服、品类、版本切片,不只看全局平均。
- 每条规则和指标都有 owner,能追溯到最近一次变更。
小结
技术债台账的核心不是记录,而是排序。把债务写成“描述、影响、利息、偿还计划”,再用月利息把它和需求放到同一张排期表里,债务才可能被还。
适应度函数把架构约束从文档变成指标。阈值要分级,新代码阻断、存量警告;例外要有时限和 owner,到期自动失效。下一节讨论这些规则如何变成自动化检查:规则从哪里来、在哪个层级运行、误报和废弃怎么处理。
阅读导航:上一节:8.1 架构评审与决策权 · 下一节:8.3 架构规则的自动化检查 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。