2586 字
约 8 分钟
13
S18-X13 · 领域事件与最终一致的建模
两个聚合需要互相知道对方发生了什么,最直接的做法是直接调用。调用一多,聚合之间长成一张网:下单要通知库存、积分、消息、报表,任何一个下游失败都拖着主流程回滚。领域事件把这张网的连线换成消息,代价是把「强一致」换成「最终一致」——而最终一致需要三件基础设施:可靠投递、幂等消费、不一致的观测。
1. 事件解决什么,又带来什么
| 维度 | 直接调用 | 领域事件 |
|---|---|---|
| 耦合 | 发布方知道所有订阅方 | 发布方只知道「发生了这件事」 |
| 一致性 | 同一事务内强一致 | 最终一致,存在中间态窗口 |
| 失败传播 | 下游失败导致上游回滚 | 下游失败不影响上游,但需要补偿 |
| 可观测性 | 调用栈可见,链路清晰 | 链路断开,必须靠事件表/消息轨迹追踪 |
| 变更成本 | 加一个下游要改上游代码 | 加下游只需新增订阅 |
事件把编译期依赖换成运行期契约,契约变形后故障点从「编译不过」变成「数据悄悄不对」。
2. 事件该包含什么
| 字段 | 作用 | 缺失后果 |
|---|---|---|
eventId |
全局唯一,消费端幂等键 | 无法去重,重投造成重复处理 |
aggregateId |
聚合根标识 | 消费端无法定位主体 |
aggregateType |
聚合类型,路由用 | 多聚合共用一张表时混淆 |
occurredAt |
事件发生时间(业务时间) | 无法按时间重放或对账 |
version |
聚合版本号,用于顺序与乐观并发 | 乱序无法识别 |
eventType + schemaVersion |
类型与结构版本 | 升级时无法兼容读取 |
payload |
自包含快照 | 消费端必须回查,形成反向依赖 |
载荷为什么要自包含:如果 payload 只放 id,消费端必须回查发布方的库。这等于把「解耦」又还原成了「共享数据库」——发布方改表,所有消费方跟着挂。而且回查拿到的是「当前状态」而不是「事件发生时的状态」,同一事件在不同时间消费会得到不同结果,重放就失去意义。
public record OrderPaidEvent(
String eventId,
Long orderId,
long version,
Instant occurredAt,
String eventType,
int schemaVersion,
Payload payload) {
public record Payload(
String orderNo,
Long userId,
long amountFen,
String currency,
Instant paidAt,
List<Item> items) {}
public record Item(Long skuId, int qty, long priceFen) {}
}
载荷过大是另一个极端:把明细、附件、画像全塞进去,事件会膨胀到几十 KB,中间件与大字段存储都吃不消。标准是消费端真正需要的最小快照。
3. 发布时机与三者的一致性差异
| 发布时机 | 做法 | 一致性窗口 | 风险 |
|---|---|---|---|
| 领域层发布 | 聚合内 registerEvent(),仓储保存时取出 |
与业务提交绑定 | 需框架支持(如 @DomainEvents) |
| 应用层发布 | Service 处理完调用 publisher.publish() |
事件可能先于事务提交 | 事务回滚但事件已发出 |
| 事务提交后发布 | TransactionSynchronization.afterCommit 或 @TransactionalEventListener |
不早于提交 | 提交后进程崩溃会丢事件 |
// 提交后才投递
@Component
@RequiredArgsConstructor
public class SpringEventPublisher {
private final ApplicationEventPublisher publisher;
public void publish(Object event) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
publisher.publishEvent(event);
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCommit() { publisher.publishEvent(event); }
});
}
}
afterCommit 解决了「回滚了但事件已发」,但没解决「提交了但进程挂了、事件没发」。要解决后者,只能把事件落盘,也就是第 4 节的 outbox。
4. 本地事务 + 事件表(outbox)
思路是:业务表和事件表在同一个本地事务里写——提交一起成功,回滚一起消失;后台任务再把事件投递出去,成功则标记。
CREATE TABLE domain_event_outbox (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_id VARCHAR(64) NOT NULL,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0待投递 1已投递 2失败超限
retry_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_event_id (event_id),
KEY idx_status_retry (status, next_retry_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@Service
@RequiredArgsConstructor
public class OrderPaidService {
private final OrderMapper orderMapper;
private final OutboxMapper outboxMapper;
@Transactional
public void markPaid(Long orderId) {
int n = orderMapper.updateStatus(orderId, OrderStatus.PAID);
if (n == 0) throw new IllegalStateException("订单状态已变更");
Order order = orderMapper.selectById(orderId);
outboxMapper.insert(OutboxRecord.from(OrderPaidEvent.of(order)));
}
}
// 后台投递:先取待投递,投递成功后置位;失败则指数退避重试
@Scheduled(fixedDelay = 1000)
public void relay() {
List<OutboxRecord> batch = outboxMapper.lockPending(100); // FOR UPDATE SKIP LOCKED
for (OutboxRecord r : batch) {
try {
mqProducer.send(r.getEventType(), r.getEventId(), r.getPayload());
outboxMapper.markSent(r.getId());
} catch (Exception e) {
outboxMapper.markRetry(r.getId(), backoff(r.getRetryCount()));
}
}
}
| 环节 | 必须做到 | 为什么 |
|---|---|---|
| 写入 | 同一本地事务 | 否则不满足「业务成功 ⇒ 事件存在」 |
| 领取 | SKIP LOCKED 或多实例分片 |
否则多实例重复投递 |
| 投递 | 携带 eventId 作为消息 key |
消费端可去重,同聚合可保序 |
| 清理 | 已投递记录定期归档 | 表膨胀拖慢索引与扫描 |
5. 最终一致的三个必须
| 必须 | 做法 | 不做会怎样 |
|---|---|---|
| 幂等消费 | 消费端按 eventId 建唯一索引或 SETNX |
重投导致重复扣款、重复加积分 |
| 顺序或版本 | version 更大才应用,或同聚合路由同一分区 |
旧状态覆盖新状态 |
| 观测与补偿 | 对账任务、状态机超时、死信队列 | 数据错了没人知道,直到用户投诉 |
// 幂等消费:以 (event_id) 唯一索引兜底,重复插入直接忽略
@Transactional
public void on(OrderPaidEvent event) {
try {
consumedMapper.insert(event.eventId(), event.eventType());
} catch (DuplicateKeyException dup) {
return; // 已处理过
}
pointService.add(event.payload().userId(), event.payload().amountFen() / 100);
}
状态类聚合必须校验版本,累加类事件天然可重复,靠幂等即可:
if (event.version() <= inventoryView.getLastVersion()) {
return; // 乱序或重复,丢弃
}
inventoryView.apply(event);
6. 事件版本演进与命令的区别
6.1 演进规则
| 变更 | 是否兼容 | 做法 |
|---|---|---|
| 新增字段 | 兼容 | 只加可选字段,消费端缺省处理 |
| 删除字段 | 不兼容 | 先标记废弃,双发过渡后再删 |
| 改字段类型 | 不兼容 | 新增字段并存,老字段保留一段时间 |
| 改语义 | 不兼容 | 必须换 eventType 或升 schemaVersion |
消费端读旧事件时要能容忍缺失字段:payload.discountFen() 返回 null 不能直接拆箱。
6.2 事件与命令
| 维度 | 事件 | 命令 |
|---|---|---|
| 语义 | 已发生的事实,用过去式命名 | 请求做某事,用祈使句命名 |
| 接收方数量 | 零到多 | 恰好一个 |
| 否决权 | 无,不能拒绝 | 可以拒绝(校验失败) |
| 失败处理 | 重试、补偿、对账 | 直接返回错误给调用方 |
把一个有拒绝语义的操作做成事件,就丢掉了「被拒绝」的表达能力——这是很多「事件驱动」系统数据不一致的源头。
7. 什么时候不该用事件
| 场景 | 理由 |
|---|---|
| 要求强一致(如余额扣减与记账) | 存在用户可观察的中间态,必须同事务 |
| 只有一个下游、链路很短 | 运维成本大于解耦收益 |
| 下游需同步结果才能返回 | 事件天然异步,套壳同步只是把复杂度藏起来 |
| 团队不熟悉对账 | 缺幂等、缺补偿,出错只能人工修数据 |
8. 排错:事件系统的六类故障
| 现象 | 根因 | 处理 |
|---|---|---|
| 事务回滚了但下游收到事件 | 在提交前就发了消息 | 改 afterCommit 或落 outbox |
| 事件丢了,下游永不触发 | 提交后进程崩溃,afterCommit 未执行 |
落 outbox 表,用后台投递兜底 |
| 积分被加了两次 | 重投且消费端无幂等 | 加 event_id 唯一索引 |
| 库存视图出现旧值 | 乱序,旧 version 覆盖新值 |
消费端校验版本号;同聚合保序 |
| outbox 表越来越大、扫描越来越慢 | 已投递记录未归档 | 定时任务归档 + 只扫 status=0 |
| 事件风暴里出现循环事件 | A 发事件 → B 处理后又发事件 → A 再处理 | 事件里带 causationId/correlationId,设置传播深度上限 |
9. 边界:这套模型的成本
| 成本项 | 说明 |
|---|---|
| 延迟 | 从同事务毫秒级变为「投递 + 消费」,典型量级百毫秒到秒,具体按中间件与消费并发核对 |
| 复杂度 | 多一张表、一个投递进程、一套幂等与对账 |
| 排障 | 需靠 eventId 串联的事件轨迹还原链路 |
| 数据修复 | 需要可重放脚本,而非手工改库 |
一句话:事件换来的不是「更松的耦合」,而是「可编排的异步契约」,前提是幂等、顺序、对账三件套齐全。
10. 自测
- 为什么事件载荷要自包含,而不是只放聚合 id?
- 在
@Transactional方法里直接发 MQ 消息,会出现哪两种不一致? - outbox 的投递进程多实例部署时,靠什么避免重复投递?
- 累加类事件为什么可以不用版本号,而状态类事件必须用?
- 把「扣减库存」做成事件而非命令,丢掉了什么表达能力?
- 什么情况下应该放弃事件方案,回到同事务的同步调用?
S18-X13 · 领域事件与最终一致的建模
https://www.clxhxhhr.top/posts/5863/ 评论
0 条
还没有评论,先写一条吧。