干了几年微服务,见过太多系统一开始用 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/