单体拆分的第一课:不是拆代码,是拆边界——DDD 限界上下文实战

引言

公司电商单体三年长到了 80 万行:一个 war 包里装着用户、订单、库存、支付、营销五个业务域,开发者 40 多人往同一个仓库里提交代码。CTO 终于拍板拆微服务,第一个专题会的议题却是让所有人都沉默的一问——"那我们怎么切?

研发 Leader 的直觉方案是按现有包结构切:com.shop.user 拆成用户服务、com.shop.order 拆成订单服务。看起来天经地义,但第一个反对声音来自订单组的资深开发:"订单里查用户收货地址是直接 join 用户表的,拆了服务这个 join 怎么办?"第二个反对来自库存组:"订单创建要锁库存、扣库存、释放库存,这是三个服务还是放订单里?"

这两个问题都不是代码问题,是边界问题。 单体时代用"共享数据库"偷偷掩盖了所有边界争议——join 一下就完事了,谁也不用想"收货地址到底属于用户域还是订单域"。拆分的第一课就是把这笔账算清楚:边界不清,拆出去的服务只是"分布式的单体",接口间的 join 变成 RPC 调用,性能更差、复杂度更高

这篇文章用这个真实电商单体做案例,把"拆边界"的全过程走一遍:用 DDD 限界上下文识别业务边界 → 数据拆分策略 → 拆分优先级 → 常见反模式,文末附一份可以直接开会用的边界识别实操清单。


一、为什么"按包结构切"是错的:DDD 的边界观

1.1 单体里的三个假象

拆分前要打破对单体代码结构的三个迷信:

假象真相
包结构 = 业务边界包结构是三年里几十个人的随手分层,service 包里互相调用成网,早没有边界
一张表只属于一个模块订单 join 用户表、营销 join 订单表——表是"全局共享内存",边界全靠默契
一个模块一个团队就清晰模块按技术分层(controller/service/dao),按业务切时谁也不知道刀往哪下

DDD(领域驱动设计)给出的核心工具就是解决"刀往哪下":限界上下文(Bounded Context)——一个明确的业务边界,边界内有一套自洽的模型和语言。

1.2 限界上下文 vs 模块 vs 微服务:三个词的关系

概念定义关系
限界上下文逻辑边界:一套模型只在边界内成立先有它,才有资格谈拆
模块代码组织单元单体里用模块"预演"边界
微服务物理部署单元一个限界上下文可以对应一个(或多个)微服务,但不是一一映射的强制要求

关键认知:限界上下文是逻辑概念,先在单体里用模块划分出来、验证边界是否站得住,再谈物理拆分。直接"服务化"跳过了验证环节,是拆分失败的头号原因。


二、识别边界:四步找到你的限界上下文

2.1 第一步:事件风暴——从业务事件倒推边界

别从代码出发,从业务出发。拉上产品、运营、各域开发,白板上列"这个系统里发生了哪些业务事件":

电商系统的业务事件(部分):
  用户注册了 / 用户更新了收货地址        ← 用户域
  订单被创建 / 订单被取消 / 订单已支付    ← 订单域
  库存被预占 / 库存被扣减 / 库存被释放    ← 库存域
  支付发起 / 支付成功 / 退款完成         ← 支付域
  优惠券被领取 / 优惠券被核销            ← 营销域

然后问两个黄金问题:

  1. 这个事件由谁关心、谁会做出反应?——"订单已支付"事件:库存域扣减、支付域对账、营销域核销优惠券、通知域发短信。反应者越多,说明事件越核心;
  2. 这个事件的"语言"在谁那里最自洽?——"库存"在订单域的语言里是"订单明细的商品数量",在库存域的语言里是"可售库存/预占库存/锁定库存"。同一个词在不同上下文里含义不同,含义不同就是边界所在

2.2 第二步:找"统一语言"的分歧点

DDD 最实用的判定技巧:同一个名词在两个模块里需要"翻译",就该切一刀

用户域语境订单域语境支付域语境判定
用户完整档案:注册/地址/实名/偏好只是一个 userId + 收货地址快照付款人标识"订单里的用户"≠"用户档案"→ 订单域不依赖用户域内部模型,只依赖 userId
商品商品详情:图文/分类/SKU下单快照:名称/价格/图(成交时刻)交易摘要订单永远用"下单时刻快照",商品改价不影响历史订单 → 快照属订单域
库存只关心"能不能买到"(可售数量>0)订单域调用库存域的"预占"能力,但不读库存的仓库/批次模型

分歧点清清楚楚之后,收货地址 join 的问题自动有了答案:订单域保存"收货地址快照"(下单时刻的地址值拷贝),不实时 join 用户表。这不是妥协,是正确性——用户今天改了地址,昨天的订单应该送到昨天的地址。

2.3 第三步:画上下文映射图(Context Map)

边界找到了,边界之间怎么协作要显式画出来:

┌─────────────┐
        ACL/防腐层   │   订单上下文  │
  ┌───────────────▶│  (核心域)   │
  │                └──┬───────┬──┘
  │     OHS/事件订阅    │       │  ACL
┌─┴──────────┐   ┌─────▼───┐ ┌─▼────────┐
│ 用户上下文   │   │ 库存上下文│ │ 支付上下文 │
│(通用域)    │   │(核心域) │ │(通用域)  │
└────────────┘   └─────────┘ └──────────┘

图例:
  ACL(防腐层):订单服务调用用户服务时,包一层适配器,用户模型的
                 变化不渗透进订单代码
  OHS(开放主机服务):用户服务对外暴露稳定的 userId/地址 API
  事件订阅:订单已支付 → 库存/营销/通知通过 MQ 订阅,异步解耦

映射关系的类型要刻意区分:核心域之间用事件解耦(订单已支付 → 库存扣减,异步化,失败可补偿);核心域对通用域用防腐层隔离(订单调用户,包一层,用户服务重构不连坐订单)。这些映射模式直接决定了后面拆服务的调用拓扑。

2.4 第四步:用"改一个需求要动几个上下文"验证边界

边界的最终检验标准:一个典型的业务需求,理想情况下只落在 1 个上下文里,最多 2 个

验证用例 1:"支持商品预售,先付定金"(营销域新玩法)
  → 营销上下文 + 订单上下文(新增定金单类型)→ 2 个,可接受 ✅

验证用例 2:"用户注销后订单数据保留但匿名化"
  → 用户上下文(注销流程)+ 订单上下文(匿名化历史单)→ 2 个,边界成立 ✅

验证用例 3:"改一下订单金额的计算精度"
  → 如果同时要动 订单/营销/支付 三个上下文 → 说明"金额计算"的边界画错了
  → 解法:金额规则下沉到一个独立上下文(或核心域内的共享内核)⚠️

过不了验证的边界,回到 2.2 重新找语言分歧点。宁可多花两天调边界,不要带着模糊的边界上微服务


三、数据拆分:先拆库还是先拆表

3.1 原则:边界先行,数据跟随

数据拆分的唯一正确顺序是:先按限界上下文拆表(逻辑隔离)→ 稳定后拆库(物理隔离)→ 服务化(部署隔离)。跳级直接拆库的风险:边界错了改不动(表已经物理分开了)。

3.2 三阶段拆分路线

阶段一:单体库内拆 schema(1~2 个月)
  shop_db/
    ├── user_schema/     (用户域表)
    ├── order_schema/    (订单域表)
    ├── stock_schema/    (库存域表)
    └── pay_schema/      (支付域表)
  动作:按边界归属重新分配每张表;禁止跨 schema join(用 ID 关联 +
       应用层组装);同库事务仍可用,风险最低
  目的:验证表归属是否正确——join 改造中暴露的"这张表到底该归谁"
       的争议,全部在这一阶段解决

阶段二:独立数据库实例(2~3 个月)
  user_db / order_db / stock_db 各自独立实例
  动作:跨库写操作改造成 Saga/本地消息表;跨库读改成 API 组装或
       数据冗余;连接池独立,故障隔离开始生效

阶段三:随服务上线各自独立(与阶段二交替进行)
  每个域的数据 + 服务一起走

3.3 那张"最难的表":共享表的归属裁决

每个单体都有几张"人人都 join"的表,裁决方法给个模板:

争议裁决依据
user_address订单 join 它取地址归用户域;订单域存"地址快照"字段地址的变更逻辑在用户域;订单要的是历史事实不是实时数据
product全域 join归商品域;订单存商品快照,营销存活动价快照同上,"快照"是解共享表的标准答案
order_item.product_id订单和库存都要归订单域;库存域只留 SKU ID 做关联键订单明细是订单域的私产,库存按 SKU 维度对账
统计/报表宽表多域数据谁也不归:从各域 MQ/CDC 汇入独立数仓报表是派生数据,不该阻塞业务库拆分

快照策略的通用公式:跨域引用 = 引用 ID(实时关联)+ 关键字段快照(历史正确性)。快照字段选哪些?问一句"这个字段变更后,历史业务单据该不该跟着变"——该变的存 ID 实时取,不该变的存快照。

3.4 跨域写操作:拆库后的事务怎么办

拆库后最痛的是原来一个事务的操作跨了库。以"下单扣库存"为例的演进路径:

单体:@Transactional 里 insert 订单 + update 库存(一个 DB 事务)
  ↓ 拆库后
方案 A(同步链):订单库事务提交 → RPC 调库存服务预占 → 失败则补偿取消订单
  适用:库存强一致(少卖可接受,超卖不可接受)
方案 B(事件驱动):订单库事务提交 + 本地消息表 → MQ → 库存服务消费扣减
  适用:最终一致可接受(数秒内对齐),吞吐高
方案 C( Saga 编排):长流程多域协作(下单→支付→履约),编排器管理
  每步的正反操作,卡在中间态可人工/自动补偿
  适用:跨 3+ 域的长流程

选型口诀:强一致选 A(小范围),最终一致选 B(默认),长流程选 C

本地消息表的最小实现值得记住:业务库同事务写入 msg 表(status=待发送)→ 定时任务/MQ 事务消息投递 → 消费成功改 status=已发送。它用"同事务写业务+写消息"换掉了分布式事务,是拆库后跨域写的地基设施。


四、拆分优先级:先拆谁,收益最大

4.1 优先级矩阵:四象限

拆分顺序不是拍脑袋,按两个维度打分:业务变更频率(近期需求是否扎堆)和 性能/资源特征(是否拖累整体):

变更频繁
                    │
   ┌────────────────┼────────────────┐
   │  第三批拆:      │  第一批拆:★    │
   │  支付(谨慎!)   │  营销/活动      │
   │  变更不频繁但核心 │  变更最频繁      │
   │                │  隔离风险敞口     │
低 ─────────────────┼───────────────── 高
风险                │                风险
   │  最后拆/不拆:   │  第二批拆:      │
   │  用户(稳定)    │  订单(核心但热)│
   │  权限/字典       │  先模块化再服务化 │
   └────────────────┼────────────────┘
                    │
                 变更稳定
批次上下文理由拆分形态
第一批营销/活动需求最频繁(大促周更)、大促时资源峰值独立(活动流量不打挂主交易)、出错影响面可控直接服务化,独立扩缩容
第二批订单核心域但业务热度高;先在单体里完成模块化 + schema 隔离,再连数据一起拆模块化 → 服务化两步走
第三批支付涉及资金安全,改动验证成本极高;晚拆不是不拆,等订单拆完、消息机制稳定后再拆慎拆,带完整回归用例
缓拆用户/权限/字典变更少、被所有人依赖,拆出去反而多一层网络调用;先以"开放主机服务"形态供其他域调用可长期模块化不服务化

4.2 拆分收益的量化检验

每个批次拆完,用这三个指标验证"拆对了":

① 部署独立性:该服务发版是否不再牵连其他域回归?(发版联动次数)
② 资源弹性:大促时只扩营销服务,主交易成本不涨?(扩容实例数变化)
③ 团队自治:营销组需求交付周期是否缩短?(需求 lead time 对比)

三条没有改善的拆分,要复盘是"边界画错了"还是"拆早了"。


五、拆分反模式:这些坑每个都有尸体

5.1 反模式一:拆太细——微服务粒度的"纳米服务"

症状:一个"地址服务"独立成微服务,订单查地址一次 RPC、用户中心查地址又一次 RPC、售后单再查一次——一次下单页面渲染拉起 11 个服务调用,链路上任何一个抖动都全局超时。

调用链爆炸的量化信号:
  一次核心请求的服务间调用 > 5 跳   → 粒度过细预警
  排查一个 bug 要看 > 4 个服务的日志 → 边界切碎了
  两个服务"总是同时发版"            → 它们本来就是一个上下文

治理:合并回一个上下文("总是同时发版"就是合并判决书);RPC 改异步事件;读多写少的跨域数据用本地视图(订阅事件落一份只读副本)。

5.2 反模式二:分布式单体——服务拆了,耦合没拆

症状:服务物理分离了,但调用拓扑是同步链式的串行依赖(订单→用户→会员→积分→优惠券,5 层串行 RPC),任何一层慢 200ms,下单接口就慢 1 秒;任何一层发版,全链路回归。

本质:把单体内的方法调用原样翻译成了 RPC——拆的是部署,没拆的是耦合。解法回到第二章的映射图:同步链改事件驱动(订单已支付 → 各域异步消费),把"调用"变"通知"。

5.3 反模式三:共享库/共享表阴魂不散

症状:拆出了"公共领域包"(domain-common),五个服务都依赖它;或者数据拆了但搞了个"公共 DB"大家连。

危害:公共包一改全员回归,升级靠开会——它就是一个不打 netty 的单体。治理:公共包只留技术设施(工具类、中间件封装),业务模型禁止下沉;每个域的模型在自己的上下文里长,需要复用走 API/事件而不是共享类。

5.4 反模式四:一步到位拆库

症状:边界还没验证,直接把表拆到三个数据库实例,跨库 join 全改 RPC。

后果:边界错的修改成本翻十倍(表已物理分离);数据一致性方案在边界未稳时仓促定型。永远记住 3.2 的三阶段:schema 逻辑隔离 → 拆库 → 服务化,每阶段给边界纠错留窗口。

5.5 反模式速查表

┌──────────────┬────────────────────────┬──────────────────────┐
│ 反模式        │ 识别信号                 │ 治理                  │
├──────────────┼────────────────────────┼──────────────────────┤
│ 纳米服务      │ 一次请求 >5 跳 RPC       │ 合并上下文 / 事件化    │
│ 分布式单体    │ 同步串行链 / 全链路回归   │ 事件驱动重构           │
│ 共享库阴魂    │ domain-common 塞业务模型 │ 只留技术设施           │
│ 一步到位拆库  │ 未验证边界就物理分离      │ 三阶段:schema→库→服务 │
│ 为拆而拆      │ 说不清拆完解决什么问题    │ 回到 4.1 优先级矩阵    │
└──────────────┴────────────────────────┴──────────────────────┘

六、边界识别实操清单(开会直接用)

6.1 会前准备(半天)

□ 拉齐角色:产品 1 名 + 各业务域研发 + 运营(事件风暴需要业务侧视角)
□ 材料准备:现有核心表清单(含被 join 次数 TOP20)
□ 输出载体:白板/Miro 一块

6.2 识别四步(1~2 天工作坊)

□ STEP 1 列业务事件:过去一年所有业务事件贴墙上(动词句式:
  "X 被做了"),按直觉聚类分堆
□ STEP 2 找语言分歧:同一个名词在不同堆里含义不同的,标红——
  红色名词就是切刀位置
□ STEP 3 定上下文:每个堆命名(必须是业务语言,如"履约"而非
  "订单模块"),列出每个上下文的"私有模型"与"对外身份"
□ STEP 4 画映射图:上下文之间标注协作方式(同步调用/事件/防腐层)
□ STEP 5 验证:拿 5 个近期真实需求走查,理想只动 1~2 个上下文,
  不达标的回炉

6.3 数据与优先级(1 天)

□ 表归属裁决:每张核心表回答"谁负责它的写?"——多个人回答
  就是共享表,按 3.3 模板裁决(快照策略)
□ 跨域写流程梳理:每个跨域事务标注强一致/最终一致/长流程,
  对应方案 A/B/C
□ 优先级打分:每个上下文按"变更频率 × 风险可控度"排拆分批次
□ 定边界契约:每个上下文写出"对外承诺"(API + 事件清单),
  作为团队间协议

6.4 一页纸判定工具

问:这个功能要拆成独立服务吗?
  ├─ 它有独立的变化节奏吗?(别人稳定它狂改)→ 无 → 留在单体
  ├─ 它有独立的资源画像吗?(流量/存储特征不同)→ 无 → 留在单体
  ├─ 它的语言能自洽吗?(不用"翻译"就能说清)→ 否 → 边界还没找到
  ├─ 拆出去后调用链 < 3 跳吗?→ 否 → 合并到调用方
  └─ 团队能独立负责它吗?(从需求到运维)→ 否 → 先补自治能力
全部通过 → 拆

七、常见问题

7.1 不学 DDD,直接按"用户/订单/库存"切行不行?

业务概念清晰的小系统往往行——DDD 的价值在于当"怎么切"出现争议时提供裁决工具。我们案例里"收货地址归谁""库存服务放订单里还是独立"这两个卡住拆分的问题,靠直觉是吵不出结果的,靠语言分歧点和快照策略五分钟出答案。DDD 不是仪式,是争议仲裁器;没有争议的小系统,凭直觉切完用"改一个需求动几个上下文"验证即可。

7.2 限界上下文和微服务数量必须一一对应吗?

不必须。一个限界上下文可以对应一个微服务(常见),也可以模块化留在单体里(用户/权限缓拆的形态),也可以拆成多个服务(订单上下文超大后按读写分离成查询/交易两个服务)。先有逻辑边界,物理拆分是边界验证后的独立决策——这正是本文标题"不是拆代码,是拆边界"的含义。

7.3 事件风暴要拉业务方一起吗?研发自己开不行吗?

必须拉。事件风暴的核心价值是让"业务语言"浮出水面,而业务语言在产品/运营嘴里,不在代码里。研发自己开的结局通常是把现有包结构换个说法——重新掉回 1.1 的假象。会前把"哪些事件由谁关心"的问题列好,业务方的参与时间可以控制在半天内

7.4 拆分后跨域查询(报表/后台列表)怎么办?

三层方案:① 简单场景:API 组装(BFF 层聚合 2~3 个服务);② 复杂列表:本地只读视图——订阅上游事件把需要的字段落到自己的从表,查询不出域;③ 报表分析:CDC/数仓汇聚,永不和业务库混在一起。警惕把"跨域 join"用 RPC 循环组装的方式实现——那是把 N+1 问题从 SQL 层搬到了服务层。

7.5 团队没人懂 DDD,怎么落地不变成"名词考试"?

抓两个最有杠杆的实践就够:事件风暴(找边界)+ 统一语言(分歧点判定),其余战术模式(聚合根、仓储、防腐层代码结构)按需后补。落地检验标准也很朴素:开完会,团队能不能不查资料地回答"为什么库存是独立上下文而地址不是"。能用自己的话说清的边界才是真边界——说清不了的,DDD 学了也白学。

7.6 单体确实太小,还有必要搞这些吗?

10 人以下、年变更低频的单体,限界上下文的价值主要是"预防":用模块边界 + schema 隔离把边界画好(本文的阶段一),成本两天,收益是未来任何一次拆分都有现成的地图。反过来,已经有明确拆分诉求的中大型系统,跳过边界设计直接上微服务的失败案例,几乎都在本文的反模式清单里。工具没有门槛高低,只有和问题的匹配度


八、总结

全流程速查卡

┌────────────────┬──────────────────────────────────────────────┐
│ 阶段            │ 关键动作                                      │
├────────────────┼──────────────────────────────────────────────┤
│ 破除假象        │ 包结构≠边界 / 表是共享内存 / 分层≠分域          │
│ 识别边界四步    │ 事件风暴 → 语言分歧点 → 上下文映射图 → 需求走查   │
│ 数据拆分        │ schema 逻辑隔离 → 拆库 → 服务化(三阶段)        │
│ 共享表裁决      │ 跨域引用 = ID + 快照字段                       │
│ 跨域事务        │ 强一致同步链 / 最终一致消息表 / 长流程 Saga      │
│ 拆分优先级      │ 营销先拆(高频+独立峰值)→ 订单 → 支付慎拆        │
│                 │ → 用户/权限缓拆                                │
│ 反模式防线      │ 拒绝纳米服务/分布式单体/共享库/一步拆库           │
└────────────────┴──────────────────────────────────────────────┘

一句话

单体拆分最难的不是把代码搬到新仓库,而是回答"这笔业务账该记在谁头上"——收货地址归用户域还是订单域、订单该不该知道库存的内部模型。DDD 限界上下文提供的不是一套新架构,而是一台争议仲裁器:用事件风暴找到业务语言,用语言分歧点确定切刀位置,用"改一个需求动几个上下文"验证边界,用"ID + 快照"解开共享表的死结。边界清晰之后,拆库是体力活,拆服务是搬运工——顺序反了,拆出去的只是分布式单体;顺序对了,边界本身就是架构。

给团队的建议

建议
立刻可做用"改一个需求动几个模块"审计现有单体,找出边界烂账
本月开一次半天版事件风暴(研发+产品),产出第一版上下文草图
拆分前强制走完边界识别清单 6.2/6.3,边界不过验证不开工
数据schema 隔离先行,禁止跨 schema join 作为团队红线
长期每季度复盘:需求落地分布是否与上下文设计一致,漂移即调

互动话题:你们拆微服务时吵得最凶的一个边界问题是什么?"这张表归谁"的裁决最后怎么定的?评论区聊聊。


参考资料


标题:单体拆分的第一课:不是拆代码,是拆边界——DDD 限界上下文实战
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/09/11/1788597624862.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消