CQRS 与事件溯源
CQRS(Command Query Responsibility Segregation)将读写操作分离,事件溯源以事件序列替代状态快照。两者常结合使用,解决高并发读写冲突。
1. CQRS 核心思想
传统模式的问题
┌─────────────┐
┌──读────→ │ 统一模型 │
│ │ (Entity) │
│ └─────────────┘
│ ↑
Client ──┤ │
│ │
└──写────→ 数据库事务阻塞读
读写操作在同一模型上竞争资源,复杂查询需要大量 JOIN。
CQRS 分离
┌─────────────┐ ┌─────────────┐
┌──读──→ │ Query Side │ │ Read Model │
│ │ (简单、快速) │ ←────────── │ (为查询优化)│
│ └─────────────┘ 同步/异步 └─────────────┘
Client ↑
│ ┌─────────────┐ │
└──写───→ │ Command Side│ ────┘ 事件
│ (业务校验) │ 事件溯源
└─────────────┘
↓
┌───────────┐
│ Event Store│
└───────────┘
| 维度 | Command | Query |
|---|---|---|
| 职责 | 业务规则、状态变更 | 数据检索 |
| 模型 | 领域模型 | 视图模型/投影 |
| 存储 | 写库 | 读库 |
| 优化 | 一致性、事务 | 性能、灵活查询 |
2. 事件溯源(Event Sourcing)
不存储当前状态,而是存储导致状态变更的所有事件。
状态重建
Initial: Account(balance=0)
Events:
[0] AccountCreated(id=123, holder="Alice")
[1] Deposited(amount=100)
[2] Withdrawn(amount=30)
[3] Deposited(amount=50)
Rebuild: fold(apply event)
→ balance = 100 - 30 + 50 = 120
数据结构
public interface DomainEvent {
UUID getAggregateId();
long getVersion();
Instant getOccurredOn();
}
public class BankAccount extends AggregateRoot {
private UUID id;
private BigDecimal balance;
public void deposit(BigDecimal amount) {
apply(new Deposited(id, amount, ++version));
}
public void withdraw(BigDecimal amount) {
if (balance.compareTo(amount) < 0)
throw new InsufficientBalanceException();
apply(new Withdrawn(id, amount, ++version));
}
@Override
protected void when(DomainEvent event) {
switch (event) {
case Deposited e -> balance = balance.add(e.amount());
case Withdrawn e -> balance = balance.subtract(e.amount());
}
}
}
3. 事件存储
-- 事件表设计
CREATE TABLE events (
aggregate_id UUID,
version INT,
event_type VARCHAR(100),
event_data JSONB,
metadata JSONB,
occurred_on TIMESTAMP,
PRIMARY KEY (aggregate_id, version)
);
-- 应用事件(乐观锁)
INSERT INTO events (aggregate_id, version, ...)
VALUES (?, ?, ...)
ON CONFLICT (aggregate_id, version) DO NOTHING;
-- 影响行数为0 → 并发冲突
存储选型
| 方案 | 特点 |
|---|---|
| 关系型数据库 | 简单、事务支持好 |
| EventStoreDB | 专用事件数据库,内置投影 |
| Kafka | 高吞吐、保留策略 |
4. 快照优化
事件过多时重建状态耗时,引入快照:
每 N 个事件创建一次快照:
Events: [1][2][3] ... [998][999][1000]
Snapshot at v1000: Account(balance=5000, version=1000)
重建时:加载 Snapshot + 应用 v1000 之后的事件
public class SnapshotRepository {
@Transactional
public <T extends AggregateRoot> T load(UUID id) {
var snapshot = loadLatestSnapshot(id);
var events = loadEventsAfter(id, snapshot.getVersion());
return replay(snapshot, events);
}
}
5. 投影(Projection)与读模型
// 监听器将事件投影到读模型
@Component
public class OrderProjection {
@EventListener
public void on(OrderCreatedEvent event) {
readRepository.save(new OrderView(
event.getOrderId(),
event.getCustomerId(),
event.getTotal()
));
}
@EventListener
public void on(OrderPaidEvent event) {
readRepository.updateStatus(event.getOrderId(), PAID);
}
}
投影同步模式
| 模式 | 延迟 | 复杂度 | 一致性 |
|---|---|---|---|
| 同步投影 | 无 | 低 | 强一致 |
| 事务消息 | 低 | 中 | 最终一致 |
| 异步消费者 | 中 | 中 | 最终一致 |
| CDC (Debezium) | 低 | 高 | 最终一致 |
6. 一致性保障
写模型一致性
- 聚合内:单个聚合的事务一致性
- 聚合间:最终一致性(通过领域事件)
读模型一致性
读模型滞后是正常情况。如何处理?
1. UI 乐观更新:前端先展示修改结果,后台异步同步
2. 读后写重试:写入后未在读取模型找到,延迟重试
3. 版本标记:写入后返回版本号,读模型校验版本
7. 适用场景
| 适用 | 不适用 |
|---|---|
| 审计要求严格 | 简单 CRUD |
| 回溯/回放需求 | 一致性强于性能 |
| 复杂业务规则 | 团队无 DDD 经验 |
| 高并发写入 | 短期项目 |
总结
| 概念 | 含义 |
|---|---|
| CQRS | 读写分离,独立优化 |
| 事件溯源 | 以事件序列替代状态快照 |
| 投影 | 事件转读模型 |
| 快照 | 状态重建优化 |
8. 实施路线图
增量式引入策略
CQRS + 事件溯源不建议从零开始全新设计,推荐渐进式演进:
阶段一:单一写库 + 读库(只读副本)
└── 最简单的 CQRS:写主库,读从库
└── 零业务改动,纯基础设施配置
阶段二:引入事件(仅用于通知)
└── 写模型完成后发送领域事件
└── 订阅者更新缓存或发送通知(不改变读模型)
阶段三:独立读模型
└── 为特定查询场景构建独立读模型
└── 通过事件同步(异步最终一致)
阶段四:完整事件溯源
└── 写模型以事件为唯一存储
└── 可回溯、可审计、可重放
与微服务结合
| 模式 | 说明 | 适用 |
|---|---|---|
| 单服务 CQRS | 一个服务内部分读写分离 | 复杂查询场景 |
| 跨服务事件 | 服务间通过事件解耦 | 微服务事件驱动 |
| Saga 事务 | 跨服务长事务补偿 | 分布式事务 |
9. 常见反模式
反模式 1:所有查询都用 CQRS
❌ 错误:简单 CRUD 场景也强行引入读模型,增加不必要的复杂度。
✅ 正解:只有当传统模式无法满足性能或查询灵活性需求时才引入 CQRS。
反模式 2:读写模型共享数据库表
❌ 错误:读写分离只是在不同代码中操作同一张表,失去了分离的意义。
✅ 正解:读模型可以是独立的数据库、缓存、搜索引擎(如 Elasticsearch )。
反模式 3:忽略最终一致性
❌ 错误:写入后立即从读模型查询,期望立即可见。
✅ 正解:UI 设计应包容短暂延迟,或使用乐观更新策略。
反模式 4:事件风暴失控
❌ 错误:所有状态变更都通过事件传播,导致系统满是事件耦合。
✅ 正解:仅聚合间传递领域事件,聚合内部仍可直接调用。
总结
| 概念 | 含义 | 核心价值 |
|---|---|---|
| CQRS | 读写分离,独立优化 | 读性能、查询灵活性 |
| 事件溯源 | 以事件序列替代状态快照 | 审计追溯、状态重建 |
| 投影 | 事件转读模型 | 读模型按需定制 |
| 快照 | 状态重建优化 | 平衡重建速度与存储成本 |
| Saga | 跨服务事务补偿 | 分布式最终一致性 |
CQRS + 事件溯源拥有强大的追溯能力和灵活查询能力,但引入了架构复杂度,需谨慎评估团队能力与业务需求。建议从最简单的读写分离开始,逐步引入事件和读模型,避免一步到位式的激进重构。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。