2016 年 7 月

第 31 卷,第 7 期

CQRS - 利用 CQRS 来创建高速响应系统

作者 Peter Vogel | 2016 年 7 月

命令查询职责分离 (CQRS) 模式在过去的三四年间开始普及。当然,在通过多个进程更新数据集的协作场景中,这是一种重要的工具(Dino Esposito 在 2015 年 6 月的“前沿技术”专栏,即在 bit.ly/1OtQba3 上的“适用于通用应用程序的 CQRS”中,构建了一个更加广泛地使用 CQRS 的案例)。我将进一步深化,并阐述:事实上,对于要查询数据以将其展示在自己的视图中,然后在相应的数据发送回 MVC 控制器中后发出表格更新命令的 ASP.NET MVC 开发人员而言,CQRS 是默认设计模式。

但是,CQRS 是一种应该作为更大策略的一部分来应用的技术。大策略中的第一个步骤是领域驱动设计 (DDD)。Julie Lerman 已在 2013 年 6 月的专栏,即 bit.ly/1TfF7dk 上的“使用 DDD 界定的上下文收缩 EF 模型”中介绍了 DDD。DDD 将你的应用程序分解为多个协作的领域,而每个领域甚至可能拥有自己的数据库,当然也有自己专用的业务模型。DDD 提供策略和技术,使各个领域在互相协作的同时仍能彼此独立发展。

定义库存领域

但是 DDD 只是消除潜在的协作需求。例如,试想一下,如果有一个在线销售应用程序。此时,存在一条重要的共享数据:库存水平。若要保证企业不会销售自己没有的东西,企业可以对各个“库存单位”(SKU) 的“现存数量”(QoH) 进行准确的库存统计或者总是留有“以防万一”的额外库存现货。在如今追求“精简”的时代,第二种选项不予考虑: 公司不想保留超过其必需的库存之外的部分。

根据业务交易更新 QoH 比你想象的更为复杂,因为实际库存系统会处理各种各样的事务。明显的事务,当然是在已销售某个 SKU 时减少 QoH,而在接收新 SKU 时增加 QoH。此外,公司将定期进行“库存统计”,以确定每个 SKU 的实际 QoH。由于即使在管理良好的系统中,库存准确率也达不到 100%,因此该统计将需要对库存水平进行更改。另外,有时会发现 SKU 在某些方面存在瑕疵而将其从库存中删除。有时,已销售某个 SKU 并将其从库存中删除后,客户取消了订单,因此该 SKU 又重新回到货架。

对于这些不同的交易,公司希望全部予以追踪,来详细记录每笔交易的详细信息。例如,在接收新 SKU 后,公司希望了解客户在购买 SKU 时使用了何种发票;发现 SKU 存在瑕疵后,公司希望了解原因;清点存货期间,公司希望知道出入有多大。会计部门需要这些信息来准确报告“公司状态”;运营部门需要这些信息来准确规划未来。由于存在对这种额外信息的需求,这些事务不能仅视为是对库存的删减。

但是,并非所有这些事务都属于同一个领域。这些事务会划分为多个领域,如“销售”、“会计”、“运营”、“接收”等等。将事务划分为多个领域反映了以下事实:不同领域有不同的需求。

例如,大部分领域,不需要实时数据,甚至他们运行的数据晚于实际交易一个工作日,也没有关系。例如,会计部门只需要在月底了解库存的财务状况,甚至他们可能预计在下月初才能收到上述信息。尽管保证库存数据更实时地更新不是不可能,但很难找到这样做的商业动机。这些部门的库存信息可以“最终一致”。

但销售系统不能具有“最终一致”的库存信息。销售部门需要知道目前 QoH 如何,这样才能决定是否将某个 SKU 展示给客户(例如“仅剩两套! 立即订购!”)。事实上,尽管多数领域只有一个数字来代表每个 SKU 的 QoH,销售系统却可能将 QoH 保存为两个数字。一个数字代表“预留”数量(即正在创建订单的用户要求的 SKU),另一个数字代表“仍可出售”的数量。 如果客户购买了两件商品,则预留数量增加二,而仍可出售数目则减少二;在交易的最后阶段,预留数量会减少二;如果用户取消了订单,仍可出售数目会重新增加到以前。

会计和运营部门都需要一个关系数据库能够灵活地将不同表格以各种方式关联在一起。他们还需要可以搜索相关数据的功能,有时需要在发现特定问题后,以特定的方式进行搜索。鉴于涉及的数据数量和研究历史交易记录的需要,还需要对数据进行分页。

销售系统对灵活性的要求没有这么高。实体间的关系将由 UI 的设计固定,搜索要求亦是如此(但仍需要分页支持)。

各领域对响应时间的要求也存在差异。对于大多数部门而言,响应时间用秒衡量对公司并无损害;而对于销售系统,响应时间必须用秒之下的单位衡量。

打造一个满足以上所有需求的系统很困难(我认为不可能)。而为各个领域都打造一个应用程序至少还是有可能的。例如,产品管理部门会有一个产品列表,经常会随新产品和现有产品信息而更新;另一方面,销售领域可能拥有一个只读/只可查询的产品列表,会定期随产品管理领域中的数据同步更新。

将领域视为应用于企业级别的单一责任原则。每个领域处理好业务的一部分。尽管企业很复杂,但每个领域可以相对简单。

CQRS 解决方案

而所有的这些领域仍在共享库存水平。当事务经过各领域时,例如经过会计部门和接收部门,相关部门则必须通知销售系统对库存水平的更改。甚至在销售系统内部,多个客户可能正在尝试购买相同的 SKU,每个都会导致存货水平上下浮动,这就需要在调整这些数目时,对存货水平进行一定程度的锁定。

CQRS 模式此时则因为其超乎普通 ASP.NET MVC 开发人员想象的功能而发挥作用。例如,在大部分领域中,应用程序可以查询自己的数据库,其中包含该领域所需要的信息。一旦发出命令来调整库存水平,所有的领域都必须更新在线销售领域的数据。相关的责任义务按以下两种方式承担: 商品卖出后,销售系统必须通知会计、运营和其他领域各个 SKU 销售带来的相应的 QoH 更改。

各个领域仅负责将其他领域关注的内容(本案例中,则是 QoH)通知相应领域,而无需更新其他领域的数据。各个领域必须负责更新自己的数据,因为各个领域了解自己数据的管理方式而其他领域不知道。

例如,运营领域经常利用自己数据中的关系来预测库存需求并用其来确定促使存货水平波动的因素。该领域必须能够灵活地查询传统关系数据库提供的数据。运营领域的复杂性由该领域需要的分析类型驱动。

而另一方面,销售领域需要的则是一些更简单的内容。销售领域需要知道各个 SKU 的 QoH(预留和可供销售)如何。甚至对销售系统而言,仅时常存储各个 SKU 的 ID 和两种 QoH 数目也是可以的。如果由于库存商品的数量,以上做法不可行,还可以存储驱动公司 80% 的销售活动的 20% 的库存。其他的库存商品可能保存在某个用于支持销售事务但无需提供如运营领域要求的灵活性的 NoSQL 数据库中。销售领域的复杂性是由于对快速响应时间的需求所导致。

这些差异意味着运营领域不会知道如何更新销售领域中的 QoH 数目(当然,反之亦然)。

因此,各个领域不妨查询一个数据库(自己的),同时向其他数据库发送命令(例如,每个人都向销售领域发送 QoH 更新)。DDD 提供了分离具有不同业务要求的领域的策略,而 CQRS 则提供了一种管理不同领域的更新的技巧(有关 CQRS 查询端的更深入的探讨,请参阅 Esposito 2016 年 3 月的专栏,即 bit.ly/1WzjvPi 上的“CQRS 体系结构的查询堆栈”)。

处理命令和事件

当然,你不希望使这些领域中的应用程序更为复杂,还要处理各个事务必须通知到的多个领域。各个应用程序将把事务发送到某个实用程序(通常称为“命令总线),由其来负责通知各个领域,而不用追踪必须要更新的所有领域。定义新领域(或现有领域更改需求)时,只需要在产生事务的相应领域中更新命令总线,来反映需要的新通知。

这些事务可以按类别划分为命令和事件。两者的区别更侧重概念上的而不是技术上的不同。实际上,命令和事件都是包含某项事务关键信息的消息。对于库存事务而言,关键信息可能是 SKU 的 ID、库存水平的净改变量、事务需要的其他数据(接收货物后,其他数据可能是供应商编号和发票号码;清点存货时,其他数据可能是实际统计 SKU 的员工 ID)。这些消息可能编码为 POCO 对象或 XML/JSON 文档(或者根据数据在领域间的发送方式,可以进行这两种编码)。

对于我而言,命令是为执行某项任务而指向单个接收器的东西。命令通常是需要立即执行的任务,当然,要在执行任务前发送出去。命令还可以返回成功/失败响应,应用程序可以用其来通知用户操作是否已成功(而且可能促使应用程序执行查询来检索数据,以显示最终结果)。在产生事务的各个领域中,大部分更新都可能通过命令处理。

另一方面,事件在执行任务之后发生,可以由多个接收器处理,并且通常不需要立即处理。事件不能返回结果,至少不能立即返回。如果事件出错,应用程序通常会通过一些延迟返回的消息查明相关原因(“很抱歉,由于您的信用卡被拒绝,我们无法处理您的订单”)。多数情况下,但并非所有情况下,产生事务的领域之外的更新可能由事件处理。

而且,就像大部分概念区分一样,这可能是一种统一体;一些消息“明显”是命令,一些消息“明显”是“事件”,而还有一些行家无法统一定论的消息。

某个领域中的单个事务可能同时生成命令和事件。试想一下新 SKU 在接收平台上出现时。正确接收 SKU 后,该领域的总线会向销售系统发送命令,使该 SKU 的 QoH 立即增加;总线还会发送事件来通知会计和运营系统“有事件发生”,并通知其在月底时应将相关事件考虑在内。看到涉及的各种消息,或许除非查看消息的名称,否则可能很难确定哪个是事件,哪个是命令;事件一般以过去时命名 (GoodsReceived),而命令总是以祈使语气命名 (IncreaseInventory)。

总线可能通过在相应领域中调用 RESTful 服务向销售系统发送命令以立即执行;可能将事件写入某个消息队列,以供其他领域在方便时进行处理。(我在 VisualStudioMagazine.com 上的一篇文章中(即 bit.ly/1qn1wwV 上的“通过实现领域事件的最终一致简化应用程序”)已探讨过一些选项。

当然,即使命令发送到 Web 服务,谁知道 Web 服务背后又会发生什么? 为了处理同时出现的大量请求,该领域的 Web 服务可能只将命令消息写入队列并返回一个“谢谢,收到!”的响应,以保持短暂的响应时间并提高可伸缩性。除了提高可伸缩性,将命令写入队列还使该领域可以从可能的灾难性问题中恢复。例如,如果数据库或网络瘫痪,销售系统可以耐心等待恢复服务,然后处理队列中存在的所有命令。所以,命令甚至也可能在队列上。

正如我所说,命令和事件的区别是概念上的,而非技术上的。

处理命令和事件

由于 CQRS 的出现,应用程序现在可以结合使用两种数据库:一种用于查询(可能针对领域本地),而其他数据库针对命令和事件。例如,销售系统将使用包含存储 QoH 数据的 NoSQL 数据库的数据存储;运营和会计应用程序可能使用以事件/命令的历史记录为中心组织的数据存储。

两种系统间的区别在于销售系统需要拍摄库存水平现状的快照,以满足响应时间需求;运营和会计领域需要每个 SKU 的历史记录以支持分析。运营和会计领域可以通过使用另一种技术 - 事件溯源来工作。使用事件溯源,领域逻辑已经通知过的事件的审核日志以提供最终答案(对于会计系统,可能是“根据已发出事务的历史记录,您的库存当前价值是 X 美元”)。

事件溯源有利有弊。使用事件溯源,始终可以通过重新处理事务列表来重新创建对数据当前状态的快照;会计部门高度认可此功能,因为它增加了调整事件列表的功能。使用事件溯源,还可以通过处理潜在事件(预计交付和销售)来描述未来;运营部门高度认可此功能在规划时的作用。

而事件列表增加时,响应时间也会增加。会计部门可以通过自己的“最新已知良好状态”(可能是上月底结束时的数字)反映未来,并生成代表月底数字的快照。该快照被保存为当前的“最新已知良好状态”,并作为月底报表发布。运营部门可以反映今日至未来某个不明确的时间点,但可能无法生成快照;每次必要时,他们会重新创建未来。鉴于这些领域的响应时间预期,以上可能属于合理的场景。

但是若要使用事件溯源来确定销售系统的当前 QoH,销售系统将必须查阅最新库存统计之后的所有事务。由于库存统计耗费体力,因此不会经常进行统计。这样,处理最新统计之后的所有事件将导致销售系统的响应时间难以接受。而销售系统市场会把时常更新的 QoH 数字加以保存。

总结

查询需要数据库不同级别的支持(以此生成各种索引和外键/主键),而更新不需要。实际上,所有的更新都由所涉及的实体的 ID 驱动。例如,带有 QoH 数字的库存 SKU 列表完全由 SKU 的 ID 驱动。这将极大地简化 CQRS 系统的命令端的数据模型。如果命令/事件消息仅包括所有在事务中更改的 SalesOrderItems 的 ID,实体框架生成 SalesOrder 的 SalesOrderItems 集合的功能则无关紧要。

该设计中在数据库中锁定的影响很有趣。对销售系统中的 QoH 和预留数量的更新包括更改两者中的一个或两个整数值;锁定应最小化。如果对其他系统进行了事件溯源,对这些系统的锁定可能会消失;事务总会向事件表格中插入一些事务,所以不会出现更新。

实际上,企业具有多个独立的处理器更新其领域内的数据、处理命令和事件。未经锁定,也可能会创建冲突。例如,购买两件商品的命令可能与将 QoH 减少到 0(有人做了库存统计,注意到货架上无货时)的事件同时出现。有趣的是,基于队列的事件溯源方法可能解决此问题;销售系统中的 QoH 更新处理器可以在事件溯源的基础上工作,翻阅队列中最新收到的所有命令(在一定限制范围内)并根据结果汇总来更新 QoH。同时出现的命令将纳入单个更新。或许,只是需要意识到这一点 - 偶尔也允许企业像用户一样取消订单。

CQRS 是一个强大的工具。但是,当应用于共享数据存储、协作过程和 DDD 提供的策略中时,效果才最显著。


Peter Vogel是 PH&V Information Services 的系统架构师兼主管。PH&V 提供从 UX 设计到对象建模再到数据库设计的全面堆栈咨询服务。你可以通过 peter.vogel@phvis.com 与他取得联系。

衷心感谢以下 Microsoft 技术专家对本文的审阅: Dino Esposito 和 Julie Lerman
Dino Esposito 是《Microsoft .NET: 构建面向企业的应用程序》(Microsoft Press,2014 年)和《使用 ASP.NET 构建新型 Web 应用程序》(Microsoft Press,2016 年)的作者。Esposito 是 JetBrains 的 .NET 和 Android 平台的技术传播者,经常出现在世界各地的行业活动中担任主讲人,他通过 software2cents@wordpress.com 和 Twitter: @despos 分享了自己对软件的见解。

Julie Lerman 是 Microsoft MVP、.NET 导师兼顾问,她居住在佛蒙特州的山区。您可以在全球的用户组和会议中看到她对数据访问和其他 .NET 主题的演示。她的博客地址是 thedatafarm.com/blog。她是“Entity Framework 编程”及其 Code First 和 DbContext 版本(全都出版自 O’Reilly Media)的作者。通过 Twitter 关注她:@julielerman 并在 juliel.me/PS-Videos 上观看她的 Pluralsight 课程。