PHP,DDD,CQRS,Event Sourcing,Kubernetes,Docker,Golang

0%

CQRS/ES架构下如何保证用户名的唯一性?

CQRS/ES下,许多开发者不了解如何在系统中处理这样的场景:

在用户创建的时候,我们想要去验证用户名在数据库中是否是唯一的。我应该使用Event Store还是使用读数据库查询?我该在哪执行这个查询?

这个问题是StackOverflow上与CQRS话题有关的最受关注的话题。因此在这篇文章中,我们将尝试针对这个问题寻找出最佳解决方案。

方案1:在Command Handler中使用Event Store来验证

我们假设在Command Handler中使用Event Store来对用户名进行验证。但是我们会有个问题,这样做会很低效,让我们来看一下为什么。想象一下你的聚合对象包含了100个事件,并且我们有将近5000个注册用户。每重构一遍这个聚合都需要先获取100个事件,则我们将获得100*5000 = 500000个事件,毫无疑问这个方案行不通。

方案2:在Command Handler中使用读数据库来验证

由于上述讨论Event Store不适合来做这项验证工作,那么我们使用读数据库在Command Handler中来验证。这样的验证查询非常简单:

select * from user where username = :username;

这看起来是一个好的办法,但还是有两点问题。首先,我们不应该在Command中使用Query,这违背了CQRS原则。其次,这方法不一定每次都能奏效,因为我们还需要处理两种数据库(ES读数据库)之间的最终一致性问题。看一下下面的场景会发生什么问题:

cqrs-diagram-1024x292.png

假设有人创建了用户名为TestUser的帐号。他的command已经被发送到了command handler,并且一个对应的领域对象被创建出来,随后一个UserCreatedEvent事件被保存到Event Store中。该事件需要通过事件总线来同步到读数据库。但此时事件总线中正在处理另一个用户名同样是TestUser的帐号创建事件,并且通过读数据库查询该用户名是不存在的,因此这个事件可以被最终同步到读数据库中去。然而后一个事件如果也被事件总线同步的话,就无法保证用户名的唯一性了,所以我们还需要再考虑一下其他方案。

方案3:客户端验证

这个方案是客户端验证。我们通过从客户端发起Http请求作为query去查询用户名是否已经存在,如果存在则可以给出提示给用户。如果不存在,则再发起一个创建用户的请求作为command来创建用户。这样看起来不错,但也存在缺陷。这仅仅是客户端验证,它也会因为最终一致性问题而失败,但我们可以大致遵循这个思路继续往下想。

方案4:客户端验证+Saga模式

你可能会想,客户端验证方案刚才不是说过也会偶尔失败吗?为什么还要继续用,因为这个失败率比较低,并且我们将会在服务端也做个保障措施。我们知道Event Store的数据存储结构并不适合做这样的唯一性验证,所以我们就需要使用读数据库。但是我们也知道由于CQRS原因我们不能在command中使用query去查询读数据库。所以我们接下来这么做,我们给读数据库增加一个用户名唯一性约束,这样的话当插入一个已经存在的用户名帐号时会抛出一个异常。很好,但这有另一个问题!在对读数据库插入已经存在的用户名帐号前,我们已经在Event Store中存储了对应的事件。所以我们应该删掉那个事件,因为查询时抛出了异常?但是,要记住请不要删除事件!我们不能去做删除事件这样的操作,因为Event Sourcing的思想就是要记录下所有对象状态变化的完整历史。我们所需要去做的应该是去创建一个UserCreationCanceledEvent事件,并且在我们聚合中增加一些逻辑以使得这种创建失败情况下不再重新构建出聚合对象。这看起来似乎很有道理,但我们需要去创建合适的事件。怎么建?这正是Saga派上用场的时候了。我们考虑一下这样的时间序列图:

sq-diagram-1024x730.png

这图可能看起来较为复杂,实际上并不是。我们所需要理解的是Saga的职责。如果创建用户失败了,我们把这个创建用户事件传给Saga。随后,随后一个修正命令会被发往command bus去创建一个UserCreationCanceledEvent事件,然后该事件将被存储到Event Store中。就这些步骤就行了。

结束

我希望这篇文章能够帮到需要的人嗯。如果你有其他能保证用户名唯一性的好办法也可以在下面评论。