🔀 CQRS 命令查询职责分离

写和读可以是不同的模型。不是每个系统都需要 CQRS,但当你需要的时候,它是架构级别的自由。——源自 Greg Young,由 Martin Fowler 普及

📖 核心概念

CQRS 全称 Command Query Responsibility Segregation(命令查询职责分离),最早由 Greg Young 提出,Martin Fowler 在 2010 年的 bliki 中对其做了系统性阐述。核心思想很简单:

💡 一句话定义:用不同的模型来更新信息(Command)和读取信息(Query),而不是共享同一个 CRUD 模型。

传统 CRUD 模型的问题

主流的信息系统交互方式是 CRUD——对同一张表、同一个对象模型执行 Create/Read/Update/Delete。当系统简单时这没问题,但随着业务复杂化:

传统 CRUD:
用户界面 ──→ 单一领域模型 ──→ 单一数据库
↑ 读写共享同一个模型
 
CQRS:
用户界面 ──┬→ 写模型 (Command) ──→ 写数据库 (PostgreSQL)
└→ 读模型 (Query) ──→ 读数据库 (Meilisearch / ClickHouse)
↑ 写和读各自优化

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 中。

CQRS 的核心收益

📊 读写独立伸缩

如果读远多于写(大多数 Web 应用的读写比在 10:1 到 100:1),可以单独扩展读模型。写模型不需要承受读流量。

🎯 各自优化

写模型优化事务一致性(PostgreSQL),读模型优化查询速度(Elasticsearch / Redis / 列式存储),各得其所。

🧩 复杂领域简化

当命令侧有复杂业务规则时,不需要同时兼顾查询需求。模型更聚焦、更纯粹。

🔒 安全分离

读模型不需要暴露写模型的内部结构。不同的用户角色可以访问不同的模型。

⚖️ CRUD vs CQRS 详细对比

维度传统 CRUDCQRS
模型数量 1 个共享模型 ≥2 个模型(命令 + 查询)
数据存储 单一数据库 可以不同数据库
一致性 强一致性 可能最终一致性
复杂度 高(需要同步机制)
读写伸缩 不能独立伸缩 可以独立伸缩
ORM 适配 自动生成 CRUD 代码 需自定义逻辑桥接
适用场景 简单业务、快速迭代 复杂领域、高读写比
团队认知负担

什么时候该用 CQRS?

🧭 场景选择器

选择你的场景,看看 CQRS 是否适合:

选择场景查看建议...

🏗️ CQRS 架构详解

Level 1:同一数据库,不同模型

最简形式的 CQRS——命令和查询使用不同的对象/接口,但共享同一个数据库。这是入门级实现,没有最终一致性问题。

┌──────────┐ ┌─────────────────┐ ┌──────────────┐
│ Client │────→│ Command Handler │────→│ │
└──────────┘ └─────────────────┘ │ │
PostgreSQL
┌──────────┐ ┌─────────────────┐ │ (共享数据库) │
│ Client │────→│ Query Handler │────→│ │
└──────────┘ └─────────────────┘ └──────────────┘
TypeScript - Level 1 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,
    };
  }
}

Level 2:不同数据库,事件同步

这是更完整的 CQRS——写侧和读侧使用完全不同的数据存储。写侧写入后,通过事件/消息通知读侧更新。这里引入了最终一致性

┌──────────┐ ┌─────────────┐ ┌──────────┐ ┌────────────────┐ ┌──────────────┐
│ Client │──→│ Command │──→│PostgreSQL│──→│ Event Bus │──→│Elasticsearch
│ (Write) │ │ Handler │ │(Write DB)│ │ (Kafka/RabbitMQ)│ │ (Read DB) │
└──────────┘ └─────────────┘ └──────────┘ └────────────────┘ └──────────────┘
↑ 更新读模型
┌──────────┐ ┌─────────────┐ ┌──────────────────────────────────────────────┘
│ Client │──→│ Query │──→│ Elasticsearch / Meilisearch / ClickHouse
│ (Read) │ │ Handler │ │ (读优化:全文搜索 / 聚合分析 / 缓存)
└──────────┘ └─────────────┘ └──────────────────────────────────────────────
TypeScript - Level 2 CQRS with Event Sync
// 写侧: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);
  }
}

Level 3:Event Sourcing + CQRS

最高级形式——不存储当前状态,存储所有事件。读模型完全由事件投影(Projection)构建。这是 Greg Young 最初设想的架构,也是最复杂的。

🔗 与 Event Sourcing 的关系:CQRS 和 Event Sourcing 经常一起出现,但它们是独立模式。CQRS 可以不用 Event Sourcing(Level 1/2),Event Sourcing 也可以不用 CQRS(虽然很少见)。它们组合在一起是因为 ES 天然产生事件流,CQRS 需要事件流来同步读写模型。

┌──────────┐ ┌──────────┐ ┌──────────────┐
│ Command │──→│ Aggregate│──→│ Event Store │ ← 不存状态,只存事件
│ │ │ (领域对象)│ │ (KurrentDB / │
└──────────┘ └──────────┘ │ PostgreSQL) │
└───────┬───────┘
│ 事件流
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
Projection 1│ │ Projection 2│ │ Projection 3
│ (订单列表) │ │ (库存视图) │ │ (财务报表) │
│ Elasticsearch│ │ Redis │ │ ClickHouse │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
▼ ▼ ▼
┌──────────────────────────────────────────────────┐
Query Handlers (读模型) │
└──────────────────────────────────────────────────┘

🛒 SaaS 中的 CQRS 实战

典型架构:PostgreSQL + Meilisearch + ClickHouse

这是独立开发者 / 小团队最常见的 CQRS 实现方式。不是教科书级的完美架构,而是实际可落地的方案。

写侧
用户操作 → API Server → PostgreSQL (事务、关系、强一致)
├──→ Meilisearch (全文搜索、即时搜索)
│ 同步方式:webhook / listener / 定时轮询
└──→ ClickHouse / TimescaleDB (分析、报表)
同步方式:Kafka / Kafka Connect / Vector

四种同步策略对比

策略延迟复杂度一致性保证适合场景
定时轮询 秒~分钟级 最终一致 MVP / 小流量
PG LISTEN/NOTIFY 毫秒级 可能丢事件 实时性要求高但可容忍少量丢失
Outbox Pattern 秒级 至少一次投递 生产环境推荐
CDC (Debezium) 亚秒级 精确一次(with Kafka) 大规模 / 企业级

PostgreSQL LISTEN/NOTIFY

最轻量的实时同步方式。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 Pattern(推荐)

在同一个数据库事务中,同时写入业务数据和"发件箱"事件。然后由一个单独的进程将事件推送到消息队列。保证事件不会丢失。

// 写侧:事务内同时写 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 同步的最佳实践。

CDC (Change Data Capture) — Debezium

通过读取 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

Supabase 是独立开发者最爱的后端平台。虽然它不标榜 CQRS,但其架构隐式地使用了 CQRS 模式:

📝 写侧

PostgreSQL 事务处理所有写入操作。Row Level Security 保证数据安全。

🔍 读侧 1

PostgREST 提供直接查询(CRUD 的读)。Realtime 通过 WAL 监听变更推送 WebSocket。

🔍 读侧 2

pgvector 做向量搜索——这本质上是另一个读模型,优化语义搜索。

🤖 Agent 系统中的 CQRS

AI Agent 的记忆管理天然适合 CQRS——"写入记忆"和"检索记忆"的需求完全不同。

Agent 记忆的读写不对称

维度写侧(记忆写入)读侧(记忆检索)
数据结构 事件流(消息、工具调用、思考) 向量嵌入 + 摘要 + 关键词索引
写入频率 每次交互都写入 每次生成前都读取
一致性要求 必须持久化(不能丢对话) 可以容忍轻微延迟
优化方向 高吞吐写入、事务安全 低延迟搜索、语义相关性
存储选择 PostgreSQL / Event Store pgvector / Pinecone / Meilisearch
典型操作 appendMessage()、logToolCall() searchMemory()、getContext()
TypeScript - Agent 记忆 CQRS 实现
// === 写侧: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 的 CQRS 启示

LangGraph 是目前最流行的 Agent 框架之一。它的 Checkpoint 机制就是 CQRS + Event Sourcing 的变体:

⏱️ 最终一致性:你无法回避的代价

CQRS 不同数据库的核心代价是最终一致性——写入后,读模型可能不是立即更新的。你需要理解这个延迟窗口。

一致性延迟模拟器

🔬 调整参数观察最终一致性延迟

写入速率(事件/秒):

当前:100 事件/秒

同步策略延迟:

读侧索引延迟:

60ms
端到端延迟
0.6%
读请求命中过期数据概率
6
延迟窗口内的事件数

缓解最终一致性的 4 种策略

1️⃣ 写后读走写库

用户刚提交的变更,紧接着的查询直接走写侧数据库(强一致)。只有其他用户的查询走读库。

2️⃣ 乐观 UI 更新

写入成功后,前端立即更新本地状态,不等读模型同步。用户看到的是"即时"反馈。

3️⃣ 版本号比对

写侧返回版本号,读侧查询时携带版本号。如果版本不匹配,前端知道数据可能过期。

4️⃣ SSE/WebSocket 推送

写侧变更后,通过 WebSocket/SSE 通知前端。前端收到通知后重新查询读模型。

⚠️ 常见陷阱

Martin Fowler 说大多数 CQRS 实施是失败的。以下是典型陷阱:

❌ 到处用 CQRS

CQRS 应该只在特定 Bounded Context 中使用,不是整个系统的架构。用户管理模块用 CRUD 完全没问题。

❌ 没有 Event Sourcing 却做了独立读库

如果你不需要事件溯源,也不需要独立的查询优化,单纯分离读写只是增加了复杂度。Reporting Database(报表库)是更轻量的替代。

❌ 忽视最终一致性

用户提交订单后立即查看,发现状态还是"未支付"——因为他们命中了还没同步的读模型。必须处理这个场景。

❌ 同步层太复杂

Outbox → Kafka → Projector → 读库,这个链路很长。任何一个环节出问题,读模型就会不一致。监控和告警必不可少。

❌ 过早引入

"我们未来可能需要 CQRS" → 现在不需要就不要做。先 CRUD,遇到瓶颈再重构。YAGNI(You Ain't Gonna Need It)。

❌ 读模型太多

每种查询都建一个 Projection?维护成本爆炸。大多数场景 2-3 个读模型就够了(搜索、缓存、分析)。

CQRS 决策树

🌲 回答问题,决定是否用 CQRS

🔧 框架和工具

工具语言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。

Python CQRS 最小实现

Python - 最小 CQRS 框架
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

🔗 CQRS 与 Event Sourcing 的关系

这两个模式经常被一起提及,但它们是独立的。让我们精确理清关系。

┌─────────────────────────────────────────────────────┐
│ 所有可能的组合 │
├─────────────────────────────────────────────────────┤
│ 1. CRUD only — 最常见,80%+ 的系统 │
│ 2. CQRS only — 不同模型,但存状态不存事件 │
│ 3. ES only — 很罕见,通常最终走向 CQRS │
│ 4. CQRS + ES — 完整形态,最强大也最复杂 │
└─────────────────────────────────────────────────────┘
组合写侧存储读侧存储一致性典型场景
CRUD 当前状态 (PostgreSQL) 同一张表 强一致 博客、CMS、管理后台
CQRS only 当前状态 (PostgreSQL) 搜索/缓存 (ES/Redis) 最终一致 电商、SaaS 仪表盘
CQRS + ES 事件流 (EventStoreDB/PG) Projection (ES/Redis/CH) 最终一致 金融、审计、AI Agent
📖 为什么 Event Sourcing 通常搭配 CQRS?

Event Sourcing 不存当前状态,只存事件。如果你要查询"用户 A 的所有订单",你需要重放所有事件来计算——这显然太慢了。

所以 ES 天然需要一个"读模型投影"(Projection)——把事件流实时转换为可查询的数据结构。这个 Projection 本质上就是 CQRS 的读侧。

反过来,CQRS 不需要 ES。你可以用传统方式存状态(PostgreSQL),同时为搜索建一个 Elasticsearch 索引——这是 Level 2 CQRS。

关键洞察:ES → CQRS 是单向推动的。ES 几乎强制要求 CQRS,但 CQRS 可以独立存在。

📖 什么情况只用 CQRS 不用 ES?

大多数实际场景!

  • 电商:订单写入 PostgreSQL,商品搜索走 Elasticsearch,销量排行走 Redis。不需要事件溯源。
  • SaaS:用户数据写入 PostgreSQL,仪表盘数据走 ClickHouse,全文搜索走 Meilisearch。
  • 内容平台:文章写入 PostgreSQL,搜索走 Algolia,推荐走特征向量。

这些场景的共同特点:写侧有状态(不需要重放事件),读侧需要不同优化。CQRS enough。

✅ CQRS 实施 Checklist

如果你决定引入 CQRS,按这个清单逐项检查。

📋 逐步检查

完成度: 0%

📝 总结

✅ 适合用 CQRS

• 读写比 > 10:1
• 写侧有复杂业务规则
• 读侧需要不同存储优化
• 你能接受最终一致性
• 团队有分布式系统经验

❌ 不适合用 CQRS

• 简单 CRUD 就够的场景
• 强一致性是硬需求
• 团队小、迭代快
• "未来可能需要"(YAGNI)
• 读侧和写侧模型高度重合

💡 实用建议

• 先 CRUD,遇到瓶颈再重构
• 只在需要的 Bounded Context 用
• 从 Level 1 开始,逐步升级
• Outbox Pattern 是同步的最佳实践
• 永远保留"切回 CRUD"的能力

🎯 一句话总结:CQRS 不是银弹,它是"当你真的需要时"的架构工具。大多数系统用 CRUD + Reporting DB 就够了。当读写差异大到共享模型成为瓶颈时,CQRS 才值得付出复杂度的代价。

📚 延伸阅读

📘 核心文章

Martin Fowler - CQRS(定义性文章)

Microsoft Azure - CQRS Pattern

Martin Fowler - Reporting Database(轻量替代)

📗 书籍

Domain-Driven Design — Eric Evans(CQRS 的理论基础)

Implementing Domain-Driven Design — Vaughn Vernon(实战指南)

Building Event-Driven Microservices — Adam Bellemare

📙 本站相关

📚 Event Sourcing — CQRS 的最佳搭档

🤝 共识算法 — 分布式一致性的基础

🔄 CRDT — 另一种处理读写分离的方式