我非常确定,这不是一个"该不该重写"的问题,而是一个"怎么把存量代码从旧容器里救出来"的问题。MWGA是我在一个遗留系统现代化专项里用的内部代号,当时我手里那套C#业务系统,累计代码量已经到了几十万行,散落在上千个程序集里。把视野放到行业层面,全球范围内仍在生产环境运行的C#代码,量级到千亿行并不夸张。这篇文章想把我在这个过程中反复验证过的思路、工具选择、迁移路线和具体坑点整理出来,给同样背着存量C#代码的团队一个能直接抄作业的框架。
1. 一千亿行C#代码,是一个被"重写"口号坑惨的数字
1.1 为什么这个量级听起来夸张,算下来却合情合理
很多人一听"C#代码1000亿行"就觉得是博眼球。但你把账算一下就会发现,这个量级相当保守。从.NET Framework时代开始,C#就是Windows端业务系统的第一选择字段,各类ERP、MES、WMS、财务系统、进销存、银行柜台程序、仓储调度软件,几乎全是C#堆出来的。一个中型制造业的MES系统动辄三五十万行,大型金融核心业务系统几百万行很常见。
如果把全球几十万个被长期维护的业务系统、还有那些已经不再活跃但仍在某个服务器角落运行的老系统都算进去,1000亿行只是一个量级的快速估算。关键不是这个数字准不准,而是它传递了一个信号:这片代码资产是过去二十年企业信息化沉淀下来的,不是某个团队能轻易推倒重来的东西。
1.2 先算一笔重写的账,很多人算完就不说话了
我经常在技术讨论里听到"干脆重写"这句话。说这句话的人,很少认真算过重写的成本。我们做一个偏保守的估算:
假设你组建一支20人的精英开发团队,每个人每个月能稳定产出3000行高质量代码——这已经是非常乐观的水平,因为真实编码之外还有需求分析、测试、评审、联调、返工。那么这支团队一年能产出大约20×3000×12=72万行代码。
要重写1000亿行,需要大约138888年。这是个超出直觉的数字,所以再换一个角度:如果全行业投入100万人同时做这件事,每人每年产出3万行,一年也只能写30亿行左右,完成1000亿行需要一个多人的整个职业生涯。
哪怕你的系统只有几百万行,重写周期也普遍是3到5年起步,而且业务不会停下来等新系统上线。世界上的确有重写成功的大型项目,但大多数老系统重写的结局都是:项目延期、预算超支、业务方失去耐心、最后草草收场。
1.3 老代码的真正价值不是代码,而是行为
我见过不少人指着老代码说"写得真烂"。这个判断没错,但它忽略了一件事:代码烂,不代表行为烂。老系统里那些看起来愚蠢的"不管三七二十一先Try-Catch"、那些半夜定时任务、那些没人敢碰的存储过程,它们的组合行为是经过十年甚至二十年业务需求、Bug报告、监管检查、客户投诉反复磨出来的。
真正值钱的是行为,不是代码行数。复活的意思,不是让每一行老代码都继续活下去,而是让这些行为在新环境下继续有效。理解这一层,后续的技术路线选择才不会跑偏。
2. 体检先行:不上手改动,先把存量盘明白
2.1 从二进制程序集入手,别一上来就读源码
我在推进MWGA时的第一条经验是:永远不要先读源码。一个几十万行的系统,源码读三个月也读不完,而且读的过程中会有大量判断被"感觉"带着跑。正确的做法是从编译产物入手,用程序的方式把存量资产摸一遍。
做法很直接:扫描目标目录下的DLL,用System.Reflection.Metadata这类API读取程序集的元数据,提取类型定义、方法定义、属性以及程序集之间的引用关系。示例如下:
using System; using System.IO; using System.Reflection.PortableExecutable; string targetDir = @"D:\legacy-bin"; foreach (var dll in Directory.GetFiles(targetDir, "*.dll")) { using var fs = File.OpenRead(dll); using var peReader = new PEReader(fs); var mdReader = peReader.GetMetadataReader(); foreach (var typeHandle in mdReader.TypeDefinitions) { var typeDef = mdReader.GetTypeDefinition(typeHandle); Console.WriteLine(mdReader.GetString(typeDef.Name)); } }这里的重点是:只扫描元数据,不加载程序集。老程序集的依赖关系往往已经千疮百孔,直接Assembly.Load很可能抛异常,因为依赖找不到。而元数据读取方式不触发运行时加载,非常稳。
2.2 依赖图不是画来好看的,是找"循环引用"和"上帝对象"的
扫描完程序集之后,下一步是构建依赖关系图。每一个DLL引用了哪些外部程序集、被哪些上层模块引用,这些信息必须结构化地落到报表里,而不是停留在架构师的脑子里。
依赖图会立刻暴露两类问题:
第一类是循环依赖。A模块引用B模块,B模块又引用A模块,这在编译期可能没报错,但会让后续的独立迁移完全做不了,因为无法把任何一个模块单独摘出来。
第二类是"上帝对象"。某些公共类被几百个调用方引用,改一个字段签名要牵连所有模块。这种类型往往躺在底层公共库里,是典型的改动放大点。找到它们之后,后续的封装方案要优先考虑如何削减影响面。
2.3 把存量资产切成三层:核心内核、稳定外壳、历史包袱
盘点的结果是用来做分层决策的。我在实际项目里会把所有模块分成三类:
- 核心内核:高频变更、包含核心业务规则、出了事故会被追责的模块。比如计费引擎、订单状态机、风控规则库。
- 稳定外壳:长期运行、极少变更、但价值稳定的模块。比如报表导出服务、消息通知组件、接口转发层。
- 历史包袱:已无真实调用方、无业务价值、只是暂时没人删除的代码。比如十年前预留的扩展接口、从未启用的功能开关。
这个分类决定了后面三周不同的处理方式。核心内核要保守迁移,稳定外壳可以服务化,历史包袱直接冻结甚至删除。
2.4 比源代码更值钱的东西:日志、配置差异、运维手册
扫描代码只是盘点的第一步,真正理解行为还要靠代码之外的信息。我在MWGA阶段花了很多时间整理线上的日志样本,尤其是异常堆栈、慢调用记录和定时任务的执行轨迹。这些日志比代码更能说明系统真实在干什么。
配置文件的历次变更也一样有价值。一个连接字符串从A库切换到B库,背后可能是一次业务迁移;一个开关从关到开,可能意味着一条变了的需求。把这些配置差异和时间线拉出来,能还原出系统的演化史。还有运维手册里的冷启动步骤、重启顺序、依赖的外部服务清单,这些不说内容有多少含金量,至少能让你面对老系统时不再两眼一抹黑。
3. 路线图:同构迁移、服务化封装、定向重写三头并进
3.1 同构迁移:把运行时升级到底为止
对满足条件的模块,首选同构迁移:把代码从旧运行时搬到新运行时,尽量保持业务代码不变,先把底下那层运行时换掉。这个路线的核心逻辑是"先把路换好,再慢慢换车"。
判断一个模块能不能走同构迁移,我总结出三条规则:
- 没有不可替代的Windows系统调用或底层原生库依赖;
- 没有深度使用旧运行时才有的组件模型,比如庞大的全局应用域状态;
- 第三方依赖在目标版本上有兼容替代包。
符合这三条的模块,迁移顺序建议是:公共库优先、后台服务其次、界面层最后。公共库没有外部交互,迁移风险最小,跑通之后能快速建立团队信心。
这里要特别强调:同构迁移不是一次"编译通过"就完了。编译通过只是开始,运行时行为的一致性才是真正的验收标准。我见过太多项目编译过了就宣布迁移完成,结果一上线,加密协议握手失败、第三方接口超时、GC行为异常,全线崩溃。
3.2 服务化封装:用绞杀者模式保护老逻辑收入
大量模块其实并不需要迁移,它们已经稳定运行了多年,唯一的痛点是无法被新系统调用。对这类模块,我的首选方案是服务化封装——在老系统外面包一层新接口,让新系统通过接口访问老系统的能力。
这个路线在业界有成熟的思想,叫"绞杀者模式":不推倒旧系统,而是在旧系统外面一层一层缠上新能力,直到旧系统的份额被逐步替代。好处是风险极低,老系统继续跑,新系统只需要对接暴露出来的接口,互不干扰。
每次只封装一个业务能力,输出一个接口,验证通过后再包下一个。这个节奏听起来慢,但实际效果非常稳。我之前统计过一个项目,三个月内封装了十几个接口,一点都没影响老系统的正常运作。
3.3 定向重写:只对真正值得动手的地方出手
重写并不代表绝对禁止,但它的使用范围必须极小。我建议团队只在三个条件下考虑重写:
- 模块逻辑相对简单,但性能长期不达标,而且优化空间已经被老设计锁死;
- 模块在容器化环境里无论如何都无法运行,且严重阻塞整体上云;
- 模块的维护成本已经高到改一行代码需要半天回归,且业务方愿意承担切换风险。
这三个条件的共同点是"老代码已经成了瓶颈",而不是"看老代码不顺眼"。重写时也要按新系统的工程标准来:完善的测试、清晰的边界、独立的发布管道。哪怕只有一个模块在重写,也必须按绿地项目的要求做,否则容易在过程中又写出一堆新的历史包袱。
3.4 决策矩阵:成本、风险、周期和团队熟练度怎么平衡
三条路线之间不是互斥的,而是可以并行展开。放在一起对比会更清晰:
| 路线 | 相对成本 | 风险等级 | 典型周期 | 适用条件 |
|---|---|---|---|---|
| 同构迁移 | 中 | 中 | 3到9个月/系统 | 依赖可兼容、无深度底层绑定 |
| 服务化封装 | 中低 | 低 | 1到3个月/接口 | 老系统稳定运行、新系统需要调用 |
| 定向重写 | 高 | 高 | 半年以上/模块 | 性能瓶颈、容器化阻塞、逻辑相对简单 |
团队熟练度也要考虑。如果你的团队大部分人不熟悉老框架的特性,同构迁移的排期要预留出足够的学习时间;反之,如果团队里有几个从老时代走过来的老手,同构迁移会比服务化封装更高效,因为他们能快速识别迁移过程中的行为差异。
4. 迁移项目里的死亡陷阱:七个我反复踩过的雷
4.1 网络与安全层的老司机翻车点:TLS协议和加密套件
迁移到新运行时后,最容易出现的问题不是代码报错,而是连不上外部服务。老系统运行的那些年里,底下一个对外部API的调用可能一直用的是老式安全协议。新运行时出于安全考虑,默认策略发生了变化,于是连接直接被拒。
现象是:代码没有任何改动,但对方返回握手失败。原因就是可用协议列表和加密套件的默认值变了。处理方式有两个方向:要么推动对方接受新协议,要么在不影响安全底线的前提下开启兼容配置。实际项目中我一般会先做链路抓包,确认双方各自支持的协议列表,再决定升级还是兼容。
4.2 线程与并发在新时代的"同词不同义"
lock还是那个lock,Thread也还是那个Thread,但运行时的线程池调度策略、异步等待模型都和旧时代不完全一样。
最典型的情况是:老代码里大量使用Thread.Sleep来控节奏,比如轮询队列时睡几秒。在新运行时里,线程池对阻塞线程的容忍度变低,在低负载服务器上表现和以前差异极大,可能引发吞吐量断崖式下跌。迁移后要重点审查所有"睡眠等待"和Monitor.TryEnter的写法,尽量改成真正的异步等待或信号量控制。
4.3 Web形态的断链:Session状态与长连接
老Web系统的Session默认存在进程内,一台机器跑单实例什么都好。一上容器、多副本,负载均衡一轮询,Session就丢了。这不是代码问题,是架构假设失效了。
处理思路是把Session状态外置到分布式缓存,但老代码到处都在用Session集合直接读写数据,改造量不小。另一个高频断链点是老式服务框架的回调机制,这类底层通信协议在容器网络里几乎无法工作。替代方案是老式回调改成消息队列驱动或双向流式通信,并自行实现心跳和重连。
4.4 数据侧的老狐狸:连接串、文化区域与GC行为
数据库驱动是最容易被忽略的迁移陷阱。老系统当年的数据库驱动连接字符串写法,在新驱动下不一定完全兼容。比如某些参数名的变化、字符集设置方式不同,都会导致连接时各种诡异报错。
文化区域设置也经常捣乱。旧框架里默认的区域性和新运行时不一定一致,ToString("yyyy-MM-dd")这类格式化在不同区域下表现会有差异,轻则日期格式不对,重则字符串排序结果不一致。建议迁移后专门做一轮全球化扫描,把硬编码的格式化字符串全部收敛到显式区域设置。
GC行为也要盯。老式的并发工作站GC和大对象堆处理策略在不同版本之间有差异。一个批量处理模块在旧环境里内存平稳,迁移后突然频繁Full GC,就是因为大对象的分配方式和回收时机变了。遇到这种情况,可以通过调整GC模式、拆分超大对象、减少临时大数组的分配来缓解。
4.5 配置文件:最不起眼、最容易出事的迁移项
老系统的配置是全的,但里面的内容一半是遗产。迁移到新框架后,配置系统的读取方式、环境变量优先级、JSON/YAML/XML的互相转换,每一步都可能产生偏差。
我在项目中养成了一个习惯:迁移前先把配置文件做一次基线快照,跑完迁移后再把运行时实际读到的配置和快照做一次diff。很多"上线后行为不一致"的问题,最后都被证明是某个配置项没被新系统读到。
4.6 序列化兼容性:新老系统互相握手时的暗坑
服务化封装和同构迁移都会遇到序列化格式问题。老系统的二进制序列化产物,新的运行时不一定认;新系统默认的JSON序列化,老系统可能因为类型成员命名规则不同而解析失败。
处理原则是:所有跨系统交互的模型都走显式的契约定义,字段名、类型、可空性全部定死,不使用运行时默认行为来猜。能转为文本协议的尽量转文本协议,能显式声明版本的尽量显式声明。
4.7 异步改造:老逻辑假设的是同步世界
很多老代码的逻辑是同步请求-响应,中间夹杂着全局状态和共享变量。迁移时看到接口支持异步就顺手改成异步,结果出现大量并发冲突和状态错乱。
异步改造的前提是:调用链上没有共享的可变状态,或者共享状态本身是线程安全的。如果只是把签名从同步改成异步而内部依旧共享同一个静态字典,那只是把原来的问题从单线程灾难升级为并发灾难。异步改造要按调用链一层层梳理,而不是一键替换。
5. 老代码服务化的正确打开方式:防腐层与门面设计
5.1 直接暴露老系统类型是一场灾难
服务化封装最忌讳的是把老系统内部的数据类型直接搬出来当接口契约。老系统内部的DTO往往是几十个字段的大杂烩,里面一半字段没人知道是干嘛的,而且对象可变、值语义混乱、枚举值还是魔法数字。
正确做法是在老系统和新系统之间加一层防腐层,新系统只面对自己定义的、干净的新契约。接口定成什么样,完全以新系统的消费需求为准,老系统内部的复杂性由防腐层自己消化。比如你只需要一个报价接口,就定义GetQuoteAsync这样的接口,别把内部500个字段的实体对象整个抛出去。
public interface IQuoteService { Task<QuoteResponse> GetQuoteAsync(QuoteRequest request); } public sealed record QuoteRequest(string ProductId, int Quantity, string Region); public sealed record QuoteResponse(string QuoteId, decimal Amount, string Currency, DateTime ValidUntil);5.2 接口设计的五个基本要素:版本化、超时、熔断、幂等、鉴权
没有这五个要素,服务化封装出来的接口就是给未来埋雷。逐个来说:
- 版本化:从第一个接口开始就带上
/v1/前缀,老版本下线也不能硬删,至少留足兼容期; - 超时:每个调用要设置明确的超时时间,不能默认无限制等待;
- 熔断:上游老系统如果性能抖动,下游不能让请求无限堆积,要有熔断策略;
- 幂等:老系统内部的重复提交防护通常很差,新接口要自己提供幂等键支持;
- 鉴权:哪怕只是内网联调,也要有统一的身份校验,不能裸奔。
5.3 数据层兼容:老存储与新模型之间的翻译
服务化封装之后,新系统、老系统会同时访问同一份数据库,这是非常危险的状态。老系统里直接读写表的地方很多,新系统也写同一张表,稍不留神就会产生锁竞争和一致性问题。
更稳妥的方式是:新系统不直接碰老库表,所有数据访问都通过防腐层转发给老系统完成。不得不共享数据时,优先采用单向数据同步的方式,比如老系统负责写入,新系统通过增量订阅拿到数据到自己的读库。这样两边各有归属,互踩的风险最低。
5.4 用合同测试证明行为没有漂移
服务化封装最大的隐忧是"我以为老逻辑是这样,其实不是"。要解决这个问题,不能靠代码走查,要靠合同测试。
具体做法很简单:抽取线上真实请求日志,构造一批历史请求样本,每个样本保留对应的历史响应。新接口开发完毕后,把同样的请求样本同时打到老系统和新接口上,逐字段比对响应差异。这个自动化回归集一旦建立起来,每次改动都有了安全网。我在MWGA推进期间,最信任的不是架构评审,而是这套请求-响应样本比对。
6. 剩下的事情是运营,不是工程:让复活持续发生
6.1 用一张仪表盘量化现代化进度
现代化项目最容易烂尾的原因之一,是"没有进度感"。今天改一个类,明天拆一个模块,三个月后没人说得出整体进度是多少。我在项目里强制要求维护一张现代化仪表盘,至少包含以下指标:
- 已服务化接口数 / 总待服务化接口数;
- 迁移后模块的错误率、平均响应时间;
- 尚未迁移的历史遗留依赖数量;
- 高危接口的线上请求成功率。
每周五更新一次,贴到项目群里。有了数据,任何人问"现在到哪一步了",都能直接给答案。这个看起来朴素的做法,对团队士气的提振作用比想象中大。
6.2 小步渐进:每一周只吞一头大象
推进现代化改造时,我强烈反对"大爆炸式"发布。更有效的节奏是:每两周至少交付一个可验收的接口或模块,并且交付后允许一键回滚。
要把系统切成这样的节奏,切分边界要按业务能力来,而不是按技术层来。比如"订单查询"是一个能力,"订单创建"是另一个能力。哪怕它们底层都是同一个订单表,也要在业务能力边界上先动刀,而不是先去拆通用的数据访问层。
6.3 自动生成代码地图,降低接手成本
老系统最大的问题不是没有文档,而是没人知道某个改动会波及哪里。我在盘点阶段生成的依赖图和调用链数据,后续要持续维护成一份"代码地图"。
代码地图不需要画得多精美,关键是要能回答三个问题:某个入口方法背后调用了哪些模块?某个底层类被哪些入口使用?某条定时任务链路的完整拓扑是什么?新成员入职后,不用再找人讲半个月的业务,直接对着调用链走一遍即可。这个地图一旦成为团队惯例,接手成本会大幅下降。
6.4 让团队从"毁灭崇拜"转向"外科手术"
很多开发者的第一反应是厌恶老代码,我理解这种情绪,但它会直接摧毁现代化项目。一个团队如果总想着"写一套新的把旧的干掉",就会在每次遇到麻烦时选择绕路,而不是把问题修好。
我的调整方式很具体:任何一次改动,先写测试再动代码;任何一次封装,先定合同再做映射;任何一次迁移,先做行为比对再清理旧代码。设定这套流程既能管住质量,也能让团队重新建立对系统的信任。信任建立起来之后,"复活"就不再是某个项目的目标,而是一种日常习惯。
做了几年这类事情,我最大的体会是:复活老代码本质上不是技术战,而是信任战。老系统积累的行为是财富,只是它们的表达方式不够现代。先定量、再动手,先保行为、再谈优化,这个顺序不能反。如果只能分享一个技巧,那就是从今天开始,挑一条你最常出问题的调用链,把它完整画出来,然后交给下一个要动它的人。这个动作看起来小,积累起来比任何宏大的重构计划都更有价值。