拆分的失败通常不是因为拆错技术,而是因为拆得太早、拆得没有边界。一个单体被切成六个服务,结果服务之间共用一个数据库、一个共享库、一条长事务链,最后得到的是分布式单体:部署变复杂了,耦合一点没少。这篇讲边界怎么认、拆分按什么顺序做、拆完怎么验证边界真的存在。
1. 限界上下文的判据
限界上下文不是「一个功能模块」,而是一套术语与规则自洽的边界。三个可观察信号几乎能覆盖全部判断:
| 判据 | 具体表现 | 例 |
|---|---|---|
| 同一术语含义不同 | 同一个词在两张表里字段不同、规则不同 | 「订单」在交易上下文是购买契约,在履约上下文是待发货任务 |
| 变更节奏不同 | 两边提测频率、发布窗口差一个量级 | 促销规则每周改,账务每月改 |
| 数据所有权不同 | 各自是某张表的唯一写入方 | 库存只有仓储服务能写 |
| 团队归属不同 | 由不同人负责、目标不一致 | 增长团队与结算团队 |
| 一致性要求不同 | 一侧强一致、一侧可最终一致 | 支付流水强一致,推荐画像可延迟 |
反过来,如果两个模块共享同一批表、同一批术语、同一批人、同一个发布节奏,它们大概率属于同一个上下文,硬拆只会制造远程调用。
2. 从单体到拆分的判断顺序
正确顺序是:先模块化 → 再进程化 → 最后服务化。跳过第一步直接上服务,等于把没有边界的东西用网络连起来。
| 阶段 | 目标 | 允许的手段 | 不允许 |
|---|---|---|---|
| 1 模块化 | 代码层有清晰边界 | Maven/Gradle 模块、包私有、ArchUnit 规则 | 直接调别的模块的 Mapper |
| 2 数据隔离 | 每个模块只写自己的表 | 独立 schema、禁止跨 schema 写 | 共用一个 xxx_all 库并随意 join |
| 3 进程化 | 能独立部署与扩缩容 | 独立 jar、独立发布流水线 | 共享内存态、共享本地缓存目录 |
| 4 服务化 | 跨网络调用 | HTTP/RPC + 事件 + 契约测试 | 远程事务、分布式锁滥用 |
<!-- 阶段 1:先做模块,模块之间只暴露 api 包 -->
<modules>
<module>order-api</module>
<module>order-domain</module>
<module>order-infra</module>
<module>inventory-api</module>
<module>inventory-domain</module>
<module>inventory-infra</module>
</modules>
order-infra 依赖 order-domain,order-domain 只依赖 order-api;inventory-* 同理。跨上下文只能依赖对方的 *-api,拿不到 *-infra。
// 阶段 2/3 之间的过渡:门面 + 开关,让调用一处一处切换,随时可回滚
@Service
@RequiredArgsConstructor
public class OrderFacade {
private final LegacyOrderService legacy;
private final OrderQueryService newModule;
private final Toggle toggle;
public OrderDetail detail(Long id) {
return toggle.isOn("order_module_v2") ? newModule.detail(id) : legacy.detail(id);
}
}
3. 模块边界的可验证性
边界如果不能被 CI 检查,就必然退化。三条可自动化的检查:
| 检查 | 工具 | 失败意味着 |
|---|---|---|
| 模块能否独立编译 | mvn -pl order-domain -am compile |
有隐藏的反向依赖 |
| 禁止跨模块访问实现类 | ArchUnit 规则 | 边界只写在文档里 |
| 禁止跨模块直接访问数据库 | 独立数据源 + 账号权限 | 数据所有权是假的 |
@AnalyzeClasses(packages = "com.example")
class ModuleBoundaryTest {
@ArchTest
static final ArchRule context_isolation = noClasses()
.that().resideInAPackage("..inventory.domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("..order.infra..", "..order.domain..");
@ArchTest
static final ArchRule no_cross_context_mapper = noClasses()
.that().resideOutsideOfPackage("..order..")
.should().accessClassesThat().haveSimpleNameEndingWith("OrderMapper");
}
-- 数据所有权用账号权限兜底:订单库账号不能操作库存表
CREATE USER 'svc_order'@'%' IDENTIFIED BY '***';
GRANT SELECT, INSERT, UPDATE, DELETE ON trade.orders TO 'svc_order'@'%';
GRANT SELECT ON trade.order_item TO 'svc_order'@'%';
-- 不授予 inventory 库任何权限
有了账号隔离,跨库 join 会在运行期直接报错,比 code review 可靠。
4. 四种切法
| 切法 | 依据 | 优点 | 风险 |
|---|---|---|---|
| 按业务流程 | 下单→支付→履约→售后 | 与业务语言一致,边界好讲 | 流程跨多个上下文时事务被切断 |
| 按数据所有权 | 谁写哪张表 | 边界清晰,可验证 | 强关联的聚合被拆开,查询变复杂 |
| 按团队 | 康威定律,谁维护谁拥有 | 沟通成本最低 | 团队调整时边界跟着抖 |
| 按变更频率 | 高频变更与稳定内核分离 | 发布互不阻塞 | 容易切出「技术层」而非业务边界 |
实践里通常以「数据所有权 + 业务流程」为主,团队结构作为约束条件,变更频率只用来决定是否单独抽一个上下文。
反例:按技术层切成 user-service、order-service 却在内部共享 common-db,本质是「按目录拆了个包」。
5. 跨上下文的三种交互
| 方式 | 一致性 | 耦合 | 适用 |
|---|---|---|---|
| 同步 API | 请求内可见结果 | 运行期强依赖,对方挂了这边超时 | 需要立即拿到结果、量小 |
| 事件 | 最终一致 | 只依赖事件契约 | 通知类、可延迟 |
| 共享只读数据 | 弱一致(取决于同步频率) | 依赖对方表结构 | 报表、字典类低频数据 |
同步调用必须包一层防腐层(ACL),把对方的模型翻译成本上下文的模型:
// 防腐层:对方字段变了只改这里
@Component
@RequiredArgsConstructor
public class InventoryGateway {
private final RestClient restClient;
public StockView query(Long skuId) {
InventoryDto dto = restClient.get()
.uri("http://inventory/api/v1/stocks/{skuId}", skuId)
.retrieve()
.body(InventoryDto.class);
if (dto == null) return StockView.unknown(skuId);
return new StockView(skuId, dto.available(), dto.locked(), dto.version());
}
}
跨上下文直接复用对方的 DTO 是耦合的起点——一旦对方改字段名,你的编译期就断了,而你并不想跟着改。
6. 典型的拆分反模式
| 反模式 | 表现 | 根因 |
|---|---|---|
| 分布式单体 | 服务之间同步调用成链,一次请求跨 5 个服务 | 边界按技术层切,业务仍在一条链上 |
| 共享数据库 | 多个服务连同一个库,随意 join | 数据所有权没定义 |
| 共享库耦合 | 一个 common 模块被所有服务依赖 |
用共享代码代替契约 |
| 分布式事务 | 用 Seata/2PC 把拆开的事务又拼回去 | 拆分时没想清事务边界 |
| 服务数量膨胀 | 20 个服务、3 个人维护 | 按表拆服务 |
「共享库把耦合搬了个家」值得单独说:把实体、DTO、工具类、异常全放进 common,任何一次改动都要全量升级,耦合不但没减少,还多了版本对齐的成本。
7. 拆分的成本清单
| 成本项 | 具体表现 | 缓解 |
|---|---|---|
| 运维 | 部署单元翻倍,日志、监控、告警都要分开配 | 统一脚手架与可观测性基线 |
| 链路 | 调用链变长,排障要跨服务 | TraceId 全链路透传 |
| 事务 | 本地事务变成最终一致 | outbox + 幂等 + 对账 |
| 联调 | 接口契约变更需要多方协同 | 契约测试 + 版本化 |
| 发布 | 有依赖的服务要按序发布 | 兼容性设计,避免强顺序 |
| 数据 | 跨库查询、报表要额外做 | 读模型/数仓同步 |
成本清单的作用是在拆之前算一遍账:如果拆完只为了「看起来微服务」,这笔账通常是亏的。
8. 什么时候不该拆
| 场景 | 理由 |
|---|---|
| 团队 5 人以内 | 沟通成本本来就低,拆分的运维成本占主导 |
| 数据强耦合、几乎每张表都要 join | 拆开等于把 join 变成 N 次远程查询 |
| 事务边界复杂(跨多个聚合强一致) | 拆开就必须上分布式事务或大量补偿 |
| 业务还在摸索期 | 边界会频繁变,拆了就要反复迁移 |
| 没有可观测性基础 | 出问题无法定位,只能回滚 |
9. 排错:边界失效的六个信号
| 信号 | 含义 | 处理 |
|---|---|---|
| 改一个字段要发 4 个服务 | 共享库或共享 DTO 在拖后腿 | 拆掉共享模型,改契约 |
| 服务间出现循环调用 | 边界画反了 | 抽公共下游,或合并上下文 |
| 每次上线都要「按顺序发」 | 契约没有向前兼容 | 加版本/加可选字段 |
| 报表要连 3 个库 | 数据所有权与查询需求冲突 | 建读模型或数仓 |
| 事务失败要靠人工补数据 | 缺补偿与对账 | 补 outbox + 对账任务 |
ArchUnit 规则被 @ArchIgnore 绕过 |
边界已名存实亡 | 规则进主干门禁,禁止豁免 |
10. 边界:这套方法的适用前提
| 前提 | 不满足时 |
|---|---|
| 有明确的业务语言与术语表 | 上下文无法命名,只能按技术层拆 |
| 有 CI 与自动化测试 | 边界规则无法落地,很快退化 |
| 有基本可观测性 | 拆完无法排障,故障半径扩大 |
| 有独立发布能力 | 「服务化」只是目录分家 |
一句话:拆分的单位是「一套自洽的术语与数据所有权」,不是「一个部署进程」。
11. 自测
- 判断两个模块是否属于同一上下文,你会看哪三个信号?
- 为什么「先模块化再服务化」的顺序不能颠倒?
- 如何用工程手段(而非文档)保证模块之间不越界访问数据库?
- 按技术层拆服务和按数据所有权拆服务,后果差在哪里?
- 跨上下文同步调用为什么必须包一层防腐层?
- 列出三个「现在不该拆」的具体情形。