logo

Kafka 基础知识

Published on

数据同步是任何产品中最重要的方面之一。Apache Kafka 是设计一个期望近实时传播大量数据的系统时最受欢迎的选择之一。尽管 Kafka 具有简单而强大的语义,但使用它需要对其架构有深入的了解。本文总结了 Kafka 作为代理和作为数据生产者或消费者的应用程序的最重要的设计方面。

关于 Kafka

Apache Kafka 起源于 LinkedIn,被开发为一个高度可扩展的遥测和使用数据分发系统。随着时间的推移,Kafka 演变为一个通用的流数据骨干,结合了高吞吐量和低数据传输延迟。其次,Kafka 是一个分布式日志。一个(提交)日志是一个仅追加的数据结构,生产者将数据(日志记录)追加到其末尾,订阅者从头开始读取日志以重放记录。这种数据结构用于例如数据库的预写日志。分布式日志意味着实际的数据结构不是托管在单个节点上,而是分布在多个节点上,以实现高可用性和高性能。

内部结构和术语

在我们深入了解应用程序如何使用 Kafka 之前,让我们快速浏览一下基本术语和架构,以便我们了解 Kafka 为其用户提供的保证。

单个 Kafka 节点称为代理。代理从生产者接收消息并将其分发给消费者。生产者将消息发送到分布式日志中,这些日志称为主题(在传统消息传递中,这相当于一个队列)。为了将单个主题的性能扩展到单个节点的容量之上,每个主题可以分成多个分区。为了实现存储数据的高可用性和持久性,每个分区都有一个领导者(执行所有读写操作)和多个跟随者。分区会自动分配给代理,代理的故障转移也是自动的,对使用 Kafka 的开发人员是透明的。在后端,领导者/副本角色的分配是通过 Apache ZooKeeper 中的领导者选举或在 Kafka 的较新版本中使用 KRaft 协议进行协调的。

Maple

在图中,我们可以看到一个由五个代理组成的 Kafka 集群。在这种情况下,创建了两个主题(A 和 B)。主题 A 有两个分区,而主题 B 只有一个分区。集群的复制因子设置为 3——这意味着始终存储有三份数据副本,允许两个节点故障而不会丢失数据。复制因子为 3 是一个合理的默认值,因为它保证即使在维护另一个代理期间也能容忍节点故障。

你可能会问为什么主题 A 被分成两个分区;有什么好处?首先,请注意分区 1 的领导者与分区 2 的领导者位于不同的节点上。这意味着如果客户端向该主题生产/消费数据,他们可以使用 2 个节点而不是 1 个节点的磁盘吞吐量和性能。另一方面,这一决定也有代价:消息排序仅在单个分区内得到保证。

生产者和消费者

现在我们对 Kafka 的内部工作原理有了一些了解,让我们从生产者/消费者的角度来看一下情况。

生产者

让我们从生产者开始。如上所述,主题/分区的复制或分配是 Kafka 自己的事情,对生产者或消费者是不可见的。因此,生产者只需要知道它希望将数据发送到哪些主题,以及这些主题是否有多个分区。如果主题是分区的(实体-1),生产者可以在其代码中创建一个“分区器”,这是一个简单的类,决定给定记录属于哪个分区。因此,在 Kafka 中,分区是由生产者驱动的。如果生产者没有指定任何分区器(但主题是分区的),则使用轮询策略。对于没有确切顺序重要性的实体,轮询是完全可以的——记录之间没有因果关系。例如,如果你有一个带有传感器测量值的主题,这些测量值可能由传感器按计划发送——因此记录没有特定的顺序。轮询提供了一种简单的方法来在各个分区之间平衡记录。

我们的传感器示例还显示了另一个重要细节:可能有多个生产者将记录发送到一个主题。

Maple

在上图中,我们看到有许多传感器(生产者)创建两种类型的记录:湿度(绿色)和 CO2 浓度(红色)。每个记录还包含有关传感器本身的信息(例如其(序列)号,在此示例中为简单起见使用整数)。由于每个传感器都具有测量湿度的能力,而只有一些传感器支持 CO2 测量,系统设计者决定使用传感器的序列号将湿度记录分成两个分区。

请注意,每个湿度分区(以及 CO2 分区)内都有严格的排序,但分区之间的记录没有排序——换句话说:B 将始终在 D 和 E 之前处理。A 将始终在 C 之前处理,但 B 和 A(或 C)之间没有排序保证。

消费者

Kafka 消费者是一个从主题中读取记录的应用程序。在 Kafka 中,通过消费者组这个概念——一组合作的消费者。当同一组中的多个消费者订阅同一主题时,Kafka 始终以每个分区被精确读取一次的方式在同一组中的消费者之间分配分区(可能有多个分区被单个消费者读取,但一个分区不会被多个消费者读取)。如果某些消费者失败,Kafka 会自动将分区重新分配给其他消费者(请注意,消费者不需要订阅所有主题)。

Maple

但在故障转移或切换的情况下,Kafka 如何知道从哪里继续?我们已经说过,一个主题包含所有消息(即使是已经读取的消息)。这是否意味着消费者必须再次读取整个主题?答案是消费者能够从上一个消费者停止的地方继续。Kafka 使用一个称为偏移量的概念,它本质上是分区中消息的指针,存储任何给定消费者组处理的最后一条消息的位置。

偏移量

虽然这看似简单,但偏移量和分布式日志的概念极其强大。可以动态添加新的消费者,这些消费者(从偏移量=0 开始)能够赶上完整的数据历史。虽然在传统队列中,消费者需要以某种方式从消费者中获取所有数据(因为在经典消息传递中,消息一旦读取就会被删除)。这种数据同步更为复杂,因为生产者要么将消息生成到用于增量的一个队列中(并影响所有其他消费者),要么消费者需要使用其他机制(如 REST 或另一个专用队列),这会产生数据同步问题(因为使用了两个独立的不同步机制)。

另一个巨大的好处是消费者可以随时决定重置偏移量并从时间的开始读取。为什么要这样做?首先,有一类分析应用程序(如机器学习)需要处理整个数据集,偏移量重置提供了这样一种机制。其次,可能会发生消费者中存在错误,导致数据损坏。在这种情况下,消费者产品团队可以修复问题并重置偏移量——有效地重新处理整个数据集并用正确的数据替换损坏的数据。这种机制在 Kappa 架构中被广泛使用。

保留和压缩

我们上面提到提交日志是仅追加的,但这并不意味着日志是不可变的。事实上,这仅适用于某些类型的部署,在这些部署中,有必要保留所有更改的完整历史记录(出于审计目的或拥有真正的 kappa 架构)。这种策略很强大,但也有代价。首先是性能:消费者需要通过大量数据才能到达日志的顶部。其次,如果日志包含任何敏感信息,很难摆脱它(这使得这种类型的日志不友好于要求在请求时删除数据的法规)。

但在许多情况下,日志有一些固定的保留——无论是基于大小还是时间。在这种情况下,日志只包含一段消息(任何溢出都会自动删除)。使用日志作为缓冲区使日志大小合理,并确保数据不会永远留在日志中(使其更容易遵守合规性要求)。然而,这也使得日志在某些用例中不可用——其中一个用例是当你希望所有记录对新订阅的消费者可用时。

最后一种日志是所谓的压缩日志。在压缩日志中,每条记录不仅有一个值,还有一个键。每当新记录被追加到主题中,并且已经存在具有相同键的记录时,Kafka 最终会压缩日志并删除原始记录。请注意,这意味着在某段时间内,会有多个具有相同键的记录,并且最新插入的记录始终包含最新的值——如果你选择至少一次语义(保证消息会被传递,但在任何不确定的情况下(例如由于网络问题),消息可能会被多次传递),这不需要任何额外的处理。

你可以将压缩日志视为一种流式数据库,允许任何人订阅最新数据。这种 Kafka 的形象是非常正确的,因为流和表之间存在二元性。这两个概念只是同一事物的不同视图——在 SQL 数据库中,我们也使用表,但在底层,有一个提交日志。同样,任何 Kafka 主题(包括压缩的)都可以被视为一个表。事实上,Kafka Streams 库建立在这种二元性之上。甚至有 ksqlDB(Kafka SQL),允许你在 Kafka 中的记录上发出 SQL 语句。

Maple

在上面的拓扑中,我们看到入站测量主题(温度、湿度、co2…)是正常主题,保留设置为七天。保留允许开发人员在发现实现中的错误时回溯一周。从这些入站主题中,数据由两个服务读取(每个服务在一个单独的消费者组中)。测量历史服务将遥测数据存储到时间序列数据库中(长期存储),可以用作系统 UI 中图表和小部件的来源。

趋势服务聚合数据(在给定房间中创建 24 小时的测量窗口),以便下游控制器可以使用这些数据,并通过压缩主题发送结果。主题是压缩的,因为没有必要保留任何历史记录(只有最新的趋势是有效的)。另一方面,客户可以随时添加新设备(和相关控制器),因此我们希望确保始终存在给定房间的最新读数。

模式和原则

在前面的段落中,我们介绍了基本概念。在本节中,我们将扩展这些概念并讨论一些其他的 Kafka 模式。

最终一致性架构

在基于消息传递的数据同步架构中,我们希望确保无论何时在一个产品中生成新数据,它都将在近实时内对所有相关产品可用。这意味着如果用户在产品 A 中创建/修改某个实体并导航到产品 B,他/她应该(理想情况下)看到该实体的最新版本。

然而,由于各个产品使用多个独立的数据库,因此不实际拥有分布式事务机制并在这些数据库之间实现原子一致性。相反,我们选择最终一致性。在这种模型中,数据生产者负责将其创建/更新/删除的任何记录发布到 Kafka,感兴趣的消费者可以从中检索记录并在本地存储。

这种系统之间的传播需要一些时间。

  • 从记录发布到记录对订阅者可用的时间不到一秒(预期)
  • 此外,消费者可以优化对其数据库的写入(例如,批量写入)。

在此期间,某些系统(副本)具有略微过时的数据。也可能发生某些副本在一段时间内无法赶上(停机、网络分区)。但从结构上讲,保证所有系统最终会收敛到相同的结果,并将持有一致的数据集——因此称为“最终一致性”。

优化对本地数据库的写入

如前段所述,消费者可能希望优化对其本地数据库的写入。例如,在关系数据库中逐条记录提交是非常不理想的,因为事务提交是一个相对昂贵的操作。更明智的做法可能是批量提交(每 5000 条记录提交一次;最大间隔为 500 毫秒——以先到者为准)。Kafka 完全能够支持这一点(因为提交偏移量由消费者掌握)。

另一个例子是 AWS Redshift,它是一个数据仓库/OLAP 数据库,其中提交非常昂贵。此外,在 Redshift 中,每次提交都会使其查询缓存失效。因此,集群会因提交本身而受到两次打击——一次是执行提交,第二次是所有先前缓存的查询必须重新评估。出于这些原因,你可能希望在计划的基础上每 X 分钟提交一次 Redshift(和类似技术),以限制此操作的影响范围。

最后一个例子可能是 NoSQL 数据库,它们不支持事务。根据数据库引擎的能力,逐条记录流式传输数据可能是完全可以的。

有一个要点:即使它们消费相同的数据,不同的副本也可能使用略有不同的持久化策略。始终假设另一方可能尚未获得数据。

主题之间的参照完整性

重要的是要理解,由于基于 Kafka 的数据同步是最终一致的,因此在各个主题(或分区)之间没有隐式的参照完整性或因果完整性。在涉及参照完整性时,消费者应该以期望他们可能会收到例如尚未收到的房间的测量值的方式编写。一些消费者可能会克服这种情况,要么在所有维度都存在之前不显示数据(例如,当你不知道房间时,无法打开通风)。对于其他系统,缺少的引用并不是一个真正的问题:无论是否存在房间详细信息,房子的平均温度都是相同的。

出于这些原因,在中心实施任何严格限制可能是不切实际的。

有状态处理

Kafka 消费者可能需要有状态处理——例如聚合、窗口函数和去重。此外,状态本身可能不是一个简单的大小,或者可能有一个要求,即在发生故障时,副本能够几乎立即继续。在这些情况下,将结果存储在消费者的 RAM 中不是最佳选择。幸运的是,Kafka Streams 库对 RocksDB——一个高性能嵌入式键值存储——提供了开箱即用的支持。RocksDB 能够将结果存储在 RAM 和磁盘中。

缓存策略和并行性

与有状态处理密切相关的是缓存策略。Kafka 由于其设计,不适合竞争消费者的工作方式,因为每个分区都分配给一个处理器。如果想要实现竞争消费者,他需要创建比系统中消费者多得多的分区以模拟行为。然而,这不是在基于 Kafka 的系统中处理并行性的方式(如果你真的需要一个不相关作业的作业队列,你会更适合使用 RabbitMQ、SQS 和 ActiveMQ…)。

Kafka 是一个流处理系统,期望一个分区中的记录以某种方式相互关联并应一起处理。各个分区充当数据分片,由于 Kafka 保证每个分区将分配给一个且仅一个消费者,消费者可以确保没有其他竞争处理器——因此可以根据需要在本地缓存中缓存结果,而不需要实现任何分布式缓存(如 Redis)。如果处理器失败/崩溃,Kafka 只会将分区重新分配给另一个消费者,该消费者将填充其本地缓存并继续。这种流处理设计比竞争消费者要简单得多。

不过,有一个考虑因素。那就是分区策略,因为默认情况下由生产者定义,而不同的消费者可能有多个相互不兼容的需求。出于这个原因,在 Kafka 的世界中重新分区主题是很常见的。在我们的场景中,它将以以下方式工作:

Maple

在图中,我们可以看到趋势在其主题中生成趋势。此主题是轮询分区和压缩的。专注于大型工业客户的 ProductX 需要以某种其他方式对数据进行分区,例如按 customerId。在这种情况下,ProductX 可以编写一个简单的应用程序来重新分区数据(重新分区通常由 Kafka Streams 库在后台管理)。换句话说,它从源主题读取数据并将其写入由 ProductX 管理的另一个主题,该主题以不同的方式分区数据(在本例中按业务单元)。通过这种分区,ProductX 能够将不重叠的业务单元分片到专用处理节点,大大增加了处理并行性。内部 ProductX 主题可能只有短期保留(如 24 小时),因为它不持有数据的权威副本,并且如果需要,可以轻松地从原始主题重播数据。

在 Kafka Streams 中,你可能希望加入多个实体以组合数据(这是一个常见的用例)。请注意,如果你有多个消费者,你需要将入站主题以完全相同的方式分区(相同的分区器(基于连接键),相同的分区数)。只有这样,你才能保证具有匹配连接键的实体将由同一消费者(处理器)接收。

总结

在本文中,我们讨论了 Kafka 的整体架构,以及这种低级架构如何使代理能够轻松水平扩展(得益于分区)并确保高可用性和持久性(得益于领导者/副本设计和主选举)。我们还介绍了设计基于 Kafka 的拓扑的基础知识,解释了最终一致性及其对我们应用程序的保证的影响,并学习了如何使用 Kafka 的不同类型的日志及其保留。

虽然 Kafka 乍一看可能令人望而生畏,但重要的是要意识到其内部基于简单的分布式日志。这种相对简单的内部结构赋予了 Kafka 其直观的语义、高吞吐量和低数据传播延迟。这些品质对于构建任何数据管道至关重要。