别白白当牛马了:教你如何在被优化前,把手头的“屎山项目”重构为最有用的面经

Jimmy Lauren

Jimmy Lauren

更新于2026年7月1日
阅读时长约 27 分钟

分享

用 GankInterview 的实时屏幕提示,自信应答下一场面试。

立即体验 GankInterview
别白白当牛马了:教你如何在被优化前,把手头的“屎山项目”重构为最有用的面经

这篇文章的核心结论很直接:真正有价值的屎山重构,不是把遗留代码改得多优雅,而是把一次高风险、不可控的“牛马经历”,转化为一套在裁员和面试前都能自保、能变现的工程案例。对个人来说,屎山项目几乎是躲不开的现实,但它并不等于无意义的技术债清理;只要方法正确,屎山重构本身就是最容易讲清工程判断力、风险意识和业务理解的屎山面经。文章强调,重构裁员前最重要的不是速度,而是安全性和可叙述性:先控风险、再隔离变化、最后小步替换,把“没炸生产”本身变成可验证的成果。遗留代码重构的价值,也不在于一次性消灭技术债,而在于建立测试或日志安全网、识别不可动的核心逻辑、把隐性的部落知识文档化,并通过 feature flag、旁路验证等手段降低重构风险。这样做的结果,是你手里不再只是一个“别人留下的屎山案例”,而是一整套可复盘、可量化、可回答重构面试题的工程实践:你知道哪些地方不能动,为什么不能动,动之前如何验证,动之后如何监控和回滚。哪怕最终项目没有完全重构完成,这种安全重构指南式的思路,也足以证明你具备在真实生产环境中清理技术债、驾驭复杂系统的能力。对正在被 AI 重构工具、组织优化和不确定性挤压的工程师来说,这种把屎山重构为“最有用的面经”的能力,往往比多写几个新项目更重要。

屎山重构的核心思路:先控风险,再逐步替换

面对遗留“屎山项目”,第一反应不要是“我来重写一版更优雅的”。真正安全、也最适合讲成面经的路径是:先把风险框住,再用小步替换降低复杂度。重构的目标不是让代码立刻变漂亮,而是在不破坏线上业务的前提下,让系统逐渐变得可理解、可验证、可回滚。

面试里讲屎山重构,重点不是炫技,而是证明你有“保护生产、识别边界、渐进交付”的工程判断力。

可以把完整流程压缩成这 5 步:

  1. 绘制业务与代码依赖地图
    先搞清楚核心链路、接口调用、数据库表、定时任务、消息队列和外部系统依赖。很多遗留系统的问题不是代码难改,而是没人知道“改这一行会影响哪笔钱、哪个状态、哪个下游”。
  2. 识别高风险模块与不可动区域
    先把资金、权限、订单状态流转、核心交易、历史事故模块标红。看起来重复的代码,可能分别承载不同渠道、不同地区、不同历史版本的隐性规则;这类区域在没有证据前不要贸然合并。
  3. 补充最小可行测试或日志验证
    不要求一上来补齐完美单测,遗留系统常常做不到。更现实的做法是先加黑盒接口测试、关键路径回归用例、快照对比或关键日志埋点,用来锁定“当前行为”;这种“先建立安全网”的思路也符合遗留系统重构中常见的测试保护实践
  4. 用 feature flag 或旁路逻辑隔离新实现
    新逻辑不要直接替换老逻辑,可以先走旁路:同样的输入同时跑新旧两套逻辑,只让旧逻辑出结果,新逻辑只做比对和记录。等差异收敛后,再通过 feature flag 按用户、渠道、比例逐步放量。
  5. 逐步替换并持续监控
    每次只替换一个明确边界内的能力,例如一个支付渠道、一个订单状态、一个计费规则。上线后盯错误率、耗时、转化率、金额差异、告警量和回滚记录;如果指标异常,立刻关开关,而不是继续“相信新代码”。

举个典型场景:支付模块里堆了几百行 if-else,按支付方式、会员等级、优惠券、地区和历史活动分支计算手续费。最危险的做法是直接抽象一个“优雅的策略模式”然后全量替换;更稳的做法是先把现有支付路径画出来,标出资金计算和退款链路为不可动区域,再给关键订单样本加结果对比,最后只把一个低风险支付渠道切到新实现。

这种思路和“保护—隔离—优化”的遗留系统治理模型是一致的:先保护现有行为,再隔离变化范围,最后才谈结构优化。换句话说,风险控制优先级永远高于代码美观;能讲清楚这一点,你的“屎山项目”才不只是加班经历,而是面试里能打的工程案例。

步骤1:画出真实的业务与依赖地图

步骤1:画出真实的业务与依赖地图

很多屎山项目没人敢动,不是因为代码有多“高级”,而是因为它的真实规则不在代码里:一部分藏在历史需求里,一部分藏在线上事故里,还有一部分藏在老同事的口头约定里。你看到的是 if-else、临时字段、重复方法;系统真正依赖的是“哪些用户不能走新流程”“哪些状态不能回滚”“哪些表被下游报表凌晨扫走”。

所以,理解成本往往远高于修改成本。改一行判断可能只要 5 分钟,但弄清楚这行判断为什么存在,可能要翻 3 年前的需求、查线上日志、问产品和测试。遗留系统重构的第一步应该是“画地图”,而不是“动刀”。这也符合大型遗留系统重构中的常见经验:先尽可能分析原有业务需求和代码设计,再补测试、再小步重构;直接进入代码修改,风险会被放大,尤其是在上下文断层严重的项目里。InfoQ 对遗留系统重构的访谈也强调,第一步需要尽可能分析了解原有业务需求,而不是只看代码坏味道本身:遗留系统重构流程

你可以用一个很朴素的方式快速建立系统地图,不需要上来就画复杂架构图:

  1. 接口调用链:谁调用我,我又调用谁
    把入口接口、定时任务、消息消费者、外部 RPC/HTTP 调用列出来。重点标记同步链路和异步链路,因为异步消息经常是“代码里看不出、线上却会触发”的隐藏依赖。
  2. 数据库表依赖:哪些表被读、被写、被联动更新
    记录核心表、状态字段、唯一索引、软删除字段、历史兼容字段。尤其注意“只写不读”“只读不写”的表,它们可能服务于风控、财务、BI 或人工运营后台。
  3. 关键业务流程:正常路径、异常路径、补偿路径分开画
    不要只画 happy path。真正容易炸的是退款、撤销、超时重试、重复回调、人工补单、状态回滚这些边缘流程。
  4. 隐性规则:代码注释没有,但业务默认存在的规则
    例如“老用户不能迁移到新优惠模型”“某渠道订单不能自动关闭”“金额为 0 的订单不能触发发票流程”。这些规则不一定优雅,但它们通常解释了屎山为什么长成现在这样。

一个简单的依赖列表就够用了,比如:

创建订单 API
├─ 校验用户状态:UserService / userstatus 表
├─ 计算价格:PromotionService / couponrecord 表 / activityconfig 表
├─ 锁库存:InventoryService / stocklock 表
├─ 创建支付单:PaymentService / payorder 表
├─ 发送消息:ordercreated_topic
└─ 下游消费:履约、发票、风控、运营报表

实际场景里,订单系统最常见的问题是:一个 createOrder() 方法里塞满多条件分支,按用户类型、渠道、活动、支付方式、库存类型、是否预售不断追加判断。新人看代码会觉得“这几个分支重复了,可以抽成策略模式”;但一问业务才发现,看似重复的两段逻辑分别服务于“普通优惠券”和“平台招商券”,退款时的承担方不同,财务对账口径也不同。如果你只按代码形态重构,很可能把两个不能合并的业务规则合并掉。

面试里讲这一步,可以用一句话压住重点:

我不会一上来就重写核心方法,而是先画出业务流程、接口调用链和数据依赖,确认哪些分支是真重复,哪些分支是在承载历史业务规则。

常见误区有两个:

  • 误区一:直接重写。
    重写看起来最快,但在没有业务地图的情况下,本质是在复刻你“以为存在”的系统,而不是线上真实运行的系统。
  • 误区二:只读代码,不走业务路径。
    只看代码容易陷入局部细节,比如方法命名、类职责、重复判断;但屎山项目的危险点通常在跨系统调用、状态流转和历史数据兼容上。代码只是地图的一层,线上流量、数据库状态、运营流程才是完整地形。

第一步的产出不需要漂亮,但必须能回答三个问题:这段代码服务哪条业务链路?它依赖哪些上下游?我改它时最可能影响谁? 能回答这三个问题,后面的测试补充、风险隔离和逐步替换才有落点。

步骤2:识别不能动的核心逻辑与高风险区域

步骤2:识别不能动的核心逻辑与高风险区域

屎山项目里最危险的代码,往往不是“最丑”的代码,而是最接近钱、权限、交易状态和 SLA 的代码。你看到的是一堆重复判断、硬编码和历史分支;线上系统依赖的可能是多年事故修出来的隐性规则。所以第二步不是马上抽象、合并、删分支,而是先给代码打风险标签:哪些可以清理,哪些只能包起来,哪些暂时绝对不能碰。

面试里讲这一步,核心不是展示你多会重构,而是展示你知道:重构的第一目标是不要把生产环境搞炸。

可以先按三类风险给模块分级:

风险类型

典型代码区域

判断线索

初始策略

业务风险

下单、支付、退款、结算、会员权益、权限校验

影响收入、用户登录、订单状态、运营活动

先标记为“不可直接改逻辑”,只补日志/测试/旁路验证

数据一致性风险

库存扣减、账户余额、积分、幂等表、状态机流转

涉及事务、补偿、重试、消息队列、定时任务

先梳理状态变化和回滚路径,避免改出脏数据

性能风险

首页接口、核心查询、批处理任务、网关鉴权、搜索推荐

高 QPS、慢 SQL、缓存穿透、批量任务超时

先看监控和容量瓶颈,不要为了“优雅”引入额外调用链

一个常见坑是:两个方法看起来 90% 重复,于是顺手合并。比如订单系统里有两个优惠计算分支:

  • calculateNormalOrderDiscount()
  • calculateChannelOrderDiscount()

新人一看,都是“满减 + 优惠券 + 会员折扣”,就抽成一个通用方法。但老同事告诉你:渠道单的优惠券不能和会员折扣叠加,是因为两年前某个渠道合同约束;普通单允许叠加,是运营活动承诺。代码确实重复,但重复背后承载的是不同业务规则。这种地方如果只按代码坏味道处理,很容易把“重复代码”重构成“统一事故”。

识别高风险区域时,不要只靠读代码。更可靠的做法是把信息从三个地方捞出来:

  1. 看日志和监控
    找错误率高、告警频繁、调用量大的接口。尤其关注支付回调、订单状态更新、权限拦截器、定时补偿任务这类“平时安静,出事致命”的模块。
  2. 翻历史 issue、事故复盘和发布记录
    如果某个类反复出现在紧急修复、线上回滚、数据修复脚本里,它大概率不是普通坏代码,而是事故高发区。先记录原因,不要急着“顺手优化”。
  3. 问维护过它的人
    很多遗留系统的真实规则不在注释里,而在老同事脑子里。InfoQ 关于遗留系统重构的实践也提到,结对重构能通过熟悉业务上下文的人实时确认,从而更好保障安全性;同时应通过测试和小步提交降低风险(见这篇关于有条不紊实现代码重构的访谈)。

实际落地时,可以给每个模块贴一个简单标签:

  • P0:不可动区域
    资金、权限、核心交易链路、历史事故模块。短期只允许加日志、加测试、加旁路校验,不直接改业务判断。
  • P1:谨慎改动区域
    依赖较多但有一定测试或回滚手段的模块。允许做小范围重构,但必须有灰度、监控和回滚方案。
  • P2:可优先清理区域
    低流量、低业务影响、边界清晰的工具类、展示逻辑、内部管理功能。适合作为第一批重构样本,积累安全网和团队信任。

面试时可以这样表达:

“我不会先挑最乱的代码改,而是先挑风险最低、收益可验证的部分改。对于支付、权限、订单状态机这类核心逻辑,我会先标记为不可动区域,通过日志、历史事故、监控和业务负责人确认隐性规则,再决定是否用旁路或 feature flag 做替换。”

这句话的含金量在于:你没有承诺“重构一定安全”,而是说明你知道如何把不确定性关进笼子里。对于屎山项目来说,先识别不能动的地方,往往比知道怎么动手更重要

步骤3:补最小测试或日志验证机制

不要在“零测试”的屎山里直接动核心逻辑。你以为只是改了一个 if,实际可能改掉了某个大客户、某个历史订单、某个灰度城市才会触发的隐藏规则。更现实的做法不是立刻搭一套完美测试体系,而是先补一层最小安全网:只保护最关键、最容易炸、最能代表业务价值的路径。

在面试里,这一步可以这样表达:

“遗留系统通常很难马上补单元测试,所以我会先用黑盒方式锁定当前行为,比如关键路径回归脚本、接口快照测试、日志对比。目标不是追求覆盖率,而是在重构前知道哪些行为不能被我无意中改掉。”

这个思路和遗留系统重构中的常见实践是一致的:对于腐化严重的系统,往往会先覆盖中大型接口测试或 UI 测试来守护核心业务逻辑,等结构变清晰后再补更细粒度的单元测试。InfoQ 的重构访谈中也提到,很多遗留系统因为上万行类、大方法等问题,直接写单元测试很困难,更务实的策略是先用自动化测试保护主要业务场景,再小步重构并频繁反馈,逐步补充更小型的测试

可以按下面这个优先级建立安全网:

安全网类型

适用场景

怎么做

关键路径回归脚本

登录、下单、支付、退款、权限校验等主链路

用脚本固定输入数据,跑一遍核心流程,校验状态码、关键字段、数据库状态

接口快照测试

老接口没人敢改,但调用方很多

记录一组典型请求的响应结构和关键值,重构后做 diff

日志对比

逻辑太复杂、输出不完全可控

在新旧逻辑入口打业务日志,对比分支命中、金额、状态流转、异常码

数据库前后校验

批处理、账务、库存等强状态逻辑

执行前后对比关键表字段,不只看接口是否返回成功

举个最简单的例子:你要重构一个老的用户详情接口 /api/user/profile,不要一上来拆 Service。先固定几类用户样本:普通用户、会员用户、冻结用户、历史迁移用户。然后写一个接口快照校验,至少保证旧接口的关键字段没有被你改没。

cases = [
  { userId: 1001, type: "normal" },
  { userId: 2001, type: "vip" },
  { userId: 3001, type: "frozen" }
]

for case in cases:
    response = GET("/api/user/profile?id=" + case.userId)

assert response.status == 200
    assert response.body contains ["userId", "name", "level", "status"]
    assert response.body.userId == case.userId
    assert response.body.status in ["ACTIVE", "FROZEN", "DELETED"]

saveSnapshot(case.type, normalize(response.body))

这里的 normalize 很关键:时间戳、随机 ID、动态推荐列表这类字段不要硬比,否则测试会天天误报。你可以只比较稳定字段业务关键字段,比如用户等级、账户状态、权限标识、金额、订单状态。快照测试更像“脚手架”,先锁住当前外部行为,而不是证明内部设计有多优雅。类似观点在遗留代码重构讨论中也很常见:重构前先建立基准测试或快照测试,锁定“输入 A 得到输出 B”的当前行为,即使输出里有历史问题,也要先区分“行为锁定”和“修 Bug”两个阶段,避免重构和业务修正混在一起

如果连自动化测试都不好接入,那就先做日志验证机制。例如重构订单优惠计算前,在旧逻辑关键出口打印结构化日志:

log.info("couponcalcresult", {
  orderId,
  userId,
  couponId,
  originAmount,
  discountAmount,
  finalAmount,
  ruleId,
  resultCode
})

重构后仍然打印同样字段。上线前用测试环境或灰度流量跑一批订单,对比新旧日志:同一个订单是否命中同一个规则、优惠金额是否一致、异常码是否一致。它不如测试干净,但在祖传系统里非常实用,尤其适合“逻辑散落在多个 Service、没人敢抽函数”的阶段。

常见误区是:试图一次性补齐所有测试,然后项目直接停滞。
这在真实团队里很容易发生:你刚接手屎山,越看越不放心,于是想补 Controller 测试、Service 单测、DAO mock、边界用例、异常分支,最后两周过去了,业务需求没动,重构也没开始,老板只看到“进度为零”。

更现实的标准是:

  1. 先覆盖最赚钱或最容易出事故的链路:支付、登录、下单、结算、权限。
  2. 先测外部行为,再测内部实现:接口输入输出、状态变化、日志结果优先。
  3. 先保留当前行为,再讨论修 Bug:不要把“重构”包装成“顺手改业务逻辑”。
  4. 每次只补能支撑下一刀的测试:你要拆优惠计算,就先保护优惠计算;你要改用户状态机,就先保护状态流转。

面试官真正想听的不是“我会把测试覆盖率拉到 90%”,而是你知道在工期、风险、业务压力都存在的情况下,如何先搭出一张够用的网,让后面的每一次重构都有反馈、有证据、可定位。

步骤4:用隔离策略逐步替换旧逻辑

步骤4:用隔离策略逐步替换旧逻辑

有了最小测试和日志安全网之后,真正动刀时不要把旧逻辑“一锅端”。遗留系统最怕的不是代码丑,而是你不知道哪段丑代码承载了历史事故、运营特例、灰度补丁。更稳的做法是:先隔离,再并行,最后替换。这和遗留系统治理里常说的“保护—隔离—优化”思路一致,核心目标是缩小改动的爆炸半径,而不是证明你能一次性重写一个模块。

可以优先使用三类隔离策略:

策略

适用场景

关键收益

Feature Flag

新旧逻辑都能返回结果,但只能选择一路生效

可灰度、可回滚,线上风险可控

Shadow Logic

新逻辑还不敢影响用户,只想验证结果差异

可观察新旧逻辑差异,不改变现有行为

Adapter / 防腐层

老接口、脏数据、历史字段很多,新模块不想直接依赖

把混乱挡在边界外,内部模型保持干净

比如你手里有一段经典“祖传 if-else”,根据用户类型、地区、活动、渠道计算价格:

// before:所有业务规则缝在一个方法里
Money calculatePrice(Order order) {
    if (order.userType == VIP && order.region == "CN") {
        // 会员价 + 国内活动
    } else if (order.userType == NEW && order.channel == "APP") {
        // 新人 App 优惠
    } else if (order.hasCoupon()) {
        // 优惠券逻辑
    } else {
        // 默认价格
    }

// 下面还夹着库存、风控、日志、埋点……
}

不要第一步就上来搞一套“完美领域模型”。更现实的做法是先抽出一个稳定入口,让外部调用不感知内部变化:

// after:先建立统一入口,再逐步迁移规则
interface PriceStrategy {
    Money calculate(Order order);
}

class LegacyPriceStrategy implements PriceStrategy {
    Money calculate(Order order) {
        return legacyCalculatePrice(order); // 原逻辑原封不动包起来
    }
}

class VipPriceStrategy implements PriceStrategy {
    Money calculate(Order order) {
        // 新的 VIP 价格逻辑
    }
}

class PriceService {
    Money calculate(Order order) {
        if (FeatureFlag.enabled("newvipprice") && order.isVip()) {
            return new VipPriceStrategy().calculate(order);
        }
        return new LegacyPriceStrategy().calculate(order);
    }
}

这里的重点不是“用了策略模式就高级”,而是你把风险边界收窄了:旧逻辑仍然存在,新逻辑只接管一个明确场景,例如“VIP 用户 + 国内订单”。如果线上发现异常,只需要关闭 newvipprice,请求立刻回到旧链路,不必紧急回滚整个版本。

更稳一点的做法是先跑 Shadow Logic。也就是说,线上仍然使用旧结果,但后台偷偷计算新结果并记录差异:

Money oldResult = legacyPrice.calculate(order);

if (FeatureFlag.enabled("shadownewprice")) {
    Money newResult = newPrice.calculate(order);
    logCompare(order.id, oldResult, newResult);
}

return oldResult; // 用户仍然拿旧结果

这一步非常适合面试时展开讲,因为它体现的是工程判断:你不是“相信自己写的新代码”,而是用真实流量验证它。你可以观察差异率、差异金额分布、命中的业务场景,再决定是否从 1% 灰度扩大到 10%、50%、100%。

如果旧模块对外暴露的数据结构很脏,例如金额是字符串、状态码混用、字段含义依赖历史渠道,就不要让新模块直接消费这些数据。可以加一层 Adapter / 防腐层

class LegacyOrderAdapter {
    CleanOrder adapt(LegacyOrder legacy) {
        return new CleanOrder(
            parseMoney(legacy.amountText),
            normalizeStatus(legacy.status),
            mapChannel(legacy.source)
        );
    }
}

这样做的好处是:旧世界的混乱留在 Adapter 里,新逻辑只面对相对干净的模型。类似的防腐层思路也常被用于新旧系统并存时隔离脏数据,腾讯云开发者社区的重构实践中也提到,可用适配器把旧系统原始数据转换为新领域模型所需的标准化对象,避免污染核心逻辑(防腐层设计)。

面试里可以把这一段总结成一句话:

“我不会直接删除旧逻辑,而是先用 Feature Flag 和 Adapter 建边界,让新旧逻辑并行;再通过 Shadow 对比和灰度逐步扩大覆盖范围。这样即使新逻辑出问题,也能快速回退,并且能用日志证明迁移是否安全。”

常见误区是把“隔离”做成“架构洁癖”:一上来引入复杂框架、拆十几个模块、设计一堆抽象接口,结果业务逻辑还没迁完,项目先被交付压力打断。正确尺度是:只为当前要替换的关键路径设计隔离层。今天只迁价格计算,就不要顺手重构库存、优惠券、支付、通知。每次替换一小块,每次都有开关、日志和回滚路径,这才是屎山项目里真正可落地的安全重构。

步骤5:上线监控与回滚机制

很多“屎山项目”重构不是死在编码阶段,而是死在发布阶段:测试环境数据太干净,压测流量不真实,灰度范围选错,或者上线后没人盯指标。你在本地把代码整理得再漂亮,只要生产环境某个历史边界条件被打穿,业务方看到的就不是“架构变优雅了”,而是“昨天还能用,今天坏了”。

所以重构的最后一步不是合并代码,而是设计一个可观察、可止损的发布闭环。尤其在面试里,你要表达出一个关键意识:重构上线不是证明自己写得对,而是快速发现哪里不对,并能安全退回去。 InfoQ 关于遗留系统重构的实践也强调,小步提交有助于问题定位和回滚,重构过程需要建立有效的度量反馈机制,否则价值和风险都很难被团队看见。

上线前至少准备这几类监控:

监控类别

重点观察什么

异常信号示例

错误率

接口 5xx、业务异常码、异常日志数量

新逻辑接口 5xx 突增,或特定错误码集中出现

响应时间

P95/P99 延迟、慢查询、下游调用耗时

重构后平均耗时没变,但 P99 明显变差

业务指标

下单成功率、支付成功率、登录转化、任务完成率

技术指标正常,但支付成功率下降

数据一致性

新旧逻辑结果差异、关键字段为空率、金额/状态异常

新模块生成的订单状态无法被旧流程消费

资源指标

CPU、内存、线程池、连接池、队列积压

某个适配层引入额外查询,导致数据库连接耗尽

一个实际场景:你把老的优惠券计算逻辑从一堆 if-else 重构成策略模式,上线灰度 5% 用户。错误率没有明显上升,接口耗时也稳定,但业务监控发现“企业客户续费订单”的优惠使用率突然下降。继续查日志才发现,旧逻辑里有一个没人写进文档的边界规则:企业客户在月底续费时,可以叠加一类历史白名单券;新策略漏掉了这个分支。

这时真正考验你的不是“能不能马上修”,而是有没有提前准备退路:

  1. 优先用 feature flag 关闭新逻辑
    如果新旧逻辑是并行存在的,先把流量切回旧逻辑,而不是现场热修核心代码。
  2. 保留旧版本可部署包或容器镜像
    不要让“回滚”变成重新打包、重新审核、重新猜配置。上线前就确认上一稳定版本可以一键恢复。
  3. 数据库变更要可逆或兼容
    最危险的是代码能回滚,数据结构回不去。字段删除、状态枚举调整、数据迁移脚本都要考虑向前兼容;不确定时,先加字段、双写、观察,再清理。
  4. 日志要能定位到用户、请求和策略分支
    至少记录 requestId、用户类型、命中的新旧逻辑版本、关键入参摘要、结果差异。不要只打一行“calculate failed”,那对事故恢复几乎没有帮助。
  5. 约定止损阈值
    例如:某核心接口 5xx 连续 5 分钟高于基线、P99 延迟翻倍、支付成功率明显低于同期水平、关键业务告警连续触发,就暂停灰度或回滚。阈值不需要夸张,但必须提前写清楚,避免事故现场靠情绪决策。

面试时可以这样收束你的回答:

“我不会把重构上线当成一次性发布,而会按灰度、监控、回滚三个动作闭环。先通过 feature flag 控制流量,只放一小部分低风险用户;上线后同时看技术指标和业务指标,尤其关注错误率、P99 延迟、核心转化率和新旧结果差异;如果发现边界业务异常,先切回旧逻辑止损,再根据日志定位问题。这样即使重构没有一次完全覆盖所有历史规则,也不会把风险扩大成生产事故。”

这段回答的重点不是显得你“很会写代码”,而是让面试官相信:你知道遗留系统里有暗雷,也知道如何在暗雷爆炸前把影响范围控制住。真正成熟的重构,不是没有事故风险,而是风险可观察、可隔离、可回滚

为什么项目会一步步变成“屎山”

很多“屎山项目”一开始并不烂,甚至恰恰相反:它通常是某个阶段的功臣系统——能交付、能上线、能撑住业务增长。真正的问题在于,它在一次次“先这样”“下个版本再说”“这个客户比较特殊”的选择里,被逐层加厚。就像有开发者总结的那样,屎山不会立刻带来灾难,正因为它还能跑,才会长期存在并继续膨胀。

一个常见的演化故事是这样的:最初只是一个简单的订单状态判断,三五个状态、两三个入口,代码清晰,负责人也知道每个分支的含义。后来来了大客户,要加专属折扣;运营活动要临时绕过库存校验;财务系统要求兼容老字段;线上出过一次事故,于是又补了一个保护分支。半年后,原本的“订单处理模块”已经变成:谁都能看懂一小段,但没人敢保证自己看懂了全貌。

常见的技术债来源通常不是某个程序员“水平不行”,而是这些现实因素叠加:

  • 需求压力:DDL 近在眼前,重构收益说不清,补丁式开发最容易被批准。
  • 历史兼容逻辑:旧客户、旧数据、旧接口不能动,只能在新逻辑外面再包一层判断。
  • 补丁式修复:线上 Bug 优先止血,根因分析和结构治理被推到“以后”。
  • 测试覆盖不足:没有自动化测试兜底,改核心逻辑的风险被放大。
  • 文档缺失:规则只存在于聊天记录、老员工记忆和已经失效的注释里。
  • 人员流动:熟悉背景的人离职后,代码里的业务意图被进一步稀释。

这里有一个关键洞察:理解成本越高,团队越倾向于继续打补丁;补丁越多,理解成本又越高。 这就是屎山的自我强化机制。对准备面试的人来说,真正有价值的不是单纯吐槽“代码太烂”,而是能讲清楚:它为什么变成这样、风险在哪里、你如何在不炸生产的前提下逐步拆解它。

典型屎山结构:if-else森林与隐性业务规则

典型屎山结构:if-else森林与隐性业务规则

最常见的屎山项目,不一定是“代码完全不能跑”,恰恰相反,它往往跑得很久、支撑过很多业务高峰,甚至曾经是团队的“功臣系统”。问题在于:每次需求变化都没有改变结构,只是在原流程里继续加条件。久而久之,代码看起来像一片 if-else 森林,真正的业务规则却散落在分支、魔法值、注释和历史补丁里。

一个典型的核心方法可能长这样:

public PriceResult calculatePrice(Order order, User user) {
    if (order.getType() == 1) {
        if (user.getLevel() >= 3) {
            if (order.getSource().equals("APP")) {
                // 老活动用户特殊处理
                if (order.getCreateTime().before("2022-06-01")) {
                    return oldVipAppPrice(order);
                } else {
                    return newVipAppPrice(order);
                }
            } else if (order.getSource().equals("MINIPROGRAM")) {
                return miniProgramVipPrice(order);
            }
        } else {
            if (order.hasCoupon()) {
                if (couponService.isSpecialCoupon(order.getCouponId())) {
                    return specialCouponPrice(order);
                }
                return normalCouponPrice(order);
            }
        }
    } else if (order.getType() == 2) {
        // B端客户定制逻辑,不能删
        if (order.getCustomerId().equals("C10087")) {
            return customCustomerPrice(order);
        }
        return enterprisePrice(order);
    }

return defaultPrice(order);
}

这段代码最可怕的地方,不是嵌套层数本身,而是每个分支都可能代表一个真实业务承诺:

  • order.getType() == 1:可能是早期 C 端订单类型;
  • createTime before 2022-06-01:可能是一次价格体系迁移留下的兼容逻辑;
  • customerId == C_10087:可能是某个大客户合同里的特殊条款;
  • source == MINI_PROGRAM:可能是小程序渠道上线时临时补的规则;
  • specialCoupon:可能是一次营销事故后的兜底补丁。

所以,if-else 森林不是单纯的“代码写得丑”,而是业务演化没有被建模,只被追加进流程控制里。当团队习惯了“新需求 → 新分支,新场景 → 新判断,新规则 → 新特例”这种交付方式,屎山就会进入稳定增长曲线,这也是很多开发者复盘屎山形成时反复提到的模式:每一个需求都是加法

它会不断膨胀,通常有三个技术原因:

  1. 修改入口太集中
    所有业务都挤在一个 service、一个 handler、一个 controller 里。新需求来了,开发者最容易找到的地方就是旧方法,于是继续在旧方法中间插入分支。
  2. 规则没有名字
    代码里只有 if (status == 3 && type == 2),没有 isRefundableEnterpriseOrder() 这样的业务表达。规则没有被命名,就无法被讨论、复用和测试,只能靠读完整段代码猜含义。
  3. 缺少可验证边界
    没有单元测试、契约测试或回归用例时,没人能确认“改掉这个分支会影响哪些用户”。于是最安全的做法不是删除旧逻辑,而是再加一个更窄的判断,把新逻辑绕进去。

这也是为什么很多屎山不能直接重写。你以为自己在重写代码,实际上是在重写一堆没人完整记得的业务历史。一个接口为什么旧代码用 POST,新代码看文档却应该用 GET?一个页面为什么后台还有隐藏入口?一个字段为什么空字符串和 null 表示不同状态?这些都可能不是“垃圾逻辑”,而是线上系统多年运行后沉淀出的隐性规则。类似的重构翻车案例里,常见问题就是模块遗漏、接口行为不一致、一次性上线导致问题集中爆发,最终只能回滚;这类风险在重构屎山项目的复盘中很典型。

面试里如果被问到“你遇到过什么复杂老项目”,不要只说“代码很多 if-else,很乱”。更好的表达是:

这个模块的问题不是单纯代码风格差,而是业务规则长期以内联条件的形式沉淀在主流程里。我的第一步不是立刻重写,而是先识别哪些分支对应真实业务规则,哪些是过期补丁,哪些需要用测试保护,再决定是否抽象、隔离或删除。

能把“if-else 很多”分析到“隐性业务规则缺少建模”这一层,你的回答就从吐槽变成了技术判断。

组织因素:需求压力、人员流动与部落知识

很多“屎山项目”不是因为某个开发写得烂,而是因为组织长期默认了一种协作方式:业务规则不进文档、不进测试、不进代码语义,只存在于少数老员工的脑子里。这就是所谓的“部落知识”。

部落知识:系统能正常运行所依赖的隐性规则,但这些规则没有被明确写进需求文档、接口契约、测试用例或代码注释里,只靠老员工口口相传。

比如一个订单状态字段 status = 7,代码里只写了:

if (status == 7 && source == 3) {
    // 特殊处理
    return manualReview(order);
}

新人看到这段逻辑,只知道它“特殊”,但不知道为什么特殊。问一圈之后才知道:三年前某个大客户的渠道订单,因为对账系统延迟,必须绕过自动审核;后来客户合同结束了,但没人敢删,因为不知道还有没有别的渠道复用了这套逻辑。于是这个 if 就从临时补丁变成了祖传规则。

人员流动会让问题进一步恶化。老员工离职时,交接文档通常会写“订单模块负责创建、支付、退款流程”,但不会完整写清楚:

  • 哪些逻辑是历史补丁;
  • 哪些字段不能随便改;
  • 哪些接口表面废弃但仍有后台入口在调用;
  • 哪些异常路径只在月底、节假日、大客户活动时触发;
  • 哪些线上问题曾经发生过,以及当时为什么这么修。

结果是,新接手的人面对一个“线上稳定运行”的系统,最理性的选择往往不是重构,而是绕开看不懂的核心逻辑,在外围继续加补丁。这也是很多开发者故事里反复出现的现象:系统没有完整架构图和需求文档,核心逻辑全靠口口相传;有一段代码没人敢改,也没人能说清作用,这类项目往往已经具备典型的屎山指数特征

一个很常见的情境是这样的:

角色

真实处境

最后行为

产品

客户下周要功能,延期会影响续约

要求“先按现有逻辑兼容一下”

老开发

知道历史坑,但在忙别的项目或准备离职

口头提醒“那块别乱动”

新开发

没有上下文,也没有足够测试保护

不改主流程,只在旁边加判断

测试

只知道当前需求,不知道历史异常路径

覆盖正常链路,漏掉隐藏入口

业务

只关心上线后别出事故

默认“能跑就是没问题”

我见过类似的场景:新人接手会员权益模块,要加一个“黑金会员专属券”。代码里已有普通会员、企业会员、渠道会员、试用会员四套判断,最干净的做法是梳理权益模型;但排期只有三天,且没人能确认旧会员等级是否还在被某些运营活动引用。最后方案变成:在原方法第 260 行后面加一个 if (vipType == BLACK_GOLD)。上线没问题,但下次再来一个“联名卡会员”,这个方法就继续膨胀。

这类问题最危险的地方在于:每一次补丁都是当下最安全的选择,但长期看会不断抬高理解成本。理解成本越高,后来的人越不敢重构;越不敢重构,就越倾向于补丁式开发。循环几轮之后,系统就不再依赖文档和模型,而是依赖“谁还记得当年发生了什么”。

所以,如果你准备把这段屎山经历整理成面试材料,不要只说“项目代码很乱”。更有价值的表达是:

  • 你识别出了哪些部落知识;
  • 你如何从老员工、工单、日志、历史 PR、线上事故记录里还原业务规则;
  • 你如何把口头规则沉淀成文档、测试用例、状态机图或接口契约;
  • 你如何在不影响交付的前提下,降低新人接手成本。

面试官真正想听的,通常不是你怎么吐槽前人,而是你有没有能力把“只有某个人知道”的风险,变成“团队都能理解和验证”的资产。

AI重构工具能救屎山吗?现实能力与局限

先给结论:AI 可以帮你更快读懂屎山、定位坏味道、生成小步重构方案,但不能替代你理解业务。尤其在面试里,别把答案讲成“我用 AI 一键重构了祖传代码”,更可信的说法是:你用 AI 做结构分析和低风险改造,用测试、灰度和业务确认来兜底。

AI 在重构中的价值,更接近“加速器”而不是“自动驾驶”。例如,IBM 对 AI 代码重构的介绍也强调,AI 建议仍需要开发者审查,并通过测试验证受影响功能是否正常;一些实践文章也指出,AI 重构高度依赖上下文,缺少明确意图时往往只能做重命名、提取方法等基础操作,而不是可靠地完成复杂架构判断。可以参考这篇关于 AI 辅助重构依赖有效上下文 的分析。

维度

AI 相对擅长

AI 容易失误

代码结构

梳理调用链、模块边界、函数职责、循环依赖

判断“这个边界为什么当年这么切”

重复逻辑

找相似代码、建议抽公共函数、统一命名和风格

区分“真正重复”和“看似重复但业务差异很大”

局部重构

提取纯函数、拆分长函数、降低嵌套、补充类型

跨多个系统改协议、改数据模型、改核心交易链路

风险判断

根据代码形态提示潜在坏味道

理解历史事故、兼容老客户、监管要求、灰度策略

验证方式

生成单测样例、列测试清单、提示边界条件

保证线上行为完全不变;这一点仍要靠人工 review、测试和监控

一个常见翻车场景是:AI 看到某段逻辑“永远不会被新版本客户端调用”,于是建议删除。但真实情况可能是,3 年前某个大客户还在用旧客户端,服务端必须保留这段兼容分支;或者支付风控里某个看似多余的 if,其实是一次资损事故后的补丁。AI 能看见代码,却未必知道事故复盘、客户合同、运营后台配置和历史灰度规则。

所以,正确姿势不是问“AI 能不能救屎山”,而是把它放在合适的位置:让 AI 负责扫描、归纳、生成候选方案;让人负责业务语义、风险排序和最终决策。实践上,最好把任务拆成小粒度,先让 AI 输出 50~200 行以内的 diff,再人工审查和跑测试;类似“人做规划、AI 做执行”的边界,在一些 AI 编码实践中也被反复提到,比如这篇关于 规划归人、执行归 AI 的经验总结。这样你在面试里讲出来的,不是工具崇拜,而是一套可控、可验证、能落地的工程方法。

AI适合做的三类重构任务

AI 最适合当“重构副驾驶”,帮你把机械、重复、结构化的信息先整理出来;但方向盘仍然要在你手里。尤其是准备面试时,你可以把 AI 的产出转化成三类可讲的材料:发现了什么问题、怎么拆解风险、如何验证没有破坏业务。IBM 对 AI 代码重构的总结也提到,AI 更适合提升一致性、加速分析和辅助低层级重构,但开发者仍需要审查建议并运行测试验证结果,不能直接盲合并。

第一类:重复代码检测,把“到处复制粘贴”变成可量化问题。
屎山项目里最常见的不是某个函数写得丑,而是同一段逻辑散落在 8 个入口、12 个服务、20 个页面里。你可以让 AI 帮你扫描相似代码块,按“重复逻辑类型”归类,而不是一上来就让它改代码。

示例提示词:

请分析以下三个订单价格计算函数,找出重复逻辑、差异点和潜在可抽象的公共部分。
不要直接重写代码,只输出:
1. 重复逻辑清单
2. 每处差异是否可能是业务差异
3. 建议优先合并的部分

面试里这类工作可以讲成:“我先用工具辅助识别重复分支,发现优惠计算、会员折扣、运费兜底三类逻辑重复出现;随后和产品/测试确认哪些是业务差异,最后只合并确定一致的部分。”这比说“我用 AI 优化了代码”可信得多。

第二类:函数拆分建议,把 500 行大函数拆成可测试单元。
AI 对“提取纯函数”“拆分副作用”“改善命名”这类局部重构通常比较有帮助。比如一个前端 submitOrder 函数里同时包含表单校验、价格计算、埋点、接口请求、错误弹窗,你可以先让 AI 标注职责边界,再逐步拆。类似实践中,开发者常用 AI 先分离表单校验、价格计算等纯逻辑,再保留原函数负责流程编排,这种思路在重构案例中也很常见。

可执行的拆法是:

  1. 先让 AI 标记函数里的 纯逻辑副作用
  2. 只要求它提取纯函数,比如 validateOrder()calculateTotalPrice()
  3. 给这些纯函数补单元测试;
  4. 最后再整理原流程函数,让它只负责协调调用。

示例提示词:

请只分析这个 submitOrder 函数的职责边界。
把代码分成三类:
1. 可提取为纯函数的逻辑
2. 必须保留在流程中的副作用
3. 暂时不建议改动的高风险逻辑
不要修改代码。

这里的关键是“先分类,再动刀”。不要让 AI 一次性重写整个大函数,否则它很可能把异常处理、兼容分支、埋点时机一起改掉。更稳的做法是每次只产生小范围 diff;有实践经验的开发者也强调,把任务拆成 5 到 15 分钟一轮、每轮人工审查,比把规划和执行全部交给 AI 更安全。

第三类:依赖关系分析,先画地图,再决定从哪里下手。
很多屎山项目真正难的不是代码长,而是依赖关系乱:一个订单服务被支付、库存、发票、售后同时调用;一个公共工具类改一行,十几个模块跟着炸。AI 可以帮你初步整理调用链、模块边界和高风险依赖,但你要用 IDE、静态分析、日志和测试结果交叉验证。

示例提示词:

请根据以下文件和调用关系,整理 orderService 的依赖地图:
1. 被哪些模块调用
2. 它依赖哪些外部服务或工具类
3. 哪些方法属于高风险修改点
4. 哪些地方适合先加测试再重构

你可以把 AI 输出整理成一张简单表格:

分析对象

AI 能帮你做什么

人工必须确认什么

调用链

找出直接调用方、间接调用方

线上真实流量是否覆盖这些路径

公共方法

标记被多处复用的方法

是否存在历史兼容调用

外部依赖

梳理 API、缓存、消息队列等依赖

超时、重试、幂等规则是否有业务约束

高风险模块

根据复杂度和引用次数排序

是否属于核心链路或事故高发区

真正面试时,你可以这样表达:“我没有直接改核心服务,而是先梳理依赖地图,找出引用最多、缺测试、事故影响面大的方法;对这些点先补回归用例,再做小步重构。”这会让面试官听到你的风险意识,而不只是工具使用能力。

最后记住一个原则:AI 的输出只能算候选方案,不是最终结论。 每次采用 AI 建议前,至少做三件事:读 diff、跑测试、查业务场景。没有测试的地方,先补 characterization test;不确定的规则,先问老同事、查需求单或翻事故记录。能把 AI 当成分析器和执行助手,而不是“自动重构按钮”,这才是你在面试里真正值得讲的能力。

AI无法理解的三件事:业务语义、历史事故、隐性规则

AI 看代码时,看到的是“当前文本里的结构”;人看屎山项目时,看到的是“为什么它会长成这样”。这两者差别很大。很多危险的重构事故,不是因为 AI 不会抽函数、不会改命名,而是因为它不知道某段丑代码背后可能藏着业务妥协、线上事故和团队约定。

所以在面试里聊 AI 重构时,比较稳的表达不是“我用 AI 把项目重构了”,而是:

AI 负责帮我提高代码理解和局部改造效率;关键业务判断、风险识别和上线边界由我来控制。

这也符合更现实的工程经验:AI 重构依赖有效上下文,缺少上下文时,建议可能不准确;开发者仍然需要审查、测试和验证 AI 输出。类似观点在 AI 辅助软件工程实践 和 IBM 对 AI code refactoring 的讨论中也反复出现:AI 可以提升效率,但不能替代工程师的判断。

AI 难理解的上下文

表面上 AI 可能怎么判断

实际风险

人该怎么兜底

业务语义

“这段判断重复,可以合并”

合并后改变不同客群、渠道、地区的业务含义

找产品文档、接口契约、运营规则确认含义

历史事故

“这个兜底分支永远不会走,可以删除”

删除后复现曾经的线上事故

查事故复盘、报警记录、老提交说明

隐性规则

“这个限制没有注释,可以简化”

破坏团队长期依赖的灰度、风控、兼容逻辑

问老同事、补充测试、先加监控再动代码

第一,AI 很难理解业务语义。

比如支付系统里有一段看起来很奇怪的逻辑:

if (user.isNew && order.amount > 500) {
  riskLevel = 'HIGH'
}

if (channel === 'partner_x' && order.amount > 300) {
  riskLevel = 'HIGH'
}

AI 可能会建议把它们抽象成统一规则:

if (order.amount > threshold) {
  riskLevel = 'HIGH'
}

从代码整洁度看,这个建议没问题;但从业务看,新用户大额支付特定渠道支付 可能对应两套完全不同的风控策略:前者防盗刷,后者防渠道套利。它们只是“长得像”,不是“含义一样”。

面试时你可以这样讲:

我不会直接让 AI 合并相似判断,而是先把规则按业务语义分类:用户维度、订单维度、渠道维度、设备维度。只有确认两段逻辑的触发条件、风险等级、后续动作一致时,才会提取公共函数;否则最多提取命名更清楚的私有方法,保留业务边界。

第二,AI 很难知道历史事故。

屎山项目里经常有这种代码:

// 不要删,历史原因
if (request.getClientVersion() < 231) {
    return oldResponseFormat(data);
}

或者更糟糕,连注释都没有:

if ("0".equals(user.getStatus())) {
    return defaultProfile();
}

AI 看不到三年前那次线上事故:某个老版本 App 无法解析新字段,导致大面积白屏;也不知道某个合作方接口长期传错状态值,但合同没到期,系统只能兼容。于是它可能判断:“这是过期逻辑”“这是无效分支”“可以删除”。

这类地方最适合转化成面试亮点,因为它体现的是工程成熟度,而不是单纯写代码:

  1. 先查证:看 Git blame、提交信息、事故复盘、监控报警、客服工单。
  2. 再隔离:把历史兼容逻辑包成有明确命名的方法,例如 handleLegacyClientResponse()
  3. 补测试:为老版本、异常状态、边界输入补回归用例。
  4. 设退出条件:如果业务允许,增加埋点统计调用量,连续几个版本低于阈值后再评估删除。

这样讲,比说“我把无用代码删了”可靠得多。

第三,AI 很难识别隐性规则。

隐性规则不是代码本身写不出来,而是没人把它完整写进代码、文档和注释里。比如:

  • 某个接口不能并发调用,因为下游库存服务没有幂等;
  • 某个字段不能改名,因为 BI 报表直接依赖 JSON key;
  • 某个 sleep(200ms) 看起来很蠢,但其实是在绕一个第三方接口的最终一致性延迟;
  • 某个订单状态不能合并,因为财务、客服、仓储系统各自理解不同。

AI 可能会把这些视为“坏味道”,但人要判断它到底是技术债,还是业务约束。如果暂时无法确认,比较安全的做法不是马上重构,而是先做“三件小事”:

  • 给规则命名:把魔法判断改成表达意图的函数或常量,例如 isLegacyPartnerSettlementCase()
  • 给规则加护栏:补单元测试、集成测试,至少覆盖当前行为。
  • 给规则找主人:确认它属于产品规则、运营规则、财务规则,还是纯技术遗留。

在实际使用 AI 时,可以把边界说得更明确:

请只分析这段代码中可能存在的隐性业务规则,不要直接给出删除或合并建议。
请按“可能的业务含义 / 删除风险 / 需要向谁确认 / 建议补充的测试”输出。

这类 Prompt 的目标不是让 AI 替你拍板,而是让它帮你整理风险清单。真正的判断仍然要回到人:你是否理解业务链路,是否知道事故历史,是否能设计验证路径。

如果把这段经历写进简历或面试回答,可以强调一句:

我使用 AI 的原则是“让它扩大我的视野,不让它替我承担责任”。涉及支付、风控、权限、账务、兼容性这类高风险逻辑时,我会先建立测试和监控,再做小步重构,而不是一次性套用 AI 的大改方案。

如何把屎山重构经历讲成高质量面经

很多工程师明明啃过最难的遗留系统,面试时却只说成了“我把代码优化了一下”“拆了几个模块”“补了测试”。问题不在于经历不够硬,而在于没有把维护工作翻译成面试官能评估的工程能力:你面对的系统有多复杂、重构失败会造成什么风险、你为什么选择这个方案、最后对交付或稳定性有什么影响。

讲屎山重构,不能按“技术流水账”讲,而要按问题—行动—结果来组织;如果是行为面,也可以套 STAR,但要把技术细节嵌进去:

  1. 问题规模:代码量、模块数量、调用链复杂度、测试覆盖缺口、发布频率、线上影响面。
  2. 风险判断:哪些逻辑不能随便动?是否涉及支付、登录、订单、权限、计费等核心链路?
  3. 具体决策:你是先补特征测试/快照测试,还是先做防腐层、Facade、模块拆分、依赖倒置?为什么不直接重写?
  4. 执行方式:如何小步提交、灰度发布、回滚、监控、Code Review、与业务方对齐窗口期。
  5. 结果证明:用可验证指标表达价值,例如构建时间、缺陷率、回归问题数、发布耗时、需求交付周期、圈复杂度、重复代码率等。

这里的关键是:重构不是“我把代码写漂亮了”,而是“我在不中断业务的前提下降低了系统风险和后续交付成本”。遗留系统里看似丑陋的 if-else 可能承载着历史事故和边界条件,因此面试中要体现敬畏感:在没有安全网时贸然修改核心逻辑,本质上是在放大生产风险。关于这一点,遗留系统重构常见建议也强调先建立测试防护网,再逐步触碰核心逻辑,而不是上来就推倒重来;可以参考这篇关于治理屎山代码的系统化思路

一个简单但够用的表达结构可以这样准备:

背景:我们有一个历史订单模块,核心流程集中在少数几个大类里,新增促销规则时经常牵动支付、库存、优惠券逻辑。
问题:需求交付慢,回归范围大,测试主要依赖人工验证,团队不敢改核心分支。
行动:我没有直接重写,而是先补核心链路的黑盒测试/快照测试,锁定现有行为;再通过接口抽象和防腐层隔离外部依赖;最后按业务能力拆分方法和模块,每次提交只处理一个明确变化点。
结果:后续新增规则时改动范围更可控,回归定位更快,模块边界更清晰;如果有数据,就补充构建时间、缺陷数、发布耗时或开发周期的前后对比。

如果你手里有真实指标,一定要提前整理。没有指标也不要编数字,可以用“可观察变化”描述:例如“原来一个需求需要同时改 5 个文件,现在主要集中在策略模块内”“原来只能靠全量回归,现在核心路径有自动化用例兜底”。InfoQ 对重构价值的讨论也提到,重构结果可以通过圈复杂度、函数长度、重复率、构建速度、发布效率等指标显性化,这类指标非常适合放进面经里表达工程收益,详见这篇关于有条不紊实现代码重构的访谈。

接下来的准备重点就是两件事:第一,把面试官高频追问的问题提前拆解,避免现场只会说“看情况”;第二,把你的技术债故事包装成一个有复杂度、有取舍、有业务影响的工程案例,而不是一次普通的代码清理。

面试官最常问的屎山重构问题

面试官问“屎山重构”,通常不是想听你吐槽前人代码多烂,而是在判断你有没有风险控制、业务判断和渐进式改造能力。回答时别急着说“我会拆模块、上DDD、写单测”,先把问题框住:系统当前承载什么业务、哪里最危险、你如何证明改动没有破坏线上行为。遗留系统重构的关键往往是先建立测试防护网,再做隔离和优化,这一点在关于遗留代码重构策略的讨论中也反复被强调。

可以重点准备这些问题:

  • 你会如何重构一个没有文档、没有测试的遗留系统?
    回答思路:先不要直接改核心逻辑,而是通过日志、调用链、线上监控、数据库表关系和老员工访谈还原业务地图。随后优先补“特征测试”或黑盒测试,锁定当前输入输出,再从边界清晰、收益明确的模块开始小步重构。
  • 面对一坨核心链路屎山代码,你第一刀下在哪里?
    回答思路:不要说“先重写最烂的地方”,而是说“先找变更频率高、事故率高、影响范围可控的切入点”。如果是登录、支付、订单这类核心链路,第一刀通常不是改内部实现,而是加监控、补测试、抽接口、做隔离层。
  • 如何降低重构带来的线上风险?
    回答思路:强调小步提交、灰度发布、特性开关、回滚方案、自动化测试和核心指标监控。可以补一句:重构提交和业务逻辑修复要分开,否则出了问题无法判断是结构调整引入的回归,还是业务修复本身有误。
  • 如果代码很烂,但业务需求排期很紧,你还会重构吗?
    回答思路:不要走极端。可以说会区分“必须先重构才能安全交付”的阻塞型技术债,和“可以随业务迭代顺手改善”的一般技术债;对于前者,要用风险和交付成本说服团队,而不是用“代码不优雅”作为理由。
  • 如何说服产品、Leader 或团队给你重构时间?
    回答思路:把技术语言翻译成业务语言:当前结构会导致什么风险,比如回归 Bug、测试周期变长、上线无法回滚、需求交付越来越慢。更有说服力的说法是给出方案对比:不重构的交付风险 vs. 投入一小段时间做隔离和测试后的收益。
  • 你如何判断一次重构是成功的?
    回答思路:不要只说“代码更清晰了”。可以从工程指标回答,例如圈复杂度下降、重复代码减少、平均函数长度变短、构建或发布效率提升、缺陷定位时间缩短;InfoQ 对重构实践的访谈也提到,可以用代码健康度和工程效率指标让重构价值显性化。
  • 重构过程中发现原逻辑有 Bug,你会顺手修掉吗?
    回答思路:谨慎回答。更稳的做法是先用测试记录当前行为,完成结构性重构后,再用单独提交修复 Bug;这样可以清楚地区分“保持行为不变的重构”和“有意改变行为的修复”。
  • 如果团队里有人反对重构,你怎么处理?
    回答思路:先确认对方担心的是排期、风险、收益不明确,还是历史事故阴影。然后用低风险试点降低阻力:选一个非核心但高频变更模块,设定可观察指标,做完后用结果讨论,而不是靠争论赢得支持。
  • 你会选择推倒重写,还是渐进式重构?
    回答思路:默认倾向渐进式,除非旧系统已经无法满足关键业务、维护成本远高于迁移成本,且有清晰的双写、迁移、灰度和回滚方案。面试里直接说“重写一遍”很危险,因为这通常意味着你低估了隐含业务规则和线上稳定性。
  • 如何处理屎山里的隐性业务规则?
    回答思路:不要只看代码表面。要通过线上数据、历史需求、客服/运营反馈、异常分支、老工单和监控告警去还原规则来源;那些看似奇怪的 if-else,可能是某次生产事故后的补丁,删除前必须验证它保护的场景是否还存在。
  • 如果重构后性能变差了,你怎么排查?
    回答思路:先看重构前是否有基准数据,没有基准就很难证明问题来源。排查时从接口耗时、数据库查询、缓存命中率、线程池、调用次数变化入手,必要时回滚到上一个小版本,而不是在大提交里盲查。
  • 你在简历里写了“主导重构”,具体主导了什么?
    回答思路:准备讲清楚你的职责边界:是识别问题、设计方案、推动评审、拆任务、写核心代码、补测试,还是负责上线和复盘。不要只说“参与重构”,要说明你影响了哪个模块、解决了什么痛点、如何验证结果。

把技术债故事讲成“有价值的工程案例”

面试里讲“我重构了一个屎山模块”,最怕讲成代码洁癖:你花了很多时间抽方法、拆类、改设计模式,但面试官听不出这件事为什么重要。更好的讲法是把它包装成一个工程案例,围绕三个维度展开:复杂度、决策、影响

一句话框架:我接手的系统复杂在哪里,我做了哪些有取舍的技术决策,最后对交付效率、稳定性或协作成本产生了什么影响。

可以按下面的顺序准备:

维度

面试官想听什么

你要准备的素材

复杂度

这是不是一个值得讲的难问题

模块规模、调用链、历史包袱、测试缺失、多人协作冲突、线上风险

决策

你是不是只会改代码,还是能控制风险

为什么不推倒重写、如何分阶段、如何加测试、防腐层/接口隔离/灰度方案怎么设计

影响

这次重构有没有业务价值

开发效率、缺陷率、回滚次数、构建耗时、发布风险、需求交付周期、新人上手成本

举个面试中可以展开的案例:

普通讲法:

“我们之前订单模块代码很乱,我把一个很大的 Service 拆成了几个类,引入了策略模式,代码可读性提升了。”

这句话的问题是:只有技术动作,没有工程价值。面试官很难判断你的贡献大小,也不知道你有没有处理真实风险。

更好的讲法:

“我接手订单优惠模块时,核心 Service 混合了参数校验、库存判断、优惠券核销和金额计算。每次改促销规则都需要读完整条链路,而且缺少稳定的单元测试,测试同学通常要做大量回归。

我没有直接重写,而是先梳理高频变更点和核心交易路径,用接口测试锁住主要行为;然后把金额计算、优惠券校验、库存预占拆成独立组件,并通过门面层保持原有调用方式不变,避免影响上游。后续新促销规则只需要扩展对应策略,不再修改主流程。

结果上,我们至少可以用三个指标说明价值:同类需求的开发耗时是否下降、核心链路回归问题是否减少、相关模块的构建或测试反馈是否更快。如果公司有历史数据,就用真实数据;如果没有,就说清楚你用了哪些可观察信号,比如 PR 变更行数减少、回归范围缩小、上线前阻塞问题减少。”

这里的重点不是“用了什么模式”,而是你证明了:这个重构降低了未来变化的成本

量化时不要硬编数字。可以优先从这些地方找证据:

  • 研发效率:相似需求从评估到上线的周期、PR review 轮次、修改文件数量、冲突次数;
  • 质量风险:线上回滚、P0/P1 缺陷、回归测试问题数、监控告警次数;
  • 工程反馈速度:构建耗时、测试耗时、本地调试链路长度;
  • 代码健康度:圈复杂度、重复率、平均函数长度、模块依赖方向是否收敛。

这类指标不是面试八股。InfoQ 在讨论重构度量时也提到,中小型重构可以观察圈复杂度、平均函数行数、类行数等代码健康度指标;大型重构则可以观察编译速度、发布效率等工程效率指标,这能让重构价值更显性。参考这篇关于有条不紊实现代码重构的访谈,里面提到大型遗留系统重构时,度量反馈机制本身就是重构落地的重要部分。

最后,避开一个常见错误:只讲技术细节,不讲业务影响

不要把回答停在:

  • “我用了 DDD。”
  • “我加了防腐层。”
  • “我把 if-else 改成策略模式。”
  • “我把一个大类拆成了十几个小类。”

这些当然可以讲,但它们只是手段。你还要补上:

  • 为什么当时不能继续堆代码?
  • 为什么选择渐进式重构,而不是重写?
  • 如何保证重构期间不影响业务迭代?
  • 重构后谁受益了:业务方、测试、后端、前端、运维,还是新同事?
  • 如果再做一次,你会保留或调整哪些决策?

面试官真正想判断的是:你是不是能在遗留系统里做出可控、有收益、能落地的工程改进。把“我清理了一坨屎山”讲成“我降低了一个核心业务模块的变更风险和交付成本”,这才是有竞争力的面经。

一个真实可复用的屎山重构案例结构

面试里讲“屎山项目重构”,最忌讳两种说法:一种是只吐槽历史包袱,听起来像甩锅;另一种是把成果吹成“性能提升 10 倍、线上零事故”,但拿不出过程和证据。更稳的讲法,是把它整理成一个可复用的案例结构:背景 → 问题 → 风险 → 方案 → 结果

你的目标不是证明自己“改了很多代码”,而是证明自己能在高风险遗留系统里,识别关键问题、控制发布风险,并把技术债转化为业务可理解的收益。

下面以一个典型的订单模块重构为例来组织面经素材。订单模块很适合讲,因为它通常同时牵涉库存、优惠券、支付、风控、退款、通知等链路,天然具备复杂度;同时又不能随便停机重写,能体现你对风险控制的理解。类似的结构也可以迁移到权限模块、用户中心、结算系统或审批流系统。

你可以先用一张“案例卡”把故事压缩清楚:

面经维度

你要讲清楚的内容

面试官真正想判断什么

背景

这是哪个系统、承担什么业务、为什么必须改

你是否理解业务上下文,而不是单纯洁癖式重构

问题

代码坏味道、典型 bug、开发效率瓶颈

你是否能定位主要矛盾,而不是泛泛说“代码乱”

风险

哪些链路不能出错、哪些改动可能引发连锁反应

你是否具备生产意识和边界感

方案

怎么拆模块、怎么隔离新旧逻辑、怎么补测试

你是否有工程化方法,而不是一把梭重写

结果

缺陷率、回归时间、发布信心、代码复杂度等变化

你是否能量化价值,且不夸大

在方案表达上,不建议一上来就说“我们上了 DDD / 微服务 / 中台化”。更可信的说法是:先识别变化最频繁、风险最高的业务点,再用最小结构调整承接它。例如,把原来堆在 OrderService 里的校验、库存、优惠、金额计算拆出来,让订单实体、金额计算服务、优惠券策略、外部系统适配层各自承担清晰职责。腾讯云开发者社区的订单系统案例也提到过类似方向:重构前常见问题是一个 Service 混杂大量校验、库存和优惠逻辑;重构后则通过更明确的领域模型和服务拆分承载业务规则,避免所有逻辑继续堆在一个方法里(参考:订单系统重构案例)。

但这里要注意:案例的亮点不在“用了什么高级模式”,而在关键决策。比如:

  • 没有直接全量重写,而是先围绕下单主链路建立回归用例;
  • 没有立刻拆成很多微服务,而是先在单体内做包结构和依赖方向治理;
  • 对旧数据、旧接口、历史异常订单保留兼容层,而不是假设数据都是干净的;
  • 每次只迁移一个业务分支,例如先迁移优惠券计算,再迁移库存锁定;
  • 用灰度、开关、日志比对或双写校验降低上线风险。

这种表达会比“我负责重构订单系统,提升了可维护性”更有说服力。因为面试官听到的是:你知道遗留系统不能靠勇气硬推,必须靠测试保护、小步提交、可回滚设计和指标反馈。InfoQ 关于遗留系统重构的访谈中也强调,重构通常要先理解业务需求和原有设计,再补充守护测试、小步安全重构,并通过圈复杂度、函数长度、重复率、构建效率等指标度量结果(参考:遗留系统重构六步法)。

接下来的案例可以按两个层次展开:先讲重构前的结构、风险和技术债,让问题显得真实;再讲重构后的架构变化、落地效果和复盘经验。这样组织出来的“屎山项目”,就不再只是你简历上的苦活,而是一段能回答系统设计、工程治理、风险控制和协作能力的高价值面经。

重构前:问题、风险与技术债

先别急着说“我把屎山重构了”。面试官真正想听的不是你吐槽代码有多烂,而是你能不能准确识别复杂性在哪里、风险边界在哪里、为什么值得动它。一个可复用的说法是:这是一个典型的订单模块,早期按 Controller -> Service -> DAO 快速堆出来,后来不断接入优惠券、库存、支付、风控、售后,最终所有规则都沉进了一个巨大的 OrderService

这种结构在很多遗留系统里都常见:表面上分了层,实际业务规则集中在 Service 里,领域对象只是 DTO/POJO。腾讯云开发者社区的订单系统重构案例也提到过类似问题:重构前的 OrderService 中混杂校验、库存计算、优惠券核销等逻辑,重构后才把订单、优惠券、金额计算等概念显性化为领域模型(见这个订单系统重构示例)。

重构前的代码大概长这样:

@Transactional
public Order createOrder(OrderDTO dto) {
    // 1. 参数校验:用户、地址、商品、活动、渠道
    if (dto.getUserId() == null || dto.getItems().isEmpty()) {
        throw new BizException("invalid order");
    }

// 2. 查库存并扣减
    for (OrderItemDTO item : dto.getItems()) {
        Stock stock = stockDao.query(item.getSkuId());
        if (stock.getAvailable() < item.getQuantity()) {
            throw new BizException("stock not enough");
        }
        stockDao.decrease(item.getSkuId(), item.getQuantity());
    }

// 3. 优惠券逻辑,夹杂活动规则
    BigDecimal total = calculateTotal(dto);
    if (dto.getCouponId() != null) {
        Coupon coupon = couponDao.query(dto.getCouponId());
        if (coupon != null && coupon.getExpireTime().after(new Date())) {
            total = total.subtract(coupon.getDiscount());
            couponDao.markUsed(dto.getCouponId());
        }
    }

// 4. 根据支付方式写不同分支
    if ("BALANCE".equals(dto.getPayType())) {
        walletService.freeze(dto.getUserId(), total);
    } else if ("ONLINE".equals(dto.getPayType())) {
        paymentService.createPrepay(dto.getUserId(), total);
    }

// 5. 落库、发消息、写日志
    Order order = buildOrder(dto, total);
    orderDao.insert(order);
    mq.send("order_created", order.getId());
    return order;
}

这段代码的问题不只是“方法太长”,而是业务不变量没有明确归属:订单金额在哪里保证一致?优惠券什么时候算占用、什么时候算核销?库存扣减失败后如何补偿?支付回调重复来了怎么办?这些规则散在 if-else、DAO 调用和外部服务调用之间,导致任何小需求都可能触发连锁反应。

可以把重构前的问题归成几类,面试时这样讲会更清楚:

问题类别

具体表现

真实风险

巨型 Service

一个 OrderService 同时处理下单、改价、取消、退款、通知

改一处逻辑,要回归多个业务流程

隐式业务规则

满减、会员价、渠道价散落在多个私有方法里

新活动上线时容易和旧规则冲突

事务边界混乱

扣库存、用券、创建支付单放在一个事务或半事务里

外部服务失败后出现库存、券、订单状态不一致

重复逻辑

创建订单、改订单、补单各自计算金额

同一笔订单在不同入口金额不一致

缺少测试保护

主要靠测试同学点页面和线上灰度发现问题

重构或改需求时不敢删代码,只能继续打补丁

跨模块耦合

订单直接查用户表、优惠券表、库存表

任一表结构或接口变更都会影响订单发布

开发痛点也很具体:新人接一个“加一个企业客户折扣”的需求,第一天在代码里搜 discountcouponprice,第二天才发现订单确认页、创建订单接口、支付前校验、售后退款各算了一遍价格。最危险的是,旧逻辑没人敢删,因为它可能是某个历史渠道、某个大客户、某次线上事故后的补丁。

这里有一个面试中很加分的表达:

我当时判断这个模块不是“代码风格差”,而是已经影响交付确定性:需求评估时间变长、回归范围不可控、线上问题定位依赖老员工记忆。所以重构前,我先把问题拆成复杂度、耦合、测试缺口和业务风险四类,而不是直接开新分支重写。

如果你有真实数据,可以补充得更有说服力,例如:

  • 最近 3 个月订单模块线上故障中,有多少和金额、库存、状态流转相关;
  • 一个普通订单需求从开发到联调平均需要几天;
  • OrderService 行数、圈复杂度、重复代码比例;
  • 核心流程是否有接口测试、单元测试、回归用例;
  • 发布失败后是否能快速回滚,是否依赖手工修数据。

没有数据也不要硬编。可以说:“当时没有完整度量,但我们用缺陷记录、发布回滚记录和代码扫描结果做了初步判断。”InfoQ 关于遗留系统重构的讨论也强调,重构前要分析业务需求和代码坏味道,并用圈复杂度、方法长度、重复率等指标让问题显性化(参考这篇遗留系统重构实践)。

最后,重构前最需要讲清楚的是风险边界:哪些地方不能随便动,哪些地方必须先补测试,哪些逻辑要找产品或老同事确认。比如订单系统里,金额计算、库存扣减、支付状态流转、退款补偿通常属于高风险区;后台列表字段、日志格式、内部查询条件可能是低风险区。你能把这些边界说清楚,后面的“怎么重构”才会显得可信,而不是一次冲动的技术洁癖。

重构后:架构变化与实际效果

重构后:架构变化与实际效果

重构后的核心不是“把代码写得更优雅”,而是把原来混在一个 OrderService 里的业务规则拆成可定位、可测试、可替换的模块。面试时不要只说“我用了 DDD / 策略模式 / 责任链”,要讲清楚:为什么拆、拆到哪里、怎么保证不炸。

这个订单模块最终采用的是“轻量领域模型 + 应用服务编排 + 策略扩展点”的结构:

Before
order
├── OrderController
├── OrderService          # 下单、库存、优惠、支付前校验、通知全在这里
├── OrderMapper
└── dto / entity / util

After
order
├── application
│   └── OrderAppService   # 编排流程:创建订单、锁库存、计算金额、提交支付
├── domain
│   ├── Order             # 聚合根:订单状态、明细、不变式
│   ├── OrderItem
│   ├── OrderCalculator   # 金额计算领域服务
│   └── promotion
│       ├── PromotionPolicy
│       ├── CouponPolicy
│       └── FullReductionPolicy
├── infrastructure
│   ├── OrderRepository
│   └── LegacyCouponAdapter
└── api
    └── OrderFacade

拆分时最关键的决策有三个。

第一,把“流程编排”和“业务规则”分开OrderAppService 只负责调用顺序,不再写几百行 if else。金额计算、优惠校验、订单状态流转下沉到领域对象或领域服务里。类似订单系统中把贫血 Service 拆成显式领域模型的做法,在腾讯云开发者社区的订单系统重构案例里也有类似示例:优惠券校验放到 Order.applyCoupon(),金额计算放到 OrderCalculator,这样规则不再散落在流程代码中。

第二,把高频变化点做成策略接口。比如优惠规则最容易变,不能每次活动都去改下单主链路:

public interface PromotionPolicy {
    boolean supports(PromotionContext context);
    Money discount(Order order, PromotionContext context);
}

public class CouponPolicy implements PromotionPolicy {
    public Money discount(Order order, PromotionContext context) {
        // 只处理优惠券规则,不关心下单流程
    }
}

这样面试官追问“新增一个满减活动要改哪里”时,你可以回答得很具体:新增一个 FullReductionPolicy,补对应单测,把策略注册到 PromotionPolicyFactory,主流程不动。这个答案比“我用了策略模式降低耦合”可信得多。

第三,新旧系统并存时加防腐层,不直接污染新模型。老优惠接口可能返回的是 Map<String, Object>,字段还可能有历史脏值。不要让新领域模型直接依赖它,而是用 LegacyCouponAdapter 做转换和兜底:

public class LegacyCouponAdapter {
    public Coupon toDomainCoupon(LegacyCouponDTO dto) {
        // 字段清洗、默认值、异常码转换
        return new Coupon(dto.getCouponId(), Money.of(dto.getAmount()));
    }
}

这类防腐层适合在面试里重点讲,因为它说明你不是“为了架构而架构”,而是在处理遗留系统的真实边界:脏数据、旧接口、不一致的异常语义。

实际效果要讲得克制,但要可验证。不要说“性能提升 10 倍”这种没有依据的话,可以这样表达:

维度

重构前

重构后

需求改动范围

改优惠规则要碰 createOrder() 主流程

多数情况下只新增或修改一个策略类

回归范围

下单、库存、优惠、支付全链路都要回归

优惠规则可先跑策略单测,再做核心链路回归

故障定位

日志集中在一个大方法里,难判断是哪段规则失败

按应用服务、领域服务、适配器分层定位

新人接手

先读完整 OrderService 才敢改

可从模块名定位到金额、优惠、库存等子域

发布风险

大量手工修改,回滚粒度粗

小步提交,按模块灰度或开关控制

如果有真实数据,可以补充“平均函数行数、圈复杂度、重复率、构建耗时、缺陷数”等指标;如果没有数据,就讲你如何度量。InfoQ 在遗留系统重构访谈中也强调,重构价值需要通过复杂度、类/方法长度、重复率、构建与发布效率这类指标显性化,而不是只靠主观感受。

最后,这个案例在面试里的关键经验可以收成一句话:

我没有选择一次性推倒重写,而是先圈定高频变化点,补核心链路测试,再用策略模式和防腐层把风险隔离;重构后的收益不是“代码变漂亮”,而是后续需求可以小范围修改、小范围测试、小范围发布。

这个总结很重要。很多“屎山项目重构”失败,不是因为模式没用对,而是因为把重构做成了大爆炸上线。真实项目里,最有价值的工程判断往往是:哪里必须拆,哪里暂时不动,哪里用测试和监控兜底。

用 GankInterview 的实时屏幕提示,自信应答下一场面试。

立即体验 GankInterview

相关文章

在职就是你最大的特权:如何在面试中开启“防守反击”,拿到心仪的定级溢价?
面试准备Jimmy Lauren

在职就是你最大的特权:如何在面试中开启“防守反击”,拿到心仪的定级溢价?

在职面试真正的红利,不在于“我还有工作”这一事实本身,而在于你拥有选择权、时间窗口和风险优势,并且能把这些优势转化为可审批的职级与薪资结果。大量定级成功案例反复...

Jul 1, 2026
LeetCode 终将被 AI 抹平,但数学永远是终极护城河:大模型时代的算法面试终局
面试准备Jimmy Lauren

LeetCode 终将被 AI 抹平,但数学永远是终极护城河:大模型时代的算法面试终局

在大模型全面渗透招聘流程之后,刷 LeetCode 正在迅速失去它曾经的区分度:代码可以被 AI 补全,套路可以被模型复述,模板化解题已经很难再证明一个候选人的...

Jun 6, 2026
写得一手好代码,却死在 HR 面?技术人如何用“营销产品”的思维重构 STAR 面试法
面试准备Jimmy Lauren

写得一手好代码,却死在 HR 面?技术人如何用“营销产品”的思维重构 STAR 面试法

很多技术人写得一手好代码,却在 HR 面和行为面里频频受挫,问题往往不在能力本身,而在于 STAR 面试的叙事方式选错了视角。真正拉开差距的技术人 STAR 面...

Jun 6, 2026
“你做过 5 万用户的爆款,为啥还来投简历?”:如何把独立开发经历,变成大厂面试时的最高筹码
面试准备Jimmy Lauren

“你做过 5 万用户的爆款,为啥还来投简历?”:如何把独立开发经历,变成大厂面试时的最高筹码

在当前的求职环境中,带着拥有数万用户的爆款产品去求职,往往被开发者视作降维打击的绝对优势,但在真实的独立开发经历大厂面试博弈中,这却是一把极具风险的双刃剑。站在...

Mar 20, 2026