原文链接:https://docs.eventsourcingdb.io/blog/2025/11/06/cqrs-without-the-complexity/
想象一下,你正站在本地图书馆的柜台前。你想借一本书——就比如《2001:太空奥德赛》。你告诉图书管理员,他们检查书是否在馆,在你的借书卡上盖章,然后把书递给你。很简单,对吧?
但等等。在你借到这本书之前,必须先发生别的事情:图书馆需要先“购得”这本书。必须有人下单、接收、编目,然后把它放到书架上。而在这一切发生之前,图书馆本身必须作为一个拥有系统和流程的机构而存在。
这个看似简单的互动——借书——揭示了系统运作的根本性问题。“做事”(购入书籍、借出书籍)和 “知事”(哪些书在馆、谁借了什么)之间有着明显的区别。这种分离正是 CQRS 的核心。
命令 (Command) 的旅程
让我们从最开始说起。我们的图书馆需要购入一本新书。某人——也许是图书管理员或采购部门——决定将《2001:太空奥德赛》加入馆藏。
在 CQRS 系统中,这个意图被表达为一个命令 (Command):
class AcquireBookCommand {constructor(public data: {bookId: string;title: string;author: string;isbn: string;acquiredBy: string;}) {}
}const command = new AcquireBookCommand({bookId: '/books/42',title: '2001 – A Space Odyssey',author: 'Arthur C. Clarke',isbn: '978-0756906788',acquiredBy: '/librarian/jane'
});
命令(Command)只是一个“做某事”的请求。它是祈使性的——它表达了改变系统状态的意图。当这个命令到达时,业务逻辑开始介入。我们应该购入这本书吗?我们是不是已经有这本书了?ISBN 是否有效?
如果一切都检查通过,一件重要的事情发生了:系统不只是更新数据库记录。相反,它将发生的事情记录为一个事实——一个事件 (Event):
class BookAcquiredEvent {constructor(public data: {bookId: string;title: string;author: string;isbn: string;acquiredAt: Date;acquiredBy: string;}) {}
}const event = new BookAcquiredEvent({bookId: '/books/42',title: '2001 – A Space Odyssey',author: 'Arthur C. Clarke',isbn: '978-0756906788',acquiredAt: new Date('2025-02-17T13:37:00Z'),acquiredBy: '/librarian/jane'
});
注意这里的过去时态:BookAcquired (已购入),而不是 AcquireBook (去购入)。事件是一个历史事实。它代表已经发生且无法改变的事情。
这就是 CQRS 中的“C”——命令(Command)端。命令表达我们想做什么,业务逻辑决定是否允许,而事件记录实际发生了什么。但 CQRS 还有另一面,我们稍后会探讨:用于读取数据的查询(Query)端。
现在我们快进一下。几天后,你走进图书馆,想借阅那本书:
class BorrowBookCommand {constructor(public data: {bookId: string;borrowedBy: string;}) {}
}const command = new BorrowBookCommand({bookId: '/books/42',borrowedBy: '/readers/23',
});
业务逻辑再次运行:这本书在馆吗?会员卡有效吗?如果“是”,另一个事件被记录下来:
class BookBorrowedEvent {constructor(public data: {bookId: string;borrowedBy: string;borrowedAt: Date;dueDate: Date;}) {}
}const event = new BookBorrowedEvent({bookId: '/books/42',borrowedBy: '/readers/23',borrowedAt: new Date('2025-08-12T10:23:00Z'),dueDate: new Date('2025-09-27T23:59:59Z')
});
主题 (Subjects):让事务井井有条
你可能想知道:系统如何知道我们谈论的是哪本书?我们如何区分《2001:太空奥德赛》的不同副本,或完全不同的书?
这就是主题 (Subject)(或称流 Stream)概念的用武之地。我们图书馆中的每本书都有其唯一的标识符——/books/42、/books/17 等等。与特定书籍相关的所有事件都存储在一起,与该书的主题相关联。
把它想象成每本书各自的时间线。书 /books/42 有它自己的历史:何时被购入、何时被借阅、何时被归还。书 /books/1T 则有完全不同的历史。通过这种方式组织事件,系统总能通过重放其事件来重建任何特定书籍的当前状态。
这种斜杠表示法 (/books/42) 创建了一种层次结构。你甚至可以拥有像 /books/42/pages/15 这样的子流(sub-stream),如果你需要更细粒度地跟踪事件(尽管很少有必要)。关键在于,主题提供了一种将相关事件清晰组合在一起的方法。
如果你熟悉领域驱动设计(DDD),你可能会认出这类似于“聚合”(Aggregate)的概念,但我们在这里不需要深入探讨那么复杂的东西。
这些事件去哪儿了?
现在我们有命令(Commands)进来,事件(Events)出去。但这些事件到底存在哪里?
这里有件重要的事情:CQRS 本身并不规定你如何存储数据。理论上,你可以将这些事件存储在关系型数据库、文档存储,甚至是平面文件中。CQRS 是一种关于将“改变状态”与“读取状态”的责任分开的模式——它与底层存储机制无关。
然而,有一种存储方法与 CQRS 搭配起来异常地自然:事件溯源 (Event Sourcing)。
事件溯源:天作之合
事件溯源意味着将事件作为你唯一的真实来源 (source of truth) 进行存储。你不再只保留一本书的“当前状态”(比如“目前被 /readers/23 借阅中”),而是保留发生在这本书上所有事情的完整历史。
/books/42 的事件流可能看起来像这样:
[new BookAcquiredEvent({bookId: '/books/42',acquiredAt: new Date('2025-02-17T13:37:00Z'), ...}),new BookBorrowedEvent({bookId: '/books/42',borrowedBy: '/readers/23',borrowedAt: new Date('2025-08-12T10:23:00Z'), ...}),new BookReturnedEvent({bookId: '/books/42',returnedBy: '/readers/23',returnedAt: new Date('2025-09-27T21:12:00Z'), ...}),new BookBorrowedEvent({bookId: '/books/42',borrowedBy: '/readers/89',borrowedAt: new Date('2025-10-06T04:20:00Z'), ...})
]
想知道书的当前状态?重放它的所有事件。
想知道它的完整历史?你已经有了。
想回答“这本书在去年被借了多少次?”这样的问题?数据就在那里。
这就是专业数据库发挥作用的地方。传统数据库是为存储“当前状态”而构建的。而事件溯源数据库——比如 EventSourcingDB(你正在其文档站上阅读这篇博客!)——则是专门为高效存储和查询事件流而构建的。它们理解主题、事件顺序以及事件溯源系统的独特需求。
读取端:投影 (Projections) 和读取模型 (Read Models)
现在我们来看看 CQRS 中的“Q”:查询(Queries)。
想想人们想从我们的图书馆了解些什么:
- 哪些书目前可以借?
- 这个月最热门的书是哪本?
/readers/23目前借了多少本书?- 平均借阅时长是多久?
这些都是读取操作,它们与命令(Commands)有着截然不同的特征:
- 它们不改变任何东西
- 它们需要快速
- 它们通常需要与“写入”时完全不同的数据结构
- 不同的用户需要同一数据的不同视图
这就是投影 (Projections) 登场的地方。投影获取事件流,并构建一个专门的读取模型 (Read Model),为特定查询进行优化。
最美妙的部分在于:从一个事件流,你可以构建出许多不同的读取模型。
示例 1:在馆可借书籍
// 投影:构建一个可借书籍列表
function projectAvailableBooks(events) {const books = {}for (const event of events) {switch (event.constructor) {case BookAcquiredEvent:books[event.data.bookId] = {title: event.data.title,isbn: event.data.isbn,available: true}breakcase BookBorrowedEvent:books[event.data.bookId].available = falsebreakcase BookReturnedEvent:books[event.data.bookId].available = truebreak}}return Object.values(books).filter(book => book.available)
}
这个读取模型非常适合回答“我现在能借什么?”
示例 2:会员统计
但是,也许我们想要不同的信息。让我们为会员统计构建一个读取模型:
// 投影:构建会员借阅统计
function projectMemberStats(events) {const stats = {}for (const event of events) {switch (event.constructor) {case BookBorrowedEvent:if (!stats[event.data.borrowedBy]) {stats[event.data.borrowedBy] = { totalBorrowed: 0, currentlyBorrowed: 0 }}stats[event.data.borrowedBy].totalBorrowed++stats[event.data.borrowedBy].currentlyBorrowed++breakcase BookReturnedEvent:stats[event.data.returnedBy].currentlyBorrowed--break}}return stats
}
同样的事件,完全不同的视图。这就是投影的力量。
示例 3:搜索索引
你可以构建另一个投影,将其数据导入全文搜索引擎,允许会员按书名、作者或主题搜索书籍。或者一个投影,根据借阅频率计算热门书籍。或者一个为图书管理员仪表盘跟踪逾期书籍的投影。
每个读取模型都是:
- 为特定查询而生:专门用于回答特定的问题。
- 独立优化:你可以把一个模型存在 PostgreSQL,另一个存在 Elasticsearch,再一个存在 Redis。
- 源自唯一的真实来源:事件流。
当一个新事件被写入时,所有相关的投影都可以被更新。这通常是异步完成的——命令端不会等待所有读取模型都更新完毕才确认成功。
为什么要分离命令和查询?
看到这里,你可能会问:为什么要费这么大劲?为什么不像传统方式那样,只用一个数据库,包含书籍表和借阅表呢?
答案在于,我们必须认识到“写入”和“读取”有着根本上不同的需求:
写入 (命令)
- 执行业务规则和不变量
- 验证操作是否被允许
- 保护数据完整性
- 通常处理较低的流量
- 需要强一致性
读取 (查询)
- 为不同的访问模式优化
- 通常处理高得多的流量
- 需要快速响应
- 可以容忍轻微的延迟
- 需要不同的索引和数据结构
通过分离这些关注点,你将获得:
- 灵活性 (Flexibility):想添加一个新的视图?构建一个新的投影。无需更改你的写入模型或添加复杂的 JOIN。
- 可伸缩性 (Scalability):读取和写入端可以独立扩展。大多数系统都是读多写少——现在你可以据此进行优化。
- 简单性 (Simplicity):每一端都可以为其目的而变得尽可能简单。你的写入端纯粹关注业务逻辑。你的读取端纯粹关注查询性能。
- 演进性 (Evolution):需要更改数据显示方式?更新一个投影。你的历史事件保持不变,你可以随时重建读取模型。
关于“最终一致性”的一点说明
这里有一件重要的事情需要理解:在 CQRS 系统中,从命令成功到读取模型反映这一变化之间,通常会有一个微小的延迟。这被称为最终一致性 (Eventual Consistency)。
当你借一本书时,BookBorrowedEvent 被立即写入,命令返回成功。但是更新所有读取模型——可借书籍列表、你的会员统计、搜索索引——是异步发生的,可能在毫秒或几秒钟之后。
这是个问题吗?在实践中,几乎从来不是。
想想现实世界的图书馆:当你借走一本书时,它在系统中显示为“不可借”也会有延迟。其他人可能会在几秒钟内仍看到它是“可借”的。但这不会破坏任何东西——如果他们试图借阅它,命令将会失败,因为业务逻辑会检查实际的状态。
传统系统也存在同样的问题。你的浏览器显示的是缓存数据。数据库副本会滞后于主库。延迟总是存在的。CQRS 只是将其明确化,并学着去利用它,而不是假装它不存在。
在大多数用户界面中,用户无论如何都会预期有轻微的延迟。当你提交表单时,你会看到一个加载指示器。等到下一页加载时,你的读取模型早就更新了。对于那些你真正需要即时一致性的极少数情况(比如在操作后立即显示“你已成功借阅此书!”),可以由 UI 直接处理,而无需等待投影更新。
开始:保持简单
如果你是 CQRS 新手,好消息是:你不需要一次性构建所有东西。
从小处着手:
- 找出一个“读取”和“写入”需求不同的领域。
- 将意图表达为命令(Commands)。
- 将发生的事情记录为事件(Events)。
- 为你最常见的查询构建一个简单的投影(Projection)。
你最初不需要分离的数据库,也不需要复杂的基础设施。你可以先在你现有的架构中,将 CQRS 作为一种模式开始实践。
如果你在 JVM 上工作,像 OpenCQRS 这样的框架可以帮你快速起步。它们为命令、事件和投影提供了构建模块,这样你就不必从头实现所有东西。
随着你需求的增长,你可以:
- 添加更多的投影
- 为你的一部分或全部领域引入事件溯源
- 独立扩展读取和写入端
- 使用像 EventSourcingDB 这样的专业数据库来更好地管理事件流
综上所述
CQRS 的根本在于认识到“做某事”和“查询某事”是两种具有不同需求的操作。通过将它们分开处理:
- 你的写入端保持对业务逻辑和维护一致性的关注
- 你的读取端保持对高效服务数据的关注
- 两者都可以独立演进
- 你的系统变得更加灵活和可扩展
事件溯源(Event Sourcing)通过将事件作为你的“真实来源”,完美地补充了这一点。你存储的不再只是当前状态,而是完整的历史,这自然地通过投影为多个专门的读取模型提供了数据。
这可能看起来比传统的 CRUD 应用程序更复杂,但它实际上只是将优秀系统默认在做的事情明确化了:分离关注点,维护历史,以及为不同操作分别优化。
图书馆不会用同一套系统来采购书籍和帮助人们查找书籍。你的应用程序也不应该。
从简单开始,保持务实,让这个模式在能提供价值的地方自然浮现。这就是卸下了复杂度的 CQRS。