事件溯源新招:直接读取聚合数据表
啥是事件溯源?为啥听起来这么麻烦?
想象一下,你在玩一个超级复杂的存档游戏,每次你干点啥,比如买个装备、升级、打怪,游戏都会把你的每一步操作记下来,存成一个“事件日志”。这个日志就像你的游戏日记,记着你从开局到现在的每一步,比如“玩家买了把剑”“玩家升级到10级”。想知道你现在的状态(比如有多少金币、啥装备)?那就得把这堆日记从头读到尾,重新算一遍。这就是事件溯源的精髓:不直接存“当前状态”,而是存“发生过啥”,然后靠这些“发生过啥”来算出你现在的状态。
听起来挺酷,对吧?但问题来了:如果你的游戏日志有几千条记录,每次你想买个新装备,都得把这几千条从头读一遍,算出你现在的金币够不够,装备能不能买。这得花多少时间啊!而且,为了用这套系统,你还得先设计一堆复杂的规则,比如“订单”这个东西,得定义清楚“订单创建”“订单更新”“订单归档”这些事件,还得给每个订单(比如订单001、订单002)单独建一个事件流。这就像给每个玩家建一个专属日记本,麻烦得要命!
传统的事件溯源咋搞?
用个例子讲讲,比如你在搞个网购系统,里面有个“订单”模块。每个订单(比如订单001)都会有一串事件,像“订单创建”“订单信息改了”“订单完成了”。这些事件就是你的“日记”,存在一个叫“聚合”(Aggregate)的东西里。每次用户想干点啥,比如改订单地址,你得先把这个订单的全部事件(可能几千条!)从头到尾“重放”一遍,算出现在订单是啥状态,才能确认这个操作合不合法。这就叫“重放聚合”。
再比如银行账户。你有个账户“BankAccount-1234”,里面可能有几百万条事件,比如“存了100块”“取了50块”。每次你想转账,得把这几百万条事件全读一遍,算出你账户里还有多少钱,才能确认转账能不能成功。这速度慢得跟乌龟爬似的!为了加速,有人发明了“快照”(Snapshots),就是定期存个“当前状态”的备份,这样不用每次都重头算。但就算有快照,这套系统还是复杂得让人头大。
为啥这套传统方法这么费劲?
因为它追求“完美一致性”!它要确保你每次操作都是基于最新的状态,不能有半点差错。这就像你玩游戏,每次买装备都得确认你的金币数是100%准确的,不能因为网络卡了一下就用错数据。这虽然安全,但门槛高得吓人!你要先学会一大堆复杂的“领域驱动设计”理论,还得设计一堆“聚合”和“事件流”,不是每个程序员都能搞定的。
还有另一种方法可以实现验证(并实现事件溯源的核心概念),这种方法不需要您处理重新填充整个事件流的复杂性,也不需要仅仅为了验证新的用户操作而设计聚合。我将要解释的这种替代方案降低了 CQRS + 事件溯源的入门门槛,因为它消除了 DDD 设计的复杂性,并显著扩展了用例和可访问性(一些经典用例可能不适合这种方法)。但与此同时,它需要一个不同且强大的基础架构。
有没有更简单的办法?
当然有!简单粗暴,门槛低,适合大多数情况!这个方法不用你去设计复杂的“聚合”或者“重放”几百万条事件,照样能达到事件溯源的效果。
建议的方法是重新利用领域事件,将其作为事件流(我们称之为“事件类型”)。与其为每个订单创建事件流,不如将每个已创建、已更新、已存档或已完成的订单归入其各自的事件类型。
这个新方法把事件按“类型”分组,而不是给每个订单或账户单独建一个事件流。比如,网购系统里,你不用给“订单001”“订单002”各建一个日记本,而是把所有订单的“创建”事件放一个流,所有“更新”事件放另一个流,所有“归档”事件再放一个流。就像把你的游戏日记按“买装备”“升级”“打怪”分类,而不是按每个玩家分。这意味着,您将有 4 个用于订单聚合的事件流,而不是系统中每个订单都有一个事件流。
实现事件溯源的方法是通过对实时读取模型进行简单的 SQL 业务逻辑检查。这些模型包含我的应用程序的最新状态,在高吞吐量的关键情况下,延迟为个位数毫秒;在吞吐量较低的关键情况下,延迟为个位数秒。
验证用户操作(比如改订单地址)的时候,不用重放所有事件,而是直接查一个叫“读模型”(Read Model)的实时数据库。这个数据库就像你的游戏存档,存着最新的状态,更新速度超快!在超级忙碌的场景下,延迟也就几毫秒;在不那么忙的场景下,最多几秒钟。有了这个,你直接查读模型就能知道当前状态,验证操作合不合法,省得每次都把老古董事件翻出来重算。
两种方法都使用应用程序的当前状态:
- 要么调用读取模型,
- 要么通过 rehydration 所有过去事件来重建当前状态。
rehydration 仅在读取模型不同步不可接受时才真正重要。生产数据库是 CQRS 中的下游服务,因此始终存在轻微延迟。在高竞争或超低延迟领域(例如真实货币转账),您应该重放单个账户流以避免风险。如果读取模型在几毫秒到几秒内更新,那么针对它进行验证对于绝大多数应用程序来说就完全足够了。
这新方法有啥好处?
简单到飞起:不用学那一堆复杂的领域驱动设计,也不用给每个订单、账户建单独的事件流,省心省力!
速度快:查读模型比重放几百万条事件快多了,延迟低到几乎感觉不到。
适用范围广:适合大多数场景,比如电商、社交媒体啥的,门槛低,谁都能上手。
不过,这方法也不是万能的。如果你在搞超级严格的业务,比如银行转账,读模型那点毫秒级的延迟可能就不够保险了。这种时候,还是得老老实实重放事件流,确保100%准确。但对大部分应用来说,读模型已经够用了!
总结一下
传统事件溯源就像让你把一本厚厚的日记从头读到尾,才能知道你现在有啥装备、多少钱,麻烦但超级精准。
新方法呢,就像直接给你看个实时更新的游戏存档,速度快、简单,但偶尔可能有几毫秒的延迟。
大部分时候,新方法够用,还能让你少费点脑细胞!
网友热评:
1、我使用 .NET 中的EventFlow 构建了一个类似的系统。它确实很酷,但对于简单的 CRUD 应用程序来说,所需的代码量太高了。你真的应该只将它用于需要高度可审计性的、业务逻辑繁重的复杂工作流。我们还发现,由于你可能会意外地将不良事件放入不可变的事件存储中,因此错误会更加严重。在这种情况下,你要么必须手动重写历史记录,要么创建一个永久的“错误事件”处理程序,在不良事件重放时对其进行修复。第一次就正确构建它非常棘手。
2、我认为事件溯源最有趣的一点是,数据以一种能够重建应用程序的方式存储,例如删除生产数据库后,点击重放,生产数据库就会重建。这种机制的价值和可能性非常巨大。
看到每个变化都被捕获为一个不可变的事件,您可以按“重播”并通过您喜欢的任何投影来驱动该历史记录。
3、重新补充整个事件历史记录的意义在于,您想要重新创建应用程序的当前状态,或者更具体地说,实体/聚合实例的当前状态。
如果事件流太大,无法及时处理(补充)聚合,您可以引入聚合快照。快照是聚合在某个时间点的持久状态。要进行补充,请从聚合开始,并将快照拍摄后发布的事件应用于聚合。其目的是加速补充。
4、这意味着,对于所提供的示例,您将有 4 个用于订单聚合的事件流,而不是为系统中的每个订单都有一个事件流。
那么,如何实现并发控制?
每个聚合使用一个事件流的原因是,它允许你在流上使用乐观并发控制,因为流具有与聚合相同的范围,而聚合是事务一致性的单位。你仍然可以使用一个投影,为每个事件类型提供一个流。
为了实现并发控制,需要在读取模型条目上添加一个版本时间戳行