CQRS 与事件溯源落地指南:Spring Boot 读写分离模型、状态快照与最终一致性补偿

0 阅读8分钟

干了几年微服务,见过太多系统一开始用 CRUD 写得飞快,后期业务一复杂,改个字段要牵动七八张表,查询慢得让用户怀疑人生,审计日志全靠触发器拼凑。这时候团队往往会盯上 CQRS 和事件溯源(Event Sourcing)。这套组合拳不是银弹,但在高并发、强审计、多视图的场景下,确实能把架构从“泥潭”里拔出来。下面直接上干货,聊清楚 Spring Boot 怎么落地,线上踩过哪些坑,以及怎么把这套模型跑稳。


为什么 CRUD 跑着跑着就扛不住了

传统架构里,业务逻辑和数据库表是深度绑定的。写操作要维护范式,读操作要拼宽表,两边需求经常打架。最典型的痛点就几个:同一张表既要扛高频写入,又要跑多表关联查询,索引互相拖累,行锁和间隙锁一碰就死锁;为了查得快,业务代码里塞满冗余字段和 JOIN,违反单一职责不说,后期维护成本直线上升;最要命的是状态变更被 UPDATE 直接覆盖,历史轨迹全靠业务自己打日志或者建审计表,时间一长就成了没人敢动的祖传代码。

CQRS 的思路其实很朴素:把“改状态”和“查状态”拆开。命令端只关心业务规则校验和状态变更,查询端只管组装 DTO 和提供高性能检索。两边通过消息异步解耦,甚至可以用不同的数据库。事件溯源则是把写端再推进一步:不存最终状态,只存导致状态变化的事件流。当前状态是事件按时间顺序重放出来的结果。这么一搞,审计日志变成了架构的一部分,历史状态随时可回溯,不同业务线还能基于同一套事件流物化出各自的读模型。


读写分离与事件总线:别被概念唬住,落地才是关键

命令模型和查询模型物理分离是前提。写端以聚合根为中心,严格走 接收命令 → 校验不变量 → 变更状态 → 产出事件 → 持久化事件 这条线。读端就简单粗暴,面向查询场景做反范式,直接落 MongoDB、ES 或者 MySQL 宽表,不掺和业务逻辑,保证高并发下的查询吞吐。

中间这座桥就是事件总线。选型别盲目追求高大上,看场景说话:

  • 单应用或者延迟要求极低,直接用 Spring 的 ApplicationEventPublisher 配本地事务表(Outbox Pattern)就行。注意,Spring 事件是 JVM 内同步的,跨服务或者需要可靠投递时,必须配一个后台任务或者用 Debezium 监听 Outbox 表,把事件推给 MQ。
  • 分布式场景首选 Kafka。ES 架构对事件顺序要求极高,分区键一定要用 AggregateId 做 Hash,保证同一个聚合的事件严格按序消费。RabbitMQ 也行,但得仔细排布 Exchange 和 RoutingKey,不然乱序和重复消息够喝一壶的。
// Outbox 表结构示意,线上建议加唯一约束和状态索引
@Entity
@Table(name = "event_outbox")
public class OutboxEvent {
    @Id @GeneratedValue private Long id;
    private String aggregateId;
    private String eventType;
    private String payload; // 事件体 JSON,注意预留 schema 版本字段
    private LocalDateTime createdAt;
    private Boolean published = false; // 标记是否已投递至 MQ
}

核心链路:命令发布、投影更新与线上避坑

Spring Boot 原生就能玩 CQRS。如果不引入 Axon 这种重型框架,用 ApplicationEventPublisher 足够应付大多数内部链路。关键是要把事务边界卡死,事件必须在数据库事务提交后再发,否则业务回滚了事件却飞出去了,读端全是脏数据。

@Service
@RequiredArgsConstructor
public class OrderCommandService {
    private final OrderAggregateRepository repo;
    private final ApplicationEventPublisher publisher;

    @Transactional
    public void createOrder(CreateOrderCommand cmd) {
        Order order = new Order(cmd.getOrderId(), cmd.getUserId(), cmd.getAmount());
        List<OrderEvent> events = order.process(cmd); // 聚合根内部产生事件
        repo.save(events); // 落事件表
        // 注意:这里发布的是 Spring 应用事件,配合 @TransactionalEventListener 保证事务提交后触发
        publisher.publishEvent(new OrderEventsPublishedEvent(order.getAggregateId(), events));
    }
}

投影器(Projection)是读端的搬运工,本质是异步消费者。线上跑过就知道,投影最头疼的不是同步数据,而是幂等和乱序。很多教程里写投影器先 existsById 判断再插入,并发一高绝对翻车。正确做法是在读库建 event_id 的唯一索引,用 INSERT ... ON CONFLICT DO NOTHING(PG)或 INSERT IGNORE(MySQL)兜底。

@Component
@RequiredArgsConstructor
public class OrderProjection {
    private final OrderReadRepository readRepo;
    
    // 事务提交后再消费,避免读到未提交数据
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderCreated(OrderCreatedEvent event) {
        // 依赖 DB 唯一约束保证幂等,不要依赖 existsById
        OrderReadDTO dto = OrderReadDTO.builder()
                .orderId(event.getOrderId())
                .userId(event.getUserId())
                .status("CREATED")
                .totalAmount(event.getAmount())
                .eventId(event.getEventId()) // 必须落库
                .build();
        readRepo.save(dto); // 内部执行 ON CONFLICT 逻辑
    }
    
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderPaid(OrderPaidEvent event) {
        // 更新操作也要带 eventId 做乐观版本控制
        readRepo.updateStatus(event.getOrderId(), "PAID", event.getEventId());
    }
}

投影器必须设计成无状态的。读库延迟是常态,别指望它能和写端强一致。复杂场景建议拆投影,订单查一个,统计看板走一个,互不干扰。


状态重建与快照:解决事件无限膨胀的脏活

事件流只增不减,时间一长聚合根重建会拖垮性能。快照就是用来掐断无限重放的。逻辑不复杂:当某个聚合的事件数量超过阈值(比如 500 条),就把当前聚合状态序列化存一张快照表。下次加载时,先捞最新快照拿到基线版本,再只拉取这个版本之后的事件增量应用。

public Order loadAggregate(String aggregateId) {
    SnapshotEntity snap = snapshotRepo.findLatest(aggregateId);
    int baseVersion = (snap != null) ? snap.getVersion() : 0;
    // 只拉取基线之后的事件
    List<Event> events = eventStore.findEventsAfter(aggregateId, baseVersion);
    
    Order order = (snap != null) ? deserialize(snap.getState()) : new Order();
    events.forEach(order::applyEvent);
    return order;
}

这里有个细节:快照生成不能阻塞主写入链路。线上一般是在投影端或者独立的后台 Job 异步生成,落盘成功后再更新事件流的快照标记。并发冲突处理靠版本号就行,聚合根加载时记下 expectedVersion,持久化时对比,不一致直接抛 ConcurrencyException。上层根据业务决定是重试、拒绝还是走补偿,别自己偷偷覆盖,否则数据一致性瞬间崩塌。


存储选型、Saga 补偿与性能调优的实战心得

事件存储选什么,看团队运维能力和数据量级。初期别折腾 EventStoreDB,学习曲线陡不说,出问题连个能问的人都没有。老老实实用 PostgreSQL 或 MySQL,JSONB 存事件体,配合按时间或聚合分区的策略,扛到日活百万没问题。等 Kafka 基础设施齐了,再把它接进来做事件分发中枢,数据库只留 Outbox 和快照,平滑过渡。

跨服务事务在 CQRS 里基本告别 2PC 了,Saga 是标配。事件驱动的 Choreography 模式写起来清爽,但线上跑必须盯紧补偿闭环。正向动作失败,立刻发补偿事件;补偿命令必须带原始事件 ID 做幂等;每个步骤的状态机要画清楚正向和反向路径(比如 RESERVE 对应 CANCEL_RESERVE)。补偿失败别死循环重试,打到死信队列(DLQ),配个运营后台让人工介入或者标记跳过,比全自动瞎跑安全得多。

// 补偿链路示例,注意事件 ID 的透传
@EventListener
public void onStockReservedFailed(StockReserveFailedEvent e) {
    // 携带原始订单创建事件 ID,确保补偿命令幂等
    publisher.publishEvent(new CompensateOrderCommand(e.getOrderId(), "STOCK_RESERVE_FAILED", e.getCorrelationId()));
}

压测和调优这块,别光看 QPS 数字。命令端瓶颈通常在数据库连接池和批量提交,适当调大 rewriteBatchedStatements 能省不少 RT。投影延迟是核心指标,线上必须盯 P99。延迟高了,先查 Kafka 消费者并行度是不是没拉开,再读库批量 Upsert 能不能合并。Projection 里的 @Async 一定要绑独立线程池,别跟 Tomcat 主线程池混用,一堵全堵。事件堆积量用 Prometheus 配合 Kafka Exporter 看板一眼就能看出来,超过阈值加消费者实例比改代码管用。记住,读写比例通常是 8:2 甚至 9:1,压测脚本别平均分配压力,模拟真实峰谷流量才能暴露真问题。


写在最后

CQRS 加事件溯源,本质是用架构的复杂度换扩展性和可追溯性。这套东西上线前,团队得先达成共识:读端延迟怎么跟业务解释?事件 Schema 怎么版本化管理?出问题时是重放日志还是直接修库?如果业务只是简单的增删改查,强行上这套模型只会徒增运维负担和认知摩擦。

但当你需要多端同步、强审计、高频状态变更,或者读模型差异巨大的时候,这套范式能帮你把系统从“改一处崩一片”的死循环里捞出来。事件一旦落地,就是数字资产,向后兼容是底线。Spring Boot 生态够用,Outbox 保交付,Kafka 扛吞吐,投影器拆视图,一步步把链路跑通。架构演进从来不是毕其功于一役,先跑起来,再磨细节,线上反馈会告诉你下一步该怎么走。


🎁 福利时间

如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。

知识库地址:farerboy.com/