旧代码不等于架构债务,新代码也可能在上线当天就欠下债。判断标准不是代码写了多久,而是一个暂时决定是否持续增加变更成本,并且团队已经说不清它应在什么条件下被替换。

我参与过的一个任务平台,最初用一列 status 支撑全部流程。MVP 只有排队、执行、成功和失败,简单枚举完全够用。后来产品陆续增加暂停、人工审批、超时重试和外部回调,前端、API、Worker、报表开始各自解释同一个状态。代码仍然能跑,新需求也能继续加,但每次修改都要在几个模块里同步补条件。

真正让我确认它已经从“简单设计”变成债务的,不是某段代码难看,而是三件具体的事:一次取消任务仍然触发了外部通知;同一个状态在列表和计费报表中含义不同;新增“等待审批”时,三个团队分别改了自己的判断,联调到最后一天才发现不一致。

这类债务危险之处在于,它很少让系统立即停机。它只是持续收取利息,直到一次业务变化把隐含矛盾集中暴露出来。

先区分有意识的借款和失控债务

两周 MVP 采用简单枚举并没有错。错误是上线后没有记录适用边界,也没有定义超出边界时怎么办。

一个健康的临时决定至少应写清四件事:当前为什么足够、牺牲了什么、什么信号出现时必须重审、谁负责推动重审。例如:

decision: single-status-column
context: 首版仅支持同步任务,无暂停与外部副作用
accepted_limits:
  - 一个任务只有一个执行尝试
  - 状态只服务运行流程,不承担计费口径
review_triggers:
  - 引入人工审批或补偿
  - 同一任务支持多次 attempt
  - 两个以上下游直接解释 status
owner: workflow-platform

触发器不是预测未来,而是避免团队在条件已经变化后仍然沿用旧决定。债务往往不是当初选错,而是系统演进后没有重新做决定。

债务利息必须能和业务优先级放在同一张表里

“这里应该重构”很难获得资源,因为它没有说明继续推迟会付出什么。架构债务需要像可靠性问题一样记录已发生影响,而不是表达工程师的不适。

我会把利息分成四类:

利息 任务状态案例中的证据 可观察指标
交付 新增状态要修改多处条件并反复联调 同类需求周期、跨模块改动数
可靠性 状态竞争导致重复通知和错误重试 状态冲突、人工恢复次数
认知 新成员无法判断哪个模块拥有状态语义 评审往返、升级咨询数量
机会 无法安全支持长任务与人工确认 被放弃或降级的业务需求

这里不需要伪造一个精确的“债务金额”。粗略但持续采集的事实已经足够帮助排序。某个旧模块虽然难看,但一年没有需求也没有故障,利息可能很低;一个刚上线的公共字段每周都引发跨团队误解,优先级反而更高。

找到共同根因,不要逐个修补症状

债务清单很容易膨胀:前端状态映射需要重构、Worker 重试条件混乱、报表口径不统一、取消逻辑有竞态。把它们当四项任务,会得到四套局部改进;把它们放回系统边界,会发现共同根因是“运行状态、用户意图和业务结果被压在同一个字段里”。

新的模型把这些事实拆开:

type RunProjection = {
  desiredState: "active" | "cancel_requested";
  executionState: "queued" | "running" | "stopped";
  outcome: "unknown" | "succeeded" | "failed" | "compensation_required";
  currentAttemptId: string | null;
  version: number;
};

这不是为了追求更多字段。用户请求取消,不代表 Worker 已经停止;Worker 停止,也不代表已经提交的外部副作用被撤回。把三个事实分开,API、页面、计费和恢复流程才能各自依赖正确语义。

架构治理的价值就在这里:它不是统一命名,而是重新决定哪个边界拥有事实,其他模块通过什么契约消费它。

偿还债务要先建立对照,再切换流量

直接替换旧状态字段风险很高,因为历史代码中可能有尚未发现的消费者。我们采用双算而不是立即双写:旧逻辑继续作为生产事实源,同一批事件同时送入新投影,结果只用于比较。

任务状态模型从旧逻辑、影子投影到逐步切换的迁移时序

图 1:新模型先证明能解释现有事实,再承担读取和写入责任。每一阶段都有差异指标和回退入口。

迁移分成六步:

  1. 盘点所有读写 status 的入口,并为旧行为补历史样本测试。
  2. 从领域事件计算新投影,不影响任何线上读取。
  3. 按原因记录新旧差异,优先处理权限、计费和外部副作用相关差异。
  4. 让内部诊断页和少量租户读取新投影,旧读取保留一键切换。
  5. 新写入只通过领域命令发生,旧字段由兼容层反向投影。
  6. 覆盖完整数据保留周期后,删除旧写入口,再逐步清理读取兼容。

双算期间最容易犯的错误是只看总差异率。一个展示文案差异和一个计费结果差异不能被同一个百分比平均。差异需要按业务后果分级,关键语义要求零未解释样本,低风险差异可以设置收敛窗口。

回退也必须演练。配置里保留 read_projection=legacy 没有意义,除非值班人知道谁能操作、切换后新写入怎样兼容、缓存多久失效,以及怎样确认用户结果恢复。

双写和兼容层都有截止日期

渐进式迁移不是天然安全。双写会引入新的不一致来源,兼容层会把新旧模型耦合在一起。如果没有退出条件,“临时迁移架构”会成为下一笔债务。

因此每个阶段都要有 owner、进入条件和退出日期。迁移看板不只显示完成百分比,还要显示剩余旧消费者、未解释差异、回退演练时间和兼容层删除计划。

我通常会保留一个明确的停止条件:关键差异没有解释、回退演练失败、值班团队还不能独立定位时,不继续扩大流量。进度压力不能把未知风险包装成“后续优化”。

债务偿还要验证收益,而不是统计新代码

重构完成不代表债务已经还清。需要回到最初记录的利息:新增状态的交付周期是否缩短,跨模块条件是否减少,状态冲突和人工恢复是否下降,其他团队能否只依赖稳定契约。

如果代码更漂亮,但业务需求仍要跨多个团队同步修改,根因可能没有解决;如果引入通用平台后值班复杂度显著上升,偿还方式也可能产生了新的利息。

架构债务管理本质上是持续做决定:今天为了速度接受了什么限制,系统何时越过适用边界,继续推迟的代价是什么,谁负责以可回退方式处理。只要这些问题有明确答案,一个不完美的系统仍然可以健康演进。真正失控的不是临时代码,而是所有人都知道问题存在,却没有触发器、负责人和验证标准。

“触发器”要落到文档和监控两处:文档里是 ADR 的复审条件(见ADR 怎么写才有人看),监控里是利息指标到达阈值自动建任务。状态字段这类单点模型的迁移,我在低代码配置升级里用单向迁移、黄金样例和写回隔离做过一次完整的演示——债务的偿还方法,常常已经在别的问题上练过一遍。