CQRS架构调研报告:linkerlin/cqrs项目分析

1. CQRS架构基本原理与核心概念

1.1 CQRS模式定义

命令查询职责分离(Command Query Responsibility Segregation, CQRS)是一种软件架构模式,其核心思想在于将应用程序的数据读取操作(查询,Query)和数据修改操作(命令,Command)分离开来,使用不同的模型进行处理 。这种分离不仅仅是代码层面的职责划分,更是一种深层次的架构设计理念的转变。传统的CRUD(Create, Read, Update, Delete)架构通常使用单一的数据模型来处理所有的数据操作,这在业务逻辑相对简单的场景下是可行的。然而,随着业务复杂度的增加,单一模型往往难以同时满足读写双方的不同需求,例如,读取操作可能需要复杂的联表查询和聚合计算以优化展示效果,而写入操作则更关注数据的一致性和业务规则的校验。CQRS通过明确区分命令和查询的职责,为这两类操作提供了独立优化和演进的路径,从而提升系统的整体性能、可伸缩性和安全性 。Greg Young在2010年左右正式提出并定义了CQRS模式,他强调CQRS遵循了Meyer对命令和查询的定义,并认为它们应该是纯粹的,其根本区别在于CQRS中对象被拆分为两个对象,一个包含命令,另一个包含查询 。

CQRS模式并非一种全新的发明,它是对Bertrand Meyer提出的「命令查询分离」(Command-Query Separation, CQS)原则在架构层面的延伸和扩展。CQS原则主要应用于对象设计层面,强调一个对象的方法要么是执行某种动作的命令(不返回数据,但可能改变对象状态),要么是返回数据的查询(不改变对象状态)。CQRS将这一思想提升到系统架构层面,将读模型和写模型进行物理或逻辑上的分离。这种分离的程度可以根据实际需求灵活调整,从最简单的代码层面分离到完全独立的数据库和服务的分离 。例如,在一个电商系统中,商品信息的展示(读操作)和商品信息的修改(写操作)就可以采用CQRS模式进行设计。读操作可以直接查询针对展示优化的缓存或视图,而写操作则通过专门的命令处理器进行业务校验和数据持久化,并通过事件机制同步更新读模型。在微服务架构中,CQRS模式尤为重要,它能够帮助解决跨服务数据共享、读写负载不均以及不同服务对数据模型需求不同等问题 。

1.2 核心原则:命令与查询职责分离

CQRS架构的核心原则在于严格区分命令(Command)和查询(Query)的职责。 命令是指那些会改变系统状态的操作,例如创建订单、更新用户信息、删除商品等。这些操作通常包含业务逻辑的校验和执行,并且不直接返回数据给调用方,或者仅返回操作结果(成功/失败)。查询则是指那些不会改变系统状态,仅用于获取数据的操作,例如获取订单列表、查询用户详情、搜索商品等。查询操作应该尽可能简单高效,避免复杂的业务逻辑处理 。通过这种职责分离,可以使得命令模型和查询模型能够独立地进行设计、优化和扩展,从而更好地适应不同操作类型的特定需求。这种分离借鉴了Bertrand Meyer提出的命令查询分离(Command-Query Separation, CQS)原则,该原则主张一个方法要么是执行某种动作的命令,要么是返回数据的查询,而不应该两者皆是 。

这种分离带来的直接好处是架构的清晰度和可维护性的提升。开发人员可以更专注于特定类型的操作,例如,负责命令模型的开发人员可以深入理解业务规则和领域逻辑,确保数据变更的准确性和一致性;而负责查询模型的开发人员则可以专注于数据展示的需求,优化查询性能和用户体验。此外,由于读写操作被解耦,它们可以独立地进行扩展。例如,在一个读多写少的系统中,可以部署更多的查询服务实例来应对高并发的读取请求,而命令服务则可以保持较小的规模。这种分离也为采用不同的技术栈和存储方案提供了可能性,例如,写模型可以使用关系型数据库保证事务完整性,而读模型可以使用NoSQL数据库或搜索引擎来优化查询效率 。这种清晰的职责划分也使得系统更容易理解和调试。

1.3 读写模型分离

在CQRS架构中,读写模型的分离是其核心特征之一。 这意味着系统至少包含两个独立的数据模型:一个用于处理写操作(命令模型),另一个用于处理读操作(查询模型)。这两个模型可以共享同一个物理数据库,也可以使用完全独立的数据库。当使用独立的数据库时,通常需要一个同步机制来确保查询模型能够及时反映命令模型所做的更改,这通常会引入最终一致性的概念 。命令模型通常设计为面向领域模型,包含复杂的业务逻辑和验证规则,确保数据在写入时的正确性和一致性。它可能采用领域驱动设计(DDD)中的聚合根等概念来组织数据。

查询模型则完全不同,它被设计为面向展示和查询需求,其结构通常经过优化以支持高效的查询操作。这意味着查询模型可能是高度非规范化的,甚至是为特定查询场景量身定制的物化视图 。例如,为了快速显示用户的订单列表,可以专门构建一个只读的订单历史记录服务,该服务订阅订单服务发布的事件,并维护一个针对订单列表查询优化的数据存储 。这种分离允许开发者为不同的操作选择最合适的技术和数据存储。例如,命令模型可能使用SQL数据库,而查询模型可以使用Redis等内存数据库作为缓存或主存储 。读写模型的分离是CQRS提升系统性能、可伸缩性和灵活性的关键所在。在一些更高级的CQRS实现中,读写模型甚至会使用完全不同的数据存储 。例如,写模型可能将数据持久化到一个关系型数据库中,而读模型则从一个专门为查询优化的NoSQL数据库或内存数据库(如Redis)中获取数据 。

1.4 优势与考量

CQRS架构模式带来了多方面的优势,使其在特定场景下成为一种有价值的架构选择。 首先,CQRS能够显著提升系统的性能和可扩展性。通过将读写操作分离,可以针对各自的负载特性进行独立优化和扩展 。例如,读密集型应用可以部署更多的读服务实例,并使用缓存、物化视图等技术来加速查询;而写操作则可以集中在较少的实例上,以减少并发冲突并保证数据一致性 。其次,CQRS提高了系统的灵活性。读写模型可以使用不同的数据结构和存储技术,使得数据模型能够更好地适应业务需求的变化 。例如,可以为特定的查询需求创建高度优化的读模型,而无需修改复杂的写模型。此外,CQRS有助于实现关注点分离,使得开发团队可以更独立地工作在读模型和写模型上,从而提高开发效率和代码可维护性 。在安全性方面,由于读写操作被分离,可以更精细地控制对数据的访问权限,降低数据在非预期上下文中暴露的风险 。CQRS与事件溯源(Event Sourcing)模式结合使用时,还能提供完整的审计日志和历史状态追溯能力,这对于金融、医疗等对数据变更历史有严格要求的领域尤为重要 。

然而,采用CQRS架构也需要仔细考量其带来的复杂性和成本。 最主要的考量是系统复杂度的增加。引入独立的读写模型、事件驱动机制以及数据同步逻辑,都会使系统的设计和实现变得更加复杂 。开发团队需要具备相关的领域知识和经验,才能有效地应用CQRS。其次,数据一致性是一个核心挑战。由于读模型通常是最终一致的,这意味着在数据更新后的一段时间内,用户可能无法立即看到最新的数据 。这对于需要强一致性保证的业务场景可能不适用。此外,CQRS可能会引入数据冗余,因为读模型通常需要存储数据的副本或物化视图,这会增加存储成本和数据同步的开销 。最后,CQRS并非银弹,它更适合于那些读写负载不均衡、业务逻辑复杂、对性能和可扩展性有较高要求的系统。对于简单的CRUD应用,引入CQRS可能会带来不必要的开销和复杂性 。因此,在决定是否采用CQRS时,需要仔细评估其带来的收益和成本,并根据具体的业务需求和技术背景做出权衡。

2. linkerlin/cqrs项目实现细节

2.1 项目概述与技术栈

linkerlin/cqrs项目是一个基于CQRS架构的全栈内容管理系统(CMS)框架 。该项目旨在提供一个实践CQRS设计模式的示例,并展示了如何利用现代技术栈构建一个解耦的、可扩展的应用程序。根据项目README提供的信息,该系统明确采用了CQRS架构,并利用Redis作为消息队列和缓存层,以实现读写操作之间的通信和数据同步 。这表明项目在设计上充分考虑了CQRS的核心原则,即命令和查询的职责分离,并通过引入消息队列来解耦读写两端,以及利用缓存来提升查询性能。

在技术选型方面,linkerlin/cqrs项目采用了前后端分离的开发模式。前端技术栈主要包括React 18、TypeScript、Vite以及Tailwind CSS,并使用Axios进行HTTP通信 。React作为目前主流的前端框架之一,能够提供高效的UI渲染和组件化开发体验。TypeScript的引入则增强了代码的可维护性和类型安全。Vite作为新一代的前端构建工具,以其快速的冷启动和热模块替换能力著称,能够提升开发效率。Tailwind CSS则是一种实用优先的CSS框架,有助于快速构建自定义的用户界面。后端技术栈则基于NestJS框架,同样采用TypeScript进行开发,并使用Redis作为核心的基础设施组件,负责消息队列和缓存功能 。NestJS是一个用于构建高效、可扩展的Node.js服务器端应用程序的框架,它借鉴了Angular的架构思想,提供了依赖注入、模块化等特性,非常适合构建结构清晰的CQRS后端服务。Redis则凭借其高性能的内存数据存储和丰富的数据结构,在消息队列和缓存场景下表现出色。

2.2 核心架构特点:基于Redis List的双向消息通讯

linkerlin/cqrs项目在CQRS架构的实现上,其核心创新点在于利用Redis List数据结构实现了双向消息通讯机制 。这一设计选择对于理解项目的整体架构至关重要。传统的CQRS实现中,命令从客户端发送到命令处理端,命令处理端执行写操作后,通常会通过事件总线或消息队列将领域事件发布出去,查询端订阅这些事件来更新其读模型。linkerlin/cqrs项目采用Redis List作为消息队列,不仅用于命令处理端向查询端发送数据更新通知(例如,领域事件),还可能用于实现查询端向命令处理端发送某些请求或反馈,从而实现「双向」通讯。

Redis List是一个简单的字符串列表,按照插入顺序排序,支持在列表的两端进行高效的插入和删除操作(LPUSH, RPUSH, LPOP, RPOP等)。这种特性使其非常适合用作消息队列。在linkerlin/cqrs的上下文中,当命令处理服务(写模型)成功处理一个命令并更新了数据后,它可以将一个包含更新信息的事件或消息LPUSH到一个特定的Redis List中。查询处理服务(读模型)则可以BRPOP(阻塞式右端弹出)或类似命令从这个List中获取消息,并根据消息内容更新其读数据库或缓存。如果实现了真正的「双向」通讯,那么查询端也可能将某些请求(例如,需要命令端处理某些任务或获取某些信息)通过另一个Redis List发送给命令端。这种基于Redis List的实现方式相对轻量级,并且能够利用Redis的高性能和持久化特性。然而,它也意味着项目需要自行处理消息的序列化、反序列化、错误处理、消息确认以及可能的消息重试等机制,这些在成熟的消息队列系统中通常是内置的功能。项目README中特别强调这一点作为核心创新,表明其在消息传递机制上可能进行了一些定制化的设计,以更好地适应CQRS模式的需求,并充分利用Redis List的特性。

2.3 查询(Query)流程解析

linkerlin/cqrs项目中,查询流程(Query)的设计充分体现了CQRS架构的核心思想,即优化读取操作。 当后端Service层接收到来自前端(React应用)的HTTP请求时,首先会判断该请求是否为查询请求 。如果确认是查询请求,Service层会直接尝试从Redis缓存中获取所需数据。Redis作为高性能的键值存储数据库,能够快速响应缓存查询。如果缓存中存在请求的数据(即缓存命中,Cache Hit),Service层会直接将缓存数据返回给前端,从而避免了后续可能涉及数据库的复杂查询操作,显著提升了系统的响应速度和吞吐量。这种设计对于读多写少的应用场景尤其有利,能够有效减轻数据库的压力。整个查询流程简洁高效,Service层在缓存命中的情况下,其职责非常明确,即作为缓存的消费者和数据的提供者,不涉及任何业务逻辑的复杂处理或数据库的直接交互,保证了查询路径的快速和轻量。

如果Redis缓存中不存在请求的数据(即缓存未命中,Cache Miss),则查询流程会转入一个特殊的处理逻辑。 根据项目的描述,当缓存未命中时,Service层并不会直接去查询数据库,而是将这个查询请求(或者说是触发该查询请求的命令)以JSON格式推入一个Redis List中。这个List可以被视为一个待处理的命令队列或任务队列。随后,后台的Job层会通过BRPOP(Blocking Right Pop)操作从这个Redis List中消费这个任务。Job层负责执行实际的数据库查询操作,获取数据,并将数据写入Redis缓存,以便后续相同的查询请求可以直接命中缓存。这种设计将缓存填充的责任从Service层转移到了Job层,使得Service层可以更专注于处理HTTP请求和返回数据,而将耗时的数据加载和缓存更新操作异步化 。这种机制也隐含了当缓存未命中时,用户请求可能需要等待Job层处理完成并填充缓存后才能得到响应,或者系统需要一种机制来通知用户数据正在加载。

2.4 命令(Command)流程解析

linkerlin/cqrs项目中,命令(Command)流程负责处理那些会改变系统状态的操作,例如创建、更新或删除数据,以及缓存未命中时触发的数据加载任务。 当Service层接收到一个HTTP请求,并且该请求是一个命令请求时(例如,创建一个新的订单),或者是一个查询请求但发生了缓存未命中,Service层并不会直接执行这个命令所对应的业务逻辑,比如直接操作数据库。相反,Service层会将这个命令(通常包含执行操作所需的数据)序列化为JSON格式,然后将其推入一个预先定义好的Redis List中。这个Redis List充当了一个命令队列,用于暂存所有待处理的命令 。

命令的后续处理则由独立的Job层负责。 Job层会持续监控这个命令队列(Redis List),并使用BRPOP(Blocking Right Pop)这样的阻塞弹出操作来获取队列中的命令。BRPOP命令确保了当队列为空时,Job层的消费者线程会进入等待状态,直到有新的命令被推入队列。一旦Job层从队列中获取到一个命令,它就会对这个命令进行反序列化,解析出命令的类型和参数,然后调用相应的命令处理器来执行实际的业务逻辑。命令处理器可能会涉及到对数据库的写入操作、对其他服务的调用、或者触发领域事件等。在处理完任务后,Job层将结果推送到Service层指定的返回队列,并负责将必要的数据更新到Redis缓存中(根据小改款的设计)。Service层接收到返回的数据后,会将其返回给前端。这个流程清晰地分离了命令的执行和数据加载的责任,使得Service层可以快速响应用户请求,而将耗时或复杂的操作交由异步的Job层处理。

2.5 Service层与Job层的职责与协作

linkerlin/cqrs项目中,Service层和Job层各自承担着清晰的职责,并通过Redis List实现高效的协作,共同构成了CQRS架构的核心运作机制

Service层的职责主要集中在以下几个方面:

  1. 接收HTTP请求:作为系统的入口点,Service层负责接收来自客户端的HTTP请求,无论是查询请求还是命令请求。
  2. 处理查询请求(Cache Hit):对于查询请求,Service层首先尝试从Redis缓存中获取数据。如果数据存在(Cache Hit),则直接返回给客户端。
  3. 触发命令处理和缓存填充(Cache Miss):当遇到查询请求但缓存未命中(Cache Miss),或者接收到命令请求时,Service层并不直接执行复杂的业务逻辑或数据库操作。而是将这些请求转化为任务,序列化为JSON格式,并将其推入相应的Redis List(命令队列或任务队列)中,等待Job层处理 。
  4. (可能存在的)等待和响应Job处理结果:在某些场景下,例如缓存未命中后需要同步返回数据,Service层可能需要等待Job层处理完任务并将结果(例如,填充的缓存数据)通过另一个Redis List返回。这取决于具体的交互设计是同步还是异步。

Job层的职责则主要集中在后台任务的执行:

  1. 监听和处理命令/任务:Job层通过BRPOP等操作持续监听Redis List中的命令或任务。当有新的任务到达时,Job层会将其弹出队列。
  2. 执行业务逻辑:对于从队列中获取的命令,Job层负责执行相应的业务逻辑。这可能包括对数据库的读写操作、复杂的计算、调用外部服务等。例如,处理一个「创建订单」的命令,Job层会校验数据、操作数据库写入订单信息。
  3. 填充和更新缓存:特别是在查询流程中,当Service层因缓存未命中而将任务交给Job层时,Job层负责从数据库或其他数据源加载数据,并将加载到的数据写入Redis缓存,以便后续的查询请求可以直接命中缓存 。
  4. (可能存在的)返回处理结果:如果Service层需要Job层的处理结果,Job层在处理完任务后,可以将结果推送到另一个Redis List,供Service层消费。

Service层与Job层的协作模式是基于消息队列的异步处理。 Service层作为前端请求的接收者和初步处理者,将耗时或复杂的任务「委托」给Job层。Job层作为后台的「工人」,负责执行这些任务。它们之间的通讯完全通过Redis List进行,实现了松耦合。这种协作方式使得Service层可以保持轻量和快速响应,而Job层则可以专注于业务逻辑的执行和数据持久化,并且可以根据负载情况进行独立扩展。例如,可以部署多个Job实例来并行处理队列中的任务,提高系统的整体处理能力。

3. 缓存处理小改款设计与实现

3.1 改款背景:Service层与缓存写入职责分离

在传统的CQRS架构实现中,或者更广泛的Web应用设计中,Service层在处理查询请求时,如果遇到缓存未命中(Cache Miss)的情况,通常的作法是Service层自身负责从数据库加载数据,并在返回数据给客户端之前,将加载到的数据写入缓存。这种设计虽然直观,但也存在一些固有的问题。最主要的问题是它违反了单一职责原则,使得Service层的职责变得不够纯粹。 Service层不仅需要处理业务逻辑、协调各个组件,还需要关心缓存的维护和更新。这会导致Service层的代码变得复杂,难以维护和测试。此外,如果缓存写入逻辑需要修改,或者需要引入更复杂的缓存策略(例如,缓存预热、缓存失效策略的精细化控制等),都可能导致Service层的代码频繁变动。

linkerlin/cqrs项目提出的这个小改款,其核心目标就是将缓存填充的职责从Service层中剥离出来,使得Service层不再包含任何直接写入缓存的代码 。这样做的好处是显而易见的:Service层可以更加专注于其核心职责——处理业务逻辑和协调请求响应。缓存的维护则交给专门的组件(Job层)来处理。这种职责的进一步分离,使得系统架构更加清晰,各个组件的边界更加明确,有利于提高代码的可读性、可维护性和可测试性。同时,这也为后续缓存策略的优化和扩展提供了更大的灵活性,例如可以独立地调整Job层的缓存写入逻辑,而无需修改Service层的代码。这种职责分离的设计,使得系统的各个组件更加内聚,降低了耦合度,并为缓存的精细化管理提供了可能。

3.2 设计思路:Cache Miss后数据填充任务交由Job处理

基于将缓存写入职责从Service层剥离的背景,linkerlin/cqrs项目的小改款提出了一个明确的设计思路:当Service层在处理查询请求时发生缓存未命中(Cache Miss),不再由Service层自身去数据库加载数据并写入缓存,而是将这个「数据填充缓存」的任务交给独立的Job来处理 。这个设计思路严格遵循了CQRS架构的核心理念,即命令(写操作,此处指缓存写入)和查询(读操作)的彻底分离。Service层只负责读取缓存和触发数据加载请求,而实际的数据库交互和缓存更新则由Job层异步完成。

具体来说,当Service层发现所需数据不在缓存中时,它会生成一个「缓存填充任务」,这个任务包含了加载数据所需的关键信息(例如,数据的唯一标识符)。然后,Service层将这个任务通过消息队列(在此项目中是Redis List)发送出去。Job层作为消息的消费者,会从队列中获取到这个任务,并根据任务描述从数据库中加载相应的数据。数据加载完成后,Job层再将数据写入Redis缓存。通过这种方式,Service层完全解耦了与缓存写入相关的逻辑,其代码更加简洁,只关注于业务处理和响应。这种设计不仅提升了Service层的纯粹性,也使得缓存的管理更加集中和专业化,Job层可以根据需要实现更复杂的缓存策略,如批量写入、延迟写入、缓存失效控制等,而不会对Service层的性能和逻辑造成影响。

3.3 实现方式

3.3.1 利用现有消息队列机制

linkerlin/cqrs项目中小改款的实现,巧妙地利用了项目中已有的基于Redis List实现的消息队列机制 。这个机制原本用于在Service层和Job层之间传递命令和处理结果,现在被扩展用于传递缓存填充任务。当Service层遇到缓存未命中时,它会将需要填充缓存的数据标识(例如,一个实体ID)封装成一个消息对象,然后通过LPUSH等命令将这个消息推送到一个专门用于缓存填充任务的Redis List中。这个List可以看作是一个任务队列,存放着所有待处理的缓存填充请求。

Job层会持续监听这个特定的Redis List,通过BRPOP命令进行阻塞式读取。一旦有新的缓存填充任务进入队列,Job层便会获取到该任务。这种消息队列的机制确保了缓存填充任务的异步处理,Service层在发出任务后无需等待任务完成,可以立即返回响应或进行其他操作。同时,Redis List的先进先出(FIFO)特性也保证了任务的处理顺序,尽管在CQRS架构中,对于缓存填充这类操作,严格的顺序性可能不是首要考虑,但基本的顺序保证有助于系统的可预测性。利用现有消息队列机制,避免了引入新的中间件,简化了系统架构,并充分利用了Redis的高性能和可靠性。

3.3.2 Service层:触发缓存填充任务

linkerlin/cqrs项目的小改款设计中,Service层在缓存处理方面的职责被显著简化,其核心任务是触发缓存填充任务,而不是直接执行缓存写入 。当Service层接收到一个查询请求,并发现所需数据在Redis缓存中不存在(Cache Miss)时,它会构造一个代表「缓存填充」需求的命令或消息。这个命令包含了足够的信息,使得后续的Job层能够准确地从数据库中检索出缺失的数据,例如,可能包含实体类型、实体ID等关键参数。构造完这个命令后,Service层会利用项目已有的基于Redis List的消息队列机制,将这个命令对象(通常序列化为JSON格式)推送到一个预定义的、专门用于处理此类缓存填充任务的Redis List中。

完成消息推送后,Service层的关于缓存填充的职责就基本结束了。它不会等待Job层实际完成数据的加载和缓存的写入。如果这是一个查询请求,并且系统设计为同步等待数据,那么Service层可能会在另一个返回List上等待Job处理后的数据(如项目基础架构所述)。但关键在于,Service层自身不包含将数据写入Redis缓存的代码逻辑。这种设计确保了Service层的轻量化和职责单一性,使其能够更快速地响应用户请求,并将数据持久化和缓存管理的复杂性下放到Job层。这种触发机制是异步的,有助于提高系统的并发处理能力和整体吞吐量。

3.3.3 Job层:执行数据加载与缓存写入

在小改款的设计中,Job层承担了缓存未命中时数据加载和缓存写入的核心职责 。Job层作为一个独立的处理单元,持续监听特定的Redis List,这个List专门用于接收由Service层触发的缓存填充任务。当Job层通过BRPOP命令从队列中获取到一个新的缓存填充任务时,它会解析任务内容,提取出加载数据所需的关键信息,例如要查询的数据实体类型和唯一标识符。

根据这些信息,Job层会执行必要的业务逻辑以从主数据库(或其它数据源)中加载所需的数据。这可能涉及到调用数据库查询接口、执行复杂的查询语句或访问其他微服务。一旦数据成功从数据库加载,Job层的下一个关键步骤就是将这些数据写入Redis缓存。写入缓存的操作由Job层全权负责,这包括确定缓存键(Cache Key)的生成策略、数据的序列化方式、以及设置缓存的过期时间(TTL)等。通过将数据加载和缓存写入的逻辑都封装在Job层,实现了与Service层的解耦。Service层不再关心数据是如何被加载和缓存起来的,它只需要知道当缓存未命中时,发出一个任务请求即可。这种集中化的缓存管理方式,使得缓存策略的调整和优化(例如,改变缓存数据结构、引入更复杂的缓存失效机制等)都可以在Job层独立进行,而不会影响到Service层的代码和性能。

3.4 避免重复写入缓存的机制

3.4.1 任务去重与幂等性处理

在将缓存填充任务交由Job异步处理的改款设计中,一个潜在的问题是可能会发生重复的缓存写入操作,尤其是在高并发场景下或者消息队列出现某些异常(如消息重复投递)时。为了避免这种情况,引入任务去重和幂等性处理机制至关重要。 任务去重是指在Job层处理任务之前,先检查该任务是否已经被处理过或者正在被处理。这可以通过在消息体中携带一个唯一任务ID(例如,由请求ID、数据标识符和时间戳等组合生成)来实现。Job层在处理任务前,先查询一个临时的去重存储(例如,一个设置了较短过期时间的Redis键,键名为任务ID),如果该ID已存在,则说明任务可能重复,可以直接丢弃或跳过。如果不存在,则将该ID写入去重存储,然后继续处理任务。

幂等性处理则是指即使同一个缓存填充任务被多次执行,最终的结果也是一致的,不会导致数据错误或缓存状态混乱。 对于缓存写入操作,由于其本质是「设置」一个键值对,多次设置同一个键值对(值不变的情况下)本身是具有幂等性的。但是,如果任务涉及到更复杂的操作,例如从数据库读取最新数据然后更新缓存,那么就需要确保整个「读取-处理-写入」流程的幂等性。例如,可以在写入缓存时采用特定的命令(如Redis的SET key value NX,仅在键不存在时设置),或者通过版本号、时间戳等机制来判断缓存中的数据是否已经是最新,避免用旧数据覆盖新数据。在linkerlin/cqrs项目的架构中,由于Service层在缓存未命中时会将请求转入Command流程,并通过消息队列通知Job处理 ,如果消息队列本身不保证Exactly-Once语义,那么Job层就需要实现幂等性逻辑来应对可能的重复消息。例如,在CSDN博客中关于CQRS架构基础的文章提到了通过事件ID或版本号避免重复处理的重要性 。

3.4.2 通过Job统一管理缓存写入

linkerlin/cqrs项目的小改款设计,其核心思想之一是通过将缓存填充任务完全交由Job层处理,从而实现缓存写入逻辑的统一管理这种设计从根本上避免了在Service层和Job层中重复编写缓存写入代码的问题。在传统的或者不够彻底分离的CQRS实现中,Service层在遇到Cache Miss时可能会直接查询数据库并写入缓存,同时,Job层在处理某些命令(例如数据更新命令)后,也可能需要更新缓存以保证数据一致性。这就导致了缓存写入的逻辑分散在系统的不同部分,增加了代码的冗余和维护的复杂性。

通过将缓存填充(无论是由于Cache Miss还是数据更新引起的)都统一交由Job层处理,缓存写入的入口就只有一个。Service层完全不再包含任何写缓存的代码,它只负责读取缓存和触发缓存更新任务。所有与「如何写缓存」、「何时写缓存」相关的决策和逻辑都集中在Job层。例如,Job层可以根据业务需求决定缓存的过期策略、缓存的序列化格式、以及在写入缓存前是否需要复杂的数据转换或聚合。这种集中管理的方式,不仅消除了代码重复,还使得缓存策略的调整和优化变得更加容易和可控。如果需要修改缓存逻辑,只需要在Job层进行修改,而无需关心Service层的调用。这种设计也使得缓存相关的监控、日志和错误处理可以更加集中和统一,提高了系统的可观察性和可维护性。例如,在哔哩哔哩上关于CQRS架构下异步事件治理的实践中,提到Job服务负责消费异步事件,并从Kafka中获取消息后处理和写入至后续的MySQL和缓存中 ,这体现了Job统一处理数据写入(包括缓存)的思路。

《CQRS架构调研报告:linkerlin/cqrs项目分析》有1条评论

发表评论

人生梦想 - 关注前沿的计算机技术 acejoy.com 🐾 步子哥の博客 🐾 背多分论坛 🐾 知差(chai)网 🐾 DeepracticeX 社区 🐾 老薛主机 🐾 智柴论坛 🐾