上下文画好了,然后呢?------怎么部署、怎么通信,是紧接着的两个问题。
前一篇,我们花了一整篇文章讲限界上下文怎么划分:用事件风暴画边界,把"商品"在订单上下文、商品上下文、库存上下文里分别建模。
但画完边界之后,两个问题会立刻冒出来:
第一个问题:这些上下文,在部署上怎么落地?
订单上下文、商品上下文、库存上下文------它们是应该分别部署成三个独立的微服务,还是放在同一个微服务里?如果合并,合并到什么程度?如果拆分,拆到什么粒度?
第二个问题:上下文之间,怎么通信?
订单上下文需要知道库存够不够,库存上下文需要知道订单有没有支付。如果它们真的拆成了独立的微服务,怎么互相调用?用同步API?用异步消息?什么时候用哪个?
这两个问题不解决,限界上下文就只是一张画在PPT上的图------好看的摆设。
这篇文章把这两个问题一起讲。
读完这篇文章,你将获得:
✅ 理解微服务 ≠ 限界上下文 ------什么情况下1个上下文拆成多个服务,什么情况下多个上下文合并成1个服务
✅ 掌握拆与合的决策模型 ------从业务一致性、发布频率、技术异构三个维度判断
✅ 分清领域事件 vs 应用事件 vs 系统事件 ------它们各解决什么问题
✅ 学会同步API vs 异步事件 的选择标准------什么时候该用哪个
✅ 看清从战略设计到代码落地的完整路径------上下文画好了,代码怎么组织
一、先回顾:限界上下文画完之后的样子
前一篇,我们用一个电商系统做了事件风暴,划分出了五个限界上下文:
text
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 订单上下文 │ │ 商品上下文 │ │ 库存上下文 │
│ (Order) │ │ (Product) │ │ (Inventory) │
└─────────────┘ └─────────────┘ └─────────────┘
┌─────────────┐ ┌─────────────┐
│ 支付上下文 │ │ 用户上下文 │
│ (Payment) │ │ (User) │
└─────────────┘ └─────────────┘
每个上下文内部,都有自己独立的领域模型:
java
// 文件: order/domain/order/Order.java(订单上下文)
public class Order {
private Long id;
private Long userId;
private List<OrderItem> items; // 商品快照
private OrderStatus status;
// 订单自己的行为......
}
// 文件: inventory/domain/inventory/InventoryItem.java(库存上下文)
public class InventoryItem {
private Long productId;
private Integer availableStock;
private Integer lockedStock;
// 库存自己的行为......
}
// 文件: product/domain/product/Product.java(商品上下文)
public class Product {
private Long id;
private String name;
private Category category;
// 商品自己的行为......
}
现在问题来了:这些上下文在代码里怎么组织?在部署上怎么落地?
二、问题一:拆成微服务,还是合在一个服务里?
2.1 一个最常见的误解
很多人以为:一个限界上下文 = 一个微服务。
这句话对,也不对。
对的是:如果两个上下文业务边界清晰、团队边界清晰、数据独立性高,拆成两个微服务是合理的。
不对的是:如果机械地执行这个公式,你会得到灾难性的结果。
比如,你有一个"权限上下文"和"用户上下文",它们高度耦合------用户删除了,权限也要跟着删;用户角色变了,权限也要跟着变。如果强行拆成两个微服务,每次用户变更你都要跨服务调用,网络延迟、分布式事务、最终一致性问题全来了。
系统崩溃了,不是因为"拆错了",而是因为"不该拆的拆了"。
2.2 拆与合的决策模型
拆不拆,不看"是不是不同的上下文",而看三个维度。
维度一:业务一致性要求
如果两个上下文之间存在强一致性要求------A改了,B必须在同一个事务里立即改------它们应该合在同一个服务里,使用同一个数据库事务。
如果允许最终一致性------A改了,B可以几秒钟之后再改------它们可以拆成两个服务,通过消息异步同步。
判断标准:问业务方,"订单支付成功后,库存扣减可以延迟3秒吗?"如果回答"可以",拆;如果回答"绝对不行",合。
维度二:发布频率和变更节奏
如果两个上下文经常需要同时上线------改了订单逻辑必须同时改库存逻辑------它们应该合在一起。
如果它们各自独立发布------订单团队每周发两次,商品团队每月发一次------它们应该拆开。
判断标准:问两个团队的负责人,"你们能接受对方的发布节奏吗?"如果不能,拆。
维度三:技术异构
如果两个上下文需要不同的技术栈------一个用Java,一个用Go;一个用MySQL,一个用MongoDB------它们必须拆成独立的服务。
如果技术栈相同,这条不构成拆分的强制理由。
2.3 一张表帮你做决策
| 维度 | 合(1个服务) | 拆(2个服务) | 判断依据 |
|---|---|---|---|
| 一致性要求 | 强一致,必须同一个事务 | 最终一致,允许延迟 | 问业务方"能等几秒吗?" |
| 发布频率 | 一起上线,节奏相同 | 各自独立,节奏不同 | 问团队"能接受对方节奏吗?" |
| 技术栈 | 相同 | 不同 | 看代码仓库 |
2.4 三种映射模式
根据上述维度的综合判断,限界上下文到微服务有三种映射关系:
| 映射模式 | 含义 | 适用场景 |
|---|---|---|
| 1:1 | 一个上下文 = 一个微服务 | 理想情况,边界清晰,团队独立 |
| 1:N | 一个上下文拆成多个微服务 | 上下文内部有子域,某些子域有特殊要求 |
| N:1 | 多个上下文合并在一个微服务里 | 强一致性要求,或变更节奏相同 |
重要结论:限界上下文是业务边界,微服务是部署边界。业务边界不一定要等于部署边界------这是战略设计到技术落地的关键一步。
2.5 合与拆的示例
合的示例:权限上下文 + 用户上下文
用户删除时,权限必须同时删除------强一致性要求。如果拆成两个微服务,需要分布式事务,复杂且容易出错。所以,合在同一个微服务里。
拆的示例:订单上下文中的"订单导出"子功能
订单导出是一个独立的子域,它和订单主流程的关系是:订单数据产生后,导出功能读取数据生成报表。它对一致性要求低(延迟几分钟甚至几小时都可以),并发量低,技术栈可以和主流程不同(用Go写导出服务更高效)。所以,拆成一个独立的微服务。
代码体现:
java
// 方案:合------用户上下文和权限上下文在同一个服务里
// 文件: user/domain/user/User.java
public class User {
// ... 用户字段
public void delete() {
this.status = UserStatus.DELETED;
// 同一个事务里删除权限
permissionRepository.deleteByUserId(this.id);
}
}
// 方案:拆------订单导出作为独立服务
// 文件: order-export/infrastructure/event/OrderExportListener.java
@Component
public class OrderExportListener {
@EventListener
public void onOrderPaid(OrderPaidEvent event) {
// 异步监听订单支付事件,生成导出数据
// 不影响主订单流程
}
}
三、问题二:上下文之间怎么通信?
拆完了,下一个问题来了:上下文的代码虽然分开了,但业务上它们必须协同工作。怎么协同?
你是一个商品上下文,订单上下文要查你的商品信息------怎么查?
你是一个订单上下文,库存上下文要扣库存------怎么扣?
有两种方式。
3.1 方式一:同步API调用
(关于openfeign的内容可以看我的另一篇文章深入解析 OpenFeign:从重试、拦截到负载均衡)
订单上下文发起HTTP/RPC请求,等待商品上下文返回商品信息。请求-响应模式,阻塞等待。
java
// 文件: order/infrastructure/acl/ProductApiClient.java
@Component
public class ProductApiClient {
@Autowired private ProductApiFeignClient feignClient;
public ProductDto getProduct(Long productId) {
// 同步调用商品上下文的开放主机服务
// 阻塞等待,直到商品上下文返回结果
return feignClient.getProduct(productId);
}
}
什么时候用:需要实时获取对方的当前状态。比如"下单时查商品价格",必须同步。
什么时候不用:对方响应慢,或者不需要实时结果。
3.2 方式二:异步事件通信
订单上下文发出一个"订单已支付"事件,库存上下文监听到这个事件后,自行扣减库存。发送即忘,非阻塞。
java
// 文件: order/domain/event/OrderPaidEvent.java
public class OrderPaidEvent {
private Long orderId;
private Long userId;
private List<OrderItem> items;
private LocalDateTime paidAt;
}
// 文件: order/application/service/PayOrderAppService.java(发布事件)
@Service
public class PayOrderAppService {
@Autowired private DomainEventPublisher eventPublisher;
@Transactional
public void handle(Long orderId) {
// 1. 业务逻辑
order.pay();
orderRepo.save(order);
// 2. 发布领域事件
eventPublisher.publish(new OrderPaidEvent(orderId, userId, items));
}
}
// 文件: inventory/application/listener/OrderPaidListener.java(订阅事件)
@Component
public class OrderPaidListener {
@EventListener
@Transactional
public void onOrderPaid(OrderPaidEvent event) {
// 库存上下文独立处理------扣减库存
for (OrderItem item : event.getItems()) {
inventoryService.deductStock(item.getProductId(), item.getQuantity());
}
}
}
什么时候用:不需要实时结果,允许最终一致性。比如"支付成功→扣库存→加积分"------扣库存可以等几秒,加积分可以等更久。
什么时候不用:需要对方的返回值来做业务决策。比如"下单时查商品价格",必须同步。
3.3 一张表看懂同步 vs 异步
| 对比维度 | 同步API | 异步事件 |
|---|---|---|
| 通信方式 | 请求-响应 | 发布-订阅 |
| 阻塞性 | 阻塞等待 | 非阻塞 |
| 耦合度 | 高(依赖对方可用) | 低(对方挂了不影响我) |
| 一致性 | 强一致 | 最终一致 |
| 适用场景 | 查价格、查库存、查用户信息 | 支付后扣库存、积分增加、发通知 |
| 是否跨服务 | 通常跨服务 | 通常跨服务 |
经验法则:需要"问"对方时用同步,需要"通知"对方时用异步。
3.4 两种通信方式的代码组织对比
回到前一篇的"下单支付"场景,现在有两个上下文------订单上下文和库存上下文,我们看看两种通信方式的区别:
同步方式(API调用) :
java
// 文件: order/application/service/PayOrderAppService.java
@Service
public class PayOrderAppService {
private final OrderRepository orderRepo;
private final InventoryApiClient inventoryClient; // 调用库存API
@Transactional
public void handle(Long orderId) {
Order order = orderRepo.findById(orderId);
order.pay();
orderRepo.save(order);
// 同步调用库存上下文扣库存
// 如果库存扣减失败,订单支付也会回滚
inventoryClient.deductStock(order.getItems());
}
}
异步方式(事件驱动) :
java
// 文件: order/application/service/PayOrderAppService.java
@Service
public class PayOrderAppService {
private final OrderRepository orderRepo;
private final DomainEventPublisher eventPublisher;
@Transactional
public void handle(Long orderId) {
Order order = orderRepo.findById(orderId);
order.pay();
orderRepo.save(order);
// 只发布事件,不关心谁处理、怎么处理
eventPublisher.publish(new OrderPaidEvent(orderId, order.getItems()));
}
}
// 文件: inventory/application/listener/OrderPaidListener.java
@Component
public class OrderPaidListener {
@EventListener
public void onOrderPaid(OrderPaidEvent event) {
// 库存上下文独立处理
inventoryService.deductStock(event.getItems());
}
}
四、把两件事放在一起看:一个完整的落地示例
用"下单支付"这个业务场景,把拆/合决策和通信方式放在一起看。
第一步:两个上下文怎么部署?
订单上下文和库存上下文------是拆成两个微服务,还是合在一个微服务里?
- 业务一致性:支付成功→扣库存,可以接受3秒延迟(最终一致)
- 发布频率:订单团队每天发布,库存团队每周发布,节奏不同
- 技术异构:都用Java,技术栈相同
结论:拆成两个微服务(一致性允许最终一致 + 发布节奏不同)。
第二步:两个上下文怎么通信?
拆成两个微服务之后,订单上下文怎么通知库存上下文扣库存?
- 订单不需要库存的返回值(扣没扣成不影响订单支付状态)
- 允许最终一致(扣库存可以延迟几秒)
结论:用异步事件------订单发出"订单已支付"事件,库存订阅并处理。
完整代码:
java
// === 订单上下文(微服务A) ===
// 文件: order/domain/event/OrderPaidEvent.java
public class OrderPaidEvent {
private Long orderId;
private List<OrderItemSnapshot> items;
private LocalDateTime paidAt;
}
// 文件: order/application/service/PayOrderAppService.java
@Service
public class PayOrderAppService {
private final OrderRepository orderRepo;
private final DomainEventPublisher eventPublisher;
@Transactional
public void handle(Long orderId) {
Order order = orderRepo.findById(orderId);
order.pay();
orderRepo.save(order);
// 发布事件------库存上下文会监听
eventPublisher.publish(new OrderPaidEvent(orderId, order.getItems()));
}
}
// === 库存上下文(微服务B) ===
// 文件: inventory/application/listener/OrderPaidListener.java
@Component
public class OrderPaidListener {
private final InventoryService inventoryService;
@EventListener
public void onOrderPaid(OrderPaidEvent event) {
// 扣减库存------自己的独立事务
for (OrderItemSnapshot item : event.getItems()) {
inventoryService.deductStock(item.getProductId(), item.getQuantity());
}
}
}
关键点:
- 订单上下文不知道库存上下文的存在------它只管发事件
- 库存上下文不知道谁发了事件------它只管处理
- 两者通过事件解耦,各自独立演进
五、领域事件、应用事件、系统事件:别搞混了
在异步事件通信中,很多人把三种不同的事件混为一谈。
| 事件类型 | 定义 | 例子 | 谁来发 | 谁来订阅 |
|---|---|---|---|---|
| 领域事件 | 领域内发生了业务上重要的事 | OrderPaidEvent(订单已支付) |
领域层 | 本上下文的其他聚合,或其他上下文 |
| 应用事件 | 应用层的流程状态变化 | OrderPaymentStarted(支付流程开始) |
应用层 | 同一个服务的其他应用组件 |
| 系统事件 | 技术层面的状态变化 | CacheRefreshedEvent(缓存已刷新) |
基础设施层 | 基础设施层 |
最容易混淆的是领域事件和应用事件。
区分标准很简单:问自己,这个事件业务方(非技术人员)听得懂吗?
- 业务方听得懂"订单已支付" → 领域事件
- 业务方听不懂"缓存已刷新" → 系统事件
- 业务方可能听得懂"支付流程开始",但不关心 → 应用事件
核心结论 :上下文之间通信,只用领域事件。应用事件和系统事件都在上下文内部使用,不跨上下文。
六、战略设计 → 战术设计:完整的主脉
到现在为止,四篇文章已经串起了一条完整的路径:
| 阶段 | 做什么 | 产出 |
|---|---|---|
| 战略设计 | 事件风暴 → 划分限界上下文 | 上下文的边界和名称 |
| 战略→技术 | 上下文映射 → 确定协作关系 | 防腐层/开放主机服务的定义 |
| 战略→技术 | 拆/合决策 → 确定部署边界 | 多少个微服务,每服务包含哪些上下文 |
| 战略→技术 | 通信方式决策 → 确定交互方式 | 哪些用同步API,哪些用异步事件 |
| 战术设计 | 在每个上下文内部做战术设计 | 实体、值对象、聚合、仓储...... |
| 代码落地 | 按包结构组织代码 | 可运行的代码 |
把这一篇放到整个系列里,主脉变得更完整了:
三层架构有一个Service黑盒 → DDD把它拆成四层 → 类爆炸了,砍掉一半 → 系统大了,用限界上下文切分 → 切完了,决定怎么部署(微服务) → 部署完了,决定怎么通信(事件/API) → 最后在每个上下文内部做战术设计。
七、核心总结:一张表讲透"部署 + 通信"
如果你没时间读完全文,读完这张表就够了。
拆/合决策表
| 决策维度 | 合(1个微服务) | 拆(2个微服务) |
|---|---|---|
| 一致性 | 强一致 | 最终一致 |
| 发布频率 | 相同 | 不同 |
| 技术栈 | 相同 | 不同 |
| 代码体现 | 同一个模块,共享事务 | 不同服务,用事件同步 |
通信方式决策表
| 决策维度 | 同步API | 异步事件 |
|---|---|---|
| 需要返回值 | 需要 | 不需要 |
| 实时性 | 强(立即) | 弱(可延迟) |
| 耦合度 | 高 | 低 |
| 适用场景 | 查询、校验 | 通知、触发 |
| 代码体现 | HTTP/RPC调用 | 事件发布+订阅 |
写在最后
微服务的流行,让很多人忘了问"为什么要拆"。
"我们用了微服务"成了目的本身,而不是手段。结果是:一个简单的系统被拆成了十几个服务,运维复杂度暴涨,分布式事务满天飞,问题定位像大海捞针。
"能拆"和"该拆"是两件事。
如果你遵循了限界上下文的划分,但业务一致性要求很强------强行拆开只会带来灾难。合在一个服务里不是耻辱,是不该拆的时候不拆的智慧。
所以,回答开头的那个问题: "怎么判断该不该拆?"
该拆的时候: 两个上下文之间可以接受最终一致,且发布节奏不同,或技术栈不同。
不该拆的时候: 两个上下文之间必须强一致,改了A必须立即改B,或者团队规模小于等于5人。
微服务是你的架构手段,不是你的KPI。拆之前先想清楚:你是为了解决团队协作问题而拆,还是为了赶时髦而拆?