命令和查询职责隔离 (CQRS) 模式
使用接口把更新数据的操作与读取数据的操作隔离。这可以最大限度地提高性能、可伸缩性和安全性。提供给系统随时间持续演化的灵活性,并防止更新命令会导致域级别的合并冲突。
问题背景
在传统的数据管理系统中,命令(对数据的更新)和查询(对数据的请求)都是针对单库中的同一组实体执行的。这些实体可以是关系数据库(如SQLServer)中一个或多个表中的行的子集。
通常在这些系统中,所有创建、读取、更新和删除(CRUD)操作都应用于实体的相同表示形式。例如,表示客户的数据传送对象(DTO)由数据访问层 (DAL)从数据存储中检索, 并显示在屏幕上。用户更新dto的某些字段(可能是通过数据绑定),然后将dto保存到数据存储中。读取和写入操作都使用相同的 DTO。该图说明了传统的 CRUD 体系结构。
传统的 CRUD 体系结构
当将有限的业务逻辑应用到数据操作时,传统的CRUD设计是可以很好地工作的。开发工具提供的Scaffold机制可以非常快速地创建数据访问代码, 然后可以根据需要进行定制。
然而, 传统的 CRUD 方法有一些缺点:
这通常意味着数据的读写表示形式不匹配,如额外的列或属性必须被更新,即使它们和操作没有关系。
如果在协作域中的数据存储区中对记录加锁,并且多个参与者在同一数据上并行操作, 则会有数据竞争。或在并发更新时使用了乐观锁时,会引起冲突。随着系统的复杂性和吞吐量的增长,这些风险也会逐渐增加。此外,随着数据存储和数据访问层的负载以及查询的复杂性, 传统的方法可能会对性能产生负面影响。
它可以使管理安全性和权限变得更加复杂,因为每个实体都受读写操作的控制,这可能会使得数据在错误的上下文中操作。
为了更深入地了解 crud 方法的局限性,请参阅 crud. (https://blogs.msdn.microsoft.com/maarten_mullender/2004/07/23/crud-only-when-you-can-afford-it-revisited/)
解决方案
命令和查询职责隔离 (CQRS) 是一种模式,它使用单独的接口将更新数据(命令) 操作与读取数据(查询)操作隔离。这意味着用于查询和更新的数据模型是不同的。数据模型也是可以隔离的, 如下图所示, 尽管这不是绝对的要求。
一个基本的 CQRS 结构
与CRUD-based系统中使用的单一数据模型相比,CQRS-based系统简化了设计和实现。然而, 一个缺点是, 与 CRUD 设计不同, CQRS 代码不能自动生成使用scaffold机制。
用于读取数据的查询模型和用于写入数据的更新模型可以访问同一个物理存储,可能是通过使用SQL视图或通过动态映射。但是,将数据分离到不同的物理存储区中以最大限度地提高性能、可伸缩性和安全性是很常见的, 如下图所示。
读写存储不同的CQRS体系结构
读存储可以是写存储的副本,或者读写存储区可以具有完全不同的结构。使用读存储区的多个只读副本可以极大地提高查询性能和应用程序UI响应能力,特别是在只读副本处于分布式环境。某些数据库系统(SQLServer)提供了其他功能,如副本故障转移,以最大限度地提高可用性。
读写存储区的分离也使得各自可以分别进行缩放以匹配负载(节省资源)。例如,读存储通常会遇到比写存储更高的负载。
当查询/读取模型包含非正则化数据(请参见实例化视图模式)时,在对应用程序中的每个视图读取或查询时, 性能将最大化。
问题和注意事项
在决定如何实现此模式时, 请考虑以下几点:
将数据存储分成不同的物理存储区以进行读写操作可提高系统的性能和安全性,但它也会增加灵活性和最终一致性方面的复杂性。读取的数据存储需要保证是最新的,当用于读取旧数据时,系统很难检测到。
有关最终一致性的说明, 请参阅数据一致性入门。(https://msdn.microsoft.com/library/dn589800.aspx)
考虑将 CQRS 应用到您的系统中的一部分,发挥其最大价值。
部署最终一致性的一种典型方法是将EventSourcing与CQRS一起使用,这样一来,写模型就是由执行命令驱动的事件流。这些事件用于更新实例化视图的模型。有关详细信息, 请参阅事件来源和 CQRS。(https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs#EventSourcingandCQRS)
何时使用此模式
在下列情况下使用此模式:
在同一数据上并行执行多个操作的协同。CQRS允许您定义具有足够细粒度的命令以最小化合并冲突的级别(任何出现的冲突都可以由该命令合并),甚至在更新相同类型的数据时也是如此。
基于任务的用户接口,用户交互过程比较复杂,包含一系列步骤。对于已经熟悉领域驱动设计(DDD)技术的团队来说也很有用。写模型具有一个完整的命令处理堆栈,具有业务逻辑、输入验证和业务验证,以确保每一个聚合(对数据修改的操作集合)在写模型上始终一致。读模型没有业务逻辑或验证堆栈,只返回一个在视图模型中使用的DTO。读写保持最终一致性。
对于一些场景,读写性能需要分离优化。特别是当读/写比率非常高,以及需要水平扩展时。例如,在许多系统中,读操作的次数比写入操作的次数大很多倍。为适应此情况,请考虑扩展读模型,但只在一个或几个实例上运行写模型。少量的写实例也有助于合并冲突发生的最小化。
一个开发人员团队可以专注于作为写模型一部分的复杂域模型的情况,另一个团队可以关注读模型和用户界面。
读写由不同团队进行开发。
与其他系统的集成,特别是与事件源的集成,其中一个子系统的暂时性故障不影响其他系统的可用性。
在下列情况下不建议使用此模式:
业务规则很简单。
使用简单的CRUD就能够满足用户操作。
用于整个系统的实现。总体数据管理方案中有特定的组件,其中CQRS是有用的,但应用于不必要的场景,它会增加大量和不必要的复杂性。
Event Sourcing和 CQRS
CQRS模式通常与EventSourcing模式一起使用。CQRS系统使用单独的读写数据模型,每种模型都用于相关的任务,并且通常在物理上是隔离的。与Event Sourcing模式一起使用时 (https://docs.microsoft.com/en-us/azure/architecture/patterns/event-sourcing),事件的存储是写模型。CQRS-based系统的读模型提供了数据的实例化视图,通常是高度非正则化的视图。这些视图针对应用程序的接口和显示要求进行了调优,这有助于最大限度地提高显示和查询性能。
使用事件流作为写存储,而不是在某个时间点的实际数据,避免了单一聚合上的更新冲突,并最大限度地提高了性能和可伸缩性。这些事件可异步生成用于读存储的数据视图。
由于事件存储是正式的信息源,因此可以删除整个视图并回放所有过去的事件,重新生成系统状态。实例化视图实际上是数据的只读缓存。
在使用 CQRS 与事件Event Sourcing相结合时, 请考虑以下几点:
与任何读写分离的系统一样,基于这种模式的系统只能保证最终一致性。在生成的事件与更新的数据存储之间会有一些延迟。
该模式增加了复杂性,因为必须手动来启动和处理事件,并装配或更新读模型中的视图或对象。当与EventSourcing模式一起使用时,CQRS模式的复杂性会使实现更加困难, 并需要采用不同的方法来设计系统。但是,Event Sourcing可以简化领域建模的过程, 并使重新生成视图或创建新查询更容易, 因为数据中的更改意图会被保留。
通过重播和处理特定实体或实体集合的事件,以生成用于在读模型视图可能需要大量的处理时间和资源。尤其对于需要长时间的求和或分析,因为需要检查所有相关事件。可通过规划时间间隔实现数据的快照(如已发生的特定操作的数目的总数或实体的当前状态)来解决此问题。
例子
下面的代码演示了从CQRS实现的示例中提取的一些摘录,使用了不同定义的模型进行读写操作。模型接口本身不包含数据存储的任何功能,它们可以独立进行演化和调优,因为这些接口是隔离的。
下面的代码为读取模型定义。
系统允许用户对产品进行评价。应用程序代码使用下面的代码中的RateProduct 命令来执行此操作。
系统使用ProductsCommandHandler类来处理由应用程序发送的命令。客户端通常通过邮件系统(如队列)发送命令。命令处理程序接受这些命令并调用域接口的方法。每个命令的粒度是为了减少冲突请求的可能性而设计的。下面的代码显示了ProductsCommandHandler类的具体实现。
下面的代码是写入模型中IProductsDomain 接口的实现。
还要注意IProductsDomain接口中包含了在业务领域内有意义的方法名。通常,在CRUD环境中,这些方法会使用比较泛化的名称(如保存或更新),并将DTO作为唯一参数。CQRS则为具体业务和库存管理系统的需要来定义接口。
相关的模式和说明
以下模式与说明在实现此模式时很有用:
有关CQRS与其他架构风格的比较,请参阅架构风格(https://docs.microsoft.com/en-us/azure/architecture/guide/architecture-styles/)和CQRS(https://docs.microsoft.com/en-us/azure/architecture/guide/architecture-styles/cqrs)。
数据一致性入门(https://msdn.microsoft.com/library/dn589800.aspx)。解释了在使用CQRS模式时,读写数据存储之间的最终一致性会遇到的问题, 以及如何解决这些问题。
数据分区指南(https://docs.microsoft.com/en-us/azure/architecture/best-practices/data-partitioning)。描述如何将CQRS模式中的读写数据存储进行分区,以提高可伸缩性、减少资源争用并提高性能。
Event Sourcing模式(https://docs.microsoft.com/en-us/azure/architecture/patterns/event-sourcing)。更详细地描述了EventSourcing如何与CQRS模式一起使用,以简化业务中的任务,同时提高性能、可伸缩性和响应能力。以及如何为事务提供一致性,同时通过维护完整审核跟踪和历史记录来只用补偿事务机制。
实例化视图模式(https://docs.microsoft.com/en-us/azure/architecture/patterns/materialized-view)。CQRS 实现的读模型可包含写模型数据的实例化视图,或者使用读模型生成实例视图。
CQRS 的旅程(https://msdn.microsoft.com/en-us/library/jj554200.aspx)。例子中包括了命令和查询职责隔离模式(https://msdn.microsoft.com/library/jj591573.aspx), 探讨了其有用之处和经验教训(https://msdn.microsoft.com/library/jj591568.aspx)有助于您了解使用此模式时会出现的一些问题。
Martin Fowler关于CQRS的文章(https://martinfowler.com/bliki/CQRS.html), 它解释了模式的基本知识和其他有用资源的链接。
Greg Young(http://codebetter.com/gregyoung/)的帖子, 探索了CQRS 模式的许多方面。
使用接口把更新数据的操作与读取数据的操作隔离。这可以最大限度地提高性能、可伸缩性和安全性。提供给系统随时间持续演化的灵活性,并防止更新命令会导致域级别的合并冲突。
问题背景
在传统的数据管理系统中,命令(对数据的更新)和查询(对数据的请求)都是针对单库中的同一组实体执行的。这些实体可以是关系数据库(如SQLServer)中一个或多个表中的行的子集。
通常在这些系统中,所有创建、读取、更新和删除(CRUD)操作都应用于实体的相同表示形式。例如,表示客户的数据传送对象(DTO)由数据访问层 (DAL)从数据存储中检索, 并显示在屏幕上。用户更新dto的某些字段(可能是通过数据绑定),然后将dto保存到数据存储中。读取和写入操作都使用相同的 DTO。该图说明了传统的 CRUD 体系结构。
传统的 CRUD 体系结构
当将有限的业务逻辑应用到数据操作时,传统的CRUD设计是可以很好地工作的。开发工具提供的Scaffold机制可以非常快速地创建数据访问代码, 然后可以根据需要进行定制。
然而, 传统的 CRUD 方法有一些缺点:
这通常意味着数据的读写表示形式不匹配,如额外的列或属性必须被更新,即使它们和操作没有关系。
如果在协作域中的数据存储区中对记录加锁,并且多个参与者在同一数据上并行操作, 则会有数据竞争。或在并发更新时使用了乐观锁时,会引起冲突。随着系统的复杂性和吞吐量的增长,这些风险也会逐渐增加。此外,随着数据存储和数据访问层的负载以及查询的复杂性, 传统的方法可能会对性能产生负面影响。
它可以使管理安全性和权限变得更加复杂,因为每个实体都受读写操作的控制,这可能会使得数据在错误的上下文中操作。
为了更深入地了解 crud 方法的局限性,请参阅 crud. (https://blogs.msdn.microsoft.com/maarten_mullender/2004/07/23/crud-only-when-you-can-afford-it-revisited/)
解决方案
命令和查询职责隔离 (CQRS) 是一种模式,它使用单独的接口将更新数据(命令) 操作与读取数据(查询)操作隔离。这意味着用于查询和更新的数据模型是不同的。数据模型也是可以隔离的, 如下图所示, 尽管这不是绝对的要求。
一个基本的 CQRS 结构
与CRUD-based系统中使用的单一数据模型相比,CQRS-based系统简化了设计和实现。然而, 一个缺点是, 与 CRUD 设计不同, CQRS 代码不能自动生成使用scaffold机制。
用于读取数据的查询模型和用于写入数据的更新模型可以访问同一个物理存储,可能是通过使用SQL视图或通过动态映射。但是,将数据分离到不同的物理存储区中以最大限度地提高性能、可伸缩性和安全性是很常见的, 如下图所示。
读写存储不同的CQRS体系结构
读存储可以是写存储的副本,或者读写存储区可以具有完全不同的结构。使用读存储区的多个只读副本可以极大地提高查询性能和应用程序UI响应能力,特别是在只读副本处于分布式环境。某些数据库系统(SQLServer)提供了其他功能,如副本故障转移,以最大限度地提高可用性。
读写存储区的分离也使得各自可以分别进行缩放以匹配负载(节省资源)。例如,读存储通常会遇到比写存储更高的负载。
当查询/读取模型包含非正则化数据(请参见实例化视图模式)时,在对应用程序中的每个视图读取或查询时, 性能将最大化。
问题和注意事项
在决定如何实现此模式时, 请考虑以下几点:
将数据存储分成不同的物理存储区以进行读写操作可提高系统的性能和安全性,但它也会增加灵活性和最终一致性方面的复杂性。读取的数据存储需要保证是最新的,当用于读取旧数据时,系统很难检测到。
有关最终一致性的说明, 请参阅数据一致性入门。(https://msdn.microsoft.com/library/dn589800.aspx)
考虑将 CQRS 应用到您的系统中的一部分,发挥其最大价值。
部署最终一致性的一种典型方法是将EventSourcing与CQRS一起使用,这样一来,写模型就是由执行命令驱动的事件流。这些事件用于更新实例化视图的模型。有关详细信息, 请参阅事件来源和 CQRS。(https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs#EventSourcingandCQRS)
何时使用此模式
在下列情况下使用此模式:
在同一数据上并行执行多个操作的协同。CQRS允许您定义具有足够细粒度的命令以最小化合并冲突的级别(任何出现的冲突都可以由该命令合并),甚至在更新相同类型的数据时也是如此。
基于任务的用户接口,用户交互过程比较复杂,包含一系列步骤。对于已经熟悉领域驱动设计(DDD)技术的团队来说也很有用。写模型具有一个完整的命令处理堆栈,具有业务逻辑、输入验证和业务验证,以确保每一个聚合(对数据修改的操作集合)在写模型上始终一致。读模型没有业务逻辑或验证堆栈,只返回一个在视图模型中使用的DTO。读写保持最终一致性。
对于一些场景,读写性能需要分离优化。特别是当读/写比率非常高,以及需要水平扩展时。例如,在许多系统中,读操作的次数比写入操作的次数大很多倍。为适应此情况,请考虑扩展读模型,但只在一个或几个实例上运行写模型。少量的写实例也有助于合并冲突发生的最小化。
一个开发人员团队可以专注于作为写模型一部分的复杂域模型的情况,另一个团队可以关注读模型和用户界面。
读写由不同团队进行开发。
与其他系统的集成,特别是与事件源的集成,其中一个子系统的暂时性故障不影响其他系统的可用性。
在下列情况下不建议使用此模式:
业务规则很简单。
使用简单的CRUD就能够满足用户操作。
用于整个系统的实现。总体数据管理方案中有特定的组件,其中CQRS是有用的,但应用于不必要的场景,它会增加大量和不必要的复杂性。
Event Sourcing和 CQRS
CQRS模式通常与EventSourcing模式一起使用。CQRS系统使用单独的读写数据模型,每种模型都用于相关的任务,并且通常在物理上是隔离的。与Event Sourcing模式一起使用时 (https://docs.microsoft.com/en-us/azure/architecture/patterns/event-sourcing),事件的存储是写模型。CQRS-based系统的读模型提供了数据的实例化视图,通常是高度非正则化的视图。这些视图针对应用程序的接口和显示要求进行了调优,这有助于最大限度地提高显示和查询性能。
使用事件流作为写存储,而不是在某个时间点的实际数据,避免了单一聚合上的更新冲突,并最大限度地提高了性能和可伸缩性。这些事件可异步生成用于读存储的数据视图。
由于事件存储是正式的信息源,因此可以删除整个视图并回放所有过去的事件,重新生成系统状态。实例化视图实际上是数据的只读缓存。
在使用 CQRS 与事件Event Sourcing相结合时, 请考虑以下几点:
与任何读写分离的系统一样,基于这种模式的系统只能保证最终一致性。在生成的事件与更新的数据存储之间会有一些延迟。
该模式增加了复杂性,因为必须手动来启动和处理事件,并装配或更新读模型中的视图或对象。当与EventSourcing模式一起使用时,CQRS模式的复杂性会使实现更加困难, 并需要采用不同的方法来设计系统。但是,Event Sourcing可以简化领域建模的过程, 并使重新生成视图或创建新查询更容易, 因为数据中的更改意图会被保留。
通过重播和处理特定实体或实体集合的事件,以生成用于在读模型视图可能需要大量的处理时间和资源。尤其对于需要长时间的求和或分析,因为需要检查所有相关事件。可通过规划时间间隔实现数据的快照(如已发生的特定操作的数目的总数或实体的当前状态)来解决此问题。
例子
下面的代码演示了从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)
{
...
}
}
下面的代码是写入模型中IProductsDomain 接口的实现。
public interface IProductsDomain
{
void AddNewProduct(int id, string name, string description, decimal price);
void RateProduct(int userId, int rating);
void AddToInventory(int productId, int quantity);
void ConfirmItemsShipped(int productId, int quantity);
void UpdateStockFromInventoryRecount(int productId, int updatedQuantity);
}
还要注意IProductsDomain接口中包含了在业务领域内有意义的方法名。通常,在CRUD环境中,这些方法会使用比较泛化的名称(如保存或更新),并将DTO作为唯一参数。CQRS则为具体业务和库存管理系统的需要来定义接口。
相关的模式和说明
以下模式与说明在实现此模式时很有用:
有关CQRS与其他架构风格的比较,请参阅架构风格(https://docs.microsoft.com/en-us/azure/architecture/guide/architecture-styles/)和CQRS(https://docs.microsoft.com/en-us/azure/architecture/guide/architecture-styles/cqrs)。
数据一致性入门(https://msdn.microsoft.com/library/dn589800.aspx)。解释了在使用CQRS模式时,读写数据存储之间的最终一致性会遇到的问题, 以及如何解决这些问题。
数据分区指南(https://docs.microsoft.com/en-us/azure/architecture/best-practices/data-partitioning)。描述如何将CQRS模式中的读写数据存储进行分区,以提高可伸缩性、减少资源争用并提高性能。
Event Sourcing模式(https://docs.microsoft.com/en-us/azure/architecture/patterns/event-sourcing)。更详细地描述了EventSourcing如何与CQRS模式一起使用,以简化业务中的任务,同时提高性能、可伸缩性和响应能力。以及如何为事务提供一致性,同时通过维护完整审核跟踪和历史记录来只用补偿事务机制。
实例化视图模式(https://docs.microsoft.com/en-us/azure/architecture/patterns/materialized-view)。CQRS 实现的读模型可包含写模型数据的实例化视图,或者使用读模型生成实例视图。
CQRS 的旅程(https://msdn.microsoft.com/en-us/library/jj554200.aspx)。例子中包括了命令和查询职责隔离模式(https://msdn.microsoft.com/library/jj591573.aspx), 探讨了其有用之处和经验教训(https://msdn.microsoft.com/library/jj591568.aspx)有助于您了解使用此模式时会出现的一些问题。
Martin Fowler关于CQRS的文章(https://martinfowler.com/bliki/CQRS.html), 它解释了模式的基本知识和其他有用资源的链接。
Greg Young(http://codebetter.com/gregyoung/)的帖子, 探索了CQRS 模式的许多方面。