Akka命令校验方案探讨:用户分类分配的系统一致性问题
在Akka + CQRS/ES架构下处理跨聚合的存在性校验
这确实是用Akka做CQRS/ES时经常碰到的一致性难题,咱们先拆解你提到的两个方案的问题,再结合架构原则给出更合适的思路:
先聊聊你纠结的两个方案的问题
- 方案1(Ask CategoriesActor):你担心违背Tell-Don't-Ask原则是对的,但这里要区分「命令侧聚合根」和「查询侧读模型」:如果
CategoriesActor是负责Category业务逻辑的命令侧聚合根,直接Ask它的状态确实违反了Tell-Don't-Ask——因为聚合根的状态应该只通过接收命令来改变,而不是被外部查询。但如果换成查询侧的CategoriesQueryActor(基于Projection生成的读模型),Ask就完全合理,因为读模型的设计目的就是对外提供查询服务,请求-响应是它的本职工作。 - 方案2(ValidatorActor):这种辅助Actor的思路确实会导致系统中Actor数量膨胀,而且如果
UserActor是持久化FSM,「等待校验结果」的状态需要被持久化,暂存的命令也得跟着存,后续恢复时还要处理重试、超时等问题,会大幅增加状态管理的复杂度,很容易引入bug。
适合CQRS/ES架构的解决方案
根据业务对一致性的要求,有几种不同的处理方式:
1. 基于读模型的前置校验(推荐,适配绝大多数场景)
在CQRS/ES中,读模型(Projection)是实时从事件流生成的,虽然是最终一致,但对于大部分业务场景来说,这种一致性已经足够。具体操作:
- 当
UserActor收到AssignCategory命令时,先通过Ask查询CategoriesQueryActor(读模型Actor),确认目标CategoryId存在。 - 如果存在,直接处理命令,生成
UserCategoryAssigned事件;如果不存在,直接拒绝命令并返回错误。 - 如果你觉得在
UserActor里做查询有点耦合,也可以把校验逻辑提到命令网关层:所有发给UserActor的AssignCategory命令先经过网关,网关查询读模型校验通过后再转发给UserActor,这样UserActor可以专注处理自己的核心业务,不用关心校验逻辑。
这种方式既避免了Tell-Don't-Ask的问题(因为查询的是读模型而非命令侧聚合根),也不需要额外的辅助Actor,持久化FSM也不用处理复杂的等待状态——大不了重启后重新查询一次读模型就行。
2. 用Saga(事务协调者)处理强一致性场景
如果你的业务要求绝对强一致性(比如分类必须100%存在才能分配给用户,不接受读模型的最终一致延迟),可以引入Saga来协调跨聚合的操作:
- 当系统收到「给用户分配分类」的请求时,先发给
AssignUserCategorySaga。 - Saga先向
CategoriesActor发送CheckCategoryExistence命令(Tell模式),CategoriesActor处理后回复CategoryExists或CategoryNotFound。 - Saga收到回复后,如果分类存在,就给
UserActor发送AssignCategory命令;如果不存在,就向请求方返回失败结果。
这里UserActor不需要切换到等待状态,只需要处理自己的AssignCategory命令即可;Saga作为协调者,承担了跨聚合的流程管理,完全符合Tell-Don't-Ask原则——你是在告诉CategoriesActor去执行检查动作,而不是询问它的状态。
3. 把分类存在性作为User聚合的业务规则(进阶思路)
如果分类和用户的关联是核心业务规则,你可以考虑把「用户所属分类必须存在」的约束,通过领域事件的方式固化:
- 当
UserActor生成UserCategoryAssigned事件后,下游的Projection可以监听这个事件,然后去检查Category是否存在。 - 如果发现Category不存在,可以生成一个
InvalidUserCategoryAssignment事件,触发补偿逻辑(比如通知用户、撤销分配等)。 - 这种方式适合那些可以接受短暂不一致,但需要后续补偿的业务场景,完全遵循CQRS/ES的事件驱动理念。
总结
- 优先选择「读模型前置校验」,简单高效,适配绝大多数场景;
- 强一致性需求用Saga协调,避免聚合根之间的直接查询;
- 尽量避免用辅助ValidatorActor或让聚合根进入复杂等待状态,会增加系统的维护成本。
内容的提问来源于stack exchange,提问作者Hav
相关产品推荐
相关产品推荐

