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. 自测

  1. 为什么事件载荷要自包含,而不是只放聚合 id?
  2. 在 @Transactional 方法里直接发 MQ 消息,会出现哪两种不一致?
  3. outbox 的投递进程多实例部署时,靠什么避免重复投递?
  4. 累加类事件为什么可以不用版本号,而状态类事件必须用?
  5. 把「扣减库存」做成事件而非命令,丢掉了什么表达能力?
  6. 什么情况下应该放弃事件方案,回到同事务的同步调用?
S18-X13 · 领域事件与最终一致的建模
https://www.clxhxhhr.top/posts/5863/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。