那次我们拉了十几个业务方关在会议室两天,用事件风暴把微服务边界理清楚了

那天下午三点,我站在白板前,看着满墙的便利贴和画得乱七八糟的箭头,突然觉得这事儿能成。

十几个业务方的代表挤在会议室里,空调开到了最低温还是压不住讨论的热度。这已经是第二天了,但真正关键的转折点发生在第一天下午四点半——当时我们刚刚把“订单已支付”这个事件拆开,产品经理突然站起来说:“等等,我们财务结算那边根本不需要知道用户支付了多少钱,我们只需要知道‘钱到账了’就行。”

这句话让整个房间安静了三秒。然后我们意识到,之前花了三个月争论的微服务边界问题,根源就在这里:每个业务方对同一个业务事件的理解完全不同。

为什么选事件风暴而不是其他方法

说实话,我们在组织这次工作坊之前,已经试过很多办法。2023年初,我们开始推微服务拆分,架构组先画了一版服务蓝图,然后找各业务方评审。结果每次评审都变成吵架会——订单团队说“库存扣减必须同步”,库存团队说“你们订单失败了我们还得回滚,耦合太重了”。来回拉锯了两个月,服务边界画了七版,每一版都有人不满意。

后来我在一次技术会议上听说了事件风暴,当时觉得这个方法太“软”了,不像正经的架构设计工具。但真用起来才发现,它的核心价值不在于画图,而在于强制所有人用同一种语言描述业务

工作坊开始前,我给每个参与方发了一份准备材料,要求他们带上自己团队最核心的三个业务场景,用“谁在什么时候做了什么,产生了什么结果”的格式写。这个要求看起来简单,但收上来的材料五花八门:有的写成了技术实现细节,有的只写了正常流程完全不提异常情况,还有的直接甩过来一份PRD让我自己看。

第一天上午:建立统一语言花了整整三小时

工作坊真正开始时,我让每个人把自己写的三个场景贴在墙上,然后我发现了一个致命问题:同一个词在不同团队那里意思完全不一样。

物流团队说的“已发货”,意思是仓库把货交给了快递员;但客服团队说的“已发货”,意思是系统里录入了快递单号;而财务团队根本不关心“已发货”,他们只关心“已出库”,因为这会触发成本核算。

这种概念上的不一致,是之前所有服务划分失败的根本原因。所以我们花了整整三个小时,只做一件事:在白板左边列出所有参与方都使用的业务名词,然后逐个澄清每个词的确切定义。

这件事进行得非常痛苦。我记得定义“订单状态变更”这个概念时,订单团队、支付团队和售后团队争论了将近四十分钟。最后我们达成的共识是:订单状态变更是一个领域事件,但不同上下文关心的状态值完全不同——支付上下文只需要知道“待支付/已支付/已退款”,履约上下文需要知道“待发货/已发货/已签收”,而售后上下文需要知道“是否在退换货流程中”。

这个共识直接导向了一个关键结论:不要试图做一个统一的订单状态机,每个微服务只维护自己关心的状态维度。

第一天下午:从业务事件反推服务边界

下午的节奏明显加快了。我们用橙色便利贴写“领域事件”,用蓝色便利贴写“命令”,用黄色便利贴写“聚合”。规则很简单:每个事件必须有一个明确的触发命令,每个命令必须作用在一个聚合上。

转折点出现在分析“用户下单”这个命令时。前台业务方认为下单就是创建订单,但库存团队提出:“如果库存不足,这个订单创建了也没用。”支付团队又补充:“如果用户用了优惠券,下单时就要锁定优惠券,不然用户换设备再来券就没了。”

这时候事件风暴的优势就体现出来了。我们不再争论“下单应该由哪个服务处理”,而是先梳理完整的业务事件流:

  1. 用户提交订单 → 触发“订单已提交”事件
  2. 订单已提交 → 触发库存预占命令 → 库存预占成功 → 触发“库存已预占”事件
  3. 库存已预占 → 触发优惠券锁定命令 → 优惠券锁定成功 → 触发“优惠券已锁定”事件
  4. 优惠券已锁定 → 触发支付创建命令 → 触发“支付单已创建”事件

当这个事件流完整贴在墙上后,服务边界就自然浮现出来了:订单服务的职责是生成订单并串联流程,但它不应该直接操作库存和优惠券——它只需要发出“订单已提交”这个事件,由库存服务和营销服务各自订阅并处理。

当场就有人问:“那订单服务怎么知道库存预占成功了?”这是关键问题。我们的答案是:库存服务处理完预占后,会发出“库存已预占”事件,订单服务订阅这个事件,收到后继续推动流程。如果库存预占失败,库存服务会发出“库存预占失败”事件,订单服务收到后触发订单取消。

这就是事件驱动的本质:服务间通过事件协作,而不是通过RPC调用互相依赖。

第二天上午:处理异常流程才是真正的考验

第一天结束的时候,大家都很兴奋,觉得找到了一套完美的方法论。但第二天上午,当我们在墙上贴满了正常流程后,我开始引导大家往异常流程上想。

“如果支付超时了怎么办?”我问。

支付团队说:“我们会有支付超时事件发出来。”

“那订单服务收到支付超时事件后,需要做什么?”

这一下炸了锅。订单服务需要取消订单,但取消订单又会触发一系列连锁反应:库存要释放预占、优惠券要解锁、如果有赠送积分也要扣回。这涉及订单、库存、营销、会员四个服务。

最激烈的争论集中在一个点上:这些补偿操作应该由谁协调?

有人提出用Saga模式,由订单服务作为协调者。但售后团队立刻反对:“如果是用户主动取消订单,由订单服务协调没问题。但如果是支付超时导致的取消,支付服务才是事件源,为什么不让支付服务来协调?”

这个争论持续了将近一个小时。最后我们决定看数据:统计了过去三个月所有订单取消的原因,发现支付超时导致的取消只占15%,用户主动取消占60%,商家取消占15%,系统异常取消占10%。基于这个数据,我们决定:订单取消的主协调者放在订单服务,但支付超时这个特定场景,由支付服务直接发出“支付超时取消订单”命令给订单服务,订单服务收到后执行标准的取消流程。

这个设计意味着订单服务对外暴露了两个接口:一个是用户主动取消的API,一个是接收支付超时取消的命令处理接口。但核心的取消逻辑是同一段代码,只是触发源不同。

第二天下午:从墙上的便利贴到代码结构

工作坊最后三个小时,我们开始把墙上的东西翻译成技术产出物。这是大多数事件风暴工作坊最薄弱的环节——很多团队讨论得热火朝天,但回到工位后就不知道怎么写代码了。

我们的做法是分三步走。

第一步:确定每个服务的事件接口。 每个微服务对外暴露的不是REST API,而是它发布和订阅的事件列表。我们用一张表格记录,格式如下:

服务 发布的事件 订阅的事件
订单服务 订单已提交、订单已取消、订单已完成 库存已预占、库存预占失败、支付已完成、支付已超时
库存服务 库存已预占、库存预占失败、库存已释放 订单已提交(触发预占)、订单已取消(触发释放)
支付服务 支付已创建、支付已完成、支付已超时、支付已退款 订单已提交(触发支付创建)

这张表后来直接变成了我们事件总线的拓扑配置。

第二步:确定每个服务的内部结构。 我们给每个服务定义了统一的分层结构:

order-service/
├── domain/           # 聚合、实体、值对象、领域事件
├── application/      # 命令处理器、事件处理器
├── infrastructure/   # 消息队列适配器、数据库实现
└── interfaces/       # 对外暴露的API和事件订阅入口

关键是application层的事件处理器。每个订阅的事件对应一个独立的handler文件,比如InventoryPreoccupiedHandler.java,其职责是:接收“库存已预占”事件,调用订单聚合的方法更新状态,然后判断是否所有前置条件都满足了(库存预占成功且优惠券锁定成功),如果是则触发下一步流程。

第三步:确定跨服务的事件契约。 这是最容易出问题的地方。我们给每个事件定义了schema,包括必填字段、可选字段和字段类型。比如“订单已提交”事件:

{
  "eventId": "evt_20240115_001",
  "eventType": "order.submitted",
  "timestamp": "2024-01-15T10:30:00Z",
  "payload": {
    "orderId": "ord_12345",
    "buyerId": "user_67890",
    "items": [
      {
        "skuId": "sku_001",
        "quantity": 2,
        "unitPrice": 99.00
      }
    ],
    "totalAmount": 198.00,
    "couponId": "coupon_xyz",
    "source": "APP"
  }
}

这里有一个重要的约定:事件payload中只包含事件发生那一刻的完整快照,不包含任何状态标记(比如“待处理”之类的字段)。这样订阅方不需要回查发布方就能拿到所有需要的信息,避免了服务间的查询耦合。

落地过程中的三个坑

工作坊结束后,真正的挑战才开始。在接下来的两周里,我们踩了三个坑。

第一个坑是事件版本管理。工作坊结束第三天,库存团队就提出需要在“库存已预占”事件里加一个“预占超时时间”字段,支持自动释放超时未支付的预占。但订单服务已经按原来的schema写好了处理逻辑,直接加字段会导致订单服务的反序列化出错。最后的解决方案是:事件schema采用向后兼容的扩展策略,新增字段一律设为可选,消费方必须忽略未知字段。这个原则我们写进了团队的开发规范。

第二个坑是事件顺序问题。上线第一周就出了事故:用户下单后,订单服务先收到了“优惠券已锁定”事件,紧接着收到了“库存已预占”事件。但订单服务的处理逻辑是:只有库存预占成功后才检查优惠券状态。结果就是优惠券锁定了但订单没往下走,用户重新下单时发现优惠券已经被用掉了。修复方案是:订单服务在收到“优惠券已锁定”事件时,如果还没收到“库存已预占”事件,就先把优惠券信息暂存下来,等库存预占事件到了再一起处理。这本质上是一个事件聚合问题,我们后来引入了一个轻量级的Saga状态机来管理这种多事件依赖。

第三个坑是监控。事件驱动的系统出问题很难排查,因为调用链是异步的。我们在工作坊时只讨论了业务逻辑,完全没考虑可观测性。后来补上了三个监控维度:每个事件的处理延迟、每个事件处理器的成功/失败率、每个Saga流程的完成时间。具体做法是在事件总线上加了OpenTelemetry的trace,每个服务在发布事件时自动带上traceId,消费方处理时也使用同一个traceId,这样在Jaeger上就能看到一个完整的事件处理链路。

工作坊组织的一些经验

这次工作坊能成功,除了方法本身,组织方式也很关键。

参会人员的选择上,我们坚持每个业务方必须派一个能当场拍板的人。这不是说一定要CTO或者VP级别,但必须是能代表团队做技术决策的人。第一天下午讨论订单取消逻辑时,库存团队的代表当场就说:“我们可以接受订单服务来协调取消流程,但释放预占的接口必须幂等。”如果他没有这个决策权,这个结论就要等到第二天甚至下周才能确定,整个工作坊的节奏就崩了。

时间安排上,两天是下限,不能再短。第一天主要是建立统一语言和梳理正常流程,第二天是异常流程和技术落地。如果压缩到一天,要么统一语言没做透导致后面又反复,要么技术落地没时间讨论导致工作坊产出落不了地。

产出管理上,我们每天结束前做三件事:拍照记录所有便利贴、把当天的结论整理成电子文档、列出第二天必须解决的关键问题。这些文档后来成了团队的新人培训材料,新人入职先看一遍这个文档,比看十遍架构图都管用。

常见问题

事件风暴和需求评审有什么区别?

需求评审关注的是“系统要做什么”,产出是功能列表和PRD。事件风暴关注的是“业务如何发生”,产出是领域事件、命令和聚合的识别结果。需求评审的参与者主要是产品和技术,事件风暴必须有业务方深度参与,因为核心是业务语言的统一。我们在工作坊中发现,很多所谓的“需求不明确”,本质是不同业务方对同一个概念的定义不一致,这个问题靠产品写PRD解决不了。

微服务已经拆完了,再用事件风暴还有意义吗?

有意义,但目标不同。已拆分的系统用事件风暴,主要目的是发现服务间的不合理依赖和重复逻辑。我们团队另一个项目组在拆分完成后做了一次事件风暴,发现三个服务各自维护了一套几乎完全相同的用户地址校验逻辑,最后抽成了一个共享的地址校验模块。另外,事件风暴梳理出的事件流可以直接用来优化服务间的异步协作,把同步调用改成事件驱动。

事件风暴画出来的这些聚合,怎么跟数据库设计对应?

聚合不等同于数据库表,聚合是业务一致性的边界。一个聚合可能对应多张表,但必须保证聚合内的一致性通过事务来保障,聚合间的一致性通过事件来最终一致。我们在落地时,每个聚合对应一个独立的数据库schema,同一个微服务内的不同聚合可以共享数据库实例,但禁止跨聚合的联表查询。这个约束在代码评审时会被严格执行。