命令查询责任分离(CQRS)是一种设计模式,可将数据存储的读取和写入操作隔离到单独的数据模型中。 此方法允许每个模型独立优化,并可以提高应用程序的性能、可伸缩性和安全性。
上下文和问题
在传统体系结构中,单个数据模型通常用于读取和写入作。 此方法非常简单,适用于基本的创建、读取、更新和删除(CRUD)操作。
随着应用程序的增长,优化单个数据模型的读取和写入操作变得越来越困难。 读取和写入操作通常具有不同的性能和扩展性需求。 传统的 CRUD 体系结构不考虑这种不对称性,这可能会导致以下挑战:
数据不匹配: 数据读取和写入表示形式通常有所不同。 更新操作中必需的某些字段在读取操作时可能不需要。
锁争用: 同一数据集上的并行操作可能会导致锁争用。
性能问题: 传统方法可能会对性能产生负面影响,因为数据存储和数据访问层加载,以及检索信息所需的查询的复杂性。
安全挑战: 当实体受到读取和写入作的约束时,管理安全性可能很困难。 这种重叠可以在意外的上下文中公开数据。
合并这些职责可能会导致模型过于复杂。
解决方案
使用 CQRS 模式将写入作或 命令与读取作或 查询分开。 命令更新数据。 查询用于获取数据。 CQRS 模式适用于需要在命令和读取之间明确分离的方案。
了解命令。 命令应表示特定的业务任务,而不是低级别数据更新。 例如,在酒店预订应用中,使用命令“预订酒店房间”,而不是“将 ReservationStatus 设置为 Reserved”。此方法可更好地捕获用户的意图,并将命令与业务流程保持一致。 为了帮助确保命令成功,可能需要优化用户交互流和服务器端逻辑,并考虑异步处理。
精简区域 建议 客户端验证 在发送命令之前验证特定条件,以防止出现明显的故障。 例如,如果没有房间可用,请禁用“预订”按钮并在 UI 中提供明确的用户友好消息,说明为什么无法预订。 此设置可减少不必要的服务器请求并向用户提供即时反馈,从而增强其体验。 服务器端逻辑 增强业务逻辑,以正常处理边缘事例和故障。 例如,为了解决并发冲突,例如多个用户尝试预订最后一个可用房间,可以考虑将用户添加到等待列表中或提供替代建议。 异步处理 异步处理命令,将它们放入队列中,而不是同步处理。 了解查询。 查询永远不会更改数据。 相反,它们返回以方便格式呈现所需数据的数据传输对象(DTO),而无需任何域逻辑。 这种不同的职责分离简化了系统的设计和实现。
单独的读取模型和写入模型
将读取模型与写入模型分离,通过解决数据写入和数据读取的特定问题,简化了系统设计和实现。 这种分离可提高清晰度、可伸缩性和性能,但会带来权衡。 例如,对象关系映射(O/RM)框架等基架工具无法从数据库架构自动生成 CQRS 代码,因此需要自定义逻辑来弥合差距。
以下部分介绍了在 CQRS 中实现读取模型和写入模型分离的两种主要方法。 每种方法都有独特的优势和挑战,例如同步和一致性管理。
单个数据存储中的单独模型
此方法表示 CQRS 的基础级别,其中读取和写入模型共享单个基础数据库,但维护其操作的不同逻辑。 使用基本的 CQRS 体系结构,可以在依赖共享数据存储时从读取模型中划出写入模型。
此方法通过定义用于处理读取和写入问题的不同模型来提高清晰度、性能和可伸缩性。
写入模型 旨在处理更新或保留数据的命令。 它包括验证和域逻辑,并通过优化事务完整性和业务流程来帮助确保数据一致性。
读取模型 旨在提供用于检索数据的查询。 它侧重于生成针对呈现层优化的 DTO 或投影。 它通过避免域逻辑来提高查询性能和响应能力。
不同数据存储中的单独模型
更高级的 CQRS 实现对读取和写入模型使用不同的数据存储。 读取和写入数据存储的分离使你可以缩放每个模型以匹配负载。 它还使你能够对每个数据存储使用不同的存储技术。 可以将文档数据库用于读取数据存储,对写入数据存储使用关系数据库。
使用单独的数据存储时,必须确保两者保持同步。 常见的模式是在更新数据库时让写入模型发布事件,读取模型使用该事件来刷新其数据。 有关如何使用事件的详细信息,请参阅 事件驱动的体系结构样式。 由于通常无法将消息代理和数据库整合到单个分布式事务中,更新数据库及发布事件时可能会出现一致性问题。 有关详细信息,请参阅 幂等消息处理。
读取数据存储可以使用自己的针对查询优化的数据架构。 例如,它可以存储数据的物化视图,以避免复杂的连接或 O/RM 映射。 读取数据存储可以是写入存储的只读副本,也可以具有不同的结构。 部署多个只读副本可以通过降低延迟和提高可用性来提高性能,尤其是在分布式方案中。
CQRS 的优点
独立缩放。 CQRS 使读取模型和写入模型能够独立缩放。 此方法可帮助最大程度地减少锁争用并提高负载下的系统性能。
优化的数据架构。 读取操作可以使用针对查询进行优化的模式。 写入操作使用针对更新优化的模式。
安全性。 通过分隔读取和写入,可以确保只有适当的域实体或操作有权对数据执行写入操作。
关注点分离。 分离读取和写入责任会导致更简洁、更易于维护的模型。 写入端通常处理复杂的业务逻辑。 读取端可以保持简单且专注于查询效率。
更简单的查询。 在读取数据库中存储具体化视图时,应用程序可以在查询时避免复杂的联接。
问题和注意事项
在决定如何实现此模式时,请考虑以下几点:
复杂性增加。 CQRS 的核心概念非常简单,但它可以在应用程序设计中引入显著的复杂性,尤其是在与 事件溯源模式结合使用时。
信息交流挑战 消息传送不是 CQRS 的要求,但通常使用它来处理命令和发布更新事件。 包含消息交流时,系统必须考虑到潜在的问题,例如消息失败、重复和重试。 有关处理具有不同优先级的命令的策略的详细信息,请参阅 Priority 队列。
最终一致性。 当读取数据库和写入数据库分开时,读取数据可能不会立即显示最近的更改。 这种延迟会导致过时的数据。 确保读取模型存储保持最新状态,写入模型存储中的更改可能具有挑战性。 此外,检测和处理用户对过时数据执行操作的方案需要仔细考虑。
何时使用此模式
在以下情况下使用此模式:
可在协作环境中工作。 在多个用户同时访问和修改相同数据的环境中,CQRS 有助于减少合并冲突。 命令可以包含足够的粒度来防止冲突,并且系统可以解决命令逻辑中发生的任何冲突。
你有基于任务的用户界面。 通过一系列步骤或复杂域模型引导用户完成复杂流程的应用程序将受益于 CQRS。
写入模型具有完整的命令处理堆栈,其中包括业务逻辑、输入验证和业务验证。 写入模型可以将一组关联的对象视为数据更改的单个单元,这称为域驱动设计术语中的 聚合 。 写入模型还可能有助于确保这些对象始终处于一致状态。
读取模型没有业务逻辑或验证堆栈。 它返回用于视图模型的 DTO。 读取模型最终与写入模型保持一致。
需要性能优化。 必须对数据读取性能与数据写入性能分开微调的系统受益于 CQRS。 当读取数大于写入数时,此模式尤其有用。 读取模型横向扩展以处理大量查询。 写入模型在更少的实例上运行,以最大程度地减少合并冲突并保持一致性。
你实现了开发关注点的分离。 CQRS 允许团队独立工作。 一个团队在写入模型中实现复杂的业务逻辑,另一个团队开发读取模型和用户界面组件。
你有不断发展的系统。 CQRS 支持随着时间推移而演变的系统。 它适应新的模型版本、对业务规则的频繁更改或其他修改,而不会影响现有功能。
需要系统集成: 与其他子系统(尤其是使用事件溯源模式的系统)集成的系统仍然可用,即使子系统暂时失败也是如此。 CQRS 隔离故障,从而阻止单个组件影响整个系统。
在以下情况下,此模式可能不适用:
域或业务规则非常简单。
简单的 CRUD 样式用户界面和数据访问操作就足够了。
工作负荷设计
评估如何在工作负荷的设计中使用 CQRS 模式来解决 Azure Well-Architected Framework 支柱中涵盖的目标和原则。 下表提供有关此模式如何支持性能效率支柱目标的指南。
支柱 | 此模式如何支持支柱目标 |
---|---|
通过缩放、数据和代码的优化,性能效率可帮助工作负荷高效地满足需求。 | 在高读写比工作负载中,读取操作和写入操作的分离支持进行针对每个操作特定目的的性能和扩展性优化。 - PE:05 缩放和分区 - PE:08 数据性能 |
考虑这种模式可能对其他支柱目标造成的任何权衡。
合并事件溯源和 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)
{
...
}
}
后续步骤
实现此模式时,以下信息可能相关:
- 数据分区指南 介绍了如何将数据划分为可以单独管理和访问的分区,以提高可伸缩性、减少争用和优化性能的最佳做法。