写和读可以是不同的模型。不是每个系统都需要 CQRS,但当你需要的时候,它是架构级别的自由。——源自 Greg Young,由 Martin Fowler 普及
CQRS 全称 Command Query Responsibility Segregation(命令查询职责分离),最早由 Greg Young 提出,Martin Fowler 在 2010 年的 bliki 中对其做了系统性阐述。核心思想很简单:
💡 一句话定义:用不同的模型来更新信息(Command)和读取信息(Query),而不是共享同一个 CRUD 模型。
主流的信息系统交互方式是 CRUD——对同一张表、同一个对象模型执行 Create/Read/Update/Delete。当系统简单时这没问题,但随着业务复杂化:
Martin Fowler 在原文中明确警告:
⚠️ Fowler 的警告:"I have come across successful uses of CQRS, but so far the majority of cases I've run into have not been so good, with CQRS seen as a significant force for getting a software system into serious difficulties." 大多数情况下 CQRS 增加了风险性复杂度,不应该在整个系统中使用,而应在特定的 Bounded Context 中。
如果读远多于写(大多数 Web 应用的读写比在 10:1 到 100:1),可以单独扩展读模型。写模型不需要承受读流量。
写模型优化事务一致性(PostgreSQL),读模型优化查询速度(Elasticsearch / Redis / 列式存储),各得其所。
当命令侧有复杂业务规则时,不需要同时兼顾查询需求。模型更聚焦、更纯粹。
读模型不需要暴露写模型的内部结构。不同的用户角色可以访问不同的模型。
| 维度 | 传统 CRUD | CQRS |
|---|---|---|
| 模型数量 | 1 个共享模型 | ≥2 个模型(命令 + 查询) |
| 数据存储 | 单一数据库 | 可以不同数据库 |
| 一致性 | 强一致性 | 可能最终一致性 |
| 复杂度 | 低 | 高(需要同步机制) |
| 读写伸缩 | 不能独立伸缩 | 可以独立伸缩 |
| ORM 适配 | 自动生成 CRUD 代码 | 需自定义逻辑桥接 |
| 适用场景 | 简单业务、快速迭代 | 复杂领域、高读写比 |
| 团队认知负担 | 低 | 高 |
选择你的场景,看看 CQRS 是否适合:
最简形式的 CQRS——命令和查询使用不同的对象/接口,但共享同一个数据库。这是入门级实现,没有最终一致性问题。
// === Command Side ===
interface Command {
type: string;
}
interface CreateOrderCommand extends Command {
type: 'CreateOrder';
userId: string;
items: Array<{ productId: string; quantity: number }>;
}
// Command Handler - 写侧有业务逻辑
class OrderCommandHandler {
constructor(private db: Database) {}
async handle(cmd: CreateOrderCommand): Promise<string> {
// 验证库存
for (const item of cmd.items) {
const stock = await this.db.query(
'SELECT stock FROM products WHERE id = $1', [item.productId]
);
if (stock.rows[0].stock < item.quantity) {
throw new Error(`库存不足: ${item.productId}`);
}
}
// 创建订单
const orderId = crypto.randomUUID();
await this.db.query(
`INSERT INTO orders (id, user_id, status, created_at)
VALUES ($1, $2, 'pending', NOW())`,
[orderId, cmd.userId]
);
// 扣减库存 + 添加订单项(事务)
await this.db.transaction(async (tx) => {
for (const item of cmd.items) {
await tx.query(
'UPDATE products SET stock = stock - $1 WHERE id = $2',
[item.quantity, item.productId]
);
await tx.query(
`INSERT INTO order_items (order_id, product_id, quantity)
VALUES ($1, $2, $3)`,
[orderId, item.productId, item.quantity]
);
}
});
return orderId;
}
}
// === Query Side ===
// 查询侧只读,返回 DTO,没有业务逻辑
interface OrderDTO {
id: string;
userId: string;
status: string;
items: Array<{ productId: string; name: string; quantity: number; price: number }>;
totalAmount: number;
createdAt: string;
}
class OrderQueryHandler {
constructor(private db: Database) {}
async getOrderById(id: string): Promise<OrderDTO | null> {
const result = await this.db.query(`
SELECT o.id, o.user_id, o.status, o.created_at,
json_agg(json_build_object(
'productId', oi.product_id,
'name', p.name,
'quantity', oi.quantity,
'price', p.price
)) as items
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.id = $1
GROUP BY o.id
`, [id]);
if (!result.rows[0]) return null;
const row = result.rows[0];
return {
id: row.id,
userId: row.user_id,
status: row.status,
items: row.items,
totalAmount: row.items.reduce((sum: number, i: any) =>
sum + i.price * i.quantity, 0),
createdAt: row.created_at,
};
}
}
这是更完整的 CQRS——写侧和读侧使用完全不同的数据存储。写侧写入后,通过事件/消息通知读侧更新。这里引入了最终一致性。
// 写侧:Command 处理后发布事件
class OrderCommandHandler {
constructor(
private db: Database,
private eventBus: EventBus // Kafka / RabbitMQ
) {}
async handle(cmd: CreateOrderCommand): Promise<string> {
const orderId = crypto.randomUUID();
await this.db.transaction(async (tx) => {
// ... 业务逻辑和写入 ...
await tx.query(/* ... */);
// 在事务内发布事件(Outbox Pattern 保证原子性)
await tx.query(
`INSERT INTO outbox_events (event_type, aggregate_id, payload)
VALUES ($1, $2, $3)`,
['OrderCreated', orderId, JSON.stringify({
orderId, userId: cmd.userId,
items: cmd.items,
timestamp: Date.now()
})]
);
});
return orderId;
}
}
// Outbox Poller:将事件从 outbox 表推送到消息队列
class OutboxPoller {
async poll() {
const events = await this.db.query(
`SELECT * FROM outbox_events
WHERE processed = false
ORDER BY created_at ASC LIMIT 100`
);
for (const event of events.rows) {
await this.eventBus.publish(event.event_type, event.payload);
await this.db.query(
'UPDATE outbox_events SET processed = true WHERE id = $1',
[event.id]
);
}
}
}
// 读侧:事件处理器构建读模型
class OrderReadModelProjector {
constructor(private esClient: ElasticsearchClient) {}
async onOrderCreated(event: OrderCreatedEvent) {
// 将订单数据写入 Elasticsearch,优化搜索体验
await this.esClient.index({
index: 'orders',
id: event.orderId,
body: {
orderId: event.orderId,
userId: event.userId,
items: event.items,
status: 'pending',
createdAt: new Date(event.timestamp).toISOString()
}
});
}
async onOrderStatusChanged(event: OrderStatusChangedEvent) {
await this.esClient.update({
index: 'orders',
id: event.orderId,
body: { doc: { status: event.newStatus } }
});
}
}
// 读侧:查询处理器直接查读数据库
class OrderQueryHandler {
constructor(private esClient: ElasticsearchClient) {}
async searchOrders(query: string, userId: string): Promise<OrderDTO[]> {
const result = await this.esClient.search({
index: 'orders',
body: {
query: {
bool: {
must: [
{ term: { userId } },
{ multi_match: { query, fields: ['items.name', 'status'] } }
]
}
}
}
});
return result.hits.hits.map((h: any) => h._source);
}
}
最高级形式——不存储当前状态,存储所有事件。读模型完全由事件投影(Projection)构建。这是 Greg Young 最初设想的架构,也是最复杂的。
🔗 与 Event Sourcing 的关系:CQRS 和 Event Sourcing 经常一起出现,但它们是独立模式。CQRS 可以不用 Event Sourcing(Level 1/2),Event Sourcing 也可以不用 CQRS(虽然很少见)。它们组合在一起是因为 ES 天然产生事件流,CQRS 需要事件流来同步读写模型。
这是独立开发者 / 小团队最常见的 CQRS 实现方式。不是教科书级的完美架构,而是实际可落地的方案。
| 策略 | 延迟 | 复杂度 | 一致性保证 | 适合场景 |
|---|---|---|---|---|
| 定时轮询 | 秒~分钟级 | 低 | 最终一致 | MVP / 小流量 |
| PG LISTEN/NOTIFY | 毫秒级 | 低 | 可能丢事件 | 实时性要求高但可容忍少量丢失 |
| Outbox Pattern | 秒级 | 中 | 至少一次投递 | 生产环境推荐 |
| CDC (Debezium) | 亚秒级 | 高 | 精确一次(with Kafka) | 大规模 / 企业级 |
最轻量的实时同步方式。PostgreSQL 内置的发布/订阅机制,无需额外基础设施。
-- 写侧:在事务提交后发送通知
CREATE OR REPLACE FUNCTION notify_order_change()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify(
'order_events',
json_build_object(
'action', TG_OP,
'id', COALESCE(NEW.id, OLD.id),
'changed_at', NOW()
)::text
);
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER order_change_trigger
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION notify_order_change();
// Node.js 读侧监听
import { Client } from 'pg';
const client = new Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
await client.query('LISTEN order_events');
client.on('notification', async (msg) => {
if (msg.channel === 'order_events') {
const event = JSON.parse(msg.payload);
// 同步到 Meilisearch
await meilisearch.index('orders').updateDocuments([
await fetchOrderForReadModel(event.id)
]);
}
});
⚠️ 注意:LISTEN/NOTIFY 不保证投递——如果读侧宕机期间有通知,会丢失。适合实时性要求高但可容忍少量丢失的场景。
在同一个数据库事务中,同时写入业务数据和"发件箱"事件。然后由一个单独的进程将事件推送到消息队列。保证事件不会丢失。
// 写侧:事务内同时写 outbox
async function createOrder(cmd: CreateOrderCommand) {
await db.transaction(async (tx) => {
// 1. 业务写入
const orderId = crypto.randomUUID();
await tx.query(
`INSERT INTO orders (id, user_id, status)
VALUES ($1, $2, 'pending')`, [orderId, cmd.userId]
);
// 2. Outbox 事件(同一事务!保证原子性)
await tx.query(
`INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES ($1, $2, $3, $4)`,
['order', orderId, 'OrderCreated', JSON.stringify({
orderId, userId: cmd.userId, items: cmd.items
})]
);
});
// 事务提交后,outbox 中有事件等待被推送
}
// Outbox Relay:轮询 + 推送
class OutboxRelay {
private running = false;
async start() {
this.running = true;
while (this.running) {
const events = await this.db.query(
`SELECT * FROM outbox
WHERE processed_at IS NULL
ORDER BY created_at ASC LIMIT 100
FOR UPDATE SKIP LOCKED` // ← 多实例安全
);
for (const event of events.rows) {
try {
await this.kafka.produce({
topic: `${event.aggregate_type}.events`,
key: event.aggregate_id,
value: JSON.stringify({
type: event.event_type,
payload: event.payload
})
});
await this.db.query(
'UPDATE outbox SET processed_at = NOW() WHERE id = $1',
[event.id]
);
} catch (err) {
console.error('Failed to relay event:', err);
break; // 下次重试
}
}
await new Promise(r => setTimeout(r, 100)); // 轮询间隔
}
}
}
✅ 为什么推荐:Outbox Pattern 在同一个事务中保证业务数据和事件的一致性。即使 Kafka 暂时不可用,事件也不会丢失——等 Kafka 恢复后,relay 会继续推送。这是生产环境中 CQRS 同步的最佳实践。
通过读取 PostgreSQL 的 WAL(Write-Ahead Log)来捕获所有数据变更。无需修改应用代码。
// Debezium PostgreSQL Source Connector 配置
// Kafka Connect REST API
{
"name": "pg-order-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres",
"database.port": "5432",
"database.user": "debezium",
"database.password": "secret",
"database.dbname": "myapp",
"database.server.name": "pgserver1",
"plugin.name": "pgoutput",
"table.include.list": "public.orders,public.order_items",
"slot.name": "debezium_slot",
"publication.name": "debezium_pub"
}
}
// 读侧:消费 Kafka topic,更新 Elasticsearch
import { Kafka } from 'kafkajs';
const kafka = new Kafka({ brokers: ['kafka:9092'] });
const consumer = kafka.consumer({ groupId: 'order-read-model' });
await consumer.subscribe({ topic: 'pgserver1.public.orders' });
await consumer.run({
eachMessage: async ({ message }) => {
const event = JSON.parse(message.value!.toString());
if (event.payload.op === 'c') { // Create
await esClient.index({
index: 'orders', id: event.payload.after.id,
body: event.payload.after
});
} else if (event.payload.op === 'u') { // Update
await esClient.update({
index: 'orders', id: event.payload.after.id,
body: { doc: event.payload.after }
});
} else if (event.payload.op === 'd') { // Delete
await esClient.delete({
index: 'orders', id: event.payload.before.id
});
}
}
});
⚠️ CDC 的代价:Debezium 需要额外的 Kafka Connect 基础设施,运维复杂度显著增加。适合大规模系统,对独立开发者来说 Outbox Pattern 通常更实用。
Supabase 是独立开发者最爱的后端平台。虽然它不标榜 CQRS,但其架构隐式地使用了 CQRS 模式:
PostgreSQL 事务处理所有写入操作。Row Level Security 保证数据安全。
PostgREST 提供直接查询(CRUD 的读)。Realtime 通过 WAL 监听变更推送 WebSocket。
pgvector 做向量搜索——这本质上是另一个读模型,优化语义搜索。
AI Agent 的记忆管理天然适合 CQRS——"写入记忆"和"检索记忆"的需求完全不同。
| 维度 | 写侧(记忆写入) | 读侧(记忆检索) |
|---|---|---|
| 数据结构 | 事件流(消息、工具调用、思考) | 向量嵌入 + 摘要 + 关键词索引 |
| 写入频率 | 每次交互都写入 | 每次生成前都读取 |
| 一致性要求 | 必须持久化(不能丢对话) | 可以容忍轻微延迟 |
| 优化方向 | 高吞吐写入、事务安全 | 低延迟搜索、语义相关性 |
| 存储选择 | PostgreSQL / Event Store | pgvector / Pinecone / Meilisearch |
| 典型操作 | appendMessage()、logToolCall() | searchMemory()、getContext() |
// === 写侧:Agent 记忆写入 ===
interface AgentMemoryEvent {
type: 'user_message' | 'assistant_reply' | 'tool_call' | 'tool_result' | 'thinking';
sessionId: string;
content: string;
metadata?: Record<string, any>;
timestamp: number;
}
class AgentMemoryWriteModel {
constructor(private pg: Pool) {}
// 写入是 append-only,类似 Event Sourcing
async append(event: AgentMemoryEvent): Promise<void> {
await this.pg.query(
`INSERT INTO agent_events (session_id, event_type, content, metadata, created_at)
VALUES ($1, $2, $3, $4, $5)`,
[event.sessionId, event.type, event.content, event.metadata, new Date(event.timestamp)]
);
}
// 批量写入(工具调用链)
async appendBatch(events: AgentMemoryEvent[]): Promise<void> {
const client = await this.pg.connect();
try {
await client.query('BEGIN');
for (const event of events) {
await client.query(
`INSERT INTO agent_events (session_id, event_type, content, metadata, created_at)
VALUES ($1, $2, $3, $4, $5)`,
[event.sessionId, event.type, event.content, event.metadata, new Date(event.timestamp)]
);
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
}
// === 读侧:Agent 记忆检索 ===
interface MemorySearchResult {
content: string;
score: number;
source: 'semantic' | 'keyword' | 'recent';
timestamp: number;
}
class AgentMemoryReadModel {
constructor(
private pg: Pool, // 原始事件
private vectorStore: VectorStore, // pgvector / Pinecone
private searchEngine: SearchEngine // Meilisearch
) {}
// 语义搜索:找到语义相关的历史记忆
async searchSemantic(query: string, limit = 10): Promise<MemorySearchResult[]> {
const queryEmbedding = await embed(query);
return this.vectorStore.similaritySearch(queryEmbedding, limit);
}
// 关键词搜索:精确匹配
async searchKeyword(query: string, sessionId?: string): Promise<MemorySearchResult[]> {
const filter = sessionId ? { sessionId } : undefined;
return this.searchEngine.search(query, { filter, limit: 10 });
}
// 最近上下文:构造 LLM 的上下文窗口
async getRecentContext(sessionId: string, limit = 20): Promise<AgentMemoryEvent[]> {
const result = await this.pg.query(
`SELECT event_type, content, metadata, created_at
FROM agent_events
WHERE session_id = $1
ORDER BY created_at DESC
LIMIT $2`,
[sessionId, limit]
);
return result.rows.reverse(); // 按时间正序
}
// 混合检索:语义 + 关键词 + 最近上下文
async getFullContext(query: string, sessionId: string): Promise<{
recent: AgentMemoryEvent[];
semantic: MemorySearchResult[];
keyword: MemorySearchResult[];
}> {
const [recent, semantic, keyword] = await Promise.all([
this.getRecentContext(sessionId),
this.searchSemantic(query),
this.searchKeyword(query, sessionId)
]);
return { recent, semantic, keyword };
}
}
// === 同步层:写侧事件 → 读模型更新 ===
class MemoryProjector {
constructor(
private pg: Pool,
private vectorStore: VectorStore,
private searchEngine: SearchEngine
) {}
// 订阅新事件,更新读模型
async project(event: AgentMemoryEvent): Promise<void> {
// 1. 生成 embedding 并写入向量库
if (event.content.length > 10) {
const embedding = await embed(event.content);
await this.vectorStore.upsert({
id: `${event.sessionId}:${event.timestamp}`,
embedding,
metadata: {
sessionId: event.sessionId,
type: event.type,
timestamp: event.timestamp
}
});
}
// 2. 写入搜索索引
await this.searchEngine.index({
id: `${event.sessionId}:${event.timestamp}`,
content: event.content,
sessionId: event.sessionId,
type: event.type
});
}
}
LangGraph 是目前最流行的 Agent 框架之一。它的 Checkpoint 机制就是 CQRS + Event Sourcing 的变体:
CQRS 不同数据库的核心代价是最终一致性——写入后,读模型可能不是立即更新的。你需要理解这个延迟窗口。
写入速率(事件/秒):
当前:100 事件/秒
同步策略延迟:
读侧索引延迟:
用户刚提交的变更,紧接着的查询直接走写侧数据库(强一致)。只有其他用户的查询走读库。
写入成功后,前端立即更新本地状态,不等读模型同步。用户看到的是"即时"反馈。
写侧返回版本号,读侧查询时携带版本号。如果版本不匹配,前端知道数据可能过期。
写侧变更后,通过 WebSocket/SSE 通知前端。前端收到通知后重新查询读模型。
Martin Fowler 说大多数 CQRS 实施是失败的。以下是典型陷阱:
CQRS 应该只在特定 Bounded Context 中使用,不是整个系统的架构。用户管理模块用 CRUD 完全没问题。
如果你不需要事件溯源,也不需要独立的查询优化,单纯分离读写只是增加了复杂度。Reporting Database(报表库)是更轻量的替代。
用户提交订单后立即查看,发现状态还是"未支付"——因为他们命中了还没同步的读模型。必须处理这个场景。
Outbox → Kafka → Projector → 读库,这个链路很长。任何一个环节出问题,读模型就会不一致。监控和告警必不可少。
"我们未来可能需要 CQRS" → 现在不需要就不要做。先 CRUD,遇到瓶颈再重构。YAGNI(You Ain't Gonna Need It)。
每种查询都建一个 Projection?维护成本爆炸。大多数场景 2-3 个读模型就够了(搜索、缓存、分析)。
| 工具 | 语言 | CQRS 支持 | Event Sourcing | 适合谁 |
|---|---|---|---|---|
| Axon Framework | Java/Kotlin | 原生 CQRS | ✅ 完整支持 | 企业级 Java 项目 |
| KurrentDB (原 EventStoreDB) | 语言无关 | 配合 Axon/自建 | ✅ 核心产品 | 需要专业 Event Store 的团队 |
| Marten | .NET | ES + CQRS | ✅ PostgreSQL 存储 | .NET + PostgreSQL 栈 |
| Eventuous | .NET | ES + CQRS | ✅ 轻量 | .NET 微服务 |
| Sequoia | TypeScript | 自定义 | ✅ | Node.js / TypeScript |
| 自建 (PostgreSQL + Kafka) | 任意 | 自己实现 | 可选用 | 独立开发者 / 小团队 |
💡 独立开发者建议:不要上来就上 Axon Framework 或 EventStoreDB。先用 PostgreSQL + Outbox + Meilisearch 实现 Level 2 CQRS。够用了。等你的读模型真的需要独立伸缩时,再考虑引入 Kafka 或专业 Event Store。
from dataclasses import dataclass
from typing import Any, Callable
import asyncio
import json
import asyncpg
# === 命令定义 ===
@dataclass
class Command:
"""所有命令的基类"""
pass
@dataclass
class CreatePost(Command):
title: str
content: str
author_id: str
@dataclass
class UpdatePost(Command):
post_id: str
title: str | None = None
content: str | None = None
# === 查询定义 ===
@dataclass
class Query:
"""所有查询的基类"""
pass
@dataclass
class GetPost(Query):
post_id: str
@dataclass
class SearchPosts(Query):
keyword: str
limit: int = 10
# === 命令总线 ===
class CommandBus:
def __init__(self):
self._handlers: dict[type, Callable] = {}
def register(self, cmd_type: type, handler: Callable):
self._handlers[cmd_type] = handler
async def dispatch(self, cmd: Command) -> Any:
handler = self._handlers.get(type(cmd))
if not handler:
raise ValueError(f"No handler for {type(cmd).__name__}")
return await handler(cmd)
# === 查询总线 ===
class QueryBus:
def __init__(self):
self._handlers: dict[type, Callable] = {}
def register(self, query_type: type, handler: Callable):
self._handlers[query_type] = handler
async def dispatch(self, query: Query) -> Any:
handler = self._handlers.get(type(query))
if not handler:
raise ValueError(f"No handler for {type(query).__name__}")
return await handler(query)
# === 命令处理器 ===
class PostCommandHandler:
def __init__(self, pg: asyncpg.Pool):
self.pg = pg
async def handle_create(self, cmd: CreatePost) -> str:
post_id = str(__import__('uuid').uuid4())
async with self.pg.acquire() as conn:
async with conn.transaction():
# 业务写入
await conn.execute(
"""INSERT INTO posts (id, title, content, author_id)
VALUES ($1, $2, $3, $4)""",
post_id, cmd.title, cmd.content, cmd.author_id
)
# Outbox 事件
await conn.execute(
"""INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES ($1, $2, $3, $4)""",
'post', post_id, 'PostCreated',
json.dumps({'id': post_id, 'title': cmd.title, 'content': cmd.content})
)
return post_id
async def handle_update(self, cmd: UpdatePost) -> None:
async with self.pg.acquire() as conn:
async with conn.transaction():
updates = []
params = []
if cmd.title is not None:
updates.append("title = $1")
params.append(cmd.title)
if cmd.content is not None:
updates.append("content = $" + str(len(params) + 1))
params.append(cmd.content)
params.append(cmd.post_id)
await conn.execute(
f"UPDATE posts SET {', '.join(updates)} WHERE id = ${len(params)}",
*params
)
await conn.execute(
"""INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES ($1, $2, $3, $4)""",
'post', cmd.post_id, 'PostUpdated',
json.dumps({'id': cmd.post_id, 'title': cmd.title, 'content': cmd.content})
)
# === 查询处理器(读侧) ===
class PostQueryHandler:
def __init__(self, pg: asyncpg.Pool, meili: Any):
self.pg = pg
self.meili = meili
async def handle_get(self, query: GetPost) -> dict | None:
"""单条查询走写库(强一致)"""
row = await self.pg.fetchrow(
"SELECT * FROM posts WHERE id = $1", query.post_id
)
return dict(row) if row else None
async def handle_search(self, query: SearchPosts) -> list[dict]:
"""搜索走读库(Meilisearch,最终一致)"""
result = await self.meili.index('posts').search(
query.keyword, {'limit': query.limit}
)
return result['hits']
# === 组装 ===
async def setup_cqrs(pg: asyncpg.Pool, meili: Any):
cmd_bus = CommandBus()
query_bus = QueryBus()
cmd_handler = PostCommandHandler(pg)
query_handler = PostQueryHandler(pg, meili)
cmd_bus.register(CreatePost, cmd_handler.handle_create)
cmd_bus.register(UpdatePost, cmd_handler.handle_update)
query_bus.register(GetPost, query_handler.handle_get)
query_bus.register(SearchPosts, query_handler.handle_search)
return cmd_bus, query_bus
这两个模式经常被一起提及,但它们是独立的。让我们精确理清关系。
| 组合 | 写侧存储 | 读侧存储 | 一致性 | 典型场景 |
|---|---|---|---|---|
| CRUD | 当前状态 (PostgreSQL) | 同一张表 | 强一致 | 博客、CMS、管理后台 |
| CQRS only | 当前状态 (PostgreSQL) | 搜索/缓存 (ES/Redis) | 最终一致 | 电商、SaaS 仪表盘 |
| CQRS + ES | 事件流 (EventStoreDB/PG) | Projection (ES/Redis/CH) | 最终一致 | 金融、审计、AI Agent |
Event Sourcing 不存当前状态,只存事件。如果你要查询"用户 A 的所有订单",你需要重放所有事件来计算——这显然太慢了。
所以 ES 天然需要一个"读模型投影"(Projection)——把事件流实时转换为可查询的数据结构。这个 Projection 本质上就是 CQRS 的读侧。
反过来,CQRS 不需要 ES。你可以用传统方式存状态(PostgreSQL),同时为搜索建一个 Elasticsearch 索引——这是 Level 2 CQRS。
关键洞察:ES → CQRS 是单向推动的。ES 几乎强制要求 CQRS,但 CQRS 可以独立存在。
大多数实际场景!
这些场景的共同特点:写侧有状态(不需要重放事件),读侧需要不同优化。CQRS enough。
如果你决定引入 CQRS,按这个清单逐项检查。
• 读写比 > 10:1
• 写侧有复杂业务规则
• 读侧需要不同存储优化
• 你能接受最终一致性
• 团队有分布式系统经验
• 简单 CRUD 就够的场景
• 强一致性是硬需求
• 团队小、迭代快
• "未来可能需要"(YAGNI)
• 读侧和写侧模型高度重合
• 先 CRUD,遇到瓶颈再重构
• 只在需要的 Bounded Context 用
• 从 Level 1 开始,逐步升级
• Outbox Pattern 是同步的最佳实践
• 永远保留"切回 CRUD"的能力
🎯 一句话总结:CQRS 不是银弹,它是"当你真的需要时"的架构工具。大多数系统用 CRUD + Reporting DB 就够了。当读写差异大到共享模型成为瓶颈时,CQRS 才值得付出复杂度的代价。
• Domain-Driven Design — Eric Evans(CQRS 的理论基础)
• Implementing Domain-Driven Design — Vaughn Vernon(实战指南)
• Building Event-Driven Microservices — Adam Bellemare