数据更改事件的三种类型


数据变更事件是Debezium等变更数据捕获 (CDC) 解决方案的核心。它们描述对数据库中特定记录所做的更改,并允许事件使用者根据此信息采取行动,从而实现广泛的用例,例如实时 ETL(通过将更新的数据传播到下游数据存储,例如数据仓库、分析数据库或全文搜索索引)、微服务数据交换或审计日志记录。

变更事件中到底包含什么?存在哪些类型的变更事件,什么时候应该使用哪个?这些是我想在这篇文章中通过开发数据更改事件的分类法来回答的一些问题,讨论三种类型的事件:

  • 完整事件,包含更改记录的完整状态,
  • 增量delta事件,其中包含记录的变异字段,以及
  • 仅 ID 事件,仅包含已更改记录的 id(主键)。

完整事件
让我们从大多数 CDC 用户可能都熟悉的事件类型开始:完整或完整的数据变更事件。每当源数据存储中的记录发生变化时,这种变化事件就会包含该记录的完整状态。举例来说,表 customers 包含 id、first_name 和 last_name 列以及一个数组类型的 emails 列。如果客户记录的 first_name 值被更新,而其他字段没有变化,那么相应的变更事件使用 JSON 符号可以如下所示:

{
  "id" : 42,
 
"first_name" : "Barry",
 
"last_name" : "Wilson",
 
"emails" : ["barry@example.com", "bwilson@example.com"]
}

更改事件是完全独立的。它描述了记录被修改时的完整状态,特别是记录修改后的新状态。许多 CDC 解决方案都会在其更改事件中公开被修改记录的新旧状态(有时也称为新旧 "行映像"),例如 Debezium 就会将其命名为之前和之后:

{
  "before": {
   
"id" : 42,
   
"first_name" : "Billy",
   
"last_name" : "Wilson",
   
"emails" : ["barry@example.com", "bwilson@example.com"]
  },
 
"after": {
   
"id" : 42,
   
"first_name" : "Barry",
   
"last_name" : "Wilson",
   
"emails" : ["barry@example.com", "bwilson@example.com"]
  },
}

事件中包含哪些部分取决于数据变化的类型:

  • 对于表示插入记录的事件,只显示新行的图像、
  • 对于更新事件,新旧记录都会出现。
  • 对于删除事件,只有后数据块中的旧行图像才会出现。

在插入和更新事件中,旧行映像是否实际存在也取决于源数据库的配置。通常情况下,必须明确启用保留旧行映像的功能,因为这样做会增加数据库系统的磁盘空间消耗。例如,要在 Postgres 的变更事件中发出旧行版本,必须将表的副本标识设置为 "FULL"。

数据变更事件和 Apache Kafka:通过 Apache Kafka 等分区系统传输数据变更事件时,还需要为消息定义密钥。它定义了一条记录将被发送到变更事件主题的哪个分区,以确保具有相同密钥的所有记录的正确排序。对于数据变更事件,键应来自源数据存储中代表记录的主键。这样,一条记录的所有变更事件都将进入相应变更事件主题的同一个分区,消费者将按照它们在源数据库中发生的顺序接收它们。

Delta增量事件
接下来让我们看看 delta 事件或部分更改事件。它们并不包含所代表记录的完整状态,而只包含值实际发生变化的列或字段以及记录的 id。换句话说,它们准确描述了与记录的上一版本相比发生了哪些变化(但仅此而已)。对于代表插入操作的事件来说,这些是记录的所有属性,而对于更新操作来说,这些只是发生变化的属性。对于删除操作,只有 id 属性。

部分更改事件有两种不同的设计方式。第一种是在变更事件中发出任何已修改的属性。让我们再看看上一节的例子:客户 42 的名字被修改,而姓氏和电子邮件地址保持不变。再次使用 JSON 符号,并只关注新的行图片,相应的更改事件可以如下所示:

{
  "id" : 42,
 
"first_name" : "Barry"
}

根据所选择的序列化格式,在处理空值时会有一些微妙之处。特别是,它必须允许您区分被设置为空值的(可选)属性和完全未发生变化的属性。在 JSON 中,您可以通过为字段发送空值与从事件有效负载中省略空值来区分这两种情况。

部分数据更改事件的第二个选项是描述哪些操作具体应用于哪些属性。这在处理数组值属性时尤其有用。在更新的情况下,当变更事件格式包含完整的新值时,一个微小的变更就可能导致写入放大,例如,在一个有 20 个条目的数组中添加或删除一个元素。在这种情况下,JSON Patch 等格式非常有用,因为它们可以更精细地描述变化:

{
  "id" : 42,
 
"patch" : [
    {
"op": "replace", "path": "/first_name", "value": "Barry" }
         {
"op": "add", "path": "/emails/-", "value": { "berry@example.com" } }
    ]
}

与完整事件不同, delta增量数据更改事件并非完全独立。当接收到部分更新事件时,事件消费者必须能够访问该记录之前的状态,才能应用该补丁事件。例如,如果消费系统是一个 SQL 数据库,则可以发布 UPDATE 语句来更新受影响的列。

但是,如果汇数据系统不支持部分更新,而总是要求在更新发生时摄取完整记录,该怎么办呢?在这种情况下,有状态流处理(例如使用 Apache Flink)可能是一个有用的选择。您可以将这种流处理器放在事件源和汇之间,它将对完整事件进行 "再水化",这意味着将所有传入的部分事件一个接一个地应用起来。为此,它会利用内部状态存储(如 Flink 的 RocksDB)。在处理记录的插入更改事件时,该事件将被放入状态存储中,然后再向下游发送。

之后,在处理更新事件时,流处理器可以从状态存储中获取传入部分事件中缺失的任何属性值,从而只向下游事件消费者公开完整事件。虽然类似的先读后写方法也可以在汇数据存储中实施,但在流处理管道中实施这种方法可以一次性构建再水化逻辑,然后让多个汇从中受益。

在 CDC 系统大部分时间都会发出完整的数据变更事件,但在某些情况下可能会发出部分事件的情况下,这种技术也能派上用场。Debezium 的 Postgres 连接器就是一个例子,如果 TOAST 列的值未发生变化,该连接器就不会发出这些列的值。如上所述的有状态流处理可以帮助消费者避免这种行为,并始终向事件消费者暴露完整的事件。

纯 ID 事件
数据更改事件的最后一种也是最基本的一种形式是纯标识事件。它们仅描述源数据库中受更改影响的记录。为此,事件必须包含记录的 id(例如,RDBMS 中记录的主键值):

{
    "id" : 42
}

除严格意义上的数据库和 CDC 外,Id-only 事件还可用于其他场合。Amazon S3 事件通知就是一个例子,您可以用它来订阅 S3 存储桶中发生的变化,如文件的添加或删除。由于在相应的更改事件中公开整个文件状态不切实际,因此这里使用了仅 ID 事件样式。

就其本质而言,这种只有 id 的事件不会告诉你所代表的记录到底发生了什么变化。因此,这种事件类型只能在相当小的应用范围内使用。例如,可以用它来使缓存中的项目失效,但不能用它本身来更新缓存。使用仅 ID 事件的系统包括 Microsoft SQL Server 的更改跟踪功能、CockroachDB 的 "key only "模式和 DynamoDB 的 KEYS_ONLY 流视图类型。

如果想获取整条记录,除了从源存储中重新选择外,别无选择。这可以由变更事件消费者自己完成,也可以由流处理器完成,然后由流处理器向下游消费者发出完整的变更事件。这样做时有几件事需要考虑。

最重要的是,CDC 工具以异步方式发出变更事件,这意味着当你运行查询以获取完整的行状态时,该行可能已经再次发生了变更。查询将返回行的当前状态,而不是最初触发变更事件时的有效状态。如果该行在很近的时间内发生了多次更改,则可能无法提取该记录的所有中间版本。

以这种方式重新水化的事件仍然非常有用,例如用于将数据变更传播到全文搜索引擎中;一般来说,只需在索引中保留记录的最新版本即可,无需应用短时间内发生的所有中间更新。另一方面,如果您使用 CDC 来跟踪采购订单的状态转换并触发相应的下游操作,或者用于维护审计日志,那么跟踪每一次数据变化都是至关重要的,而这种技术就派不上用场了。

实施重新select策略时,应考虑一次检索多条记录。例如,当收到十条客户记录的变更事件时,与其执行十个查询逐一检索,不如将它们批量合并到一个查询中,从而大大减少源数据库的负载。另一个有趣的选择是,不只检索特定记录本身,而是检索整个数据集合。例如,当接收到客户 42 的仅 ID 事件时,可以运行一个查询,通过连接所有相关表来检索客户数据以及他们的地址信息和银行账户详细信息。

在比较三种数据变化事件类型并讨论它们各自的优缺点之前,还有一个问题值得关注,那就是事件元数据,即描述事件上下文信息的数据。

更改事件元数据
除了代表数据更改本身的实际更改事件有效载荷外,为事件提供额外的元数据通常也很有用。这通常包括

  • 更改类型(插入、更新、删除)
  • 事件发生的时间戳
  • 原始数据库、模式和表的名称
  • 事务 ID
  • 事件在源数据库事务日志中的位置
  • 触发更改的查询

举例来说,下面是 Postgres 的 Debezium 连接器发出的更新事件,在 ts_ms、op 和 source 字段中包含一系列事件元数据(在 Maxwell's Daemon 等其他 CDC 工具发出的事件中也能找到类似的元数据):

{
  "before": {
   
"id": 1004,
   
"first_name": "Billy",
   
"last_name": "Wilson",
   
"email": "bwilson@example.com"
  },
 
"after": {
   
"id": 1004,
   
"first_name": "Barray",
   
"last_name": "Wilson",
   
"email": "bwilson@example.com"
  },
 
"source": {
   
"version": "2.5.0.Final",
   
"connector": "postgresql",
   
"name": "dbserver1",
   
"ts_ms": 1705663711187,
   
"snapshot": "false",
   
"db": "postgres",
   
"sequence": "[\�\",\�\"]",
   
"schema": "inventory",
   
"table": "customers",
   
"txId": 773,
   
"lsn": 34494376,
   
"xmin": null
  },
 
"op": "u",
 
"ts_ms": 1705663711220
}

变更事件元数据可以在消费者端实现许多有趣的应用。例如,有关事件源自哪个事务的信息也可用于将相同的事务语义传播到数据管道的汇数据存储区:您可以缓冲一个事务的事件,并在一个事务中将所有事件一次性应用到汇数据存储区,而不是逐个摄取传入的事件。

这样,针对汇数据存储的查询就会受到与源数据库相同的隔离保证。另一个有趣的元数据字段是 Debezium 的 Postgres 连接器发出的 sequence 属性,客户端可利用它在数据管道中进行重复数据删除,并至少使用一次语义。

比较
在探讨了三种数据变更事件之后,您应该使用哪一种呢?很多时候,这个问题并没有统一的答案。每种类型都有各自的优缺点,您需要根据具体情况做出明智的决定。

1、对于消费系统来说,完整的数据变更事件往往最容易处理。传入的事件可以使用 "upsert "语义简单地写入汇数据存储,覆盖之前可能存在的任何版本。在通过分布式日志系统(如 Apache Kafka)传播变更事件时,可以压缩包含完整变更事件的主题。由于每个事件都是完全独立的,因此只需在日志中保留每条记录的最新变更事件即可,而且仍有可能将数据集的完整状态传播给消费者(如果变更事件包含新旧行映像,那么即使在压缩的变更事件主题中,也会保留每条记录的最后两个版本)。仅从分布式日志中的状态来引导新的事件消费者也是很容易实现的。

完整事件的缺点是体积较大。

2、标识事件则更为简洁,因为除了已更改记录的标识外,它们不传递任何其他信息。为了检索实际的事件状态,您需要再次查询源系统,这样做的风险是,您可能会错过从触发变更事件到处理该事件这段时间内对记录进行的任何中间更新。因此,它们的用途非常有限,但在某些用例(如缓存失效)中却能派上用场。

3、增量事件是一个有趣的中间地带。它们只传递已更改记录的已修改字段,比完整事件占用的空间更少。但为了将它们传播到数据存储池,数据存储池必须具备进行部分更新的能力,即只更新记录字段的子集,而不是重写整个记录。如果无法做到这一点,可以在 CDC 工具和汇数据存储之间使用有状态流处理管道来重新创建完整事件。不能压缩包含 delta 事件的变更事件主题,否则消费者可能会错过重新创建所代表源记录所需的更新事件。因此,当更新量很大时,包含部分事件的主题甚至可能比包含完整事件的(压缩)主题占用更多空间。

总结
使用 Apache Flink 等解决方案进行实时流处理,是 Debezium 等 CDC 工具的强大辅助工具,可在需要时转换和修改变更事件流。例如,通过从源数据库中选择整个行状态来扩展仅 ID 的变更事件,以及通过使用状态存储从 delta 变更事件中提取完整事件。