《游戏服务端架构·平台化与架构治理》8.2 技术债与架构适应度函数

技术债台账如果只记“待优化”,就会变成一份没人排期的清单。本节把债务拆成描述、影响、利息与偿还计划,用每次改动的额外成本乘改动频率给债务排序;同时给出依赖方向、模块耦合、接口兼容、数据所有权、延迟预算五类适应度函数,以及阻断、警告与限时例外的判定规则。

一份没人排期的技术债清单

《裂隙战场》的技术债台账最初有 60 多条,每条都写着“待优化”。半年后回看,只有三条被处理,剩下的不是不重要,而是无法比较:没人知道“重构匹配器”和“拆掉结算服务里的世界模型”哪个更值得先做。

台账变成了许愿池。每次评审都往里加一条,每次排期都跳过它,因为“优化”没有截止时间,也没有人能说清楚不做会怎样。

问题不在团队不重视技术债,而在台账缺少排序依据。描述、影响、利息、偿还计划四个字段里,只有“描述”被认真填了。没有利息,债务之间就无法比较;没有偿还计划,债务就永远排在需求后面。

这一节把技术债从“待优化清单”改造成可排序的账本,再用适应度函数把架构约束变成持续运行的指标。前者回答“先还哪笔”,后者回答“怎么知道架构正在变坏”。

四要素:描述、影响、利息、偿还计划

字段回答的问题不合格写法可验收写法
描述哪段结构、哪个约束被破坏了“代码太乱”“结算服务直接读世界分片表,跨了数据所有权”
影响不还会怎样,影响谁“影响可维护性”“每次改结算规则要同步改三个服务,去年因此出过两次口径分叉”
利息每次改动额外付多少成本“以后再说”“每次改动多 0.5 人日,月均 40 次改动”
偿还计划谁在什么时间做到什么程度“持续优化”“Q1 由结算组把读取切到结算服务,验收是对账差异为零”

四个字段里,利息最容易被忽略,却最决定排序。技术债的本质不是“代码难看”,而是“每次改动都要多付一笔税”。没有利息,就无法判断这笔债值不值得现在还。

影响和利息要分开写。影响是“最坏会怎样”,利息是“平时有多疼”。一笔债可能影响很大但很少触发,也可能影响不大但每次改动都在付钱。两者的偿还策略不同。

利息怎么算

把利息定义成两个可观测量的乘积:

月利息(示例值) = 每次改动的额外成本 × 每月改动次数
债务每次额外成本(人日,示例值)每月改动次数(示例值)月利息(人日,示例值)
结算服务直读世界表0.54020
匹配器状态与房间状态混放2.036
活动配置双写0.2204
GM 工具重复鉴权0.150.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 架构规则的自动化检查 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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