应用CQRS和事件源创建在线拍卖平台

同事们,下午好!我叫Misha,我是一名程序员。



在本文中,我想谈谈我们的团队如何决定在在线拍卖网站的项目中应用CQRS和事件采购方法。以及由此产生的结果,可以从我们的经验中得出什么结论,以及对于那些接受CQRS和ES的人不要踩踏的重要因素。

图片





序幕



首先,介绍一下历史和业务背景。一位客户来到我们这里来进行所谓的定时拍卖,该拍卖已在生产中,并且已经在该平台上收集了一定数量的反馈。客户希望我们为他创建一个现场拍卖平台。



现在一点术语。拍卖是指某些物品已售出-地段,而买方(投标人)则出价。拍卖品的所有者是出价最高的买家。定时拍卖是指每个批次都有预定的关闭时间。买家下注,在某些时候,很多交易被关闭。类似于ebay。



定时平台是使用CRUD以经典方式制作的。从时间表开始,通过单独的申请关闭了批次。所有这一切都不能十分可靠地进行:有些赌注丢失了,有些赌注似乎代表了错误的买家,很多交易没有平仓或平仓。



实时拍卖是通过互联网远程参与真正的离线拍卖的机会。有一个房间(在我们内部的术语中为“房间”),其中包含拍卖的主体,锤子和听众,在笔记本电脑旁边坐着所谓的职员,该职员通过按其界面中的按钮将拍卖的过程广播到互联网上,并且连接到拍卖时,买家看到的出价已离线放置,可以出价。



这两个平台原则上都是实时工作的,但是如果在定时的情况下所有购物者处于同一个位置,那么在直播情况下,在线购物者能够与房间中的购物者成功竞争非常重要。也就是说,系统必须非常快速和可靠。定时平台的悲惨经历毫无疑问地告诉我们经典CRUD不适合我们。



我们没有与CQRS&ES合作的经验,因此我们咨询了拥有它的同事(我们有一家大公司),向他们介绍了我们的业务现状,并共同得出结论,CQRS&ES应该适合我们。



在线拍卖还有哪些其他细节:



  • — . , « », , . — , 5 . .
  • , , .
  • — - , , — .
  • , .
  • 该解决方案必须具有可扩展性-可以同时进行多个拍卖。


CQRS和ES方法的简要概述



我不会过多考虑CQRS和ES方法的问题,Internet上,尤其是Habré上都有关于此方法的材料(例如,这里:CQRS +事件源简介)。但是,我将简要提醒您一些要点:



  • 事件源中最重要的事情是:系统不存储数据,而是存储其更改的历史(即事件)。系统的当前状态是通过顺序应用事件获得的。
  • 域模型分为称为聚合的实体。该单元有一个版本。事件适用于集合。将事件应用于汇总将增加其版本。
  • write-. , .
  • . . , , . «» . .
  • , , - ( N- ) . «» . , .
  • - , , , , write-.
  • write-, read-, , . read- . Read- .
  • , — Command Query Responsibility Segregation (CQRS): , , write-; , , read-.






. .





为了节省时间,以及由于缺乏特定经验,我们决定需要为CQRS和ES使用某种框架。



一般而言,我们的技术栈是Microsoft,即.NET和C#。数据库-Microsoft SQL Server。一切都托管在Azure中。在此堆栈上创建了一个定时平台,在其上构建一个实时平台是合乎逻辑的。



当时,正如我现在记得的那样,就技术堆栈而言龙猫几乎是唯一适合我们的选择。所以我们带走了她。



为什么我们完全需要CQRS和ES框架?他可以“开箱即用”解决此类问题并支持诸如以下方面的实现:



  • 聚合实体,命令,事件,聚合版本控制,补液,快照机制。
  • 用于使用不同DBMS的界面。将事件和聚合的快照保存/加载到写库(事件存储)中或从中写入。
  • 用于队列的接口-将命令和事件发送到适当的队列,从队列中读取命令和事件。
  • 用于websockets的界面。


因此,考虑到龙猫的使用,我们将其添加到堆栈中:



  • 作为命令和事件总线的Azure Service Bus,Chinchilla提供了开箱即用的支持。
  • 写入和读取数据库是Microsoft SQL Server,即它们都是SQL数据库。我不会说这是有意识选择的结果,而是出于历史原因。


是的,前端是在Angular中制作的。



就像我已经说过的那样,对系统的要求之一是,用户应尽快了解其操作的结果以及其他用户的操作-这既适用于客户,也适用于业务员。因此,我们使用SignalR和websockets快速更新前端上的数据。Chinchilla支持SignalR集成。



单位选择



实施CQRS和ES方法时,要做的第一件事就是确定如何将域模型划分为集合。



在我们的例子中,领域模型由几个主要实体组成,如下所示:



public class Auction
{
     public AuctionState State { get; private set; }
     public Guid? CurrentLotId { get; private set; }
     public List<Guid> Lots { get; }
}

public class Lot
{
     public Guid? AuctionId { get; private set; }
     public LotState State { get; private set; }
     public decimal NextBid { get; private set; }
     public Stack<Bid> Bids { get; }
}
 
public class Bid
{
     public decimal Amount { get; set; }
     public Guid? BidderId { get; set; }
}




我们得到了两个总计:拍卖和拍品(含出价)。总的来说,这是合乎逻辑的,但是我们没有考虑到一件事-通过这样的划分,系统的状态分布在两个单元中,并且在某些情况下为了保持一致性,我们必须对两个单元而不是一个进行更改。例如,拍卖可以暂停。如果拍卖暂停,您将无法拍卖。可以暂停拍品本身,但是暂停的拍卖除了“取消暂停”以外不能处理其他命令。



另外,也可以拍卖所有拍卖品和内部的全部拍卖品。但是,这样的对象将非常困难,因为拍卖中可能有多达数千个拍品,而一个拍卖品可能有几十个出价。在拍卖的整个生命周期中,此类聚合将有许多版本,并且如果没有对聚合进行快照,则这种聚合的补水(将所有事件顺序应用到聚合中)将花费相当长的时间。这对于我们的情况是不可接受的。如果您使用快照(我们使用快照),则快照本身会很重。



另一方面,要确保在单个用户操作的处理过程中将更改应用于两个聚合,您必须使用事务在同一命令内更改两个聚合,或在同一事务内执行两个命令。两者总体上都违反了体系结构。



在将域模型分解为聚合时,必须考虑这种情况。



在项目发展的现阶段,我们使用两个单元,拍卖和批号,并且通过在某些命令中更改两个单元来破坏体系结构。



将命令应用于聚合的特定版本



如果多个买家同时对同一批次进行出价,即他们向系统发送“出价”命令,则只有一个出价成功。很多是一个聚合,它有一个版本。在命令处理期间,将生成事件,每个事件都会增加聚合的版本。有两种方法:



  • 发送一条命令,指定我们要将其应用于聚合的哪个版本。然后,命令处理程序可以立即将命令中的版本与单元的当前版本进行比较,如果不匹配,则不再继续。
  • 不要在命令中指定单元的版本。然后,使用某种版本将聚合重新水化,执行相应的业务逻辑,并生成事件。并且只有在保存它们时,执行弹出窗口才会显示该单元的这种版本已经存在。因为其他人早些做过。


我们使用第二个选项。这使团队有更好的机会被执行。因为在应用程序中发送命令的部分(在我们的示例中,这是前端),所以聚合的当前版本(以某种可能性)将落后于后端的实际版本。特别是在发送大量命令且单元版本频繁更改的情况下。



使用队列执行命令时出错



在我们的实现中,在Chinchilla的大力推动下,命令处理程序从队列(Microsoft Azure Service Bus)中读取命令。当团队出于技术原因(超时,连接到队列/基地的错误)和业务原因(试图对已经被接受的很多相同金额进行投标等)而失败时,我们会清楚地区分情况。在第一种情况下,将重复尝试执行命令,直到达到队列设置中指定的重复次数,然后将命令发送到“死信队列”(Azure服务总线中未处理消息的单独主题)。对于业务执行,团队将立即发送到“死信队列”。







使用队列处理事件时发生错误



由命令执行产生的事件(取决于实现方式)也可以发送到队列,并由事件处理程序从队列中提取。在处理事件时,也会发生错误。



但是,与未执行命令的情况相比,这里的情况更糟-可能会执行命令并将事件写入写库,但是处理程序对它们的处理失败。并且,如果这些处理程序之一更新了读取的数据库,那么读取的数据库将不会更新。也就是说,它将处于不一致状态。由于重试读取事件的处理机制,数据库几乎总是最终被更新,但是在所有尝试之后仍然保持断开的可能性仍然存在。







我们在家里遇到了这个问题。但是,原因主要是由于我们在事件处理中具有一些业务逻辑,在大量的押注下,该逻辑很可能会不时失败。不幸的是,我们意识到为时已晚,不可能快速而简单地重做业务实施。



因此,作为一种临时措施,我们停止使用Azure服务总线将事件从应用程序的写入部分传输到读取部分。而是使用所谓的“内存中总线”,它使您可以在一个事务中处理命令和事件,并在发生故障的情况下回滚整个事件。







这样的解决方案不会增加可伸缩性,但是另一方面,我们排除了读库中断的情况,这又破坏了前端,并且无法通过重播所有事件来重新创建读库而无法继续拍卖。



发送命令以响应事件



原则上,这是适当的,但仅在执行第二条命令的失败不会破坏系统状态的情况下。



处理一个命令的多个事件



通常,一个命令的执行会导致多个事件。碰巧的是,对于每个事件,我们都需要在读取数据库中进行一些更改。碰巧事件的顺序也很重要,如果顺序错误,事件处理将无法正常工作。所有这些都意味着我们无法从队列中读取并单独处理一个命令的事件,例如,使用从队列中读取消息的不同代码实例。另外,我们需要保证队列中的事件将按照发送事件的相同顺序进行读取。或者我们需要为并非所有命令事件都会在第一次尝试中成功处理的事实做好准备。







使用多个处理程序处理一个事件



如果系统需要响应一个事件执行几个不同的动作,通常会为此事件创建多个处理程序。它们可以并行或顺序工作。在顺序启动的情况下,如果其中一个处理程序失败,则会重新启动整个序列(在Chinchilla中就是这种情况)。通过这样的实现,重要的是处理程序是幂等的,以便一次成功执行的处理程序的第二次运行不会失败。否则,当第二个处理程序从链上掉落时,它肯定不会完全起作用,因为第一个处理程序将落在第二次(和后续)尝试上。



例如,读取库中的事件处理程序将出价提高了5卢布。第一次尝试将成功,第二次将不允许约束在数据库中执行。







结论/结论



现在,在我们看来,我们的项目处于一个阶段,在此阶段,我们已经踩踏了与我们的业务细节相关的大多数现有耙。总的来说,我们认为我们的经验非常成功,CQRS和ES非常适合我们的学科领域。该项目的进一步发展体现在放弃了Chinchilla,转而采用另一个具有更大灵活性的框架。但是,也可以完全拒绝使用该框架。在一方面寻求可靠性与另一方面在解决方案的速度和可伸缩性之间寻求平衡的方向上,也可能会有一些变化。



至于业务组件,这里仍然有一些问题尚待解决-例如,将域模型划分为聚合。



我希望我们的经验对某人有用,有助于节省时间并避免出现耙子。感谢您的关注。



All Articles