技术债治理路线图:把架构问题拆成可交付切片
一个重构提议为什么总进不了排期
每段时间总会有人说「这块代码太乱了,需要重构」。然后大家点头,然后没有然后。问题不在于没人认同,而是「乱」和「重构」这两个词太宽泛,没办法变成一个 Jira ticket。
技术债治理的第一步不是动手改代码,而是把模糊的抱怨翻译成具体的、可以排期的、有验收标准的问题。这篇文章聊的就是这个翻译过程——怎么识别、怎么分类、怎么拆成可交付的切片,以及怎么让治理成果看得见。
技术债四象限:先搞清楚欠的是什么债
Martin Fowler 在 2009 年提出了技术债四象限,用两个维度——「鲁莽/审慎」和「有意/无意」——把技术债分成四类:
| 象限 | 特征 | 例子 | 处理策略 |
|---|---|---|---|
| 鲁莽且有意 | 明知不对还这么做 | 明知应该写接口抽象,直接硬编码依赖 | 流程约束 + 代码审查拦截 |
| 鲁莽且无意 | 不知道自己在欠债 | 新手写出高耦合代码,浑然不觉 | 培训 + lint 规则 + 架构守护 |
| 审慎且有意 | 有计划地借债换时间 | 赶 MVP 上线,先硬编码,排期下周重构 | 记录决策,按承诺还债 |
| 审慎且无意 | 经验积累后发现当初不够好 | 一年前划分的模块边界,现在业务变了显得不合理 | 随业务演进逐步调整 |
前两类是需要尽快偿还的「坏债」,后两类是工程实践中难免的代价。关键不在于消灭所有技术债,而是让债务可见、可衡量、可排期。
Gartner 在 2025 年预测,到 2026 年 80% 的技术债将是架构层面的,而非代码层面的。SIG 2026 年的研究进一步指出,技术债占组织 IT 支出的 21% 到 40%。这说明架构债已经从团队级的代码质量问题,变成了组织级的成本和效率问题。
四种债务,四种治法
笼统地说「技术债」容易让人无从下手。按影响范围和治理方式,我习惯把债务分成四类:
| 债务类型 | 典型表现 | 治理方式 | 验证手段 |
|---|---|---|---|
| 代码债 | 函数过长、命名混乱、重复逻辑 | 重构、lint 规则、代码审查 | 圈复杂度下降、重复率降低 |
| 测试债 | 关键路径无测试、测试不稳定 | 补测试、修 flaky test | 覆盖率提升、CI 绿灯率 |
| 架构债 | 模块边界混乱、依赖循环、数据所有权不清 | 渐进拆分、防腐层、契约测试 | 依赖图改善、变更影响范围缩小 |
| 安全债 | 权限散落、敏感数据明文、未轮换密钥 | 权限收敛、加密改造、密钥管理 | 安全扫描通过、渗透测试 |
代码债和测试债相对容易处理,工具链成熟,方向明确。架构债和安全债才是硬骨头——涉及多个模块甚至多个团队,治理周期长、验证成本高。但安全债往往有合规 deadline 倒逼,真正让人头疼的是架构债。
治理策略:为什么不能「一次性重构」
「停下来三个月做一次大重构」听起来很美好,实际上几乎不可能成功。业务不会停下来等,产品需求还在排,团队也要交付。更稳妥的方式是把大目标拆成小步,每步都能独立上线、独立验证、独立回滚。
这个思路在架构模式里有个名字叫 Strangler Fig(绞杀者模式)。Microsoft Azure Architecture Center 对这个模式的描述是:通过一个适配层(Facade)拦截流量,逐步把旧系统的功能替换为新服务,直到旧系统可以退役。
具体分四个阶段:
- 引入 Facade,大部分流量仍走旧系统
- 逐步迁移功能到新服务,Facade 调整路由比例
- 全部功能迁移完成,旧系统退役
- 移除 Facade,客户端直连新系统
这个模式的核心思想——渐进替换、双轨并行、随时可退——可以应用到几乎所有技术债治理场景。
案例一:单体订单系统的读写分离
场景
一个单体应用里,订单创建、支付回调、库存扣减三个职责挤在同一个服务里,共用一个数据库。每次改支付回调逻辑,都要回归测试整个订单链路。发布频率从每周两次降到了每两周一次。
问题
- 发布需要全量回归,前置时间 3 天
- 支付回调的改动经常导致订单创建的意外回归
- 三个模块的开发者互相等待发布窗口
方案
按 Strangler Fig 思路,分四步走:
第一步,用接口隔离三个职责,在代码层面建立边界。
第二步,把共享数据库拆成三个 schema,用 Flyway 管理迁移,用 CDC(变更数据捕获)做数据同步。
第三步,在 API 网关层引入路由规则,先切读流量到新服务。
第四步,写流量切换后下线老代码。
代码示例
坏做法——三个模块直接共享 Repository,边界模糊:
// ❌ 所有模块直接操作同一个仓储,没有边界
class OrderService {
constructor(private db: SharedDatabase) {}
async createOrder(dto: CreateOrderDto) {
const order = this.db.orders.create(dto)
// 直接调用库存逻辑,紧耦合
this.db.inventory.deduct(dto.items)
// 直接调用支付逻辑,紧耦合
this.db.payments.initiate(order)
return order
}
}好做法——通过防腐层隔离,依赖方向单向:
// ✅ 通过事件解耦,每个模块有自己的仓储
class OrderService {
constructor(
private orderRepo: OrderRepository,
private eventBus: EventBus,
) {}
async createOrder(dto: CreateOrderDto) {
const order = await this.orderRepo.create(dto)
// 发布事件,由下游模块各自处理
await this.eventBus.publish('order.created', {
orderId: order.id,
items: dto.items,
})
return order
}
}路由层的流量切换——按百分比灰度:
// ✅ 用百分比切流,支持回滚
function shouldRouteToNewService(
userId: string,
featureFlags: FeatureFlags,
): boolean {
// 未开启灰度,全走旧服务
if (!featureFlags.isEnabled('order-read-v2')) {
return false
}
// 按用户 ID 哈希取模,保证同一用户路由稳定
const hash = parseInt(userId.slice(-2), 10)
const rolloutPercent = featureFlags.getNumber('order-read-v2-percent', 0)
return hash < rolloutPercent
}# API 网关层配置,按权重分配流量
upstream order_read {
server old-order-service:8080 weight=70;
server new-order-service:8080 weight=30;
}结果
| 指标 | 治理前 | 治理后 |
|---|---|---|
| 发布前置时间 | 3 天 | 4 小时 |
| 发布频率 | 每两周一次 | 每天多次 |
| 变更导致的回归缺陷 | 每月 5-8 个 | 每月 0-1 个 |
| 模块独立部署能力 | 不支持 | 三个模块独立部署 |
案例二:循环依赖的拆解
场景
一个大型单体应用里有四个模块:订单、用户、消息、风控。它们之间的依赖关系如下:
- 订单 → 用户(查用户信息)
- 订单 → 风控(下单前检查)
- 风控 → 订单(查历史订单)
- 用户 → 消息(发送通知)
- 消息 → 用户(查用户偏好)
订单 ↔ 风控、用户 ↔ 消息形成了两个循环依赖。改任何一个模块都可能影响其他三个,开发者不敢动。
问题
- 任意模块改动需要联调其他三个模块
- 依赖图出现环,无法独立测试
- 新人看代码需要先理解整个系统的依赖关系
方案
用「事件驱动 + 依赖方向约束」打破循环:
- 用
madge或dependency-cruiser扫描依赖图,定位所有循环 - 把「查询」依赖改为「事件」驱动——订单创建后发事件,风控订阅事件做检查
- 建立架构守护规则,CI 中自动检测依赖方向违规
代码示例
坏做法——直接互相调用,形成循环依赖:
// ❌ 订单服务直接依赖风控服务的内部实现
class OrderService {
constructor(
private riskRepo: RiskRepository,
private userService: UserService, // 循环:User → Message → User
) {}
async createOrder(dto: CreateOrderDto) {
const user = await this.userService.getUser(dto.userId)
const riskLevel = await this.riskRepo.checkRisk(user)
if (riskLevel === 'high') throw new Error('Risk too high')
return this.create(dto)
}
}好做法——通过事件中介解耦,依赖方向单向:
// ✅ 事件中介,各模块订阅自己关心的事件
// 订单模块只负责发布事件,不关心谁消费
class OrderService {
constructor(
private orderRepo: OrderRepository,
private eventBus: EventBus,
) {}
async createOrder(dto: CreateOrderDto) {
const order = await this.orderRepo.create(dto)
// 发布事件,由风控模块自己订阅处理
await this.eventBus.publish('order.created', {
orderId: order.id,
userId: dto.userId,
amount: dto.amount,
})
return order
}
}
// 风控模块独立消费事件,不反向依赖订单模块
class RiskListener {
constructor(
private orderQuery: OrderQueryClient, // 通过 API 调用,非直接依赖
private riskRepo: RiskRepository,
) {}
@On('order.created')
async handleOrderCreated(event: OrderCreatedEvent) {
const history = await this.orderQuery.getUserOrders(event.userId)
const riskLevel = this.riskRepo.evaluate(event, history)
if (riskLevel === 'high') {
await this.eventBus.publish('risk.alert', { orderId: event.orderId })
}
}
}架构守护——用 dependency-cruiser 在 CI 中拦截:
{
"forbidden": [
{
"name": "no-circular-deps",
"comment": "禁止模块间循环依赖,发现即阻断 CI",
"from": { "path": "src/modules/order/" },
"to": {
"path": "src/modules/order/",
"via": { "path": "src/modules/risk/" }
}
}
]
}结果
- 循环依赖从 2 个降到 0 个
- 模块可以独立运行测试
- 新人只需要理解自己负责的模块边界
案例三:共享数据库的拆分
场景
用户系统的数据分散在 users、user_profiles、user_settings 三张表里,分别由三个不同的服务直接访问。新系统需要一个统一的用户查询接口,但全量迁移有数据一致性风险。
问题
- 三张表的 schema 由不同团队维护,字段命名不统一
- 多个服务直接读写同一张表,数据所有权不清
- 任何 schema 变更都可能影响三个服务的查询
方案
用 Strangler Fig 模式拆分:
- 建立新的「用户查询服务」,提供统一接口
- 老系统继续写老表,新服务通过 CDC 同步到新表
- 读流量逐步切到新服务,验证数据一致性
- 全部切完后,下线老表
代码示例
坏做法——多个服务直接写同一张表,数据所有权混乱:
// ❌ 三个服务直接操作同一张 users 表
// 服务 A 改了 schema,服务 B 和 C 可能查询报错
class ProfileService {
async updateProfile(userId: string, data: ProfileData) {
await this.db.query(
'UPDATE users SET name = $1, avatar = $2 WHERE id = $3',
[data.name, data.avatar, userId]
)
}
}
class SettingsService {
async updateSettings(userId: string, data: SettingsData) {
// 同一个表,不同的服务在写
await this.db.query(
'UPDATE users SET theme = $1, lang = $2 WHERE id = $3',
[data.theme, data.lang, userId]
)
}
}好做法——数据所有权收敛,通过事件同步:
// ✅ 用户服务是唯一的数据所有者
class UserService {
async updateProfile(userId: string, data: ProfileData) {
const user = await this.userRepo.update(userId, {
name: data.name,
avatar: data.avatar,
})
// 数据变更后发布事件,下游订阅
await this.eventBus.publish('user.updated', {
userId,
changedFields: ['name', 'avatar'],
})
return user
}
}
// ✅ 双写期间的过渡方案——保证数据一致性
async function migrateUserData(userId: string) {
// 先写新表
const newRecord = await newUserRepo.upsert(userId, data)
try {
// 再写老表,标记迁移状态
await oldUserRepo.update(userId, { migrated: true })
await syncLog.record({ userId, status: 'migrated' })
} catch (err) {
// 老表写入失败不影响新表,记录错误后续修复
await syncLog.record({ userId, status: 'old_write_failed', error: err })
}
}数据迁移阶段的判断标准:
// 明确每个阶段的进入条件,不靠「感觉差不多了」
const migrationPhases = [
{
phase: '全量同步',
duration: '1-2 周',
entryCriteria: 'CDC 管道搭建完成,延迟 < 1s',
exitCriteria: '数据一致性 > 99.99%,错误率 < 0.01%',
},
{
phase: '灰度切读',
duration: '1-2 周',
entryCriteria: '全量同步通过验收',
exitCriteria: '新服务 P99 < 50ms,无数据不一致反馈',
},
{
phase: '全量切读',
duration: '1 周',
entryCriteria: '灰度期无 P1/P2 故障',
exitCriteria: '老表读流量降为 0,持续 72 小时',
},
{
phase: '下线老表',
duration: '-',
entryCriteria: '老表零流量,团队 review 确认',
exitCriteria: '老表删除,CDC 管道关闭',
},
]结果
- 用户查询接口统一,前端不再需要拼三个接口
- 数据所有权明确,schema 变更只需通知一个团队
- 迁移期间零故障,回滚能力覆盖到全量切读阶段
技术债治理的决策流程
不是所有技术债都需要立即治理,也不是所有治理都需要走完整的 Strangler Fig 流程。下面这个决策流程可以帮你快速判断该怎么做:
这个流程的核心判断是「影响范围」和「业务后果」。影响 3 个模块以上的架构债,需要路线图;影响范围小的,拆成正常迭代任务就行。不直接影响业务的债务,放到积压清单里定期清理。
好/坏做法对比总结
| 维度 | 坏做法 | 好做法 |
|---|---|---|
| 债务识别 | 靠感觉、「代码太乱了」 | 工具扫描 + 指标量化 + 影响面标注 |
| 治理方式 | 「停下来三个月大重构」 | 渐进替换,每步可独立上线和回滚 |
| 依赖管理 | 模块间直接互相调用 | 事件驱动 + 依赖方向约束 |
| 数据迁移 | 全量停机迁移 | CDC + 双写 + 灰度切流 |
| 验证手段 | 上线后靠用户反馈 | 自动化对比测试 + 可观测指标 |
| 治理节奏 | 一年一次大清理 | 每个迭代顺带治理,每季度专项 |
治理路线图分阶段检查清单
识别阶段
- 用工具(SonarQube / CodeClimate / madge)扫描代码质量、复杂度、依赖图
- 收集团队反馈:哪些模块改动最频繁、最容易出 bug、最难测试
- 按风险(稳定性 / 交付效率 / 安全合规)和影响面(模块数 / 团队数)标注优先级
- 区分债务类型(代码债 / 测试债 / 架构债 / 安全债),不同债务用不同治理方式
量化阶段
- 为每个债务项建立基线指标(当前复杂度、影响模块数、关联 bug 频率、变更前置时间)
- 估算治理成本和预期收益,用业务语言描述(「发布频率从双周变每周」而不是「代码更干净」)
- 识别依赖关系:哪些债务项之间存在前置依赖,必须先治哪个
规划阶段
- 把每个债务项拆成可交付切片,每个切片工作量不超过 1 周
- 确定优先级策略:风险驱动(先治最危险的)还是价值驱动(先治收益最大的)
- 为每个切片定义验收标准:指标改善目标、回滚方案、影响范围
- 制定里程碑,每个里程碑有可演示的成果
执行阶段
- 每个切片完成后运行对比测试和指标验证,用数据证明改善
- 引入防腐层和契约测试,防止治理过程中引入新问题
- 建立架构守护工具(dependency-cruiser / ArchUnit),防止同类债务复发
- 灰度切流时保留回滚能力,直到验证通过
制度化阶段
- 把技术债治理纳入 Definition of Done:新功能必须满足依赖方向规则
- 在 CI 中加入架构质量门禁:循环依赖数、扇出数、耦合度不能超过阈值
- 每个 sprint 预留 15%-20% 的容量用于技术债治理
- 定期回顾债务积压清单,更新优先级
参考资料
- Technical Debt Quadrant — Martin Fowler:技术债四象限理论的原始文章,区分了鲁莽/审慎和有意/无意四个象限。
- Strangler Fig Pattern — Microsoft Azure Architecture Center:Strangler Fig 模式的完整架构文档,包含数据库拆分示例。
- The Next Wave of Technical Debt Is Architectural — SIG:架构级技术债的最新研究,包含 AI 时代技术债增长数据。
- How to Reduce Technical Debt: 9 Proven Strategies — Sourcegraph:技术债削减的实操策略,包含债务预算和自动化方案。
- Technical Debt vs. Architectural Technical Debt — vFunction:代码级技术债和架构级技术债的区别分析。
- The Roadmap for Reducing Technical Debt — Kong:技术债削减路线图的实际制定方法。
- Architectural Technical Debt: A Systematic Mapping Study — ACM:架构级技术债的学术综述,包含管理路线图。
- 技术债治理的四条原则 — 知乎:从可维护性、可演进性和问题可见性三个角度分析技术债。