CQRS 与事件溯源

CQRS 读写分离架构、事件存储机制、快照优化与投影设计,及一致性保障策略。

CQRS 与事件溯源

CQRS(Command Query Responsibility Segregation)将读写操作分离,事件溯源以事件序列替代状态快照。两者常结合使用,解决高并发读写冲突。

1. CQRS 核心思想

传统模式的问题

                    ┌─────────────┐
         ┌──读────→ │   统一模型   │
         │          │ (Entity)    │
         │          └─────────────┘
         │                  ↑
Client ──┤                  │
         │                  │
         └──写────→  数据库事务阻塞读

读写操作在同一模型上竞争资源,复杂查询需要大量 JOIN。

CQRS 分离

         ┌─────────────┐              ┌─────────────┐
 ┌──读──→ │  Query Side  │              │  Read Model │
 │        │ (简单、快速)  │ ←────────── │ (为查询优化)│
 │        └─────────────┘   同步/异步    └─────────────┘
Client                          ↑
 │         ┌─────────────┐     │
 └──写───→ │ Command Side│ ────┘  事件
           │ (业务校验)   │     事件溯源
           └─────────────┘
                    ↓
              ┌───────────┐
              │ Event Store│
              └───────────┘
维度CommandQuery
职责业务规则、状态变更数据检索
模型领域模型视图模型/投影
存储写库读库
优化一致性、事务性能、灵活查询

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 + 事件溯源拥有强大的追溯能力和灵活查询能力,但引入了架构复杂度,需谨慎评估团队能力与业务需求。建议从最简单的读写分离开始,逐步引入事件和读模型,避免一步到位式的激进重构。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「架构」更多文章

  1. 韧性工程与错误预算:从 SLO 到故障演练
  2. 微前端架构:组合、隔离与独立部署
  3. 数据网格(Data Mesh):领域数据产品与去中心化治理