一个框架想换掉,查下来它在三十多个地方:注入的 bean、埋点、序列化、测试基类,还有两处没人记得的业务逻辑。谁都不敢动,于是它继续留着,每年吃掉一部分维护预算。
退出难,不是因为换实现难,是因为引入时没人算过退出的账。技术栈的生命周期不是"选型—使用—过时",而是"选型—使用—退出",第三步的成本在第一步就定了。
1. 退出成本要在引入时就算
| 引入决策 | 对退出成本的影响 | 引入时的对冲 |
|---|---|---|
| 只在一层内使用 | 低,替换面收敛 | 定死"只允许出现在这一层" |
| 注解/基类侵入业务 | 高,改一处牵动全部 | 用接口包一层 |
| 数据类型直接对外 | 极高,契约不可动 | 内外部模型分离 |
| 与框架生命周期绑定 | 高,等于改启动流程 | 关键逻辑写成独立进程 |
引入时最贵的一句话是"先直接用,以后再说"。它把一次性的接入便利,换成了三年后三十个调用点的清账。
另一种误判是双向的:团队会为选型写三页对比,却不为"以后怎么撤"写三行。
2. 四个阶段与可回退性
| 阶段 | 动作 | 可回退性 | 完成判据 |
|---|---|---|---|
| 冻结新增 | 禁止新使用点,CI 拦截 | 完全可回退 | 连续一个迭代无新增 |
| 隔离依赖 | 调用收进适配层 | 完全可回退 | 调用全部过适配层 |
| 并行替代 | 新旧双跑,按流量切分 | 可退回旧实现 | 新路径稳定一个观察期 |
| 清除残留 | 删代码、配置、依赖 | 不可回退 | 残留调用为 0 且持续 |
// 每阶段结束核对的自检清单
[ ] 是否有未写进判据的"例外"?带 owner 与到期日了吗?
[ ] 能否用一个命令或一次演练证明完成判据?
[ ] 下一阶段需要谁签字,签字人是否已知晓?
关键判据:前三阶段随时可以反悔,第四阶段不行,所以清除的启动条件是"残留为 0 且持续一段时间"。顺序也不能调换:跳过冻结直接替代,等于一边排水一边进水。
3. 冻结新增:最有效也最易被跳过
| 为什么被跳过 | 真实代价 |
|---|---|
| "新代码用一下无妨" | 每加一个调用点,退出面扩大一次 |
| 没有机制,靠自觉 | 半年后新增点位比退出前更多 |
| 怕影响交付速度 | 短期慢一点,换长期可退出 |
| 没人有权说不 | 冻结必须有人能拦下合并 |
冻结是唯一成本极低、收益极高的动作:一条规则加一次评审拒绝,就把退出难度锁在当下。
// 冻结落地的最简形式:CI 里的依赖规则
// 1) 架构测试比对基线,旧栈引用只允许减少
// 2) 新增使用点需显式豁免注释 + 到期日
// 3) 基线文件随退出进度更新,充当进度凭证
// 4) 豁免到期自动变失败,不靠人记
没有基线文件的冻结,最终都会退化成"这次先放过"。
4. 退出与迁移的分野
| 维度 | 迁移 | 终止 |
|---|---|---|
| 目标 | 换实现,继续提供同样能力 | 该能力不再需要 |
| 工作量 | 高,要等价替换并验证 | 低,删掉即可 |
| 主要风险 | 行为差异 | 隐藏依赖 |
| 常见误判 | 把"想删"当成"要换" | 把"没人提"当成"没人用" |
分不清这两件事,会做出最贵的决定:为一个即将消失的能力,认真迁移一遍。
先问"如果这个能力明天消失,谁会受影响"。答案里只有"可能有几个报表",那大概率是终止而不是迁移。
5. 该退与不该退的信号
| 该退的信号 | 不该退的信号 |
|---|---|
| 补丁越来越难打,维护成本上升 | 只是不流行,功能仍稳定 |
| 安全通告频繁且无修复版 | 社区讨论变少,代码没变 |
| 招不到维护的人,知识集中在一人 | 有内部专家,且愿意继续维护 |
| 与版本基线持续冲突 | 冲突可靠隔离或降级解决 |
"不流行"是最差的退出理由:流行度是别人的偏好,退出要看自己的维护成本曲线。
真正的信号都很具体:最近三次升级卡住两次、某个 CVE 无修复版本、唯一维护者提了离职。这些能写进决策记录。
6. 双轨并行的成本上限
双轨期长度决定总成本,而成本是超线性的:两条路径都要监控、都要修 bug、都要解释"为什么这里有两套"。
// 双轨期成本估算(人月),给并行期设上限
dual_cost = 双轨月数 × (维护旧 + 维护新 + 对账) + 变更协调成本
上限判据:dual_cost <= 一次性切换风险 × 切换失败概率
超出上限就改为"限期切换 + 回滚预案",而不是继续并行
| 双轨形态 | 适用 | 隐性成本 |
|---|---|---|
| 流量按比例切分 | 可观测、可对比 | 数据一致性对账 |
| 读旧写新 | 读路径简单 | 双写期状态漂移 |
| 影子流量 | 只验证不改结果 | 双倍资源,结果需丢弃 |
给并行期设明确截止日,并写清"到期未完成就按当前状态收敛"。没有截止日的双轨,最后会变成两套永久共存。
7. 数据与状态迁移才是门槛
代码替换是机械工作,真正的门槛在状态:库表、缓存键、队列消息、文件路径、外部对账关系。
| 状态类型 | 迁移难点 | 顺序 |
|---|---|---|
| 无状态代码 | 低,改完即生效 | 先做,验证流程可行 |
| 有状态服务 | 中,需双写或停机窗口 | 再排期 |
| 持久化数据结构 | 高,不可逆 | 最后做,留可回滚备份 |
| 外部契约 | 最高,需对方配合 | 单独谈判,可能否决整个退出 |
判据:无状态调用点占比决定退出难度。占比 80% 以上是排期问题;低于 50% 是项目问题,需要立项。
凡需要"停一次机"或"改一次表结构"的部分都单独列出,不要混进"替换框架"这个笼统任务。
8. 残留检测:靠证据不靠记忆
// 三层证据,缺一层就可能有残留
// 1) 静态:依赖与引用
grep -rn "OLD_PKG\|OLD_ANNOTATION" --include=*.java --include=*.kt .
// 2) 动态:运行期是否还有流量与来源
curl -s http://TARGET/metrics | grep -E "old_impl_(calls|latency)"
// 3) 配置与数据:仍生效的开关、表、队列
grep -rn "old_impl" config/ deploy/ || true
| 层 | 能发现 | 发现不了 |
|---|---|---|
| 依赖图 | 直接与传递引用 | 反射、SPI、配置加载 |
| 日志与流量 | 运行期真实调用 | 低频路径、故障分支 |
| 人工清单 | 语义依赖 | 人忘了的 |
最常见的残留是假活:计数不为零,来源却是健康检查或某个跑批。判据是看来源分布,不是总数。
9. 时间盒与升级机制
// 退出的时间盒模板(进任务系统,不是进文档)
T0 冻结新增,建立基线文件
T0+4w 调用点收进适配层,残留清单定版
T0+10w 双轨上线,观察期开始
T0+16w 残留为 0,进入清除窗口
T0+18w 依赖与配置删除,复盘归档
未达成:升级一级,或走停手判定并记录原因
| 机制 | 作用 | 失效方式 |
|---|---|---|
| 固定截止日 | 给出收敛压力 | 到期无人复盘,自动延期 |
| 分级升级 | 卡住时向上暴露 | 没有明确升级路径 |
| 进度可量化 | 用残留数代替"差不多" | 指标不更新 |
| 停手判定 | 允许结论是"不退了" | 无停手流程,只能烂尾 |
停手判定是必需项而不是失败。很多退出最终的正确结论是"成本高于收益,保留并隔离"——但必须写下来,否则半年后会再开同样的会。
10. 终止而不迁移:常是最省的选择
| 情况 | 做法 | 收益 |
|---|---|---|
| 功能使用率极低 | 直接下线,提前通知 | 省掉整条迁移路径 |
| 能力被别处覆盖 | 下线并指引替代入口 | 减少一处认知负担 |
| 只在内部调试用 | 下线,改临时脚本 | 去掉一个维护面 |
| 有合规留存要求 | 归档只读,保留运行 | 留证据,去掉系统 |
终止的成本结构完全不同:不需要等价替换,只需要证明"没人依赖"和"有人知情"。
证明依赖的方法与残留检测相同,但要求更高:要覆盖低频路径。一季度一次的手工操作也算依赖,且最易被忽略。
11. 度量
| 指标 | 定义 | 典型量级 |
|---|---|---|
| 存量调用趋势 | 每迭代调用点/请求数 | 冻结后应单调下降 |
| 双轨期时长 | 并行开始到残留归零 | 目标 6–12 周 |
| 残留数 | 仍引用旧栈的位置 | 目标 0,且连续两迭代 |
| 冻结失守次数 | 新增使用点被放行 | 目标 0,出现即机制无效 |
趋势停滞比绝对值更值得看:残留数连续两迭代不变,说明卡在某个具体依赖上,要点名而不是继续等。
12. 排错:退出推进不下去的五种现场
| 现象 | 原因 | 处理 |
|---|---|---|
| 残留数长期不动 | 某个调用点无法替换 | 单独列方案,或给明确例外 |
| 越退越多 | 没冻结新增 | 先停进水,再排水 |
| 双轨半年还在并行 | 无截止日或无对账机制 | 设停手判定日,写收敛方案 |
| 删依赖后启动失败 | 反射/SPI/配置驱动加载 | 预发跑全量用例验证 |
| 没人愿意签字删代码 | 责任归属不清 | 明确 owner,删除过一次评审 |
第四条是老问题的新形态:现代框架大量依赖运行期装配,grep 干净不等于没在用。判据是"删掉之后在预发跑一轮全量用例"。
13. 边界:正式退出流程可以省略的情形
| 场景 | 判断 | 做法 |
|---|---|---|
| 调用量已在下降且无人依赖 | 自然衰减即可 | 停止新增,等归零后直接删 |
| 组件在边缘位置(脚本、构建插件) | 影响面小 | 直接替换,出问题即回滚 |
| 系统本身一年内重写 | 投资无法回收 | 不做迁移,只做隔离 |
| 已全量切流且观察期满 | 残留风险已消除 | 直接清除,跳过双轨 |
反向边界:跨团队公共契约、持久化数据结构、外部对账关系,这三类无论调用点多少都不能顺手删,它们的失败不在编译期暴露。
14. 自测
- 引入一个框架时,哪两种设计会显著抬高未来的退出成本?
- 四个阶段里哪一步不可回退,它的启动条件该怎么写?
- "冻结新增"为什么比"并行替代"更值得优先做?
- 怎么区分一次退出该做成迁移还是做成终止?
- 双轨期为什么成本超线性,上限该拿什么来比?
- 删依赖后启动失败,说明静态搜索漏掉了哪类加载方式?