一次后端重构的经验:从混乱代码到清晰模块
接手那个服务的第一天,我对着屏幕上两千多行的GodController发愣。路由层直接操作数据库,业务逻辑里混着SQL字符串拼接,甚至还有一段用正则解析XML配置的代码,写注释的人早已离职,而那段逻辑每周都会跑崩一次。重构不是技术洁癖的发作,而是每次上线都像在雷区里跳舞——你不知道哪次改动会引发连锁爆炸。后来我花了六周,把这个项目从“哪里都能改,哪里都敢炸”的状态,梳理成一张清晰的分层地图。这段经历没有高深算法,全是糙活、脏活,以及无数次对“如何拆解复杂”的思考。
第一步,承认混乱是常态,而不是异常
代码变质不是某一天的突然堕落。需求像藤蔓一样缠绕在原有结构上,每个人都带着“顺手改一下”的善意,却导致每个类都承载了三种职责。我的第一个动作不是重写,而是把所有“不敢动”的代码先圈出来,标注它们为什么让人恐惧。恐惧通常来自不确定的副作用:某个静态类保存了全局状态,某处隐藏的定时任务依赖数据库里无索引的查询。我建了一个“危险清单”,把每次故障的根因、触发条件、关联模块都记录下来。这个过程让我意识到,重构最大的阻力不是技术债,而是对未知的恐惧——只要还存在任何一个“改了会炸但不知道为什么会炸”的角落,团队就只能继续在泥潭里打补丁。
于是我先做了一件事:给所有关键路径加日志和指标。不是那种logger.info(“处理完成”)的无意义日志,而是记录入参、出参、耗时、异常堆栈的完整追踪。当你能实时看到每一次请求经过哪段代码、花了多长时间、哪一步抛异常时,混乱才第一次变得可观测。可观测性是重构的前提,没有数据支撑的改动都是赌运气。
从“改一处动全身”到“按边界切一刀”
真正的重构从划分边界开始。我尝试用“封装变化”的思路,把服务按业务能力拆成几个独立的模块——用户、订单、支付、通知。听起来像教科书对吧?但执行起来非常痛苦。原来的代码里,用户模块会直接查询订单表,订单模块又会反向更新用户积分。边界不是画出来的,是断出来的——你必须在每个越界访问的地方做一次抉择:是迁移数据归属,还是暴露一个接口。我选择了最保守的做法:先不破坏现有功能,而是把所有跨模块访问改成通过内部API调用。比如订单模块需要用户信息,不再直接查user表,而是调用UserService.getById()。
这个过程中的阻力来自同事的质疑:“这样不还是内部调用吗?和直接查表有什么区别?”区别在于,你给模块之间装上了感知得到的门。直接查表是穿墙而过,内部API是走正门。一旦装上这门,后续就能在门口加缓存、加权限校验、加事件通知。更重要的是,当某个模块的底层表结构要改时,影响范围被限制在那个模块内部,而不是波及整个依赖网络。两周后,我们第一次实现了“只改订单模块代码而不动其他模块”的上线。那个版本发布后,监控曲线平稳得像一条死线,团队群里安静了三分钟,然后有人打了一行字:“原来上线可以这么轻松。”
那些藏在地毯下的“神隐代码”
拆模块时,我遇到一个极端案例:有一条支付回调的入口,它先从请求头里取一个自签名参数,然后校验有效期内是否被调用过,再拼接一串加密字符串去反查数据库里的流水号。整个过程没有任何注释,方法名叫process,参数是两个String。我花了整整一个下午,靠全局搜索调用点、翻Git历史、查看测试用例(根本没有),才拼凑出它的真实作用——用于处理第三方支付的重试通知。最可怕的代码不是写得烂的代码,而是没有人知道它到底在干什么的代码。
我把这类“神隐代码”单独拉出来,建立了一个“技术债清算表”。表里每条记录包含:这段代码的真实用途、当前依赖方、风险等级、建议重构方式。有的直接删除(历史遗留的死代码),有的提取成独立服务(比如那个支付回调),有的则重写为明确命名的函数。给代码准确的命名,就是给它一个合法的身份。当process变成handlePaymentRetryNotification后,即使内部逻辑再复杂,后续维护者至少有了一个正确的起点。重构不是把所有代码都写成最优解,而是让每一个存在的代码都能被追溯到它存在的理由。
依赖反转:把“高层依赖低层”倒过来
分好模块后,我发现一个新的混乱源:业务层直接依赖具体的第三方SDK或工具类。比如订单服务里到处是HttpClientUtil.post(),支付模块里直接new了一个OkHttpClient,通知模块依赖短信渠道商的API类。一旦供应商要升级SDK,或者切换渠道商,就要在全部调用点做改动。这种依赖关系像地底下的电缆,表面上整齐,实际上缠成一团。
我用依赖注入和接口定义来扭转局面。给每个外部服务定义一个“端到端”的业务接口,例如SmsSender、PaymentGateway、InvoicePdfGenerator。业务层只依赖这些接口,具体实现放在一个独立的infrastructure适配层。这个过程听起来简单,实际却需要勇气——因为你要敢于对现有的调用方式说不,敢于为了一个接口去改动几十个文件。但收益立竿见影:我们把短信服务从阿里云切换到腾讯云,业务层零改动,只是替换了一个Bean实现。架构的优雅不在于设计时的华丽,而在于变更时的那种风轻云淡。
测试:重构的安全网,也是重构的地图
如果不提测试,任何重构经验都像只穿了一只鞋。我们项目原有的测试覆盖率为8%,关键路径几乎没有。重构的第一步,我要求团队把每个模块最核心的三条成功路径和两条异常路径写成集成测试。不是为了追求覆盖率数字,而是为了让重构过程中每次改动都有一面镜子。测试不是在验证代码正确,而是在记录当前行为——有了这些记录,你才知道自己是否破坏了什么。
写测试的过程本身就能暴露混乱。如果一个函数难以测试,通常意味着它耦合了太多东西。比如一个pay()方法内部直接读取配置文件,又写数据库,还调外部API——为了测试它,你得Mock掉一切。我拆出第一步时,把“可测试性”作为设计的硬指标:每个方法要么是纯函数,要么只依赖注入的接口。当这个指标慢慢落实,代码的结构自然趋向于清晰。重构后期,我们跑一次完整测试只要4分钟,但正是这4分钟,让团队敢于双击文件,删除几十行废代码而不手心冒汗。
删代码比写代码更需要勇气
清理死代码是重构里最被低估的部分。我统计了一下,重写后的服务里大约有35%的代码是原系统留下的“僵尸代码”——从未被调用,但所有人都知道它们的存在。为什么没人删?因为怕删错了,怕某个反射或动态调用偷偷引用它。我们花了几天时间给所有方法加了调用统计日志,在灰度环境跑了一周,然后把零调用的方法全部移进一个deprecated包,再跑两周,确认无影响后彻底删除。删掉一万行代码的爽快感,远远大于写一万行代码的成就感。
但更微妙的是“看起来有人用,其实没人知道怎么用”的代码。我们有一个Excel导出工具类,支持十几种格式配置,实际产品只用一种。它本身写得并不烂,但它的存在让新人在“用工具类”和“直接构造对象”之间犹豫不决,产生了大量重复的包装层。每一个不必要的抽象,都是一种认知税——你每多读一层“可能有用”的封装,就损失一次理解核心逻辑的机会。所以后来我们形成一条铁律:新代码若超过两周未被使用,就移出主干目录。清晰不是靠堆积设计,而是靠持续修剪。
模块稳定之后,再谈性能优化
重构进行到第四周,模块已经能独立部署、独立测试。这时我们才开始做性能优化。在混乱阶段做的性能优化,往往只是给怪物穿上更快的靴子——比如把某个频繁查询加个缓存,但没解决查询本身绕了三个模块。等到模块清晰后,性能问题变得一目了然:某个订单列表接口,先循环调用用户服务获取用户名(N+1),再调支付服务检查状态(又N+1),最后拼装返回。在清晰的结构下,我可以直接给用户服务加一个批量查询接口,把N次网络调用改成一次。
清晰的结构不会让性能自动变好,但它会让性能瓶颈显形。以前面对一个慢接口,你知道是慢查询?是序列化?是网络延迟?还是业务算法太复杂?只能猜。现在,你能顺着调用链一层层看下去,每一步的耗时都记录在案。有一次我们发现一个“统计当日营业额”的接口慢到8秒,顺着调用链发现竟然是循环调用一个“获取全部订单”的接口,然后在内存里做筛选。业务代码没变,只是结构从“数据库里算”改成了“服务里算”,但改完结构后,这个错误就无处躲藏了。性能问题的本质,多数时候是结构问题。
重构期间如何活下来:兼容、灰度、回滚
重写与重构不同,尤其在后端服务里,你不能让业务停下来等代码好转。我们的策略是“蠕动式重构”:每次改动都不改变对外接口,不改动数据表结构,只是内部模块重组。所有重构分支都独立合并到主干,但保留一个“兼容层”开关——如果新模块出了哪怕一点异常,可以通过配置中心一键切回旧逻辑。这个开关在重构期间救了我们三次:一次是缓存同步逻辑漏了更新事件,一次是分布式锁的key命名不统一,最后一次是新模块对多租户数据的过滤条件写反了。每次回滚都让我们反思:“为什么测试没发现?”然后补上对应的回归用例。
重构失败不可怕,可怕的是失败了回不去。所以每完成一步,我们都打一个可部署的标签,记录这次改动涉及哪些模块、哪些依赖、哪些环境变量。项目重构到第五周时,主干的编译时间从90秒降到25秒,启动时间从40秒降到12秒。这个数字变化直接提升了团队的迭代频率——你不再因为“改代码要等半天”而拖延小修小补。开发体验的改善,最终会反馈到代码质量的改善上,因为人们更愿意在小问题刚出现时就修掉它。
重构真正结局:不是代码变好,而是团队敢于改代码
现在回想那六周,让我印象最深的不是最后的架构图有多么漂亮,而是重构第三周的一次普通代码评审。一个新同事提交了一个改动,要在支付模块里加一个“根据金额四舍五入”的逻辑。放在以前,他能找到的位置可能有三处:一个散落在工具类里的roundMoney,一个在订单实体里的getTotalAmount,还有一个藏在PaymentController里的私有方法。而现在,他很自然地打开MoneyUtils,写完测试,然后退出。他不需要问“这个逻辑我该放哪”,因为模块的边界已经把答案写出来了。
好的架构不是让聪明人更容易写好代码,而是让普通人不容易写坏代码。当你的团队不需要靠记忆和默契来维持模块纪律,而是靠代码本身的走向就能做出正确选择时,重构才算真正结束。那些看起来枯燥的分层、接口、依赖规则,最终变成了一种无声的引导——它不告诉你怎么做,只告诉你哪里容错、哪里严谨、哪里需要小心。直到这时候,混乱不再是一种宿命,而是一种可以随时被修正的状态。于是我终于明白,重构的本质,不是把旧代码变成新代码,而是把“不敢动”变成“敢动”。这个转变,远比任何技术方案都来得金贵。
