事件溯源CQRS架构下是否需查询事件存储?查询时机与方式
Event Sourcing场景下创建用户的唯一性校验实现方案
首先明确核心原则:你不需要为了这类校验直接遍历查询Event Store,对接Read Model做前置校验也不违反CQRS/Event Sourcing设计规范,只需要补一层写入时的强一致兜底,解决最终一致性窗口的并发冲突问题即可。
为什么不需要硬查Event Store
- Event Store本身是为按聚合ID追加、加载事件设计的append-only存储,天生不支持按邮箱、用户名这类非ID业务属性做灵活查询。如果硬要绕过Read Model直接查Event Store做属性匹配,只能全量扫描所有历史事件,性能随数据量增长线性下降,属于生产环境不可用的反模式。
- 你担心的“查Read Model违反CQRS”是对原则的误解:CQRS只要求命令侧和读侧职责拆分,从来没有禁止命令侧调用读侧做前置校验。实际工业界落地ES+CQRS的时候,命令侧用Read Model做前置校验是非常普遍的做法。
- 你真正需要解决的问题不是“查询来源不合规”,而是Read Model同步事件存在短暂延迟,在这个时间窗口内可能出现两个相同属性的创建请求同时通过前置校验的并发问题,这个问题有成熟的固定解法,不需要靠查Event Store解决。
标准两层校验实现
第一层:快速前置校验(对接Read Model)
就是你现有代码里的逻辑:
const userAlreadyExists = await this.userRepo.exists(email); if (userAlreadyExists) { return new EmailAlreadyExistsError(email); } const alreadyCreatedUserByUserName = await this.userRepo.getUserByUserName(username); if (alreadyCreatedUserByUserName) { return new UsernameTakenError(username); }
这层直接查Read Model即可,性能最高,可以挡住99%以上的正常重复请求,没有任何问题。这里的userRepo本身就是读侧的仓储实现,不需要对接Event Store。
第二层:写入时强一致兜底(对接Event Store)
这层用来堵Read Model同步窗口的并发漏洞,二选一即可:
- 方案1:用唯一性聚合+Event Store乐观锁
把邮箱、用户名这类需要全局唯一的属性,单独拆成独立的唯一性聚合,聚合ID就用唯一属性类型:属性值的格式(比如email:test@example.com、username:zhangsan)。创建用户的写入流程调整为:- 从Event Store加载对应邮箱、用户名的唯一性聚合,如果聚合已经标记为“已占用”,直接返回重复错误
- 执行完前置校验、实例化User聚合之后,同步把两个唯一性聚合标记为已占用
- 把User聚合的未提交事件、两个唯一性聚合的未提交事件放到同一个批次,以事务方式写入Event Store
Event Store本身对同一个聚合ID的写入自带乐观并发控制:如果两个相同邮箱/用户名的创建请求同时走到写入步骤,必然有一个请求会因为聚合版本冲突写入失败,捕获这个冲突返回对应的业务重复错误即可,完全不会出现重复数据。
- 方案2:用Event Store原生的唯一键能力
大部分生产级Event Store实现都支持给写入的事件绑定业务唯一键,写入时如果唯一键已存在会直接抛出冲突错误。你不需要单独创建唯一性聚合,只要在写入UserCreated事件时,把邮箱、用户名作为唯一键附加到事件上,写入时捕获唯一键冲突错误,转换成对应的业务错误返回即可,实现更简单。
绝对不要做的做法
不要为了做属性校验全量扫描Event Store的所有事件:这种做法相当于你自己在Event Store之上重新实现一遍Read Model的二级索引逻辑,属于重复造轮子,而且性能、可维护性都远不如专门做查询优化的Read Model。
内容的提问来源于stack exchange,提问作者elli
相关产品推荐
相关产品推荐

