前阵子帮一个团队排查线上问题,他们的下单接口平均耗时从最初的 200 毫秒一路涨到接近 4 秒。一开始所有人都怀疑是数据库慢查询,结果一轮排查下来,发现耗时主因根本不在 SQL,而是下单成功之后挂在主链路上的一串同步动作:调库存服务扣减库存、调通知服务发短信、调财务服务记账。下游一抖动,整条链路跟着遭殃,用户等到超时,界面直接报"下单失败",可库存那边其实已经扣了。这种状态错乱在微服务架构里特别典型,也是我后来系统研究领域驱动设计(DDD)中领域事件与事件驱动架构设计的直接契机。
这篇文章不是教科书式的概念复述,而是我从数个真实项目里踩出来的落地经验。如果你正在做微服务拆分,或者在 DDD 落地过程中卡在"聚合之间怎么通信"这个问题上,又或者你的服务里已经出现大量"为了调用别人而写的同步代码",那这篇文章值得耐心看完。我会从问题根源、概念边界、事件建模、可靠发布、消费幂等、版本演进这几个角度,把领域事件从设计到落地的完整链路讲清楚。有些方案看起来不是最炫的,但都是经受过生产环境验证的东西。
1. 一次真实事故:同步调用链到底错在哪
1.1 事故复盘:主链路上挂了多少不必要的强一致
先还原一下当时线上接口的调用结构。下单接口收到请求后,主流程大约做了这些事:
- 订单服务在自己库里插入订单记录,状态置为"已创建"。
- 同步调用库存服务扣减库存,等返回结果。
- 同步调用通知服务发短信,等返回结果。
- 同步调用财务服务创建记账流水,等返回结果。
- 全部成功后才返回"下单成功"给前端。
这个流程在业务量小、服务都部署在同一机房时还没那么明显,一旦流量上来、服务拆分到不同进程甚至不同网络区域,问题就全暴露了。最典型的一个场景是:通知服务依赖的短信网关超时,默认 5 秒才抛异常,而通知服务自己的接口超时设置是 3 秒,于是订单服务的下游调用在 3 秒后失败。订单服务按"失败即回滚"的逻辑返回"下单失败",但前面库存服务的扣减已经成功了,没有回滚机制,最终出现"用户以为没下单成功、库存却少了"的脏数据。
我当时把这个调用链的每一步都列出来问团队一个问题:这几步里,有哪些业务是用户真正在当下、在这个请求里就必须拿到结果并强一致校验的?
答案是很少。
用户下单那一刻,真正必须强一致保证的,是订单记录本身要创建成功、库存扣减要能够被确认(不然可能会出现超卖)。至于发短信、记账,甚至更多营销类的连带动作,都不是用户在当前请求里必须等待的。我们把这些本可以异步化、可以最终一致的动作,全部用同步 RPC 塞进了主链路,等于把链路每一环的可用性相乘,整体可用性被拉低到了一个很夸张的程度。
1.2 问题本质:把"聚合的状态变更"和"变更后的外部反应"混为一谈
继续往深挖,这其实不是一个技术选型问题,而是建模问题。
在 DDD 的分层思想里,订单是一个聚合根。聚合根负责维护自身的不变约束,比如"创建订单时余额要足够""取消订单时订单必须处于已创建状态"。这些约束属于聚合内部逻辑,必须强一致,必须在一个事务里解决。但"订单已创建"这个事实发生之后,外部系统(库存服务、通知服务、财务服务)要对这个事实做什么反应,并不属于订单聚合的职责边界。
我们当时的代码把这两层混在了一起:订单服务创建完订单后,直接由同一个服务去"安排"其他服务干活。这就是典型的服务间耦合。反应方什么时候执行、执行到什么程度、失败了怎么补偿,全部压到了调用方头上。
从"方法调用"到"事实广播"的转变,是解决这一类问题的关键思路。订单服务不再去逐个调用下游接口,而是先在本地事务里把订单状态变更落库,然后发布一个领域事件,比如OrderPlaced,告知全世界"订单已创建"。谁对这个事实感兴趣,谁自己去订阅、自己去处理。
这一转变的本质,是让订单服务从"流程编排者"回归到"事实记录者"。这也是领域事件在 DDD 里最核心的价值:让领域模型保持内聚,同时把跨领域的协作从强耦合变成松耦合。
2. 领域事件和事件驱动架构:概念边界与常见误用
2.1 领域事件到底是个什么东西
领域事件(Domain Event),从概念上说是"领域中发生过、领域专家关心的、对业务有影响的事实"。它的关键特征有这么几点:
- 它一定发生在过去,命名通常是过去时态,比如
OrderPlaced、PaymentCaptured,而不是CreateOrder、PlaceOrder。 - 它是领域语言的一部分,不是技术语言。
OrderPlaced是业务人员也能看懂的话,而OrderCreatedDTO或者InsertOrderRpc这种名字不是。 - 它描述的是"已经发生的事实",而不是"指令"或"期待"。
UserLevelUpgraded(用户等级已提升)是事件,PleaseUpgradeUserLevel(请把用户等级提升一下)是命令,两者语义完全不同。
在实际代码里,一个领域事件通常就是一个小而纯粹的类,只包含这个事实需要被外界知道的最小字段集。下面是我们在订单服务里定义的一个简化版事件类:
public class OrderPlaced { private final String eventId; private final String orderId; private final String customerId; private final BigDecimal totalAmount; private final String currency; private final Instant placedAt; public OrderPlaced(String eventId, String orderId, String customerId, BigDecimal totalAmount, String currency, Instant placedAt) { this.eventId = eventId; this.orderId = orderId; this.customerId = customerId; this.totalAmount = totalAmount; this.currency = currency; this.placedAt = placedAt; } }注意这个事件里没有stockQuantity、没有notificationPhone,因为它描述的顺序就是订单已创建,跟库存和短信没有关系。把不相关字段塞进事件,是建模时最容易犯的错误之一,会让事件的消费者困惑于"这个事件到底代表什么"。
2.2 事件驱动架构和领域事件不是一个层次的东西
事件驱动架构(EDA)是一种系统级架构风格,指的是组件之间通过产生事件和消费事件来协作,而不是通过直接的请求/响应调用。这里的事件可以是业务领域的,也可以是技术层面的,比如监控告警、配置变更通知。
领域事件和事件驱动架构的关系,我认为是"领域事件是 EDA 在业务层面的载体"。一个事件驱动架构的系统,必须有强烈的领域事件意识:发布方要清楚地知道自己发布的每个事件代表哪个业务事实;消费方要清楚地知道自己订阅每个事件是为了响应哪个事实。否则,消息队列里跑的只是一堆没有业务语义的 JSON,架构上是 EDA,领域层面却是一团浆糊。
还要厘清一个常见混淆点:DDD 的领域事件更多时候是在同一个限界上下文内部使用,尤其在多个聚合之间协调时,可以直接在进程内发布和订阅,不一定非要走消息队列。真正跨限界上下文、跨服务的事件,通常叫集成事件(Integration Event),是通过消息中间件传递的。很多团队不区分这两者,一上来就把所有领域事件全部扔进 Kafka,结果导致同一个上下文内部的高频交互也被异步化,反而增加了复杂度、降低了可调试性。
我个人的实践准则是:聚合之间的领域事件,优先在进程内通过领域事件总线同步或异步处理;跨 Bounded Context 的集成事件,才走消息中间件。这条边界清楚了,事件驱动架构才不会变成"消息地狱"。
2.3 常见的三个误用场景
- 把命令当事件发布。事件是事实,命令是意图。
InventoryDeducted是事件,DeductInventory是命令。如果发布的是命令,那么消费方收到后还得做判断、做校验,本质上还是同步调用的变形,只是换了个通道。 - 用事件掩盖本应强一致的操作。"注册用户之后必须立刻创建默认工作空间,然后返回 workspaceId 给前端",这种强依赖关系如果硬拆成事件异步化,前端体验和接口语义都会变得很奇怪。事件驱动不应该被当成"把同步问题变成异步问题"的万能药。
- 事件满天飞却没有统一治理。今天一个
OrderPaid,明天再来一个PayOrderSuccess,后天又有PayResultUpdated,同一种事实出现三四个名字,消费方根本没法维护。事件也是领域契约,需要像接口一样有规范、有评审。
3. 事件建模实操:从事件风暴到事件契约
3.1 事件风暴四步流程
很多团队拿到"做领域事件"这个任务后的第一反应是"先设计消息的 topic 和字段",这个顺序其实是错的。事件应该从业务里长出来,而不是从技术里造出来。我最常用的建模工具是事件风暴(Event Storming),它不需要写代码,只需要一面足够大的墙、一大堆便利贴和几支笔。流程分四步:
- 把参与业务流程的领域专家、产品、开发拉到一起,先别讨论表结构和接口。
- 让所有人按时间顺序,把业务流程中"会发生的事"写在橙色的便利贴上,每张一个事件,用过去时描述。注意,这里写的是"发生了什么",而不是"要做什么"。比如在"下单"流程里,大家会贴出"报价已生成""订单已创建""支付已发起""支付已完成""库存已锁定"等十几张便利贴。
- 把这些事件按时间线排布在墙上,形成一条完整的业务时间线。这时候,同一个事件附近往往会浮现出一些触发它的动作(命令)和操作它的角色(Actor),用不同颜色的便利贴补上去。
- 从时间线上寻找聚合的边界。一个聚合通常围绕一组内聚的事件和命令展开,比如"订单已创建""订单已修改""订单已支付"围绕订单聚合;"库存已锁定""库存已释放"围绕库存聚合。边界画清楚后,跨聚合、跨上下文的事件关系也就自然浮出水面了。
一场事件风暴下来,你收获的不是一叠便利贴,而是一张团队对业务共识达成的全景图。哪怕后续不采用事件驱动架构,这张图对理解业务也有巨大的帮助。
3.2 事件命名和 payload 设计的关键原则
命名是事件设计的门面,几个原则坚持久了会受益。
- 使用业务术语,不用技术命名。
OrderApproved好过OrderStatusUpdateSuccess。 - 精确到业务粒度。
PaymentCaptured和PaymentRefunded是两个事件,不要合并成一个PaymentStatusChanged。合并会让消费方不得不在 payload 里通过 switch 分支来判断到底发生了什么,等于把事件又变回了状态枚举。 - 事件 payload 是事实快照,一般只带事件唯一 ID、聚合 ID、业务关键字段和发生时间。同一个聚合的多次变更会形成多个不同事件,而不是一个"最新状态"对象。
事件 payload 要避免塞入太多冗余数据。最常见的错误是图省事,直接把整个聚合的数据序列化后发出去,消费者确实想要什么都能取到,但代价是:
- 事件变大,消息中间件和消费者的带宽、存储成本上升;
- 消费者对不相关字段产生隐式依赖,后续聚合模型一改,消费方莫名其妙就挂了;
- 事件语义模糊,外人看不出这个事件到底代表哪个事实。
3.3 一个完整的订单事件建模案例
用一个简化版订单履约流程来走一遍:
| 阶段 | 事件 | 关键 payload 字段 | 主要消费者 |
|---|---|---|---|
| 下单 | OrderPlaced | eventId, orderId, customerId, totalAmount, placedAt | 库存服务、财务服务、通知服务 |
| 支付 | PaymentCaptured | eventId, paymentId, orderId, paidAmount, paidAt | 订单服务、财务服务、履约服务 |
| 履约 | StockReserved | eventId, orderId, skuId, quantity | 订单服务、财务服务 |
| 发货 | OrderShipped | eventId, orderId, carrierCode, trackingNo, shippedAt | 通知服务、财务服务 |
这个表的重点是:每个服务只消费和自己业务相关的事件。库存服务只关心OrderPlaced(去锁库存)和OrderCancelled(去释放库存),不关心PaymentCaptured。财务服务关心PaymentCaptured和OrderPlaced,需要在支付完成时记账。通知服务则关心OrderShipped,在发货时给用户推送物流信息。
把这张事件表确定下来,就等于确定了系统间的契约。后续任何新增消费方,都只是对新事件或已有事件的再订阅,不需要发布方做任何改动。这是事件驱动架构相比同步调用链最大的优势。
4. 事务性发件箱:领域事件可靠发布的保底方案
4.1 双写问题:为什么不能先发消息再改库
确定了要发布事件,第一个绕不开的工程问题是:如何保证"领域状态变更"和"事件发布"的一致性。
假设你在代码里这样写:
orderService.createOrder(order); // 先写库 eventPublisher.publish(new OrderPlaced(...)); // 再发消息如果发消息失败,你会捕获异常然后重试。但如果在重试之前进程崩溃了呢?事件就丢了,下游永远不知道这笔订单产生了。反过来,如果你先发消息再写库,那消息发出去了,DB 写入失败,消费者收到了一个"存在但事实并不成立"的事件,脏数据就流向了全系统。
这就是经典的"双写"问题:数据库和消息队列是两个独立的系统,无法用本地事务同时保证两份写入要么都成功、要么都失败。分布式事务(如两阶段提交)在理论上可以解决,但实际工程中复杂度太高、性能损耗太大,大多数团队最终都会放弃。
4.2 发件箱表结构与发布流程
事务性发件箱(Transactional Outbox)是解决双写问题的最实用方案,核心思路异常简单:把"要发布的事件"作为一条记录,和业务数据放在同一个数据库事务里写入。因为这是同一个事务,要么业务数据和事件数据都写成功,要么都回滚,一致性天然得到保证。
我们项目里的发件箱表结构大致是这样:
CREATE TABLE outbox_events ( id BIGSERIAL PRIMARY KEY, event_id VARCHAR(64) NOT NULL UNIQUE, aggregate_type VARCHAR(128) NOT NULL, aggregate_id VARCHAR(64) NOT NULL, event_type VARCHAR(128) NOT NULL, payload_json JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), published_at TIMESTAMPTZ ); CREATE INDEX idx_outbox_unpublished ON outbox_events (created_at) WHERE published_at IS NULL;业务写入订单数据时,在同一事务里插入一条发件箱记录:
BEGIN; INSERT INTO orders (id, customer_id, total_amount, status) VALUES (10001, 888, 1999.00, 'CREATED'); INSERT INTO outbox_events (event_id, aggregate_type, aggregate_id, event_type, payload_json) VALUES ( 'evt_20250601_10001', 'Order', '10001', 'OrderPlaced', '{"orderId":"10001","customerId":"888","totalAmount":1999.00,"placedAt":"2025-06-01T10:00:00Z"}' ); COMMIT;事务提交后,后台有一个独立的事件发布器,定期扫描发件箱表,把未发布的事件取出来,发往消息中间件。发布成功后,更新published_at字段。
4.3 发布器的工程细节:跳过锁、批量拉取与失败补偿
发件箱表的写入逻辑很简单,真正的工程细节全在发布器上。以下几个点是我们踩过坑后总结出来的:
- 多实例部署时,轮询查未发布事件会存在"多个实例同时拿到同一批事件"的竞争。我推荐用
SELECT ... FOR UPDATE SKIP LOCKED来解决。这条语法可以让不同实例各自锁定不同的行,互不阻塞,实现简单且高效。
SELECT * FROM outbox_events WHERE published_at IS NULL ORDER BY created_at LIMIT 50 FOR UPDATE SKIP LOCKED;- 每次拉取的批次不宜过大。我们生产环境通常一次拉 50~100 条,处理完立即提交事务,避免单次占用事务时间过长。
- 发布器发送消息时,把
event_id作为消息的 key 或者消息头的一部分。Kafka 这类消息中间件是至少一次投递语义,同一个事件可能被重复发送到消费者,event_id是消费端做幂等的基础。 - 发布失败的记录不要无限重试。要设置最大重试次数,超过阈值后标记为"死信"状态,或者进入人工补偿队列。发件箱表的
published_at为空且重试次数超标的记录,应该配置监控告警,而不是在循环里永远耗着。
5. 消费端的幂等与乱序:最终一致性的两个必修课
5.1 至少一次投递下幂等是硬需求
先接受一个现实:主流消息中间件(Kafka、RocketMQ、RabbitMQ 等)默认提供的是"至少一次"投递语义,也就是说同一个消息在极端情况下可能会被投递多次。可能的原因包括:消费者处理完成后,还没来得及提交 offset 就崩溃了;发布器重试导致同一事件发了两次;下游依赖接口超时后消息重发。
如果你不把消费端做成幂等的,重复投递就会直接变成重复扣款、重复发券、重复记账这类线上事故。所以在这个架构里,幂等不是一个可选项,而是消费端必须满足的约束。
5.2 幂等键与去重表的具体做法
最朴素的方案,是在消费端维护一张已处理事件表,以事件的全局唯一标识作为主键。
CREATE TABLE processed_events ( event_id VARCHAR(64) NOT NULL, handler_name VARCHAR(128) NOT NULL, processed_at TIMESTAMPTZ NOT NULL, PRIMARY KEY (event_id, handler_name) );消费逻辑的伪代码如下:
public void handleOrderPlaced(OrderPlaced event) { int inserted = jdbcTemplate.update( "INSERT INTO processed_events (event_id, handler_name, processed_at) VALUES (?, ?, now()) " + "ON CONFLICT (event_id, handler_name) DO NOTHING", event.getEventId(), "orderPlacedHandler"); if (inserted == 1) { // 第一次处理,执行真正的业务逻辑 doActualBusiness(event); } // 否则,直接跳过(已经处理过了) }这里有一个细节:真正业务逻辑的执行和去重记录的写入,必须在同一个事务里。否则可能出现业务逻辑执行成功、去重记录没写入,崩溃重投后业务逻辑又执行了一遍的漏洞。如果业务逻辑涉及外部调用且无法放入本地事务,那就要考虑"先记录处理中状态,外部调用成功后再改为已完成"的两阶段去重模式,复杂度会上升,但一致性更有保证。
还有一种常见做法是基于业务唯一键做幂等,比如"同一订单号 + 同一事件类型"只允许处理一次。这种方式稳定性也很好,而且不依赖消息系统是否丢失了event_id。实践中我会把两种方式结合:优先用event_id,同时在业务表上建立天然的唯一约束作为最后防线。
5.3 乱序事件处理的三种策略
最终一致性系统里,乱序几乎是不可避免的。订单服务先发OrderPlaced,后发OrderCancelled,但由于消费端处理延迟或其他网络因素,OrderCancelled可能先被某个消费者处理。如果这个消费者没有防护,它可能会基于一个"尚未创建"的订单做取消操作,导致异常。
处理乱序,我有以下三种策略,按成本和场景递增:
- 为同一个聚合根的事件加上单调递增的序号。消费者的处理逻辑里先比较序号,如果当前事件的序号小于或等于已经处理过的最新序号,直接丢弃。这个方案的难点在于"序号"来自哪里——通常可以在聚合的版本号上做文章。
- 在业务表中记录业务状态,通过乐观锁保证顺序。比如取消操作要求订单状态必须为"已创建",如果当前状态已经是"已取消"或"已发货",就拒绝这次取消。这本质上是把乱序冲突交给业务规则去过滤。
- 采用事件溯源(Event Sourcing)思路,把所有事件按顺序落库,消费方通过"重放事件"来构建最新状态。这个方案能从根本上解决乱序,但它改变了整个系统的存储和读模型设计,运维成本相当高,不建议在业务尚未复杂到那个程度时轻易引入。
三种策略我都要说明一点:没有银弹。我们项目里采用的是方案二为主、方案一为辅,覆盖到了 99% 的业务场景,剩下的个别极端情况通过告警人工介入处理。做架构设计不能为了追求理论完美而忽略可维护性。
6. 事件版本演进:改 Schema 是躲不掉的
6.1 破坏性升级与兼容性演进
事件契约一旦跨进生产环境,就会沉淀到各个消费方的代码里。随着业务迭代,你总会有改事件格式的需求:加一个字段、改一个字段的名字、把一个字段从字符串类型变成对象类型。
这里有一个经验:永远不要直接删字段、改字段名。OrderPlaced里原来叫customerId,后来业务方希望带出用户等级,把字段改成customer对象。如果你直接改,所有按旧格式反序列化的事件数据都会解析失败。
更稳妥的做法是兼容性演进:
- 保留原有
customerId字段不变,新增一个customerInfo对象字段。 - 发布方在事件里同时填充两个字段,持续一段时间。
- 等到所有消费方完成对新字段的消费改造,并且线上确认旧字段已无消费者使用时,再废弃旧字段。
这个周期通常需要数个版本迭代。产品经理和老板往往不理解为什么要为一个字段反复横跳,但从工程角度看,这一步省下的救命时间多得多。事件是跨系统的持久化契约,它的演进必须遵循"先兼容、后占位、再废弃"的节奏。
6.2 Schema Registry 的价值
当事件数量多起来以后,靠人肉维护"哪些字段还在用"是不可能的。我建议引入 Schema Registry 这类工具来统一管理事件契约。Confluent Schema Registry 和 Apicurio Registry 是我实际用过的两个方案,它们能提供:
- 事件 Schema 的集中注册与版本管理。
- 兼容性检查:发布方提交新版本时,注册中心会自动检查这个版本和旧版本是向后兼容还是破坏性升级。
- 消费者通过 Schema ID 拉取对应的 Schema 来解析消息,避免两端各维护一份定义,随着时间推移悄悄漂移。
接入 Schema Registry 的代价是额外的运维组件和一定程度上的发布流程约束,但它换来的是事件契约的强治理。事件驱动架构的核心资产不是消息队列,而是事件本身。没有治理的事件流,规模越大越会让团队寸步难行。
6.3 一次真实迁移的灰度经验
我们曾经把OrderPlaced事件的 payload 从扁平结构改成嵌套结构,这是一个典型的破坏性升级。当时的做法供你参考:
第一周,发布方同时输出新旧两版字段,注册中心标记为兼容。消费方按自己的节奏升级到新字段。
第二周到第三周,监控消费方对新旧字段的读取情况。这里有一个容易遗漏的点:你只能看到日志里有没有显式读取旧字段,如果某个消费方用了反射或者反序列化整个对象,它是字面上"读取"了所有字段的。所以迁移过程中,先升级那些明确关心该事件的消费方,再把依赖关系梳理清楚。
第四周,确认所有消费方都已经切到新字段,才在代码里删除旧字段。即便如此,仍然保留了至少一个历史版本的 Schema,方便回溯排查问题。
这个过程看起来慢,但它把一次可能引发全线故障的变更,拆解成了多个可回滚的小步骤。事件驱动的系统里,最忌讳的就是贪图"一步到位"。
7. 设计的边界:什么时候该用领域事件,什么时候别硬上
7.1 适合事件驱动解决的几类问题
总结一下,下面几类场景用领域事件和事件驱动架构收益最大:
- 一个业务事实发生后,需要触发多个互不相关的后续处理步骤。订单创建后要通知、要记账、要扣库存,彼此互不依赖,用事件广播再自然不过。
- 跨系统流程不要求强一致,允许出现短暂的不一致窗口。比如支付回调、物流状态同步、积分累计,这些业务天然就是最终一致的。
- 你需要完整的业务审计轨迹。领域事件本身就记录了业务发生的事实,可以作为审计日志的素材,甚至通过事件溯源来重建任意时间点的业务状态。
- CQRS 架构下的读模型构建。订单服务每次状态变更都发布事件,查询服务订阅事件去构建独立的读模型,读写彻底分离。
7.2 不适合的场景
同样,下面是我不建议引入事件驱动的场景:
- 单一小系统、单体应用阶段。一个模块调用另一个模块,直接代码调用是最高效、最可读的方案,没必要为了"用上 DDD"强行引入事件总线。领域事件在这种场景下可以考虑,但要严格控制边界。
- 需要同步返回结果的交互。用户注册后必须立刻拿到 session 和 token,这种动作不能异步化。事件驱动解决的始终是"让反应顺其自然发生",而不是"让用户等待某个后台动作完成"。
- 团队对领域边界完全没有共识。如果连"订单状态谁负责维护"都吵不清楚,先别讨论事件,先做上下文映射和领域建模,否则事件只会加速混乱。
7.3 实操层面的一点个人经验
我在实际项目里总结了一条简单判断标准:如果某个动作的失败不影响主业务事实的成立,它就适合用事件;如果它影响,那它就不适合。
举例来说,OrderPlaced这个事实成立之后,通知服务发短信失败,订单仍然是成立的,那这就是事件的天然适用场景。反过来,扣减库存失败会导致超卖,这件事不能简单地变成"库存服务订阅到事件后自行处理,失败了就失败了"。它需要更严谨的后续流程:要么走预留库存、预扣库存这类带补偿机制的方案,要么通过 Saga 模式去编排,而不是一句"发个事件就算解耦了"。
另外想提醒一句,事件驱动架构天然增加了系统的不确定性——消息延迟、重复投递、乱序、发布丢失,这些都是真实存在的。引入它之前,一定要确保产品和业务方能够接受"以最终一致换取系统解耦"的取舍。如果业务方和产品经理指着你鼻子说"我必须立刻马上看到库存扣减成功",那你需要先解决业务期望,而不是先写代码。
我在参与过的多数靠谱团队里,最终都会保留一部分同步强一致链路用于核心交易,同时把边缘但繁重的任务(通知、记账、积分、营销)放到事件驱动的通道里。这种混合架构,既是业务的理性选择,也是工程上的务实态度。领域事件是好工具,但好工具也要用在合适的场合,这个道理,比学会某个具体框架重要得多。