我至今记得第一次打开那个叫“rea”的目录时的心情。交接文档只有一行字:“老系统,别乱动,跑得通就行。”但等我真正把代码过了一遍,才发现这个看似不起眼的目录里装着的,是一套支撑着整个业务团队日常运转的批量任务调度系统——没有文档、没有测试、没有注释,只有数万行写于不同年代、风格互相打架的代码。更尴尬的是,连“REA”三个字母到底代表什么,团队里都已经没人说得清了。
这篇文章就是来复盘我接手“REA”之后做的完整重构过程:我是怎么从一堆“不敢动”的旧代码里把业务逻辑一条条挖出来,怎么在不中断生产的情况下完成迁移,以及新系统上线第一周踩过的六个真实坑。如果你想了解“老项目重构”到底难在哪、该怎么拆解、常见问题有哪些,这篇应该能给你一个比较完整的参考答案。
1. 立项始末:一个只有名字的REA目录,和没人敢动的老系统
接手之前我已经被提醒过:“这活不难,稳住就行。”但真正开始看代码后,我发现问题比想象中大得多。这个REA系统不是普通的小工具,它负责的任务包括定时数据同步、批量报表生成、内外系统的对账推送、各种夜间批处理作业。随便一个环节出问题,第二天早晨业务方的反馈邮件就会塞满收件箱。
最让人头疼的是代码本身。整套系统跑在一个单机JVM进程里,用的还是早期的Servlet架构,通过一个老旧的定时任务框架触发各个环节。任务之间的依赖关系不是写在配置里,而是散落在各个类中通过硬编码调用串联;状态信息大量保存在进程内存中,一旦进程重启,所有运行中的任务立刻“失忆”。更致命的是,这套系统没有任何日志规范,很多关键路径只在 catch 块里打了半句话,出问题时根本无从查起。
我干的第一件事不是改代码,而是把整个项目的目录结构、类文件列表、部署脚本全部打印出来,贴在工位旁边,然后把项目代码从头到尾读了三遍。这个过程大概花了两周,但非常值得——我现在面对的不是一个技术问题,而是一个“考古”问题。你得先知道这些东西当初是被谁、在什么场景下、为了解决什么问题写出来的,才谈得上改造。
三周之后,我给业务负责人和团队成员拉了一次对齐会,在会上明确了几件关键的事:
- REA 系统长期处于“能跑但没人懂”的状态,业务风险很高
- 任何新需求都不敢排期,因为改一处可能崩三处
- 我们需要一次结构性的重构,但要保证业务无感知、数据不丢失、历史任务可追溯
那次会议之后,领导终于点了头:“重构可以,但生产不能停。”这句话成了后续所有方案设计的首要约束条件。
1.1 从代码反推业务意图:REA到底在做什么
由于原文档完全缺失,我重建“需求认知”的路径只有一条:精读代码,把每一段执行逻辑翻译成人话,再找业务方一条一条核对。
这个方法听着笨,实际上非常有效,因为写代码的人再偷懒,代码本身也会忠实地记录业务规则。比如REA里有一段晦涩的订单状态流转逻辑,最初看代码时我以为只是简单的状态更新,后来把多个调用点串起来才发现,它其实在维护一个“跨系统订单一致性状态机”——一个订单在A系统创建后,要等待B系统的回执,超时未回则进入人工复核队列。这个认知直接决定了后来新系统状态机设计的数据模型。
结束后我把整个项目的逻辑梳理成了一张业务流程图,每个关键函数标注了“业务意图”“触发来源”“输出目标”。拿着这张图去和业务方对了几轮,竟然发现有好几个功能业务方自己都不知道存在,比如某个凌晨三点的自动补偿任务。可见很多老系统里藏着“孤儿功能”,它们可能在历史某个时期很重要,但后来业务变了,代码没删,就一直空跑着。
1.2 为什么大多数遗留系统重构失败在第一步
这些年见过不少重构项目,我发现一开始就注定失败的,基本都是犯了同一个毛病:上来就动手改代码,却没有先建立完整的“业务基线”。
所谓业务基线,就是在改动之前,你要有一套可以对照的预期。比如某个任务每天应该产出什么数据、失败重试几次、覆盖哪些规则、影响哪些下游,这些都得先从代码里逆向出来,再形成文档。没有这个基线,你改完根本不知道自己是改好了还是改坏了,只能凭感觉。
我做一个不恰当的类比:这就像装修老房子。你不可能上来就砸墙,得先摸清楚哪堵墙是承重墙、哪根梁不能碰、管线怎么走。代码重构里,那些“承重墙”就是隐藏的依赖关系和边界条件。画出来的依赖图里,我把所有“不知道调用了谁、又被谁调用”的节点标红,标红节点就是承重墙,必须先搞懂再动手。
2. 老REA的真实解剖:断点续跑的伪装、状态黑盒与技术债的层层叠加
在建立业务基线的过程中,我对REA的几个核心机制做了深入解剖。这部分的结论直接决定了我后来重构成什么样子,所以详细展开说。
2.1 核心调度机制:内存队列 + 定时触发 + 无持久化的“假续跑”
REA的调度核心本质上是一个内存队列加定时触发的组合:定时任务到点后,把一批“待办工作项”塞进进程内的内存队列,再由几个工作线程逐个取出执行。奇妙的是,代码里明明写着“断点续跑”相关的注释,但实际追下来,这个“断点续跑”只在进程不重启的前提下成立,一旦进程重启,队列里的任务就全部丢了。重启后系统之所以看起来“功能正常”,只是因为定时器到点会重新生成一批任务,新任务把旧任务的空缺掩盖掉了。
这个发现非常重要,因为它改变了我的排查思路——以前看到“某个任务看起来没跑”,第一反应是查失败日志,现在第一反应是问:这个任务是不是在进程重启前就已经被创建,但还没来得及执行,然后随着进程死亡一起蒸发了。
我花了一天时间在测试环境复现了这个场景:模拟一个长耗时任务在运行过程中触发重启,观察重启后任务状态。结果符合预期——任务没有重新入队,数据库里也没有任何该任务的执行记录。这个“假续跑”机制后来被我列为重构的第一优先级拆除对象。
2.2 黑盒模块:无人理解的高频执行路径
双周复盘会上,业务方提到有个“每日凌晨的数据核对任务”最近经常晚完成,但又说不出具体是哪一环拖得慢。我顺着代码查下去,发现这个任务的执行路径里藏着一个名称很误导的模块,姑且叫它“数据归整器”。它每次会扫描一张上千万行的历史表,逐行判断是否需要矫正,然后执行更新。
表面上这只是个批处理程序,但其实际复杂度远超名称所暗示的:它内部有一大段嵌套了十几层条件判断的业务规则,很多判断条件来自零点几个特定日期的特殊逻辑,写代码的人加了一堆magic number。更离谱的是,这个模块没有任何日志输出,只有最终影响行数,中间跑了什么、跳过了什么,完全是个黑盒。
我做了个粗略估算,这个“数据归整器”每轮全量扫描要消耗约三分之二的批处理耗时窗口,但真正发生数据更新操作的行数占扫描总量的比例不到千分之一。这意味着每轮跑批,绝大多数时间都浪费在了无效扫描上。业务方并不知道这个情况,因为他们看到的只是“任务最终完成了,只是偶尔慢一点”。
2.3 技术债的三种主要形态
通读完全部代码后,我把REA的技术债总结成了三种形态,替换逻辑时方便分清优先级:
时序耦合:很多任务依赖“执行先后”来保证正确性,比如必须先同步A表数据,再执行B表的汇总,但这一顺序没有通过任何调度引擎约束,而是靠定时时间错开拼凑出来的。一旦某次数据源延迟,整个链路的节奏就全乱了。
隐式共享状态:多个任务通过进程内的静态Map共享中间结果,这些Map没有做并发控制,依赖“恰好只有一个线程在写”这种脆弱前提。新需求一旦改变触发频率,并发问题立刻爆炸。
可观测性缺失:没有统一的日志框架、没有埋点、没有指标收集。老开发离职之后,后续接手的人只能靠System.out输出到文件的方式排查问题,效率极低。
这三类债不能一起还,因为一次性全部推翻,风险太大。我只做了一件事:把“假续跑”和“隐式共享状态”这两个最可能引发生产事故的机制优先解决,其它债先维持原状,等切到新系统时一并了清。
3. 重构方案怎么定:先立后破、契约先行,而不是推倒重写
确定要重构之后,团队内部有过一次激烈讨论:有人主张干脆全部推倒重写,用新架构一步到位;有人强烈反对,理由是业务不能停、历史数据要兼容、各种边缘规则根本没有穷尽。
我倾向于一个相对稳的组合方案,概括起来就是十二个字:先搭新家、逐步搬货、旧家最后拆。
在实际操作中,我分成了三条主线并行推进:基础设施搭建、业务接口契约化、数据迁移对账。下面分别说明。
3.1 为什么选择“先立后破”:旧系统的不可替代性
推倒重写在很多绿色项目里很爽,但在REA这类遗留系统上几乎是死路。原因很简单:
第一,业务不可中断。REA关联着大量下游系统的数据依赖,哪怕停十五分钟,都会造成一系列连锁反应。推倒重写意味着必须选择一个“切换时刻”,然后承受那一刻的全部风险。
第二,历史数据格式复杂。老系统积累多年的数据表结构里充满隐式约定,比如某些状态字段用0表示“未开始”、1表示“进行中”、2表示“成功”,而另外一些表用Y/N表示同样的语义。不把这些历史数据完整迁移到新模型,业务方后面的报表和对账全都会乱。
第三,规则边界无法穷尽。就算我们把代码读完一遍,也不敢说已经理解了所有业务的全部细节。推倒重写意味着你必须一开始就把所有规则都搞清楚,这几乎不可能。而“先立后破”允许你在新系统里先跑通80%的常规场景,剩下的20%边界场景在并行期观察、补齐、再切换。
因此最终的技术策略是:新老系统并行运行,每天做结果对账,对账一致超过两周后,再逐步切量,最后下线老系统。
3.2 契约先行:把老逻辑打包成稳定接入层
新系统里我没有急着写业务代码,而是先把“REA对外提供的服务能力”抽象成一份接口契约。契约包括两个层面:
- 对外接口:哪些任务由外部触发,触发参数是什么,返回什么,超时和重试策略怎么定
- 任务间数据流:上游任务输出什么、下游任务消费什么、格式怎么定义
然后我写了一个适配层,这个适配层的作用是屏蔽老系统的内部实现,对外呈现成一套稳定API。老系统内部那些乱七八糟的类名、状态码、数据格式,全部收口在这个适配层里,不允许往外泄漏。
这样做的好处非常明显:在并行期,新系统调用这套API处理常规任务,老系统仍然按原逻辑跑批,两边只要最终结果一致,就说明新系统的理解是对的。不一致时,通过对比入参出参,能快速定位是我哪块规则理解出现了偏差,而不是去老代码里翻找。
适配层的代码量不小,但它把“未知”控制在了最小范围,让我可以在不修改老系统内部逻辑的前提下,先筑起新系统的地基。
3.3 数据迁移三步走:双写、对账、切换
数据迁移是重构里最容易出问题的一环。我设计的步骤很简单,但每一步都有明确验收标准:
双写阶段:老系统继续作为主要数据写入方,新系统订阅老系统的数据变更事件,同步写入新表。双写期间新旧数据表同时更新,两边结构不同但语义一致。
对账阶段:每天跑一个对账程序,对同一业务对象,对比老系统状态字段、时间戳、结果数据,输出差异清单。对账不一致的条目自动进入告警列表,人工判断是迁移转换逻辑的问题,还是老系统本身的脏数据。
切换阶段:连续两周对账零差异后,才允许把写入口切到新系统。切换时保留一段“回退期”,一旦发现新系统有问题,立刻把入口切回老系统,数据继续双写,排查修复后再切换。
这套方案最大的价值是“可回退”。每次切换都不紧张,因为我清楚知道只要入口切回去,业务就能恢复原样,风险完全可控。
4. 新REA的架构落地:稳定、可观测、可水平扩展
并行期跑通之后,我开始全力搭建新系统的主架构。新的REA不再是一个单机“黑盒”,而是一套有清晰边界、可观测、可扩展的分布式任务平台。这不是追求技术华丽,而是REA这样的基础系统必须支撑业务的持续增长,单机架构已经是天花板了。
4.1 核心链路设计:从任务触发到执行结果回传
新系统的核心链路围绕一条主线:任务触发、任务登记、执行调度、执行确认、结果回传。
任务触发来源有两类:定时触发和外部接口触发。定时触发独立做成一个调度服务,接管的表达式动态配置,不再像老系统那样散布在多个类里。外部接口触发则走统一网关,把第三方请求翻译成内部标准任务模型。
任务登记之后,所有状态立刻落到数据库和缓存里,这一步解决了老系统最大的问题——进程重启导致状态丢失。每个任务都有一个全局唯一ID,每一步执行都记录明细日志,无论何时崩溃,都可以从数据库里知道它跑到了哪一步、下一步应该是什么。
执行调度环节,我引入了一个简单的任务队列:多个worker进程订阅队列里的任务,抢到之后执行。队列具备最小能力:按优先级排序、超时重投、死信处理。
结果回传分两路:一路是把结果数据写回业务库;另一路是发送回调通知给触发方。结果回传本身也作为一个任务登记,保证回传失败时能自动重试,而不是静静地丢掉。
4.2 状态机设计:告别内存变量,回归可持久化
新系统最核心的数据结构,是一个持久化的状态机模型。我借鉴了老系统里反推出来的订单状态流转逻辑,把每个任务抽象成以下状态:初始化、待执行、执行中、成功、失败、终态重试中、人工复核中、已取消。
每个状态之间的合法转移,都严格定义在配置里。比如“执行中”只能转移到“成功”“失败”“人工复核中”;“人工复核中”只能由人工操作触发转移,任何代码路径都不能直接把这个状态改成成功。
这样做直接淘汰了老系统里那些散落各处的if-else状态判断。因为以前的状态都在隐式约定里,某段代码认为状态是A,另一段代码认为状态是B,一旦实际路径偏离,就是诡异的脏数据。在状态机里,非法转移直接抛异常,问题在最早期就能被发现。
持久化状态机的另一个好处是便于追溯。业务方问“这个任务为什么最终是失败了?”,我能查出一条完整的流转轨迹:几点几分从初始化变成执行中,执行中持续了多长时间,期间是否触发过重试,为什么进入终态。这在老系统里是不可能做到的。
4.3 可观测性三板斧:日志、指标、链路追踪
可观测性这个话题,我踩了一个深刻的坑之后才真正重视起来。坑是这样的:新系统上线第一周,某个worker节点突然CPU飙升,但我们花了一个多小时才定位到是哪个任务引起的。事后反思,就是因为新系统虽然一开始就设计了日志规范,但只在业务层面做了日志,没有做线程维度和资源维度的关联。
后来我补齐了标准的三板斧方案:
- 结构化日志:所有模块统一使用同一个日志框架,输出格式统一为JSON,包含任务ID、状态、耗时、关键参数。日志集中收集到日志平台,支持按任务ID一键搜索全链路记录。
- 核心指标:每个任务执行前打一个start指标,完成后打一个end指标,上报耗时和结果。几个关键队列的积压数量单独成指标,设置告警阈值,积压超过N条立即告警。
- 链路追踪:一个任务从生成到结束,所有环节都带有同一个traceId。无论这个任务是被谁触发、中间经过了几个队列、落到哪个worker上,只要输入traceId,就能看到全链路的时间消耗分布。
这套系统上线后,再遇到“任务慢”这类问题,排查时间从小时级直接降到分钟级。说真的,对于一个业务强依赖的基础系统来说,这笔投入非常值。
5. 实测中的意外与修复:上线第一周我踩的六个坑
架构设计再周全,落地时总会出幺蛾子。下面这六个问题全部发生在新系统切量后的第一周,每一个都差点演变成事故,也让我对“重构”这件事有了更深入的理解。
5.1 幽灵重启:幂等性只做了一半导致的任务重跑
切换后的第三天,一个对账任务在凌晨跑了两次。第一次执行没有完全成功,进入重试状态;重试时,因为没做幂等处理,把已经写入的历史表又迭代了一遍,直接把对账结果改得面目全非。
排查后确认,问题出在幂等性只做了一半:接口层面做了防重,但内部执行逻辑没有识别“同一个任务ID是否已经执行成功过某一步”。修复方案很粗暴:每一步数据写入都带上taskId和该步唯一标识,写入时如果发现已存在且状态为成功,就直接跳过。这条原则我后来推广到了所有写操作上。
5.2 慢任务拖垮线程池:工作窃取与背压策略
上线第五天,一个外部数据源接口响应特别慢,导致所有挂在它下游的任务全部阻塞在等待IO上。因为线程池所有线程都被这些慢任务占满,其他正常任务也开始排队,系统整体吞吐骤降。
修复方法是双向的:一方面给任务线程池配置了动态调整策略,当任务耗时超过阈值时,触发放弃等待直接失败,避免长期霸占线程;另一方面引入了背压机制,队列积压超过水位线时主动放慢生产者的投递速度,而不是无脑把任务全堆进队列。
5.3 时钟偏移引发的调度错乱
并行期对账一切正常,但切量之后,某几个定时任务频繁出现“早跑了几分钟”或“晚跑了几分钟”的现象。起初我以为是调度器配置问题,后来发现,这些任务分散在不同的物理机上,各机器的系统时间有少量偏移。
时钟偏移是老系统里不存在的问题——因为老系统全部跑在单机上。到了分布式架构,这个被忽视的细节成了定时任务的大敌。最终方案是统一NTP时间源,并在调度模块增加“以调度服务器时间为准”的约束,任何worker收到定时任务后,必须先校验任务计划时间和实际时间的一致性。
5.4 对账发现的脏数据:老的脏数据差点污染新的规则引擎
数据迁移阶段,对账程序最初三天几乎全红。我一度怀疑迁移程序写错了,排查后才发现,真正的问题是老系统本身就存在大量脏数据:比如同一个业务单在老系统里既有“成功”状态记录又有“失败”状态记录,两行并不互斥。
这正是遗留系统的典型特征:状态逻辑分散,导致多行同时存在时没有约束。迁移逻辑没有做去重,直接把所有记录都搬到了新系统。我在迁移脚本里增加了一层“数据清洗”,按业务规则取最新且状态最合理的记录,并为所有被清洗掉的脏数据单独建了一张审计表,确保可追溯。这次经历让我养成了习惯:任何数据迁移都不能只做搬运,一定要先做数据质量盘点。
5.5 依赖升级带来的连锁回归
新系统为了统一配置中心,升级了某个基础依赖库的大版本。升级后,好几个任务的执行结果出现一些细微差异:时间字段的格式化方式变了。因为两个版本在解析时间戳时的默认时区不一样,导致所有依赖时间字段的任务全遭遇了时序偏移。
这个坑的教训简单直接:基础依赖升级永远不要跟业务切量放在同一个发布窗口。任何看似无感的第三方依赖升级,都应该先在测试环境跑一遍完整的回归场景,哪怕只是个小版本更新。
5.6 告警风暴与告警治理
新系统可观测性建好后,我一度把告警阈值调得特别激进,结果上线第一周,告警群从早响到晚,真正的重点问题反而被淹没在噪音里。大家开始对告警变得麻木,这比没有告警更危险。
经过两轮治理,我把告警分成了三层:P0(系统不可用、核心链路失败)、P1(局部任务积压、结果异常)、P2(性能波动、资源水位偏高)。P0必须立即响应,P1在10分钟内响应,P2仅记录›。重要的是,告警全部带上“可操作性描述”——告诉接收人这个告警意味着什么、应该去哪个看板看什么指标。经过治理后,告警量降了七成,真正的问题却一个都没漏。
6. 重构之外的心得:处理没人敢碰的老代码,到底最重要的是什么
六天的上线期结束,新REA系统正式接管全部流量,老系统随后退出了舞台。团队里有人感慨:“其实代码也没多难,难的是把业务一条条挖出来。”这句话我很认同。技术本身并不神秘,真正决定重构成败的,往往是在动手之前、动手之中的那些判断和策略。
我总结了几条心得,供参考:
- 先成为“业务考古学家”,再当开发者。读老代码不要只注意语法和结构,要多问“它为什么在这里”“它要解决什么业务问题”。先在脑子里形成一张业务地图,再谈代码改造。
- 不要一上来就优化性能,先保证正确性。老系统慢,不一定是它效率低,而是它承担了太多你不知道的隐性工作。在你没完全弄懂业务基线之前,任何性能优化都可能是负优化。
- 迁移比重写更稳妥。对旧系统,先搭一个新壳,把旧逻辑通过适配层收进来,再逐步替换内部实现,整个过程可回退、可验证。直接推倒重写的问题在于,你可能把一个还有用的“承重墙”也推掉了。
- 一切改变都要有“对账”意识。新老系统并行时,每天的对账差异就是最好的检查手段。如果差异为零,你甚至可以大胆继续推进;一旦出现差异,说明你的理解有盲区。
回看整个REA项目,我最骄傲的不是代码写得多漂亮,而是把一个“没人敢动”的系统,变成了一个业务方敢提需求、团队敢改代码、出问题十分钟内能定位的系统。重构老代码最困难的地方从来不是语法层面,而是重建信任——让业务方相信新系统可以稳稳地接住老系统所有的担子。这份信任不是靠口头承诺,而是靠一天天结果一致的对账跑出来的。
最后再分享一个小技巧:如果下次你接手一个只有一个名字、没有任何文档的陌生目录,先别打开IDE,先拿张纸,把你知道的、怀疑的、不知道的事情分成三栏列出来。你会发现,很多让人睡不着觉的困扰,在一张逻辑清晰的纸面前,会变得安静许多。