命令查询责任分离(CQRS)是一种设计模式,可将数据存储的读取和写入操作隔离到单独的数据模型中。 这允许每个模型独立优化,并可以提高应用程序的性能、可伸缩性和安全性。
上下文和问题
在传统体系结构中,单个数据模型通常用于读取和写入操作。 此方法非常简单,适用于基本 CRUD 操作(见图 1)。
显示传统 CRUD 体系结构的
图 1. 传统的 CRUD 体系结构。
但是,随着应用程序的增长,优化单个数据模型的读取和写入操作变得越来越具有挑战性。 读取和写入操作通常具有不同的性能和缩放需求。 传统的 CRUD 体系结构不考虑这种不对称性。 这会导致一些挑战:
数据不匹配: 数据读取和写入表示形式通常有所不同。 更新期间所需的某些字段在读取期间可能没有必要。
锁争用: 同一数据集上的并行操作可能会导致锁争用。
性能问题: 传统方法可能会对数据存储和数据访问层加载的性能产生负面影响,以及检索信息所需的查询的复杂性。
安全问题:当实体受到读取和写入操作的约束时, 管理安全性变得困难。 这种重叠可以在意外的上下文中公开数据。
合并这些责任可能会导致一个过于复杂的模型,试图执行太多操作。
解决方案
使用 CQRS 模式将写入操作(命令)与读取操作(查询)分开。 命令负责更新数据。 查询负责检索数据。
了解命令。 命令应表示特定的业务任务,而不是低级别数据更新。 例如,在酒店预订应用中,使用“预订酒店房间”而不是“将 ReservationStatus 设置为 Reserved”。此方法更好地反映用户操作背后的意图,并将命令与业务流程保持一致。 若要确保命令成功,可能需要优化用户交互流、服务器端逻辑,并考虑异步处理。
精简区域 | 建议 |
---|---|
客户端验证 | 在发送命令之前验证某些条件,以防止出现明显的故障。 例如,如果没有房间可用,请禁用“预订”按钮并在 UI 中提供明确的用户友好消息,说明为什么无法预订。 此设置可减少不必要的服务器请求并向用户提供即时反馈,从而增强其体验。 |
服务器端逻辑 | 增强业务逻辑,以正常处理边缘事例和故障。 例如,若要解决争用条件(尝试预订最后一个可用房间的多个用户),请考虑将用户添加到等待列表或建议替代选项。 |
异步处理 | 还可以通过将命令放在队列上而不是同步处理命令来异步 |
了解查询。 查询永远不会更改数据。 相反,它们返回以方便格式呈现所需数据的数据传输对象(DTO),而无需任何域逻辑。 这种明确的关注点分离简化了系统的设计和实现。
了解读取和写入模型分离
将读取模型与写入模型分离,通过解决数据写入和读取的不同问题,简化了系统设计和实现。 这种分离提高了清晰度、可伸缩性和性能,但引入了一些权衡。 例如,O/RM 框架等基架工具无法从数据库架构自动生成 CQRS 代码,需要自定义逻辑来弥合差距。
以下部分探讨在 CQRS 中实现读取和写入模型分离的两种主要方法。 每个方法都有独特的优势和挑战,例如同步和一致性管理。
单个数据存储中的模型分离
此方法表示 CQRS 的基础级别,其中读取和写入模型共享单个基础数据库,但维护其操作的不同逻辑。 通过定义单独的关注点,此策略可增强简单性,同时为典型用例提供可伸缩性和性能优势。 使用基本的 CQRS 体系结构,可以在依赖共享数据存储时从读取模型描述写入模型(见图 2)。
显示基本 CQRS 体系结构的
图 2. 具有单个数据存储的基本 CQRS 体系结构。
此方法通过定义用于处理写入和读取问题的不同模型来提高清晰度、性能和可伸缩性:
写入模型: 设计用于处理更新或保留数据的命令。 它包括验证、域逻辑,并通过优化事务完整性和业务流程来确保数据一致性。
读取模型: 旨在提供用于检索数据的查询。 它侧重于生成针对呈现层优化的 DTO(数据传输对象)或投影。 它通过避免域逻辑来提高查询性能和响应能力。
独立数据存储中模型的物理分离
更高级的 CQRS 实现对读取和写入模型使用不同的数据存储。 读取和写入数据存储的分离使你可以缩放每个存储以匹配负载。 它还使你能够对每个数据存储使用不同的存储技术。 可以将文档数据库用于读取数据存储和写入数据存储的关系数据库(见图 3)。
图 3. 具有单独读取和写入数据存储的 CQRS 体系结构。
同步单独的数据存储: 使用单独的存储时,必须确保两者保持同步。常见的模式是,每当写入模型更新数据库时,写入模型发布事件,读取模型使用该数据库来刷新其数据。 有关使用事件的详细信息,请参阅 事件驱动的体系结构样式。 但是,通常无法将消息代理和数据库登记到单个分布式事务中。 因此,在更新数据库和发布事件时,保证一致性可能会面临挑战。 有关详细信息,请参阅 幂等消息处理。
读取数据存储: 读取数据存储可以使用其针对查询优化的自己的数据架构。 例如,它可以存储数据
CQRS 的优点
独立缩放。 CQRS 使读取和写入模型能够独立缩放,这有助于最大程度地减少锁争用并提高负载下的系统性能。
优化的数据架构。 读取操作可以使用针对查询优化的架构。 写入操作使用针对更新优化的架构。
安全性。 通过分隔读取和写入,可以确保只有适当的域实体或操作有权对数据执行写入操作。
关注点分离。 拆分读取和写入职责会导致更简洁、更易于维护的模型。 写入端通常处理复杂的业务逻辑,而读取端可以保持简单且专注于查询效率。
查询更简单。 在读取数据库中存储具体化视图时,应用程序可以在查询时避免复杂的联接。
实现问题和注意事项
实现此模式时存在的一些挑战包括:
复杂性增加。 虽然 CQRS 的核心概念非常简单,但它可以向应用程序设计引入显著的复杂性,尤其是在与事件溯源模式结合使用时。
消息传送挑战。 尽管消息传送不需要 CQRS,但通常使用它来处理命令和发布更新事件。 涉及消息传送时,系统必须考虑潜在的问题,例如消息失败、重复项和重试。 请参阅有关 优先级队列 的指南,了解处理具有不同优先级的命令的策略。
最终一致性。 当读取和写入数据库分离时,读取数据可能不会立即反映最近的更改,从而导致数据过时。 确保读取模型存储保持 up-to日期,写入模型存储中的更改可能很有挑战性。 此外,检测和处理用户对过时数据执行操作的方案需要仔细考虑。
何时使用 CQRS 模式
CQRS 模式适用于需要在数据修改(命令)和数据查询(读取)之间明确分离的情况。 请考虑在以下情况下使用 CQRS:
协作域: 在多个用户同时访问和修改相同数据的环境中,CQRS 有助于减少合并冲突。 命令可以包含足够的粒度来防止冲突,并且系统可以解决命令逻辑中出现的任何问题。
基于任务的用户界面: 应用程序,这些应用程序将引导用户完成复杂流程,作为一系列步骤或具有复杂域模型受益于 CQRS。
写入模型具有完整的命令处理堆栈,其中包括业务逻辑、输入验证和业务验证。 写入模型可以将一组关联的对象视为数据更改的单个单元,这些对象称为域驱动设计术语中的 聚合。 写入模型还可以确保这些对象始终处于一致状态。
读取模型没有业务逻辑或验证堆栈。 它返回用于视图模型的 DTO。 读取模型最终与写入模型保持一致。
性能优化: 必须独立于数据写入性能对数据读取性能进行微调的系统,尤其是在读取次数大于写入数时,从 CQRS 中受益。 读取模型横向缩放以处理大型查询卷,而写入模型在更少的实例上运行,以最大程度地减少合并冲突并保持一致性。
开发问题分离: CQRS 允许团队独立工作。 一个团队侧重于在写入模型中实现复杂的业务逻辑,而另一个团队则开发读取模型和用户界面组件。
不断发展的系统: CQRS 支持随着时间推移而演变的系统。 它适应新的模型版本、对业务规则的频繁更改或其他修改,而不会影响现有功能。
系统集成: 与其他子系统(尤其是使用事件溯源的系统)集成,即使子系统暂时失败,也仍然可用。 CQRS 隔离故障,防止单个组件影响整个系统。
何时不使用 CQRS
在以下情况下避免 CQRS:
域或业务规则非常简单。
简单的 CRUD 样式用户界面和数据访问操作就足够了。
工作负荷设计
架构师应评估如何在工作负荷的设计中使用 CQRS 模式来解决 azure Well-Architected Framework 支柱
支柱 | 此模式如何支持支柱目标 |
---|---|
性能效率通过在缩放、数据和代码方面进行优化, 帮助工作负载高效地满足需求。 | 在高读写工作负载中,读写操作的分离可以针对每个操作的特定目的实现有针对性的性能和缩放优化。 - PE:05 缩放和分区 - PE:08 数据性能 |
与任何设计决策一样,请考虑对可能采用此模式引入的其他支柱的目标进行权衡。
合并事件溯源和 CQRS
CQRS 的某些实现包含 事件溯源模式,该模式将系统的状态存储为时间序列事件。 每个事件捕获给定时间对数据所做的更改。 若要确定当前状态,系统按顺序重播这些事件。 在此组合中:
事件存储是 写入模型 和单一事实来源。
读取模型 从这些事件生成具体化视图,通常采用高度非规范化的形式。 这些视图通过定制结构来优化数据检索,以满足查询和显示要求。
合并事件溯源和 CQRS 的好处
更新写入模型的相同事件可用作读取模型的输入。 然后,读取模型可以生成当前状态的实时快照。 这些快照通过提供数据的高效预计算视图来优化查询。
系统使用事件流作为写入存储,而不是直接存储当前状态。 此方法可减少聚合上的更新冲突,并提高性能和可伸缩性。 系统可以异步处理这些事件,以生成或更新读取存储的具体化视图。
由于事件存储充当单一事实来源,因此可以通过重播历史事件轻松重新生成具体化视图或适应读取模型中的更改。 实质上,具体化视图充当经过优化的持久只读缓存,可快速高效的查询。
合并事件溯源和 CQRS 时的注意事项
在将 CQRS 模式与事件溯源模式组合在一起之前,请评估以下注意事项:
最终一致性: 由于写入和读取存储是独立的,因此读取存储的更新可能会滞后于事件生成,从而导致最终一致性。
复杂性增加: 将 CQRS 与事件溯源相结合需要不同的设计方法,这使得成功实现更具挑战性。 必须编写代码来生成、处理和处理事件,以及为读取模型组装或更新视图。 但是,事件溯源简化了域建模,通过保留所有数据更改的历史记录和意图,可以轻松重新生成或创建新视图。
视图生成性能: 为读取模型生成具体化视图可能会消耗大量时间和资源。 这同样适用于通过重播和处理特定实体或集合的事件来投影数据。 当计算涉及长时间分析或求和值时,这种效果会增加,因为必须检查所有相关事件。 定期实现数据的快照。 例如,存储聚合总计的定期快照(发生特定操作的次数)或实体的当前状态。 快照可减少反复处理完整事件历史记录的需求,从而提高性能。
CQRS 模式示例
以下代码显示了从 CQRS 实现(它对读取和写入模型使用不同的定义)的示例中提取的一些内容。 模型接口不规定基础数据存储的任何功能,而且它们可以不断变化并进行细微调整,因为这些接口是独立的。
下面的代码显示读取模型定义。
// Query interface
namespace ReadModel
{
public interface ProductsDao
{
ProductDisplay FindById(int productId);
ICollection<ProductDisplay> FindByName(string name);
ICollection<ProductInventory> FindOutOfStockProducts();
ICollection<ProductDisplay> FindRelatedProducts(int productId);
}
public class ProductDisplay
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal UnitPrice { get; set; }
public bool IsOutOfStock { get; set; }
public double UserRating { get; set; }
}
public class ProductInventory
{
public int Id { get; set; }
public string Name { get; set; }
public int CurrentStock { get; set; }
}
}
系统允许用户对产品制定费率。 应用程序代码使用以下代码中所示的 RateProduct
命令执行此操作。
public interface ICommand
{
Guid Id { get; }
}
public class RateProduct : ICommand
{
public RateProduct()
{
this.Id = Guid.NewGuid();
}
public Guid Id { get; set; }
public int ProductId { get; set; }
public int Rating { get; set; }
public int UserId {get; set; }
}
系统使用 ProductsCommandHandler
类处理应用程序所发送的命令。 通常,客户端通过消息传递系统(如队列)将命令发送到域。 命令处理程序接受这些命令,并调用域接口方法。 每个命令的粒度旨在减少冲突请求。 下面的代码显示了 ProductsCommandHandler
类的概述。
public class ProductsCommandHandler :
ICommandHandler<AddNewProduct>,
ICommandHandler<RateProduct>,
ICommandHandler<AddToInventory>,
ICommandHandler<ConfirmItemShipped>,
ICommandHandler<UpdateStockFromInventoryRecount>
{
private readonly IRepository<Product> repository;
public ProductsCommandHandler (IRepository<Product> repository)
{
this.repository = repository;
}
void Handle (AddNewProduct command)
{
...
}
void Handle (RateProduct command)
{
var product = repository.Find(command.ProductId);
if (product != null)
{
product.RateProduct(command.UserId, command.Rating);
repository.Save(product);
}
}
void Handle (AddToInventory command)
{
...
}
void Handle (ConfirmItemsShipped command)
{
...
}
void Handle (UpdateStockFromInventoryRecount command)
{
...
}
}
后续步骤
实现此模式时,以下模式和指南将非常有用:
- 水平、垂直和功能数据分区。 介绍有关将数据划分为可单独管理和访问的分区,以提高可伸缩性、减少争用和优化性能的最佳做法。