将关系型数据库用作事件溯源存储

134
如果我使用关系型数据库(例如SQL Server)存储事件溯源数据,它的模式可能是什么样子?
我看到一些抽象概念上的变化,但没有具体的例子。例如,假设有一个“产品”实体,对该产品的更改可以采用以下形式:价格、成本和描述。 我对以下几种情况感到困惑: 1. 是否应该使用“ProductEvent”表,其中包含产品的所有字段,每次更改都意味着在该表中添加一个新记录,并根据需要添加“who, what, where, why, when and how” (WWWWWH)。当成本、价格或描述发生变化时,将添加一个全新行来表示产品。 2. 将产品成本、价格和描述存储在单独的表中,并通过外键关系连接到产品表。当这些属性发生变化时,写入新行,并添加适当的WWWWWH。 3. 在“ProductEvent”表中存储WWWWWH及其序列化对象代表的事件,这意味着必须在我的应用程序代码中加载、反序列化和重播事件,以便为给定的产品重新构建应用程序状态。
特别是我担心以上第二个选项。 如果走向极端,产品表几乎会成为每个属性的一个表,为了加载给定产品的应用程序状态,需要从每个产品事件表中加载该产品的所有事件,这种表扩展似乎不太对。
我相信“情况因人而异”,虽然没有一个单一的“正确答案”,但我正在试图了解什么是可以接受的,什么是完全不能接受的。 我也知道NoSQL可以在这里提供帮助,其中事件可以存储在聚合根中,这意味着只需要一次数据库请求即可获取事件以重建对象,但我们目前没有使用NoSQL数据库,因此我正在寻找替代方案。

2
在其最简单的形式中:[事件] {聚合ID,聚合版本,事件负载}。不需要聚合类型,但您可以选择存储它。不需要事件类型,但您可以选择存储它。这是已发生事情的长列表,其他任何内容都只是优化。 - Yves Reynhout
7
一定要远离第一和第二种方法。将所有内容串行化成一个数据块然后以这种方式存储它。 - Jonathan Oliver
6个回答

122
事件存储不需要知道事件的具体字段或属性。否则,您的模型每次修改都需要迁移数据库(就像在传统的状态持久性中一样)。因此,我根本不建议选项1和2。
以下是Ncqrs中使用的架构。正如您所看到的,表“Events”将相关数据存储为CLOB(即JSON或XML)。这对应于您的选项3(只是没有“ProductEvents”表,因为您只需要一个通用的“Events”表。在Ncqrs中,将映射到聚合根通过“EventSources”表进行,其中每个EventSource对应于一个实际的聚合根。)
Table Events:
    Id [uniqueidentifier] NOT NULL,
    TimeStamp [datetime] NOT NULL,

    Name [varchar](max) NOT NULL,
    Version [varchar](max) NOT NULL,

    EventSourceId [uniqueidentifier] NOT NULL,
    Sequence [bigint], 

    Data [nvarchar](max) NOT NULL

Table EventSources:
    Id [uniqueidentifier] NOT NULL, 
    Type [nvarchar](255) NOT NULL, 
    Version [int] NOT NULL
Jonathan Oliver的Event Store实现的SQL持久化机制基本上只包含一个名为“Commits”的表,其中包含一个BLOB字段“Payload”。这与Ncqrs基本相同,只是它将事件属性序列化为二进制格式(例如,添加加密支持)。
Greg Young建议采用类似的方法,在Greg的网站上有详细记录
他原型“Events”表的架构如下:
Table Events
    AggregateId [Guid],
    Data [Blob],
    SequenceNumber [Long],
    Version [Int]

12
好的答案!我经常看到使用事件溯源的主要论点之一是能够查询历史记录。当所有有趣的数据都被序列化为XML或JSON时,我该如何制作一个高效查询的报告工具?是否有任何寻找基于表格解决方案的有趣文章? - Marijn Huizendveld
15
你可能不想直接对事件存储进行查询。最常见的解决方案是连接一些事件处理程序来将事件投影到报告或商业智能数据库中。然后对这些处理程序回放事件历史记录。 - Dennis Traub
1
@Denis Traub谢谢您的回答。为什么不直接对事件存储进行查询呢?如果每次出现新的BI案例时都必须重放完整的历史记录,那么情况可能会变得非常混乱/强烈。 - Marijn Huizendveld
1
我曾经认为除了事件存储之外,你还应该有表来存储模型的最新状态数据?并且你将模型分成了读模型和写模型。写模型针对事件存储进行操作,而事件存储管理更新到读模型中。读模型包含代表系统实体的表,因此您可以使用读模型进行报告和查看。我一定是误解了什么。 - theBoringCoder
10
@theBoringCoder,听起来你把事件溯源和CQRS混淆了,或者至少在头脑中搞混了。它们经常一起使用,但它们并不是相同的东西。CQRS让你分离读模型和写模型,而事件溯源让你将事件流用作应用程序中唯一的真相来源。 - Bryan Anderson
显示剩余2条评论

10

GitHub项目CQRS.NET有一些具体的示例,展示了如何在几种不同的技术中使用EventStores。撰写本文时,该项目已经实现了使用Linq2SQL的SQL实现以及相应的SQL模式,还有一个适用于MongoDB的实现,一个适用于DocumentDB(如果您在Azure上,则为CosmosDB)的实现,以及一个使用EventStore的实现(如上所述)。Azure中还有更多类似于平面文件存储的Table Storage和Blob storage。

我想主要的观点是,它们都符合同一原则/契约。它们都把信息存储在一个单一的地方/容器/表中,并使用元数据来识别一个事件与另一个事件,“只是”将整个事件存储为它所是的样子,在支持技术中,在某些情况下进行序列化。因此,无论您选择文档数据库、关系数据库甚至是平面文件,都有几种不同的方法可以实现事件存储的相同意图(如果您在任何时候改变了想法并发现需要迁移或支持多个存储技术,则非常有用)。
作为该项目的开发人员,我可以分享一些我们做出的选择的见解。
首先,我们发现(即使是唯一的UUID/GUID而不是整数),由于许多原因,顺序ID会出现战略原因,因此仅仅拥有ID还不足以成为一个唯一的键,因此我们将主ID键列与数据/对象类型合并,创建应该是真正的(从应用程序的角度来看)唯一的键。我知道有些人说你不需要存储它,但这将取决于你是否处于绿地场景或者必须与现有系统共存。
出于可维护性的原因,我们坚持使用单个容器/表/集合,但我们确实尝试了每个实体/对象的单独表格。我们发现,在实践中,这意味着应用程序需要“CREATE”权限(通常来说,这不是一个好主意...一般来说,总会有例外/排除),或者每次出现新实体/对象或被部署时,都需要创建新的存储容器/表/集合。我们发现,这对于本地开发来说非常缓慢,对于生产部署也存在问题。您可能不需要这样做,但这是我们的实际经验。
另外需要记住的一件事是,请求执行动作 X 可能会导致许多不同的事件发生,因此了解由命令/事件/任何内容生成的所有事件是有用的。它们也可能跨越不同的对象类型,例如在购物车中点击“购买”可能会触发账户和仓储事件的触发。消费应用程序可能想要知道所有这些,因此我们添加了 CorrelationId。这意味着消费者可以要求所有由其请求引发的事件。您将在schema中看到这一点。
特别是在 SQL 方面,我们发现如果没有充分使用索引和分区,则性能确实成为瓶颈。请记住,如果使用快照,则需要以相反的顺序流式传输事件。我们尝试了几种不同的索引,并发现在实践中,某些附加索引需要用于在生产实际应用程序中进行调试。同样,您将在schema中看到这一点。
生产中其他元数据在生产基础调查期间很有用,时间戳为我们提供了有关事件持久化与引发顺序的洞察力。这为我们提供了一些帮助,特别是在一个极大量的事件驱动系统上,它产生了大量的事件,为我们提供了有关诸如网络性能和系统在网络上的分布等方面的信息。

太好了,谢谢。事实上,在写这个问题之后很久,我已经在我的Inforigami.Regalo库中构建了一些自己的实现,包括RavenDB、SQL Server和EventStore。想过做一个基于文件的实现,只是为了好玩。 :) - Neil Barnwell
1
谢谢。我主要是为了那些最近遇到这个问题并分享一些经验教训,而不仅仅是结果,才添加了答案。 - cdmdotnet

3

你可能想要看一下Datomic。

Datomic是一个支持查询和连接的灵活的“基于时间的事实”数据库,具有弹性可扩展性和ACID事务。

我在这里写了一个详细的回答(链接)

您可以观看Stuart Halloway关于Datomic设计的演讲(链接)

由于Datomic以时间为基础存储事实,因此您可以将其用于事件溯源等用例,以及更多用途。


2
我认为解决方案(1和2)很快就会成为一个问题,因为您的域模型在发展。新字段被创建,有些改变了含义,有些可能不再使用。最终,您的表将有几十个可空字段,并且加载事件将会变得混乱。
另外,请记住,事件存储应该仅用于写入,您只查询它来加载事件,而不是聚合的属性。它们是分开的事物(这就是CQRS的本质)。
解决方案3是人们通常采用的方法,有许多实现方式。
例如,EventFlow CQRS与SQL Server一起使用时创建具有此架构的表:
CREATE TABLE [dbo].[EventFlow](
    [GlobalSequenceNumber] [bigint] IDENTITY(1,1) NOT NULL,
    [BatchId] [uniqueidentifier] NOT NULL,
    [AggregateId] [nvarchar](255) NOT NULL,
    [AggregateName] [nvarchar](255) NOT NULL,
    [Data] [nvarchar](max) NOT NULL,
    [Metadata] [nvarchar](max) NOT NULL,
    [AggregateSequenceNumber] [int] NOT NULL,
 CONSTRAINT [PK_EventFlow] PRIMARY KEY CLUSTERED 
(
    [GlobalSequenceNumber] ASC
)

翻译如下:

  • GlobalSequenceNumber:简单的全局标识,可用于排序或识别在创建投影(读模型)时丢失的事件。
  • BatchId:原子插入的事件组的标识(TBH,不知道这有什么用处)。
  • AggregateId:聚合标识。
  • Data:序列化事件。
  • Metadata:事件的其他有用信息(例如用于反序列化的事件类型、时间戳、命令的发起者ID等)。
  • AggregateSequenceNumber:同一聚合内的序列号(如果您不能按顺序进行写操作,则可以使用此字段进行乐观并发控制)。

但是,如果您从头开始创建,则建议遵循YAGNI原则,并根据您的用例创建最少所需的字段。


我认为BatchId可能与CorrelationId和CausationId有关。它们用于找出事件的原因,并在需要时将它们串联起来。 - Daniel Park
可能是这样。然而,如果是这样的话,提供一种自定义的方式(例如将其设置为请求的ID)是有意义的,但框架并没有这样做。 - Fabio Marreco
我认为BatchId可能会被用于协调跨多个聚合的事务/回滚,你觉得呢? - Mihai Stancu

1
我认为虽然回答晚了,但是如果您的吞吐量要求不高,使用关系型数据库作为事件溯源存储是完全可行的。我将展示我构建的事件溯源分类帐的示例以说明这一点。 https://github.com/andrewkkchan/client-ledger-service 上面是一个事件溯源分类帐Web服务。 https://github.com/andrewkkchan/client-ledger-core-db 而上面我使用RDBMS计算状态,因此您可以享受与RDBMS相同的所有优势,例如事务支持。 https://github.com/andrewkkchan/client-ledger-core-memory 我还有另一个消费者在内存中处理以处理突发事件。
有人会争论上述实际事件存储仍然存在于Kafka中,因为对于插入而言,RDBMS速度较慢,特别是当插入始终是追加时。
我希望代码能够为您提供一个说明,除了已经提供给这个问题的非常好的理论答案。

谢谢。我早就建立了一个基于SQL的实现。除非您在某个聚集键上做出了低效的选择,否则我不确定为什么关系型数据库管理系统对插入操作很慢。仅追加应该没问题。 - Neil Barnwell

1
可能的提示是设计跟随“慢变维度”(type=2),应该帮助您涵盖以下内容:
  • 事件发生顺序(通过代理键)
  • 每个状态的持久性(有效期从-到)

左折叠函数也可以实现,但需要考虑未来查询复杂性。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接