分享一个CQRS/ES架构中基于写文件之EventStore的计划思路

多年来打算用C#贯彻一个冲文件之EventStore。

什么是EventStore

有关什么是EventStore,如果还非知底的意中人可以错过探听下CQRS/Event
Sourcing这种架构,我博客中为闹雅量介绍。EventStore是以Event
Sourcing(下面简称ES)模式面临,用于存储事件就此的。从DDD的角度来说,每个聚合根在大团结之状态发生变化时犹见面发一个还是多个世界事件,我们得将这些事件持久化起来。然后当我们要还原聚合根的流行状态及内存时,可以经过ES这种技术,从EventStore获取该聚合根的具备事件,然后重演这些事件,就能够以拖欠聚合根恢复至最新状态了。这种技术和MySQL的Redo日志以及Redis的AOF日志或者leveldb的WAL日志的原理是类似的。但是分别是,redo/AOF/WAL日志是Command
Sourcing,而我辈这边说的凡Event
Sourcing。关于这点儿个概念的分,我未多开展了,有趣味的意中人可错过探听下。

胡要描写一个EventStore

时ENode使用的EventStore,是依据关系项目数据库SqlServer的。虽然功效上全满足要求,但是性能及以及多少容量高达,离我的预料还有部分去。比如:

  1. 有关性,虽然好由此SqlBulkCopy方法,实现比较生的写入吞吐,但是我对EventStore的渴求是,需要支持有限单唯一索引:1)聚合根ID+事件版本号唯一;2)聚合根ID+命令ID唯一;当上加这点儿独唯一索引后,会非常怪影响SqlBulkCopy写副数据的性质;而且SqlBulkCopy只来SqlServer才有,其他数据库如MySQL没有,这样吧无形之中限制了ENode的动状况;
  2. 关于以状况,DB是依据SQL的,他非是略的援手咱保留数据,每次写副数据还如分析SQL,执行SQL,写入RedoLOG,等;另外,DB还要支持修改数据、通过SQL查询数据等状况。所以,这就要求DB内部在规划存储结构时,要兼顾各种气象。而我们现设促成的EventStore,针对的场面比较简单:1)追求大吞吐的写入,没有改动和去;2)查询好少,不需支持复杂的干项目查询,只待会支撑查询有聚合根的所有事件即可;所以,针对这种特定的运用状况,如果产生对的贯彻一个EventStore,我深信不疑性能及得起再不行之晋升空间;
  3. 关于数据量,一个EventStore可能要仓储大量之风波,百亿要么千亿级别。如果利用DB,那我们不得不进展分库分表,因为单表能积存的记录数是少的,比如1000W,超过此数目,对勾副性能为会见出必然的影响。假设我们今天而存储100亿轩然大波记录,单表存储1000W,那即便需要1000单说明,如果单个物理库中分100独说明,那就是用10独物理库;如果前数据量再追加,则用更加扩容,那便待牵涉到数据库的数迁移(全量同步、增量同步)这种劳动的业务。而而是基于文件版本的EventStore,由于尚未说明的定义了,所以单机只要硬盘够好,就能储存非常多的多寡。并且,最要紧之,性能不会见为数据量的增多而落。当然,EventStore也同样需支持扩容,但是由EventStore中的数目只是见面Append写副,不会见窜,也不见面删除,所以扩容方案相对于DB来说,要爱做过多。
  4. 这就是说干什么非应用NoSQL?NoSQL一般都是啊良数额、可伸缩、高性能而规划之。因为一般而言NoSQL不支持方第一触及吃所说之二级索引,当然有些文档型数据库如MongoDB是支撑的,但是对己来说是一个黑盒,我无能为力开,也未尝下更,所以没有考虑。
  5. 起长期来拘禁,如果会团结因自己的景象实现一个发生指向的EventStore,那未来而起性能瓶颈的问题,自己不怕来足的能力去化解。另外,对团结的技艺力量的增强也是一个雅挺的锻炼机会。而且这做好了,说不定又是温馨的一个杀好的著作,呵呵。所以,为何未尝试一下为?

EventStore的宏图目标

  • 渴求大性能顺序写副事件;
  • 求严判断聚合根的轩然大波是否比照版本号顺序递增写副;
  • 支撑命令ID的唯一性判断;
  • 支持大气事件的存储;
  • 支撑按聚合根ID查询该聚合根的富有事件;
  • 支持动态扩容;
  • 大可用(HA),需要支持集群和主备,二期还举行;

EventStore核心问题讲述、问题分析、设计思路

骨干问题讲述

一个EventStore需要缓解的骨干问题发生有限碰:1)持久化事件;2)持久化事件前判断事件版本号是否合法、事件对应的通令是否更。一个波涵盖的音信如下:

  • 聚合根ID
  • 事件版本号
  • 命令ID
  • 事件始末
  • 事件产生时间

胡是这些信?

本文所涉的波是CQRS架构中,由C端的某命令操作有聚合根后,导致该聚合根的状态发生变化,然后每次转都见面来一个对应的波。所以,针对聚合根的每个事件,我们关心的音信就是是:哪个命令操作哪个聚合根,产生了啊版本号的一个波,事件的始末以及出的年月独家是啊。

事件的版本号是呀意思?

由于一个聚合根在生命周期内经常会面吃修改,也就是说经常会出指令去修改聚合根的状态,而每次状态的成形都见面时有发生一个遥相呼应的事件,也就是说一个聚合根在生命周期内会产生多只事件。聚合根是世界让设计(DDD)中之一个概念,聚合根是一个具有全局唯一ID的实体,具有独自的生命周期,是数据强一致性的太小边界。为了确保聚合根内的多寡的赛一致性,针对单个聚合根的另修改都要是线性的,因为光有线性的操作,才会担保当前之操作所依据的聚合根的状态是流行的,这样才能够保证聚合根内数据的完整性,总是满足工作规则之不变性。关于线性操作就点,就比如对DB的均等布置表中的某部同长长的记下的改动为亟须是线性的同,数据库中的同样条记下不容许以叫简单个线程同时修改。所以,分析到这里,我们了解和一个聚合根的差不多只事件之起一定是起先后顺序的。那哪管是先后顺序呢?答案是,在聚合根上规划一个版本号,通过本号的次第递增来保证对同一个聚合根的改为连续线性依次的。这个思路其实就算是如出一辙种乐观并作控制的笔触。聚合根的第一只事件的版本号为1,第二独事件之版本号为2,第N个事件之版本号为N。当第N单事件闹时,它所根据的聚合根的状态必须是N-1。当某个版本号为N的轩然大波尝试持久化到EventStore时,如果EventStore中已经存在了一个版本号为N的风波,则以为出现并发冲突,需要报上层应用即风波持久化遇到并发冲突了,然后上层应用得取得该聚合根的时髦状态,然后再次重试当前令,然后重新来新的本号的波,再持久化到EventStore。

意在能自动检测命令是否再处理

CQRS架构,任何聚合根的改动都是经命令来好的。命令就是一个DTO,当我们如果修改一个聚合根的状态时,就发送一个发令到分布式MQ即可,然后MQ的主顾处理该令。但是大家都掌握其他分布式MQ一般还只能形成至少送一糟糕(At
Least
Once)的音投递语义。也就是说,一个指令可能会见于消费者还处理。在微情况下,某个聚合根如果重新处理某个命令,会促成聚合根的末段状态不得法,比如更扣款会造成账号余额不科学。所以,我们盼望当框架层面能支持命令的又处理的检测。那绝理想的检测位置在哪里也?如果是民俗的DB,我们见面当数据库层面通过建立唯一索引保证令绝对免会见再度执行。那针对许到我们的EventStore,自然为应当于EventStore内部检测。

主干问题分析

经者的问题讲述,我们掌握,其实一个EventStore需要解决的题目即使零星触及:1)以文件的样式持久化事件;2)持久化之前判断事件之版本号是否冲突、事件之指令是否更。

有关率先碰,自然是经过各个写文件来兑现,机械硬盘在相继写文件之场面下,性能为是特别大之。写文件之笔触好简单,我们可以固定单个文件之轻重,比如512MB。然后先勾勒第一单公文,写满后新建一个文件,再写第二只,后面以此类推。

有关第二接触,本质上是少单目录的要求:a.
聚合根ID+事件版本号唯一(当然,这里不光要力保唯一,还要判断是否是接二连三递增);b.
聚合根ID +
命令ID唯一,即针对同一个聚合根的授命不能够更处理;那什么样促成就点儿独目录的要求为?第一独目录的落实资产相对比逊色,我们特需要在内存维护每个聚合根的即版号,然后当一个轩然大波还原时,判断事件之版本号是否是眼下本号的产一个版本号即可,如果不是,则以为版本号非法;第二只目录的波成本比较高,我们得维护每个聚合根的享有来的事件对应的下令的ID,然后于某事件平复时,判断该事件对应的指令ID是否跟已生的别一个波之授命ID重复,如果有,则以为出现又。所以,归根结底,当需要持久化某个聚合根的波不时,我们得加载该聚合根的兼具已发的事件的版本号以及事件对应之命令ID到内存,然后在内存进行判断,从而检查时事变是否满足这片个目录需求。

吓了,上面是主导的也是极端直接的缓解问题之思路了。但是咱好发现,要贯彻地方就简单个问题并无轻。因为:首先我们的机器的内存大小是少数的,也就是说,无法将所有的聚合根的波的目录信息都放在内存。那么当某个聚合根的事件一经持久化时,发现内存中并无夫聚合根的风波索引时,必然要从磁盘中加载该聚合根的轩然大波索引。但问题是,我们的事件由为了追求大性能的抒写副到文件,总是只是简短的Append追加至终极一个文本之尾声。这样必然导致某个聚合根的风波或分流于差不多只文本被,这样便于咱们找这聚合根的所有有关事件带来了极大的不便。那该怎么衡量的失设计这片独需要为?

自看设计是千篇一律栽权衡,我们连应该根据我们的实在业务场景去有侧重点的开展设计,优先解决重要问题,然后下问题尽量错开化解。就如leveldb在规划时,也是看重于写副常非常简单快速,而读取时,可能会见于迂回曲折。EventStore,是甚典型的数写副但很少读取的系。但写副常需保证上述的少数独目录需求,所以,应该说之写入的要求比leveldb的写入要求还要高一些。那咱们该怎么去权衡呢?

EventStore核心设计思路

  1. 于内存中保护每个聚合根的本索引eventVersion,eventVersion中保障了手上聚合根的具有的版本、每个版本对应之cmdId,以及每个版本的风波于event文件被的物理位置;当一个事变平复时,通过者eventVersion来判断version,cmdId是否合法(version必须是currentVersion+1,cmdId必须唯一);
  2. 当写副一个事变时,只写副一个文本,event.file文件;假设一个文本之轻重缓急为512MB,一个波的大大小小也1KB,则一个文书约存储52W单事件;
  3. 一个event.file文件写满后:
    • 就目前event.file文件,然后新建一个初的event.file文件,接下去的事件写副新的event.file文件;
    • 起步一个后台线程,在内存中对当前形成的event.file文件被的event按照聚合根ID和波版本号进行排序;
    • 排序完成后,我们尽管懂得了该文件被的风波波及到什么聚合根,他们之各个,以及极要命最小聚合根ID分别是呀;
    • 新建一个暨event.file文件一律大小的临时文件;
    • 当临时文件的header中记录时event.file已排序过;
    • 每当临时文件的数码区域用免除好序的事件顺序写副文件;
    • 临时文件写副好后,将临时文件替换当前早就就的event.file文件;
    • 也event.file文件新建一个相应之风波索引文件eventIndex.file;
    • 用event.file文件中之太老以及最好小聚合根ID写副到eventIndex.file索引文件之header;每个event.file的极度酷最小的聚合根ID的涉嫌,会于EventStore启动时自动加载并缓存到内存中,这样可以一本万利我们很快掌握某聚合根在有event.file中是否在事件,因为直接以内存中判断即可。这个缓存我少命名为aggregateIdRangeCache吧,以便下面还有益于之愈益求证如何以它。
    • 将event.file文件中之每个聚合根的每个事件的目录信息写副eventIndex.file文件,事件索引信息包括:聚合根ID+事件版本号+事件之一声令下ID+事件于event.file文件被的大体位置就4单消息;有矣这些索引信息,我们就是可以仅需要拜访事件索引文件就可知取有聚合根的装有版本信息(就是上面说的eventVersion)了;
    • 然只是在事件索引文件中著录最可怜无比小聚合根ID以及每个事件的目录信息还未是不够的。原因是,当我们而摸某个聚合根的具备版本信息时,虽然足预先冲内存中缓存的每个event.file文件的太要命无比小聚合根ID快速稳定该聚合根在哪些event.file中有事件(也即是有目共睹了以什么样相应的事件索引文件被留存版本信息),但是当我们只要于这些事件索引文件被追寻有拖欠聚合根的轩然大波索引到底具体于文件的哪位位置时,只能由文本之开端位置顺序扫描文件才能够明白,这样的逐条扫描的是休便捷之。假设一个event.file文件的分寸固定啊512MB,一个轩然大波之尺寸也1KB,则一个event.file文件约存储52W独事件,每个事件索引的轻重约为:24 +
      4 + 24 + 8 =
      60单字节。所以,这52W只事件之目录信息大概占30MB,也便是最终一个事件索引文件的分寸约为30MB多或多或少。当我们设博得有聚合根的保有版本信息时,如果每次访某个事件索引文件时,总是要逐项扫描30MB的文件数量,那的确效率不强。所以,我还索要进一步想方优化,因为事件索引文件里之轩然大波索引信息还是以聚合根ID和事件版本号排序的,假而现在有52W独事件索引,则我们得以将马上52W个事件索引记录都等切分为100单点,然后把每个点对应之波索引的聚合根ID都记录到事件索引文件之header中,一个聚合根ID的长度为24单字节,则100单呢不怕2.4KB左右。这样一来,当我们怀念使懂得有聚合根的波索引大概在事件索引文件之哪个位置时,我们得以先经顾header里底音,快速理解应打哪个位置去扫描。这样一来,本来对一个轩然大波索引文件我们要扫描30MB的数量,现在化单独待扫描百分之一的数,即300KB,这样扫描的快就急忙生多矣。这无异于段子写的略微啰嗦,但一切都是为了尽可能详细的叙说自己之筹划思路,不知底诸位看官是否看懂了。
    • 除开记录记录最特别无比小聚合根ID以及记录100单当分的切割点外,还有某些可优化来增进得聚合根的版本信息的性能,就是:如果内存足够,当有eventIndex.file被读取一破后,EventStore可以自动将这个eventIndex.file文件缓存到非托管内存中;这样下次尽管好直接以非托管内存访问是eventIndex.file了,减少了磁盘IO的读取;
  4. 因内存大小有限,所以eventVersion不容许全休息存在内存;所以,当有聚合根的eventVersion不以内存中时常,需要由磁盘加载。加载的思绪是:扫描aggregateIdRangeCache,快速搜索来拖欠聚合根的事件在哪些event.file文件被在;然后经过地方提到的物色算法快速搜索这些event.file文件对应之eventIndex.file文件,这样虽可知便捷取得该聚合根的eventVersion信息了;
  5. 此外,EventStore启动时,最好待预加载一些看好聚合根的eventVersion信息及缓存。那该预加载哪些聚合根呢?我们可以在内存中保护一个恒定大小(N)的环形数组,环形数组中维护了不久前窜的聚合根的ID;当有聚合根有事件来,则以该聚合根ID的hashcode取摸N得到环形数组的下标,然后拿欠聚合根ID放入该下标;定时将欠环形数组中之聚合根ID
    dump到文件preloadAggregateId.file进行仓储;这样当EventStore启动时,就可打preloadAggregateId.file加载指定聚合根的eventVersion;

思路总结:

上面的宏图之主要思路是:

  • 写副一个波前先行内存中判断是否允许写入,如果同意,则相继写副event.file文件;
  • 针对一个早就写副得的event.file文件,则就此一个后台异步线程对文件被的事件仍聚合根ID和波版本号进行排序,然后拿排序后的现event.file文件替换原event.file文件,同时将排序后拿走的事件索引信息写副eventIndex.file文件;
  • 形容副一个事变不时,如果手上聚合根的版本信息不以内存,则需要由有关的eventIndex.file文件加载到内存;
  • 鉴于加载版本信息可能得看多单eventIndex.file文件,会来反复诵读磁盘的IO,对性影响比较生,所以,我们连年应该尽量在内存缓存聚合根的版本信息;
  • 全部EventStore的属性瓶颈在于内存中可知缓存多少聚合根版本信息,如果能缓存百分百之聚合根版本信息,且会得无GC的问题(尽量避免),那我们就是得成功写入事件颇迅速;所以,如何设计一个支持大容量缓存(比如缓存几十只GB的多少),且无GC问题的大性能缓存服务,就变得可怜重大了;
  • 是因为来矣风波索引信息及这么多的缓存机制,所以,当要询问有聚合根的具有事件,也即非常简单了;

如何解决多线程并发写的当儿的CPU占用高之题目?

暨此处,我们解析了如何存储数据,如何勾勒副数据,还有如何询问聚合根的具有事件,应该说基本职能的落实思路都想吓了。如果现在是单线程访问EventStore,我信任性能应无会见怪没有了。但是,实际的气象是N多客户端会同时起的顾EventStore。这个时候即便见面招EventStore服务器会生不少线程要求而写副事件到数据文件,但是大家懂得写文件要是单线程的,如果是多线程,那吧如因此锁之建制,保证跟一个随时只能发出一个线程在写文件。最简单易行的主意尽管是摹写文件时用一个lock搞定。但是透过测试发现简单的运用lock,在多线程的状态下,会招CPU很高。因为每个线程在处理时事件不时,由于要描写文件要读文件,都是IO操作,所以锁的占据时间较长,导致成千上万线程都以死等待。

以缓解者题材,我举行了部分调研,最后决定利用对复苏冲队列的技巧来缓解。大致思路是:

统筹简单个队,将要写入的事件先放开入行1,然后当前一旦真处理的波放在队列2。这样就算水到渠成了拿接收数据和处理数据立马有限单过程在情理上分别,先飞接收数据并雄居队列1,然后处理时拿班1里的数据放入队列2,然后队列2里之数量单线程线性处理。这里的一个关键问题是,如何将班1里之数传于班2乎?是一个个正片吗?不是。这种做法太低效。更好的措施是故交换两只班的援的不二法门。具体思路这里自己莫开展了,大家可网上搜索一下双缓冲队列的概念。这个规划我道太老的便宜是,可以中之下挫多线程写副数据经常对沿的占用时间,本来一次锁霸占后只要直接处理当下事件的,而本光待将事件放入队列即可。双缓冲队列可以在很多情景下叫采取,我看,只要是多独消息生产者并发来信息,然后单个消费者单线程消费信息的观,都可以使用。而且是规划还有一个好处,就是咱们可出空子单线程批量处理队列2里之数据,进一步提高处理数量的吞吐力。

什么缓存大量风波索引信息?

最为简单易行的法是应用支持并发访问的字典,如ConcurrentDictionary<T,K>,Java中就是是ConcurrentHashmap。但是透过测试发现ConcurrentDictionary在key增加及3000基本上万的上便见面那个慢,所以自己好实现了一个粗略的缓存服务,初步测试下来,基本满足要求。具体的设计思路本文先不介绍了,总的我们希望实现一个过程内之,支持缓存大量key/value的一个字典,支持并发操作,不要坐内存占用越多如果导致缓存能力的下跌,尽量不要闹GC的题目,能满足这些需求便OK。

怎么样扩容?

咱俩重新来拘禁一下说到底一个自认为于主要之问题,就是什么扩容。

则咱单台EventStore机器只要硬盘够充分,就足以储存相当多之轩然大波。但是硬盘再特别吗发生上限,所以扩容的需求总是有。所以什么扩容(将数据迁移到其他服务器上)呢?通过上面的宏图我们询问及,EventStore中极度基本之文书就是event.file,其余文件还可以由此event.file文件来变化。所以,我们扩容时才待迁移event.file文件即可。

那么如何扩容为?假设现在发4光EventStore机器,要扩容至8贵。

发少个方式:

  1. 土豪的做法:准备8玉新的机械,然后把原先4台机械的整个数码分散到新预备的8令机械及,然后重新把老机器上的数目全刨除;
  2. 屌丝的做法:准备4光新的机器,然后将原4贵机器的一半数码分散到新预备的4雅机器上,然后再将老机器上之那一半多少删除;

对比之下,可以生容易觉察土豪之做法比较简单,因为光待考虑什么迁移数据到新机器即可,不欲考虑迁移后将已搬过去的数量还要去。大体的思路是:

  1. 应用关的道,新的8玉目标机器还于通往一直的4台源机器拖事件数据;目标机器记录时拖到乌了,以便使碰到意外中断停止后,下次重新开能持续由该位置连续拖延;
  2. 列台源机器都围观所有的事件数据文件,一个个风波进行扫描,扫描的起始位置由目前一经捱数照的靶子机器给起;
  3. 各个令目标机器该拖哪几事件数量?预先在源机器上安排好这次扩容的对象机器的有所唯一标识,如IP;然后当有平台目标机器过来拖数据经常,告知自己的机器的IP。然后源机器根据IP就能了解该对象机器在备目标机器中清除第几,然后源机器便能够懂得应该将如何事件数量并于该目标机器了。举个例子:假设当前目标机器的IP在拥有IP中排名第3,则对每个事件,获取事件的聚合根ID,然后用聚合根ID
    hashcode取摸8,如果余数为3,则觉得该事件需要同于该目标机器,否则便过了该事件;通过这样的思绪,我们得以确保与一个聚合根的装有事件还最终并到了同一台新的对象机器。只要我们的聚合根ID够均匀,那最终必将是都匀的管持有聚合根的波都匀的齐到目标机器及。
  4. 当目标机器及同完整了一个event.file后,就活动异步生成其对应之eventIndex.file文件;

扩容过程的数目并搬迁的思路差不多了。但是扩容过程不仅只是发多少迁移,还有客户端路由切换等。那若客户端何动态切换路由信息为?或者说哪些形成不停机动态扩容为?呵呵。这个实际上是一个外场的艺。只要数据迁移的速度和得上数据写入的速,然后还配合动态推送新的路由配置信息及具有的客户端。最终就能够兑现动态库容了。这个问题我这里先不深刻了,搞了数据库动态扩容的情侣应还了解原理。无非就是是一个全量数据迁移、增量数据迁移、数据校验、短暂歇写服务,切换路由安排信息马上几个根本之步骤。我上面介绍的是不过核心之多少迁移的笔触。

结束语

本文介绍了自事先一直怀念做的一个因文件版本的EventStore的根本设计思路,希望由此这首文章将好的思路系统地整理出来。一方面通过写篇可更确信自己的笔触是否OK,因为要您文章写不出,其实思路一定是哪里来问题,写稿子的历程尽管是大脑整理思绪的长河。所以,写篇也罢是反省自己计划的平栽好办法。另一方面,也得经投机之原创分享,希望跟大家交流,希望大家会给我有视角要建议。这样也许可以在自己动手写代码前能及时纠正一些统筹及的左。最后更续某些,语言不根本,重要的凡架构设计、数据结构,以及算法。谁说C#言语做不有好东西呢?呵呵。

网站地图xml地图