模块化单体 + DDD + 绞杀式迁移:从单体到微服务的渐进式演进
一句话结论:模块化单体是“一个可部署单元 + 内部强边界”的架构,DDD 负责识别边界,绞杀式迁移负责在不重写系统的前提下逐步把模块抽成微服务。三者的关系是:DDD 定义“切哪里”,模块化单体提供“切得动”的代码结构,绞杀式迁移给出“安全切出去”的路径。
一、为什么需要模块化单体:先看传统单体的困境
1.1 传统单体的典型问题
❌ 传统单体(大泥球) ├── OrderService.cs ← 2万行,什么都往里塞 ├── CustomerService.cs ← 直接 new SqlConnection ├── InventoryService.cs ← 调用 OrderService 的私有方法 └── Controllers/ ← 直接操作数据库典型症状:边界侵蚀(模块互相 reach-through)、共享数据库耦合(跨模块 JOIN)、变成大泥球(只是按文件夹分类,没有真正的模块)。
1.2 模块化单体的定位
模块化单体是“一个可部署单元,内部拆成强边界模块”的架构。它提供微服务级别的组织清晰度,但不承担分布式系统的成本(网络失败、最终一致性、运维开销)。
| 维度 | 传统单体 | 模块化单体 | 微服务 |
|---|---|---|---|
| 部署单元 | 1个 | 1个 | N个 |
| 模块边界 | 文件夹 | 强强制边界 | 网络边界 |
| 数据库 | 共享 | 共享(可 schema 隔离) | 独立 |
| 重构成本 | 低 | 中(边界约束) | 高(跨服务) |
| 分布式复杂度 | 无 | 无 | 高 |
适用场景:小到中型团队、领域边界仍在探索中、希望保持微服务就绪的接缝但暂时不想支付分布式税。
二、DDD 如何定义模块边界
2.1 战略设计:限界上下文 → 模块
DDD 战略设计的第一步是划分限界上下文(Bounded Context)——一个领域内特定模型适用的边界。在模块化单体中,每个限界上下文对应一个模块。
以电商系统为例:
限界上下文划分: ├── 订单上下文 (Order BC) → OrderModule ├── 支付上下文 (Payment BC) → PaymentModule ├── 库存上下文 (Inventory BC) → InventoryModule └── 客户上下文 (Customer BC) → CustomerModule同一个词在不同上下文中含义不同:在订单上下文中,“商品”是购买快照(名称、价格、数量);在库存上下文中,“商品”是可量化和预留的库存单元。这正是限界上下文存在的意义——每个上下文有自己的模型,不强行统一。
2.2 战术设计:模块内部的领域模型
每个模块内部应用 DDD 战术模式:
| 模式 | 说明 | 示例 |
|---|---|---|
| 实体 | 有唯一标识,随时间持久 | Order(订单号不变,状态可改) |
| 值对象 | 无标识,属性定义,不可变 | Address、Money、OrderItem |
| 聚合 | 一致性边界,一个聚合根 | Order是根,OrderItem只能通过 Order 修改 |
| 领域事件 | 记录已发生的事,用于解耦 | OrderPlaced、PaymentCompleted |
| 仓储 | 聚合的持久化接口(定义在领域层) | IOrderRepository |
核心原则:实体应封装行为,而非仅传数据。如果业务逻辑在 Service 层而非实体中,就是贫血模型反模式。
三、模块化单体的代码结构与强制边界
3.1 分包结构(facade + internal 模式)
Spring Modulith 推荐的结构:只有基础包中的类型对其他模块可见,internal/下的全部隐藏。
com.example.app.{module}/ ├── {Module}Service.kt # 公开门面(跨模块 API,只返回 DTO) ├── {PublicDto}.kt # 公开 DTO ├── events/ # 领域事件(公开) │ └── {DomainEvent}.kt └── internal/ # 全部私有 ├── model/ # 实体、值对象 ├── repository/ # 仓储实现 ├── application/ # 内部服务 └── controller/ # REST 控制器关键规则:orders模块可以依赖inventory :: events(只依赖事件子包),但不能依赖inventory的完整 API。
3.2 边界强制手段
模块化单体的最大风险是边界侵蚀——模块互相 reach-through。必须用架构测试强制边界:
// ArchUnitNET 示例:禁止跨模块直接访问 internal[Fact]publicvoidOrderModule_ShouldNotAccess_InventoryInternal(){varresult=Types.InAssembly(typeof(OrderService).Assembly).That().ResideInNamespace("App.Order").ShouldNot().HaveDependencyOn("App.Inventory.Internal").GetResult();result.IsSuccessful.Should().BeTrue();}3.3 模块间通信:契约 + 事件
模块之间不能直接调用对方的内部服务或访问数据库。通信方式:
模块间通信规则: ├── 同步查询 → 通过公开的 Facade 接口(返回 DTO) ├── 状态变更 → 通过领域事件(进程内事件总线) └── 禁止:跨模块 JOIN、直接引用对方实体、调用对方 internal 方法进程内事件总线(如 MediatR / EventEmitter)在模块化单体中充当“微型消息代理”,让模块间通信像微服务一样,但零网络开销。这为后续绞杀式迁移铺路——把进程内事件换成 Kafka 消息,边界不动。
四、绞杀式迁移:从模块化单体到微服务的路径
4.1 绞杀植物模式的启示
“绞杀植物”的生态过程:种子被鸟带到寄主树顶 → 空中发芽 → 气根下垂入土 → 网状根系包裹寄主 → 寄主枯死 → 绞杀植物独立成树。
软件映射:
- 寄主树= 旧单体/遗留系统
- 绞杀植物种子= 新功能或新模块
- 气根入土= 新模块逐渐接管流量
- 寄主枯死= 旧功能被完全替换
- 独立成树= 新模块独立部署为微服务
4.2 在模块化单体上实施绞杀式迁移的完整步骤
前置条件:你已经有一个模块化单体,模块边界清晰,模块间通过契约和事件通信。
步骤 1:选定绞杀目标
从模块化单体中选一个边界清晰、依赖少、有独立伸缩需求的模块。优先选择:
- 对伸缩有独立需求的模块(如 AI 推理需要 GPU)
- 变更频率高、影响面大的模块
- 技术栈不同的模块(如 Python AI 服务)
步骤 2:将进程内事件替换为跨进程消息
模块化单体中,模块通过进程内事件总线通信。迁移第一步:把该模块的入站/出站事件通过消息中间件(Kafka/RabbitMQ)桥接。
// 迁移前:进程内事件eventBus.Publish(newOrderPlaced(orderId));// 迁移后:进程内事件 + Kafka 桥接eventBus.Publish(newOrderPlaced(orderId));// 本地继续kafkaProducer.SendAsync(newOrderPlaced(orderId));// 同时发到 Kafka这样新模块可以订阅 Kafka 事件,而旧模块仍然处理本地事件。两者并行运行。
步骤 3:新模块独立部署,接管部分流量
新模块作为独立服务部署,通过网关或特性开关逐步接管流量:
流量切换策略(绞杀式): ├── 阶段 1:新模块只接收 1% 流量(影子模式) ├── 阶段 2:新模块接收 10% 流量(金丝雀) ├── 阶段 3:新模块接收 50% 流量 ├── 阶段 4:新模块接收 100%,旧模块停写 └── 阶段 5:移除旧模块代码步骤 4:数据拆分(最困难的一步)
模块化单体通常共享数据库。绞杀式迁移的最大障碍是数据耦合。策略:
- Schema 分离:先把该模块的表移到独立 schema,禁止跨 schema JOIN
- 读写分离:新模块写自己的库,旧模块读旧库,通过事件同步
- 双写过渡:新模块写新库的同时,通过事件让旧模块更新旧库(反之亦然)
- 最终一致:接受短暂不一致,用补偿/对账机制兜底
步骤 5:移除旧模块
当新模块完全接管,旧模块的代码、数据库表、配置全部移除。绞杀完成。
五、完整案例:ITS M 系统的渐进式演进
5.1 背景
一个 ITSM(IT 服务管理)系统,Go 单体应用,包含80+ 服务和 80+ 控制器。面临的问题:
- 部署不灵活:AI 推理需要 GPU,但整个单体一起部署
- 数据库耦合:所有模块共享 PostgreSQL,大租户场景性能瓶颈
- AI 紧耦合:Python AI 服务与 Go 后端紧耦合,无法独立扩展
- 测试困难:全量测试耗时长
5.2 决策:模块化单体 + 事件驱动中台
不直接拆微服务,而是先采用“模块化单体 + 事件驱动中台”的渐进式架构:
itsm-backend/ ├── domain/ │ ├── ticket/ # 工单领域(模块) │ │ ├── model/ │ │ ├── service/ │ │ └── repository/ │ ├── incident/ # 事件领域 │ ├── problem/ # 问题领域 │ ├── change/ # 变更领域 │ └── common/ # 公共领域(共享内核) ├── infrastructure/ │ ├── event/ # 事件总线实现(Kafka) │ ├── cache/ │ └── storage/ └── api/ # API 层关键决策:
- 模块化单体结构:保持单部署单元,按业务领域划分子包
- 事件驱动解耦:引入 Kafka,定义工单/审批/SLA/通知事件
- AI 服务独立化:Python AI 服务独立部署,通过 HTTP/gRPC 通信
- 多租户强化:租户上下文贯穿全链路
5.3 实施计划
| 阶段 | 内容 | 时间 |
|---|---|---|
| Phase 1 | 搭建 Kafka、定义事件 schema、实现事件总线 | 2 周 |
| Phase 2 | 按领域重组代码包、关键逻辑改事件驱动 | 4 周 |
| Phase 3 | AI 服务 Docker 化、定义 Go-Python 协议 | 2 周 |
5.4 效果
正面:
- 保留现有开发/调试体验,无需大幅重构
- 支持独立扩展 AI 服务(GPU 按需)
- 事件驱动降低模块耦合,便于后续拆分
- 多租户能力为 SaaS 化奠定基础
负面:
- 需要引入 Kafka 等消息基础设施
- 事件一致性需要额外处理(幂等、补偿)
- 团队扩大后需评估是否进一步拆分为微服务
六、关键设计原则清单
模块化单体原则
| 原则 | 说明 | 验证方式 |
|---|---|---|
| 显式接口 | 模块通过公开 Facade 暴露能力,返回 DTO | 架构测试禁止跨模块引用 internal |
| Schema 隔离 | 每模块独立 schema,禁止跨 schema JOIN | 数据库审计 |
| 事件通信 | 模块间状态变更通过领域事件解耦 | 检查是否有跨模块直接调用 |
| 高内聚低耦合 | 模块内强内聚,模块间弱依赖 | 依赖数量分析 |
DDD 边界原则
| 原则 | 说明 |
|---|---|
| 一个上下文一个模型 | 不强行统一不同上下文的概念 |
| 聚合是事务边界 | 一个事务只修改一个聚合 |
| 值对象优先 | 默认用值对象建模,仅在需跟踪身份时用实体 |
| 实体封装行为 | 避免贫血模型,业务规则放在实体内 |
绞杀式迁移原则
| 原则 | 说明 |
|---|---|
| 不重写 | 新模块逐步接管,旧系统继续运行 |
| 事件桥接 | 进程内事件 → 跨进程消息,边界不动 |
| 流量渐进 | 影子 → 金丝雀 → 全量 |
| 数据最后拆 | 代码边界先拆,数据边界后拆 |
七、三者的关系总览
┌─────────────────────────────────────────────────────────┐ │ 演进路线 │ │ │ │ 传统单体 模块化单体 微服务 │ │ (大泥球) → (强边界模块) → (独立服务) │ │ │ │ ↑ ↑ ↑ │ │ │ │ │ │ │ DDD 战略设计 DDD 战术设计 绞杀式迁移 │ │ 划分子域 设计聚合/事件 逐步抽出 │ │ │ │ 关键:模块化单体是"过渡态",不是"终态" │ │ DDD 保证边界质量,绞杀保证迁移安全 │ └─────────────────────────────────────────────────────────┘最终心法:
- 不要过早微服务——先用模块化单体验证边界
- DDD 是边界工具——限界上下文决定模块怎么切
- 绞杀是迁移纪律——永远保持系统可运行,逐步替换
- 模块化单体是微服务的准备阶段——边界清晰的模块化单体,拆微服务只是“把进程内调用换成 HTTP/gRPC + 把进程内事件换成 Kafka”