单体拆分的第一课:不是拆代码,是拆边界——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 第一步:事件风暴——从业务事件倒推边界
别从代码出发,从业务出发。拉上产品、运营、各域开发,白板上列"这个系统里发生了哪些业务事件":
电商系统的业务事件(部分):
用户注册了 / 用户更新了收货地址 ← 用户域
订单被创建 / 订单被取消 / 订单已支付 ← 订单域
库存被预占 / 库存被扣减 / 库存被释放 ← 库存域
支付发起 / 支付成功 / 退款完成 ← 支付域
优惠券被领取 / 优惠券被核销 ← 营销域
然后问两个黄金问题:
- 这个事件由谁关心、谁会做出反应?——"订单已支付"事件:库存域扣减、支付域对账、营销域核销优惠券、通知域发短信。反应者越多,说明事件越核心;
- 这个事件的"语言"在谁那里最自洽?——"库存"在订单域的语言里是"订单明细的商品数量",在库存域的语言里是"可售库存/预占库存/锁定库存"。同一个词在不同上下文里含义不同,含义不同就是边界所在。
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 作为团队红线 |
| 长期 | 每季度复盘:需求落地分布是否与上下文设计一致,漂移即调 |
互动话题:你们拆微服务时吵得最凶的一个边界问题是什么?"这张表归谁"的裁决最后怎么定的?评论区聊聊。
参考资料
- Eric Evans《领域驱动设计》(DDD 原著)
- Vaughn Vernon《实现领域驱动设计》(IDDD)
- Event Storming 官方网站(Alberto Brandolini)
- Martin Fowler:Bounded Context
- Martin Fowler:Microservices(单体优先原则)
- Microsoft 云设计模式:Saga 分布式事务
- Chris Richardson《微服务架构设计模式》(数据一致性章节)
标题:单体拆分的第一课:不是拆代码,是拆边界——DDD 限界上下文实战
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/11/1788597624862.html
公众号:服务端技术精选
- 引言
- 一、为什么"按包结构切"是错的:DDD 的边界观
- 1.1 单体里的三个假象
- 1.2 限界上下文 vs 模块 vs 微服务:三个词的关系
- 二、识别边界:四步找到你的限界上下文
- 2.1 第一步:事件风暴——从业务事件倒推边界
- 2.2 第二步:找"统一语言"的分歧点
- 2.3 第三步:画上下文映射图(Context Map)
- 2.4 第四步:用"改一个需求要动几个上下文"验证边界
- 三、数据拆分:先拆库还是先拆表
- 3.1 原则:边界先行,数据跟随
- 3.2 三阶段拆分路线
- 3.3 那张"最难的表":共享表的归属裁决
- 3.4 跨域写操作:拆库后的事务怎么办
- 四、拆分优先级:先拆谁,收益最大
- 4.1 优先级矩阵:四象限
- 4.2 拆分收益的量化检验
- 五、拆分反模式:这些坑每个都有尸体
- 5.1 反模式一:拆太细——微服务粒度的"纳米服务"
- 5.2 反模式二:分布式单体——服务拆了,耦合没拆
- 5.3 反模式三:共享库/共享表阴魂不散
- 5.4 反模式四:一步到位拆库
- 5.5 反模式速查表
- 六、边界识别实操清单(开会直接用)
- 6.1 会前准备(半天)
- 6.2 识别四步(1~2 天工作坊)
- 6.3 数据与优先级(1 天)
- 6.4 一页纸判定工具
- 七、常见问题
- 7.1 不学 DDD,直接按"用户/订单/库存"切行不行?
- 7.2 限界上下文和微服务数量必须一一对应吗?
- 7.3 事件风暴要拉业务方一起吗?研发自己开不行吗?
- 7.4 拆分后跨域查询(报表/后台列表)怎么办?
- 7.5 团队没人懂 DDD,怎么落地不变成"名词考试"?
- 7.6 单体确实太小,还有必要搞这些吗?
- 八、总结
- 全流程速查卡
- 一句话
- 给团队的建议
- 参考资料
评论