代码仓库里那套老模块已经七岁高龄了。它用着十几年前流行的分层架构,Service层里躺着三千行的上帝类,方法命名从doProcess到processData2再到handleDataFinal,像极了在代码里玩俄罗斯套娃。每次加需求,开发都得先在IDE里全局搜索调用链,生怕漏掉某个藏在工具类里的隐式状态。真正让我下决心的,不是洁癖发作,而是一个生产事故——某个并发场景下,由于共享的可变静态Map没有做同步控制,导致线上订单数据串了。那个周五晚上,我盯着监控面板上飙红的错误率,忽然意识到:修修补补已经不能解决信任危机了,这个模块需要的是一次彻底的重生。
重写之前,先回答三个“为什么”
很多人把重写当成一场技术狂欢,但从老系统继承而来的业务逻辑里,藏着无数个“当初为什么这么写”的暗坑。重写最大的敌人不是旧代码太烂,而是我们对业务真相的一知半解。我做的第一件事不是新建Spring Boot项目,而是把旧模块的每个方法按调用频次和异常日志导出成一份清单,和产品经理、测试同事一起过了一遍“哪些逻辑现在还在用、哪些已经死了十年”。这个过程枯燥却关键,它让我发现:有四成的代码——包括那些看起来“高大上”的策略模式实现——实际上从未被任何入口触达过。它们是技术债的利息,早已逾期,却依然躺在代码里增加每个后来者的认知负担。
我也问了团队里的老成员:这个模块为什么会有两个含义几乎相同的状态字段?答案是他也不清楚,只记得当初为了兼容某个银行接口而临时加的。这种“不知道为什么要存在”的代码,正是重写时最危险的陷阱。如果你不能解释旧代码中每一个分支和异常处理的意义,那你就还没有资格动手重写。我让团队用一周时间,把旧模块的所有魔法数字、空指针保护、特殊字符过滤都标注了来源:有的来自监管要求,有的来自某个大客户的定制需求,还有的纯粹是老板拍脑袋的结果。做完这步,我才敢在白板上画出新的领域模型。
那段日子我们反复问自己:这个模块存在的本质是什么?是用状态机管理订单流转,还是用模板方法抽取公共流程?是处理不同支付渠道的差异,还是屏蔽底层通讯协议的变化?最后我们达成共识——这个模块的真正核心是“流程编排与状态一致性”,而不是某段具体的计算逻辑。明确了核心之后,那些繁琐的如果判断就能被重新组织成更清晰的结构。重写不是拿新语言重抄一遍旧代码,而是借着重写的机会重新审视问题域,把埋在实现细节里的本质抽象出来。
第一次妥协:我选择了“不那么干净”的落地方式
理想很丰满:用EventSourcing解决状态溯源,用DCI架构划分角色行为,用响应式流处理高并发。但现实是团队里除了我,没人写过Reactor,生产环境的技术栈也不允许引入太多新的中间件。技术选型不是给你自己选,是给半年后接手你代码的人选。于是我在第一版设计里砍掉了事件溯源,保留了关系型数据库,用乐观锁加版本号来控制并发。有人会嘲笑这不够摩尔,但我知道,在这个业务场景下,传统的事务加锁已经足够保证数据一致性,而事件溯源带来的回放复杂性,对我们的审计需求是过度设计。
另一个妥协发生在对象模型上。旧系统用的是贫血模型,Service层包揽了所有业务逻辑,实体类只有getter/setter。我原本想彻底转向充血模型,把业务规则下沉到领域对象内部。可仔细一分析,发现这个模块的业务规则和外部依赖耦合极重:每条规则都要查配置表、调用远程服务,甚至要读取当前登录用户的权限上下文。强行充血只会让领域对象背上无休止的基础设施依赖,到时既测不了单元,也讲不清边界。我最后采用了折中方案:核心状态变更相关的规则内聚到实体中,而涉及外部协同的流程逻辑保留在应用服务层。这不算漂亮,但每个类都讲得清楚自己的责任。
代码结构上也做了取舍。我放弃了原先单模块的Maven工程,拆成三个子模块:domain(纯Java,无Spring依赖)、application(用例编排)、infrastructure(数据库和消息队列实现)。分层这件事,知易行难。很多人分层只是换了个包名,依赖方向照样乱成一锅粥。为了让依赖规则名副其实,我写了个自定义的ArchUnit测试,在CI里强制检查:domain层不能引用infrastructure的任何类,application层只能依赖domain接口。刚开始成员还经常编译不过,后来慢慢形成了肌肉记忆。这种强制约束比代码评审里的口头提醒有效得多。
那些Java特性帮了大忙,也坑了不少人
重写过程中,我认真用上了Java新语法。record类替换了那堆lombok注解,switch表达式消除了好几个“Fall-through”陷阱,Stream的toList让收集器不再啰嗦。Java最迷人的地方,不是它能变出飞碟,而是它在保守的版本演进里逐步放下历史包袱。用Map.of构造不可变集合替换Collections.unmodifiableMap那段重构,至少消灭了三个潜在的NPE来源。团队在代码规范里约定:返回值一律不可变,入参集合不做防御性拷贝但必须在注释里标明所有权。这种显式的约定,比靠自觉健壮得多。
真正让我反思的是Optional。我用它包装了所有可能为null的查询结果,看起来很美——直到有人对我说:“你这个接口返回Optional,但调用方根本不知道该怎样优雅处理它。”我回看代码,确实满屏的.orElseThrow(() -> new BizException("xxx不存在")),本质上和过去if(obj == null) throw没有区别,只是多了一层盒子的仪式感。Optional的正确用法不是消灭null,而是强迫你在类型系统层面表达“可能缺失”这一语义。可Java的Optional本身也是对象,忘了检查照样NPE。我在这次重写里学会了只在“公有接口的返回值”和“集合内元素提取”两层使用它,内部方法的局部变量绝不滥用。
另一个坑是CompletableFuture。我为了提升批量查询性能,把十几个远程调用写成异步并行,然后用allOf().join()等待。上线一个礼拜后,观察到了间歇性的线程饥饿。排查下来,是因为公共线程池被某个第三方SDK的阻塞IO给占光了。异步编程的最大骗局是:你以为加了async就快了,其实只是把等待挪了个地方,顺便把问题藏进了线程池。后来我改用有界线程池加超时控制,并且对聚合任务增加了数量限制:超过二十个查询就分批,绝不无限并行。这个教训告诉我,语言的特性永远是好用的,但用不用、用多狠,取决于你对运行环境的敬畏。
重构中的“业务迁移”才是真正的硬骨头
代码迁移容易,数据迁移和业务补偿才见功力。这个模块维护着订单的草稿、待审批、处理中、成功、失败、已撤销六种状态。旧代码在状态变更时散落着if(order.getStatus()==2)这样的硬编码,甚至有两处地方用字符串“success”和数字“3”混用。我在新模型里定义了枚举OrderStatus,并给每个状态转换写了校验逻辑。但真正惊心动魄的是存量数据:旧的“处理中”状态在历史上曾经表示过两种不同的含义,光靠状态码无法区分。我翻遍了归档的数据库备份,比对操作流水和日志,最后不得不在新模型里增加一个sourceTraceId字段,用来关联那批“历史遗留处理中”的订单,让它们在后台任务里逐步补偿终结。
每一步数据迁移都像是在给飞行中的飞机换引擎,你必须准备好在万米高空的迫降方案。我们采用双写策略:新版代码上线后,先跑灰度流量,同时旁路把关键操作同步到旧模块的日志表里,以便随时回滚。为了对比新旧模块的结果一致性,我在旁路里记录了request和response的摘要哈希,每天跑一边比对任务,找出所有不一致的case。这个过程极其折磨人——大部分不一致是因为旧代码的bug被新代码修正了,比如原来漏判了某种优惠组合,现在正确计算了。还有小部分是新代码自身的问题,多数发生在边界条件下的空值处理。我们花了两周时间,把对账差异从每天几百条缩小到个位数,才敢逐步放量。
回滚预案不是摆设。我们在发布平台准备了三个开关:功能开关、读流量切回、写流量切回。最成功的重写,是当用户毫无感知时换掉了底座;最好的回滚,是永远用不上的回滚。但人算不如天算。灰度到百分之三十的一个晚上,我们收到告警:某个渠道的回调处理耗时上涨了三倍。定位后发现,新代码里我用了一个分布式锁来防止重复回调,原本以为redis锁的开销可忽略,但锁的key设计不够均匀,导致同一个订单的多次回调全部挤到了同一把锁上,产生了串行等待。我快速调整了锁的粒度,变成“订单号+回调事件类型”的组合,并加了localCache做首屏缓冲。凌晨一点半,监控曲线终于恢复平滑。那天晚上我对着窗外想:为什么重写一个模块这么难?因为旧系统里每一个臃肿的设计,都曾经解决过某个未被文档记录的突发问题。
让测试替我说真话
老代码没有单元测试,集成测试也只是冒烟级别的“跑一遍不报错”。重写给了我补齐测试的机会。但我不敢写那种脆弱的、只校验内部调用的单元测试。测试不是用来证明代码能跑,而是用来记录设计决策,让后来的修改者看见“这里为什么不能轻易乱动”。我鼓励团队用真实业务用例驱动开发,每个需求先抽象出几条Given-When-Then场景,再写实现。测试名就用完整的句子描述行为,比如shouldApproveOrderWhenBalanceSufficientAndRiskPassed。这些测试后来成了新团队的活文档——比什么Swagger和Word方案都直观。
覆盖率是个容易骗人的指标。我看到很多项目把覆盖率刷到90%,却只测了happy path,异常分支全是空的。我做了个简单的规则:凡是在catch块中吞掉异常的地方,必须写一个触发该异常的测试;凡是可能出现null的返回值,必须写一个“缺失时表现如何”的测试。这两种测试通常没人爱写,但它们是生产环境真正的护身符。重写期间我们遇到过一个特别隐蔽的bug:在新代码里,我把BigDecimal的除法默认精度写成2,导致某个渠道的费率计算在极端情况下损失了0.001元。单元测试当时没暴露,因为测试数据都是整数倍。后来在属性测试库jqwik的辅助下,生成了随机的费率组合,才在回归时捕到了这个误差。所以,一条失败的分支测试,价值胜过一百条成功的粗粒度测试。这也是我后来坚持在做代码评审时,先看测试再看实现的原因。
但测试也带来了新的烦恼。重量级的SpringBootTest启动太快,导致一次全量测试要跑十几分钟,开发者只想跑单测却被集成测试拖累。我重新划分了测试金字塔:纯领域逻辑的测试用JUnit5+AssertJ,不加载Spring上下文;涉及持久层的测试用Testcontainers起一个真实PostgreSQL;真正的外部调用全部mock掉,在应用层只做契约测试。分层后,单测速度回到秒级,CI流水线里把集成测试和冒烟测试分开跑。工程能力的提升不是靠某一个炫技框架,而是靠把每种测试放在合适的位置上运转。如今这个新模块的测试全量跑完不到五分钟,而旧模块根本没有自动化防线,高下立见。
砍掉的“优化”,反而让我明白什么是必需品
重写过程中,我本打算趁手引入Redis缓存热点订单数据,但在一番推演后,决定不为当下的性能瓶颈预支复杂性。这个模块的写操作本就占八成,读操作多发生在后台的运营查询场景,而他们的查询条件五花八门,缓存命中率预料会很低。与其做一份徒增缓存和数据库一致性维护负担的缓存层,不如用数据库字段的索引优化和查询超时限制来硬扛。如果未来真的出现高并发读需求,那时候再引入CQRS也不迟,而不必现在埋下双写不一致的地雷。这让我反思「性能焦虑”——互联网上到处是秒杀案例,好像不加Redis就不好意思说自己是Java后端。但回归到业务现实,绝大多数模块的压力根本到不了需要微服务+缓存+消息队列才能撑住的程度。每次想引入一个“先进”组件前,我都问自己一句:没了它,系统会不会出问题?会死吗?如果不会,那就先不用。
真正的业务瓶颈反而是数据一致性。我们在代码里用了Spring的@Transactional管理数据库事务,但有些操作涉及调用外部接口(如通知业务方、发送Mq消息),“事务内调用外部系统”是很多故障的温床。旧模块里就出现过:本地事务回滚了,但消息却因为没在事务里而发出去了,造成消费者处理了不存在的订单。我在新模型里改用事务发件箱模式:把需要发出的消息先和业务状态变更写入同一张发件箱表,事务提交后由后台任务轮询发送,确认成功后再标记已发送。这个模式带来了几个额外的好处——消息可以重试、可以追踪,还能在紧急情况下人工干预。有时候,看起来“绕远路”的架构,反而是最省心的近路。我希望每个重写者都能明白:重要的不是你能用多少新框架,而是你能为业务守住哪些不可妥协的底线。
来自代码评审的争吵与共识
重写不是一个人的战斗。团队里我们每天花四十分钟做代码走查,每次走查都围绕“这段代码是否讲清了业务规则”展开。有一次新同事在application层里直接调用了orderRepository.save(order)之后紧跟着又调了一个orderDetailRepository.save(detail)。我说这里应该由聚合根统一控制一致性,他却反驳说“事务本来就是原子的,用户订单和明细表分两次保存有什么问题?”我让他去领域模型图上画一下外卖订单和订单明细的关系,他终于明白:对业务而言,订单和明细必须作为一个整体变化,如果允许在应用层随意分割操作,以后就会出现“主表成功但明细失败”的脏数据。代码评审的价值,不在于告诉别人“你写得不对”,而在于把隐藏在实现背后的思维模型拉到同一张桌面上。那次争吵之后,我们把聚合边界画在了团队内的wiki上,每个人改动前先看边界,再动手。
我们也在评审中发现了过度设计的苗头。一个同事试图给状态机引入状态模式,为每个状态创建一个类。我问他这个模块的状态转换路径是固定的还是动态的?他说:“未来可能加状态。”我再问:“你能举出一个真实的业务需求,需要客户端在运行时动态扩展状态吗?”他沉默了。于是我们把状态机收敛成一张枚举+转换表,简单粗暴但足够用。“可扩展性”这话害了多少代码,它让开发者忙着为想象中的未来搭建抽象脚手架,却对眼前真实的调用链无动于衷。我的原则就是:不存在的需求不需要留接口。代码里的设计应当是写代码时真实思考的产物,而不是对某个未知未来的谦卑预演。
有时候,业务方也会被拉进评审。当我们为某个异常处理分支发生争论时,最直接的方法是问产品经理:“如果这个操作超过30秒还没成功,用户最合理的体验是什么?”产品说可以提示“处理中”,后台稍后通过异步任务补偿。于是一场关于“要抛异常还是返回重试码”的争论戛然而止。技术决策最终的裁判是用户的真实感受,不是技术社区里的最佳实践。我让团队把这类从业务反馈引发技术决策的case记录下来,形成了一份“决策日志”。后来,每个新人遇到类似问题,先查决策日志,比再讨论一个下午高效得多。
上线之后的反思:重写本质上是一次风险投资
新模块上线运行三个月后,线上故障率下降了至少80%,平均响应时间缩短了四十个百分点。代码行数从旧版本的八千行减少到五千行左右,但这并不是最重要的收获。真正让我意识到重写价值的,是一次新需求的交付流程:过去需要两天时间梳理旧逻辑再小心翼翼地拼代码,如今只花了半天时间,在状态机新增一个节点并补充一条转化规则就完成了。重写不是炫技,不是把代码搬到更新的框架上以求心安,而是为了让每一次需求迭代都能踩在坚实的、可理解的地基上。代价是重写耗费了约三个人月时间,期间团队承受了与业务方解释“为什么一个看起来没变的模块这么久”的压力。若从纯粹的财务角度看,这笔投入也许需要一年多的维护成本节省才能回本。
有人问我,以后遇到一个烂模块,还会选择重写吗?我的回答是:会,但不会轻易动手。重写前必须回答“业务规则是否完全已知”和“失败后是否有退路”这两道题。如果业务逻辑已经像一锅烧糊的粥,且没有任何文档、测试或专家记忆可以澄清每一粒米为什么在锅底,那重写的风险和直接写一个全新的功能不相上下。而如果团队没有足够的时间窗口和可以随时回滚的灰度机制,重写就是一次豪赌。
如果让我再走一遍这段历程,我会更早地引入契约测试和数据对账机制,而不是等到功能开发完才开始补。我也会更克制地使用Java的并发工具——新版本里我们用虚拟线程替换了大部分异步链式调用,让代码读起来又回到了同步的样子,却依然能享受高并发下的吞吐弹性。有时候最好的写法,是让读代码的人根本不需要猜测线程模型。用Java重写一个业务模块,表面上是换了语言的新写法,其实换的是我们与旧世界之间的认知契约。每一处优雅设计背后都有一滩泥巴被清理,每一个取舍背后都是一次对业务深挖的证明。我把这些反思写下来,既是为了告慰那无数个调试到深夜的日子,也是想告诉后来者:重写代码并不是浪漫的技术冒险,而是一场需要谦卑、纪律和勇气的徒步远征。走完全程,你会发现自己带的不是代码,而是一份明明白白的责任。