Files
cs-note/hhs/MQ/08-消息设计模式/29-MQ-CQRS-与-Event-Sourcing.md
T
2026-05-24 20:51:06 +08:00

6.7 KiB
Raw Blame History

tags, create time
tags create time
MQ
消息队列
CQRS
Event Sourcing
架构模式
2026-05-24 19:52

MQ CQRS 与 Event Sourcing

概述

CQRS(Command Query Responsibility Segregation)将读写模型分离,Event Sourcing 用事件流代替状态快照。两者结合并通过 MQ 广播事件,可以构建高性能、可审计、可追溯的系统架构。本文详解这两个模式的原理、组合方式、与传统 CRUD 的对比,以及事件存储的核心设计。

正文

CQRS 概述

传统 CRUD 模式下,同一个数据模型既用于写入也用于查询。这在简单场景下工作良好,但随着业务复杂化,读写的性能需求和模型结构会逐渐分化。

CQRS 的核心思想:Command(写)和 Query(读)使用不同的模型。

  • 写模型(Command Side):专注于业务规则校验和状态变更,使用领域模型(Domain Model),可以高度规范化。
  • 读模型(Query Side):专注于查询性能,使用反规范化的视图模型(View Model),可以针对不同查询场景定制。
graph LR
    Client["客户端"] --> CmdAPI["Command API"]
    Client --> QueryAPI["Query API"]

    CmdAPI --> WriteDB["写模型 (Domain)"]
    WriteDB -->|"事件通过 MQ 广播"| MQ["Message Queue"]
    MQ --> ReadModel1["读模型 A (列表视图)"]
    MQ --> ReadModel2["读模型 B (统计视图)"]
    ReadModel1 --> QueryAPI
    ReadModel2 --> QueryAPI

    style Client fill:#4A90D9,color:#fff
    style CmdAPI fill:#F5A623,color:#fff
    style QueryAPI fill:#6EC1E0,color:#fff
    style WriteDB fill:#D0021B,color:#fff
    style MQ fill:#F5A623,color:#fff
    style ReadModel1 fill:#6EC1E0,color:#fff
    style ReadModel2 fill:#6EC1E0,color:#fff

Event Sourcing

传统方式存储数据的"当前状态"——每次更新都是覆盖写入。Event Sourcing 反其道而行:不存储当前状态,存储所有导致状态变更的事件。

以电商订单为例:

传统 CRUD Event Sourcing
UPDATE orders SET status='paid' 追加事件 OrderPaid{orderId, amount, time}
只保留最终状态 保留完整的事件历史
无法追溯"为什么变成这样" 可以重放任意时间点的状态

Event Sourcing 的优势:

  • 完整审计追踪:每个状态变更都有记录,满足金融、医疗等合规要求。
  • 时间旅行:通过重放事件,可以重建任意历史时刻的状态。
  • 调试友好:出 bug 时可以精确回放复现问题。

Event Sourcing 的挑战:

  • 事件版本兼容:事件 Schema 演进时需要保证新旧版本兼容。
  • 查询复杂:获取当前状态需要重放所有事件(可用 Snapshot 优化)。
  • 最终一致性:读模型通过 MQ 异步更新,存在短暂延迟。

[!question] Event Sourcing 让历史可追溯,但也带来事件版本兼容的挑战。你会如何处理事件 Schema 的演进?

CQRS + Event Sourcing 的组合

CQRS 和 Event Sourcing 天然互补:

  1. Command Side 采用 Event Sourcing,将状态变更以事件形式追加到 Event Store。
  2. Event Store 通过 MQ 将事件广播给所有关心的消费者。
  3. Query Side(Read Model)消费事件,构建针对查询优化的视图。

这种架构下,MQ 是连接读写两侧的桥梁——写入端产生事件,MQ 负责分发,读取端消费事件并更新视图。

与传统 CRUD 的对比

维度 传统 CRUD CQRS + Event Sourcing
数据一致性 强一致(同一数据库) 最终一致(读模型异步更新)
查询性能 受限于写模型结构 读模型可针对查询优化
审计追踪 需要额外设计审计表 天然支持,事件即审计日志
扩展性 读写耦合,难以独立扩展 读写独立扩展
复杂度 低 高(事件设计、版本管理、最终一致性)
适用场景 简单 CRUD 应用 复杂业务、高并发读、审计要求高

[!question] CQRS 带来最终一致性,用户下单后立刻查询可能看不到最新状态。在你的业务场景中,这种延迟可以接受吗?如何在 UX 层面缓解?

事件存储设计

事件版本

事件一旦写入就不可修改(Immutable),但业务会演进。处理方式:

  • Upcast:读取旧版本事件时,在内存中转换为新版本。
  • 多版本共存:事件中携带版本号,消费者根据版本号分别处理。

快照(Snapshot)

当事件数量很大时,每次重放全部事件来获取当前状态会很慢。Snapshot 在特定时间点保存状态快照,重放时只需从最近的 Snapshot 开始。

事件重放

重放是 Event Sourcing 的核心能力——从头(或从 Snapshot)依次应用事件,重建状态。读模型的重建、bug 修复后的数据修复、新读模型的初始化,都依赖事件重放。

Go 代码:简化版 Event Sourcing

package main

import "time"

// Event 事件定义:每个事件代表一次状态变更
type Event struct {
	ID        string
	AggregateID string   // 聚合根 ID(如订单 ID)
	Type      string     // 事件类型
	Data      []byte     // 事件数据
	Version   int        // 事件版本号
	Timestamp time.Time
}

// EventStore 事件存储接口
type EventStore interface {
	Save(events []Event) error
	Load(aggregateID string) ([]Event, error)
}

// OrderAggregate 订单聚合根
type OrderAggregate struct {
	ID     string
	Status string
	Amount float64
}

// Apply 将单个事件应用到聚合根,更新状态
func (o *OrderAggregate) Apply(event Event) {
	switch event.Type {
	case "OrderCreated":
		o.Status = "created"
	case "OrderPaid":
		o.Status = "paid"
	case "OrderShipped":
		o.Status = "shipped"
	}
}

// ReplayEvents 从事件流重建聚合根状态
func ReplayEvents(aggregateID string, store EventStore) (*OrderAggregate, error) {
	events, err := store.Load(aggregateID)
	if err != nil {
		return nil, err
	}

	agg := &OrderAggregate{ID: aggregateID}
	for _, event := range events {
		agg.Apply(event) // 依次应用每个事件
	}
	return agg, nil
}

核心逻辑:ReplayEvents 从 Event Store 加载指定聚合根的所有事件,依次调用 Apply 重建当前状态。这就是 Event Sourcing 的精髓——状态是事件的函数,State = f(events)。

关联笔记