CQRS架构简介
前不久,看到博客园一位园友写了一篇文章,其中的观点是,要想高性能,需要尽量:避开网络开销(IO),避开海量数据,避开资源争夺。对于这3点,我觉得很有道理。所以也想谈一下,CQRS架构下是如何实现高性能的。
关于CQRS(Command Query Responsibility Segregation)架构,大家应该不会陌生了。简单的说,就是一个系统,从架构上把它拆分为两部分:命令处理(写请求)+查询处理(读请求)。然后读写两边可以用不同的架构实现,以实现CQ两端(即Command Side,简称C端;Query Side,简称Q端)的分别优化。CQRS作为一个读写分离思想的架构,在数据存储方面,没有做过多的约束。所以,我觉得CQRS可以有不同层次的实现,比如:
- CQ两端数据库共享,CQ两端只是在上层代码上分离;这种做法,带来的好处是可以让我们的代码读写分离,更好维护,且没有CQ两端的数据一致性问题,因为是共享一个数据库的。我个人认为,这种架构很实用,既兼顾了数据的强一致性,又能让代码好维护。
- CQ两端数据库和上层代码都分离,然后Q的数据由C端同步过来,一般是通过Domain Event进行同步。同步方式有两种,同步或异步,如果需要CQ两端的强一致性,则需要用同步;如果能接受CQ两端数据的最终一致性,则可以使用异步。采用这种方式的架构,个人觉得,C端应该采用Event Sourcing(简称ES)模式才有意义,否则就是自己给自己找麻烦。因为这样做你会发现会出现冗余数据,同样的数据,在C端的db中有,而在Q端的db中也有。和上面第一种做法相比,我想不到什么好处。而采用ES,则所有C端的最新数据全部用Domain Event表达即可;而要查询显示用的数据,则从Q端的ReadDB(关系型数据库)查询即可。
我觉得要实现高性能,可以谈的东西还有很多。下面我想重点说说我想到的一些设计思路:
避开资源争夺
秒杀活动的例子分析
我觉得这是很重要的一点。什么是资源争夺?我想就是多个线程同时修改同一个数据。就像阿里秒杀活动一样,秒杀开抢时,很多人同时抢一个商品,导致商品的库存会被并发更新减库存,这就是一个资源争夺的例子。一般如果资源竞争不激烈,那无所谓,不会影响性能;但是如果像秒杀这种场景,那db就会抗不住了。在秒杀这种场景下,大量线程需要同时更新同一条记录,进而导致MySQL内部大量线程堆积,对服务性能、稳定性造成很大伤害。那怎么办呢?我记得阿里的丁奇写过一个分享,思路就是当MySQL的服务端多个线程同时修改一条记录时,可以对这些修改请求进行排队,然后对于InnoDB引擎层,就是串行的。这样排队后,不管上层应用发过来多少并行的修改同一行的请求,对于MySQL Server端来说,内部总是会聪明的对同一行的修改请求都排队处理;这样就能确保不会有并发产生,从而不会导致线程浪费堆积,导致数据库性能下降。这个方案可以见下图所示:
如上图所示,当很多请求都要修改A记录时,MySQL Server内部会对这些请求进行排队,然后一个个将对A的修改请求提交到InnoDB引擎层。这样看似在排队,实际上会确保MySQL Server不会死掉,可以保证对外提供稳定的TPS。
但是,对于商品秒杀这个场景,还有优化的空间,就是Group Commit技术。Group Commit就是对多个请求合并为一次操作进行处理。秒杀时,大家都在购买这个商品,A买2件,B买3件,C买1件;其实我们可以把A,B,C的这三个请求合并为一次减库存操作,就是一次性减6件。这样,对于A,B,C的这三个请求,在InnoDB层我们只需要做一次减库存操作即可。假设我们Group Commit的每一批的size是50,那就是可以将50个减操作合并为一次减操作,然后提交到InnoDB。这样,将大大提高秒杀场景下,商品减库存的TPS。但是这个Group Commit的每批大小不是越大越好,而是要根据并发量以及服务器的实际情况做测试来得到一个最优的值。通过Group Commit技术,根据丁奇的PPT,商品减库存的TPS性能从原来的1.5W提高到了8.5W。
从上面这个例子,我们可以看到阿里是如何在实际场景中,通过优化MySQL Server来实现高并发的商品减库存的。但是,这个技术一般人还真的不会!因为没多少人有能力去优化MySQL的服务端,排队也不行,更别说Group Commit了。这个功能并不是MySQL Server自带的,而是需要自己实现的。但是,这个思路我想我们都可以借鉴。
CQRS如何实现避免资源竞争
那么对于CQRS架构,如何按照这个思路来设计呢?我想重点说一下我上面提到的第二种CQRS架构。对于C端,我们的目标是尽可能的在1s内处理更多的Command,也就是数据写请求。在经典DDD的四层架构中,我们会有一个模式叫工作单元模式,即Unit of Work(简称UoW)模式。通过该模式,我们能在应用层,一次性以事务的方式将当前请求所涉及的多个对象的修改提交到DB。微软的EF实体框架的DbContext就是一个UoW模式的实现。这种做法的好处是,一个请求对多个聚合根的修改,能做到强一致性,因为是事务的。但是这种做法,实际上,没有很好的遵守避开资源竞争的原则。试想,事务A要修改a1,a2,a3三个聚合根;事务B要修改a2,a3,a4;事务C要修改a3,a4,a5三个聚合根。那这样,我们很容易理解,这三个事务只能串行执行,因为它们要修改相同的资源。比如事务A和事务B都要修改a2,a3这两个聚合根,那同一时刻,只能由一个事务能被执行。同理,事务B和事务C也是一样。如果A,B,C这种事务执行的并发很高,那数据库就会出现严重的并发冲突,甚至死锁。那要如何避免这种资源竞争呢?我觉得我们可以采取三个措施:
让一个Command总是只修改一个聚合根
这个做法其实就是缩小事务的范围,确保一个事务一次只涉及一条记录的修改。也就是做到,只有单个聚合根的修改才是事务的,让聚合根成为数据强一致性的最小单位。这样我们就能最大化的实现并行修改。但是你会问,但是我一个请求就是会涉及多个聚合根的修改的,这种情况怎么办呢?在CQRS架构中,有一个东西叫Saga。Saga是一种基于事件驱动的思想来实现业务流程的技术,通过Saga,我们可以用最终一致性的方式最终实现对多个聚合根的修改。对于一次涉及多个聚合根修改的业务场景,一般总是可以设计为一个业务流程,也就是可以定义出要先做什么后做什么。比如以银行转账的场景为例子,如果是按照传统事务的做法,那可能是先开启一个事务,然后让A账号扣减余额,再让B账号加上余额,最后提交事务;如果A账号余额不足,则直接抛出异常,同理B账号如果加上余额也遇到异常,那也抛出异常即可,事务会保证原子性以及自动回滚。也就是说,数据一致性已经由DB帮我们做掉了。
但是,如果是Saga的设计,那就不是这样了。我们会把整个转账过程定义为一个业务流程。然后,流程中会包括多个参与该流程的聚合根以及一个用于协调聚合根交互的流程管理器(ProcessManager,无状态),流程管理器负责响应流程中的每个聚合根产生的领域事件,然后根据事件发送相应的Command,从而继续驱动其他的聚合根进行操作。
转账的例子,涉及到的聚合根有:两个银行账号聚合根,一个交易(Transaction)聚合根,它用于负责存储流程的当前状态,它还会维护流程状态变更时的规则约束;然后当然还有一个流程管理器。转账开始时,我们会先创建一个Transaction聚合根,然后它产生一个TransactionStarted的事件,然后流程管理器响应事件,然后发送一个Command让A账号聚合根做减余额的操作;A账号操作完成后,产生领域事件;然后流程管理器响应事件,然后发送一个Command通知Transaction聚合根确认A账号的操作;确认完成后也会产生事件,然后流程管理器再响应,然后发送一个Command通知B账号做加上余额的操作;后续的步骤就不详细讲了。大概意思我想已经表达了。总之,通过这样的设计,我们可以通过事件驱动的方式,来完成整个业务流程。如果流程中的任何一步出现了异常,那我们可以在流程中定义补偿机制实现回退操作。或者不回退也没关系,因为Transaction聚合根记录了流程的当前状态,这样我们可以很方便的后续排查有状态没有正常结束的转账交易。具体的设计和代码,有兴趣的可以去看一下ENode源代码中的银行转账的例子,里面有完整的实现。
对修改同一个聚合根的Command进行排队
和上面秒杀的设计一样,我们可以对要同时修改同一个聚合根的Command进行排队。只不过这里的排队不是在MySQL Server端,而是在我们自己程序里做这个排队。如果我们是单台服务器处理所有的Command,那排队很容易做。就是只要在内存中,当要处理某个Command时,判断当前Command要修改的聚合根是否前面已经有Command在处理,如果有,则排队;如果没有,则直接执行。然后当这个聚合根的前一个Command执行完后,我们就能处理该聚合根的下一个Command了;但是如果是集群的情况下呢,也就是你不止有一台服务器在处理Command,而是有十台,那要怎么办呢?因为同一时刻,完全有可能有两个不同的Command在修改同一个聚合根。这个问题也简单,就是我们可以对要修改聚合根的Command根据聚合根的ID进行路由,根据聚合根的ID的hashcode,然后和当前处理Command的服务器数目取模,就能确定当前Command要被路由到哪个服务器上处理了。这样我们能确保在服务器数目不变的情况下,针对同一个聚合根实例修改的所有Command都是被路由到同一台服务器处理。然后加上我们前面在单个服务器里面内部做的排队设计,就能最终保证,对同一个聚合根的修改,同一时刻只有一个线程在进行。
通过上面这两个设计,我们可以确保C端所有的Command,都不会出现并发冲突。但是也要付出代价,那就是要接受最终一致性。比如Saga的思想,就是在最终一致性的基础上而实现的一种设计。然后,基于以上两点的这种架构的设计,我觉得最关键的是要做到:1)分布式消息队列的可靠,不能丢消息,否则Saga流程就断了;2)消息队列要高性能,支持高吞吐量;这样才能在高并发时,实现整个系统的整体的高性能。我开发的EQueue就是为了这个目标而设计的一个分布式消息队列,有兴趣的朋友可以去了解下哦。
Command和Event的幂等处理
CQRS架构是基于消息驱动的,所以我们要尽量避免消息的重复消费。否则,可能会导致某个消息被重复消费而导致最终数据无法一致。对于CQRS架构,我觉得主要考虑三个环节的消息幂等处理。
Command的幂等处理
这一点,我想不难理解。比如转账的例子中,假如A账号扣减余额的命令被重复执行了,那会导致A账号扣了两次钱。那最后就数据无法一致了。所以,我们要保证Command不能被重复执行。那怎么保证呢?想想我们平时一些判断重复的操作怎么做的?一般有两个做法:1)db对某一列建唯一索引,这样可以严格保证某一列数据的值不会重复;2)通过程序保证,比如插入前先通过select查询判断是否存在,如果不存在,则insert,否则就认为重复;显然通过第二种设计,在并发的情况下,是不能保证绝对的唯一性的。然后CQRS架构,我认为我们可以通过持久化Command的方式,然后把CommandId作为主键,确保Command不会重复。那我们是否要每次执行Command前线判断该Command是否存在呢?不用。因为出现Command重复的概率很低,一般只有是在我们服务器机器数量变动时才会出现。比如增加了一台服务器后,会影响到Command的路由,从而最终会导致某个Command会被重复处理,关于这里的细节,我这里不想多展开了,呵呵。有问题到回复里讨论吧。这个问题,我们也可以最大程度上避免,比如我们可以在某一天系统最空的时候预先增加好服务器,这样可以把出现重复消费消息的情况降至最低。自然也就最大化的避免了Command的重复执行。所以,基于这个原因,我们没有必要在每次执行一个Command时先判断该Command是否已执行。而是只要在Command执行完之后,直接持久化该Command即可,然后因为db中以CommandId为主键,所以如果出现重复,会主键重复的异常。我们只要捕获该异常,然后就知道了该Command已经存在,这就说明该Command之前已经被处理过了,那我们只要忽略该Command即可(当然实际上不能直接忽略,这里我由于篇幅问题,我就不详细展开了,具体我们可以再讨论)。然后,如果持久化没有问题,说明该Command之前没有被执行过,那就OK了。这里,还有个问题也不能忽视,就是某个Command第一次执行完成了,也持久化成功了,但是它由于某种原因没有从消息队列中删除。所以,当它下次再被执行时,Command Handler里可能会报异常,所以,健壮的做法时,我们要捕获这个异常。当出现异常时,我们要检查该Command是否之前已执行过,如果有,就要认为当前Command执行正确,然后要把之前Command产生的事件拿出来做后续的处理。这个问题有点深入了,我暂时不细化了。有兴趣的可以找我私聊。
Event持久化的幂等处理
然后,因为我们的架构是基于ES的,所以,针对新增或修改聚合根的Command,总是会产生相应的领域事件(Domain Event)。我们接下来的要做的事情就是要先持久化事件,再分发这些事件给所有的外部事件订阅者。大家知道,聚合根有生命周期,在它的生命周期里,会经历各种事件,而事件的发生总有确定的时间顺序。所以,为了明确哪个事件先发生,哪个事件后发生,我们可以对每个事件设置一个版本号,即version。聚合根第一个产生的事件的version为1,第二个为2,以此类推。然后聚合根本身也有一个版本号,用于记录当前自己的版本是什么,它每次产生下一个事件时,也能根据自己的版本号推导出下一个要产生的事件的版本号是什么。比如聚合根当前的版本号为5,那下一个事件的版本号则为6。通过为每个事件设计一个版本号,我们就能很方便的实现聚合根产生事件时的并发控制了,因为一个聚合根不可能产生两个版本号一样的事件,如果出现这种情况,那说明一定是出现并发冲突了。也就是一定是出现了同一个聚合根同时被两个Command修改的情况了。所以,要实现事件持久化的幂等处理,也很好做了,就是db中的事件表,对聚合根ID+聚合根当前的version建唯一索引。这样就能在db层面,确保Event持久化的幂等处理。另外,对于事件的持久化,我们也可以像秒杀那样,实现Group Commit。就是Command产生的事件不用立马持久化,而是可以先积累到一定的量,比如50个,然后再一次性Group Commit所有的事件。然后事件持久化完成后,再修改每个聚合根的状态即可。如果Group Commit事件时遇到并发冲突(由于某个聚合根的事件的版本号有重复),则退回为单个一个个持久化事件即可。为什么可以放心的这样做?因为我们已经基本做到确保一个聚合根同一时刻只会被一个Command修改。这样就能基本保证,这些Group Commit的事件也不会出现版本号冲突的情况。所以,大家是否觉得,很多设计其实是一环套一环的。Group Commit何时出发?我觉得可以只要满足两个条件了就可以触发:1)某个定时的周期到了就可以触发,这个定时周期可以根据自己的业务场景进行配置,比如每隔50ms触发一次;2)要Commit的事件到达某个最大值,即每批可以持久化的事件个数的最大值,比如每50个事件为一批,这个BatchSize也需要根据实际业务场景和你的存储db的性能综合测试评估来得到一个最适合的值;何时可以使用Group Commit?我觉得只有是在并发非常高,当单个持久化事件遇到性能瓶颈时,才需要使用。否则反而会降低事件持久化的实时性,Group Commit提高的是高并发下单位时间内持久化的事件数。目的是为了降低应用和DB之间交互的次数,从而减少IO的次数。不知不觉就说到了最开始说的那3点性能优化中的,尽量减少IO了,呵呵。
Event消费时的幂等处理
CQRS架构图中,事件持久化完成后,接下来就是会把这些事件发布出去(发送到分布式消息队列),给消费者消费了,也就是给所有的Event Handler处理。这些Event Handler可能是更新Q端的ReadDB,也可能是发送邮件,也可能是调用外部系统的接口。作为框架,应该有职责尽量保证一个事件尽量不要被某个Event Handler重复消费,否则,就需要Event Handler自己保证了。这里的幂等处理,我能想到的办法就是用一张表,存储某个事件是否被某个Event Handler处理的信息。每次调用Event Handler之前,判断该Event Handler是否已处理过,如果没处理过,就处理,处理完后,插入一条记录到这个表。这个方法相信大家也都很容易想到。如果框架不做这个事情,那Event Handler内部就要自己做好幂等处理。这个思路就是select if not exist, then handle, and at last insert的过程。可以看到这个过程不像前面那两个过程那样很严谨,因为在并发的情况下,理论上还是会出现重复执行Event Handler的情况。或者即便不是并发时也可能会造成,那就是假如event handler执行成功了,但是last insert失败了,那框架还是会重试执行event handler。这里,你会很容易想到,为了做这个幂等支持,Event Handler的一次完整执行,需要增加不少时间,从而会最后导致Query Side的数据更新的延迟。不过CQRS架构的思想就是Q端的数据由C端通过事件同步过来,所以Q端的更新本身就是有一定的延迟的。这也是CQRS架构所说的要接收最终一致性的原因。
关于幂等处理的性能问题的思考
关于CommandStore的性能瓶颈分析
大家知道,整个CQRS架构中,Command,Event的产生以及处理是非常频繁的,数据量也是非常大的。那如何保证这几步幂等处理的高性能呢?对于Command的幂等处理,如果对性能要求不是很高,那我们可以简单使用关系型DB即可,比如Sql Server, MySQL都可以。要实现幂等处理,只需要把主键设计为CommandId即可。其他不需要额外的唯一索引。所以这里的性能瓶颈相当于是对单表做大量insert操作的最大TPS。一般MySQL数据库,SSD硬盘,要达到2W TPS应该没什么问题。对于这个表,我们基本只有写入操作,不需要读取操作。只有是在Command插入遇到主键冲突,然后才可能需要偶尔根据主键读取一下已经存在的Command的信息。然后,如果单表数据量太大,那怎么办,就是分表分库了。这就是最开始谈到的,要避开海量数据这个原则了,我想就是通过sharding避开大数据来实现绕过IO瓶颈的设计了。不过一旦涉及到分库,分表,就又涉及到根据什么分库分表了,对于存储Command的表,我觉得比较简单,我们可以先根据Command的类型(相当于根据业务做垂直拆分)做第一级路由,然后相同Command类型的Command,根据CommandId的hashcode路由(水平拆分)即可。这样就能解决Command通过关系型DB存储的性能瓶颈问题。其实我们还可以通过流行的基于key/value的NoSQL来存储,比如可以选择本地运行的leveldb,或者支持分布式的ssdb,或者其他的,具体选择哪个,可以结合自己的业务场景来选择。总之,Command的存储可以有很多选择。
关于EventStore的性能瓶颈分析
通过上面的分析,我们知道Event的存储唯一需要的是AggregateRootId+Version的唯一索引,其他就无任何要求了。那这样就和CommandStore一样好办了。如果也是采用关系型DB,那只要用AggregateRootId+Version这两个作为联合主键即可。然后如果要分库分表,我们可以先根据AggregateRootType做第一级垂直拆分,即把不同的聚合根类型产生的事件分开存储。然后和Command一样,相同聚合根产生的事件,可以根据AggregateRootId的hashcode来拆分,同一个AggregateRootId的所有事件都放一起。这样既能保证AggregateRootId+Version的唯一性,又能保证数据的水平拆分。从而让整个EventStore可以无限制水平伸缩。当然,我们也完全可以采用基于key/value的NoSQL来存储。另外,我们查询事件,也都是会确定聚合根的类型以及聚合根的ID,所以,这和路由机制一直,不会导致我们无法知道当前要查询的聚合根的事件在哪个分区上。
设计存储时的重点考虑点
在设计command, event的存储时,我认为主要考虑的应该是提高整体的吞吐量,而不是追求单机存储的性能。因为假如我们的系统平均每秒产生1W个事件,那一天就是8.64亿个事件。已经是很大的数据量。所以,我们必须要对command, event这种进行分片。比如我们设计864个表,那每个表每天产生100W条记录,这是在可以接受的范围内。然后,我们一旦分了864个表了,肯定会把它们分布在不同的物理数据库上。这样就是多个物理数据库同时提供存储服务,可以整体提高存储的吞吐量。我个人比较倾向于使用MySQL来存储即可,因为一方面MySQL是开源的,各种分库分表的成熟做法比较多。另一方面,关系型数据库相比Mongodb这种,自己更熟悉,能更好的控制。比如数据扩容方案可以自己做,不像MongoDB这种,虽然它都帮我们搞定了大数据存储,但一旦出了问题,也许自己无法掌控。另一方面,关于RT,即单条数据存储时的响应时间,这个我觉得不管是关系型数据库还是NoSQL,最终的瓶颈都是在磁盘IO。NoSQL之所以这么快,无非就是异步刷盘;而关系型DB不是很快,因为它要保证数据的落地,要保证数据的更高级别的可靠性。所以,我觉得,要在保证数据不会丢失的情况下,尽量提高RT,可以考虑使用SSD硬盘。另一方面,我觉得由于我们已经做了分库分表了,所以单个DB的压力不会太大,所以一般局域网内的RT也不会延迟很大,应该可以接受。
聚合根的内存模式(In-Memory)
In-Memory模式也是一种减少网络IO的一种设计,通过让所有生命周期还没结束的聚合根一直常驻在内存,从而实现当我们要修改某个聚合根时,不必再像传统的方式那样,先从db获取聚合根,再更新,完成后再保存到db了。而是聚合根一直在内存,当Command Handler要修改某个聚合根时,直接从内存拿到该聚合根对象即可,不需要任何序列化反序列化或IO的操作。基于ES模式,我们不需要直接保存聚合根,而是只要简单的保存聚合根产生的事件即可。当服务器断电要恢复聚合根时,则只要用事件溯源(Event Sourcing, ES)的方式恢复聚合根到最新状态即可。
了解过actor的人应该也知道actor也是整个集群中就一个实例,然后每个actor自己都有一个mailbox,这个mailbox用于存放当前actor要处理的所有的消息。只要服务器不断电,那actor就一直存活在内存。所以,In-Memory模式也是actor的一个设计思想之一。像之前很轰动的国外的一个LMAX架构,号称每秒单机单核可以处理600W订单,也是完全基于in-memory模式。不过LMAX架构我觉得只要作为学习即可,要大范围使用,还是有很多问题要解决,老外他们使用这种架构来处理订单,也是基于特定场景的,并且对编程(代码质量)和运维的要求都非常高。具体有兴趣的可以去搜一下相关资料。
关于in-memory架构,想法是好的,通过将所有数据都放在内存,所有持久化都异步进行。也就是说,内存的数据才是最新的,db的数据是异步持久化的,也就是某个时刻,内存中有些数据可能还没有被持久化到db。当然,如果你说你的程序不需要持久化数据,那另当别论了。那如果是异步持久化,主要的问题就是宕机恢复的问题了。我们看一下akka框架是怎么持久化akka的状态的吧。
- 多个消息同时发送给actor时,全部会先放入该actor的mailbox里排队;
- 然后actor单线程从mailbox顺序消费消息;
- 消费一个后产生事件;
- 持久化事件,akka-persistence也是采用了ES的方式持久化;
- 持久化完成后,更新actor的状态;
- 更新状态完成后,再处理mailbox中的下一个消息;
从上面的过程,我们可以看出,akka框架本质上也实现了避免资源竞争的原则,因为每个actor是单线程处理它的mailbox中的每个消息的,从而就避免了并发冲突。然后我们可以看到akka框架也是先持久化事件之后,再更新actor的状态的。这说明,akka采用的也叫保守的方式,即必须先确保数据落地,再更新内存,再处理下一个消息。真正理想的in-memory架构,应该是可以忽略持久化,当actor处理完一个消息后,立即修改自己的状态,然后立即处理下一个消息。然后actor产生的事件的持久化,完全是异步的;也就是不用等待持久化事件完成后再更新actor的状态,然后处理下一个消息。
我认为,是不是异步持久化不重要,因为既然大家都要面临一个问题,就是要在宕机后,恢复actor的状态,那持久化事件是不可避免的。所以,我也是认为,事件不必异步持久化,完全可以像akka框架那样,产生的事件先同步持久化,完成后再更新actor的状态即可。这样做,在宕机恢复actor的状态到最新时,就只要简单的从db获取所有事件,然后通过ES得到actor最新状态即可。然后如果担心事件同步持久化有性能瓶颈,那这个总是不可避免,这块不做好,那整个系统的性能就上不去,所以我们可以采用SSD,sharding, Group Commit, NoSQL等方法,优化持久化的性能即可。当然,如果采用异步持久化事件的方式,确实能大大提高actor的处理性能。但是要做到这点,还需要有一些前提的。比如要确保整个集群中一个actor只有一个实例,不能有两个一样的actor在工作。因为如果出现这种情况,那这两个一样的actor就会同时产生事件,导致最后事件持久化的时候必定会出现并发冲突(事件版本号相同)的问题。但要保证急群众一个actor只有一个实例,是很困难的,因为我们可能会动态往集群中增加服务器,此时必定会有一些actor要迁移到新服务器。这个迁移过程也很复杂,一个actor从原来的服务器迁移到新的服务器,意味着要先停止原服务器的actor的工作。然后还要把actor再新服务器上启动;然后原服务器上的actor的mailbox中的消息还要发给新的actor,然后后续可能还在发给原actor的消息也要转发到新的actor。然后新的actor重启也很复杂,因为要确保启动之后的actor的状态一定是最新的,而我们知道这种纯in-memory模式下,事件的持久化时异步的,所以可能还有一些事件还在消息队列,还没被持久化。所以重启actor时还要检查消息队列中是否还有未消费的事件。如果还有,就需要等待。否则,我们恢复的actor的状态就不是最新的,这样就无法保证内存数据是最新的这个目的,这样in-memory也就失去了意义。这些都是麻烦的技术问题。总之,要实现真正的in-memory架构,没那么容易。当然,如果你说你可以用数据网格之类的产品,无分布式,那也许可行,不过这是另外一种架构了。
上面说了,akka框架的核心工作原理,以及其他一些方面,比如akka会确保一个actor实例在集群中只有一个。这点其实也是和本文说的一样,也是为了避免资源竞争,包括它的mailbox也是一样。之前我设计ENode时,没了解过akka框架,后来我学习后,发现和ENode的思想是如此接近,呵呵。比如:1)都是集群中只有一个聚合根实例;2)都对单个聚合根的操作的Command做排队处理;3)都采用ES的方式进行状态持久化;4)都是基于消息驱动的架构。虽然实现方式有所区别,但目的都是相同的。
小结
本文,从CQRS+Event Sourcing的架构出发,结合实现高性能的几个要注意的点(避开网络开销(IO),避开海量数据,避开资源争夺),分析了这种架构下,我所想到的一些可能的设计。整个架构中,一个Command在被处理时,一般是需要做两次IO,1)持久化Command;2)持久化事件;当然,这里没有算上消息的发送和接收的IO。整个架构完全基于消息驱动,所以拥有一个稳定可扩展高性能的分布式消息队列中间件是比不可少的,EQueue正是在向这个目标努力的一个成果。目前EQueue的TCP通信层,可以做到发送100W消息,在一台i7 CPU的普通机器上,只需3s;有兴趣的同学可以看一下。最后,ENode框架就是按照本文中所说的这些设计来实现的,有兴趣的朋友欢迎去下载并和我交流哦!不早了,该睡了。