2674 字
约 8 分钟
14
S18-X15 · 限界上下文与模块拆分

拆分的失败通常不是因为拆错技术,而是因为拆得太早、拆得没有边界。一个单体被切成六个服务,结果服务之间共用一个数据库、一个共享库、一条长事务链,最后得到的是分布式单体:部署变复杂了,耦合一点没少。这篇讲边界怎么认、拆分按什么顺序做、拆完怎么验证边界真的存在。


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. 自测

  1. 判断两个模块是否属于同一上下文,你会看哪三个信号?
  2. 为什么「先模块化再服务化」的顺序不能颠倒?
  3. 如何用工程手段(而非文档)保证模块之间不越界访问数据库?
  4. 按技术层拆服务和按数据所有权拆服务,后果差在哪里?
  5. 跨上下文同步调用为什么必须包一层防腐层?
  6. 列出三个「现在不该拆」的具体情形。
S18-X15 · 限界上下文与模块拆分
https://www.clxhxhhr.top/posts/5865/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。