
接到这个任务的时候是周四下午三点多我正坐在工位上改一个拉了半年的迁移方案。负责人走过来往我桌上扔了一句话“下个月中旬之前老系统必须换掉。五十万行你来牵头。”我第一反应是他在开玩笑第二反应是打开日历数了数还剩多少工作日第三反应是回了句“那得看看这五十万行里有多少是能扔的。”现在回头来看这场十一天的重写是我职业生涯里最累、也最清醒的一段日子。如果你也在考虑“重写一套核心系统”或者正在评估手里的老代码到底值不值得救这篇文章里写的这些思路和坑应该能帮你少走很多弯路。我不打算讲什么理论就是纯实测记录怎么在开干之前把代码“称重”、怎么把时间账算明白、怎么切模块才能不把老毛病全带过去、以及那几件差点让我们整个项目翻车的事。1. 这活儿不是冲动接的先搞清楚“50万行”里有多少是废话重写最大的诱惑和陷阱都是同一个你以为自己面对的是几十万行需要逐行翻译的代码但实际上绝大多数老系统里真正需要保住的“业务语义”远没有那么庞大。我们这套老系统是公司在不同阶段由不同团队拼出来的一个订单核心链路。最早一批代码可能是八年前写的中间换过好几拨人有人喜欢把新功能写成独立类再复制一遍老流程有人注释里写着“这个别动动了线上出事”还有人干脆把已经下线的活动逻辑留在代码里只通过配置开关关掉入口。这些东西堆在一起从Git仓库统计出来的Java文件加上XML配置、SQL脚本、模板文件确实超过了五十万行。但五十万行不等于五十万行有效逻辑。1.1 我用三种方式给代码“称重”写代码的人对“行数”往往有一种盲目的敬畏但重写项目如果被行数吓住第一步就已经输了。我在这几天里用的第一个工具是基础的代码统计脚本把整个仓库拉下来跑一遍按目录统计行数分布。这里给一段可以直接抄的参考命令# 按目录统计 Java 代码行数忽略空行和注释 find src -name *.java | xargs wc -l | sort -nr | head -30 # 分别统计注释行和非注释行粗算 find src -name *.java -exec awk BEGIN{c0;b0} {if($0 ~ /^[[:space:]]*\/\*/){inblock1} if(inblock || $0 ~ /\/\//){c} else {b} if($0 ~ /\*\//){inblock0}} END{print FILENAME: blankb commentc} {} \; | sort -t -k2 -nr | head -20第一轮统计下来基本上能看出哪些目录是真正在跑的链路哪些目录是历史沉淀。但静态度量只是热身真正有价值的两个动作是链路追踪和静态引用扫描链路追踪把线上日志里的实际调用链拉出来找出真正被触发的接口和方法和代码仓库里的声明的类做对比。这个过程用 Arthas 或者 SkyWalking 都能做我们当时直接对着线上环境跑了半小时的调用采样。静态引用扫描用 IDE 的分析功能加脚本把所有“没有任何地方调用”的 public 类和方法列出来。很多团队为了好测试写了一堆没有被接线的工具类这些通常是行数虚胖的最大来源。1.2 盘点结果让我有了底气真实的盘点结果很有戏剧性。五十万行里代码类型估算规模处理策略线上真实链路的核心逻辑约22万行必须迁移且语义不能走样历史活动遗留但已下线的逻辑约12万行只保留入口占位逻辑不迁移重复实现/复制粘贴变体约8万行合并同类项迁移时统一收敛测试代码、工具类、配置模板约8万行按需抽用不整体迁移换句话说真正需要守住的是二十来万行而且这二十来万行里有大量是数据表操作和状态机流转。如果一开始就抱着“五十万行逐行翻译”的心态十一连国内都不可能够。重写的第一步永远是判断哪些是业务必须保留的“语义”哪些只是历史遗留的“噪音”。2. 11天的时间账不靠硬扛靠的是在动手之前做减法很多团队听到“重写几十万行”第一反应是“要不要再给一个月”而我们当时只有十一天前提很现实老系统所在的运行环境马上要整体退出中间整整两周的切换窗口是业务方和运维一起锁死的错过这个窗口再想找这样低流量的切换期至少得等到下个季度。时间紧不是最可怕的最可怕的是时间紧还什么都想干。后来我总结下来能压出这十一天靠的是四件事需求冻结、阶段切割、双班制、随时可回滚。2.1 需求冻结是把双刃剑重写期间的业务需求冻结不是靠嘴说是要有书面记录的。我们当时拉了一次多方会明确了一条规则这十一天里老系统那边只做故障修复不上任何新业务功能新系统这边只做迁移兼容不做新需求设计。任何一方想突破需要我、业务负责人和架构负责人三方签字。这条规则在最开始看起来有点不近人情但事后证明是救命的。我记得第三天下午运营同学临时提了一个报表加字段的小需求放到平时半天就能改完在重写期间如果放进去会牵动整个数据模型的迁移设计。我们当时顶住了把需求登记到冻结清单里在切换完成后的第二周才补上。如果重写过程中需求还在随便变更那别说十一天五倍时间都收不了尾。2.2 四个阶段的切割和每日交付物十一的天计划我切成了四段每一段都有硬性的交付物阶段时间核心交付物第1天~第2天盘点与骨架核心链路清单、前后端/DB契约表、新系统骨架仓库第3天~第7天主体迁移订单主流程、支付回调、对账处理全部可用双跑对比通过率≥95%第8天~第9天外围清理报表、运维脚本、后台管理功能迁移异常场景补测第10天~第11天切换演练预发验证、灰度切换、回滚方案演练、正式切换每天晚上的固定动作只有一个对照契约表检查今天的接口对拍结果通过的标记合并不通过的列出责任人和预计解决时间绝不把问题拖到第二天早上。2.3 关于人月神话和干活节奏十一万二十万行有效代码的迁移不是一个人在战斗。我们组了一个七人的临时小队按业务模块拆成三组一组做订单和支付主链路一组做商品和库存还有一组专门负责对拍脚本、数据迁移和公共组件。我自己没有固定归属每天上午在各组转一圈看阻塞项下午集中处理跨组依赖晚上盯对拍结果。不要迷信“人月神话”那套说辞就觉得人多没用其实十一的时间里人多有没有用取决于任务可不可以并行。只要模块边界切得够清楚交付物定义得够明确三组并行是可行的。真正不能让并行的是底层架构和数据模型这部分我坚持只有两个人碰——一个是我一个是负责数据库的老王其他人只能在上面加业务代码不许动地基。3. 迁移不是翻译模块怎么切才不至于把老问题全带过去重写一个系统最忌讳的就是拿新语言、新框架去逐行翻译老代码。你把 Java 换成 Go把 Spring 换成自研框架但如果模块边界还是老边界、调用关系还是老关系、数据读写还是老套路那你只是在给同一套问题换一层皮。我们定的原则是按业务闭环切不按代码目录切。老系统是典型的三层架构controller/service/dao 一层套一层如果照着这个结构写新系统那新系统生下来就是个微缩版的老系统。我们花了大半天时间把老系统的所有接口按“业务场景”重新归了一次类例如“用户下单”这个场景老代码里可能会碰用户余额、商品库存、优惠券、订单号生成、消息发送五个模块。新系统里我们就以“订单创建”这个事务边界为核心把所有相关操作收敛到一个服务域下面对外暴露的仍然是兼容老接口的 HTTP 入口但内部已经完全是新的组织方式。3.1 契约先行先定接口再写实现接口兼容是这次能快速切换的生命线。我们在动手写第一行业务代码之前做了一张接口映射表把老系统每个对外接口的请求参数、响应结构、异常码、超时行为全部列出来。这里所谓“对外接口”不只是写给前端的还包括回调第三方、定时任务触发生成的消息、管理后台的内网接口。这张表的维护贯穿了整个过程每天更新一次。举个例子// 老接口POST /order/v1/create // 请求体{userId:1001,skuId:A200,count:1,source:app} // 响应体{code:0,data:{orderId:NO20240001}} // 新接口与老接口完全一致内部事务边界收敛到 OrderDomain 服务 func (s *OrderServiceImpl) CreateOrder(ctx context.Context, req *CreateOrderReq) (*CreateOrderResp, error) { // 1. 校验用户与商品 // 2. 检查库存并锁定 // 3. 校验优惠券 // 4. 生单并落库 // 5. 发出订单创建事件 }这张表最大的作用是让三组人并行时不打架。A组做订单主流程B组做通知回调谁也不用猜对方接口长什么样都对着同一张表开发。接口兼容做得越早后面的联调成本就越低。3.2 老逻辑里有些“味道”是动不得的重写期间最容易犯的错是顺手“优化”老逻辑。比如老代码里有一段在订单金额计算时对浮点数做四舍五入的怪逻辑明明看起来可以换成更合理的 decimal 算法但如果真的换了对拍的时候就会发现结果差了 0.01 元。这类问题在交易系统里不是小事。我们定了一个底线迁移的第一目标是把老系统的行为原样搬到新系统哪怕是怪逻辑也要先做到“结果一致”。至于这个逻辑是不是该优化记录到一个“技术债清单”里切换完成后慢慢改。商业系统最怕的是重写之后行为变了用户没感知却在对账时发现资金对不上。先把行为复刻再追求设计干净这是顺序问题也是原则问题。4. 真正让对拍通过的是双跑和逐项 Diff有人可能会问新系统写完了怎么证明它和老系统“一样”光靠测试用例覆盖远远不够我们当时靠的是双跑对拍在预发环境把老系统和新系统同时启动用构造好的流量分别打两个系统然后把两份结果落库到一张 Diff 表里逐项比对。4.1 双跑对拍的架构设计双跑听起来简单但坑不少。最核心的一点是不能让两个系统同时去改同一个数据库否则你根本分不清结果差异是谁造成的。我们的做法是老系统继续读写正式环境的核心库只读一个从库副本新系统则连接一个独立构建的影子库两边接收的是同一份请求流量各自独立落库。对拍程序定期扫描两边数据库里的订单状态、金额、消息记录生成差异报告。对拍脚本的简化思路类似这样type DiffResult struct { BizID string json:biz_id FieldName string json:field_name OldValue string json:old_value NewValue string json:new_value DiffType string json:diff_type // inconsistent / missing / extra } func CompareOrder(oldDB, newDB *sql.DB, bizID string) (*DiffResult, error) { oldOrder : loadOrder(oldDB, bizID) newOrder : loadOrder(newDB, bizID) // 逐字段比对金额字段统一转 integer 分做等值比较 if !orderEquals(oldOrder, newOrder) { return buildDiffResult(oldOrder, newOrder), nil } return nil, nil }4.2 对拍过程中自曝的问题类型双跑跑了三天暴露的问题远超预期这也验证了靠人肉经验检查代码根本不可靠。我总结了主要的几类时区问题老代码里取“当天零点”用的是服务器的默认时区新系统用了 UTC 标准换算结果批处理任务的对账日期全部偏了一天。这类问题光看代码很难发现双跑结果一出来就露馅了。Float 精度问题金额字段老系统是 Java BigDecimal新系统起初用 Go 的 float64 计算折扣对拍差了两分钱。最后统一方案是金额全部转成整数“分”运算注入环节就做了强约束。空值语义差异老系统有个接口在用户没有优惠券时返回空数组新代码图省事返回了 null前端没感知但对拍程序会标记出来。这种小差异在上线后可能变成线上事故的伏笔。4.3 切换一刻的底气从哪来灰度切换那天我们的底气就是这一摞对拍报告。我们把通过率拉到了 99.6% 以上剩下的 0.4% 全部在差异清单里有明确解释属于老系统本身的非确定性行为。正因为有这份底账切换的时候我们才敢在五分钟内把线上流量切过去然后盯着监控数字等第一波真实订单。数据迁移上也用了同样的思路全量迁移在切换前完成切换时只补一个时间窗里的增量数据回滚开关用一个配置中心开关控制一旦新系统出现重大异常可以从网关层一键把流量切回老系统。好在我们并没有真正用上这个开关但这个开关本身就让团队敢踩油门。5. 过程中差点翻车的几个真实时刻十一天的故事如果只有“计划完美、执行顺利”那是骗人的。真实过程里至少有三次差点让整个项目一夜回到解放前每一次都不是写在方案里的技术难点而是藏在细节里的“意外”。5.1 外部系统给了个“旧版”接口文档我们新系统要对接一个支付渠道的退款接口对方提供的接口文档是两个月前的版本。开发按文档写完后联调时才发现真实接口的加密方式已经更新而且对账单下载接口还分环境。这种问题在正常节奏下顶多拉长一天工期但在迁移期间一天的工期就是整个进度表里的一个大洞。后来我们定了个规矩所有外部依赖的联调必须提前用真实测试账号跑通一遍并且以跑通结果为准不以文档为准。这条规矩在后续的很长一段时间里成了组里的标准操作。5.2 “修好”老逻辑反而搞出了大问题第三天的时候组里一个小伙子跟我说他发现老代码里一个并发扣库存的逻辑有明显问题新系统里他换成了带分布式锁的写法觉得顺手把一个隐患解决了。我听完先是一愣然后让他立刻把写法回退成和老系统一样的“先查再扣”逻辑。原因很简单那套老逻辑在并发下确实会有超卖风险但线上已经用了一个“隔一段时间做一次库存修正”的补偿任务来兜底。如果新系统改成强一致扣减虽然更正确但响应时间会明显变长而且和补偿任务的行为对不上对拍时大概率会稳定复现一堆差异。重写期间最不需要的就是“自作主张的正确性提升”行为一致才是第一优先级。5.3 人才是这次重写里最不确定的因素七个人不是每一分钟都状态在线。第五天的时候负责库存模块的同事家里有事必须请假两天当时那个模块的迁移正好在关键路径上。我们临时做了调整把他的任务拆成“接口层”和“数据层”两块数据层由老王顶着接口层由我当时做了一版回来再走查。这个经历让我对任务拆分的重要性有了更深刻的认识真正可靠的计划一定是每个任务都能在关键成员缺席时被其他人接住的计划。6. 11天之后的复盘什么情况下才适合这么干切换完成后的第一个周末我没有去庆祝而是睡了一整天。醒来之后认真做了一次复盘这次重写到底赢在哪里又留下了什么隐患。赢的地方很清晰我们现在有了一套结构清楚、能快速响应的系统线上不再需要靠运维半夜人工修数据来续命。老系统那些纠缠不清的缓存和状态逻辑被我们借这个机会彻底收敛了。这套在重写过程中沉淀下来的接口映射表和 Diff 对拍脚本后来成了团队里做任何大改动的标配工具省钱也更省心。但留下的问题也不容回避因为是业务先冻结、快速切换的节奏新系统里确实有一些“为了兼容而兼容”的历史包袱被原样保留了下来。这些债务被记录在技术债清单里等着后续慢慢清。另外这种高强度节奏对团队状态的消耗是真实的事后我做了一件很重要的事给每个人安排了一周小假期并且在复盘会上明确说了这种极限节奏是例外不是常态。如果用一句话总结这段经历的核心那就是重写系统的成功与否从来不取决于你写了多少行代码而取决于你在动手前砍掉了多少不必要的东西以及在切换前构建了多少能证明你们“行为一致”的证据。这十一条是我个人踩坑踩出来的经验如果你想挑战类似的事情不妨先把这三件事做好准确地称重你的代码、严格地冻结你的需求、认真地做每一轮对拍。剩下的就是按节奏往前跑了。