命令和查询责任隔离(CQRS)模式

【博文目录>>>】


命令和查询责任隔离(CQRS)模式

使用单独的接口将读取数据的操作与更新数据的操作隔离开来。这种模式可以最大限度地提高性能、可伸缩性和安全性;通过更高的灵活性支持系统随时间的发展;并防止更新命令在域级别造成合并冲突。

背景与问题

在传统的数据管理系统中,命令(对数据的更新)和查询(对数据的请求)都针对单个数据存储库中的同一组实体执行。这些实体可以是关系数据库(例如SQL Server)中一个或多个表中行的子集。

通常,在这些系统中,所有创建、读取、更新和删除(CRUD)操作都应用于实体的相同表示形式。例如,数据访问层(DAL)从数据存储中检索表示客户的数据传输对象(DTO),并显示在屏幕上。用户更新DTO的某些字段(可能通过数据绑定),然后DTO被DAL保存回数据存储中。相同的DTO用于读写操作,如图1所示。

在这里插入图片描述

图1-传统CRUD体系结构

当应用于数据操作的业务逻辑有限时,传统的CRUD设计工作良好。开发工具提供的脚手架机制可以非常快速地创建数据访问代码,然后可以根据需要定制这些代码。

然而,传统的CRUD方法有一些缺点:

  • 它通常意味着数据的读和写表示之间不匹配,例如,即使操作中不需要它们,也必须正确更新其他列或属性。
  • 当数据存储中锁定记录时,可能会遇到协作域中的数据争用(多个参与者在同一组数据上并行操作),或者在使用乐观锁定时由并发更新引起的更新冲突。这些风险随着系统的复杂性和吞吐量的增加而增加。此外,由于数据存储和数据访问层的负载以及检索信息所需查询的复杂性,传统方法也会对性能产生负面影响。
  • 它会使管理安全性和权限变得更麻烦,因为每个实体都要同时执行读和写操作,这可能会无意中暴露错误上下文中的数据。


要更深入地了解CRUD方法的局限性,请参阅MSDN上的"CRUD,只有在您能够负担的情况下"在MSDN上。

解决方案

命令和查询责任隔离(CQRS)是一种模式,它通过使用单独的接口将读取数据(查询)的操作与更新数据(命令)的操作隔离开来。这意味着用于查询和更新的数据模型是不同的。然后可以隔离模型,如图2所示,尽管这不是绝对的需求。

在这里插入图片描述

图2-基本的CQRS体系结构

与基于CRUD的系统中固有的数据模型(开发人员从中构建自己的概念模型)相比,对基于CQRS的系统中的数据使用单独的查询和更新模型大大简化了设计和实现。但是,一个缺点是,与CRUD设计不同,CQRS代码不能通过使用脚手架机制自动生成。

用于读取数据的查询模型和用于写入数据的更新模型可以访问相同的物理存储,可以使用SQL视图或动态生成投影。但是,通常将数据分成不同的物理存储以最大化性能,可伸缩性和安全性。如图3所示。

在这里插入图片描述

图3-具有单独读写存储的CQRS体系结构

读存储可以是写存储的只读副本,或者读和写存储可能具有完全不同的结构。使用读取存储的多个只读副本可以显著提高查询性能和应用程序UI响应能力,特别是在分布场景中,其中只读副本位于接近应用程序实例的位置。一些数据库系统(如SQL Server)提供了其他功能,如故障转移副本,以最大限度地提高可用性。

读和写存储的分离也允许每个存储被适当地缩放以匹配负载。例如,读存储通常会遇到比写入存储高得多的负载。

当查询/读取模型包含非规范化信息时(请参见物化视图模式),在为应用程序中的每个视图读取数据或查询系统中的数据时,性能都会最大化。

有关CQRS模式及其实现的更多信息,请参见以下资源:

问题和思考

在决定如何实现此模式时,请考虑以下几点:

  • 将数据存储划分为单独的物理存储进行读和写操作可以提高系统的性能和安全性,但它可以在弹性和最终一致性方面增加相当大的复杂性。必须更新读取模型存储,以反映对写入模型存储的更改,并且可能难以检测用户何时基于过时的读取数据发出了请求,这意味着操作无法完成。


有关最终一致性的说明,请参见"数据一致性入门"

  • 考虑将CQRS应用于系统中最有价值的有限部分,并从中吸取经验教训。
  • 实现最终一致性的一种典型方法是将事件源与CQRS结合使用,这样写模型就是由命令执行驱动的仅附加的事件流。这些事件用于更新作为读取模型的物化视图。有关更多信息,请参见事件源和CQRS。

何时使用此模式

这种模式非常适合于:

  • 在同一数据上并行执行多个操作的协作域。CQRS允许您定义具有足够粒度的命令,以尽量减少域级别的合并冲突(或任何确实出现的冲突都可以由命令合并),即使在更新看起来是相同类型的数据时也是如此。
  • 与基于任务的用户界面(其中,通过一系列步骤指导用户完成一个复杂的过程),复杂的域模型以及已经熟悉域驱动设计(DDD)技术的团队一起使用写入模型具有完整的命令处理堆栈,其中包含业务逻辑,输入验证和业务验证,以确保每个聚合始终保持一致(每个关联对象的群集都被视为一个单元,以进行数据更改)。读取模型没有业务逻辑或验证堆栈,仅返回DTO以在视图模型中使用。读取模型最终与写入模型一致。
  • 数据读取的性能必须与数据写入的性能分开微调的场景,特别是当读写比很高时,以及当需要水平缩放时。例如,在许多系统中,读取操作的数量比写入操作的数量大几个数量级。为此,请考虑扩展读取模型,但仅在一个或几个实例上运行写入模型。少量的写模型实例还有助于最大程度地减少合并冲突的发生。
  • 一个团队的开发人员可以专注于作为写模型部分的复杂域模型,而另一组经验不足的团队可以专注于读模型和用户界面的场景。
  • 预计系统会随着时间而发展并且可能包含模型的多个版本,或者业务规则会定期更改的场景。
  • 与其他系统的集成,特别是与事件源的组合,其中一个子系统的时间故障不会影响其他子系统的可用性。

这种模式可能不适用于以下情况:

  • 领域或业务规则很简单的地方。
  • 简单的CRUD风格的用户界面和相关的数据访问操作就足够了。
  • 用于整个系统的实施。总体数据管理方案中有一些特定的组件,在这些组件中CQRS可能有用,但是在实际上不需要的情况下,它可能会增加相当多的不必要的复杂性。

事件源与CQRS

CQRS模式通常与事件源模式一起使用。基于CQRS的系统使用独立的读写数据模型,每个模型都是根据相关任务量身定做的,并且通常位于物理上独立的存储区。当与事件源一起使用时,事件的存储是写模型,这是信息的权威来源。基于CQRS的系统的读取模型提供数据的物化视图,通常是高度非规范化的视图。这些视图是根据应用程序的接口和显示需求定制的,这有助于最大限度地提高显示和查询性能。

使用事件流作为写存储而不是某个时间点的实际数据,可以避免在单个聚合上发生更新冲突,并最大限度地提高性能和可伸缩性。这些事件可用于异步生成用于填充读取存储的数据的物化视图。

因为事件存储是信息的权威来源,所以可以在系统演进或必须更改读取模型时删除实例化视图并重播所有过去的事件,以创建当前状态的新表示形式。物化视图实际上是数据的持久只读缓存。。

当使用CQRS与事件源模式相结合时,请考虑以下几点:

  • 与任何读写存储分离的系统一样,基于这种模式的系统最终只能是最终一致的。在生成的事件与保存由这些事件更新的操作结果的数据存储之间会有一些延迟。。
  • 该模式带来了额外的复杂性,因为必须创建代码来初始化和处理事件,以及组装或更新查询或读取模型所需的适当视图或对象。当将CQRS模式与事件源结合使用时,其固有的复杂性会使成功实施变得更加困难,并且需要重新学习一些概念和设计系统的不同方法。但是,由于保留了数据更改的意图,因此事件源可以使对域的建模更加容易,并且可以更轻松地重建视图或创建新视图。
  • 通过重放和处理特定实体或实体集合的事件来生成物化视图以用于读取的模型或数据的投影,可能需要大量的处理时间和资源使用,尤其是在需要长时间汇总或分析值的情况下,因为可能需要检查所有相关事件。通过按计划的时间间隔实施数据快照(例如已发生的特定操作的总数或实体的当前状态),可以部分解决此问题。


有关更多信息,请参见在MSDN上事件源模式物化视图模式,以及模式和实践指南CQRS旅程。特别是你应该阅读这一章。事件源简介以充分探讨该模式及其如何在CQRS中使用,以及本章CQRS与ES深度历险了解更多–包括如何在MicrosoftAzure中与CQRS一起使用聚合分区。

示例

下面的代码显示了CQRS实现示例中的一些摘录,CQRS实现对读和写模型使用了不同的定义。模型接口并不规定底层数据存储的任何特性,它们可以独立地进化和调整,因为这些接口是分开的。

下面的代码显示了读取模型定义。

// Query 
interface namespace ReadModel { 
    public interface ProductsDao { 
        ProductDisplay FindById(int productId); 
        IEnumerable<ProductDisplay> FindByName(string name); IEnumerable<ProductInventory> FindOutOfStockProducts(); IEnumerable<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.RateProuct(command.UserId, command.rating);      repository.Save(product);    
        }  
    }  
    
    void Handle (AddToInventory command)  { ... } 
    void Handle (ConfirmItemsShipped command)  { ... }  
    void Handle (UpdateStockFromInventoryRecount command)  { ... }
}

以下代码显示了写入模型中的ProductsDoman接口。

public interface ProductsDomain {
    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);
}

还要注意ProductsDomain接口如何包含在域中具有意义的方法。通常,在CRUD环境中,这些方法将具有通用名称(如“Save”或“Update”),并且将DTO作为唯一参数。可以更好地定制CQRS方法,以适合该组织执行业务和库存管理的方式。

相关模式和指导

在实施这一模式时,下列模式和指导也可能相关:

  • 数据一致性入门。本指南介绍了使用CQRS模式时由于读写数据存储之间最终的一致性而通常遇到的问题,以及如何解决这些问题。
  • 数据分区指南。本指南介绍了如何将CQRS模式中使用的读写数据存储区划分为多个单独的分区,这些分区可以分别进行管理和访问,以提高可伸缩性,减少争用并优化性能。
  • 事件源模式。此模式更详细地描述了如何将事件源与CQRS模式一起使用以简化复杂域中的任务。改善性能,可伸缩性和响应能力;提供交易数据的一致性;并保持完整的审计追踪和历史记录,以支持采取补偿措施。
  • 物化视图模式。CQRS实现的读取模型可以包含写入模型数据的实例化视图,或者读取模型可以用于生成实例化视图。

更多信息

  • MSDN上模式与实践指南CQRS之旅
  • Martin Fowler博客上的文章CQRS
  • Greg Young在Code Better网站上的文章。

原文链接

https://docs.microsoft.com/en-us/previous-versions/msp-n-p/dn568103%28v%3dpandp.10%29

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章