【真实案例】一个电商系统的重构日志:50 人团队如何在不停机的情况下翻新系统
不是“重写一遍就好了”的爽文,而是一份真的踩过坑的重构日志:从立项、拆边界、灰度发布到指标回收,完整复盘一个 50 人电商团队如何在不停业务的前提下完成前端与 BFF 重构。
【真实案例】一个电商系统的重构日志:50 人团队如何在不停机的情况下翻新系统
周五晚上 23:40,大促开始前 20 分钟,运营突然发现商品详情页的“满减预估”展示错了。
表面上看,这只是一个前端文案问题。真正可怕的是:没有人敢立刻改。
原因很简单——这个页面背后缠着 4 个接口、3 套价格口径、2 个历史活动系统,还有一堆“先这么写,回头再整理”的临时代码。你改一个 computed,购物车可能炸;你补一个字段,订单确认页可能白屏;你修一个 bug,第二天埋点报表又会对不上。
这不是某一个人的代码写得烂。
这是系统已经超过了原来那套组织方式的承载上限。
这篇文章不讲“理想中的重构”,讲的是一次真实得有点狼狈的电商系统重构:
- 团队 50 人,前端 12 人,后端 18 人,测试 8 人,产品/运营 12 人
- 业务不能停,每周仍然要发版本
- 不能“大爆炸式重写”
- 必须在 6 周内交出阶段性结果
- 最终目标不是“代码更优雅”,而是需求周期、线上风险、发布效率都要变好
如果你正在接手一个“谁都知道该重构,但谁都不敢动”的项目,这篇会比“重构原则大全”更有用。
一、项目背景:系统不是一下子坏掉的
先交代基本盘。
1.1 业务形态
这是一个做消费电子的电商站,核心链路有四条:
- 商品浏览
- 搜索与筛选
- 购物车与结算
- 活动会场与优惠计算
大促期间,前端日活峰值能到 180 万,移动端占 82%。
1.2 技术栈现状
重构开始前的主栈如下:
| 层级 | 旧方案 | 典型问题 |
|---|---|---|
| 前端 | Vue 2 + Vuex + Webpack 4 | 页面过大、状态堆积、构建缓慢 |
| Node 中间层 | Express + 手写聚合逻辑 | 路由胖、容错弱、日志碎 |
| 后端 API | 多个 Java / Go 服务 | 字段口径不一致 |
| 监控 | Sentry + ELK + 自建埋点 | 监控割裂,无法串链路 |
| 发布 | Jenkins + 手工灰度 | 发版慢,回滚重 |
1.3 最危险的不是“代码丑”,而是“改动不可预测”
大家常把重构理解成“历史包袱太多”。
更准确的说法是:系统已经失去了局部改动的可预测性。
当时我们统计了 3 个月的数据:
| 指标 | 3 个月前 | 重构前 | 变化 |
|---|---|---|---|
| 首页首屏 JS 体积 | 420KB | 890KB | +112% |
| 商品详情页平均改动文件数 | 4.1 | 9.8 | +139% |
| MR 平均评审时长 | 28 分钟 | 97 分钟 | +246% |
| 热修复次数 / 月 | 3 | 11 | +267% |
| 大促前冻结时长 | 1 天 | 4 天 | +300% |
| 新人独立接需求时间 | 5 天 | 14 天 | +180% |
你会发现,真正触发重构的,从来不是“代码看着不舒服”,而是:
- 发布开始拖慢业务
- 团队协作成本被技术债吃掉
- 风险开始超过“继续忍”的阈值
二、先别急着重构:先判断是不是到了“必须动”的时点
在这个项目里,第一个关键决策不是“怎么重构”,而是:
现在动,值不值?
决策点 1:我们用什么信号来证明“该重构了”
我们没有用“工程师受不了了”做理由,而是用 4 组业务信号:
信号 A:需求交付变慢
过去一个商品详情页需求,从设计联调到上线,平均 6 个工作日;重构前已经拉长到 11.5 天。
信号 B:线上风险上升
线上 bug 不只是数量变多,而是影响面变大。一个价格模块改动,可能同时影响详情页、购物车、订单页。
信号 C:大促冻结越来越长
冻结时间越长,说明团队越不相信系统的稳定性。
信号 D:管理层已经开始感知成本
管理层不关心“store 太大了”,但关心:
- 为什么发版越来越慢?
- 为什么同样人手做不出更多需求?
- 为什么每逢大促就不敢动?
决策点 2:不用“大重写”,而是做“业务不中断的结构翻新”
这个选择,基本决定了项目是成功还是死亡。
我们当时拒绝了两个看起来很诱人的方案:
方案 A:新开一个仓库,全部重写
优点:干净,爽。
缺点:
- 6 周内不可能替换完整业务
- 重写期原系统还要继续改
- 双线开发会让团队直接分裂
- 最后大概率变成“新系统永远差一点”
方案 B:只做局部性能优化,不动结构
优点:短期见效快。
缺点:
- 修的是症状,不是病因
- 3 个月后还会回到原样
- 需求流继续把结构拖垮
最终选择:Strangler Fig(绞杀者)式渐进重构
思路是:
- 先找边界最清晰、业务收益最大的模块
- 新能力在新结构里长出来
- 旧系统只做必要维护
- 流量和职责逐步迁移
- 最后把旧实现“包住并替换掉”
这套打法的好处不是“优雅”,而是现实。
三、先画战场地图:重构前必须知道系统到底烂在哪
真正的重构,不是从改目录开始,而是从建立共享事实开始。
3.1 我们先做了一周“盘点”,而不是直接写代码
这一周只做 4 件事:
- 统计页面级性能与依赖
- 列出核心业务链路
- 标记高风险共用模块
- 画出“谁依赖谁”的实际图,而不是理想图
3.2 盘点结果:系统主要烂在 5 个点
| 问题 | 具体表现 | 后果 |
|---|---|---|
| 页面过胖 | 详情页单文件 1400+ 行 | 修改成本极高 |
| 状态无边界 | Vuex 一个 store 负责 11 类状态 | 改动互相污染 |
| 接口口径不统一 | 商品价格字段有 4 种命名 | 前端做了大量胶水转换 |
| 组件与业务缠死 | UI 组件里直接请求接口 | 无法复用、难测 |
| 发布缺少隔离 | 没有按能力灰度,只能整站发 | 风险放大 |
3.3 我们定义了新的目标结构
不是“上某个框架”,而是建立 4 条工程纪律:
- 页面只负责编排,不直接承载复杂业务规则
- 跨页面复用逻辑沉到领域层 / composable / BFF facade
- 状态只按边界持有,不按“方便”全局共享
- 每个迁移都必须能灰度、能回滚、能量化收益
对应的目标结构如下:
apps/web/
├── pages/
│ ├── product-detail/
│ ├── cart/
│ └── checkout/
├── features/
│ ├── pricing/
│ ├── promotion/
│ ├── inventory/
│ └── recommendation/
├── shared/
│ ├── ui/
│ ├── lib/
│ └── telemetry/
└── bff/
├── product/
├── order/
└── promotion/
这里最重要的不是目录本身,而是:
features负责领域能力shared负责无业务语义的可复用部分bff负责后端口径适配与容错pages只做组装与页面路由
四、6 周是怎么拆出来的:不是按技术拆,是按风险拆
决策点 3:先拆哪条链路
很多团队一重构就上来先动“最烂的地方”。
这是错的。
应该先动:
- 收益高
- 边界相对清晰
- 失败了也不会把业务整体拖死
我们最后选的是:
- 商品详情页的价格与活动计算链路
- 购物车价格汇总链路
- BFF 的聚合层
原因很简单:
- 这些模块是大促高频路径
- 之前故障也集中在这里
- 它们虽然复杂,但边界比搜索推荐清晰
- 改好了,业务能立刻看到价值
4.1 六周计划表
| 周次 | 目标 | 产出 | 风险控制 |
|---|---|---|---|
| 第 1 周 | 盘点与基线建立 | 指标看板、依赖图、迁移清单 | 不改线上逻辑 |
| 第 2 周 | 搭新骨架 | feature 目录、BFF facade、日志标准 | 新旧并存 |
| 第 3 周 | 迁移价格计算 | pricing 模块落地 | 开关控制 |
| 第 4 周 | 迁移活动逻辑 | promotion 模块落地 | 影子比对 |
| 第 5 周 | 购物车与结算接入 | 关键页面接新链路 | 10% 灰度 |
| 第 6 周 | 全量切换与回收旧代码 | 指标复盘、删除旧实现 | 保留回滚路径 |
决策点 4:禁止“同时优化所有问题”
这次重构刻意没有做这些事:
- 没有同步把 Vue 2 全量升级成 Vue 3
- 没有顺手换掉所有 UI 组件库
- 没有一并改 SSR 方案
- 没有顺便重做埋点体系
因为每多一个目标,项目就少一分成功率。
重构必须有主目标:
降低关键业务链路的耦合与发布风险。
只要不直接服务这个目标,就延后。
五、第一刀怎么下:先做 BFF 防腐层,而不是先改页面
决策点 5:我们为什么先重构 BFF
前端团队一开始都想直接改页面。毕竟页面最痛。
但如果后端口径还是乱的,前端再怎么拆都只是把脏逻辑从一个文件搬到多个文件。
所以第一刀砍在 BFF。
5.1 旧问题:前端页面自己做字段适配
当时商品详情页里有这样的代码:
const finalPrice =
product.activityPrice
?? product.promoPrice
?? product.salePrice
?? product.price
const inventory = product.stock ?? product.inventoryCount ?? 0
const canUseCoupon = product.couponFlag === 1 || product.marketing?.couponEnabled === true
看上去只是“兼容一下字段差异”。
实际上,这意味着:
- 接口变化会在多个页面复制扩散
- 业务规则被埋在 UI 层
- 没法统一测试
- 页面开发者必须理解后端历史包袱
5.2 新做法:BFF 统一口径,页面只消费标准模型
// bff/product/getProductView.ts
interface ProductViewModel {
id: string
title: string
originPrice: number
finalPrice: number
inventory: number
promotionTags: string[]
couponAvailable: boolean
}
export async function getProductView(productId: string): Promise<ProductViewModel> {
const [product, promotion, inventory] = await Promise.all([
productService.fetch(productId),
promotionService.fetch(productId),
inventoryService.fetch(productId),
])
return {
id: product.id,
title: product.title,
originPrice: product.marketPrice,
finalPrice: resolveFinalPrice(product, promotion),
inventory: inventory.available,
promotionTags: resolvePromotionTags(promotion),
couponAvailable: Boolean(promotion.coupon?.enabled),
}
}
这个改动的意义,不在于“代码更漂亮”,而在于职责被重新划分了:
- 后端异构 -> BFF 负责消化
- 领域规则 -> facade 负责封装
- 页面 -> 只拿标准结果渲染
这一步做完,前端页面复杂度立刻下降了一截。
六、第二刀:把“价格逻辑”从页面里剥出来
决策点 6:先抽“变化频繁且最容易出事故”的规则
我们统计了过去 2 个月的 bug,发现价格相关占了 37%。
所以第二刀,切的是 pricing。
6.1 旧实现的问题
旧代码把这些规则都揉在页面里:
- 原价 / 到手价展示
- 会员价优先级
- 限时活动覆盖逻辑
- 优惠券可叠加规则
- 埋点打点
结果就是:
- 任何一个价格逻辑变更,都要碰 UI
- 同一套规则在详情页、购物车、结算页写了 3 次
- 测试只能靠手点
6.2 新实现:定价规则内聚到领域模块
// features/pricing/domain/resolvePrice.ts
export interface PriceContext {
basePrice: number
memberPrice?: number
flashSalePrice?: number
couponDiscount?: number
isMember: boolean
now: number
flashSaleStartAt?: number
flashSaleEndAt?: number
}
export function resolvePrice(ctx: PriceContext) {
const inFlashSale =
ctx.flashSalePrice !== undefined &&
ctx.flashSaleStartAt !== undefined &&
ctx.flashSaleEndAt !== undefined &&
ctx.now >= ctx.flashSaleStartAt &&
ctx.now <= ctx.flashSaleEndAt
let current = ctx.basePrice
let source = 'base'
if (ctx.isMember && ctx.memberPrice !== undefined && ctx.memberPrice < current) {
current = ctx.memberPrice
source = 'member'
}
if (inFlashSale && ctx.flashSalePrice! < current) {
current = ctx.flashSalePrice!
source = 'flash-sale'
}
if (ctx.couponDiscount) {
current = Math.max(0, current - ctx.couponDiscount)
}
return {
finalPrice: current,
source,
savedAmount: Math.max(0, ctx.basePrice - current),
}
}
这样做带来了 3 个直接收益:
- 页面不再理解复杂价格优先级
- 购物车和结算页可直接复用同一规则
- 可以把最容易出事故的逻辑做成纯函数测试
6.3 配套测试不是“可选项”
// features/pricing/domain/resolvePrice.spec.ts
import { describe, expect, it } from 'vitest'
import { resolvePrice } from './resolvePrice'
describe('resolvePrice', () => {
it('会员价低于原价时应优先生效', () => {
const result = resolvePrice({
basePrice: 3999,
memberPrice: 3699,
isMember: true,
now: Date.now(),
})
expect(result.finalPrice).toBe(3699)
expect(result.source).toBe('member')
})
it('限时活动价低于会员价时应覆盖会员价', () => {
const now = Date.now()
const result = resolvePrice({
basePrice: 3999,
memberPrice: 3699,
flashSalePrice: 3499,
flashSaleStartAt: now - 1000,
flashSaleEndAt: now + 1000,
isMember: true,
now,
})
expect(result.finalPrice).toBe(3499)
expect(result.source).toBe('flash-sale')
})
})
以前一次价格改动要 QA 走半天页面;现在先跑规则测试,再做页面回归。
不是不需要人工验证,而是人工验证终于不再是唯一防线。
七、第三刀:引入功能开关和影子比对,不让切换变成赌博
决策点 7:任何重构路径,都必须支持“新旧并行”
很多失败的重构,问题不在代码,而在切换方式。
如果你只能“今晚全量切新逻辑”,那不是发布,是祈祷。
我们做了两件事:
- 功能开关:控制哪些流量走新链路
- 影子比对:新旧链路同时算,结果不上屏,只做差异监控
7.1 功能开关控制切流
// shared/lib/featureFlags.ts
export async function shouldUseNewPricing(ctx: {
userId?: string
region: string
isBigPromotion: boolean
}) {
if (ctx.isBigPromotion) return false
if (ctx.region !== 'cn-east-1') return false
const bucket = hashToPercent(ctx.userId || 'guest')
return bucket < 10
}
这样我们可以做到:
- 非大促时只给 10% 用户走新逻辑
- 指定区域先试
- 有问题一键回旧逻辑
7.2 影子比对盯差异,不直接上生产结果
// bff/pricing/getPriceWithShadowCompare.ts
export async function getPriceWithShadowCompare(input: PriceInput) {
const legacy = await legacyPricingService.calculate(input)
const modern = await modernPricingService.calculate(input)
if (legacy.finalPrice !== modern.finalPrice) {
telemetry.capture('pricing_diff_detected', {
skuId: input.skuId,
legacyPrice: legacy.finalPrice,
modernPrice: modern.finalPrice,
diff: modern.finalPrice - legacy.finalPrice,
})
}
return shouldExposeModernResult(input.userId) ? modern : legacy
}
这套机制帮我们在正式切换前发现了两个隐藏问题:
- 某类店铺券在旧系统里有“向下取整”规则,新实现漏了
- 某些组合活动的生效顺序不是“平台券后店铺券”,而是相反
如果没有影子比对,这两个 bug 会直接在真实用户账单里爆炸。
八、第四刀:团队重组,不然好结构也会被旧习惯拖回去
重构的对象不只是代码,还是协作方式。
决策点 8:从“按页面分工”改成“按领域负责”
原来团队分法是:
- A 负责首页
- B 负责详情页
- C 负责购物车
- D 负责结算页
听上去合理,但问题是:价格、活动、库存这些能力横跨多个页面。于是同一套逻辑会被不同人各写一份。
我们改成了:
| 小组 | 负责领域 | 典型职责 |
|---|---|---|
| Pricing 小组 | 定价与优惠 | 到手价、会员价、活动叠加 |
| Promotion 小组 | 活动编排 | 会场、优惠券、促销标签 |
| Order 小组 | 购物车与结算 | 下单链路、订单确认 |
| Shared 小组 | 组件与基础库 | UI、埋点、工具链 |
这不是为了“组织设计很先进”,而是为了让代码边界和团队边界尽量一致。
决策点 9:代码所有权必须明确
我们加了 CODEOWNERS 风格的责任归属:
features/pricing/**:Pricing 小组必须 reviewbff/product/**:BFF owner 必须 reviewshared/ui/**:基础组 review
直接效果是:
- Review 不再“谁有空谁看”
- 规则改动不再绕过真正懂上下文的人
- 模块演进开始有连续性
九、第五刀:埋点、日志、告警先统一,不然根本不知道自己有没有成功
决策点 10:没有统一观测,重构收益无法被证明
工程团队很容易掉进一个坑:
觉得“代码更整洁了”,就等于重构成功了。
管理层不会为“整洁”买单,只会为风险下降、速度提升、收入不受损买单。
所以我们把观测做成了强约束。
9.1 统一日志字段
// shared/telemetry/logger.ts
export function logBffEvent(event: string, payload: Record<string, unknown>) {
console.log(JSON.stringify({
event,
ts: Date.now(),
service: 'web-bff',
traceId: payload.traceId,
page: payload.page,
userId: payload.userId,
skuId: payload.skuId,
version: payload.version,
...payload,
}))
}
统一字段后,我们终于能把:
- 页面埋点
- BFF 日志
- 接口错误
- 发布版本
串成一条链路看。
9.2 统一核心看板
重构期间只盯这 6 个指标:
| 指标 | 为什么重要 | 阈值 |
|---|---|---|
| 价格计算差异率 | 判断新旧逻辑是否一致 | < 0.05% |
| 商品详情页 JS 错误率 | 判断页面稳定性 | < 0.3% |
| 结算转化率 | 防止重构伤业务 | 不得下降 > 1% |
| 页面首屏耗时 P75 | 量化性能收益 | 下降 20%+ |
| MR 平均评审时长 | 量化工程收益 | 下降 30%+ |
| 回滚次数 | 判断切换策略是否稳健 | 最好多周为 0 |
这让重构从“工程自嗨”变成了一个可被业务接受的项目。
十、最关键的一次会议:怎么拿到管理层支持
决策点 11:重构提案必须用业务语言,而不是技术语言
如果你去和管理层说:
- Vuex 太重了
- 模块边界不清晰
- BFF 缺少防腐层
大概率没人点头。
我们最后拿到支持,是因为提案写成了下面这种:
| 问题 | 当前损失 | 重构后预期 | 对业务的意义 |
|---|---|---|---|
| 需求周期拉长 | 每月少做约 8 个需求点 | 缩回 20%-30% | 提高交付量 |
| 大促冻结过长 | 活动窗口被压缩 | 冻结从 4 天降到 2 天 | 增加运营空间 |
| 线上热修复频繁 | 团队被动救火 | 月热修减少 50% | 降低事故成本 |
| 页面性能变差 | 转化率承压 | 首屏 P75 降 25% | 直接影响成交 |
同时我们承诺了三件事:
- 只做 6 周,不是无限期项目
- 业务不停,不影响正常版本节奏
- 每周给数据,随时决定是否继续
这类项目想拿资源,最重要的不是你说得多专业,而是让老板看到:
这不是“技术想折腾”,而是在买回组织效率。
十一、切换那一周:真正考验不是代码,而是纪律
决策点 12:切换必须有明确的回滚剧本
第 6 周切全量前,我们写了一份回滚 Runbook,只回答 3 个问题:
- 什么指标触发回滚?
- 谁拍板?
- 多久能回去?
回滚条件很具体:
- 价格差异率 > 0.1%
- 结算转化率 30 分钟内下降 > 2%
- 商品详情页 JS 错误率 > 0.5%
- 客服工单 1 小时内异常增长
11.1 发布剧本
T-2 天:完成全量回归
T-1 天:10% -> 30% 灰度
T 日 10:00:30% -> 60%
T 日 14:00:观察四小时核心指标
T 日 18:00:60% -> 100%
T 日 18:10:冻结非必要发布
T+1 天:删除旧分支上的应急 patch only 路径
11.2 真正发生了什么
切 60% 时,确实出了一个问题:
某类“预售 + 店铺券 + 跨店满减”的组合活动,在购物车页的优惠文案展示顺序错了,价格没错,但文案顺序和老逻辑不一致。
如果没有预先定义“价格问题”和“展示问题”的处置优先级,现场会很乱。
最终处理:
- 价格逻辑不回滚
- 文案映射临时 patch
- 继续停在 60% 观察 2 小时
- 当晚 21:30 再推进到 100%
这里的经验很重要:
灰度不是为了“证明你没问题”,而是为了“让你带着问题也能稳住局面”。
十二、六周后,我们到底换回了什么
重构不是做完 merge request 就算结束。
最后看的是结果。
12.1 技术指标变化
| 指标 | 重构前 | 重构后 | 变化 |
|---|---|---|---|
| 商品详情页核心文件最大行数 | 1430 | 220 | -84.6% |
| 价格规则重复实现处数 | 3 | 1 | -66.7% |
| 首屏 JS 包体积 | 890KB | 540KB | -39.3% |
| 详情页首屏时间 P75 | 2.8s | 1.9s | -32.1% |
| 构建时间 | 11m 20s | 6m 40s | -41.2% |
| 单测覆盖率(关键模块) | 18% | 71% | +53pt |
12.2 团队效率变化
| 指标 | 重构前 | 重构后 | 变化 |
|---|---|---|---|
| MR 平均评审时长 | 97 分钟 | 42 分钟 | -56.7% |
| 需求平均交付周期 | 11.5 天 | 7.2 天 | -37.4% |
| 月热修复次数 | 11 | 4 | -63.6% |
| 新人独立承担小需求时间 | 14 天 | 6 天 | -57.1% |
12.3 业务指标变化
| 指标 | 重构前 | 重构后 | 变化 |
|---|---|---|---|
| 结算页转化率 | 41.8% | 43.1% | +1.3pt |
| 大促冻结时长 | 4 天 | 2 天 | -50% |
| 价格相关客服工单 / 周 | 128 | 51 | -60.2% |
这些数字不代表“重构万能”。
它们只说明一件事:
当你把结构、职责、切换策略和观测体系一起重做,系统会重新变得可控。
十三、这次重构最值钱的 7 个教训
教训 1:不要从“最烂的文件”开始,从“最值得的链路”开始
最烂的地方往往边界最糊,先碰容易把自己拖死。
教训 2:先做口径统一,再做页面拆分
接口口径不统一,前端再怎么模块化也只是换地方写脏逻辑。
教训 3:没有开关和影子比对,就不要谈渐进重构
你必须允许新旧逻辑并行存在一段时间。
教训 4:团队边界要和代码边界对齐
否则新结构只是目录层面的自我安慰。
教训 5:重构项目必须有硬指标,不然永远说不清有没有做成
“感觉更好了”不是复盘结论。
教训 6:不要顺手把所有技术升级一起做了
重构最怕“顺便”。
教训 7:回滚剧本要在发布前写,不要在事故中现想
真正稳的团队,不是不会出问题,而是出问题时动作很一致。
十四、如果你也准备重构,可以直接抄这份检查清单
启动前检查
- 有没有量化证据证明“现在不动更贵”
- 有没有明确主目标,而不是顺便解决一堆问题
- 有没有确定首批迁移链路
- 有没有统一的指标面板
- 有没有可灰度、可回滚的开关能力
实施中检查
- 新旧逻辑是否能并行一段时间
- 是否建立了模块 owner
- 关键规则是否有单测覆盖
- 日志字段是否统一
- 每周是否能复盘实际收益
切换前检查
- 是否写好回滚 Runbook
- 是否定义了触发回滚的阈值
- 是否区分“展示错误”和“结算错误”的优先级
- 是否验证客服、运营、测试都知道应急路径
总结
这次电商系统重构,最让我确定的一件事是:
真正的重构,不是把旧代码删掉,而是把系统重新变成“人能理解、团队能协作、发布能控制”的状态。
它不是一次性的大扫除,更像是在高速行驶的车上换掉松动的零件。
你不能指望完全不冒险。
但你可以做到:
- 每一步都可观察
- 每一次切换都可回退
- 每一个收益都能量化
这样,重构就不再是“工程师的信仰之战”,而会变成一笔算得清的工程投资。
最后送你一句很适合重构现场的话:
系统最危险的时候,不是它已经很乱,而是所有人都习惯了它很乱。
等大家都把“发版前先拜一下”当成流程的一部分时,说明真的该动刀了。