第四章 超越消息传递:Kafka Broker 概述
Kafka 集群本质上是由分布在多台机器上的、存储着消息的文件集合所构成的。Kafka 的大部分代码逻辑在于:将这些分散的日志文件关联起来,确保消息从生产者可靠地传输至消费者,通过数据副本来实现容错,并妥善处理各种故障。因此,它确实可以算作一种消息系统,但与早期的消息代理(message broker)相比,它有着本质的区别。和任何技术一样,Kafka 既有优势也有劣势,这些特性也影响着我们所构建系统的设计方式。本章将从构建业务系统的视角,探讨 Kafka Broker(即服务端组件)。我们将简要了解其工作原理,并深入探讨它所支持的一些非常规用例,例如数据存储、动态故障转移以及带宽保护。
Kafka 最初是为了分发大型社交网络所产生的数据集而构建的,其设计核心在于如何在大规模环境下保持稳定运行,并从容应对各种故障。因此,从架构层面来看,它更多地借鉴了 HDFS、HBase 或 Cassandra 等存储系统,而非传统的、基于 JMS(Java 消息服务)或 AMQP(高级消息队列协议)的消息系统。
正如计算机科学中许多优秀的成果一样,这种可扩展性在很大程度上源于其简洁性。其底层的抽象模型是“分区日志”(partitioned log)——本质上就是分布在多台机器上的一组“仅追加”(append-only)文件。这种设计促进了顺序访问模式,能够与底层硬件的特性完美契合。
Kafka 集群是一个分布式系统,通过将数据分布在多台机器上来实现容错和线性扩展。该系统旨在应对多种应用场景:从仅关注最新消息的高吞吐流处理,到对数据完整性和顺序性有严格要求(类似于数据库管理系统或存储系统所提供的保障)的关键业务场景。为了实现这种可扩展性,Kafka 在接口契约上做了一些简化,舍弃了 JMS 或 AMQP 中定义的部分功能(例如消息选择器)。
然而,这种设计思路的转变却至关重要。得益于 Kafka 出色的吞吐能力,在不同进程间进行数据传输变得比以往任何技术都更加高效、更加实用。它存储数据集的能力,消除了困扰传统消息系统的队列深度问题。此外,它丰富的 API(尤其是 Kafka Streams 和 KSQL)提供了一种独特的机制,能够将数据处理功能直接嵌入到客户端程序中。得益于这些特性,它被许多企业用作服务架构中的消息与存储基石,满足了这些企业对上述各项功能的需求。
日志:一种用于存储和分发消息的高效结构
Kafka 消息系统的核心是一个经过分区且可重放的“日志”(log)。这种基于日志的结构,其核心理念非常简单:将一系列消息按顺序追加到一个文件中。当某个服务想要从 Kafka 读取消息时,它会“定位”到上次读取消息的位置,然后从该位置开始顺序扫描,依次读取后续消息,并在此过程中定期记录其在日志中的最新位置(见图 4-1)。

采用日志结构(log-structured)的方法会带来一个有趣的副作用:读写操作均为顺序操作。这种方式非常契合底层存储介质的特性,能够充分利用预读(prefetch)机制和各级缓存,并自然地将多个操作合并为批处理,从而显著提升效率。事实上,当从 Kafka 读取消息时,服务器甚至无需将这些消息加载到 JVM(Java 虚拟机)中;数据可直接从磁盘缓冲区复制到网络缓冲区(即“零拷贝”技术)——这得益于其接口契约与底层数据结构的简洁性。
这种批处理式的顺序操作不仅提升了整体性能,还使该系统非常适合长期存储消息。大多数传统消息代理(message broker)依赖索引结构(如哈希表或 B 树)来管理确认机制、过滤消息头,并在消息被读取后将其删除。然而,这种做法的代价是必须维护这些索引;为了保证性能,索引通常需要常驻内存,这极大地限制了消息的保留时长。相比之下,在日志结构中,对分区进行读写操作的时间复杂度均为 O(1),因此数据究竟是存储在磁盘上还是缓存在内存中,其影响要小得多。
这种日志结构的方法还带来了一些深远影响。如果某个服务发生故障,导致长时间无法读取消息,积压的消息也不会造成基础设施性能的显著下降(而在传统消息代理中,这是一个常见问题——随着存储空间被填满,系统性能往往会随之下降)。此外,得益于这种日志结构,Kafka 非常适合充当“事件存储”(event store),这对于那些希望在服务中采用“事件溯源”(Event Sourcing)架构的开发者来说尤为理想。关于这一主题,本书第 7 章将进行深入探讨。
线性可扩展性
正如我们之前讨论过的,对于消息处理工作负载而言,日志(log)是一种非常契合硬件特性的数据结构;而 Kafka 本质上是由分布在多台机器上的多个日志组成的。该系统将这些日志有机地结合在一起,能够可靠地路由消息,通过数据复制实现容错,并能从容地处理各种故障。
虽然 Kafka 可以在单机上运行,但在生产环境中,集群规模通常至少从 3 台机器起步,大型集群甚至可达数百台机器。当你对某个主题(topic)进行读写操作时,实际上是在与集群中的所有机器进行交互,数据被分布(分区)存储在可用的多台机器上。因此,扩展集群变得非常简单:只需添加新机器并执行重新平衡(rebalance)操作即可。此外,消息消费也可以并行进行:一个主题中的消息会被分发给同一个消费者组(consumer group)内的多个消费者(见图 4-2)。

从架构的角度来看,这种方案的主要优势在于彻底消除了扩展性方面的顾虑。在使用 Kafka 的情况下,业务系统几乎不可能遇到扩展性瓶颈。这一点非常有价值,尤其是在生态系统不断扩张时,它让架构设计者可以选择那些在带宽占用和数据移动上更为“大手大脚”的模式,而不必处处精打细算。
扩展性还带来了其他机遇。单个集群可以扩展至支撑整个企业的规模,且无需担心工作负载会压垮基础设施。例如,New Relic 依靠一个包含约 100 个节点的集群(跨越三个数据中心)来处理高达 30 GB/s 的数据流量。在其他数据密集度较低的领域,仅需 5 到 10 个节点的集群,往往就能支撑起整个企业的工作负载。不过需要指出的是,并非所有公司都选择“单一大型集群”的路线。以 Netflix 为例,他们建议使用多个较小的集群,以降低维护超大规模部署所带来的运营开销;即便如此,他们规模最大的集群依然包含约 200 个节点。
为了管理共享集群,可以利用 Kafka 自带的带宽隔离功能,对带宽资源进行划分。我们接下来将探讨这些功能。
在多服务生态系统中进行负载隔离
从定义上讲,服务架构本质上是多租户的。一个集群往往会被多个不同的服务所共用。事实上,公司内部的所有服务共享同一个生产集群的情况并不罕见。然而,这种做法可能会引发无意间的“拒绝服务”攻击,进而导致服务性能下降或系统不稳定。
为解决这一问题,Kafka 引入了一种吞吐量控制机制——“配额”(quotas)。通过该机制,可以为特定服务分配限定的带宽,从而确保它们在严格的服务等级协议(SLA)约束下运行(见图 4-2)。对于那些占用资源过多的服务,系统会对其进行强力限流;因此,即便多个服务共享同一个集群,也无需担心出现意外的网络资源争抢。该功能既可应用于单个服务实例,也可应用于经过负载均衡的服务组。
维持严格的顺序保证
虽然在分析类应用场景中未必如此,但大多数业务系统都需要严格的顺序保证。假设客户对其个人信息进行了多次更新,那么这些更新的处理顺序至关重要;否则,最新的变更可能会被旧的、过时的数据所覆盖。
要确保严格的顺序保证,需要考虑几个方面。首先,要求保持相对顺序的消息必须发送到同一个分区(Partition)中。(Kafka 仅在分区内部提供顺序保证。)这一机制由系统为你管理:你只需为所有需要保持相对顺序的消息指定相同的键(key)即可。例如,在处理客户信息更新流时,可以使用“客户 ID”(CustomerId)作为分区键。这样,同一客户的所有消息都会被路由至同一个分区,从而确保了严格的顺序(见图 4-3)。

有时,仅靠基于键(key)的排序是不够的,还需要全局排序。这种情况常见于从旧版消息系统进行迁移的场景,因为全局排序往往是这些旧系统最初设计时的前提假设。若要实现全局排序,可使用仅包含单个分区(partition)的主题(topic)。虽然吞吐量会受限于单台机器的处理能力,但这对于此类应用场景通常已足够。
第二个需要注意的问题是重试机制。在绝大多数情况下,我们都应在生产者(producer)端启用重试功能;这样,一旦遇到网络抖动、长时间的垃圾回收(GC)停顿、系统故障或其他类似问题,未能成功发送至集群的消息便会自动触发重试。这里有一个细节需要留意:由于消息是以批次(batch)形式发送的,我们必须确保针对每个目标机器,每次仅发送一个批次。这样,即便发生故障并触发了批次重试,也不会导致事件顺序被打乱。这只需通过相应的配置即可实现。
确保消息的持久性
Kafka 通过副本机制(replication)来保障持久性。这意味着消息会被写入到指定数量的机器上;即使其中一台或多台机器发生故障,消息也不会丢失。如果将副本因子(replication factor)配置为 3,那么即便有两台机器宕机,数据也不会丢失。
为了充分利用副本机制,对于服务型应用中涉及的敏感数据集,建议为每个分区(partition)配置 3 个副本,并设置生产者(producer)在继续后续操作前,必须等待副本同步完成。此外,正如前文所述,还应在生产者端配置重试机制。
在对数据敏感度要求极高的场景下,可能需要将数据同步刷盘(即同步写入磁盘),但这种做法应谨慎使用。它会对系统吞吐量产生显著影响,尤其是在高并发环境下。如果确实需要采用这种方式,建议增大生产者的批次大小(batch size),以提升单次磁盘刷盘操作的效率(即让多条消息组成批次,一并刷入磁盘)。这种方法也适用于单机部署场景——即在同一台机器上运行单个 ZooKeeper 节点,并通过同步刷盘来增强数据的可靠性。
实现服务负载均衡与高可用性
除非确实没有高可用性(HA)的需求,否则事件驱动型服务应始终以高可用配置运行。这样做之所以合理,是因为它几乎不需要额外的工作(即“零成本”操作)。如果我们原本有一个服务实例,此时再启动第二个实例,负载便会自动在两者之间实现均衡。同样的机制也能在某个节点发生故障时确保系统的高可用性(见图 4-4)。
假设我们有两个“订单服务”(orders service)实例,它们共同从“订单”(Orders)主题(topic)中读取消息。Kafka 会将分区(partition)平均分配给这两个实例,从而使负载分布在两者之上。

如果其中某个服务发生故障,Kafka 会检测到这一状况,并将原本发往该故障服务的消息重新路由至其他正常运行的服务。一旦故障服务恢复上线,负载分配便会自动切回原状。
该机制的运作方式是将完整的分区(partition)分配给不同的消费者(consumer)。这种方法的一大优势在于,每个分区在任何时刻都只能分配给唯一的服务实例(即消费者)。这一特性始终保持不变,从而确保了消息顺序的一致性——即便在服务发生故障或重启的情况下,顺序依然能够得到保障。
因此,服务既具备了高可用性,也实现了负载均衡;这意味着系统能够进行横向扩展(scale out)、应对突发故障,或在不造成服务中断的前提下执行滚动重启。事实上,Kafka 的各个版本始终保持向后兼容,确保你能够在无需系统下线的情况下,完成新版本的发布。
压缩主题(Compacted Topics)
默认情况下,Kafka 中的主题是基于保留策略(retention-based)的:消息会保留一段可配置的时间。Kafka 还提供了一种特殊类型的主题,用于管理带键(keyed)的数据集——即包含主键(标识符)的数据,类似于数据库表中的数据。这类“压缩主题”(compacted topics)仅保留每个键对应的最新事件,而该键对应的旧事件则会被移除。它们还支持删除操作(参见第 13 章第 127 页的“删除数据”一节)。
压缩主题的工作方式类似于简单的日志结构合并树(LSM 树)。系统会定期扫描主题,如果旧消息已被(基于键的)新消息所取代,则会将旧消息移除;参见图 4-5。值得注意的是,这是一个异步过程,因此压缩主题中可能仍会包含一些已被取代、但尚未被压缩移除的消息。

压缩主题使我们能够进行几项优化。首先,它们有助于减缓数据集的增长(通过移除已被取代的事件);这种处理方式是针对数据本身特性的,而非简单地删除超过一定时间(例如两周)的消息。其次,较小的数据集也更易于在不同机器之间进行迁移。
这对“有状态流处理”(stateful stream processing)至关重要。假设某项服务使用 Kafka Streams API 将最新版的产品目录加载到一张表中(正如第 14 章第 135 页“窗口、连接、表与状态存储”一节所述,这里的“表”是指该 API 内部维护的一种基于磁盘的哈希表)。如果产品目录存储在 Kafka 的压缩主题中,那么在加载数据时,无需加载完整的历史版本记录(若使用普通主题则必须加载全部历史记录),从而使加载过程变得更快、更高效。
长期数据存储
Kafka 与其他消息系统的一个主要区别在于,它可以用作存储层。事实上,在基于保留策略(retention-based)或经过压缩(compacted)的主题中,存储超过 100 TB 的数据并不罕见。但 Kafka 并非数据库;它本质上是一个提交日志(commit log),并不提供广泛的查询功能(而且未来也不会增加此类功能)。不过,它这种简单的契约机制,在大型系统或企业架构中存储共享数据集时非常有用——例如,将事件作为共享的事实来源(shared source of truth),正如我们在第 9 章中所讨论的那样。
数据既可以存储在普通主题中(非常适合审计或“事件溯源”/Event Sourcing),也可以存储在压缩主题中(有助于减少整体存储占用)。你还可以将两者结合起来:同时保存这两种主题,并通过 Kafka Streams 作业将它们连接起来,从而兼得两者的优势(代价是需要额外的存储空间)。这种模式被称为“最新版本模式”(latest-versioned pattern)。
安全性
Kafka 提供了多种企业级安全功能,涵盖身份验证和授权。客户端身份验证可通过 Kerberos 或传输层安全协议(TLS)客户端证书来实现,从而确保 Kafka 集群能够识别每个请求的发起方。此外,Kafka 还具备一套类似于 Unix 的权限系统,可用于控制不同用户对特定数据的访问权限。网络通信可以进行加密,确保消息能够在不可信的网络环境中安全传输。最后,管理员还可以要求在 Kafka 与 ZooKeeper 之间的通信中进行身份验证。
第 21 页“在多服务生态系统中隔离负载”(Segregating Load in Multiservice Ecosystems)一节中讨论的配额(Quotas)机制,可以与身份标识这一概念相关联。同时,Kafka 的安全功能也扩展到了 Confluent 平台的各个组件中(例如 REST Proxy、Confluent Schema Registry、Replicator 等)。
总结
Kafka 与一般的消息传递技术有所不同。它被设计为一种分布式、可扩展的基础设施组件,这使其成为服务之间交换和缓冲事件的理想“骨干”。该技术本身具备许多独特的要素,其中最显著的特点包括:出色的扩展能力、持续运行(始终在线)的能力,以及长期保留数据集的能力。
我们可以利用本章讨论的模式和特性,构建出多种多样的架构——从细粒度的基于服务的系统,到庞大的企业级架构,均可涵盖。这种方法既安全又务实,且经过了充分的实践检验。