[译] CQRS Without The Complexity

news/2025/11/9 20:07:11/文章来源:https://www.cnblogs.com/talentzemin/p/19205171

原文链接: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 新手,好消息是:你不需要一次性构建所有东西。

从小处着手:

  1. 找出一个“读取”和“写入”需求不同的领域。
  2. 将意图表达为命令(Commands)。
  3. 将发生的事情记录为事件(Events)。
  4. 为你最常见的查询构建一个简单的投影(Projection)。

你最初不需要分离的数据库,也不需要复杂的基础设施。你可以先在你现有的架构中,将 CQRS 作为一种模式开始实践。

如果你在 JVM 上工作,像 OpenCQRS 这样的框架可以帮你快速起步。它们为命令、事件和投影提供了构建模块,这样你就不必从头实现所有东西。

随着你需求的增长,你可以:

  • 添加更多的投影
  • 为你的一部分或全部领域引入事件溯源
  • 独立扩展读取和写入端
  • 使用像 EventSourcingDB 这样的专业数据库来更好地管理事件流

综上所述

CQRS 的根本在于认识到“做某事”和“查询某事”是两种具有不同需求的操作。通过将它们分开处理:

  • 你的写入端保持对业务逻辑和维护一致性的关注
  • 你的读取端保持对高效服务数据的关注
  • 两者都可以独立演进
  • 你的系统变得更加灵活和可扩展

事件溯源(Event Sourcing)通过将事件作为你的“真实来源”,完美地补充了这一点。你存储的不再只是当前状态,而是完整的历史,这自然地通过投影为多个专门的读取模型提供了数据。

这可能看起来比传统的 CRUD 应用程序更复杂,但它实际上只是将优秀系统默认在做的事情明确化了:分离关注点,维护历史,以及为不同操作分别优化。

图书馆不会用同一套系统来采购书籍和帮助人们查找书籍。你的应用程序也不应该。

从简单开始,保持务实,让这个模式在能提供价值的地方自然浮现。这就是卸下了复杂度的 CQRS。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/960700.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Jetson AGX Thor —— 英伟达NVIDIA Jetson AGX Thor 128G开发者套件 AI智能 T5000模组

Jetson AGX Thor —— 英伟达NVIDIA Jetson AGX Thor 128G开发者套件 AI智能 T5000模组本博客是博主个人学习时的一些记录,不保证是为原创,个别文章加入了转载的源地址,还有个别文章是汇总网上多份资料所成,在这之…

科技赋能塞上农业:宁夏从黄土地到绿硅谷的蝶变 - 详解

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

Python 循环引用怎么破?用 weakref 轻松解决 GC 回收难题

Python 循环引用怎么破?用 weakref 轻松解决 GC 回收难题 如果你开发过链表、树、图等数据结构,大概率会遇到一个棘手问题:明明用del删除了所有对象的引用,内存却依然居高不下 —— 这就是 “循环引用” 导致的 GC…

实用指南:Starlake:一款免费开源的ETL数据管道工具

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

[LangChain] 16. 检索优化

RAG 标准流程:索引:外挂知识库 检索 生成Advanced RAG 针对上述 3 个阶段做了优化。例如检索阶段,新增了 检索前处理 以及 检索后处理。 检索前处理:查询转换 查询扩充 ......查询扩充(Query Expansion) 在不改变…

那坍塌废墟 铺满尘垢 回忆中 谁仍昂着头 谁撕开 簇拥的伤口 搅动一汪 腐烂的血肉

test35 7-A 火车站 (train.cpp) 首先对二元组的入栈时间排序出一个出栈时间序列,你就是想要掰成尽可能少的单调下降子序列,如果你有直接并且极度自信你可以直接输出最长上升子序列的长度。考虑这个怎么连接起来,你顺…

详细介绍:Excel如何排序?【图文详解】Excel表格排序?Excel自动排序?

详细介绍:Excel如何排序?【图文详解】Excel表格排序?Excel自动排序?2025-11-09 19:59 tlnshuju 阅读(0) 评论(0) 收藏 举报pre { white-space: pre !important; word-wrap: normal !important; overflow-x: au…

Python实践指南:del与__del__的正确用法,避坑指南

Python实践指南:del与__del__的正确用法,避坑指南 del与与和__del__是最容易被误用的特性之一——有人把del当成“删除对象的命令”,有人把__del__当成“内存释放的工具”,结果写出漏洞百出的代码:文件关不掉、数…

摸鱼笔记[4]-电脑桌面常用软件简介

简要简介一下工作电脑桌面常用的那些软件(以防以后需要恢复🤔).摘要 简要简介一下工作电脑桌面常用的那些软件(以防以后需要恢复🤔). 列举 PS C:\Users\25578\Desktop> tree.com /f 卷 OS 的文件夹 PATH 列表 卷…

P10627 [JOI Open 2024] 中暑 / Heat Stroke

P10627 [JOI Open 2024] 中暑 / Heat Stroke P10627 [JOI Open 2024] 中暑 / Heat Stroke - 洛谷 (luogu.com.cn) Solution 限制:在一个人坐飞机之前,两边的医院必须住满。 先考虑 Sub4,每条路上只有前两个人有用,…

从监听风险到绝对隐私:Zoom偷听门后,Briefing+CPolar重新定义远程会议安全标准 - 教程

从监听风险到绝对隐私:Zoom偷听门后,Briefing+CPolar重新定义远程会议安全标准 - 教程pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important…

【做题记录】多校-ds

C. [Ynoi2005] rmscne 直接做不好维护,考虑扫描线。用线段树对每个位置 \(i\) 维护 \(p_i\) 表示 \([i,p_i]\) 是 \([i,r]\) 的最小的合法子区间。维护方式很简单,当加入 \(a_r\) 时,设上一次出现的位置为 \(j\),则…

11-08 题

11-08 题 目录11-08 题P5405 [CTS2019] 氪金手游 - 洛谷AT_agc036_f [AGC036F] Square Constraints - 洛谷F - Almost Sorted 2G - One Time Swap 2P13004 [GCJ 2022 Finals] Schrdinger and Pavlov - 洛谷Problem - 1…

POSIX兼容系统上read和write系统调用的行为总结

关于UNIX和Linux的宣传语中,一切皆文件应该是最广为人知的一句。 不管是普通文件,还是硬件设备、管道、网络套接字,在Linux甚至还有信号和定时器都共享一套相似的api,大家可以用类似的代码完成各种不同的任务,大大…

AI也能管文件?RustFS+Claude实现智能存储自动化!

AI也能管文件?RustFS+Claude实现智能存储自动化!2025年,当Claude 4.5宣布可​连续编程30小时不"断片" ​,而RustFS凭借零GC设计将存储性能提升42% 时,我们终于意识到:AI管理存储系统的时代已经到来。一…

跟着小码学算法Day16:对称二叉树 - 指南

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

摸鱼笔记[3]-给windows添加类似macOS的按空格预览

🌟 神器「QuickLook」让 Windows 也能像 macOS 一样,空格键一按👇 ✨ 视频 / 图片 / PDF / PSD / 压缩包…统统秒开!零加载零等待~摘要 🌟 神器「QuickLook」让 Windows 也能像 macOS 一样,空格键一按👇…

11.8 联考总结

11.8 联考总结 T1题意较复杂,读懂之后比较简单。发现T3是计数题,很好。先开 T3 ,比想象中简单,思路非常自然。回去看T2发现有简单 \(O(n\log n)\) 做法,84分!但我想得非常复杂,但还有更简单的 \(O(n^2)\),52分…

Spring BeanDefinition接口

[[Spring IOC 源码学习总笔记]] BeanDefinition BeanDefinition 主要是用来描述Bean,主要包括:Bean的属性、是否单例、延迟加载、Bean的名称、构造方法等。 简而言之: 在容器中的 一个Bean 对应一个 BeanDefinition…

pythontip 计算字符串中的音节数

编写一个程序来计算一个单词中的音节数。 音节之间由连字符分隔。例如: beau-ti-ful => 三个音节 cat => 一个音节 re-frig-er-a-tor => 五个音节 定义函数count_syllables()的函数,该函数接受一个参数word…