为什么你需要关注 CQRS?
在传统的 CRUD 架构中,我们通常使用同一个模型来处理读写操作。但随着业务复杂度增加,读写需求往往不对称:读操作可能远多于写操作,且查询逻辑复杂多变(如多维度筛选、聚合统计),而写操作则注重业务规则和一致性。强行用同一模型应对,会导致代码臃肿、性能瓶颈和难以维护。
CQRS(Command Query Responsibility Segregation,命令查询职责分离)正是解决这一痛点的模式。它主张将命令(写操作)和查询(读操作)分离,使用不同的模型甚至不同的存储,从而让系统更灵活、可扩展。
本文将以一个简单的订单系统为例,带你从零实现 CQRS。你将学到:
- CQRS 的核心概念与适用场景
- 如何设计命令与查询模型
- 使用事件驱动同步读写存储
- 实战中常见的坑与解决方案
- 何时不该使用 CQRS
案例背景:订单系统
假设我们有一个订单服务,需求如下:
- 用户可创建订单、支付订单、取消订单。
- 后台需要查询订单列表,支持按状态、用户、时间范围过滤,并展示商品详情。
- 订单量较大,读请求远多于写请求。
传统做法是用一个 Order 实体,同时处理读写。但查询时需要关联商品、用户等信息,写操作需要校验业务规则。随着时间推移,代码会变得混乱。我们决定采用 CQRS。
第一步:定义命令与查询
命令表示“做什么”,通常返回 void 或简单结果;查询表示“要什么”,返回数据。我们使用 TypeScript 来演示,但思想适用于任何语言。
// 命令接口
interface Command {
readonly type: string;
}
// 查询接口
interface Query {
readonly type: string;
}
// 具体命令
export class CreateOrderCommand implements Command {
readonly type = 'CreateOrder';
constructor(
public readonly orderId: string,
public readonly userId: string,
public readonly items: Array<{ productId: string; quantity: number; price: number }>
) {}
}
export class PayOrderCommand implements Command {
readonly type = 'PayOrder';
constructor(public readonly orderId: string) {}
}
// 具体查询
export class GetOrdersQuery implements Query {
readonly type = 'GetOrders';
constructor(
public readonly userId?: string,
public readonly status?: string,
public readonly startDate?: Date,
public readonly endDate?: Date
) {}
}
注意:命令和查询应该是不可变的数据对象,仅包含执行所需的信息,不包含业务逻辑。
第二步:设计写模型(命令端)
写模型负责处理业务规则,维护领域状态。我们使用领域驱动设计(DDD)中的聚合根概念。
// 订单状态
export enum OrderStatus {
Created = 'created',
Paid = 'paid',
Cancelled = 'cancelled',
}
// 订单聚合根
export class Order {
private _status: OrderStatus = OrderStatus.Created;
private _items: Array<{ productId: string; quantity: number; price: number }> = [];
constructor(public readonly id: string, public readonly userId: string) {}
get status() { return this._status; }
get items() { return this._items; }
addItem(productId: string, quantity: number, price: number) {
if (this._status !== OrderStatus.Created) {
throw new Error('只能在创建状态下添加商品');
}
if (quantity <= 0) throw new Error('数量必须大于0');
this._items.push({ productId, quantity, price });
}
pay() {
if (this._status !== OrderStatus.Created) {
throw new Error('订单状态不允许支付');
}
if (this._items.length === 0) {
throw new Error('订单不能为空');
}
this._status = OrderStatus.Paid;
}
cancel() {
if (this._status === OrderStatus.Paid) {
throw new Error('已支付订单不能取消');
}
this._status = OrderStatus.Cancelled;
}
}
接下来,实现命令处理器。每个命令对应一个处理器,负责加载聚合、执行业务逻辑、保存并发布事件。
// 事件存储接口(简化版)
interface EventStore {
saveEvents(aggregateId: string, events: any[], expectedVersion: number): Promise<void>;
getEvents(aggregateId: string): Promise<any[]>;
}
// 事件发布器
interface EventPublisher {
publish(event: any): Promise<void>;
}
// 命令处理器
export class CreateOrderHandler {
constructor(
private eventStore: EventStore,
private publisher: EventPublisher
) {}
async handle(command: CreateOrderCommand): Promise<void> {
const order = new Order(command.orderId, command.userId);
command.items.forEach(item => order.addItem(item.productId, item.quantity, item.price));
const events = [
{
type: 'OrderCreated',
aggregateId: order.id,
userId: order.userId,
items: order.items,
timestamp: new Date(),
},
];
await this.eventStore.saveEvents(order.id, events, -1); // -1 表示新聚合
for (const event of events) {
await this.publisher.publish(event);
}
}
}
💡 提示:这里我们简单地将聚合状态直接保存为事件,实际项目中可以使用事件溯源(Event Sourcing)持久化所有状态变化。为了简化,本文不深入事件溯源。
第三步:设计读模型(查询端)
读模型专注于查询性能,通常使用反范式化的数据表或文档。我们为订单列表创建一个读模型,包含订单基本信息和商品详情。
// 读模型数据库表(这里用内存模拟)
export class OrderReadModel {
private orders: Map<string, any> = new Map();
async upsert(order: any) {
this.orders.set(order.id, order);
}
async find(query: GetOrdersQuery): Promise<any[]> {
let results = Array.from(this.orders.values());
if (query.userId) {
results = results.filter(o => o.userId === query.userId);
}
if (query.status) {
results = results.filter(o => o.status === query.status);
}
if (query.startDate) {
results = results.filter(o => o.createdAt >= query.startDate);
}
if (query.endDate) {
results = results.filter(o => o.createdAt <= query.endDate);
}
return results;
}
}
查询处理器直接调用读模型:
export class GetOrdersHandler {
constructor(private readModel: OrderReadModel) {}
async handle(query: GetOrdersQuery): Promise<any[]> {
return this.readModel.find(query);
}
}
第四步:同步读写模型(事件驱动)
写模型产生事件后,需要更新读模型。我们使用一个事件订阅者来监听事件并更新读模型。
// 事件订阅者
export class OrderProjection {
constructor(private readModel: OrderReadModel) {}
async handle(event: any): Promise<void> {
switch (event.type) {
case 'OrderCreated':
await this.readModel.upsert({
id: event.aggregateId,
userId: event.userId,
status: 'created',
items: event.items,
createdAt: event.timestamp,
updatedAt: event.timestamp,
});
break;
case 'OrderPaid':
const order = await this.readModel.find({} as GetOrdersQuery).then(orders => orders.find(o => o.id === event.aggregateId));
if (order) {
order.status = 'paid';
order.updatedAt = event.timestamp;
await this.readModel.upsert(order);
}
break;
case 'OrderCancelled':
// 类似处理
break;
}
}
}
注意:读模型更新可能失败,需要重试机制和幂等处理。事件应至少投递一次,因此订阅者必须幂等。
第五步:组装与测试
现在我们把所有组件组装起来,模拟一次完整的订单流程。
// 简单的内存事件存储和发布器
class InMemoryEventStore implements EventStore {
private events: Map<string, any[]> = new Map();
async saveEvents(aggregateId: string, events: any[], expectedVersion: number) {
const existing = this.events.get(aggregateId) || [];
if (existing.length !== expectedVersion + 1) {
throw new Error('并发冲突');
}
this.events.set(aggregateId, [...existing, ...events]);
}
async getEvents(aggregateId: string) {
return this.events.get(aggregateId) || [];
}
}
class SimpleEventPublisher implements EventPublisher {
private subscribers: Array<(event: any) => void> = [];
subscribe(handler: (event: any) => void) {
this.subscribers.push(handler);
}
async publish(event: any) {
for (const sub of this.subscribers) {
await sub(event);
}
}
}
// 组装
const eventStore = new InMemoryEventStore();
const publisher = new SimpleEventPublisher();
const readModel = new OrderReadModel();
const projection = new OrderProjection(readModel);
publisher.subscribe(event => projection.handle(event));
const createOrderHandler = new CreateOrderHandler(eventStore, publisher);
const getOrdersHandler = new GetOrdersHandler(readModel);
// 执行命令
(async () => {
await createOrderHandler.handle(
new CreateOrderCommand('order-1', 'user-1', [
{ productId: 'p1', quantity: 2, price: 100 },
])
);
// 查询
const orders = await getOrdersHandler.handle(new GetOrdersQuery('user-1'));
console.log(orders);
})();
运行后,你会看到查询结果包含了刚创建的订单。这证明了读写分离的流程是通的。
常见坑与最佳实践
- 最终一致性:CQRS 通常导致读写延迟。对于需要强一致的场景,可以在命令处理后同步更新读模型,但会失去解耦优势。建议在 UI 上做适当处理,如显示“处理中”。
- 事件版本管理:事件结构可能变化,需要版本化。建议在事件中包含版本号,并编写向上兼容的转换器。
- 读模型重建:当读模型结构变更时,需要从事件存储重放事件来重建。设计时应支持重放,并考虑性能。
- 不要过度设计:CQRS 增加复杂度,仅适用于读写负载差异大、查询复杂或需要多视图的场景。简单 CRUD 系统慎用。
- 命令校验:命令应在处理器中校验,而不是在命令对象中。命令对象保持纯净。
总结
CQRS 是一种强大的模式,通过分离读写职责,让系统更易于扩展和维护。本文通过订单案例,展示了从命令定义、写模型、读模型到事件同步的完整实现。关键点包括:
- 命令与查询分离,各司其职
- 写模型维护业务规则,读模型优化查询
- 使用事件驱动同步读写模型
- 注意最终一致性和幂等性
下一步,你可以探索事件溯源(Event Sourcing)与 CQRS 的结合,或使用消息队列(如 Kafka)实现可靠的事件传递。
延伸阅读
- 领域驱动设计(DDD)中的聚合与领域事件
- 事件溯源(Event Sourcing)模式
- 使用 Axon Framework 或 EventStoreDB 实现 CQRS