某个周五晚上我从公司归档服务器里翻出一个没有版权声明、没有README的zip压缩包。解压那一刻IDE提示“文件编码不可识别”我第一反应是压包损坏硬着头皮用二进制方式打开看到文件头残留的2001年份才明白自己撞上了传说中的“千禧年祖传代码”。而真正让我倒吸一口气的是版本控制历史里那一条条“fix bug”“update”“???”的提交记录以及一份写了二十多年、至今还在生产环境里日夜跑着的订单调度系统。这是“算法重构从技术债到算法驱动架构的升级之路”系列的第一篇。今天我先不急着讲算法怎么换而是要完整记录一下我是怎么以“代码考古学家”的身份把一套老系统的家底摸清楚又是怎么在和业务方、下游系统的反复博弈中把那个看起来“还能跑”的调度核心安全替换掉的。这篇的受众是那些即将接手遗留系统、或者已经在遗留系统里挣扎的开发者。如果你手头也有一套别人不愿碰的老代码这篇文章或许能帮你少走一些弯路。1. 千禧年代码库的“活化石”诊断报告1.1 这个系统到底老到什么程度我接手这套订单调度系统时第一件事不是读代码而是先做了一次“放射性检测”——统计整个代码库的规模、使用率和技术栈分布。结果非常刺激代码总量接近四十万行由三拨不同年代的人用至少三种语言风格写成早期模块还是控制台程序风格函数动辄上千行变量名是汉语拼音缩写混合英文单词。前端是老的模板渲染后端核心是一个Java调度引擎旁边还嵌着一段C写的计算插件数据库里则躺着一两百个没人说得清用途的存储过程。静态分析工具跑完一轮报告显示有接近30%的类和方法在整个工程里找不到引用属于“理论上的孤儿代码”。但诡异的是生产环境一删除某个疑似孤儿类第二天就会出现线上告警——这说明代码库里存在大量动态加载、反射调用和跨进程调用的隐藏链路静态分析根本看不见。我用命令行工具数了一下全局状态结果把自己吓了一跳整个调度引擎里static可变字段有87个其中不少是公共静态集合比如一个全局的“待分配订单池”多个线程都在往里写注释却写着“synchronized only for read”。这样的老系统本质上不是一个程序而是一层一层叠起来的“沉积岩”。1.2 全局状态、魔数与“不要动”注释老代码的三大图腾这套代码里最有代表性的不是某个算法而是三种反复出现的图腾。第一种是全局状态。调度引擎里有一个类静态字段存着当天所有订单的集合而2300多行的外层调度方法会在这个集合上做遍历、排序、并发修改。更麻烦的是这个集合的初始化时机取决于用户首次访问页面的时间而不是系统启动时间这意味着压力测试和线上行为完全不一致。我曾试着把集合改成局部变量传入结果引发了十几个关联类的编译错误——它们全都直接引用了这个静态字段。“全局状态”为什么在老代码里这么流行因为早期开发者追求‘省事’不同模块共享一个变量匀不需要设计接口当时确实高效。可一旦系统进入维护期这个选择就会变成沉重的负债你无法确定哪个模块正在读它也无法安全地修改它的生命周期。第二种是魔数。调度规则里有一个延期阈值直接用数字“3”写在if判断里。我顺着调用链往上找才发现这个3对应着“允许延迟三天”而三天这个业务规则来自某位业务负责人在2004年拍脑袋定下的一个临时策略。现在已经没人知道当年为什么是3而不是5但所有下游报表、对账逻辑都默认了这个数字它已经从“魔数”变成了一种隐性的业务契约。第三种是“不要动”注释。我在核心方法里见过这样一行注释// TODO: fix this in next release. // DO NOT TOUCH!! This method is black magic. // 2007-08-14: why does this work? nobody knows.这段注释后面跟着的是一个名叫weirdFix的私有方法它做的事是在两个列表同时为空时往一个缓存里写一个空字符串。从代码逻辑看这个操作毫无意义但删除之后线上会出现偶发的空指针异常。后来我才查清某个早期调用方依赖“空字符串被写入缓存”这个副作用把那当作一个“初始化信号”。这类注释对考古者来说是非常有价值的线索但也非常危险——它告诉你这里有坑却不会告诉你坑有多深、谁来填。1.3 从一份注释里挖出的“业务规则”除了代码层面的考古更复杂的是从注释里挖掘业务规则。调度模块里有一块特殊判断注释写着// 2003-06-11: Add special early priority for channel X. // Do not remove! This is critical for monthly report.这段逻辑做了什么事呢只有四个字某个特定渠道的订单永远优先哪怕是凌晨三点来的订单也要排在上午十点大客户订单之前。放在2003年这可能是为了某个渠道合作方的特殊要求必须给对方面子。但二十年后那个渠道早已名存实亡合作合同也换了好几轮可这条硬编码的优先级依然在调度引擎里执行并且还污染了下游的排产数据——每个月月底生产计划系统都会出现一批奇怪的空闲期就是因为这些“某渠道订单”插队插坏了顺序。这就是典型的技术债欠债的人可能早就离职了但利息到现在还在还。而且这种债不会体现在代码规范报告里只会体现为“系统行为不符合业务直觉”“每次改动都胆战心惊”。代码考古最难的部分恰恰是把这种尘封在注释里的业务规则挖出来翻译成人话交给业务方去确认这条规则还需要吗是留还是删2. 在动刀之前先想清楚为什么非动不可2.1 业务数据的增长曲线压垮了性能底线如果这套老代码只是丑、只是乱但性能还够用那重构的优先级不会那么高。真正促使我下定决心动刀的是业务数据量的增长曲线——过去八年订单量从日均两千单涨到了日均百万单而调度引擎的算法复杂度还停留在“所有订单遍历所有资源”的阶段。老算法的核心逻辑本质上是一个双重循环外层遍历全部待分配订单内层遍历全部可分配资源每找到一个“最早可用”的资源就分配出去。在订单只有两千的时候这种O(m×n)的穷举是看不见问题的跑一轮调度也就几百毫秒。可当订单量到百万、资源量到几百的时候一次完整调度需要几分钟甚至更久生产环境的调度线程频繁超时下游排产系统不得不反复重试重试又加剧了数据库锁竞争形成恶性循环。更要命的是调度引擎依赖一个全局的时间窗口每天早上六点整系统要跑一次“全天订单预分配”而这个批处理必须在五秒内完成否则流水线开工时间就确定不下来。老算法显然做不到只能靠临时加机器硬扛扛到硬件资源瓶颈出现终于谁也扛不动了。2.2 每次需求变更都要支付的“考古通行费”性能只是导火索真正让团队崩溃的是需求变更的成本。一次简单的“新增一类订单优先级规则”需要开发者在那个2300多行的调度方法里找到四五个不同的切入点每一处都牵涉一堆全局状态。有一次我为了理解一个参数为什么从true改成false会引发下游报表差异花了整整两天追踪调用链最后发现问题出在某个生成器依赖了“调度完成后全局集合的顺序”——而那个顺序恰恰取决于老算法遍历订单的先后顺序。这种依赖非常隐蔽它不在接口契约上而是建立在“人恰好这样遍历”的行为上。每次改动就像在一座积木塔底下抽一块积木你永远不知道哪一块会塌。业务方提需求时觉得“就改一行判断”但技术人员每改一行都要支付一笔沉重的“考古通行费”先读懂上下文再评估副作用然后做回归测试最后还要为意外行为写补偿逻辑。当这类成本累积到“一个需求要开发两周、其中一周在考古”的时候算法驱动的架构升级就从“可以以后再说”变成了“刻不容缓”。2.3 “算法驱动”三个字为什么能把重构提上日程“算法驱动”这个词很多团队挂在嘴边但落到这次重构上它有三层具体含义。第一层是核心决策逻辑从“手写的业务分支”变成“可参数化、可比较的算法模型”。老代码里的优先级判断是一长串if-else嵌套每接一个新需求就往里塞一个分支算法驱动的意思是把优先级定义为一组可量化的权重因子比如订单类型、承诺交期、资源匹配度、历史违约率由系统统一排序计算。这样新需求就不必再拆散代码结构只需要调整权重参数。第二层是计算过程从“全局状态双重遍历”变成“索引化、分阶段调度”。核心调度模块不再需要反复读全局集合而是将资源状态维护成有序结构订单到达时通过索引快速定位可分配资源复杂度能从O(m×n)降到近似O(n log m)。第三层是系统行为从“不可解释”变成“可追溯、可验证”。老算法分配完订单之后几乎没有任何日志记录“为什么选这个资源”出了问题全靠猜。算法驱动的架构每一步抉择都能输出一条决策记录复盘成本大幅下降。这三点每一项都直接对应着业务侧的一个痛点。所以当我把这层关系讲给业务负责人听时对方很快就松口了“只要别停线你可以动。”好的那就动。3. 考古工具箱不打碎瓷器的前提下把家底摸清3.1 静态分析扫出的“无人区”正式动手前我用静态分析工具对代码库做了一轮全面扫描。说实话这类工具在老代码上的表现很分裂它们特别擅长找“没用到的私有方法”“空catch块”“可空的返回值”但对于“全局状态竞争”“隐式依赖顺序”“反射调用的隐藏链路”基本无能为力。扫描结果里最有价值的是“依赖环”和“修改频率”两张图。依赖环显示调度核心模块与工具类、数据访问层、报表生成器之间存在着12个循环依赖修改频率图则显示近五年里有一百多个文件被改动超过20次而其中相当一部分文件在被不断改动的过程中参数数量已经从3个膨胀到了11个。看到这两张图的时候我心里已经很清楚这个模块之所以越来越难改不是某个人的问题而是结构性问题——它被改的次数太多每一层新逻辑都叠在老逻辑之上像一棵树的年轮。静态分析还有一个用处是帮我圈定“无人区”那些五年以上没人动过的文件。注意我用的措辞是“圈定”而不是“清理”。无人区里既有真正的死代码也有那种“所有人都怕它、所以没人敢动”的代码。前者可以慢慢评估是否删除后者恰恰是需要优先解剖的恐龙化石。3.2 手绘依赖地图的真实体验工具给的依赖图太碎了动辄上百个节点根本没法看。我自己做了一件事在白板上手工画“依赖地图”。不是类级别的依赖而是模块级别的数据流谁在往全局集合里写数据谁在读哪个下游系统会消费调度结果调度结果里的字段顺序是否被依赖。画到一半我就发现了一个奇怪现象报表系统读取调度结果的SQL依赖一个叫sched_order_seq的字段而这个字段并不是数据库sequence而是调度引擎在遍历订单时用一个静态计数器生成的序号。也就是说报表的行顺序取决于调度遍历的输入顺序一旦我们把双重循环改成更高效的算法遍历顺序变了报表行号可能全部漂移——轻则报表对不上重则对账差一分钱。发现问题的那一刻我惊出一身冷汗。这正是手绘依赖地图的价值工具只能告诉你“A调用了B”但只有人脑才能识别出“B的结果里字段顺序被C隐式依赖”这种跨模块的契约。这张地图后来成了整个重构项目的“宪法”每一次改动前都要先查它防止动到暗线。3.3 特征测试给老代码套上的第一张安全网考古做完接下来不是改代码而是给老代码建立“特征测试”。所谓特征测试就是把老代码当前的实际行为当作预期行为记录下来形成一张安全网。它的目的不是验证功能“对不对”而是验证重构之后行为“变没变”。对这套订单调度系统我写了一批批处理测试输入是历史某一天的订单快照和资源状态快照输出是调度结果。具体做法是先跑一套老代码把结果序列化成一个基线文件然后在新代码上跑同样的输入再把结果和基线逐字段比对。def test_legacy_schedule_behavior_is_preserved(): orders, resources load_fixture(orders_2019_06_03.csv) baseline load_baseline(legacy_result_2019_06_03.json) new_result run_new_scheduler(orders, resources) assert compare_baseline(new_result, baseline) 0, behavior drift detected这套测试的意义不是让新代码和旧代码一模一样——有些旧行为我们本来就想改掉但必须先知道改了哪些、影响多大。特征测试的价值在于把“隐性行为”变成“显性差异清单”。跑完第一轮屏幕上刷出了137条差异那一刻我心里反而踏实了问题总算浮出水面了。4. 第一个战场重写调度分配核心算法4.1 祖传调度算法的原始形态调度核心的老代码大概长这样public void schedule() { ListOrder orders globalOrderPool.getAll(); ListResource resources globalResourcePool.getAll(); for (int i 0; i orders.size(); i) { Order o orders.get(i); int bestResource -1; int bestFinishTime Integer.MAX_VALUE; for (int j 0; j resources.size(); j) { Resource r resources.get(j); if (!r.isAvailable(o.getWindowStart(), o.getWindowEnd())) { continue; } int finish r.estimateFinishTime(o); if (finish bestFinishTime) { bestFinishTime finish; bestResource j; } } if (bestResource -1) { // ??? 2003-05-11: dont know why, maybe add to queue backlog.add(o); } else { resources.get(bestResource).assign(o); } } }这段代码最大的问题从算法角度讲是双重扫描每个订单都要从头到尾扫一遍所有资源资源越多单次分配越慢。从架构角度讲问题更严重globalOrderPool和globalResourcePool是两个静态集合多线程在调用schedule时会同时遍历并修改它们。更麻烦的是backlog.add(o)的插入顺序取决于外层循环顺序下游系统恰恰依赖这个顺序来生成延期报告。我们后面才发现这条依赖它让重构方案多花了两周。什么情况下会走到bestResource -1的分支在双十一这种订单高峰资源窗口全部排满订单会被压入backlog。老代码没有给backlog设置容量上限内存溢出的风险一直存在但勉强能跑。4.2 新旧设计的关键差异从全量扫描到容量索引新方案的核心思想是把“反复全量扫描”变成“有序结构二分定位”。每一步的输入是一批订单和一组资源每台资源维护一个“下次可分配时间”和“已排容量”。我们不再把订单逐个拿来扫所有资源而是先按订单的承诺交期排序再在资源集合上维护一个按“最近可用时间”排序的索引结构。对每个订单用二分查找快速找到最早可用的资源如果找不到才进入backlog。这里有一个细节非常值得说订单排序的基准不再沿用老的全局静态计数器而是一组显式字段——“优先级权重、承诺交期、订单量”。为此我写了一个可比较的排序键def schedule(orders, resources): # 维护资源队列每个资源按最近可用时间升序排列 indexed_resources build_availability_index(resources) # O(m log m) for order in sorted(orders, keypriority_weight, reverseTrue): candidate indexed_resources.find_earliest_available(order.window_start, order.window_end) if candidate is None: backlog.append(order) # 显式记录无法分配 else: candidate.assign(order) indexed_resources.update(candidate) # O(log m) 更新位置复杂度从O(n×m)降到O(n log m m log m)。这看起来就是很小的一步但效果非常可观原来十万订单分配要整整五分钟新算法跑完只用不到三秒。当然复杂度上的进步只是其一更关键的是我们把“全局状态”拆掉了资源状态被封装在索引结构内部不再需要所有线程共享同一个静态集合。4.3 对拍验证让数据自己承认谁是对的核心算法设计完后我没有立刻切换生产而是做了一轮双轨对拍让新老两套逻辑并行跑两周每天对比调度结果。为什么要这么做因为新算法虽然“更高效”但排序规则变了遍历顺序变了最终分配组合必然和老代码不同。如果直接上线下游报表、排产系统、财务系统很可能立刻告警而我们根本分不清哪些差异是“合理的优化”哪些是“意外的破坏”。对拍的产出物是一张差异清单。我定义了一套规则来给差异分类如果新算法分配的总耗时更短、并且没有订单被无故延迟算“正向差异”如果某个订单从资源A换到了资源B但承诺交期不变算“中性差异”如果出现某个订单被压入backlog而老代码没有算“必须审查差异”。两周对拍跑下来正向差异和中性差异占了绝大多数必须审查差异只有三条。围绕这三条差异我约了业务方逐条确认其中两条是因为新算法避免了老算法的一个隐性错误——老代码有时会把一个订单同时分配给两个资源新代码从机制上杜绝了它。最后我们选择在一个低峰期夜晚做了切换。切换后第一个任务跑完看到调度耗时从“分钟级”掉到“秒级”整个团队在群里发了一长串感叹号。但我知道真正的考验还在后面——因为代码考古的坑往往藏在“看起来没变”的地方。5. 那些在代码考古里差点翻车的细节5.1 时区梦回Y2K一个“按日结算”隐藏的年份陷阱在重构过程中我遇到一个特别“祖传”的问题老代码里所有日期时间都用Date类型存储但并没有规范时区。服务器常年设在某个固定时区所有业务逻辑都默认“服务器本地时间就是业务时间”。问题出在我们把调度引擎容器化。新容器默认UTC时区一到晚上八点系统里“明天”的判断就差了几个小时订单预分配的时间窗口整体错位。查了很久才意识到罪魁祸首是十几年前写的一段日期解析代码DateFormat df new SimpleDateFormat(dd-MM-yy); // 两位年份服务器时区 Date orderDate df.parse(order.getDateString());01-06-02这个字符串在不同时区、不同年份规则下可能被解析成2002年6月1日也可能被解析成2001年6月2日……而订单日期错了后续调度排序全错。这让我想起一个道理千禧年问题从没真正消失只是从“两位年份存储”转移到了“时区解析”和“日期时间处理”这些角落。每次换运行环境这些问题就会借尸还魂。修复办法不是打补丁而是全面改为基于UTC存储、展示层做时区转换同时把解析逻辑改成显式的LocalDate.parse不再依赖服务器默认时区。这一步的教训是重构不只是改算法逻辑基础设施层面的“环境默认值”同样是定时炸弹。另一个差点翻车的地方是老系统里用两位数年份解析日期的地方不止一处。静态搜索yy相关格式化字符串找到了37处。有些地方已经不再被调用但仍有几处藏在报表生成模块的角落里平时不发作遇到未来跨年就会突然出错。我建议所有做这类重构的朋友把“年份解析”当成一个专项漏洞来排查不要觉得Y2K离我们很远。5.2 隐式契约调用方依赖的“错误行为”也是一种需求调度核心改完后我最担心的事情还是发生了下游某个报表模块开始报警。排查后发现老调度算法在资源不足时把订单按“先来先到”的顺序压入backlog新算法按优先级排序后backlog的顺序变成了“高优先级先入”。从算法角度看后者显然更合理但下游报表却“炸了”——因为它把backlog的行顺序当成了一种隐式的优先级排序并把这个顺序同步给了供应链同事。这个坑非常典型。我们在做需求分析时重点考察了接口契约和数据结构却低估了“调用方对行为顺序的依赖”。这种依赖不会写在接口文档里它藏在人的习惯里看到报表里同一个backlog列表从上往下读自然就以为第一行是“最紧急”。当第一行变成高优先级订单而不再是先来先到的订单时业务方的解读就变了。处理办法是回到对拍差异清单把“backlog顺序变更”单独列出来和业务方逐条确认新顺序是否可接受。最终我们保留了一个兼容层backlog仍然按“进入队列的顺序”展示但调度决策本身按优先级执行。换句话说决策顺序和展示顺序解耦。这个妥协听起来简单却帮我保住了下游系统的稳定也保住了和业务方的信任。这个案例给我的启发是重构时不要只盯着“技术合理性”还要把“行为契约”当成一等公民来对待。任何你打算改掉的旧行为都需要先确认没有人在依赖它。5.3 死代码的“诈尸”式引用静态分析报告显示有一个名为LegacyEngineHelper的类在整个代码库里没有任何直接引用。按常规反模式识别方法这类类应该被标记为“可删除”。但我多留了一个心眼检查了运行时的启动日志发现这个类其实会被一个字符串拼出来的类名加载。String helperName com.legacy.scheduler. LegacyEngine Helper; Class? clazz Class.forName(helperName);字符串一拼接静态分析工具就找不到了。生产环境里这个Helper会在调度完成后异步调用一个老掉牙的统计接口把“资源利用率”写到一张没人看的表里。虽然没人看但数据库存储过程每个月月底会读这张表做汇总一旦删除类月底汇总就会静默失败。这就是死代码的“诈尸式引用”看起来是死的其实还活着只是没有人知道它靠什么活着。处理死代码我最后的建议是先用运行时追踪工具观察一段时间确认某个类在真实流量里确实未被加载、未被反射调用再做删除。如果时间不允许做运行时追踪保守策略是“留着标注释不修改”。清理死代码的成就感固然吸引人但误删带来的线上事故远大于那点整洁感。6. 首战之后的复盘这套打法能否复制6.1 用数据说话重构前后的工程指标重构第一仗打完我整理了这样一组对比都是站在工程和运维视角的真实数据指标重构前重构后变化日均调度全量耗时约3500ms约420ms性能提升近8倍夜间批量预分配超时率12%低于0.1%稳定性大幅提升调度核心模块代码行数3200行540行代码量减少83%全局可变静态字段数87个5个并发隐患大幅收敛新增需求平均交付周期9.5天2.8天交付效率提升约3倍月度线上缺陷数12个3个缺陷率下降75%最让我欣慰的不是性能数字而是最后两项。新增需求交付周期从九天半降到两天八意味着团队终于不用再把一半时间花在“读懂老代码”上月度缺陷数减少说明行为差异被特征测试接住很多风险在测试阶段就暴露了而不是等到线上让用户报警。当然重构从来不是免费的。这轮改造算上分析、测试、双轨对拍和切换总共花了一个半月。期间还积压了几个常规业务需求但业务方看到调度稳定性带来的排产收益后认为这波“技术投入”完全值得。6.2 给同为“代码考古学家”的三条实用建议第一先做行为基线再谈算法优化。没有特征测试兜底的重构等于在没有安全绳的情况下爬高楼。哪怕你觉得“这段代码一看就是错的”在把它改对之前也要先把“当前的错”固化下来否则你根本分不清回归测试该以什么为准。第二依赖地图要手工画不能只信工具。静态分析能告诉你调用关系但不能告诉你“报表行顺序被遍历顺序影响”这种跨模块的隐式契约。花两天时间自己把核心模块的数据流画一遍比盲目依赖工具扫描更值钱。第三清理死代码要配合运行时追踪。纯静态分析找出来的“孤儿代码”可能只是反射、字符串拼类名的盲区。如果你不想在生产里体验“诈尸”请给可疑类留一段观察期或者干脆留着并加注释。执着于“代码库必须干净”是一种审美洁癖而生产环境需要的是“改变是可验证的”。写在第一集后面这篇记录的是我“代码考古”的第一站摸清家底、建立基线、重写核心算法、处理隐式契约。在这个过程里我最大的体会是技术债看不见摸不着但它会精准地出现在每一次需求变更、每一次故障排查、每一次新人入职之后——它从来不会消失只会从代码层面转移到流程层面再从流程层面回到线上事故。如果你也要对一套祖传系统动刀我建议你记住一句话老代码不是敌人而是唯一忠实的“行为记录仪”。它记得每一个业务决策哪怕这个决策已经被所有人遗忘。你改动的每一行逻辑背后都可能站着一段历史的影子。作为代码考古学家我们最大的责任不是证明自己比前人聪明而是把前人的意图读懂再决定是保留它、调整它还是和业务方商量之后埋掉它。这还只是第一集。后续我会接着写“那5个保留的全局状态到底怎么拆”“隐式依赖的完整排查清单”“如何和高龄业务系统负责人安全沟通改造范围”这些实战话题。考古的工作才刚刚开始而我这个“代码考古学家”的噩梦也远没有结束。
阅读完成 · 觉得有帮助?