Kafka:分布式日志系统

128 阅读29分钟

本章内容包括

  • 日志的用途与属性
  • 将 Kafka 视为一种基于日志的系统
  • 作为分布式系统的 Kafka:分区与复制
  • 进一步剖析 Kafka 集群组件
  • Kafka 在企业环境中的应用

在本章中,我们将探讨“日志”这一概念以及 Kafka 如何采用基于日志的方式运行。我们会审视 Kafka 作为分布式系统的架构并深入其核心组件。最后,我们会讨论 Kafka 在企业环境中的实际应用,突出其在企业数据管理中的优势。

4.1 日志(Logs)

我们喜欢把 Kafka 描述为一个“日志”。虽然在 Kafka 的语境下“日志”这个词对许多人来说是新的,但在 IT 世界中我们日常就经常接触到各种日志。

4.1.1 什么是日志?

我们的操作系统会生成系统日志。如果我们负责维护这些系统,就会定期查看这些日志以了解系统状态,并在发生错误时通过日志追踪错误产生的原因。我们甚至可能使用日志监控系统,在某些事件发生时触发告警,必要时(但希望不要发生)半夜把我们叫醒去处理故障。

同样地,我们的应用和服务也会产生日志事件,通过这些日志可以看到系统发生了什么——是否出现了错误、各项服务是否正常运行。我们用系统日志和应用日志来分析错误并监控相应系统的状态。在这些场景里,日志是有用且必不可少的,但它们并不是系统的核心。

数据库系统的情况则不同。这里的提交日志(commit log)被深藏在系统内部,但它却是系统的主要组成部分之一。数据库会把对数据的任何更改写入提交日志:当数据被新增、修改或删除时,数据库首先将该变化写入提交日志,然后才将变化应用到相应表中。如果数据库发生故障,提交日志可以保证我们将其恢复到最后一次干净的状态而不丢失数据。这同样适用于基于复制的场景,例如集群数据库设置中,常常通过保持提交日志在集群单元间同步来实现复制,各服务器再基于复制来的提交日志独立重建表数据。

尽管这些日志形式各不相同,但它们都回答同一个问题:“发生了什么?”系统与应用日志为系统的运维人员回答这个问题,而数据库的提交日志则为数据库集群中的其它服务器或在系统崩溃时为自身回答这个问题。

回想第一章的示例,我们也能看到类日志的数据结构。例如,以下是我们主题 products.prices.changelog 的内容示例:

coffee pads 10
coffee pads 11
coffee pads 12
coffee pads 10

这些相似性的原因很简单:Kafka 以日志作为其中心数据结构。与数据库不同,Kafka 并不隐藏日志,而是将日志作为系统的核心元素。我们的数据存储在 Kafka 的日志中,我们可以把在系统、应用和数据库日志世界中发现的模式与见解,直接应用到 Kafka 上!

4.1.2 日志的基本属性

我们在 IT 之外的场景中也常用日志。最简单的例子可能是日记。如果我们从日记的第一页开始每天写一页(不留空白页,且用圆珠笔而不是铅笔),我们就能识别出日志的若干特征:

  • 顺序与排序 — 日志中的消息按时间排序。最早的消息在日志开头,最新的消息在日志末尾。就像日记一样(如果中间不留空页),第 10 页的条目比第 11 页的条目要早。
  • 写入与读取方向 — 我们总是把新条目写在日志的末尾。阅读日志时,通常从某一页开始,先读较旧的条目再读较新的。当然我们也可以回溯阅读,但日志的自然阅读方向是从旧到新。
  • 不可变性 — 一旦写入一条条目,就很难修改或删除它。在日记里这仍然可行(例如添加批注、划掉文字或撕掉页面),但这种修改通常是显而易见的。

日志的另一个有趣特性是:我们可以很容易地理解日志所描述的那部分世界随时间如何变化,从而知道该世界在某个时间点的样子。

当我们想弄清楚某个错误是如何发生时,常会查看系统或应用日志。按时间顺序阅读日志条目,往往可以理解随时间发生了哪些变化,从而找到问题线索。数据库也是如此:如果想知道某张表在某一时间点的状态,可以从提交日志的最开始处出发,逐条阅读直到目标时间点,从而看到表是如何随着事件演进而变化的,以及为何我们最终在表中看到那些特定的条目。

此外,日志还允许我们“回到过去”!如果我们想把数据库状态恢复到前天的某一时刻,可以从提交日志头部开始读,直到对应的前天条目,借此重现当时的数据库状态。

image.png

有了这些认识,我们可以更正式地定义“日志”。日志是一个事件的列表,我们在上面定义以下两种操作(这些操作在图 4.1 中有可视化表示):

  • 写入操作——我们将新条目追加到列表的末尾。
  • 读取操作——我们从某个特定条目开始,从旧到新读取,通常读到日志的末尾。

大多数情况下,日志太大、太长,无法一次性读完。应用日志可能达到数 GB。那么我们如何记住哪些条目已经读过、哪些还没读?我们给条目编号:第一条条目的位置为 0,第二条为 1,依此类推。

在 Kafka 中,这个位置信息就是 offset;我们在上一章想要从头读取主题时就遇到过它。offset 有两个作用:一是像页码一样告诉我们一条消息在日志中的位置,二是指向我们下一次要读取的条目,类似于记住书中的下一页页码。就像我们读书会从起始页(offset 0)开始,读几页后记住下一次要读的页码一样,Kafka 使用类似书签的方式来记录进度。

注意:Kafka broker 在记录写入主题的瞬间会自动为该记录分配 offset,从而确保在日志中的精确定位。

读取日志的系统可以把这些 offset 保存在内存(RAM)中,但那并不可靠——系统一旦失败,可能得从头开始读。为此,Kafka 提供了更可靠的方式:它将这些 offset 存储在我们在上一章列出集群主题时看到的 __consumer_offsets 主题中。也就是说,Kafka 不仅维护日志本身,还为消费者提供管理其 offset(从而管理其消费进度)的手段。

4.1.3 把 Kafka 看作日志

由于 IT 系统中的日志条目通常不是静态的,会持续追加新条目,所以日志通常没有“结尾”。在 Kafka 中,我们记录下下一个要读取的 offset,并持续向 Kafka 集群查询是否有新条目。如果有新条目,便取回这些条目并记下下一次要读取的 offset;如果没有新条目,则不会返回数据,也不需要调整 offset。

offset 的有趣之处在于它只描述消息的位置,而不描述消息内容。就像书页编号一样,offset 是连续的编号本身并不携带额外含义。这一点非常重要:在日志中,offset 是唯一的寻址方式,我们不能像访问数组那样直接定位具体的元素。

这一点对使用 Kafka 的服务架构有深远影响。我们不应把 Kafka 用作随时随地回答任意数据查询的工具;也不应像在键值存储中那样通过特定键去频繁访问数据。任何这类操作都可能引发对日志中所有数据的扫查(scan),代价极高。

日志(及 Kafka)并不能万能地替代我们所有的数据库、缓存和分析工具,但 Kafka 可以作为企业内更有效地组织与共享数据的中枢(见图 4.2)。

与其把 Kafka 误用为服务的中央数据库,不如把它作为中心数据枢纽,同时在每个服务内部选择最合适的技术来根据具体用例存储数据。例如,如果我们要为商品构建搜索服务,就把数据从 Kafka 写入搜索引擎(如 Elasticsearch)。若要进行临时性分析,可以将数据写入关系型数据库(如 PostgreSQL)进行即席查询。

在前几页里我们已经理解了 Kafka 作为日志的概念:日志是一个列表,我们向其尾部追加数据以写入,并从旧到新读取。数据一旦写入日志,我们就不去修改或删除这些数据。日志被使用的一个原因是...

image.png

在许多场景中之所以使用日志,是因为它是最简单的数据结构之一,并且不包含任何“魔法”。与数据库那样存储世界的当前状态不同,日志通常从头开始记录事件的历史。重要的是不要把日志视为万能解药,也不要滥用 Kafka 当作数据库。我们会在后续章节中把 Kafka 看作流处理平台的核心,并讨论 Kafka 如何与我们 IT 体系内的其他系统最佳配合。

4.2 将 Kafka 视为分布式系统

仅仅把 Kafka 当作日志来理解已经能带来一些洞见,但这还不足以真正掌握 Kafka。我们需要更进一步,将 Kafka 理解为分布式日志。因为仅靠单个日志很难同时达到所需的速度、可扩展性和弹性,我们必须学习如何将日志高效地分布到多台服务器上。

image.png

计算机系统相对不够可靠,但批量生产的服务器相当便宜。因此,我们通常不是把 Kafka 部署在昂贵、专用的硬件(例如大型主机或专用设备)上,而是运行在通用的商用硬件上,并通过不把 Kafka 放在单台机器上来弥补由此带来的故障易发性。我们不再以避免错误为目标,而是接受错误并据此规划系统。实践证明这是正确的。当只运行单台系统时,可靠性通常足够好,但数据中心硬件故障是常见的问题。例如,Backblaze 会统计其部署硬盘的可靠性(www.backblaze.com/b2/hard-dri… 1%。在计算机科学中,我们可以通过纵向或横向扩展系统来提高能力(见图 4.3)。

纵向扩展意味着使用更强大的单台系统。例如,用 8 核 CPU、16 GB 内存的数据库服务器换成 64 核、256 GB 的服务器,希望获得更好的性能。这是提高系统性能的一种简单方法,并且在一段时间内确实有效。不幸的是,可靠性并不会因此显著提升。大多数情况下,当我们用更强的服务器替换旧服务器时,故障概率并不会显著下降。

横向扩展则是通过使用多台服务器而不是更大的单台服务器来扩展。这种方式非常诱人,因为看起来可以几乎无限地扩展。当现有服务器达到容量上限时,只需增加一台新的服务器即可。我们希望通过这种并行化获得显著的性能提升,并通过冗余提高可靠性和数据持久性。

但事情往往没有这么简单。把系统分布到多台服务器上远非易事。软件必须能够协调;需要有人决定哪台服务器处理哪个请求;还得考虑服务器宕机时会怎样。如何判断某台服务器已停止工作,或更糟的是正在错误地响应请求?如何替换故障服务器?这些并不是理论问题——即便在 Google、Amazon、Netflix 等大型 IT 公司,也会定期发生故障,这显示出作为分布式系统的开发者与运维人员必须认真对待这些问题。对于习惯于纵向扩展的人来说,处理分布式系统通常具有挑战性,需要不同的思维方式。

我们不仅在运行时接受错误,更在系统设计阶段就开始考虑故障会如何影响系统,并把容错设计融入日常工作中。一个非常重要的点是:在可能的范围内,将分布式系统设计得尽量简单。虽然 Kafka 本身是复杂的软件系统,但其基本概念和底层技术尽量保持简单。例如,日志本身是存储大量数据最简单的数据结构之一;日志便于逐条复制(message-by-message)并且容易拆分(用多个日志代替一个日志)。

4.2.1 分区与键

从一开始,Kafka 的设计目标之一就是能够处理海量数据并以极高速度处理这些数据量。此外,在许多场景中,保证数据的保存性与完整性、避免数据丢失与损坏也至关重要。因此我们可以配置 Kafka 来可靠地写入消息并保持消息顺序。接下来的章节中,我们将学习 Kafka 如何恰好实现这些性能与可靠性目标的基础知识。

我们刚讨论过,把系统分散到多台服务器上的一个希望是性能提升。但只说横向扩展会提升性能是短视的。通常在刚开始做横向扩展时,消息处理速度反而会下降。这类似于多核处理器:如果任务无法并行化,多几个核也无济于事。因此,我们谈论横向扩展时,不仅谈“性能提升”,更要谈“并行化”。通过并行化,我们可以在单位时间内处理比单台服务器更多的消息。要实现这一点,消息的处理方式必须是可并行化的。

将数据在多个子系统间划分最简单的方法是分区(在数据库领域也称为分片,sharding)。例如,假设我们要构建一个全球的出租车交换平台。我们可以把所有城市、所有行程的数据都存到一个中央数据库,这样可以识别通用的移动模式、优化运营、提升服务。但如果服务成功,处理的数据量会远超单台服务器的能力,结果就是系统变慢,客户流失到竞争对手那儿。然而仔细观察数据会发现:把所有城市所有行程的数据都放在一个中央数据库并非必要。把同一城市的数据放在一起就足够了,而跨城市的数据顺序对我们并不重要。这样我们就更容易进行横向扩展:从一台服务器开始,随着客户增多再添加服务器,把部分城市的数据分流到新服务器上。

在我们前面章节的在线商店示例中也可以采用类似做法。我们并不需要把所有产品的所有数据都放在一个日志里;把单个产品的数据保存在一个日志中就足够了。不同产品之间的数据顺序不一定要全局正确,关键是单一产品的数据必须按正确顺序排列(见图 4.4)。

image.png

在 Kafka 中我们也采取同样的做法。不是把一个主题的所有数据写入单一日志,而是把该主题划分为多个分区。创建主题时,需要指定分区数量。现在我们来创建一个包含多个分区的主题。为此使用熟悉的 kafka-topics.sh 命令,并将分区数增为 2(--partitions):

$ kafka-topics.sh \
    --create \
    --topic products.prices.changelog.multi-partitions \
    --partitions 2 \
    --replication-factor 1 \
    --bootstrap-server localhost:9092
Created topic products.prices.changelog.multi-partitions.

接着像前面一样用 kafka-console-producer.sh 向该主题写入一些数据:

$ kafka-console-producer.sh \
    --topic products.prices.changelog.multi-partitions \
    --bootstrap-server localhost:9092
> coffee pads 10
> cola 2
> energy drink 3
> coffee pads 11
> coffee pads 12
> coffee pads 10
# Press Ctrl-D to cancel

然后我们用 kafka-console-consumer.sh 再把数据读出来:

$ kafka-console-consumer.sh \
    --topic products.prices.changelog.multi-partitions \
    --from-beginning \
    --bootstrap-server localhost:9092
cola 2
coffee pads 11
coffee pads 10
energy drink 3
coffee pads 10
coffee pads 12
# Press Ctrl-C to cancel
Processed a total of 6 messages

我们会注意到消息的顺序突然不再正确!在某些场景这也许不是问题,但在我们的线上商店示例中,这会造成错误的商品价格,更糟的情况是会导致因不同系统读取顺序不同而出现不一致(如前章所述)。事实上,在大多数情况下这确实是个问题,因为数据不一致和错误处理会对依赖 Kafka 进行数据流与处理的系统的可靠性与准确性造成重大负面影响。原因在于 kafka-console-producer.sh 会自动将数据分布到多个分区,而 Kafka 仅保证单个分区内消息的顺序性。

注意:在我们的示例中消息数量很少,可能需要停止并重启生产者以确保消息被发送到不同分区;此外可能还要多次消费消息才能真正观察到消息顺序问题。

要保证某些消息之间的正确顺序,就必须确保这些消息最终落到同一个分区。我们可以通过为这些消息分配相同的键(key)来保证它们被写入同一分区。键是可选的,像值一样只是简单的字节数组。当使用键时,生产者端的 Kafka 库会根据键的哈希值决定写入哪个分区,通常计算方式为:

partition_number = hash(key) % number_of_partitions;

即先对键计算哈希值,再对分区数取模得出分区编号。如果在中间更改了主题的分区数量,则 Kafka 的顺序保证将不再成立。

注意(CAUTION) :默认情况下,librdkafka 库使用的哈希算法与 Java 客户端库不同。在多语言或多客户端库并存的环境(例如后端使用 Java 客户端,而某些应用使用基于 librdkafka 的 Python 客户端)中,消息可能被不一致地分区,这会导致错误的计算,尤其是在使用像 Kafka Streams 这样的流处理库时。为保证正确性,应在 librdkafka 的生产者中将分区器设置为 murmur2_random,以使 Java 客户端与 librdkafka 的分区策略保持一致,从而实现更均匀的分区分配。

如果消息的全局顺序对我们并不重要,则可以省略键。在这种情况下,生产者会采用轮询(round-robin)方式将消息分布到各分区:第一条消息发到分区 0,第二条到分区 1,依此类推。在无键示例中你可能已经注意到,消息虽然被某种程度地分组,但偶数序号的消息先被消费,然后是奇数序号的消息(见图 4.5),这种分组结果也可能纯属机缘巧合。

为了减少网络请求次数并提高吞吐量,自 Kafka 2.4 起,生产者采用了更优化的轮询策略。它不是在每条消息后都切换分区,而是把消息先累积成已有的批(batch),只有在一个批发送完后才选择下一个分区。这样既减少了网络请求次数,又更好地填充批次,从而能获得更好的批处理效率和(若启用)更好的压缩比。

至此,生产者会将消息分发到不同的分区。理想情况下,分区会均匀分布在所有 broker 上,这样负载也会相对均衡地分布到这些 broker。

image.png

要指定键,我们在 kafka-console-producer.sh 中使用 --property parse.key=truekey.separator 属性。像前面一样,我们先创建一个包含两个分区的新主题,然后以商品名作为键生产一些消息:

# 先按前文方式创建主题 products.prices.changelog.multi-partitions-keys
$ kafka-console-producer.sh \
    --topic products.prices.changelog.multi-partitions-keys \
    --property parse.key=true \
    --property key.separator=":" \
    --bootstrap-server localhost:9092
> coffee pads:10
> cola:2
> cola:1
> energy drink:3
> coffee pads:11
> coffee pads:12
> energy drink:4
> coffee pads:10
# 按 Ctrl-D 停止

在消费者端,我们可以启用 print.key 属性以显示键:

$ kafka-console-consumer.sh \
    --from-beginning \
    --topic products.prices.changelog.multi-partitions-keys \
    --property print.key=true \
    --property key.separator=":" \
    --bootstrap-server localhost:9092
coffee pads:10
cola:2
cola:1
coffee pads:11
coffee pads:12
coffee pads:10
energy drink:3
energy drink:4
# 按 Ctrl-C 停止
Processed a total of 8 messages

我们可以看到,读取时数据的全局顺序与生产时不同,但具有相同键的消息会保持正确的相对顺序,即便它们之间夹杂着其它键的消息(如图 4.6 所示)。

image.png

注意(CAUTION) :Kafka 中键分布不均会导致分区间的数据分布失衡。由于键决定消息落在何处,键分布不均会使某些分区承担不成比例的消息量,影响负载均衡和集群整体性能。为优化 Kafka 的工作负载与资源利用率,平衡键的分布至关重要。

通常情况下,如果键的数量远大于分区数,键分布不会成为大问题。例如当键为客户 ID 且客户数为数千时,各分区之间大概率能获得足够均衡的分布。但当少数几个键承担了大量流量时就需要谨慎,这会导致分布出现偏斜并可能形成瓶颈。例如社交网络需要对流量远超平均水平的顶级用户做特殊处理(因为他们可能产生超过 90% 的流量),以保证系统保持高效与响应性。

4.2.2 消费者组(Consumer groups)

我们现在可以把这些分区分配到不同的 broker,从而在 broker 之间平衡负载。生产者独立决定数据应写入哪个分区。如果不使用键,生产者会以轮询(round-robin)的方式把数据分配到分区:先到分区 0,然后分区 1,依此类推;如果使用键,则会按键的哈希将消息分配到同一分区,保证同一键的消息落入同一分区。

在性能方面,这种方式通常非常有效。不过,如果只有一个消费者需要从所有分区读取数据,该消费者可能跟不上,无法及时处理数据。由于 Kafka 采用拉取模型(消费者主动拉取数据而非 broker 推送),只要消费者程序编写得当,它不会被“淹没”而崩溃——只是会相应地变慢处理数据。在这一点上,我们还没有介绍如何对消费者进行横向扩展。这很快会导致消费者成为瓶颈,使系统无法充分利用 Kafka 的能力,如图 4.7 所示。

image.png

大多数情况下,我们也不会只有一种消费者想要消费相同的数据。在我们的线上商店示例中,可以设想有一个分析服务从 products.prices.changelog 主题读取数据并把价格更新写入数据库。对于这个服务来说,使用单个消费者就完全足够——这是一个非常简单且资源开销低的任务。同时,我们还有另一个服务希望对同一 products.prices.changelog 主题的数据做更“科学”的分析,可能还会把这些数据与其它主题或数据源的数据做关联补充。对于这种用途,单个消费者通常不够用,因为仅靠一个消费者处理这些数据需要耗费太多时间。

为此场景我们需要两件事:第一,我们需要一种方式让不同的服务能独立地从分区读取数据而互不干扰。例如,尽管我们的指标服务已经读取了数据,科学分析服务仍应能独立读取这些数据。幸运的是,Kafka 在这方面很容易做到:消费者可以通过 offset 自行决定要读取哪些数据。

第二,我们需要一种水平扩展服务的方法,使得分区数据能在服务的不同实例之间分配,从而不仅把所有数据读完,而且能以正确顺序读取。为保证顺序性,每个分区在同一时间内对某个服务来说只能被恰好一个实例消费;同一服务的两个实例同时消费同一分区将破坏顺序保证。

为同时解决这两点,Kafka 引入了消费者组(consumer groups) 的概念。如果多个消费者的 group.id 相同,它们就组成了一个消费者组。消费者组内的成员会尽可能均匀地分配负载,并将各自的 offsets 写回 Kafka。这意味着:如果我们启动了一个属于某消费者组的消费者、然后停止它、再重新启动它,消费者可以从上次中断处继续消费——这是因为 Kafka 记住了 offset。关于这一点的可视化示例见图 4.8。

image.png

我们可以把之前一节的 products.prices.changelog.multi-partitions-keys 主题做个试验:

$ kafka-console-consumer.sh \
    --from-beginning \
    --topic products.prices.changelog.multi-partitions-keys \
    --property print.key=true \
    --property key.separator=":" \
    --group products \
    --bootstrap-server localhost:9092
coffee pads:10
energy drink:3
coffee pads:11
coffee pads:12
energy drink:4
coffee pads:10
cola:2
Cola:1
# Press Ctrl-C to cancel
Processed a total of 8 messages

我们在命令中唯一修改的是指定消费者组(--group products)。当我们再次运行该命令时,不会看到任何消息;按 Ctrl-C 后会显示:

Processed a total of 0 messages

要查看消费者组内的消费者如何在它们之间分担负载,我们可以同时以相同的组 ID 启动多个消费者。方法是把刚才用的命令分别在两个终端中运行,并把两个终端窗口并排显示以同时观察输出。接着,我们再启动一个生产者,产生更多数据:

$ kafka-console-producer.sh \
    --topic products.prices.changelog.multi-partitions-keys \
    --property parse.key=true \
    --property key.separator=: \
    --bootstrap-server localhost:9092
>energy drink:2
>energy drink:3
>cola:2
>cola:5
>energy drink:1
>cola:2
# Press Ctrl-D to cancel

现在消费者间应该会出现类似表 4.1 的分配情况。

表 4.1 Consumer 窗口

Consumer 1Consumer 2
cola:2cola:5cola:2energy drink:2energy drink:3energy drink:1

这表明消费者组内的消费者已成功地将工作分担开来。Consumer 1 负责包含 cola 消息的分区;Consumer 2 负责另一个分区。在同一消费者组内,每个分区最多只能被一个消费者读取,但一个消费者可以读取多个分区。因为我们为该主题只创建了两个分区,所以组内超过两个消费者没有意义。我们将在性能章节中更详细地讨论消费者组。

注意:如果我们用的是 coffee pads 而不是 energy drink,那么所有这些消息都会落到同一个分区,因此最终都会由同一个消费者处理。

4.2.3 复制(Replication)

在了解了分区如何把数据可靠地分发到多台系统后,接下来我们希望提高系统的可靠性。我们可能都有这样的经验:计算机系统会无明显原因地失败、硬盘会突然坏掉、有人误删了文件夹,等等。数据丢失的原因有很多。防止数据丢失最可靠的方法是复制(replication)。在小规模下,我们通常通过设置备份来防止在系统故障时丢失所有数据;企业环境亦是如此。备份的缺点是恢复需要时间:备份属于“冷复制(cold replication)”,在灾难发生后虽然能恢复数据,但无法保证不中断运行。Kafka 本身不提供这种冷备份;如果需要备份,必须由我们自行处理。

相比之下,Kafka 的复制策略可以称为“暖复制(warm)”。Kafka 并不是把数据只存储在单台服务器上,而是把数据放在多台运行中的服务器上。如果某个 Kafka broker 故障,另一台 broker 可以几乎立即接管,继续接受生产者和消费者的请求。

在 Kafka 中,复制是在主题级别配置的。每个主题可以独立设置复制因子,通常在创建主题时指定。例如,我们创建一个名为 products.prices.replication 的主题,设置 3 个分区并将复制因子设为 3:

$ kafka-topics.sh \
    --create \
    --topic products.prices.replication \
    --partitions 3 \
    --replication-factor 3 \
    --bootstrap-server localhost:9092
Created topic products.prices.changelog.replication.

逻辑上,复制因子不能大于可用 broker 的数量。在我们的测试环境(附录 A 所述)中有 3 个 Kafka broker。如果尝试创建复制因子为 4 的主题,会得到错误信息,例如:

Error while executing topic command : Unable to replicate the partition
4 time(s): The target replication factor of 4 cannot be reached because
only 3 broker(s) are registered.
[…] ERROR org.apache.kafka.common.errors.InvalidReplicationFactorException:
Unable to replicate the partition 4 time(s): The target replication factor
of 4 cannot be reached because only 3 broker(s) are registered.
 (org.apache.kafka.tools.TopicCommand)

我们再用 kafka-topics.sh --describe 来查看创建主题后的情况:

$ kafka-topics.sh \
    --describe \
    --topic products.prices.replication \
    --bootstrap-server localhost:9092
Topic: products.prices.replication PartitionCount: 3 ReplicationFactor: 3
Configs:    #1
Topic: products.prices.replication Partition: 0 Leader: 2 Replicas: 2,1,3
Isr: 2,1,3 Elr:   #2
Topic: products.prices.replication Partition: 1 Leader: 3 Replicas: 3,2,1
Isr: 3,2,1 Elr:
Topic: products.prices.replication Partition: 2 Leader: 1 Replicas: 1,3,2
Isr: 1,3,2 Elr:

注释:
#1 我们得到了期望的三个分区和三个副本。
#2 显示了三个分区的详细信息。

例如对分区 0,输出显示 leader 为 broker 2。Kafka 的复制策略遵循“一个 leader — 多个 follower”的原则:具体来说,每个分区都有且只有一个 broker 扮演 leader 的角色。在我们的示例中,broker 2 是 products.prices.replication 主题分区 0 的 leader。根据复制因子(replication factor),还会有其他作为 follower 的 broker。我们把存储该分区的所有这些 broker 称为该分区的副本(replicas)。也就是说,复制因子为 1 时,通常只有一个副本,即一个 leader、无 follower;复制因子为 3 时(如示例),正常情况下会有三个副本、一个 leader 和两个 follower。

根据 kafka-topics.sh 的输出,Replicas: 2,1,3 表示该分区的副本分布在 broker 2、1、3 上。默认情况下,副本列表中第一个 broker 的 ID 即为该分区的 leader。从输出中可以看到,其他两个分区也是如此:分区 1 的首位是 broker 3(因此为 leader),分区 2 的首位是 broker 1(因此为 leader)。

最后,Isr: 2,1,3 指的是 in-sync replicas(ISR),即与 leader 保持同步的副本——也就是最新的副本。如果 ISR 列表与 replicas 列表不匹配,则说明可能出现问题(例如某个 broker 失败)。我们将在后续章节中详细讨论 ISR。

我们已提到 Kafka 遵循“一主多从”原则:每个分区只有一个 leader,所有的读写操作都仅通过 leader 完成;followers 的职责是尽快从 leader 那里复制数据并在 leader 故障时可用(参见图 4.9)。

image.png

如果 leader 故障,处于同步状态(ISR)或至少属于候选 leader 副本(ELR)之一的 follower 会接替该 leader 的工作,成为新的 leader。随后,生产者和消费者会自动切换到新的 leader。

在 Kafka 中使用日志的一大优点是复制可以以相对简单且可靠的方式实现。Kafka 的 follower 类似于消费者,会从 leader 那里按某个特定的 offset 请求新消息,并将这些数据写入各自的本地日志。这不仅通过相对简明的代码保证了高性能,而且有助于维持消息的顺序性。

不过,要保证消息按正确顺序被处理,内部机制要复杂得多。Kafka 通过多种技术的结合来实现这一点,包括确保 leader 始终是每个分区的数据权威来源,且 followers 通过持续从 leader 拉取数据来保持同步。该方法为数据复制提供了强一致性,确保所有消费者——无论从哪个副本读取——都能按正确顺序接收消息。

由此产生一个问题:既然只有一个 leader,难道各 broker 间的负载不会因此分布不均吗?如果 Kafka 集群只有一个分区那确实是个问题,但通常我们会有很多分区。每个分区都有自己的 leader,而不会由同一 broker 承担所有分区的 leader 角色。Kafka 在创建分区时会尽量将 leader 均匀分布到各个 broker 上。在线上生产系统运行时,我们必须非常注意这种负载的均匀分配——这是保证 Kafka 性能的重要前提。

4.3 Kafka 的组件

原则上,一个 Kafka 集群由三类组件构成:协调集群(coordination cluster)、brokers 和客户端(clients)。前文已多次提到协调集群与 brokers,那么客户端是什么角色?消费者和生产者又扮演何种角色?答案很简单:所谓客户端,就是指所有连接到 Kafka 集群的应用程序,包括我们已知的生产者和消费者。一个典型的 Kafka 集群通常由一个协调集群和另外三台用于实际承载的 brokers 组成(见图 4.10)。本章后文会更详细地解释这样设计的原因。

image.png

4.3.1 协调集群

像 Kafka 这样的分布式系统需要一定程度的编排来进行协调。在 Kafka 中,这项工作由协调集群负责(见图 4.11)。协调集群会持续监控集群的当前状态,检查所有 broker 是否仍然可达并正常工作。作为这项任务的一部分,它还管理所有活动 broker 的列表,并负责将新 broker 添加到集群或将已存在的 broker 从集群中移除。

image.png

协调集群存储有关主题的元数据并管理对主题的访问。作为主题管理的一部分,它还保存分区到 broker 的分配以及每个分区的 leader broker。通过协调集群,会指定某个 broker 为控制者(controller)。控制者负责管理各个分区和副本的状态。在这项工作中,例如,它负责将分区分配给 broker 并指定分区的 leader。如果某个 broker 故障,控制者会自动重新分配分区并选出新的 leader,以维持可用性和一致性。

在引入 KRaft 之前,这一协调角色由 ZooKeeper 集群(ensemble)承担。ZooKeeper 本身并非 Kafka 的一部分,而是 Apache 软件基金会的一个独立项目。它是一个分布式协调服务,帮助在分布式系统中管理配置、命名和同步。ZooKeeper 提供了一个集中式注册表来存储元数据,并提供诸如分布式锁等原语,这是分布式应用所需的。在 Kafka 的语境下,ZooKeeper 用于维护集群元数据、管理 broker 的领导者选举、跟踪分区分配以及存储访问控制列表(ACLs)。

然而,ZooKeeper 的通用性也带来了若干挑战。作为一个独立系统,它需要额外的运维专长和维护开销。其通用的数据模型虽然灵活,但并未针对 Kafka 的特定需求进行优化,在大规模时会成为性能瓶颈。

这些限制促生了 Kafka Raft(KRaft)的开发——正如前文所述,KRaft 将协调功能直接整合进 Kafka。KRaft 带来若干优点:通过消除对外部 ZooKeeper 的依赖简化了架构;通过更专门化的元数据管理机制提升了性能;通过只需维护单一系统降低了运维复杂度。KRaft 还引入了更高效的元数据更新和 leader 选举协议,这对拓扑频繁变化的大型集群尤其有利。

从 Kafka 3.3 开始,KRaft 已达到生产就绪(production-ready)并成为推荐的协调机制。对于较小的集群,KRaft 的一大优点是它可以在 Kafka 集群内部运行——部分 broker 可以同时承担常规 Kafka 操作与协调职责。然而,对于拥有大量 broker 的大型生产环境,仍建议运行专用的协调集群以确保最佳性能并保持关注点分离。

无论使用 KRaft 还是 ZooKeeper,为了让协调可靠运行,协调集群必须由奇数个节点组成。该要求使集群能够通过多数决来达成一致(consensus)。有三个节点时,集群在其中一个节点故障的情况下仍能继续运行;有五个节点时,则可以容忍两个节点故障而仍保持法定人数(quorum)。

提示:对于较小的 Kafka 集群,我们建议协调集群由三个 KRaft broker 或 ZooKeeper 节点组成。对于较大的集群,也可以部署五个实例以提高弹性。

4.3.2 Broker

Broker 构成了 Kafka 的实际集群,因为它们负责接收、存储并转发消息与数据。Kafka 集群通常由多个 broker 组成,否则我们就无法实现数据复制,这也是 Kafka 的核心概念之一。如果 Kafka 集群只有两个 broker,虽然可以实现复制,但在出现疑问或故障时会很快遇到问题。举例来说,若我们在维护集群时使其中一个 broker 下线,而另外一台也同时故障,就可能很快导致数据丢失。因此,我们应始终至少从三个 broker 开始。如果负载增加,可以添加更多 broker 来提升性能。

4.3.3 客户端(Clients)

在 Kafka 中,任何连接到集群的外部应用都称为客户端。严格来说,客户端并不被视为 Kafka 集群的内部组件——至少在集群的上下文中是这样。我们熟悉的 kafka-console-consumer.shkafka-console-producer.sh 都只是 Kafka 客户端,它们都是访问 Kafka API 的 Java 应用。非 Java 编写的客户端通常使用 librdkafka 库(github.com/edenhill/li…)。我们将在本书第三部分更详细地说明客户端的工作原理。

Kafka 客户端最简单的形式就是生产者和消费者(producer 和 consumer),这是我们已经接触过的。生产者向 Kafka 写入数据,消费者从 Kafka 读取数据——因此得名。

为了简化对数据流的处理,Kafka 附带了 Kafka Streams 库(kafka.apache.org/documentati…),可以对 Kafka 中的数据进行过滤、转换、合并等操作。为此,Kafka Streams 同时扮演消费者和生产者的角色。Kafka Connect(kafka.apache.org/documentati…)是一个用于将 Kafka 与其他系统连接的框架。例如,可以通过 Kafka Connect 将整个数据库中的数据导入到 Kafka 集群。在本书第四部分,我们将详细探讨如何将外部系统接入 Kafka 以及 Kafka Streams 和 Kafka Connect 的具体工作机制。

提示:使用 Kafka Connect 替代自定义的消费者或生产者代码有诸多好处,例如可扩展性、与各种数据源的无缝集成、内置连接器、模块化以及简化的错误处理。它有助于实现标准化、可维护且可扩展的数据集成方式。

4.4 企业环境中的 Kafka

在前几页中我们已经了解了 Kafka 作为分布式日志的概念。实际使用时,Kafka 表现为一个由多个 broker、一个协调集群以及若干生产者与消费者组成的分布式系统,用以在企业内部交换数据以解决业务问题。尽管这种架构本身就能为业务带来巨大价值,但通常我们需要的不仅仅是一个集中存储与路由数据的地点。作为流处理平台的核心,Kafka 能推动企业向数据驱动型转型,使我们能够做出近实时的决策。但单靠 Kafka 并不足以实现这一目标(参考图 4.12)。

image.png

我们的 Kafka 集群并不孤立存在,因此应与企业中现有的系统良好连接,例如存放核心数据的各类数据库、连接专有服务与外部服务提供商的消息系统、以及那些无人敢动的遗留应用等。当然,我们可以为这些服务中的每一个各自编写 Kafka 的消费者或生产者来将它们接入 Kafka。但幸好,Kafka 提供了 Kafka Connect——一个用于高效且可靠地将第三方系统对接到 Kafka 的工具与框架。Kafka Connect 随 Apache Kafka 一起发布,并使用相同的 Apache 2.0 许可证。

在企业中我们可能仍然有一些大型主机(mainframe),这些系统没有现成的 Kafka 库,为它们为 Kafka Connect 编写自定义连接器会非常繁琐。为连接这类系统,我们可以例如使用市场上可用的某些 REST 代理(REST proxy)。这样通过 HTTP/REST 通信的系统就能与 Kafka 交互。

注意:Confluent 提供其专有的 REST Proxy,用于 Confluent Enterprise 平台。另一种开源实现来自 Aiven,见:github.com/aiven/karap…

现在,我们可能会把大量系统接入 Kafka,从而在这些系统之间交换数据。通常,我们不仅希望近实时地传输数据,还希望对数据进行评估和处理。由于可以把 Kafka 中的数据视为流(stream),因此基于流处理的系统——直接在数据流上运行的系统——非常适合此类用途。存在大量可与 Kafka 配合实现流处理的框架,例如 Kafka Streams、Apache Flink、Scala 的 Akka 等。借助这些流处理库,即便对大规模数据集也能较容易地实现可扩展且稳健的流处理操作,例如对数据过滤、对单条数据的转换与合并,或连接不同的数据流等。市面上还有一些产品,甚至允许无需编程就能做流处理。

我们在 Kafka 中存储的数据越多、保留时间越长,就越需要以一致的数据格式来存储数据。尤其当不止一个团队会访问 Kafka 集群时,schema(模式)管理就显得非常重要。然而 Apache Kafka 并未自带 schema 管理功能,因为 Kafka 本身并不关心数据格式。schema 管理有多种可能的实现方式,从把 schema 定义放在 Git 仓库,到使用像 Confluent 的 Schema Registry 或 Karapace 中实现的 schema registry 等组件。

注意:Confluent 的 Schema Registry 属于 Confluent Enterprise 平台。Karapace(由前文提到的 REST Proxy 与 Schema Registry 组成)可见于 GitHub:github.com/aiven/karap…

根据我们在 Kafka 中存储数据的重要性,应考虑是否有意义把数据镜像到其他数据中心。Kafka 随附的 MirrorMaker 2 工具大大简化了多数据中心部署的操作。在某些情况下,仍然值得考虑备份与恢复(backup and recovery)策略。对于规模较大的 Kafka 部署,这比表面看起来要复杂得多并且要求更高。

此外,任何打算作为生产环境而非仅供试验的 Kafka 部署都需要其他组件。流处理平台必须通过监控工具进行全面监控。最好不要手工搭建与配置 Kafka,而应借助自动化工具来完成。另外,尤其在大公司中,需要关注并强制执行若干合规性(compliance)规则。

深入详尽地把 Kafka 看作一个完整的流处理平台已超出本书的范围,并且在撰写时该领域仍在动态演进。我们在第 18 章对其中的一些问题做了比此处更详细的讨论;如果你有进一步问题,建议咨询 Kafka 专家以了解当前的最佳实践与现状。

总结

  • 日志是一个顺序列表,我们在末端追加元素并从某个位置开始读取。
  • Kafka 是一个分布式日志,主题的数据被分发到多个 broker 上的若干分区。
  • offset 用于定义消息在分区内的位置。
  • Kafka 用于系统间的数据交换;它并不能替代数据库、键值存储或搜索引擎。
  • 分区用于水平扩展主题并实现并行处理。
  • 生产者使用分区器来决定将消息写入哪个分区。
  • 具有相同键的消息会落到同一分区。
  • 消费者组用于扩展消费者并让它们分担工作负载;在同一消费者组内,每个分区始终由一个消费者消费。
  • 复制用于通过在 Kafka 集群内的多台 broker 之间复制分区来保证可靠性。
  • 每个分区始终只有一个 leader 副本,负责该分区的协调工作。
  • Kafka 由协调集群、brokers 和客户端组成。
  • 协调集群负责协同 Kafka 集群——也就是管理 brokers。
  • Brokers 构成实际的 Kafka 集群,负责接收、存储并提供消息以供检索。
  • 客户端负责生产或消费消息,并连接到 brokers。
  • 有多种框架与工具可以便捷地将 Kafka 集成进现有的企业基础设施。