CQRS事件溯源:在发送命令时从事件存储中检查用户名是否唯一

22

当我们拥有特定唯一的实体ID时,事件溯源(EventSourcing)可以完美运作。但是当我尝试从事件存储(EventStore)中获取特定实体ID以外的信息时,我遇到了困难。

我正在使用带有事件溯源的CQRS(Command Query Responsibility Segregation)。作为事件溯源的一部分,我们将事件作为列(EntityID(唯一键),EventType,EventObject(例如UserAdded))存储在SQL表中。

因此,在存储EventObject时,我们只需对DotNet对象进行序列化并将其存储在SQL中,因此,与UserAdded事件相关的所有详细信息都将以xml格式存在。我的问题是,我想确保数据库中存在的userName应该是唯一的。

所以,在创建AddUser命令时,我必须查询EventStore(sql db)以确定特定的用户名是否已经存在于eventStore中。 因此,为了做到这一点,我需要序列化存储在事件存储中的所有UserAdded / UserEdited事件,并检查请求的用户名是否存在于eventStore中。

但作为CQRS的一部分,命令不允许查询,可能是由于竞争条件。

因此,在发送AddUser命令之前,我尝试查询事件存储并通过序列化所有事件(UserAdded)来获取所有UserNames,并获取用户名。如果请求的用户名是唯一的,则发送命令,否则抛出异常,说明用户名已经存在。

通过以上方法,我们需要查询整个数据库,而且每天可能会有成千上万的事件。因此,查询/反序列化的执行时间将变得很长,从而导致性能问题。

我正在寻找维护唯一用户名的任何更好方法/建议,无论是通过从eventStore获取所有用户名还是通过其他方法。


2
正如@Matt在他的答案中所述,在命令/写入侧使用某种索引。您可以使用一些通用的东西(我还没有看过NEventStore,所以不知道它是否有类似的东西):我在一个实验中有一个IKeyStore接口:https://github.com/Shuttle/shuttle-recall-core/blob/master/Shuttle.Recall.Core/IKeyStore.cs,您可以在这里看到实现:https://github.com/Shuttle/shuttle-recall-sqlserver/blob/master/Shuttle.Recall.SqlServer/KeyStore.cs --- 任意键的哈希与AR id相关联。希望这可以帮助 :) - Eben Roux
4个回答

19
因此,您的客户端(发出指令的实体)应该完全相信它发送的命令将被执行,并且必须通过确保在发送RegisterUserCommand之前没有其他用户注册该电子邮件地址来实现这一点。换句话说,您的客户端必须执行验证,而不是您的域或甚至围绕域的应用程序服务。这段话来源于http://cqrs.nu/Faq
以下是一个常见的问题,因为我们明确地不在写入侧执行跨聚合操作。但是,我们有多种选择:
- 创建已分配用户名的读取侧。当用户输入名称时,使客户端与读取侧进行交互查询。 - 创建反应式saga以标记并停用已使用重复用户名创建的帐户。(无论是由于极端巧合、恶意还是因为客户端故障。) - 如果最终一致性不够快,请考虑在写入侧添加一个表,即所谓的小型本地读取侧,其中包含已分配的名称。使聚合事务包括插入该表。

21
最后一段是务实的解决方案。 - MikeSW
2
如果写入端是事件存储,则最后一种选项不可行。 - Alexey Zimarev
2
@AlexeyZimarev,没有什么可以阻止您实现一个专门为写模型存在的私有读模型,而这不是事件存储。 - Matt
我不明白这个意义所在。我们说客户端应该始终发出有效的命令。虽然我们可以进行内部验证多次,但假设至少唯一性约束由客户端检查。 - Alexey Zimarev
1
@AlexeyZimarev 我同意你的观点,我并不认为这种策略比其他策略更有效。像大多数事情一样,答案是“取决于情况”。 - Matt
关于最后一段(“如果最终一致性...”):如果我们不支持事务(例如MongoDB),会怎样? - Constantin Galbenu

8
在业务逻辑的写操作中,使用存储库查询不同聚合物是没有被禁止的。你可以通过使用一些领域服务(跨聚合操作)来接受或拒绝命令,以便于由于重复用户而进行查询。Greg Young在这里提到了这一点: https://www.youtube.com/watch?v=LDW0QWie21s&t=24m55s 在正常情况下,您只需要查询所有的UserCreated + UserEdited事件即可。如果您预计每天会有成千上万个这些事件,也许您的事件已经过度膨胀,您应该更加原子地设计。例如,不要每次发生用户活动时都引发一个UserEdited事件,而是考虑采用UserPersonalDetailsEditedUserAccessInfoEdited等方式,在这种方式中,必须唯一的字段与其他用户字段分别处理。这样,在接受或不接受命令之前,查询所有的UserCreated + UserAccessInfoEdited将是一个较轻的操作。
就我个人而言,我会采用以下方法:
  1. 在事件中增加更多的原子性,以便更明确地描述所有涉及应该是全局唯一的字段的内容(例如:UserCreatedUserAccessInfoEdited)。
  2. 在写入端可用投影,以便在写入操作期间查询它们。因此,例如我将订阅所有UserCreatedUserAccessInfoEdited事件,以便保留具有所有唯一字段(例如:电子邮件)的可查询“表”。
  3. 当一个CreateUser命令到达领域时,领域服务会查询这个电子邮件表,并接受或拒绝该命令。

这个解决方案有点依赖于最终一致性,有可能查询告诉我们该字段还没有被使用,并允许命令成功引发UserCreated事件,而实际上从之前的事务中还没有更新投影,导致系统中存在2个不是全局唯一的字段。

如果您希望完全避免这些不确定的情况,因为您的业务无法真正处理最终一致性,则我的建议是通过将它们明确建模为您的普遍语言的一部分来在域中处理它们。例如,您可以以不同的方式对聚合进行建模,因为显然您的聚合用户并不是您的事务边界(即:它取决于其他人)。

几分钟后(33:24),他说的一个经常被问到的问题是:“所有用户名必须是唯一的”...从Stackoverflow帖子到这个视频,人们总是问这个问题,这很有趣。 - Ari Seyhun

5

通常情况下,并没有一个正确答案,只有适合你领域的答案。

你是否处于需要立即一致性的环境中?在检查唯一性(比如,在客户端)并处理命令之间,相同的用户名创建的概率是多少?你的领域专家是否能容忍例如 100 万个用户名冲突中的一个(之后可以进行补偿)?首先你会有一百万个用户吗?

即使需要立即一致性,“用户名应该是唯一的”……在哪个范围内?在 公司 内?在 在线商店 内?还是在 游戏服务器实例 中?您能否找到最受限制的范围,使得唯一性约束必须保持,并将该范围作为聚合根,从而发芽出一个新用户?如果聚合根使这些事件小而简单,为什么“重新播放所有 UserAdded/UserEdited 事件”的解决方案最终变得不好呢?


嗨@guillaume31,除了SO之外,有没有其他联系方式可以找到你呢?你似乎非常有资源,并且我想讨论一些建模方面的挑战,如果你不介意的话。 - plalx
@guillaume31 是的,需要立即一致性。 应用程序范围内的用户名应该是唯一的。如果聚合根使这些事件变得简单和小,为什么“重放所有UserAdded / UserEdited事件”的解决方案最终会变得糟糕呢? 答:根据检查用户名的唯一性,我需要所有已存在的用户名。在事件存储中,我们将拥有大量事件,因此,我需要重放所有实体ID的事件(无论我的当前实体ID如何)。而且,重放所有这些事件可能会对性能产生影响。是否有其他方法可以访问所有当前活动用户? - Roshan
1
但是你不能找到一种分区用户的方法,使得接受给定用户创建命令的聚合根只需重放合理数量的事件来检查唯一性吗? - guillaume31
@guillaume31 感谢您的回复。但在这种情况下,我们必须为所有用户重播UserAdded / UserEdited事件。具体而言,我确实需要一些其他实体名称是唯一的,这些实体名称经常被添加/编辑。顺便说一句,我正在尝试Matt的解决方案。 - Roshan
@guillaume31 谢谢!很抱歉,由于我的地理位置限制,我无法访问。聊天记录是持久且私密的吗?如果我稍后在那里留言(例如电子邮件地址),你能收到吗? - plalx
显示剩余3条评论

3
使用Greg Young的GetEventStore,您可以使用任何字符串作为聚合ID/流ID。将用户名作为聚合的ID,而不是GUID,或者使用类似“mycompany.users.john”这样的组合作为键.. voila!您免费获得了用户名唯一性!

1
如果业务需求要求允许修改用户名,该怎么办? - Moim
@Moim 你可以创建另一类流:"mycompany.usernames"。在该类别中,您可以存储用户名称流,例如"mycompany.usernames-john",并存储用户名绑定事件,例如"UserId 1234 现在绑定到用户名 "john"",以及另一个事件,例如"UserId 1234 不再绑定到用户名 "john",现在用户名 "john" 可以使用了。 - Narvalex
@Narvalex 看起来 EventStore 已经有一段时间没有 CreateStream 命令了。您只需将事件写入流键,如果不存在,它将会被创建。您如何防止多个 UserCreated 事件追加到同一个流中?您是暗示每次指定 expectedVersion:-1 吗? - guillaume31
你知道“聚合物的水化”或“再水化”的概念吗,对吧,@guillaume31? - Narvalex
@Narvalex 请更加明确一些。目前我没有看到与我的问题有关系。我只是好奇在技术层面上,你如何通过EventStore获得“免费独特性”。我猜想这可能是在AppendToStream()中使用expectedVersion:-1来保证不存在同名的流,但不确定。 - guillaume31
请原谅我,@guillaume31,我的英语非常糟糕。让我再试一遍。在提交到数据库之前,总有一些逻辑需要处理。您应该始终期望某些版本以避免并发问题。当然,如果您想附加类型为"BrandNewUserCreated"的事件,则应将预期版本设置为AppendToStream()中的-1。这就是您所说的自由唯一性。但是对于Moim要求(请参阅第一条评论),可能会出现类型为"UserDeletedAccountAndLeftHisUsernameAvailable"的事件。通过这种方式,您可以实现"UsenameTakenByOtherUser"。 - Narvalex

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接