技术债治理路线图:把架构问题拆成可交付切片

0阅读10分钟

一个重构提议为什么总进不了排期

每段时间总会有人说「这块代码太乱了,需要重构」。然后大家点头,然后没有然后。问题不在于没人认同,而是「乱」和「重构」这两个词太宽泛,没办法变成一个 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)拦截流量,逐步把旧系统的功能替换为新服务,直到旧系统可以退役。

流程图画布 · 115%
Mermaid 流程图加载中...

具体分四个阶段:

  1. 引入 Facade,大部分流量仍走旧系统
  2. 逐步迁移功能到新服务,Facade 调整路由比例
  3. 全部功能迁移完成,旧系统退役
  4. 移除 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 个
模块独立部署能力不支持三个模块独立部署

案例二:循环依赖的拆解

场景

一个大型单体应用里有四个模块:订单、用户、消息、风控。它们之间的依赖关系如下:

  • 订单 → 用户(查用户信息)
  • 订单 → 风控(下单前检查)
  • 风控 → 订单(查历史订单)
  • 用户 → 消息(发送通知)
  • 消息 → 用户(查用户偏好)

订单 ↔ 风控、用户 ↔ 消息形成了两个循环依赖。改任何一个模块都可能影响其他三个,开发者不敢动。

问题

  • 任意模块改动需要联调其他三个模块
  • 依赖图出现环,无法独立测试
  • 新人看代码需要先理解整个系统的依赖关系

方案

用「事件驱动 + 依赖方向约束」打破循环:

  1. madgedependency-cruiser 扫描依赖图,定位所有循环
  2. 把「查询」依赖改为「事件」驱动——订单创建后发事件,风控订阅事件做检查
  3. 建立架构守护规则,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 个
  • 模块可以独立运行测试
  • 新人只需要理解自己负责的模块边界

案例三:共享数据库的拆分

场景

用户系统的数据分散在 usersuser_profilesuser_settings 三张表里,分别由三个不同的服务直接访问。新系统需要一个统一的用户查询接口,但全量迁移有数据一致性风险。

问题

  • 三张表的 schema 由不同团队维护,字段命名不统一
  • 多个服务直接读写同一张表,数据所有权不清
  • 任何 schema 变更都可能影响三个服务的查询

方案

用 Strangler Fig 模式拆分:

  1. 建立新的「用户查询服务」,提供统一接口
  2. 老系统继续写老表,新服务通过 CDC 同步到新表
  3. 读流量逐步切到新服务,验证数据一致性
  4. 全部切完后,下线老表

代码示例

坏做法——多个服务直接写同一张表,数据所有权混乱:

// ❌ 三个服务直接操作同一张 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 流程。下面这个决策流程可以帮你快速判断该怎么做:

流程图画布 · 115%
Mermaid 流程图加载中...

这个流程的核心判断是「影响范围」和「业务后果」。影响 3 个模块以上的架构债,需要路线图;影响范围小的,拆成正常迭代任务就行。不直接影响业务的债务,放到积压清单里定期清理。

好/坏做法对比总结

维度坏做法好做法
债务识别靠感觉、「代码太乱了」工具扫描 + 指标量化 + 影响面标注
治理方式「停下来三个月大重构」渐进替换,每步可独立上线和回滚
依赖管理模块间直接互相调用事件驱动 + 依赖方向约束
数据迁移全量停机迁移CDC + 双写 + 灰度切流
验证手段上线后靠用户反馈自动化对比测试 + 可观测指标
治理节奏一年一次大清理每个迭代顺带治理,每季度专项

治理路线图分阶段检查清单

识别阶段

  • 用工具(SonarQube / CodeClimate / madge)扫描代码质量、复杂度、依赖图
  • 收集团队反馈:哪些模块改动最频繁、最容易出 bug、最难测试
  • 按风险(稳定性 / 交付效率 / 安全合规)和影响面(模块数 / 团队数)标注优先级
  • 区分债务类型(代码债 / 测试债 / 架构债 / 安全债),不同债务用不同治理方式

量化阶段

  • 为每个债务项建立基线指标(当前复杂度、影响模块数、关联 bug 频率、变更前置时间)
  • 估算治理成本和预期收益,用业务语言描述(「发布频率从双周变每周」而不是「代码更干净」)
  • 识别依赖关系:哪些债务项之间存在前置依赖,必须先治哪个

规划阶段

  • 把每个债务项拆成可交付切片,每个切片工作量不超过 1 周
  • 确定优先级策略:风险驱动(先治最危险的)还是价值驱动(先治收益最大的)
  • 为每个切片定义验收标准:指标改善目标、回滚方案、影响范围
  • 制定里程碑,每个里程碑有可演示的成果

执行阶段

  • 每个切片完成后运行对比测试和指标验证,用数据证明改善
  • 引入防腐层和契约测试,防止治理过程中引入新问题
  • 建立架构守护工具(dependency-cruiser / ArchUnit),防止同类债务复发
  • 灰度切流时保留回滚能力,直到验证通过

制度化阶段

  • 把技术债治理纳入 Definition of Done:新功能必须满足依赖方向规则
  • 在 CI 中加入架构质量门禁:循环依赖数、扇出数、耦合度不能超过阈值
  • 每个 sprint 预留 15%-20% 的容量用于技术债治理
  • 定期回顾债务积压清单,更新优先级

参考资料

评论 0

0 / 1000