mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2080 字
6 分钟
CQRS 命令查询职责分离(CQRS)
2026-06-13

一、为什么需要 CQRS#

一个电商平台的商品详情页,用户每次打开都要看到:商品基本信息、价格与库存、用户评价摘要、相关推荐、销量排名。为了拼出这个页面,后端需要 JOIN 五张以上的表,加上缓存失效时的回源查询,数据库压力山大。

但同一时刻,运营人员在后台修改商品价格——只需 UPDATE 一张表的一行。写入操作简单而低频,读取操作复杂而高频。两者对数据模型的需求完全相反:读取需要反范式的宽表来加速查询,写入需要范式化的模型来保证一致性。

传统 CRUD 用同一个模型处理读写,结果就是两头不讨好——查询慢了加索引和冗余字段,写入又变慢;为了写入快拆表,查询又得多 JOIN。CQRS 的思路很直接:既然读和写的需求矛盾,那就别用一个模型了,读写各用各的。

二、现实类比#

图书馆。书架区负责存书和借还——书按分类号排列,方便上架和归位,这是「写模型」。卡片目录(现在是检索终端)负责帮你找书——按书名、作者、主题多种维度组织,一条检索结果就包含位置、状态、简介,这是「读模型」。卡片目录是书架内容的一个查询优化投影,两者数据来源相同但结构完全不同。书架上挪了一本书,目录跟着更新,但更新不是即时的——可能隔天才同步,这就是最终一致性。

三、核心思想#

CQRS 将系统的数据操作分为两类:命令(Command,改变状态)和查询(Query,读取状态),各自使用独立的数据模型。写模型专注于业务规则和一致性,读模型专注于查询效率,两者通过事件总线异步同步。

flowchart LR subgraph 写端 CMD[Command] --> CH[CommandHandler] CH --> WM[Write Model] WM --> |发布事件| EB[Event Bus] end subgraph 读端 EB --> |订阅事件| EH[EventHandler] EH --> RM[Read Model / Projection] QRY[Query] --> QH[QueryHandler] QH --> RM end style CMD fill:#f96,stroke:#333 style QRY fill:#69f,stroke:#333 style EB fill:#ff9,stroke:#333

写端处理命令,更新写模型,发布事件。读端订阅事件,更新投影(Projection,即读优化的视图)。查询只读投影,永远不碰写模型。两端之间是异步通信,因此存在最终一致性——写入后不是立刻能在读端看到,但最终一定会一致。

3.1 核心数据结构#

结构职责示例
Command表达意图,不含业务逻辑CreateOrderCommand{userID, items[]}
Event描述已发生的事实,不可变OrderCreatedEvent{orderID, userID, items[], timestamp}
Projection读优化的物化视图OrderSummaryView{orderID, total, status}

Command 是动词——「创建订单」,Event 是过去式——「订单已创建」。Command 可能被拒绝(库存不足),Event 一定已经发生。区分两者是 CQRS 的基础。

3.2 操作复杂度#

操作复杂度说明
发送 CommandO(1)写入写模型 + 发布事件
查询 ProjectionO(1)~O(log n)直接读预计算的投影表
重建 ProjectionO(n)重放所有事件
事件存储O(n) 增长只追加,不可变

查询快,是因为投影已经提前算好了。代价是事件回放重建投影的开销,以及事件存储的无限增长——需要配合快照机制来缓解。

四、变体与对比#

模式读写模型一致性复杂度适用场景
CRUD同一模型强一致简单业务,读写比接近
CQRS分离模型最终一致读多写少,读写模型差异大
CQRS + Event Sourcing分离 + 事件溯源最终一致需要完整审计追踪
Event Sourcing(无 CQRS)单一事件流强一致中高审计需求为主,查询简单

CQRS 和事件溯源(Event Sourcing)经常一起出现,但它们是完全独立的模式。Event Sourcing 的核心是用事件序列代替状态快照来持久化数据,它不一定需要分离读写模型。CQRS 也不一定需要 Event Sourcing——你可以用传统数据库做写模型,只是同步事件到读端。

两者结合时威力最大:事件溯源提供完整的历史记录和重建能力,CQRS 提供灵活的查询投影。但复杂度也最高——你要处理事件版本迁移、投影重建、最终一致性带来的 UI 问题。单独使用 CQRS(不用事件溯源)简单得多,写模型仍然是传统数据库,只是把变更事件推到读端。

五、多语言实现#

5.1 Go 实现#

package cqrs
import "fmt"
// ---------- Command 与 Event 定义 ----------
// Command 表达写操作意图
type Command interface {
CommandName() string
}
// Event 描述已发生的事实
type Event interface {
EventName() string
}
// CreateOrderCommand 创建订单命令
type CreateOrderCommand struct {
OrderID string
UserID string
Items []OrderItem
}
func (c CreateOrderCommand) CommandName() string { return "CreateOrder" }
// OrderItem 订单条目
type OrderItem struct {
ProductID string
Quantity int
Price float64
}
// OrderCreatedEvent 订单已创建事件
type OrderCreatedEvent struct {
OrderID string
UserID string
Items []OrderItem
Total float64
}
func (e OrderCreatedEvent) EventName() string { return "OrderCreated" }
// ---------- 写模型 ----------
// CommandHandler 处理命令,返回事件
type CommandHandler func(cmd Command) (Event, error)
// WriteModel 写端模型:保存订单状态,处理命令
type WriteModel struct {
orders map[string]*Order
}
// Order 写端领域对象
type Order struct {
ID string
UserID string
Items []OrderItem
Total float64
Status string
}
func NewWriteModel() *WriteModel {
return &WriteModel{orders: make(map[string]*Order)}
}
// HandleCommand 处理命令并返回事件
func (w *WriteModel) HandleCommand(cmd Command) (Event, error) {
switch c := cmd.(type) {
case CreateOrderCommand:
// 业务规则校验
if len(c.Items) == 0 {
return nil, fmt.Errorf("订单不能为空")
}
total := 0.0
for _, item := range c.Items {
total += item.Price * float64(item.Quantity)
}
// 更新写模型状态
w.orders[c.OrderID] = &Order{
ID: c.OrderID, UserID: c.UserID,
Items: c.Items, Total: total, Status: "CREATED",
}
// 返回事件
return OrderCreatedEvent{
OrderID: c.OrderID, UserID: c.UserID,
Items: c.Items, Total: total,
}, nil
default:
return nil, fmt.Errorf("未知命令: %s", cmd.CommandName())
}
}
// ---------- 读模型 ----------
// OrderSummary 投影:读优化的订单摘要
type OrderSummary struct {
OrderID string
UserID string
Total float64
Status string
}
// ReadModel 读端模型:维护物化视图
type ReadModel struct {
// 按订单 ID 索引的摘要视图
summaries map[string]*OrderSummary
// 按用户 ID 索引的订单列表
userOrders map[string][]string
}
func NewReadModel() *ReadModel {
return &ReadModel{
summaries: make(map[string]*OrderSummary),
userOrders: make(map[string][]string),
}
}
// HandleEvent 订阅事件,更新投影
func (r *ReadModel) HandleEvent(evt Event) {
switch e := evt.(type) {
case OrderCreatedEvent:
r.summaries[e.OrderID] = &OrderSummary{
OrderID: e.OrderID, UserID: e.UserID,
Total: e.Total, Status: "CREATED",
}
r.userOrders[e.UserID] = append(r.userOrders[e.UserID], e.OrderID)
}
}
// QueryByOrderID 查询订单摘要
func (r *ReadModel) QueryByOrderID(id string) (*OrderSummary, bool) {
s, ok := r.summaries[id]
return s, ok
}
// QueryByUser 查询用户的所有订单 ID
func (r *ReadModel) QueryByUser(userID string) []string {
return r.userOrders[userID]
}
// ---------- 事件总线 ----------
// EventBus 连接写端和读端
type EventBus struct {
handlers []func(Event)
}
func (b *EventBus) Subscribe(handler func(Event)) {
b.handlers = append(b.handlers, handler)
}
func (b *EventBus) Publish(evt Event) {
for _, h := range b.handlers {
h(evt) // 同步调用;生产环境通常异步
}
}

写模型只关心业务规则(订单不能为空、计算总价),读模型只关心查询效率(按订单 ID 索引、按用户 ID 索引)。两者通过 EventBus 解耦,写端不用知道读端怎么存数据。生产环境中 EventBus 通常换成消息队列(Kafka、RabbitMQ),实现真正的异步和持久化。

5.2 TypeScript 实现#

// ---------- Command 与 Event 定义 ----------
interface Command {
readonly type: string;
}
interface Event {
readonly type: string;
readonly timestamp: number;
}
interface OrderItem {
productId: string;
quantity: number;
price: number;
}
// 创建订单命令
interface CreateOrderCommand extends Command {
readonly type: "CreateOrder";
orderId: string;
userId: string;
items: OrderItem[];
}
// 订单已创建事件
interface OrderCreatedEvent extends Event {
readonly type: "OrderCreated";
orderId: string;
userId: string;
items: OrderItem[];
total: number;
}
// ---------- 写模型 ----------
// 订单领域对象
interface Order {
id: string;
userId: string;
items: OrderItem[];
total: number;
status: string;
}
class OrderWriteModel {
private orders = new Map<string, Order>();
// 处理命令,返回事件或抛出异常
handleCommand(cmd: CreateOrderCommand): OrderCreatedEvent {
// 业务规则校验
if (cmd.items.length === 0) {
throw new Error("订单不能为空");
}
const total = cmd.items.reduce(
(sum, item) => sum + item.price * item.quantity, 0
);
// 更新写模型
this.orders.set(cmd.orderId, {
id: cmd.orderId,
userId: cmd.userId,
items: cmd.items,
total,
status: "CREATED",
});
// 返回事件
return {
type: "OrderCreated",
orderId: cmd.orderId,
userId: cmd.userId,
items: [...cmd.items], // 防止外部修改
total,
timestamp: Date.now(),
};
}
}
// ---------- 读模型(投影) ----------
// 反范式化的订单摘要视图
interface OrderSummaryView {
orderId: string;
userId: string;
total: number;
status: string;
itemCount: number; // 冗余字段,避免回查明细
}
class OrderReadModel {
// 投影:按订单 ID 索引
private byOrderId = new Map<string, OrderSummaryView>();
// 投影:按用户 ID 索引
private byUserId = new Map<string, string[]>();
// 订阅事件,更新投影
handleEvent(evt: OrderCreatedEvent): void {
// 构建读优化的视图
this.byOrderId.set(evt.orderId, {
orderId: evt.orderId,
userId: evt.userId,
total: evt.total,
status: "CREATED",
itemCount: evt.items.length, // 预计算,查询时不用遍历 items
});
const userOrders = this.byUserId.get(evt.userId) ?? [];
userOrders.push(evt.orderId);
this.byUserId.set(evt.userId, userOrders);
}
// 查询接口——只读投影,不碰写模型
findByOrderId(orderId: string): OrderSummaryView | undefined {
return this.byOrderId.get(orderId);
}
findByUser(userId: string): string[] {
return this.byUserId.get(userId) ?? [];
}
}
// ---------- 事件总线 ----------
type EventHandler = (evt: Event) => void;
class EventBus {
private handlers: EventHandler[] = [];
subscribe(handler: EventHandler): void {
this.handlers.push(handler);
}
publish(evt: Event): void {
for (const handler of this.handlers) {
handler(evt);
}
}
}
// ---------- 组装 ----------
const eventBus = new EventBus();
const writeModel = new OrderWriteModel();
const readModel = new OrderReadModel();
// 读端订阅事件
eventBus.subscribe((evt) => {
if (evt.type === "OrderCreated") {
readModel.handleEvent(evt as OrderCreatedEvent);
}
});
// 发送命令 → 写端处理 → 发布事件 → 读端更新投影
function sendCommand(cmd: CreateOrderCommand): void {
const evt = writeModel.handleCommand(cmd);
eventBus.publish(evt);
}

TypeScript 实现用 EventEmitter 风格连接读写两端。读模型的 itemCount 是一个典型的反范式化字段——写端不需要它,但读端查询时直接拿来用,不用回查 items 数组。这就是 CQRS 的核心收益:读端自由地按查询需求组织数据,不用顾虑写端的范式约束。

六、生产验证#

项目源码位置用途
Axon Frameworkgithub.com/AxonFramework/AxonFrameworkJava 生态最成熟的 CQRS + Event Sourcing 框架,提供 Aggregate、CommandBus、EventBus、QueryBus 全套抽象
EventStoreDBgithub.com/EventStore/EventStore专为事件溯源设计的数据库,内置事件存储和订阅机制,天然适配 CQRS
MediatRgithub.com/jbogard/MediatR.NET 生态的中介者模式库,IRequestHandler<TCommand>IRequestHandler<TQuery, TResult> 天然分离读写
Lagomgithub.com/lagom/lagomLightbend 出品的微服务框架,CQRS + Event Sourcing 为一等公民

Axon Framework 是 CQRS 领域的标杆实现。它的 CommandGateway 发送命令到 Aggregate(写模型),EventProcessor 将事件投影到读模型的 QueryHandler。整个读写分离的生命周期——命令路由、事件存储、投影重建、 Sagas 编排——都有开箱即用的支持。

七、小结#

何时使用:

  • 读多写少且查询复杂——电商商品页、社交 Feed、报表系统,读端可以自由反范式化
  • 读写模型差异大——写端需要严格校验和业务规则,读端需要多种维度的快速查询
  • 与 Event Sourcing 配合——事件溯源提供完整审计追踪,CQRS 提供灵活查询能力
  • 团队可以分治——写端和读端由不同团队独立开发、独立部署、独立扩缩容

何时不用:

  • 简单 CRUD 应用——一个模型就能搞定读写,CQRS 增加的复杂度远大于收益
  • 强一致性需求——金融交易等场景要求写入后立刻读到最新状态,最终一致性不可接受
  • 团队规模小——维护两套模型、事件同步、投影重建需要额外的人力和运维成本
  • 领域模型简单——没有复杂的业务规则,读写逻辑用同一套模型毫无压力

CQRS 不是银弹,它是一种用复杂度换性能和灵活性的权衡。引入 CQRS 之前,先问自己:单模型真的撑不住吗?如果答案是「还好」,那就不需要 CQRS。

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

CQRS 命令查询职责分离(CQRS)
https://blog.souloss.com/posts/programming/system-patterns/system-patterns-cqrs/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时