← 返回文章列表

DDD 实战(七):边界确定之后的拆与不拆

2026年7月18日架构设计
DDD微服务模块化单体Monorepo

海外 AI 服务代充推荐:BeWild(推广链接)。

边界划完了,紧接着就是那个绕不开的问题:那要不要拆微服务?

前面那张领域全景图把协同办公 IM 分成了账号、组织、关系、群组、消息等上下文。接下来很容易顺着图为每个方框建一个服务——十个上下文,十个服务,架构图看着特别整齐。

但从十个上下文直接跳到十个服务,只是把进程内的一次调用换成了一次网络调用:业务边界一点没变清楚,超时、重试和数据同步倒是都来了。真正的问题从来不是“要不要拆”,而是“现在拆能换来什么”。

笔者更愿意先把几种边界分开看。它们可以重合,也可以不同步演进。

边界需要回答的问题
领域边界这套术语和规则在哪个范围内成立?
代码边界谁能依赖谁,通过什么接口访问?
数据边界谁拥有事实,谁能修改,哪些操作一起提交?
部署边界哪些代码一起发布、扩容和故障恢复?
团队边界谁负责需求、交付和运行结果?

一个进程里可以有多个限界上下文。反过来,一个上下文也可能有 API、异步 worker 等多个运行单元。不能靠进程数量判断 DDD 做得好不好。Microsoft 的领域分析文档也把领域分析当作微服务设计的输入,但它并没有把“发现一个限界上下文”直接等同于“立刻拆一个独立服务”。Microsoft: Domain analysis for microservices

普通单体真正难改的地方

早期按技术分层组织代码很常见:

internal/
├── controller/
├── service/
├── dao/
└── model/

问题不在目录名本身,而在所有业务是否因此都能穿透调用。群组服务直接改组织表,消息服务依赖好友模块的内部结构,定时任务又绕开 Service 更新状态。一次组织字段调整,可能牵动整个调用网。

这种情况下直接拆服务,只会先把进程内依赖换成网络依赖。业务边界没有变清楚,超时、重试和数据同步却都增加了。

单体的问题从来不是“它是单体”,而是没有内部边界。反过来,加了目录也不等于有了边界——如果 group 包能直接 import organization 的数据库实现,那只是把一个大泥球分装进了几个盒子。

模块化单体先限制写入口

可以先按上下文组织代码,仍用一套部署:

internal/
├── identity/
├── organization/
├── relationship/
├── group/
├── messaging/
├── notification/
├── openplatform/
├── search/
└── governance/

每块业务负责自己的规则和数据写入,其他模块通过明确接口访问。这条规则可以写成一句可检查的话:Messaging 可以调用 Group 暴露的接口校验会话权限,但不能直接查 Group 的表,也不能 import 它的基础设施实现。目录只是提示,代码评审、测试和依赖检查还要确保调用没有穿透。

同一个数据库也可以保留。关键是表的所有者明确,不能任意跨模块改表。遇到确实需要跨模块本地事务的用例,可以显式协调并记录这个耦合;没有必要为了追求“纯粹”立刻引入分布式事务。将来拆开时,这些事务正好是需要重新设计的清单。

查询则可以更务实。报表、搜索页和列表页需要组合数据时,可以通过查询服务或读模型实现。限制的是业务事实的修改权限,不是禁止任何跨表查询。

通知和搜索这两块尤其容易出问题:通知容易把未读数和消息状态搅在一起,搜索则容易在权限过滤和索引一致性上两头为难。它们不是“拆不拆”的问题,而是拆之前就得先把模型理清楚。

拆部署要有可以观察到的收益

下面这些理由值得认真评估。以开放平台为例,机器人、Webhook、配额、签名校验、回调审计和事件版本管理这几件事的流量形态和安全模型都跟内部消息不一样,往往最先出现隔离需求。

现象可能的部署调整仍需承担的代价
搜索建索引占用内存,干扰消息请求拆出索引 worker增量同步、重建与积压监控
外部应用回调慢、重试多独立回调 worker任务持久化、权限复核、重试隔离
消息接入需要不同的扩容节奏独立消息接入或处理服务协议演进、容量与数据一致性
某模块确实需要独立发布按稳定接口拆服务兼容版本、独立值守和回滚

这里没有固定顺序。搜索流量很小的系统,不必因为它“天然适合微服务”就先拆;外部回调已经拖累主进程的系统,也不必等到所有领域模型都完美才隔离 worker。

这些现象在单体里其实很好认:发布一个审批模块的改动,要带着消息模块一起回归;某个低优先级的同步任务占满线程池,核心链路的发送接口跟着抖;搜索建索引和消息请求抢同一份内存。出现这些情况,说明进程边界已经不合适了,而不只是代码写得不好。

最好能说明调整前后的指标,例如延迟、资源竞争、发布等待或故障影响范围。只说“便于扩展”,很难判断多出来的运维成本是否值得。

从模块调用改成远程调用,会失去什么

进程内函数调用通常可以明确知道函数是否返回。换成 RPC 后,请求超时可能发生在服务端提交之后。调用方不能把超时都当作“没执行”,然后直接重做。

原来的本地事务也可能被拆开。创建群与写入后续任务如果分属两个数据库,需要重新选择提交边界、幂等键和补偿方式。单纯把接口签名搬到 HTTP 上,无法保留原来的原子性。

事件机制同样如此。进程内回调不会自动获得可靠投递;直接换成 MQ,也不保证业务提交和发送事件同时成功。需要可靠传播的事实,应使用 outbox、事务日志转发或适合当前存储的机制,再处理重复、乱序和积压。

拆开以后,有些原本免费的东西要自己补:缓存、事件、降级、幂等、补偿、链路追踪。这些在单体里要么不存在,要么由框架顺手解决;变成跨服务之后,每一项都得有人负责。

拆分评审中,笔者会单独追踪超时、重复请求和进程退出三个场景。正常流程能跑通,说明不了这些边界已经处理好。

迁移可以只围绕一个明确的写入口展开

假设决定独立搜索 worker,可以先把搜索写入集中到模块接口,确认索引是派生数据,再建立可重放的事件与重建流程。待新 worker 能追赶增量、核对结果之后,才停用原写入路径。

若拆的是事实源,迁移更谨慎:需要明确哪一侧是权威写入方、何时切换、在途请求如何处理,以及回退时数据怎么对齐。双写不是一句“过渡方案”就能保证正确;两边部分成功时仍需检测和修复。

每次迁移只改变必要的边界,会比较容易判断问题来自哪里。没有必要同时更换数据库、重写领域模型、调整协议并拆服务。

仓库布局与部署数量也不是一回事

一个仓库可以保留多个运行入口:

im-platform/
├── cmd/
│   ├── api/
│   ├── notification-worker/
│   └── search-worker/
├── internal/
│   ├── messaging/
│   ├── notification/
│   └── search/
└── go.mod

这些入口可以分别构建和运行,但是否独立发布,还取决于依赖、数据和 CI 约束。单 module 并不禁止多个部署;多个 module 也不会自动消除耦合。

当模块需要独立版本、独立依赖管理时,再评估多 module。不要把仓库布局当作服务边界的替代品。

十个上下文不需要十个微服务。领域图上画了十个方框,只说明这套业务需要十套术语和规则,不说明需要十个部署单元、十套告警和十条跨服务协议。

对于这个 IM 案例,一个可行的起点仍是模块化单体,加上确有隔离需要的 worker。后续每次拆分都要能回答:这次获得了什么独立性,失去了哪些原本免费的保证,谁负责新增的失败场景。

再往前一步,打算把这些约定落到 Go 的包、接口和事务代码里,看它们能不能在代码结构里立住。

← 返回文章列表