【真实案例】一个电商系统的重构日志:50 人团队如何在不停机的情况下翻新系统

不是“重写一遍就好了”的爽文,而是一份真的踩过坑的重构日志:从立项、拆边界、灰度发布到指标回收,完整复盘一个 50 人电商团队如何在不停业务的前提下完成前端与 BFF 重构。

16 分钟阅读
小明

【真实案例】一个电商系统的重构日志:50 人团队如何在不停机的情况下翻新系统

周五晚上 23:40,大促开始前 20 分钟,运营突然发现商品详情页的“满减预估”展示错了。

表面上看,这只是一个前端文案问题。真正可怕的是:没有人敢立刻改。

原因很简单——这个页面背后缠着 4 个接口、3 套价格口径、2 个历史活动系统,还有一堆“先这么写,回头再整理”的临时代码。你改一个 computed,购物车可能炸;你补一个字段,订单确认页可能白屏;你修一个 bug,第二天埋点报表又会对不上。

这不是某一个人的代码写得烂。

这是系统已经超过了原来那套组织方式的承载上限

这篇文章不讲“理想中的重构”,讲的是一次真实得有点狼狈的电商系统重构:

  • 团队 50 人,前端 12 人,后端 18 人,测试 8 人,产品/运营 12 人
  • 业务不能停,每周仍然要发版本
  • 不能“大爆炸式重写”
  • 必须在 6 周内交出阶段性结果
  • 最终目标不是“代码更优雅”,而是需求周期、线上风险、发布效率都要变好

如果你正在接手一个“谁都知道该重构,但谁都不敢动”的项目,这篇会比“重构原则大全”更有用。


一、项目背景:系统不是一下子坏掉的

先交代基本盘。

1.1 业务形态

这是一个做消费电子的电商站,核心链路有四条:

  1. 商品浏览
  2. 搜索与筛选
  3. 购物车与结算
  4. 活动会场与优惠计算

大促期间,前端日活峰值能到 180 万,移动端占 82%。

1.2 技术栈现状

重构开始前的主栈如下:

层级旧方案典型问题
前端Vue 2 + Vuex + Webpack 4页面过大、状态堆积、构建缓慢
Node 中间层Express + 手写聚合逻辑路由胖、容错弱、日志碎
后端 API多个 Java / Go 服务字段口径不一致
监控Sentry + ELK + 自建埋点监控割裂,无法串链路
发布Jenkins + 手工灰度发版慢,回滚重

1.3 最危险的不是“代码丑”,而是“改动不可预测”

大家常把重构理解成“历史包袱太多”。

更准确的说法是:系统已经失去了局部改动的可预测性

当时我们统计了 3 个月的数据:

指标3 个月前重构前变化
首页首屏 JS 体积420KB890KB+112%
商品详情页平均改动文件数4.19.8+139%
MR 平均评审时长28 分钟97 分钟+246%
热修复次数 / 月311+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(绞杀者)式渐进重构

思路是:

  1. 先找边界最清晰、业务收益最大的模块
  2. 新能力在新结构里长出来
  3. 旧系统只做必要维护
  4. 流量和职责逐步迁移
  5. 最后把旧实现“包住并替换掉”

这套打法的好处不是“优雅”,而是现实


三、先画战场地图:重构前必须知道系统到底烂在哪

真正的重构,不是从改目录开始,而是从建立共享事实开始。

3.1 我们先做了一周“盘点”,而不是直接写代码

这一周只做 4 件事:

  1. 统计页面级性能与依赖
  2. 列出核心业务链路
  3. 标记高风险共用模块
  4. 画出“谁依赖谁”的实际图,而不是理想图

3.2 盘点结果:系统主要烂在 5 个点

问题具体表现后果
页面过胖详情页单文件 1400+ 行修改成本极高
状态无边界Vuex 一个 store 负责 11 类状态改动互相污染
接口口径不统一商品价格字段有 4 种命名前端做了大量胶水转换
组件与业务缠死UI 组件里直接请求接口无法复用、难测
发布缺少隔离没有按能力灰度,只能整站发风险放大

3.3 我们定义了新的目标结构

不是“上某个框架”,而是建立 4 条工程纪律:

  1. 页面只负责编排,不直接承载复杂业务规则
  2. 跨页面复用逻辑沉到领域层 / composable / BFF facade
  3. 状态只按边界持有,不按“方便”全局共享
  4. 每个迁移都必须能灰度、能回滚、能量化收益

对应的目标结构如下:

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:先拆哪条链路

很多团队一重构就上来先动“最烂的地方”。

这是错的。

应该先动:

  • 收益高
  • 边界相对清晰
  • 失败了也不会把业务整体拖死

我们最后选的是:

  1. 商品详情页的价格与活动计算链路
  2. 购物车价格汇总链路
  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 个直接收益:

  1. 页面不再理解复杂价格优先级
  2. 购物车和结算页可直接复用同一规则
  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:任何重构路径,都必须支持“新旧并行”

很多失败的重构,问题不在代码,而在切换方式。

如果你只能“今晚全量切新逻辑”,那不是发布,是祈祷。

我们做了两件事:

  1. 功能开关:控制哪些流量走新链路
  2. 影子比对:新旧链路同时算,结果不上屏,只做差异监控

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
}

这套机制帮我们在正式切换前发现了两个隐藏问题:

  1. 某类店铺券在旧系统里有“向下取整”规则,新实现漏了
  2. 某些组合活动的生效顺序不是“平台券后店铺券”,而是相反

如果没有影子比对,这两个 bug 会直接在真实用户账单里爆炸。


八、第四刀:团队重组,不然好结构也会被旧习惯拖回去

重构的对象不只是代码,还是协作方式。

决策点 8:从“按页面分工”改成“按领域负责”

原来团队分法是:

  • A 负责首页
  • B 负责详情页
  • C 负责购物车
  • D 负责结算页

听上去合理,但问题是:价格、活动、库存这些能力横跨多个页面。于是同一套逻辑会被不同人各写一份。

我们改成了:

小组负责领域典型职责
Pricing 小组定价与优惠到手价、会员价、活动叠加
Promotion 小组活动编排会场、优惠券、促销标签
Order 小组购物车与结算下单链路、订单确认
Shared 小组组件与基础库UI、埋点、工具链

这不是为了“组织设计很先进”,而是为了让代码边界和团队边界尽量一致。

决策点 9:代码所有权必须明确

我们加了 CODEOWNERS 风格的责任归属:

  • features/pricing/**:Pricing 小组必须 review
  • bff/product/**:BFF owner 必须 review
  • shared/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%直接影响成交

同时我们承诺了三件事:

  1. 只做 6 周,不是无限期项目
  2. 业务不停,不影响正常版本节奏
  3. 每周给数据,随时决定是否继续

这类项目想拿资源,最重要的不是你说得多专业,而是让老板看到:

这不是“技术想折腾”,而是在买回组织效率。


十一、切换那一周:真正考验不是代码,而是纪律

决策点 12:切换必须有明确的回滚剧本

第 6 周切全量前,我们写了一份回滚 Runbook,只回答 3 个问题:

  1. 什么指标触发回滚?
  2. 谁拍板?
  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 技术指标变化

指标重构前重构后变化
商品详情页核心文件最大行数1430220-84.6%
价格规则重复实现处数31-66.7%
首屏 JS 包体积890KB540KB-39.3%
详情页首屏时间 P752.8s1.9s-32.1%
构建时间11m 20s6m 40s-41.2%
单测覆盖率(关键模块)18%71%+53pt

12.2 团队效率变化

指标重构前重构后变化
MR 平均评审时长97 分钟42 分钟-56.7%
需求平均交付周期11.5 天7.2 天-37.4%
月热修复次数114-63.6%
新人独立承担小需求时间14 天6 天-57.1%

12.3 业务指标变化

指标重构前重构后变化
结算页转化率41.8%43.1%+1.3pt
大促冻结时长4 天2 天-50%
价格相关客服工单 / 周12851-60.2%

这些数字不代表“重构万能”。

它们只说明一件事:

当你把结构、职责、切换策略和观测体系一起重做,系统会重新变得可控。


十三、这次重构最值钱的 7 个教训

教训 1:不要从“最烂的文件”开始,从“最值得的链路”开始

最烂的地方往往边界最糊,先碰容易把自己拖死。

教训 2:先做口径统一,再做页面拆分

接口口径不统一,前端再怎么模块化也只是换地方写脏逻辑。

教训 3:没有开关和影子比对,就不要谈渐进重构

你必须允许新旧逻辑并行存在一段时间。

教训 4:团队边界要和代码边界对齐

否则新结构只是目录层面的自我安慰。

教训 5:重构项目必须有硬指标,不然永远说不清有没有做成

“感觉更好了”不是复盘结论。

教训 6:不要顺手把所有技术升级一起做了

重构最怕“顺便”。

教训 7:回滚剧本要在发布前写,不要在事故中现想

真正稳的团队,不是不会出问题,而是出问题时动作很一致。


十四、如果你也准备重构,可以直接抄这份检查清单

启动前检查

  • 有没有量化证据证明“现在不动更贵”
  • 有没有明确主目标,而不是顺便解决一堆问题
  • 有没有确定首批迁移链路
  • 有没有统一的指标面板
  • 有没有可灰度、可回滚的开关能力

实施中检查

  • 新旧逻辑是否能并行一段时间
  • 是否建立了模块 owner
  • 关键规则是否有单测覆盖
  • 日志字段是否统一
  • 每周是否能复盘实际收益

切换前检查

  • 是否写好回滚 Runbook
  • 是否定义了触发回滚的阈值
  • 是否区分“展示错误”和“结算错误”的优先级
  • 是否验证客服、运营、测试都知道应急路径

总结

这次电商系统重构,最让我确定的一件事是:

真正的重构,不是把旧代码删掉,而是把系统重新变成“人能理解、团队能协作、发布能控制”的状态。

它不是一次性的大扫除,更像是在高速行驶的车上换掉松动的零件。

你不能指望完全不冒险。

但你可以做到:

  • 每一步都可观察
  • 每一次切换都可回退
  • 每一个收益都能量化

这样,重构就不再是“工程师的信仰之战”,而会变成一笔算得清的工程投资。

最后送你一句很适合重构现场的话:

系统最危险的时候,不是它已经很乱,而是所有人都习惯了它很乱。

等大家都把“发版前先拜一下”当成流程的一部分时,说明真的该动刀了。