
在编程这个行当里最容易被当成段子的一句经验是如果代码能跑就别动它。以前我也觉得这是偷懒后来几次把稳定模块改崩之后才明白这句话说的是风险不是代码质量。真正要考虑的不是代码好不好看而是“改动带来收益”和“改动引入回归风险”之间的账算不算得过来。这句话也不是说以后看到烂代码只能忍着。很多稳定运行多年的系统内部确实存在让人皱眉的写法比如超长函数、重复逻辑、隐式状态、写得很随意的命名。但要动手修改之前必须意识到一件事一段在生产环境跑了好几个月的代码它的“能跑”不光是逻辑正确还包括它已经扛住了各种异常输入、并发压力、网络抖动和团队误操作。这些隐性边界经常比代码本身的价值还大。所以我建议把这条法则理解成“先确认风险再动手而不是坚决不动手”。下面按实际落地顺序拆开聊聊什么情况下该收手什么情况下又必须动手以及动手时怎么把风险压到最低。1. 先分清“能跑就不动”和“永远不能重构”1.1 这句法则真正管理的是风险不是代码质量老程序员常说的“能跑就别动”本质上是把代码当成了一个有惯性、有历史包袱的系统来看。你看到的是一个函数但系统里可能还有定时任务、消息队列、配置中心、权限框架、外部接口在依赖它。当你只改了这个函数的内部实现却没有能力把所有间接路径都验证一遍时再小的改动也可能造成连锁故障。我见过最典型的翻车方式不是改错了一行语法而是把一段看起来很冗余的逻辑“简化”了。比如旧的判断里多了一个看似多余的if后来一查才知道这个分支是为了兼容某个老客户端传来的脏数据。你把它删掉后单测全过结果线上收到脏数据时直接报错。这种场景不是理论而是每天都在发生的维护事故。所以“能跑就不动”的含义是提醒你先追问这个模块为什么能跑它依赖了什么前提条件改动会影响哪些调用方如果这些问题答不上来那代码再难看也应该放到“暂不改动”区。1.2 不是所有“能跑”的代码都值得保护这里要刻意纠正一个误区稳定不代表质量好也不代表结构合理。很多系统能跑是因为外部条件凑巧把问题掩盖了。比如数据量一直不大接口调用量不高或者老调用方已经下线但代码分支还留着。这类代码一旦遇到条件变化照样会炸。真正值得保留的是“行为稳定且有测试覆盖”的代码而不是“没人敢碰所以一直没坏”的代码。如果一段代码只有一个人能看懂其他人都不敢改这不叫稳定这叫脆弱。脆弱代码沉默期内看起来没事一旦有人第一次上手修改就会把所有隐性问题一次引爆。因此判断要不要遵守“别动它”这条法则之前先给代码做个简单分类有回归测试保护改动风险可控。没有测试但模块边界清晰调用方少。没有测试模块内部还耦合了全局状态调用方隐藏在多个项目里。只有一个测试而且是针对 happy path根本没覆盖失败分支。第一类可以正常重构。第二类可以小心重构但要补测试。第三类和第四类最好先不动或者先把测试补出来再谈重构。很多人吃亏就是因为上来就改最后一种代码。1.3 这条法则最容易失效的三个场景“能跑就不动”并不是万能保护伞。以下三个场景里如果只记这一句话反而会害了你。第一个是安全漏洞或明显的数据正确性问题。代码能跑不代表它给出的结果是正确的。只要涉及金额、库存、权限、用户隐私遇到问题就必须处理不能拿“稳定运行”当拖延理由。第二个是功能扩展遇到结构性障碍。比如每次加一个小需求都要改动十几个文件那说明模块的扩展成本已经高到无法承受。这时候维持“能跑”只是把问题往后推早晚要还。第三个是依赖已经进入不可维护状态。比如老版本引用了已经停止维护的底层库或者运行环境不再支持当前技术栈这种时候再不想动也得动。选择是主动迁移还是被动迁移的问题不是动不动的问题。2. 想动代码之前先做一轮环境评估2.1 先看清楚代码活在哪套环境里我曾经接手过一个很“稳”的旧模块本地跑得好好的测试环境也通过结果发布到生产环境就出现偶发异常。最后排查原因才发现生产环境的 JDK 版本、操作系统字符集、磁盘路径和开发环境都不一样。这里不是怀疑开发者的能力而是想强调一个常识代码能跑通常是在某个具体环境里能跑。环境一变很多隐含假设就失效了。所以在改动任何稳定模块之前第一步不是打开编辑器而是先确认它实际运行的环境代码部署在哪些节点什么系统什么 JDK、Python、Node 或运行时版本。有没有外部依赖版本是谁固定的。日志存在哪里错误监控能不能覆盖到这次改动。配置项有哪些改动代码是否会改变配置读取方式。这些信息不整理清楚后面出的每一个 bug都会变成“本地复现不了”的玄学问题。2.2 有自动化测试和没有自动化测试是两种决策要动一段稳定代码先看它附近有没有测试。有测试的代码改动是“修改逻辑后跑测试看回归结果”没有测试的代码改动是“拍脑袋修改后交给线上流量验证”。这两种风险完全不在一个量级。如果老模块没有任何测试我一般不会直接重构而是先做“特征测试”。简单说就是把当前代码的输入输出记录下来把关键路径的预期行为固化成测试用例。哪怕这些预期很不合理也先把这些行为锁住等确认完业务逻辑后再慢慢调整。这个步骤看起来费时间实际上是最快的保命手段。反过来如果模块已经有一批能跑、能验证核心逻辑的测试那改动空间就大很多。新版本跑完测试再补一两个针对本次修改的回归用例就可以比较放心地推进。2.3 推进前先明确三个边界在真正提交代码之前我建议把以下三个边界写清楚否则很容易失控。边界一是“本次改动的目标”。你要解决的是 bug、性能、可读性还是新增功能如果一上来想把可读性、性能和架构一起解决建议先停手这几乎必然带来更大的变更范围。边界二是“绝对不会去碰的区域”。比如有些接口签名、配置文件格式、异常类型、消息结构明明可以优化但一旦调整会牵动大量调用方。第一次改动时尽量把这些区域设为红线只做内部改造不改外部契约。边界三是“回滚条件”。改动前先确认发布流程是否支持回滚日志是否足够定位问题。如果不支持快速回滚那即使是“小改动”也不能轻易上线。3. 真正值得动手的信号以及保守改造顺序3.1 五个值得介入的信号“能跑就别动”不等于“永远不要动”。如果代码开始阻碍业务发展或者维护成本明显超过重构成本那就该讨论了。我在实战中通常关注五个信号第一个是故障频率升高。代码偶尔抛异常但一直没人根因每次都是重启或者重试解决。这类问题越积越多早晚会集中爆发。第二个是数据正确性开始不稳定。如果模块有时处理正确、有时处理错误而且错误和输入格式、并发时间都有关系就不能再靠一句“偶尔出问题”糊弄过去。第三个是需求改动必须叠加补丁才能完成。代码里到处都是if (老逻辑) ... else ...每次都要靠“特判”来保证旧功能不跑偏说明结构已经被补丁堆到极限。第四个是新成员完全无法上手。一段代码如果只有原作者能改其他人都要花两周才能看懂这里的隐性成本很大。从团队效率角度看该动就得动。第五个是依赖链已经断掉或安全风险明确。比如老函数调用了一个不再维护的 SDK且官方已经公开了风险那就不能继续“只要不坏就不修”。这类信号出现后核心问题不是“要不要重构”而是“怎么在控制回归风险的前提下重构”。3.2 保守改造步骤先旁路再替换面对一段稳定但需要调整的老代码我推荐的方法从来不是直接重写内部而是先加“旁路”。意思是老逻辑继续保留新增调用入口走到新逻辑两边同时运行一段时间对比结果。举个例子假设你有一个老函数save_data里面的实现逻辑较乱但你暂时不敢动。这时候可以先在外面加一层封装把日志、重试、校验先做起来def legacy_save(data): # 老逻辑默认不修改 # 内部可能有各种历史分支这里保留现状 ... def safe_save(data): # 新增安全入口先不碰 legacy_save 内部实现 logging.info(save start, data_size%s, len(data)) try: result legacy_save(data) logging.info(save success) return result except Exception as exc: logging.exception(save failed: %s, exc) raise这个封装有三个好处第一新增逻辑不影响老行为第二你能通过日志看到老函数的真实调用频率和出错率第三一旦发现新入口有问题立刻切回老入口回滚成本非常低。等日志跑了一段时间你确认老函数的主要异常场景和性能瓶颈之后再考虑要不要深入修改legacy_save的内部实现。到这一步你手上有数据、有日志、有测试而不是靠感觉去做判断。3.3 每一步都留出后退路径很多时候项目不是被一次大规模重构搞坏的而是被连续几次“顺手优化”慢慢拖垮的。每次改动都改一点点每次都不充分测试最后整个模块变成谁都不敢碰的状态。为了对抗这种“逐渐失控”我给自己定了一条规矩每次改动都必须能够单独回滚不能出现“为了修复 A必须先提交 B、C、D”的情况。具体操作上我会尽量保持提交原子化。一次提交只做一件事要么修 bug要么加日志要么重构某个小函数。如果重构过程中发现依赖了很多杂七杂八的逻辑就把这些逻辑先记录成 TODO而不是顺手一起改。这样即使后续出问题也能快速定位到是哪一次提交引入了变化。4. 常见场景拆解bug 修复、新功能、依赖升级4.1 线上 bug 修复先复现再改判断逻辑遇到线上 bug 时最容易犯的错误是“猜原因然后直接改”。尤其是一些老代码表面上的报错原因和真正的数据问题经常不一样。盲目改代码很可能把原本正常的分支也带坏。我更推荐的顺序是第一步先在测试环境或本地复现。复现不了时就加日志观察线上输入。第二步根据日志和对输入格式的理解定位到具体是哪一行对哪种数据做出了错误判断。第三步才动手修改而且修改范围尽量缩小。这里不建议顺手重构整条链路。还有一点很现实修 bug 之后不要只验证你预期的数据。要把相邻输入、历史垃圾数据处理一遍防止修复一个 bug 又制造出另一个兼容性问题。4.2 新功能扩展优先扩展而不是改写稳定模块给稳定系统加新功能时新的能力尽量不要塞进老函数里。老函数越改越长分支越来越多回归风险自然上升。如果你发现新功能只是“在老逻辑基础上多一个选项”可以考虑通过新增参数、策略接口或独立模块来实现。比如老接口返回数据结构已经在线上被多个端消费贸然修改字段含义可能引发前端展示错误。更稳妥的做法是保留老接口新增一个版本化接口给新调用方使用。虽然短期内代码会多一些但每一条链路是清晰的双方都能独立演进。这个思路被很多人觉得“不够优雅”但它对“稳定代码”最友好。等到未来有机会统一方案时再通过数据对比和流量灰度把老接口收口而不是让所有团队一起陪着你完成一次大迁移。4.3 依赖升级和技术更新别拿新版本修不存在的错现在很多项目走到“要不要升级”的节点时团队内部会争论很凶。一部分人主张紧跟新版本一部分人说“能跑就别动”。我的判断标准很直接升级必须有明确理由不能把升级当成默认动作。如果你只是觉得“新版本更好”那先回答几个问题当前版本的哪个问题影响到了业务新版本解决了这个问题的证据是什么升级后哪些行为会发生改变有没有平滑升级路径能不能快速回滚如果没有这些问题仅因为技术债焦虑而升级很容易引入不兼容变化。常见例子是第三方库的默认行为变了升级后老调用方悄悄返回了不同结果测试环境流量小看不出来一上生产就暴露。我也经常看到 AI 编程助手给出“这里可以优化”“这个接口可以重构”的建议。对这种建议要尤其冷静。AI 看到的上下文有限它不知道一段代码经历了多少个需求变更也不会清楚线上真实流量特征。把它当成辅助工具可以但不能因为“AI 建议改”就把一段稳定的逻辑重写一遍。5. 怎么让“能跑”变成“能长期跑”5.1 先补测试比重构优先级更高很多人抱怨“老代码没有测试根本没法重构”。但这句话反过来听就是正因为没有测试所以要先补测试再考虑重构而不是一直把问题留给后来人。补测试的范围不需要一开始就铺很大。找到这次要修改的模块把最核心的输入输出路径测住。比如一个订单状态同步模块无非就是“不同状态从 A 变到 B 时消息体长什么样、失败时怎么处理、重复消息会不会幂等”。把这些关键路径测住之后这个模块至少让别人敢动了。不要只写实现细节测试。如果测试用例只是把代码翻译了一遍代码怎么写的就怎么断言那么重构时这些测试也会跟着失效起不到保护作用。好的回归测试应该描述“输入什么、应该产生什么结果”而不是“内部调用了哪几个函数”。5.2 代码评审里要明确改动边界提交代码做评审时我会特别注意描述里有没有说清楚“本次改了什么”和“本次没改什么”。因为评审者看 diff 时最怕的就是一眼望过去重构混着 bug 修复、依赖升级混着格式调整根本看不出哪个逻辑是真正变化的。实际操作中我会把一次需求的改动拆成几笔提交尽量保证每个 commit 主题单一。评审也就有了重点第一笔看 bug 修复思路是否合理第二笔看新功能是否影响老调用方第三笔才看重构是否改变行为。代码评审过程中如果发现某一处改动和本次目标完全无关即使这段代码确实有问题也建议先记录到 TODO不放进当前变更。这样做不是忽视问题而是保持变更可控。一次只解决有限的问题通常比同时解决三个问题更安全。5.3 写好“为什么”让后来的人知道你当时为什么没动代码注释里经常写“这里为什么会写成这样”却很少有人写“这里为什么没有改”。其实这两类信息同样重要。如果一个老函数内部逻辑很长你评估后决定先不动可以留下一段注释这里不要轻易重构原因是 XXX 场景依赖了这个分支的返回顺序目前缺少测试保护需要先补充 XX 用例。这样后来的人看到代码时就不会贸然的按自己的审美重写一遍然后又踩一遍前人踩过的坑。团队协作里很多事故并不是技术方案难而是后来者不理解历史约束。把“不修改”的理由写下来是一种成本很低但回报很高的文档习惯。6. 真正落地时我会问自己四个问题这里没有万能公式也没有一个绝对标准能包裹所有项目。每次面对“要不要动老代码”的决策我会把问题简化成四句话逐条过一遍。第一句如果现在不动会有什么损失如果损失只是“看着不舒服”那我大概率选择不动。如果是不动会产生持续故障、数据错误或无法扩展那就必须动。第二句如果改了最坏情况是什么这个最坏情况能不能被测试发现能不能快速回滚能回滚的改动风险往往可控不能回滚的改动即使看起来很小也要当成大项目来对待。第三句我对这段代码的理解够不够完整我是否知道所有调用方是否了解异常分支是否知道线上真实行为不够清楚时先补日志、补测试、画调用链不要急着动手。第四句这次改动是可逆的吗越可逆越可以大胆做越不可逆越要采用旁路方式让旧逻辑继续存活一段时间。这条所谓“编程第一法则”真正的价值不是劝你躺平而是劝你接受一个现实代码一旦上线它的历史就是不可见需求的一部分。你能看到它的现在未必能看清它经历过的每次妥协和修复。尊重已经能跑的代码不是向烂代码低头而是为了避免用短期审美换取长期风险。所以在团队里我经常把这句话改写得更具体一点代码能跑先别急着动它如果真的需要动就先用测试、日志和回滚方案把它保护起来再慢慢来。