CQRS 模式
存储 CQRS 是“命令和查询责任分离”的英文缩写,它是一种将数据存储的读取操作和更新操作分离的模式。 在应用程序中实现 CQRS 可以最大限度地提高其性能、可缩放性和安全性。 通过迁移到 CQRS 而创建的灵活性使系统能够随着时间的推移而更好地发展,并防止更新命令在域级别导致合并冲突。
上下文和问题
在传统的体系结构中,使用同一数据模型查询和更新数据库。 这十分简单,非常适用于基本的 CRUD 操作。 但是,在更复杂的应用程序中,此方法会变得难以操作。 例如,在读取方面,应用程序可能执行大量不同的查询,返回具有不同形状的数据传输对象 (DTO)。 对象映射可能会变得复杂。 在写入方面,模型可能实施复杂验证和业务逻辑。 结果,模型执行太多操作,过度复杂。 读取和写入工作负载通常是非对称的,两者的性能和缩放要求有很大的差异。
- 数据的读取和写入表示形式之间通常不匹配,例如必须正确更新的附加列或属性(即使它们不需要是操作的一部分)。
- 对同一组数据并行执行操作时,可能会发生数据争用。
- 由于数据存储和数据访问层上的负载以及检索消息所需查询的复杂性,传统方法可能对性能造成负面影响。
- 管理安全性和权限可能变得复杂,因为每个实体同时受读取和写入操作的影响,这可能会在错误的上下文中公开数据。
解决方案
CQRS 将读取和写入分离到不同的模型,使用命令来更新数据,使用查询来读取数据。
- 命令应基于任务,而不是以数据为中心。 (“预订酒店客房”,而不是“将 ReservationStatus 设置为 Reserved”)。
- 可将命令排入队列,以进行异步处理而不是同步处理。
- 查询从不修改数据库。 查询返回的 DTO 不封装任何域知识。
然后可以隔离模型,如下图所示(不过这不是绝对要求)。 使用独立的查询和更新模型可以简化设计和实现。 但是,一个缺点是无法使用 O/RM 工具等基架机制从数据库架构中自动生成 CQRS 代码。 为更好地实现隔离,可将读取数据与写入数据通过物理方式分离。 在此情况下,读取数据库可使用自己的已针对查询进行优化的数据架构。 例如,它可以存储数据的具体化视图,从而避免复杂联接或复杂 O/RM 映射。 它甚至可能使用不同类型的数据存储。 例如,写入数据库可能是关系数据库,而读取数据库是文档数据库。 如果使用独立的读取和写入数据库,则它们必须保持同步。通常,每次更新数据库时写入模型会发布一个事件,从而可实现同步。 有关使用事件的详细信息,请参阅事件驱动的体系结构样式。 由于消息代理和数据库通常无法登记到单个分布式事务中,因此在更新数据库和发布事件时,在保证一致性方面可能存在挑战。 有关详细信息,请参阅幂等消息处理。 读取存储可以是写入存储的只读副本,或者读取和写入存储可以具有完全不同的结构。 使用多个只读副本可以提高查询性能,尤其是在只读副本靠近应用程序实例的分布式方案中。 读取和写入存储的分离还允许彼此适当地缩放以匹配负载。 例如,读取存储通常会遇到高于写入存储的负载。 某些 CQRS 实现使用事件溯源模式。 在此模式中,应用程序状态存储为事件序列。 每个事件表示对数据所作的一系列更改。 通过重播事件构造当前状态。 在 CQRS 上下文中,事件溯源的一个好处是,可以使用相同的事件通知其他组件 — 具体而言,是通知读取模型。 读模型使用事件创建当前状态的快照,这对查询而言更高效。 但是,事件溯源增加了设计的复杂度。 CQRS 的好处包括:
- 独立缩放。 CQRS 允许读取和写入工作负载独立缩放,这可能会减少锁争用。
- 优化的数据架构。 读取端可使用针对查询优化的架构,写入端可使用针对更新优化的架构。
- 安全性。 更轻松地确保仅正确的域实体对数据执行写入操作。
- 关注点分离。 分离读取和写入端可使模型更易维护且更灵活。 大多数复杂的业务逻辑被分到写模型。 读模型会变得相对简单。
- 查询更简单。 通过将具体化视图存储在读取数据库中,应用程序可在查询时避免复杂联接。
实现问题和注意事项
实现此模式时存在的一些挑战包括:
- 复杂性。 CQRS 的基本理念十分简单。 但它可能导致应用程序的设计更为复杂,尤其在包含事件溯源模式时。
- 消息。 虽然 CQRS 不需要消息,但它通常会使用消息处理命令和发布更新事件。 在此情况下,应用程序必须处理消息失败或重复的消息。 请参阅有关优先级队列的指导,了解如何处理具有不同优先级的命令。
- 最终一致性。 如果分离读取和写入数据库,读取数据可能会过时。 必须更新读取模型存储以反映写入模型存储更改,当用户发出基于陈旧读取数据的请求后将难以将其删除。
何时使用 CQRS 模式
对于以下方案,请考虑使用 CQRS:
- 其中的许多用户同时访问相同数据的协作域。 CQRS 允许定义具有足够粒度的命令,以最大程度地减少域级别的合并冲突,确实发生的冲突可以通过命令合并。
- 基于任务的用户界面,用户在该界面可按照一系列步骤组成的复杂过程指南或通过复杂域模型指南来操作。 写入模型具有完整的命令处理堆栈,其中包括业务逻辑、输入验证和业务验证。 写入模型可将一组关联对象视为数据更改的单个单位(DDD 术语中的一个聚合),并确保这些对象始终处于一致状态。 读取模型没有业务逻辑或验证堆栈,只返回 DTO 以在视图模型中使用。 读取模型最终与写入模型保持一致。
- 其中的数据读取性能必须独立于数据写入性能进行微调的方案,尤其是当读取次数远大于写入次数时。 在此方案中,可以横向扩展读取模型,但仅在少数实例上运行写入模型。 一小部分写入模型实例还有助于最大程度减少合并冲突。
- 应用场景:一个开发团队可专注于复杂域模型(作为写入模型一部分),而另一团队可专注于读取模型和用户界面。
- 应用场景:系统会随着时间不断演变,并且可能会包含多个版本的模型,或业务规则会定期更改。
- 与其他系统集成时(尤其是与事件溯源集成时),一个子系统的临时故障错误不允许影响其他子系统的可用性。
对于以下情况不建议使用此模式:
- 域或业务规则非常简单。
- 简单的 CRUD 样式用户界面和数据访问操作就足够了。
请考虑将 CQRS 应用于系统中最能实现其价值的有限部分。
事件溯源和 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)
{
...
}
}
后续步骤
实现此模式时,以下模式和指南将非常有用:
- Data Consistency Primer(数据一致性入门)。 描述通常在使用 CQRS 模式时由于读取和写入数据存储之间的最终一致性而引起的问题,以及如何解决这些问题。
- 水平、垂直和功能数据分区。 介绍有关将数据划分为可单独管理和访问的分区,以提高可伸缩性、减少争用和优化性能的最佳做法。
- 模式与实践指南 CQRS 之旅。 具体而言,查询责任分离模式命令简介探讨了模式及其适用性,尾声:学到的经验教训介绍使用此模式时会遇到的一些问题。
Martin Fowler 的博客文章:
- What do you mean by "Event-Driven"?(“事件驱动”是什么意思?)
- CQRS