限界上下文之后:微服务怎么拆、上下文怎么聊?

16 阅读14分钟

上下文画好了,然后呢?——怎么部署、怎么通信,是紧接着的两个问题。

前一篇,我们花了一整篇文章讲限界上下文怎么划分:用事件风暴画边界,把“商品”在订单上下文、商品上下文、库存上下文里分别建模。

但画完边界之后,两个问题会立刻冒出来:

第一个问题:这些上下文,在部署上怎么落地?

订单上下文、商品上下文、库存上下文——它们是应该分别部署成三个独立的微服务,还是放在同一个微服务里?如果合并,合并到什么程度?如果拆分,拆到什么粒度?

第二个问题:上下文之间,怎么通信?

订单上下文需要知道库存够不够,库存上下文需要知道订单有没有支付。如果它们真的拆成了独立的微服务,怎么互相调用?用同步API?用异步消息?什么时候用哪个?

这两个问题不解决,限界上下文就只是一张画在PPT上的图——好看的摆设。

这篇文章把这两个问题一起讲。

读完这篇文章,你将获得:

✅ 理解微服务 ≠ 限界上下文——什么情况下1个上下文拆成多个服务,什么情况下多个上下文合并成1个服务
✅ 掌握拆与合的决策模型——从业务一致性、发布频率、技术异构三个维度判断
✅ 分清领域事件 vs 应用事件 vs 系统事件——它们各解决什么问题
✅ 学会同步API vs 异步事件的选择标准——什么时候该用哪个
✅ 看清从战略设计到代码落地的完整路径——上下文画好了,代码怎么组织

一、先回顾:限界上下文画完之后的样子

前一篇,我们用一个电商系统做了事件风暴,划分出了五个限界上下文:

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│  订单上下文  │     │  商品上下文  │     │  库存上下文  │
│ (Order)     │     │ (Product)   │     │ (Inventory) │
└─────────────┘     └─────────────┘     └─────────────┘
┌─────────────┐     ┌─────────────┐
│  支付上下文  │     │  用户上下文  │
│ (Payment)   │     │ (User)      │
└─────────────┘     └─────────────┘

每个上下文内部,都有自己独立的领域模型:

// 文件: 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写导出服务更高效)。所以,拆成一个独立的微服务

代码体现

// 方案:合——用户上下文和权限上下文在同一个服务里
// 文件: 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请求,等待商品上下文返回商品信息。请求-响应模式,阻塞等待。

// 文件: order/infrastructure/acl/ProductApiClient.java
@Component
public class ProductApiClient {
    @Autowired private ProductApiFeignClient feignClient;
    
    public ProductDto getProduct(Long productId) {
        // 同步调用商品上下文的开放主机服务
        // 阻塞等待,直到商品上下文返回结果
        return feignClient.getProduct(productId);
    }
}

什么时候用:需要实时获取对方的当前状态。比如“下单时查商品价格”,必须同步。

什么时候不用:对方响应慢,或者不需要实时结果。

3.2 方式二:异步事件通信

订单上下文发出一个“订单已支付”事件,库存上下文监听到这个事件后,自行扣减库存。发送即忘,非阻塞。

// 文件: 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调用)

// 文件: 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());
    }
}

异步方式(事件驱动)

// 文件: 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,技术栈相同

结论:拆成两个微服务(一致性允许最终一致 + 发布节奏不同)。

第二步:两个上下文怎么通信?

拆成两个微服务之后,订单上下文怎么通知库存上下文扣库存?

  • 订单不需要库存的返回值(扣没扣成不影响订单支付状态)
  • 允许最终一致(扣库存可以延迟几秒)

结论:用异步事件——订单发出“订单已支付”事件,库存订阅并处理。

完整代码

// === 订单上下文(微服务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。拆之前先想清楚:你是为了解决团队协作问题而拆,还是为了赶时髦而拆?