Kafka-权威指南第二版-全-
Kafka 权威指南第二版(全)
原文:
annas-archive.org/md5/4f989c4c89c367340acd2197ef4ad79e译者:飞龙
第二版前言
Kafka:权威指南的第一版是五年前出版的。当时,我们估计 Apache Kafka 在财富 500 强公司中被使用的比例为 30%。如今,超过 70%的财富 500 强公司正在使用 Apache Kafka。它仍然是世界上最受欢迎的开源项目之一,并且处于一个庞大生态系统的中心。
为什么这么兴奋?我认为这是因为我们的基础设施中存在着巨大的数据空白。传统上,数据管理主要是关于存储——文件存储和数据库,它们可以保护我们的数据并让我们在正确的时间查找到正确的数据。大量的智力能量和商业投资已经被投入到这些系统中。但是,一个现代公司不仅仅是一个拥有一个数据库的软件。一个现代公司是一个由数百甚至数千个定制应用程序、微服务、数据库、SaaS 层和分析平台构建而成的极其复杂的系统。而且,我们面临的问题越来越多地是如何将所有这些连接起来,并使其实时协同工作。
这个问题不是关于管理静态数据,而是关于管理动态数据。而在这种运动的核心就是 Apache Kafka,它已经成为任何动态数据平台的事实基础。
在这个过程中,Kafka 并没有停滞不前。它最初作为一个简陋的提交日志,也在不断发展:增加了连接器和流处理能力,并在这一过程中重新构建了自己的架构。社区不仅发展了现有的 API、配置选项、指标和工具,以改进 Kafka 的可用性和可靠性,而且我们还引入了一个新的编程管理 API,全局复制和 DR 的下一代 MirrorMaker 2.0,基于 Raft 的共识协议,允许在单个可执行文件中运行 Kafka,并支持分层存储。也许最重要的是,我们通过添加对高级安全选项(身份验证、授权和加密)的支持,使 Kafka 成为关键企业用例中的不二选择。
随着 Kafka 的发展,我们也看到了用例的演变。当第一版出版时,大多数 Kafka 安装仍然在传统的本地数据中心中使用传统的部署脚本。最受欢迎的用例是 ETL 和消息传递;流处理用例仍在迈出第一步。五年后,大多数 Kafka 安装都在云上,并且许多正在 Kubernetes 上运行。ETL 和消息传递仍然很受欢迎,但它们也被事件驱动的微服务、实时流处理、物联网、机器学习管道以及数百种行业特定的用例和模式所取代,这些用例和模式从保险公司的索赔处理到银行的交易系统,再到帮助实时游戏和视频流服务中的个性化等各种用例。
即使 Kafka 扩展到新的环境和用例,编写能够很好地使用 Kafka 并自信地在生产环境中部署它的应用程序需要适应 Kafka 独特的思维方式。本书涵盖了开发人员和 SRE 需要充分利用 Kafka 的一切潜力的内容,从最基本的 API 和配置到最新和最尖端的功能。它不仅涵盖了您可以使用 Kafka 做什么以及如何做,还包括了不应该做什么以及要避免的反模式。这本书可以成为新用户和有经验的从业者信赖的指南,带领他们进入 Kafka 的世界。
Jay Kreps
Confluent 的联合创始人兼首席执行官
第一版前言
Apache Kafka 现在是一个令人兴奋的时刻。Kafka 被成千上万的组织使用,包括三分之一的财富 500 强公司。它是增长最快的开源项目之一,并在其周围产生了庞大的生态系统。它是处理和管理数据流的核心。
那么 Kafka 是从哪里来的?我们为什么要构建它?它到底是什么?
Kafka 最初是我们在 LinkedIn 构建的内部基础设施系统。我们的观察非常简单:有很多数据库和其他系统用于存储数据,但在我们的架构中缺少的是能帮助我们处理连续流数据的东西。在构建 Kafka 之前,我们尝试过各种现成的选项,从消息传递系统到日志聚合和 ETL 工具,但没有一个能给我们想要的东西。
我们最终决定从零开始构建一些东西。我们的想法是,我们不会像关系数据库、键值存储、搜索索引或缓存那样专注于保存大量数据,而是会把数据视为不断发展和不断增长的流,并构建一个围绕这个想法的数据系统,确实是一个数据架构。
这个想法的适用范围比我们预期的要广泛得多。尽管 Kafka 最初是用于支持社交网络背后的实时应用程序和数据流,但现在你可以看到它成为各行各业下一代架构的核心。大型零售商正在围绕持续的数据流重新制定他们的基本业务流程,汽车公司正在收集和处理来自互联网汽车的实时数据流,银行也正在围绕 Kafka 重新思考他们的基本流程和系统。
那么 Kafka 到底是什么?它与你已经了解和使用的系统有什么不同呢?
我们开始把 Kafka 看作是一个流平台:一个让你发布和订阅数据流、存储它们并处理它们的系统,这正是 Apache Kafka 的构建目标。习惯这种关于数据的思维方式可能与你习惯的有些不同,但事实证明,这是构建应用程序和架构的一种非常强大的抽象。Kafka 经常与几个现有的技术类别进行比较:企业消息传递系统、大数据系统如 Hadoop 以及数据集成或 ETL 工具。每种比较都有一定的有效性,但也有些不足。
Kafka 就像一个消息系统,它让你发布和订阅消息流。在这方面,它类似于 ActiveMQ、RabbitMQ、IBM 的 MQSeries 和其他产品。但即使有这些相似之处,Kafka 与传统消息系统有一些核心区别,使它成为另一种完全不同的动物。以下是三个重大区别:首先,它作为一个现代分布式系统运行,作为一个集群,并且可以扩展以处理即使是最庞大的公司中的所有应用程序。与运行数十个个体消息代理不同,手动连接到不同的应用程序,这让你拥有一个可以弹性扩展以处理公司中所有数据流的中央平台。其次,Kafka 是一个真正的存储系统,可以存储数据,时间长短由你决定。这在使用它作为连接层时有巨大优势,因为它提供了真正的传递保证——它的数据是复制的、持久的,并且可以保存多长时间都可以。最后,流处理的世界显著提高了抽象级别。消息系统主要只是分发消息。Kafka 中的流处理功能让你可以使用更少的代码动态计算派生流和数据集。这些差异使 Kafka 成为自己的东西,所以把它看作“又一个队列”并不是很有意义。
对 Kafka 的另一种看法——也是我们在设计和构建时的动力之一——是将其视为 Hadoop 的实时版本。Hadoop 允许你以非常大的规模存储和定期处理文件数据。Kafka 允许你以同样大的规模存储和持续处理数据流。在技术层面上,确实存在相似之处,许多人认为流处理的新兴领域是对人们使用 Hadoop 及其各种处理层进行的批处理的超集。这种比较忽略了连续、低延迟处理开放的用例与自然落在批处理系统上的用例是完全不同的。Hadoop 和大数据针对的是分析应用程序,通常是在数据仓库领域,而 Kafka 的低延迟特性使其适用于直接支持业务的核心应用程序。这是有道理的:企业中的事件一直在发生,能够在事件发生时对其做出反应,使得构建直接支持业务运营、反馈客户体验等服务变得更加容易。
Kafka 最后被比较的领域是 ETL 或数据集成工具。毕竟,这些工具移动数据,而 Kafka 也移动数据。这也有一定的合理性,但我认为核心区别在于 Kafka 颠倒了问题。Kafka 不是一个用于从一个系统中抓取数据并将其插入另一个系统的工具,而是一个围绕实时事件流的平台。这意味着它不仅可以连接现成的应用程序和数据系统,还可以为基于这些数据流触发的自定义应用程序提供动力。我们认为围绕事件流的这种架构是非常重要的。在某种程度上,这些数据流与现代数字公司最核心的方面一样重要,就像财务报表中看到的现金流一样重要。
将这三个领域结合起来,将所有用例中的所有数据流汇聚在一起的能力,使得流平台的概念对人们非常有吸引力。
尽管如此,所有这些都有点不同,学习如何思考和构建围绕持续数据流的应用程序,如果你来自请求/响应式应用程序和关系数据库的世界,这是一个相当大的思维转变。这本书绝对是了解 Kafka 的最佳途径,从内部到 API,由一些最了解它的人撰写。我希望你和我一样享受阅读它!
Jay Kreps
Confluent 联合创始人兼首席执行官
前言
对技术书籍的作者来说,最大的赞美是“这是我在开始学习这个主题时希望拥有的书。”这是我们在开始写这本书时为自己设定的目标。我们回顾了我们编写 Kafka、在生产环境中运行 Kafka 以及帮助许多公司使用 Kafka 构建软件架构和管理数据管道的经验,然后问自己:“我们可以与新用户分享哪些最有用的东西,让他们从初学者变成专家?”这本书反映了我们每天所做的工作:运行 Apache Kafka 并帮助他人以最佳方式使用它。
我们包括了我们认为您需要了解的内容,以便成功地在生产环境中运行 Apache Kafka 并在其上构建稳健且高性能的应用程序。我们重点介绍了流行的用例:事件驱动微服务的消息总线、流处理应用程序和大规模数据管道。我们还专注于使书籍通用和全面,以便对使用 Kafka 的任何人都有用,无论用例或架构如何。我们涵盖了实际问题,例如如何安装和配置 Kafka 以及如何使用 Kafka API,并且我们还专门介绍了 Kafka 的设计原则和可靠性保证,并探讨了 Kafka 的一些令人愉快的架构细节:复制协议、控制器和存储层。我们相信,对 Kafka 的设计和内部原理的了解不仅对那些对分布式系统感兴趣的人来说是一种有趣的阅读,而且对于那些在部署 Kafka 并设计使用 Kafka 的应用程序时寻求做出明智决策的人来说也是非常有用的。您对 Kafka 的工作原理了解得越多,您就能更多地做出关于工程中涉及的许多权衡的明智决策。
软件工程中的一个问题是,总是有多种方法可以做任何事情。诸如 Apache Kafka 之类的平台提供了很大的灵活性,这对专家来说是很好的,但对初学者来说会造成陡峭的学习曲线。很多时候,Apache Kafka 告诉您如何使用一个功能,但并没有告诉您为什么应该或不应该使用它。在可能的情况下,我们试图澄清现有的选择、涉及的权衡以及您应该何时以及不应该使用 Apache Kafka 提供的不同选项。
谁应该阅读这本书
Kafka:权威指南是为开发使用 Kafka API 的应用程序的软件工程师以及在生产环境中安装、配置、调优和监控 Kafka 的生产工程师(也称为 SRE、DevOps 或系统管理员)编写的。我们还考虑了数据架构师和数据工程师——他们负责设计和构建组织的整个数据基础设施。一些章节,特别是第[3]章、[4]章和[14]章,是针对 Java 开发人员的。这些章节假设读者熟悉 Java 编程语言的基础知识,包括异常处理和并发等主题。其他章节,特别是第[2]章、[10]章、[12]章和[13]章,假设读者具有在 Linux 上运行的经验,并对 Linux 中的存储和网络配置有一定的了解。本书的其余部分讨论了更一般的 Kafka 和软件架构,并不假设特殊知识。
另一类可能对本书感兴趣的人是那些不直接使用 Kafka 但与使用 Kafka 的人一起工作的经理和架构师。他们理解 Kafka 提供的保证以及员工和同事在构建基于 Kafka 的系统时需要做出的权衡同样重要。本书可以为希望让员工接受 Apache Kafka 培训或确保团队了解他们需要了解的内容的经理提供支持。
本书中使用的约定
本书使用以下排版约定:
斜体
指示新术语,URL,电子邮件地址,文件名和文件扩展名。
常量宽度
用于程序清单,以及在段落中引用程序元素,如变量或函数名称,数据库,数据类型,环境变量,语句和关键字。
常量宽度粗体
显示用户应该按照字面意思输入的命令或其他文本。
常量宽度斜体
显示应由用户提供的值或由上下文确定的值替换的文本。
提示
这个元素表示提示或建议。
注
这个元素表示一般说明。
警告
这个元素表示警告或注意。
使用代码示例
如果您对代码示例有技术问题或问题,请发送电子邮件至bookquestions@oreilly.com。
本书旨在帮助您完成工作。一般来说,如果本书提供示例代码,您可以在程序和文档中使用它。除非您复制了代码的大部分,否则您无需联系我们以获得许可。例如,编写一个使用本书中几个代码块的程序不需要许可。出售或分发 O'Reilly 书籍中的示例需要许可。引用本书并引用示例代码来回答问题不需要许可。将本书中大量示例代码合并到产品文档中需要许可。
我们感谢您的使用,但不需要署名。署名通常包括标题,作者,出版商和 ISBN。例如:“Kafka: The Definitive Guide by Gwen Shapira, Todd Palino, Rajini Sivaram, and Krit Petty (O’Reilly). Copyright 2021 Chen Shapira, Todd Palino, Rajini Sivaram, and Krit Petty, 978-1-491-93616-0。”
如果您认为您使用的代码示例超出了合理使用范围或上述给出的许可,请随时通过permissions@oreilly.com与我们联系。
致谢
我们要感谢许多为 Apache Kafka 及其生态系统做出贡献的人。没有他们的工作,这本书就不会存在。特别感谢 Jay Kreps,Neha Narkhede 和 Jun Rao,以及他们的同事和领导 LinkedIn,共同创造了 Kafka 并将其贡献给 Apache 软件基金会。
许多人对书的早期版本提供了宝贵的反馈意见,我们感谢他们的时间和专业知识:Apurva Mehta,Arseniy Tashoyan,Dylan Scott,Ewen Cheslack-Postava,Grant Henke,Ismael Juma,James Cheng,Jason Gustafson,Jeff Holoman,Joel Koshy,Jonathan Seidman,Jun Rao,Matthias Sax,Michael Noll,Paolo Castagna 和 Jesse Anderson。我们还要感谢许多读者通过初稿反馈网站留下评论和反馈。
许多审阅者帮助我们,极大地提高了这本书的质量,所以留下的任何错误都是我们自己的。
我们要感谢我们的 O'Reilly 第一版编辑 Shannon Cutt,她的鼓励和耐心,远远超过了我们。我们的第二版编辑 Jess Haberman 和 Gary O'Brien 在全球挑战中帮助我们保持在正确的轨道上。与 O'Reilly 合作对于作者来说是一次很棒的经历——他们提供的支持,从工具到书签名,都是无与伦比的。我们感谢所有参与使这一切成为可能的人,我们感激他们选择与我们合作。
我们还要感谢我们的经理和同事,在写作过程中给予我们支持和鼓励。
Gwen 要感谢她的丈夫 Omer Shapira,在写作了又一本书的许多个月里给予她的支持和耐心;她的猫 Luke 和 Lea,因为它们是可爱的;以及她的父亲 Lior Shapira,教会她即使看起来艰巨,也要始终接受机会。
Todd 没有他的妻子 Marcy 和女儿 Bella 和 Kaylee 的支持,他将一事无成。他们对他写作的额外时间和长时间奔波的支持使他能够坚持下去。
Rajini 要感谢她的丈夫 Manjunath 和儿子 Tarun,感谢他们始终如一的支持和鼓励,感谢他们在周末审阅早期草稿,以及始终在她身边。
Krit 与妻子 Cecilia 和两个孩子 Lucas 和 Lizabeth 分享他的爱和感激之情。他们的爱和支持使每一天都充满了快乐,没有他们,他将无法追求自己的激情。他还要感谢他的妈妈 Cindy Petty,她灌输给 Krit 的愿望是永远成为最好的自己。
第一章:遇见 Kafka
每个企业都由数据驱动。我们接收信息,分析它,操纵它,并将其作为输出创建更多信息。每个应用程序都会创建数据,无论是日志消息,度量标准,用户活动,传出消息,还是其他内容。每个字节的数据都有一个故事要讲,一个重要的东西,将告知下一步要做的事情。为了知道那是什么,我们需要将数据从创建它的地方传输到可以分析它的地方。我们每天都在网站上看到这一点,比如亚马逊,我们对我们感兴趣的项目的点击被转化为稍后向我们展示的推荐。
我们能够越快地做到这一点,我们的组织就能越具敏捷性和响应性。我们在数据传输上花费的精力越少,我们就能更多地专注于手头的核心业务。这就是为什么管道是数据驱动企业的关键组成部分。我们如何移动数据几乎与数据本身一样重要。
每当科学家们意见不一致时,那是因为我们缺乏数据。然后我们可以就要获取什么类型的数据达成一致;我们获取数据;数据解决了问题。要么我是对的,要么你是对的,要么我们都错了。然后我们继续前进。
尼尔·德格拉斯·泰森
发布/订阅消息传递
在讨论 Apache Kafka 的具体内容之前,重要的是我们理解发布/订阅消息传递的概念,以及为什么它是数据驱动应用程序的关键组成部分。发布/订阅(pub/sub)消息传递是一种模式,其特点是数据(消息)的发送者(发布者)不会将其直接发送给接收者。相反,发布者以某种方式对消息进行分类,而接收者(订阅者)订阅接收某些类别的消息。发布/订阅系统通常有一个代理,即消息发布的中心点,以促进这种模式。
它是如何开始的
许多发布/订阅的用例都是从同样的方式开始的:使用简单的消息队列或进程间通信通道。例如,您创建一个需要将监控信息发送到某个地方的应用程序,因此您从应用程序直接连接到在仪表板上显示您的度量标准的应用程序,并通过该连接推送度量标准,如图 1-1 所示。

图 1-1:单一,直接的度量发布者
这是一个简单的解决方案,用于解决刚开始监控时的简单问题。不久之后,您决定希望分析您的度量标准长期运行,而这在仪表板上效果不佳。您启动了一个新的服务,可以接收度量标准,存储它们并分析它们。为了支持这一点,您修改了应用程序,使其将度量标准写入这两个系统。到目前为止,您有三个应用程序正在生成度量标准,并且它们都与这两个服务建立了相同的连接。您的同事认为定期轮询服务以进行警报是个好主意,因此您在每个应用程序上都添加了一个服务器,以便按请求提供度量标准。过了一会儿,您有更多的应用程序正在使用这些服务器获取个别度量标准,并将它们用于各种目的。这种架构看起来可能与图 1-2 相似,连接甚至更难追踪。

图 1-2:许多度量发布者,使用直接连接
这里积累的技术债务是显而易见的,因此您决定偿还其中的一部分。您设置了一个单一的应用程序,从所有外部应用程序接收度量标准,并提供一个服务器来查询那些需要它们的任何系统的度量标准。这将架构的复杂性降低到类似于图 1-3 的东西。恭喜,您已经构建了一个发布/订阅消息传递系统!

图 1-3:度量发布/订阅系统
个人队列系统
与您使用指标进行战争的同时,您的一位同事正在使用日志消息进行类似的工作。另一位同事正在跟踪前端网站上用户行为,并将该信息提供给正在进行机器学习的开发人员,同时为管理层创建一些报告。您都遵循了构建系统的类似路径,这些系统将信息的发布者与订阅者解耦。图 1-4 显示了这样的基础设施,有三个独立的发布/订阅系统。

图 1-4:多个发布/订阅系统
这肯定比使用点对点连接要好得多(如图 1-2 中所示),但存在很多重复。您的公司正在维护多个用于排队数据的系统,所有这些系统都有各自的错误和限制。您还知道将会有更多的消息传递用例即将到来。您希望拥有一个单一的集中式系统,允许发布通用类型的数据,随着业务的增长而增长。
进入 Kafka
Apache Kafka 被开发为一个发布/订阅消息系统,旨在解决这个问题。它经常被描述为“分布式提交日志”,或者最近被描述为“分布式流平台”。文件系统或数据库提交日志旨在提供所有事务的持久记录,以便可以重放它们以一致地构建系统的状态。同样,Kafka 中的数据是持久存储的,有序的,并且可以被确定性地读取。此外,数据可以在系统内分布,以提供额外的故障保护,以及显著的性能扩展机会。
消息和批次
Kafka 中的数据单元称为消息。如果您是从数据库背景接触 Kafka,您可以将其视为类似于行或记录。就 Kafka 而言,消息只是一个字节数组,因此其中包含的数据对 Kafka 来说没有特定的格式或含义。消息可以有一个可选的元数据,称为键。键也是一个字节数组,与消息一样,对 Kafka 来说没有特定的含义。当消息需要以更受控制的方式写入分区时,会使用键。最简单的方案是生成键的一致哈希,然后通过取哈希结果对主题中分区总数取模来选择该消息的分区号。这确保具有相同键的消息始终写入相同的分区(前提是分区数不会改变)。
为了提高效率,消息被批量写入 Kafka。批量只是一组消息,所有这些消息都被生产到相同的主题和分区。对于每条消息进行一次网络往返将导致过多的开销,将消息收集到一起形成批量可以减少这种开销。当然,这是延迟和吞吐量之间的权衡:批量越大,每单位时间可以处理的消息就越多,但单个消息传播所需的时间就越长。批次通常也会被压缩,提供更高效的数据传输和存储,但会消耗一些处理能力。关于键和批次的更多细节将在第三章中讨论。
模式
虽然对于 Kafka 本身来说,消息是不透明的字节数组,但建议对消息内容施加额外的结构或模式,以便轻松理解。根据应用程序的个体需求,有许多可用的消息模式选项。像 JavaScript 对象表示法(JSON)和可扩展标记语言(XML)这样的简单系统易于使用且易于阅读。但是,它们缺乏诸如强大的类型处理和模式版本之间的兼容性等功能。许多 Kafka 开发人员青睐使用 Apache Avro,这是最初为 Hadoop 开发的序列化框架。Avro 提供了紧凑的序列化格式,模式与消息有效载荷分开,并且在更改时不需要生成代码,具有强大的数据类型和模式演变,具有向后和向前的兼容性。
在 Kafka 中,一致的数据格式很重要,因为它允许写入和读取消息解耦。当这些任务紧密耦合时,订阅消息的应用程序必须更新以处理新的数据格式,与旧格式并行。只有在这之后,发布消息的应用程序才能更新以利用新的格式。通过使用明确定义的模式并将其存储在共同的存储库中,Kafka 中的消息可以在没有协调的情况下被理解。模式和序列化在第三章中有更详细的介绍。
主题和分区
Kafka 中的消息被分类为主题。主题的最接近类比是数据库表或文件系统中的文件夹。主题还可以进一步分为多个分区。回到“提交日志”的描述,分区就是一个单独的日志。消息以追加方式写入其中,并且按顺序从头到尾读取。请注意,由于主题通常具有多个分区,因此无法保证整个主题中的消息顺序,只能保证单个分区内的消息顺序。图 1-5 显示了一个具有四个分区的主题,写入被追加到每个分区的末尾。分区也是 Kafka 提供冗余和可伸缩性的方式。每个分区可以托管在不同的服务器上,这意味着单个主题可以横向扩展到多个服务器上,以提供远远超出单个服务器能力的性能。此外,分区可以被复制,以便不同的服务器将存储相同分区的副本,以防一个服务器发生故障。

图 1-5. 具有多个分区的主题的表示
在讨论像 Kafka 这样的系统中的数据时,经常使用术语流。通常情况下,流被认为是单个数据主题,而不考虑分区的数量。这代表了一条从生产者到消费者的数据流。这种引用消息的方式在讨论流处理时最常见,即在实时操作消息的框架上进行操作,其中一些框架包括 Kafka Streams、Apache Samza 和 Storm。这种操作方式可以与离线框架(如 Hadoop)在以后的某个时间点上对大量数据进行操作进行比较。有关流处理的概述在第十四章中提供。
生产者和消费者
Kafka 客户端是系统的用户,基本上有两种类型:生产者和消费者。还有高级客户端 API——用于数据集成的 Kafka Connect API 和用于流处理的 Kafka Streams。高级客户端使用生产者和消费者作为构建模块,并在其上提供更高级的功能。
生产者创建新消息。在其他发布/订阅系统中,这些可能被称为发布者或写者。消息将被生产到特定主题。默认情况下,生产者将消息均匀地分布到主题的所有分区上。在某些情况下,生产者将消息定向到特定分区。这通常是使用消息键和分区器来生成键的哈希并将其映射到特定分区。这确保使用给定键生成的所有消息将被写入同一分区。生产者还可以使用遵循其他业务规则的自定义分区器将消息映射到分区。有关生产者的详细信息,请参阅第三章。
消费者读取消息。在其他发布/订阅系统中,这些客户端可能被称为订阅者或读者。消费者订阅一个或多个主题,并按照它们被生产到每个分区的顺序读取消息。消费者通过跟踪消息的偏移量来跟踪它已经消费的消息。偏移量——一个不断增加的整数值——是 Kafka 在每条消息被生产时添加的另一个元数据。给定分区中的每条消息都有一个唯一的偏移量,并且下一条消息有一个更大的偏移量(尽管不一定是单调递增的)。通过通常在 Kafka 本身中存储每个分区的下一个可能偏移量,消费者可以在不丢失位置的情况下停止和重新启动。
消费者作为消费者组的一部分工作,这是一个或多个一起消费主题的消费者。该组确保每个分区只被一个成员消费。在图 1-6 中,有三个消费者在一个单一组中消费一个主题。其中两个消费者分别从一个分区工作,而第三个消费者从两个分区工作。消费者到分区的映射通常称为消费者对分区的所有权。
以这种方式,消费者可以水平扩展以消费具有大量消息的主题。此外,如果单个消费者失败,组的其余成员将重新分配正在消费的分区,以代替缺失的成员。有关消费者和消费者组的详细讨论,请参阅第四章。

图 1-6. 一个消费者组从一个主题中读取消息
经纪人和集群
单个 Kafka 服务器称为经纪人。经纪人接收来自生产者的消息,为它们分配偏移量,并将消息写入磁盘存储。它还为消费者提供服务,响应对分区的获取请求,并用已发布的消息进行响应。根据特定硬件及其性能特征,单个经纪人可以轻松处理数千个分区和每秒数百万条消息。
Kafka 经纪人被设计为作为集群的一部分运行。在经纪人集群中,一个经纪人还将作为集群的控制器(从集群的活动成员中自动选举产生)。控制器负责管理操作,包括将分区分配给经纪人并监视经纪人故障。集群中的单个分区由集群中的单个经纪人拥有,并且该经纪人被称为分区的领导者。复制分区(如图 1-7 中所示)分配给其他经纪人,称为分区的跟随者。复制提供了分区中消息的冗余,以便如果有经纪人故障,其中一个跟随者可以接管领导权。所有生产者必须连接到领导者才能发布消息,但消费者可以从领导者或其中一个跟随者获取消息。集群操作,包括分区复制,将在第七章中详细介绍。

图 1-7:集群中分区的复制
Apache Kafka 的一个关键特性是保留,即一段时间内消息的持久存储。Kafka 经纪人配置了主题的默认保留设置,可以保留一段时间的消息(例如 7 天),或者直到分区达到特定大小(例如 1GB)。一旦达到这些限制,消息将过期并被删除。通过这种方式,保留配置定义了任何时间可用数据的最小量。个别主题也可以配置自己的保留设置,以便仅在有用时存储消息。例如,跟踪主题可能保留几天,而应用程序指标可能仅保留几个小时。主题还可以配置为日志压缩,这意味着 Kafka 将仅保留使用特定键生成的最后一条消息。这对于只有最后更新有趣的更改日志类型数据很有用。
多个集群
随着 Kafka 部署的增长,拥有多个集群通常是有利的。有几个原因可以解释为什么这样做是有用的:
-
数据类型的分离
-
满足安全需求的隔离
-
多个数据中心(灾难恢复)
特别是在使用多个数据中心时,通常需要在它们之间复制消息。这样,在线应用程序可以访问两个站点的用户活动。例如,如果用户在其个人资料中更改公共信息,则无论在哪个数据中心显示搜索结果,该更改都需要可见。或者,监控数据可以从许多站点收集到单个中央位置,分析和警报系统在那里托管。Kafka 集群内的复制机制仅设计为在单个集群内工作,而不是在多个集群之间工作。
Kafka 项目包括一个名为MirrorMaker的工具,用于将数据复制到其他集群。在其核心,MirrorMaker 只是一个 Kafka 消费者和生产者,通过队列连接在一起。消息从一个 Kafka 集群中消耗,并在另一个集群中生成。图 1-8 显示了一个使用 MirrorMaker 的架构示例,将来自两个本地集群的消息聚合到一个聚合集群,然后将该集群复制到其他数据中心。该应用程序的简单性掩盖了它在创建复杂数据管道方面的强大功能,这将在第九章中进一步详细介绍。

图 1-8:多数据中心架构
为什么选择 Kafka?
在发布/订阅消息系统中有很多选择,那么 Apache Kafka 为什么是一个好选择呢?
多个生产者
Kafka 能够无缝处理多个生产者,无论这些客户端是使用许多主题还是相同的主题。这使得系统非常适合从许多前端系统聚合数据并使其一致。例如,通过多个微服务为用户提供内容的站点可以有一个页面浏览的单个主题,所有服务都可以使用通用格式写入。消费应用程序可以接收站点上所有应用程序的页面浏览的单个流,而无需协调从多个主题消耗,每个应用程序一个。
多个消费者
除了多个生产者外,Kafka 还设计为多个消费者可以读取任何单个消息流,而不会干扰其他客户端。这与许多排队系统相反,其中一旦消息被一个客户端消耗,它就不可用于任何其他客户端。多个 Kafka 消费者可以选择作为一个组操作,并共享一个流,确保整个组仅处理给定消息一次。
基于磁盘的保留
Kafka 不仅可以处理多个消费者,而且持久的消息保留意味着消费者不总是需要实时工作。消息被写入磁盘,并且将根据可配置的保留规则进行存储。这些选项可以根据主题选择,允许不同的消息流具有不同的保留量,以满足消费者的需求。持久的保留意味着如果消费者落后,无论是由于处理速度慢还是流量激增,都不会丢失数据。这也意味着可以对消费者进行维护,将应用程序离线一小段时间,而不用担心生产者上的消息积压或丢失。消费者可以停止,消息将被保留在 Kafka 中。这使它们可以重新启动并在离开时继续处理消息,而不会丢失数据。
可扩展
Kafka 的灵活可扩展性使其能够轻松处理任意数量的数据。用户可以从单个代理作为概念验证开始,扩展到由三个代理组成的小型开发集群,然后随着数据规模的扩大,进入由数十甚至数百个代理组成的生产集群。扩展可以在集群在线时进行,对整个系统的可用性没有影响。这也意味着多个代理组成的集群可以处理单个代理的故障并继续为客户提供服务。需要容忍更多同时故障的集群可以配置更高的复制因子。复制将在第七章中进行更详细的讨论。
高性能
所有这些功能共同使 Apache Kafka 成为一个在高负载下具有出色性能的发布/订阅消息系统。生产者、消费者和代理都可以扩展以轻松处理非常大的消息流。这可以在仍然提供从生成消息到可供消费者使用的亚秒级消息延迟的情况下完成。
平台功能
Apache Kafka 核心项目还添加了一些流平台功能,可以使开发人员更容易执行常见类型的工作。虽然不是完整的平台,通常包括像 YARN 这样的结构化运行时环境,但这些功能是以 API 和库的形式提供的,为构建和灵活性提供了坚实的基础,可以在其中运行。Kafka Connect 帮助从源数据系统中提取数据并将其推送到 Kafka,或者从 Kafka 中提取数据并将其推送到接收数据系统。Kafka Streams 提供了一个库,用于轻松开发可扩展和容错的流处理应用程序。Connect 在第九章中讨论,而 Streams 在第十四章中有详细介绍。
数据生态系统
许多应用程序参与我们为数据处理构建的环境。我们已经定义了以应用程序形式的输入,这些应用程序创建数据或以其他方式将其引入系统。我们已经定义了以度量标准、报告和其他数据产品形式的输出。我们创建循环,一些组件从系统中读取数据,使用其他来源的数据进行转换,然后将其重新引入数据基础设施以供其他地方使用。这是针对多种类型的数据进行的,每种数据都具有独特的内容、大小和用途。
Apache Kafka 为数据生态系统提供了循环系统,如图 1-9 所示。它在基础设施的各个成员之间传递消息,为所有客户端提供一致的接口。当与提供消息模式的系统配对时,生产者和消费者不再需要紧密耦合或任何直接连接。组件可以根据业务情况的创建和解散而添加和删除,并且生产者不需要担心谁在使用数据或消费应用程序的数量。

图 1-9:大数据生态系统
用例
活动跟踪
Kafka 最初的用例是在 LinkedIn 设计的用户活动跟踪。网站的用户与前端应用程序进行交互,生成有关用户正在执行的操作的消息。这可以是被动信息,例如页面浏览和点击跟踪,也可以是更复杂的操作,例如用户添加到其个人资料的信息。这些消息发布到一个或多个主题,然后由后端应用程序消费。这些应用程序可能生成报告,为机器学习系统提供数据,更新搜索结果,或执行其他必要的操作,以提供丰富的用户体验。
消息传递
Kafka 还用于消息传递,应用程序需要向用户发送通知(例如电子邮件)的情况。这些应用程序可以生成消息,而无需担心格式或消息实际上将如何发送。然后,单个应用程序可以读取所有要发送的消息并一致地处理它们,包括:
-
使用统一的外观和感觉格式化消息(也称为装饰)
-
将多个消息收集到单个通知中发送
-
应用用户的偏好来决定他们希望如何接收消息
使用单个应用程序可以避免在多个应用程序中重复功能,还可以进行聚合等操作,否则将不可能进行。
指标和日志记录
Kafka 还非常适合收集应用程序和系统指标和日志。这是一个使用案例,其中具有多个应用程序生成相同类型的消息的能力非常突出。应用程序定期向 Kafka 主题发布指标,并且这些指标可以被用于监控和警报的系统消费。它们还可以在像 Hadoop 这样的离线系统中用于执行更长期的分析,例如增长预测。日志消息也可以以相同的方式发布,并且可以路由到专用的日志搜索系统,如 Elasticsearch 或安全分析应用程序。Kafka 的另一个附加好处是,当目标系统需要更改时(例如,是时候更新日志存储系统了),无需更改前端应用程序或聚合手段。
提交日志
由于 Kafka 基于提交日志的概念,数据库更改可以发布到 Kafka,并且应用程序可以轻松监视此流以接收实时更新。此更改日志流也可用于将数据库更新复制到远程系统,或将多个应用程序的更改合并到单个数据库视图中。在这里,持久保留对于提供更改日志的缓冲区非常有用,这意味着在消费应用程序发生故障时可以重放它。或者,可以使用日志压缩主题仅保留每个键的单个更改,以提供更长时间的保留。
流处理
另一个提供多种类型应用程序的领域是流处理。虽然几乎所有对 Kafka 的使用都可以被视为流处理,但这个术语通常用来指提供类似于 Hadoop 中 map/reduce 处理功能的应用程序。Hadoop 通常依赖于在长时间范围内(小时或天)对数据进行聚合。流处理实时操作数据,就像消息产生的速度一样快。流框架允许用户编写小型应用程序来处理 Kafka 消息,执行诸如计算指标、将消息分区以便其他应用程序高效处理,或者使用来自多个来源的数据转换消息等任务。流处理在第十四章中有所涵盖。
Kafka 的起源
Kafka 是为了解决领英的数据管道问题而创建的。它旨在提供一个高性能的消息系统,可以处理多种类型的数据,并实时提供关于用户活动和系统指标的清晰结构化数据。
数据真的支撑着我们所做的一切。
领英前 CEO Jeff Weiner
领英的问题
与本章开头描述的示例类似,领英有一个用于收集系统和应用程序指标的系统,使用自定义收集器和开源工具在内部存储和呈现数据。除了传统的指标,如 CPU 使用率和应用程序性能,还有一个复杂的请求跟踪功能,利用监控系统,可以深入了解单个用户请求在内部应用程序中的传播情况。然而,监控系统存在许多缺陷。这包括基于轮询的指标收集,指标之间的大间隔,以及应用程序所有者无法管理自己的指标。该系统需要高度的人工干预,大部分简单任务都需要人工干预,而且不一致,不同系统对于相同测量的指标名称不同。
同时,还创建了一个用于跟踪用户活动信息的系统。这是一个 HTTP 服务,前端服务器会定期连接到该服务,并发布一批消息(以 XML 格式)到 HTTP 服务。然后这些批次被移动到离线处理平台,文件被解析和整理。这个系统有很多缺陷。XML 格式不一致,解析起来计算成本很高。改变被跟踪的用户活动类型需要前端和离线处理之间大量协调的工作。即使这样,由于不断变化的模式,系统经常会出现故障。跟踪是建立在每小时批处理的基础上的,因此无法实时使用。
监控和用户活动跟踪不能使用相同的后端服务。监控服务太笨重,数据格式不适合活动跟踪,而监控的轮询模型与跟踪的推送模型不兼容。同时,跟踪服务对于指标来说太脆弱,面向批处理的处理模式也不适合实时监控和警报。然而,监控和跟踪数据具有许多相似之处,信息的相关性(例如特定类型的用户活动如何影响应用程序性能)是非常可取的。特定类型的用户活动下降可能表明应用程序存在问题,但处理活动批次的延迟数小时意味着对这类问题的反应缓慢。
起初,对现有的开源解决方案进行了彻底调查,以找到一个能够提供对数据的实时访问并能够扩展以处理所需的消息流量的新系统。使用 ActiveMQ 建立了原型系统,但当时它无法处理这样的规模。对于 LinkedIn 需要使用的方式来说,它也是一个脆弱的解决方案,发现了 ActiveMQ 中许多会导致代理暂停的缺陷。这些暂停会导致连接到客户端的连接积压,并干扰应用程序为用户提供请求的能力。决定继续使用自定义基础架构进行数据管道。
Kafka 的诞生
LinkedIn 的开发团队由 Jay Kreps 领导,他是一名负责开发和开源发布分布式键值存储系统 Voldemort 的首席软件工程师。最初的团队还包括 Neha Narkhede,后来又加入了 Jun Rao。他们一起着手创建一个能够满足监控和跟踪系统需求并能够为未来扩展的消息系统。主要目标是:
-
通过使用推拉模型解耦生产者和消费者
-
在消息系统内为消息数据提供持久性,以允许多个消费者
-
优化消息的高吞吐量
-
允许系统进行水平扩展,以随着数据流的增长而增长
结果是一个发布/订阅消息系统,具有典型的消息系统接口,但存储层更像是日志聚合系统。结合采用 Apache Avro 进行消息序列化,Kafka 能够有效处理每天数十亿条消息的指标和用户活动跟踪。Kafka 的可扩展性帮助 LinkedIn 的使用量超过了每天产生的七万亿条消息(截至 2020 年 2 月),每天消耗超过五 PB 的数据。
开源
Kafka 于 2010 年末在 GitHub 上作为开源项目发布。随着它开始引起开源社区的关注,它于 2011 年 7 月被提议并接受为 Apache 软件基金会的孵化器项目。Apache Kafka 于 2012 年 10 月从孵化器毕业。从那时起,它一直在不断地进行开发,并在 LinkedIn 之外找到了一个强大的贡献者和提交者社区。Kafka 现在被用于世界上一些最大的数据管道,包括 Netflix、Uber 和许多其他公司。
Kafka 的广泛采用也在核心项目周围形成了一个健康的生态系统。全球各地都有活跃的见面会小组,提供本地的流处理讨论和支持。还有许多与 Apache Kafka 相关的开源项目。LinkedIn 继续维护其中一些,包括 Cruise Control、Kafka Monitor 和 Burrow。除了商业产品,Confluent 还发布了包括 ksqlDB、模式注册表和 REST 代理在内的项目,采用社区许可证(不严格属于开源,因为它包含使用限制)。一些最受欢迎的项目列在附录 B 中。
商业参与
2014 年秋天,杰伊·克雷普斯(Jay Kreps)、内哈·纳尔凯德(Neha Narkhede)和饶军(Jun Rao)离开领英,创立了 Confluent,这是一家以提供 Apache Kafka 的开发、企业支持和培训为中心的公司。他们还与其他公司(如 Heroku)合作,为 Kafka 提供云服务。Confluent 通过与谷歌合作,在谷歌云平台上提供托管的 Kafka 集群,以及在亚马逊网络服务和 Azure 上提供类似的服务。Confluent 的另一个重大举措是组织 Kafka 峰会系列会议。Kafka 峰会始于 2016 年,每年在美国和伦敦举办,为社区提供了一个全球范围内共享关于 Apache Kafka 和相关项目知识的平台。
名字
人们经常问 Kafka 是如何得到它的名字,以及它是否代表了应用程序本身的特定含义。杰伊·克雷普斯提供了以下见解:
我认为,由于 Kafka 是一个优化写作的系统,使用作家的名字是有道理的。我在大学时上了很多文学课,喜欢弗朗茨·卡夫卡。而且这个名字对于一个开源项目来说听起来很酷。
所以基本上没有太多关系。
开始使用 Kafka
现在我们已经了解了 Kafka 及其历史,我们可以设置它并构建自己的数据管道。在下一章中,我们将探讨安装和配置 Kafka。我们还将介绍在运行 Kafka 时选择合适的硬件,以及在转向生产运营时需要注意的一些事项。
第二章:安装 Kafka
本章描述了如何开始使用 Apache Kafka 代理,包括如何设置 Apache ZooKeeper,Kafka 用于存储代理的元数据。本章还将涵盖 Kafka 部署的基本配置选项,以及一些关于选择正确硬件来运行代理的建议。最后,我们将介绍如何在单个集群的一部分安装多个 Kafka 代理,以及在生产环境中使用 Kafka 时应该了解的一些事项。
环境设置
在使用 Apache Kafka 之前,您的环境需要设置一些先决条件,以确保其正常运行。以下部分将指导您完成这个过程。
选择操作系统
Apache Kafka 是一个 Java 应用程序,可以在许多操作系统上运行。虽然 Kafka 可以在许多操作系统上运行,包括 Windows、macOS、Linux 和其他操作系统,但 Linux 是一般用例的推荐操作系统。本章中的安装步骤将重点介绍在 Linux 环境中设置和使用 Kafka。有关在 Windows 和 macOS 上安装 Kafka 的信息,请参阅附录 A。
安装 Java
在安装 ZooKeeper 或 Kafka 之前,您需要设置并运行 Java 环境。Kafka 和 ZooKeeper 与所有基于 OpenJDK 的 Java 实现(包括 Oracle JDK)兼容。最新版本的 Kafka 支持 Java 8 和 Java 11。安装的确切版本可以是操作系统提供的版本,也可以是直接从网络下载的版本,例如Oracle 版本的 Oracle 网站。虽然 ZooKeeper 和 Kafka 可以与运行时版本的 Java 一起工作,但在开发工具和应用程序时建议使用完整的 Java 开发工具包(JDK)。建议安装 Java 环境的最新发布的补丁版本,因为旧版本可能存在安全漏洞。安装步骤将假定您已经安装了部署在/usr/java/jdk-11.0.10的 JDK 版本 11 更新 10。
安装 ZooKeeper
Apache Kafka 使用 Apache ZooKeeper 存储有关 Kafka 集群的元数据,以及消费者客户端详细信息,如图 2-1 所示。ZooKeeper 是一个集中式服务,用于维护配置信息、命名、提供分布式同步和提供组服务。本书不会详细介绍 ZooKeeper,而是将解释限制在操作 Kafka 所需的内容。虽然可以使用 Kafka 分发中包含的脚本运行 ZooKeeper 服务器,但从分发中安装完整版本的 ZooKeeper 是微不足道的。

图 2-1:Kafka 和 ZooKeeper
Kafka 已经广泛测试了稳定的 ZooKeeper 3.5 版本,并定期更新以包括最新版本。在本书中,我们将使用 ZooKeeper 3.5.9,可以从ZooKeeper 网站下载。
独立服务器
ZooKeeper 附带一个基本示例配置文件,对于大多数用例来说都可以正常工作,位于/usr/local/zookeeper/config/zoo_sample.cfg。但是,为了演示目的,我们将在本书中手动创建一些基本设置的配置文件。以下示例在/usr/local/zookeeper中使用基本配置安装 ZooKeeper,并将其数据存储在/var/lib/zookeeper中:
# tar -zxf apache-zookeeper-3.5.9-bin.tar.gz
# mv apache-zookeeper-3.5.9-bin /usr/local/zookeeper
# mkdir -p /var/lib/zookeeper
# cp > /usr/local/zookeeper/conf/zoo.cfg << EOF
> tickTime=2000
> dataDir=/var/lib/zookeeper
> clientPort=2181
> EOF
# export JAVA_HOME=/usr/java/jdk-11.0.10
# /usr/local/zookeeper/bin/zkServer.sh start
JMX enabled by default
Using config: /usr/local/zookeeper/bin/../conf/zoo.cfg
Starting zookeeper ... STARTED
#
您现在可以通过连接到客户端端口并发送四字命令srvr来验证 ZooKeeper 是否在独立模式下正确运行。这将从运行的服务器返回基本的 ZooKeeper 信息:
# telnet localhost 2181
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
srvr
Zookeeper version: 3.5.9-83df9301aa5c2a5d284a9940177808c01bc35cef, built on 01/06/2021 19:49 GMT
Latency min/avg/max: 0/0/0
Received: 1
Sent: 0
Connections: 1
Outstanding: 0
Zxid: 0x0
Mode: standalone
Node count: 5
Connection closed by foreign host.
#
ZooKeeper 集合
ZooKeeper 被设计为作为一个名为ensemble的集群工作,以确保高可用性。由于使用的平衡算法,建议集群包含奇数个服务器(例如 3、5 等),因为大多数集群成员(quorum)必须正常工作才能响应 ZooKeeper 的请求。这意味着在一个三节点集群中,您可以运行一个节点缺失。在一个五节点集群中,您可以运行两个节点缺失。
调整 ZooKeeper 集群的大小
考虑在一个五节点集群中运行 ZooKeeper。要对集群进行配置更改,包括交换节点,您需要逐个重新加载节点。如果您的集群不能容忍多于一个节点宕机,进行维护工作会增加额外的风险。此外,不建议运行超过七个节点,因为由于共识协议的性质,性能可能开始下降。
此外,如果您觉得五个或七个节点无法支持由于太多客户端连接而产生的负载,请考虑添加额外的观察者节点来帮助平衡只读流量。
要在集群中配置 ZooKeeper 服务器,它们必须有一个列出所有服务器的共同配置,每个服务器在数据目录中需要一个指定服务器 ID 号的myid文件。如果集群中的服务器的主机名是zoo1.example.com,zoo2.example.com和zoo3.example.com,配置文件可能如下所示:
tickTime=2000
dataDir=/var/lib/zookeeper
clientPort=2181
initLimit=20
syncLimit=5
server.1=zoo1.example.com:2888:3888
server.2=zoo2.example.com:2888:3888
server.3=zoo3.example.com:2888:3888
在此配置中,initLimit是允许跟随者与领导者连接的时间。syncLimit值限制了落后于领导者的跟随者可以有多久。这两个值都是tickTime单位的数字,这使得initLimit为 20×2,000 毫秒,即 40 秒。配置还列出了集群中的每个服务器。服务器以*server.X=hostname:peerPort:leaderPort*的格式指定,具有以下参数:
X
服务器的 ID 号。这必须是一个整数,但不需要基于零或连续。
hostname
服务器的主机名或 IP 地址。
peerPort
用于集群中的服务器相互通信的 TCP 端口。
leaderPort
用于执行领导者选举的 TCP 端口。
客户端只需要能够通过*clientPort*连接到集群,但集群的成员必须能够通过所有三个端口相互通信。
除了共享配置文件之外,每个服务器必须在dataDir目录中有一个名为myid的文件。该文件必须包含服务器的 ID 号,该号码必须与配置文件匹配。完成这些步骤后,服务器将启动并在集群中相互通信。
在单台机器上测试 ZooKeeper 集群
可以通过在配置中指定所有主机名为localhost并为每个实例指定唯一的*peerPort*和*leaderPort*来在单台机器上测试和运行 ZooKeeper 集群。此外,每个实例都需要创建一个单独的zoo.cfg,其中为每个实例定义了唯一的dataDir和*clientPort*。这只对测试目的有用,但不建议用于生产系统。
安装 Kafka Broker
一旦 Java 和 ZooKeeper 配置完成,您就可以安装 Apache Kafka 了。当前版本可以从Kafka 网站下载。截至目前,该版本是在 Scala 版本 2.13.0 下运行的 2.8.0 版本。本章的示例是使用 2.7.0 版本显示的。
以下示例在/usr/local/kafka中安装 Kafka,配置为使用先前启动的 ZooKeeper 服务器,并将消息日志段存储在/tmp/kafka-logs中:
# tar -zxf kafka_2.13-2.7.0.tgz
# mv kafka_2.13-2.7.0 /usr/local/kafka
# mkdir /tmp/kafka-logs
# export JAVA_HOME=/usr/java/jdk-11.0.10
# /usr/local/kafka/bin/kafka-server-start.sh -daemon
/usr/local/kafka/config/server.properties
#
一旦 Kafka 经纪人启动,我们可以通过针对集群执行一些简单操作来验证它是否正常工作:创建一个测试主题,生成一些消息,并消费相同的消息。
创建和验证主题:
# /usr/local/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --create
--replication-factor 1 --partitions 1 --topic test
Created topic "test".
# /usr/local/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092
--describe --topic test
Topic:test PartitionCount:1 ReplicationFactor:1 Configs:
Topic: test Partition: 0 Leader: 0 Replicas: 0 Isr: 0
#
向测试主题生成消息(使用 Ctrl-C 随时停止生产者):
# /usr/local/kafka/bin/kafka-console-producer.sh --bootstrap-server
localhost:9092 --topic test
Test Message 1
Test Message 2
^C
#
从测试主题消费消息:
# /usr/local/kafka/bin/kafka-console-consumer.sh --bootstrap-server
localhost:9092 --topic test --from-beginning
Test Message 1
Test Message 2
^C
Processed a total of 2 messages
#
Kafka CLI 实用程序上的 ZooKeeper 连接的弃用
如果您熟悉 Kafka 实用程序的旧版本,您可能习惯于使用“--zookeeper”连接字符串。在几乎所有情况下,这已经被弃用。当前的最佳实践是使用更新的“--bootstrap-server”选项,并直接连接到 Kafka 经纪人。如果在集群中运行,可以提供集群中任何经纪人的主机:端口。
配置经纪人
Kafka 发行版提供的示例配置足以作为概念验证运行独立服务器,但很可能不足以满足大型安装的需求。Kafka 有许多配置选项,可以控制设置和调整的各个方面。大多数选项可以保留默认设置,因为它们涉及 Kafka 经纪人的调整方面,直到您有特定的用例需要调整这些设置之前,这些设置都不适用。
一般经纪人参数
在部署 Kafka 到除单个服务器上的独立经纪人之外的任何环境时,应该审查几个经纪人配置参数。这些参数涉及经纪人的基本配置,大多数必须更改才能在与其他经纪人的集群中正确运行。
broker.id
每个 Kafka 经纪人必须有一个整数标识符,这是使用“broker.id”配置设置的。默认情况下,此整数设置为“0”,但可以是任何值。对于单个 Kafka 集群中的每个经纪人,这个整数必须是唯一的。选择这个数字在技术上是任意的,如果需要进行维护任务,它可以在经纪人之间移动。然而,强烈建议将此值设置为主机的某些固有值,以便在执行维护时,将经纪人 ID 号映射到主机上不是繁重的任务。例如,如果您的主机名包含唯一的数字(例如host1.example.com,host2.example.com等),那么分别为broker.id值选择1和2是不错的选择。
listeners
Kafka 的旧版本使用简单的“端口”配置。这仍然可以作为简单配置的备份使用,但已经是一个不推荐的配置。示例配置文件在 TCP 端口 9092 上启动 Kafka 监听器。新的“listeners”配置是一个以逗号分隔的 URI 列表,我们使用监听器名称进行监听。如果监听器名称不是常见的安全协议,那么必须配置另一个配置“listener.security.protocol.map”。监听器被定义为“
zookeeper.connect
用于存储经纪人元数据的 ZooKeeper 的位置是使用“zookeeper.connect”配置参数设置的。示例配置使用在本地主机上端口 2181 上运行的 ZooKeeper,指定为“localhost:2181”。此参数的格式是一个以分号分隔的“hostname:port/path”字符串列表,其中包括:
hostname
ZooKeeper 服务器的主机名或 IP 地址。
port
服务器的客户端端口号。
/path
用作 Kafka 集群的 chroot 环境的可选 ZooKeeper 路径。如果省略,将使用根路径。
如果指定了 chroot 路径(指定为给定应用程序的根目录的路径)并且不存在,代理在启动时将创建它。
为什么使用 Chroot 路径?
通常认为使用 Kafka 集群的 chroot 路径是一个良好的做法。这允许 ZooKeeper 集合与其他应用程序共享,包括其他 Kafka 集群,而不会发生冲突。最好还要在此配置中指定多个 ZooKeeper 服务器(它们都是同一个集合的一部分)。这允许 Kafka 代理在服务器故障时连接到 ZooKeeper 集合的另一个成员。
log.dirs
Kafka 将所有消息持久化到磁盘,并将这些日志段存储在log.dir配置中指定的目录中。对于多个目录,首选使用log.dirs配置。如果未设置此值,它将默认回到log.dir。log.dirs是本地系统上路径的逗号分隔列表。如果指定了多个路径,代理将以“最少使用”的方式在其中存储分区,一个分区的日志段存储在同一路径中。请注意,代理将在当前存储的分区数量最少的路径中放置新的分区,而不是使用的磁盘空间最少,因此不能保证在多个目录中均匀分布数据。
num.recovery.threads.per.data.dir
Kafka 使用可配置的线程池来处理日志段。目前,该线程池用于:
-
正常启动时,打开每个分区的日志段
-
在故障后启动时,检查和截断每个分区的日志段
-
关闭时,清理关闭日志段
默认情况下,每个日志目录只使用一个线程。由于这些线程仅在启动和关闭期间使用,因此合理地设置更多的线程以并行化操作是合理的。特别是在从不干净的关闭中恢复时,这可能意味着在重新启动具有大量分区的代理时节省数小时的时间!设置此参数时,请记住配置的数量是指log.dirs指定的每个日志目录。这意味着如果num.recovery.threads.per.data.dir设置为 8,并且在log.dirs中指定了 3 个路径,则总共有 24 个线程。
auto.create.topics.enable
默认的 Kafka 配置指定代理在以下情况下应自动创建主题:
-
当生产者开始向主题写入消息时
-
当消费者开始从主题中读取消息时
-
当任何客户端请求主题的元数据时
在许多情况下,这可能是不希望的行为,特别是因为没有办法通过 Kafka 协议验证主题的存在而不导致其被创建。如果您正在显式管理主题创建,无论是手动还是通过配置系统,都可以将auto.create.topics.enable配置设置为false。
auto.leader.rebalance.enable
为了确保 Kafka 集群不会因为所有主题领导都在一个代理上而变得不平衡,可以指定此配置以尽可能平衡领导。它启用了一个后台线程,定期检查分区的分布(此间隔可通过leader.imbalance.check.interval.seconds进行配置)。如果领导不平衡超过另一个配置leader.imbalance.per.broker.percentage,则会开始重新平衡分区的首选领导。
delete.topic.enable
根据您的环境和数据保留指南,您可能希望锁定集群以防止任意删除主题。通过将此标志设置为false,可以禁用主题删除。
主题默认值
Kafka 服务器配置指定了为创建的主题设置的许多默认配置。其中包括分区计数和消息保留等参数,可以使用管理工具(在第十二章中介绍)针对每个主题进行设置。服务器配置中的默认值应设置为适用于集群中大多数主题的基线值。
使用每个主题的覆盖
在较旧版本的 Kafka 中,可以使用代理配置中的参数log.retention.hours.per.topic、log.retention.bytes.per.topic和log.segment.bytes.per.topic为这些配置指定每个主题的覆盖。这些参数不再受支持,必须使用管理工具指定覆盖。
num.partitions
num.partitions参数确定新主题创建时创建多少个分区,主要是在启用自动主题创建时(这是默认设置)使用。此参数默认为一个分区。请记住,主题的分区数量只能增加,不能减少。这意味着如果主题需要比num.partitions更少的分区,就需要小心地手动创建主题(在第十二章中讨论)。
如第一章中所述,分区是 Kafka 集群中扩展主题的方式,这使得使用能够平衡整个集群中的消息负载的分区计数变得重要,因为添加代理时会增加负载。许多用户将主题的分区计数设置为等于或是集群中代理数量的倍数。这样可以使分区均匀分布到代理中,从而均匀分布消息负载。例如,在 Kafka 集群中运行的具有 10 个分区的主题,如果有 10 个主机且领导权在所有 10 个主机之间平衡,将具有最佳吞吐量。然而,这并不是必须的,因为您也可以通过其他方式平衡消息负载,比如使用多个主题。
考虑到这一切,很明显您希望有很多分区,但不要太多。如果您对主题的目标吞吐量和消费者的预期吞吐量有一些估计,可以将目标吞吐量除以预期消费者吞吐量,以此确定分区数量。因此,如果我们希望能够从主题中写入和读取 1GBps,并且我们知道每个消费者只能处理 50MBps,那么我们知道至少需要 20 个分区。这样,我们可以有 20 个消费者从主题中读取,并实现 1GBps 的吞吐量。
如果您没有这些详细信息,我们的经验表明,将磁盘上的分区大小限制在每天不到 6GB 的保留量通常会产生令人满意的结果。从小开始,根据需要扩展比从大开始更容易。
default.replication.factor
如果启用了自动主题创建,此配置设置了新主题的复制因子应该是多少。复制策略可以根据集群的所需耐久性或可用性而变化,并将在后面的章节中进行更多讨论。如果您在集群中运行 Kafka,可以防止由 Kafka 内部能力之外的因素(如硬件故障)导致的故障,以下是一个简要建议。
强烈建议将复制因子设置为至少高于min.insync.replicas设置的 1。对于更具容错性的设置,如果您有足够大的集群和足够的硬件,将复制因子设置为高于min.insync.replicas的 2(简称为 RF++)可能更可取。RF++将使维护更容易,并防止停机。这个建议的原因是允许在副本集中同时发生一个计划内的停机和一个非计划内的停机。对于典型的集群,这意味着每个分区至少有三个副本。例如,如果在 Kafka 或底层操作系统的滚动部署或升级期间发生网络交换机故障、磁盘故障或其他非计划问题,您可以确保仍然有额外的副本可用。这将在第七章中进一步讨论。
日志保留时间(log.retention.ms)
Kafka 保留消息的最常见配置是按时间。默认值在配置文件中使用log.retention.hours参数指定,设置为 168 小时,即一周。然而,还允许使用另外两个参数,log.retention.minutes和log.retention.ms。所有这三个参数都控制相同的目标(消息可能被删除的时间),但建议使用的参数是log.retention.ms,因为如果指定了多个参数,较小的单位大小将优先。这将确保始终使用log.retention.ms设置的值。如果指定了多个参数,较小的单位大小将优先。
按时间和最后修改时间保留
按时间保留是通过检查磁盘上每个日志段文件的最后修改时间(mtime)来执行的。在正常的集群操作下,这是日志段关闭的时间,并代表文件中最后一条消息的时间戳。然而,当使用管理工具在代理之间移动分区时,这个时间是不准确的,会导致这些分区的过度保留。有关此信息,请参阅第十二章讨论分区移动。
日志保留字节数(log.retention.bytes)
另一种过期消息的方法是基于保留的消息总字节数。这个值是使用log.retention.bytes参数设置的,它是针对每个分区应用的。这意味着如果您有一个包含 8 个分区的主题,并且log.retention.bytes设置为 1GB,则主题保留的数据量最多为 8GB。请注意,所有保留都是针对单个分区执行的,而不是主题。这意味着如果主题的分区数量扩大,使用log.retention.bytes时保留也会增加。将值设置为-1 将允许无限保留。
按大小和时间配置保留
如果您同时为log.retention.bytes和log.retention.ms(或其他按时间保留的参数)指定了值,则在满足任一标准时可能会删除消息。例如,如果log.retention.ms设置为 86400000(1 天),而log.retention.bytes设置为 1000000000(1GB),如果一天内的消息总量大于 1GB,则可能会删除不到 1 天的消息。相反,如果总量小于 1GB,则即使分区的总大小小于 1GB,也可能在 1 天后删除消息。为了简单起见,建议选择基于大小或时间的保留方式,而不是两者兼用,以防止意外和不必要的数据丢失,但更高级的配置可以同时使用两者。
日志段字节数(log.segment.bytes)
先前提到的日志保留设置是针对日志段而不是单个消息的。当消息被生产到 Kafka 代理时,它们会附加到分区的当前日志段。一旦日志段达到由log.segment.bytes参数指定的大小(默认为 1GB),日志段将关闭并打开一个新的日志段。一旦日志段关闭,就可以考虑将其过期。较小的日志段大小意味着文件必须更频繁地关闭和分配,这会降低磁盘写入的整体效率。
如果主题的生产速率较低,则调整日志段的大小可能很重要。例如,如果一个主题每天只接收 100 兆字节的消息,并且log.segment.bytes设置为默认值,则需要 10 天才能填满一个段。由于消息直到日志段关闭后才能过期,如果log.retention.ms设置为 604800000(1 周),则实际上将保留多达 17 天的消息,直到关闭的日志段过期。这是因为一旦当前有 10 天的消息的日志段关闭,必须在根据时间策略过期之前保留该日志段 7 天(因为在最后一条消息过期之前无法删除该段)。
按时间戳检索偏移量
日志段的大小还会影响按时间戳获取偏移量的行为。当请求特定时间戳的分区偏移量时,Kafka 会找到在该时间正在写入的日志段文件。它通过使用文件的创建时间和最后修改时间来执行此操作,并寻找在指定时间戳之前创建并在指定时间戳之后最后修改的文件。响应中返回该日志段开头的偏移量(也是文件名)。
log.roll.ms
另一种控制日志段何时关闭的方法是使用log.roll.ms参数,该参数指定多长时间后应关闭日志段。与log.retention.bytes和log.retention.ms参数一样,log.segment.bytes和log.roll.ms不是互斥的属性。Kafka 将在达到大小限制或时间限制时关闭日志段,以先到者为准。默认情况下,没有log.roll.ms设置,这导致只按大小关闭日志段。
使用基于时间的日志段时的磁盘性能
当使用基于时间的日志段限制时,重要的是要考虑当多个日志段同时关闭时对磁盘性能的影响。当有许多分区从未达到日志段的大小限制时,会发生这种情况,因为时间限制的时钟将在代理启动时开始,并且对于这些低容量分区,它将始终在相同的时间执行。
min.insync.replicas
在为数据耐久性配置集群时,将min.insync.replicas设置为 2 可以确保至少有两个副本与生产者“同步”。这与将生产者配置设置为确认“所有”请求一起使用。这将确保至少有两个副本(领导者和另一个副本)确认写入才能成功。这可以防止数据丢失,例如领导者确认写入,然后发生故障并且领导权转移到没有成功写入的副本的情况。没有这些耐用的设置,生产者会认为它成功生产了,但消息会被丢弃和丢失。然而,配置更高的耐久性会导致效率降低,因为涉及额外的开销,因此不建议对可以容忍偶尔消息丢失的高吞吐量集群更改此设置。有关更多信息,请参见第七章。
message.max.bytes
Kafka 经纪限制了可以生成的消息的最大大小,由message.max.bytes参数配置,默认为 1000000,即 1MB。尝试发送大于此值的消息的生产者将从经纪那里收到错误,并且消息将不被接受。与经纪上指定的所有字节大小一样,此配置涉及压缩消息大小,这意味着生产者可以发送比此值大得多的未压缩消息,只要它们压缩到配置的message.max.bytes大小以下。
增加允许的消息大小会对性能产生明显影响。更大的消息意味着处理网络连接和请求的经纪线程将在每个请求上工作更长时间。更大的消息还会增加磁盘写入的大小,这将影响 I/O 吞吐量。其他存储解决方案,如 blob 存储和/或分层存储,可能是解决大容量磁盘写入问题的另一种方法,但本章不涉及这些内容。
协调消息大小配置
Kafka 经纪上配置的消息大小必须与消费者客户端上的fetch.message.max.bytes配置协调。如果这个值小于message.max.bytes,那么遇到更大消息的消费者将无法获取这些消息,导致消费者陷入僵局无法继续。当在集群中配置时,经纪上的replica.fetch.max.bytes配置也适用相同规则。
选择硬件
为 Kafka 经纪选择适当的硬件配置可能更多地是一门艺术而不是科学。Kafka 本身对特定硬件配置没有严格要求,并且在大多数系统上都可以正常运行。然而,一旦性能成为问题,有几个因素可能导致整体性能瓶颈:磁盘吞吐量和容量,内存,网络和 CPU。当扩展 Kafka 到非常大规模时,由于需要更新的元数据量,单个经纪可以处理的分区数量也可能受到限制。一旦确定了哪些性能类型对您的环境最为关键,您可以选择一个适合预算的优化硬件配置。
磁盘吞吐量
生产者客户端的性能将受到用于存储日志段的经纪磁盘吞吐量的直接影响。Kafka 消息在生成时必须提交到本地存储,大多数客户端将等待至少一个经纪确认消息已提交,然后才会考虑发送成功。这意味着更快的磁盘写入将等于更低的生成延迟。
在磁盘吞吐量方面的明显决定是选择传统的旋转硬盘驱动器(HDD)还是固态硬盘(SSD)。SSD 具有极低的搜索和访问时间,并提供最佳性能。另一方面,HDD 更经济,并且每单位提供更大的容量。您还可以通过在经纪中使用更多的 HDD 来提高性能,无论是通过拥有多个数据目录还是通过设置冗余独立磁盘阵列(RAID)配置来设置驱动器。其他因素,如特定的驱动器技术(例如串行附加存储或串行 ATA),以及驱动器控制器的质量,都会影响吞吐量。一般来说,观察表明 HDD 驱动器通常对于存储需求非常高但访问频率不高的集群更有用,而如果有大量客户端连接,则 SSD 是更好的选择。
磁盘容量
容量是存储讨论的另一面。所需的磁盘容量取决于任何时候需要保留多少消息。如果预计经纪人每天将接收 1TB 的流量,并且保留 7 天,那么经纪人将需要至少 7TB 的可用存储来存储日志段。您还应该考虑至少 10%的其他文件开销,以及您希望保持用于流量波动或随时间增长的任何缓冲区。
存储容量是确定 Kafka 集群规模和确定何时扩展的因素之一。通过为每个主题设置多个分区,可以在集群中平衡总流量,这将允许额外的经纪人增加可用容量,如果单个经纪人上的密度不够。对所需磁盘容量的决定也将受到为集群选择的复制策略的影响(在第七章中有更详细的讨论)。
内存
Kafka 消费者的正常操作模式是从分区的末尾读取,消费者赶上生产者并且滞后很少,如果有的话。在这种情况下,消费者正在读取的消息被最佳地存储在系统的页面缓存中,这会比经纪人不得不重新从磁盘读取消息时读取更快。因此,为系统提供更多的内存用于页面缓存将提高消费者客户端的性能。
Kafka 本身不需要为 Java 虚拟机(JVM)配置太多堆内存。即使处理每秒 15 万条消息和每秒 200 兆位的数据速率的经纪人也可以运行 5GB 堆。系统内存的其余部分将被页面缓存使用,并且通过允许系统缓存正在使用的日志段来使 Kafka 受益。这是不建议将 Kafka 与任何其他重要应用程序共存的主要原因,因为它将不得不共享页面缓存的使用。这将降低 Kafka 的消费者性能。
网络
可用的网络吞吐量将指定 Kafka 可以处理的最大流量。这可以是集群规模的决定性因素,结合磁盘存储。这受 Kafka 支持多个消费者所造成的入站和出站网络使用之间固有不平衡的影响。生产者可能为给定主题每秒写入 1MB,但可能有任意数量的消费者对出站网络使用量产生乘数效应。其他操作,如集群复制(在第七章中介绍)和镜像(在第十章中讨论),也会增加要求。如果网络接口变得饱和,集群复制落后是很常见的,这可能使集群处于脆弱状态。为防止网络成为主要决定因素,建议使用至少 10Gb 的网卡(网络接口卡)。老旧的配备 1Gb 网卡的机器很容易饱和,不建议使用。
CPU
在扩展 Kafka 之前,处理能力并不像磁盘和内存那样重要,但它会在一定程度上影响经纪人的整体性能。理想情况下,客户端应该压缩消息以优化网络和磁盘使用。然而,Kafka 经纪人必须解压所有消息批次,以验证各个消息的“校验和”并分配偏移量。然后,它需要重新压缩消息批次以将其存储在磁盘上。这就是 Kafka 对处理能力需求的大部分来源。然而,除非集群变得非常大,单个集群中有数百个节点和数百万个分区,否则这不应该是选择硬件的主要因素。在那时,选择性能更好的 CPU 可以帮助减少集群大小。
云中的 Kafka
近年来,Kafka 在云计算环境中的安装越来越普遍,例如微软 Azure、亚马逊的 AWS 或谷歌云平台。有许多选项可以在云中设置并通过供应商(如 Confluent)或甚至通过 Azure 自己的 HDInsight 上的 Kafka 进行管理,但以下是一些简单的建议,如果您计划手动管理自己的 Kafka 集群。在大多数云环境中,您可以选择许多计算实例,每个实例都具有不同的 CPU、内存、IOPS 和磁盘组合。必须优先考虑 Kafka 的各种性能特征,以选择正确的实例配置。
微软 Azure
在 Azure 中,您可以单独管理磁盘和虚拟机(VM),因此决定您的存储需求不需要与所选的 VM 类型相关。也就是说,决策的一个好的起点是所需的数据保留量,然后是生产者所需的性能。如果需要非常低的延迟,可能需要使用优化了 I/O 的实例,利用高级 SSD 存储。否则,托管存储选项(如 Azure 托管磁盘或 Azure Blob 存储)可能就足够了。
在实际情况中,Azure 的经验表明,“标准 D16s v3”实例类型对于较小的集群是一个不错的选择,并且对于大多数用例来说性能足够好。为了匹配高性能硬件和 CPU 需求,“D64s v4”实例具有良好的性能,可以扩展到更大的集群。建议在 Azure 可用性集中构建您的集群,并在 Azure 计算故障域之间平衡分区,以确保可用性。一旦选择了 VM,接下来可以决定存储类型。强烈建议使用 Azure 托管磁盘而不是临时磁盘。如果移动 VM,您可能会面临丢失 Kafka 经纪人上所有数据的风险。HDD 托管磁盘相对便宜,但微软对可用性没有明确定义的服务级别协议(SLA)。高级 SSD 或 Ultra SSD 配置要贵得多,但速度更快,并且得到了微软 99.99%的 SLA 支持。或者,如果对延迟不那么敏感,可以使用 Microsoft Blob 存储。
亚马逊网络服务
在 AWS 中,如果需要非常低的延迟,可能需要使用具有本地 SSD 存储的 I/O 优化实例。否则,临时存储(如 Amazon 弹性块存储)可能就足够了。
在 AWS 中,常见的选择是“m4”或“r3”实例类型。 “m4”将允许更长的保留期,但磁盘吞吐量会较低,因为它在弹性块存储上。 “r3”实例将具有更好的本地 SSD 驱动器吞吐量,但这些驱动器将限制可以保留的数据量。为了兼顾两者,可能需要升级到“i2”或“d2”实例类型,但它们的价格要高得多。
配置 Kafka 集群
单个 Kafka 经纪人适用于本地开发工作,或用于概念验证系统,但将多个经纪人配置为集群有显著的好处,如图 2-2 所示。最大的好处是能够跨多台服务器分配负载。其次是使用复制来防范由于单个系统故障而导致的数据丢失。复制还将允许在维护 Kafka 或底层系统时仍保持对客户端的可用性。本节重点介绍了配置 Kafka 基本集群的步骤。第七章包含有关数据复制和持久性的更多信息。

图 2-2:一个简单的 Kafka 集群
多少个经纪人?
Kafka 集群的适当大小由几个因素决定。通常,您的集群大小将受以下关键领域的限制:
-
磁盘容量
-
每个经纪人的副本容量
-
CPU 容量
-
网络容量
要考虑的第一个因素是保留消息所需的磁盘容量以及单个经纪人上可用的存储空间。如果集群需要保留 10 TB 的数据,单个经纪人可以存储 2 TB,那么最小的集群大小是 5 个经纪人。此外,增加复制因子将至少增加 100%的存储需求,具体取决于所选择的复制因子设置(请参阅第七章)。在这种情况下,副本指的是单个分区复制到的不同经纪人的数量。这意味着相同的集群,配置为复制 2,现在需要至少包含 10 个经纪人。
要考虑的另一个因素是集群处理请求的能力。这可以通过前面提到的其他三个瓶颈来展示。
如果您有一个包含 10 个经纪人的 Kafka 集群,但在您的集群中有超过 100 万个副本(即,具有复制因子 2 的 500,000 个分区),在均衡的情况下,每个经纪人承担大约 100,000 个副本。这可能导致生产、消费和控制器队列中的瓶颈。过去,官方建议是每个经纪人不超过 4,000 个分区副本,每个集群不超过 200,000 个分区副本。然而,集群效率的提高使得 Kafka 能够扩展得更大。目前,在配置良好的环境中,建议每个经纪人不要超过 14,000 个分区副本,每个集群不要超过 1 百万个副本。
如本章前面提到的,对于大多数用例来说,CPU 通常不是主要瓶颈,但如果经纪人上有过多的客户端连接和请求,它可能会成为瓶颈。根据有多少个唯一的客户端和消费者组,以及扩展以满足这些需求,可以帮助确保大型集群的更好性能。谈到网络容量,重要的是要考虑网络接口的容量,以及它们是否能够处理客户端流量,如果有多个数据的消费者,或者数据在保留期内的流量不一致(例如,在高峰时段的流量突发)。如果单个经纪人的网络接口在高峰时期使用了 80%的容量,并且有两个数据的消费者,那么除非有两个经纪人,否则消费者将无法跟上高峰期的流量。如果在集群中使用了复制,这是数据的另一个额外的消费者,必须考虑到。您可能还希望扩展到更多经纪人的集群,以处理由较低的磁盘吞吐量或系统可用内存引起的性能问题。
经纪人配置
在经纪人配置中,只有两个要求允许多个 Kafka 经纪人加入单个集群。第一个是所有经纪人必须对“zookeeper.connect”参数具有相同的配置。这指定了 ZooKeeper 集群和路径,集群在其中存储元数据。第二个要求是集群中的所有经纪人必须具有“broker.id”参数的唯一值。如果两个经纪人尝试使用相同的“broker.id”加入同一个集群,第二个经纪人将记录错误并无法启动。在运行集群时使用了其他配置参数,特别是控制复制的参数,这些将在后面的章节中介绍。
操作系统调优
虽然大多数 Linux 发行版都有适用于内核调优参数的开箱即用配置,对于 Kafka 经纪人,可以进行一些改变以提高性能。这些主要围绕虚拟内存和网络子系统以及用于存储日志段的磁盘挂载点的特定问题。这些参数通常在/etc/sysctl.conf文件中配置,但您应参考您的 Linux 发行版文档,了解如何调整内核配置的具体细节。
虚拟内存
一般来说,Linux 虚拟内存系统会自动调整以适应系统工作负载。我们可以对交换空间的处理方式以及脏内存页面进行一些调整,以调整这些内容以适应 Kafka 的工作负载。
与大多数应用程序一样,特别是对吞吐量有要求的应用程序,最好尽量避免交换。将内存页面交换到磁盘会导致 Kafka 在性能的各个方面都有明显的影响。此外,Kafka 大量使用系统页缓存,如果 VM 系统交换到磁盘,那么分配给页缓存的内存不足。
避免交换的一种方法就是根本不配置任何交换空间。拥有交换空间并不是一个要求,但如果系统发生灾难性事件,交换空间可以提供一个安全网。拥有交换空间可以防止操作系统因内存不足而突然终止进程。因此,建议将“vm.swappiness”参数设置为一个非常低的值,比如 1。该参数是 VM 子系统使用交换空间而不是从页缓存中丢弃页面的可能性的百分比。最好减少可用于页缓存的内存量,而不是利用任何交换内存。
为什么不将 Swappiness 设置为零?
以前,“vm.swappiness”的建议总是将其设置为 0。这个值曾经意味着“除非出现内存不足的情况,否则不进行交换”。然而,随着 Linux 内核版本 3.5-rc1 的改变,这个值的含义发生了变化,并且这个改变被反向移植到了许多发行版中,包括 Red Hat 企业 Linux 内核版本 2.6.32-303。这改变了值 0 的含义为“在任何情况下都不进行交换”。这就是为什么现在建议使用值 1。
调整内核处理必须刷新到磁盘的脏页的方式也有好处。Kafka 依赖于磁盘 I/O 性能来为生产者提供良好的响应时间。这也是日志段通常放在快速磁盘上的原因,无论是具有快速响应时间的单独磁盘(例如 SSD)还是具有大量 NVRAM 用于缓存的磁盘子系统(例如 RAID)。结果是,在后台刷新进程开始将脏页写入磁盘之前允许的脏页数量可以减少。通过将vm.dirty_background_ratio的值设置为低于默认值 10 来实现。该值是系统内存总量的百分比,将该值设置为 5 在许多情况下是合适的。但是,不应将此设置为零,因为这将导致内核不断刷新页面,从而消除内核对磁盘写入的缓冲,以应对底层设备性能的暂时性波动。
在内核强制同步操作将脏页刷新到磁盘之前允许的脏页总数也可以通过将vm.dirty_ratio的值更改为默认值 20 以上(也是总系统内存的百分比)来增加。对于这个设置,有很多可能的值,但在 60 到 80 之间是一个合理的数字。这个设置确实会引入一定的风险,无论是未刷新的磁盘活动量还是强制同步刷新可能导致的长时间 I/O 暂停。如果选择更高的vm.dirty_ratio设置,强烈建议在 Kafka 集群中使用复制来防范系统故障。
在选择这些参数的值时,明智的做法是在 Kafka 集群在负载下运行时(无论是在生产环境还是模拟环境下)随时间审查脏页的数量。当前的脏页数量可以通过检查/proc/vmstat文件来确定:
# cat /proc/vmstat | egrep "dirty|writeback"
nr_dirty 21845
nr_writeback 0
nr_writeback_temp 0
nr_dirty_threshold 32715981
nr_dirty_background_threshold 2726331
#
Kafka 使用文件描述符来跟踪日志段和打开的连接。如果一个代理有很多分区,那么该代理至少需要(分区数)×(分区大小/段大小)来跟踪所有日志段,另外还需要跟踪代理建立的连接数。因此,建议根据上述计算将vm.max_map_count更新为一个非常大的数字。根据环境的不同,将这个值更改为 400,000 或 600,000 通常是成功的。还建议将vm.overcommit_memory设置为 0。将默认值设置为 0 表示内核从应用程序确定空闲内存的数量。如果将属性设置为非零值,可能会导致操作系统获取过多的内存,从而剥夺 Kafka 进行最佳操作所需的内存。这对于具有高摄入速率的应用程序是常见的。
磁盘
除了选择磁盘设备硬件以及如果使用 RAID 则配置 RAID 之外,为该磁盘选择文件系统可能对性能产生更大的影响。有许多不同的文件系统可用,但本地文件系统的最常见选择要么是 Ext4(第四个扩展文件系统),要么是 Extents 文件系统(XFS)。 XFS 已成为许多 Linux 发行版的默认文件系统,这是有充分理由的:它在大多数工作负载下的性能优于 Ext4,而且几乎不需要进行调整。 Ext4 可以表现良好,但需要使用被认为不太安全的调整参数。这包括将提交间隔设置为比默认值五更长的时间,以强制较少的刷新。 Ext4 还引入了块的延迟分配,这增加了数据丢失和文件系统损坏的风险,以防系统故障。 XFS 文件系统也使用延迟分配算法,但通常比 Ext4 使用的算法更安全。 XFS 在 Kafka 的工作负载下也具有更好的性能,而无需进行文件系统执行的自动调整之外的调整。在批处理磁盘写入时,它也更有效,所有这些都结合在一起,提供更好的整体 I/O 吞吐量。
无论选择哪种文件系统用于保存日志段的挂载点,建议为挂载点设置noatime挂载选项。文件元数据包含三个时间戳:创建时间(ctime),上次修改时间(mtime)和上次访问时间(atime)。默认情况下,每次读取文件时都会更新atime。这会产生大量的磁盘写入。atime属性通常被认为没有什么用,除非应用程序需要知道文件是否自上次修改以来已被访问(在这种情况下可以使用relatime选项)。 Kafka 根本不使用atime,因此禁用它是安全的。在挂载点上设置noatime将阻止这些时间戳的更新,但不会影响ctime和mtime属性的正确处理。使用largeio选项还可以帮助提高 Kafka 的效率,特别是在进行更大的磁盘写入时。
网络
调整 Linux 网络堆栈的默认调整对于任何产生大量网络流量的应用程序都很常见,因为内核默认情况下未针对大型高速数据传输进行调整。实际上,Kafka 的推荐更改与大多数 Web 服务器和其他网络应用程序建议的更改相同。第一个调整是更改为每个套接字分配的发送和接收缓冲区的默认和最大内存量。这将显着提高大型传输的性能。每个套接字的发送和接收缓冲区默认大小的相关参数是net.core.wmem_default和net.core.rmem_default,这些参数的合理设置是 131072,或 128 KiB。发送和接收缓冲区的最大大小的参数是net.core.wmem_max和net.core.rmem_max,这些参数的合理设置是 2097152,或 2 MiB。请记住,最大大小并不表示每个套接字都会分配这么多的缓冲区空间;它只允许在需要时分配多达这么多的空间。
除了套接字设置之外,TCP 套接字的发送和接收缓冲区大小必须使用net.ipv4.tcp_wmem和net.ipv4.tcp_rmem参数分别设置。这些参数使用三个以空格分隔的整数来指定最小、默认和最大大小。最大大小不能大于使用net.core.wmem_max和net.core.rmem_max设置的所有套接字的值。每个参数的示例设置是“4096 65536 2048000”,这是 4 KiB 最小,64 KiB 默认和 2 MiB 最大缓冲区。根据 Kafka 经纪人的实际工作负载,您可能希望增加最大大小,以允许更大的网络连接缓冲。
有几个其他网络调优参数是有用的。通过将net.ipv4.tcp_window_scaling设置为 1 来启用 TCP 窗口缩放将允许客户端更有效地传输数据,并允许数据在代理端进行缓冲。将net.ipv4.tcp_max_syn_backlog的值增加到默认值 1024 以上将允许更多的同时连接被接受。将net.core.netdev_max_backlog的值增加到默认值 1000 以上可以在网络流量突发时提供帮助,特别是在使用多千兆网络连接速度时,通过允许更多的数据包排队等待内核处理它们。
生产方面的考虑
一旦您准备将 Kafka 环境从测试中移出并投入到生产运营中,还有一些需要考虑的事项,这将有助于建立可靠的消息服务。
垃圾收集器选项
调整应用程序的 Java 垃圾收集选项一直是一种艺术,需要详细了解应用程序如何使用内存以及大量的观察和试错。幸运的是,随着 Java 7 和 Garbage-First 垃圾收集器(G1GC)的引入,情况已经改变。虽然最初 G1GC 被认为不稳定,但在 JDK8 和 JDK11 中有了显著改进。现在建议 Kafka 使用 G1GC 作为默认的垃圾收集器。G1GC 旨在自动调整不同的工作负载,并在应用程序的生命周期内提供一致的垃圾收集暂停时间。它还通过将堆分成较小的区域并不在每次暂停中收集整个堆来轻松处理大堆大小。
G1GC 在正常操作中只需进行最少量的配置。有两个用于调整其性能的 G1GC 配置选项:
MaxGCPauseMillis
此选项指定每个垃圾回收周期的首选暂停时间。这不是一个固定的最大值——如果需要,G1GC 可以超过这个时间。默认值为 200 毫秒。这意味着 G1GC 将尝试安排垃圾收集器周期的频率,以及在每个周期中收集的区域数量,以便每个周期大约需要 200 毫秒。
InitiatingHeapOccupancyPercent
此选项指定在 G1GC 启动收集周期之前可以使用的堆总量的百分比。默认值为 45。这意味着 G1GC 在堆使用 45%之后才会启动收集周期。这包括新(Eden)和旧区域的使用总量。
Kafka 代理在利用堆内存和创建垃圾对象的方式上相当高效,因此可以将这些选项设置得更低。本节提供的垃圾收集器调优选项已被证明适用于具有 64GB 内存的服务器,在 5GB 堆中运行 Kafka。对于MaxGCPauseMillis,该代理可以配置为 20 毫秒的值。InitiatingHeapOccupancyPercent的值设置为 35,这会导致垃圾收集比默认值稍早地运行。
Kafka 最初发布时 G1GC 收集器尚不可用且不稳定。因此,Kafka 默认使用并发标记和扫描垃圾回收以确保与所有 JVM 的兼容性。新的最佳实践是对于 Java 1.8 及更高版本使用 G1GC。通过环境变量很容易进行更改。使用本章前面的start命令,修改如下:
# export KAFKA_JVM_PERFORMANCE_OPTS="-server -Xmx6g -Xms6g
-XX:MetaspaceSize=96m -XX:+UseG1GC
-XX:MaxGCPauseMillis=20 -XX:InitiatingHeapOccupancyPercent=35
-XX:G1HeapRegionSize=16M -XX:MinMetaspaceFreeRatio=50
-XX:MaxMetaspaceFreeRatio=80 -XX:+ExplicitGCInvokesConcurrent"
# /usr/local/kafka/bin/kafka-server-start.sh -daemon
/usr/local/kafka/config/server.properties
#
数据中心布局
对于测试和开发环境来说,Kafka 经纪人在数据中心的物理位置并不是很重要,因为如果集群在短时间内部分或完全不可用,影响就不会那么严重。然而,在生产流量服务时,停机通常意味着损失,无论是因为用户服务的丢失还是因为对用户活动的遥测数据的丢失。这时就变得至关重要配置 Kafka 集群内的复制(参见第七章),同时也要考虑经纪人在数据中心机架中的物理位置。最好选择具有故障区域概念的数据中心环境。如果在部署 Kafka 之前没有解决这个问题,可能需要进行昂贵的维护来移动服务器。
Kafka 可以以机架感知的方式将新分区分配给经纪人,确保单个分区的副本不共享一个机架。为此,必须正确设置每个经纪人的broker.rack配置。出于类似的原因,在云环境中也可以将此配置设置为故障域。但是,这仅适用于新创建的分区。Kafka 集群不会监视不再具有机架感知的分区(例如,由于分区重新分配而导致的情况),也不会自动纠正这种情况。建议使用工具来保持集群平衡,以保持机架感知,例如 Cruise Control(参见附录 B)。正确配置这一点将有助于确保随着时间的推移继续保持机架感知。
总的来说,最佳实践是将集群中的每个 Kafka 经纪人安装在不同的机架上,或者至少不共享基础设施服务的单点故障,如电源和网络。这通常意味着至少部署将运行经纪人的服务器具有双电源连接(连接到两个不同的电路)和双网络交换机(服务器本身具有绑定接口以实现无缝故障转移)。即使有双重连接,将经纪人放在完全不同的机架中也是有益的。不时需要对机架或机柜进行物理维护,这可能需要将其脱机(例如移动服务器或重新布线电源连接)。
将应用程序放置在 ZooKeeper 上
Kafka 利用 ZooKeeper 存储有关经纪人、主题和分区的元数据信息。对 ZooKeeper 的写入仅在消费者组的成员资格发生变化或 Kafka 集群本身发生变化时才执行。这种流量通常很小,不足以为单个 Kafka 集群使用专用的 ZooKeeper 集合。事实上,许多部署将为多个 Kafka 集群使用单个 ZooKeeper 集合(对于每个集群使用 chroot ZooKeeper 路径,如本章前面所述)。
Kafka 消费者、工具、ZooKeeper 和您
随着时间的推移,对 ZooKeeper 的依赖正在减少。在 2.8.0 版本中,Kafka 引入了一个早期版本的完全无 ZooKeeper 的 Kafka,但它仍未达到生产就绪状态。然而,在此之前的版本中,我们仍然可以看到对 ZooKeeper 依赖的减少。例如,在较早的 Kafka 版本中,消费者(除了经纪人)利用 ZooKeeper 直接存储有关消费者组成和正在消费的主题的信息,并定期提交每个正在消费的分区的偏移量(以实现组内消费者之间的故障转移)。从 0.9.0.0 版本开始,消费者接口发生了变化,允许直接由 Kafka 经纪人管理这些内容。在每个 Kafka 的 2.x 版本中,我们看到了进一步的步骤,以从 Kafka 的其他必需路径中删除 ZooKeeper。管理工具现在直接连接到集群,并已经废弃了直接连接到 ZooKeeper 进行主题创建、动态配置更改等操作的需要。因此,许多先前使用--zookeeper标志的命令行工具已经更新为使用--bootstrap-server选项。--zookeeper选项仍然可以使用,但已被废弃,并将在未来删除,当 Kafka 不再需要连接到 ZooKeeper 来创建、管理或从主题中消费时。
然而,在某些配置下,消费者和 ZooKeeper 存在一些问题。虽然使用 ZooKeeper 进行此类目的已经被废弃,但消费者可以配置选择使用 ZooKeeper 或 Kafka 来提交偏移量,并且还可以配置提交之间的间隔。如果消费者使用 ZooKeeper 来提交偏移量,每个消费者将在每个间隔内为其消费的每个分区执行 ZooKeeper 写入。偏移量提交的合理间隔是 1 分钟,因为这是消费者组在消费者故障的情况下读取重复消息的时间段。这些提交可能会产生大量的 ZooKeeper 流量,特别是在具有许多消费者的集群中,需要考虑到这一点。如果 ZooKeeper 集群无法处理流量,可能需要使用更长的提交间隔。然而,建议使用最新的 Kafka 库的消费者使用 Kafka 来提交偏移量,消除对 ZooKeeper 的依赖。
除了将单个集群用于多个 Kafka 集群之外,如果可以避免,不建议将集群与其他应用程序共享。Kafka 对 ZooKeeper 的延迟和超时非常敏感,与集群的通信中断将导致经纪人的行为变得不可预测。这很容易导致多个经纪人同时下线,如果它们失去 ZooKeeper 连接,将导致离线分区。这也会给集群控制器带来压力,这可能会在中断过后的很长时间内显示出微妙的错误,例如在尝试对经纪人执行受控关闭时。其他应用程序可能会通过重度使用或不当操作对 ZooKeeper 集群施加压力,应将其隔离到自己的集群中。
摘要
在本章中,我们学习了如何启动和运行 Apache Kafka。我们还涵盖了为经纪人选择合适的硬件以及在生产环境中设置的特定问题。现在您已经有了一个 Kafka 集群,我们将介绍 Kafka 客户端应用程序的基础知识。接下来的两章将介绍如何为生产消息到 Kafka(第三章)以及再次消费这些消息(第四章)创建客户端。
第三章:Kafka 生产者:向 Kafka 写入消息
无论您将 Kafka 用作队列、消息总线还是数据存储平台,您始终会通过创建一个将数据写入 Kafka 的生产者、一个从 Kafka 读取数据的消费者或一个同时扮演这两个角色的应用程序来使用 Kafka。
例如,在信用卡交易处理系统中,可能会有一个客户端应用程序,例如在线商店,负责在付款时立即将每笔交易发送到 Kafka。另一个应用程序负责立即将此交易与规则引擎进行检查,并确定交易是否被批准或拒绝。批准/拒绝响应然后可以写回 Kafka,并且响应可以传播回发起交易的在线商店。第三个应用程序可以从 Kafka 中读取交易和批准状态,并将它们存储在分析师稍后可以审查决策并可能改进规则引擎的数据库中。
Apache Kafka 附带了内置的客户端 API,开发人员在开发与 Kafka 交互的应用程序时可以使用这些 API。
在本章中,我们将学习如何使用 Kafka 生产者,首先概述其设计和组件。我们将展示如何创建KafkaProducer和ProducerRecord对象,如何将记录发送到 Kafka,以及如何处理 Kafka 可能返回的错误。然后,我们将回顾用于控制生产者行为的最重要的配置选项。最后,我们将深入了解如何使用不同的分区方法和序列化程序,以及如何编写自己的序列化程序和分区器。
在第四章中,我们将看一下 Kafka 的消费者客户端和从 Kafka 读取数据。
第三方客户端
除了内置的客户端,Kafka 还具有二进制的传输协议。这意味着应用程序可以通过向 Kafka 的网络端口发送正确的字节序列来从 Kafka 读取消息或向 Kafka 写入消息。有多个客户端在不同的编程语言中实现了 Kafka 的传输协议,为使用 Kafka 提供了简单的方式,不仅可以在 Java 应用程序中使用 Kafka,还可以在 C++、Python、Go 等语言中使用。这些客户端不是 Apache Kafka 项目的一部分,但非 Java 客户端的列表在项目维基中进行了维护。传输协议和外部客户端不在本章的范围内。
生产者概述
应用程序可能需要将消息写入 Kafka 的原因有很多:记录用户活动以进行审计或分析,记录指标,存储日志消息,记录智能设备的信息,与其他应用程序异步通信,在写入数据库之前缓冲信息等等。
这些不同的用例也意味着不同的要求:每条消息都很重要吗,还是我们可以容忍消息的丢失?我们可以意外复制消息吗?我们需要支持任何严格的延迟或吞吐量要求吗?
在我们之前介绍的信用卡交易处理示例中,我们可以看到绝对不能丢失任何一条消息或重复任何消息是至关重要的。延迟应该很低,但可以容忍高达 500 毫秒的延迟,吞吐量应该非常高-我们预计每秒处理高达一百万条消息。
不同的用例可能是存储来自网站的点击信息。在这种情况下,可以容忍一些消息丢失或少量重复;延迟可以很高,只要不影响用户体验。换句话说,如果消息需要几秒钟才能到达 Kafka,只要用户点击链接后下一页立即加载即可。吞吐量将取决于我们预期在网站上的活动水平。
不同的要求将影响您使用生产者 API 向 Kafka 写入消息的方式以及您使用的配置。
虽然生产者 API 非常简单,但在发送数据时,在生产者的幕后会发生更多事情。图 3-1 显示了发送数据到 Kafka 涉及的主要步骤。

图 3-1:Kafka 生产者组件的高级概述
我们通过创建ProducerRecord开始向 Kafka 生产消息,其中必须包括我们要将记录发送到的主题和一个值。可选地,我们还可以指定一个键、一个分区、一个时间戳和/或一组标头。一旦我们发送ProducerRecord,生产者将首先将键和值对象序列化为字节数组,以便可以通过网络发送。
接下来,如果我们没有明确指定分区,数据将被发送到分区器。分区器将为我们选择一个分区,通常基于ProducerRecord键。一旦选择了分区,生产者就知道记录将要发送到哪个主题和分区。然后,它将记录添加到一批记录中,这些记录也将发送到相同的主题和分区。一个单独的线程负责将这些记录批次发送到适当的 Kafka 代理。
当代理接收到消息时,它会发送回一个响应。如果消息成功写入 Kafka,它将返回一个带有记录所在主题、分区和偏移量的RecordMetadata对象。如果代理未能写入消息,它将返回一个错误。当生产者收到错误时,它可能会在放弃并返回错误之前尝试重新发送消息几次。
构建 Kafka 生产者
向 Kafka 写入消息的第一步是创建一个具有要传递给生产者的属性的生产者对象。Kafka 生产者有三个必填属性:
bootstrap.servers
代理将用于建立与 Kafka 集群的初始连接的host:port对列表。此列表不需要包括所有代理,因为生产者在初始连接后会获取更多信息。但建议至少包括两个,这样如果一个代理宕机,生产者仍然能够连接到集群。
key.serializer
将用于将我们将要生产到 Kafka 的记录的键序列化的类的名称。Kafka 代理期望消息的键和值为字节数组。但是,生产者接口允许使用参数化类型,将任何 Java 对象作为键和值发送。这使得代码非常易读,但也意味着生产者必须知道如何将这些对象转换为字节数组。key.serializer应设置为实现org.apache.kafka.common.serialization.Serializer接口的类的名称。生产者将使用此类将键对象序列化为字节数组。Kafka 客户端包括ByteArraySerializer(几乎不做任何事情)、StringSerializer、IntegerSerializer等等,因此如果使用常见类型,则无需实现自己的序列化程序。即使您只打算发送值,也需要设置key.serializer,但是您可以使用Void类型作为键和VoidSerializer。
value.serializer
将用于将我们将要生产到 Kafka 的记录的值序列化的类的名称。与设置key.serializer的方式相同,将value.serializer设置为将序列化消息值对象的类的名称。
以下代码片段显示了如何通过仅设置必填参数并对其他所有内容使用默认值来创建新的生产者:
Properties kafkaProps = new Properties(); // ①
kafkaProps.put("bootstrap.servers", "broker1:9092,broker2:9092");
kafkaProps.put("key.serializer",
"org.apache.kafka.common.serialization.StringSerializer"); // ②
kafkaProps.put("value.serializer",
"org.apache.kafka.common.serialization.StringSerializer");
producer = new KafkaProducer<String, String>(kafkaProps); // ③
①
我们从一个Properties对象开始。
②
由于我们打算使用字符串作为消息的键和值,我们使用内置的StringSerializer。
③
在这里,我们通过设置适当的键和值类型并传递Properties对象来创建一个新的生产者。
通过这样一个简单的接口,很明显大部分对生产者行为的控制是通过设置正确的配置属性来完成的。Apache Kafka 文档涵盖了所有的配置选项,我们稍后会在本章中讨论重要的选项。
一旦我们实例化了一个生产者,就是发送消息的时候了。有三种主要的发送消息的方法:
发送并忘
我们向服务器发送一条消息,实际上并不在乎它是否成功到达。大多数情况下,它会成功到达,因为 Kafka 是高度可用的,生产者会自动重试发送消息。然而,在不可重试的错误或超时的情况下,消息将丢失,应用程序将不会得到任何关于此的信息或异常。
同步发送
从技术上讲,Kafka 生产者总是异步的——我们发送一条消息,send()方法返回一个Future对象。然而,我们使用get()来等待Future,看看send()是否成功发送了下一条记录。
异步发送
我们调用send()方法并陦用一个回调函数,当它从 Kafka 代理接收到响应时会触发。
在接下来的示例中,我们将看到如何使用这些方法发送消息以及如何处理可能发生的不同类型的错误。
虽然本章中的所有示例都是单线程的,但生产者对象可以被多个线程使用来发送消息。
发送消息到 Kafka
发送消息的最简单方法如下:
ProducerRecord<String, String> record =
new ProducerRecord<>("CustomerCountry", "Precision Products",
"France"); // ①
try {
producer.send(record); // ②
} catch (Exception e) {
e.printStackTrace(); // ③
}
①
生产者接受ProducerRecord对象,因此我们首先创建一个。ProducerRecord有多个构造函数,我们稍后会讨论。这里我们使用一个需要发送数据的主题名称(始终为字符串)以及我们要发送到 Kafka 的键和值的构造函数,这些键和值在这种情况下也是字符串。键和值的类型必须与我们的key serializer和value serializer对象匹配。
②
我们使用生产者对象的send()方法来发送ProducerRecord。正如我们在图 3-1 中看到的生产者架构图中一样,消息将被放入缓冲区,并将在单独的线程中发送到代理。send()方法返回一个带有RecordMetadata的Java Future对象,但由于我们简单地忽略了返回值,我们无法知道消息是否成功发送。这种发送消息的方法可以在默默丢弃消息时使用。这在生产应用中通常不是这种情况。
③
虽然我们忽略了发送消息到 Kafka 代理或代理本身可能发生的错误,但如果生产者在发送消息到 Kafka 之前遇到错误,我们仍然可能会得到异常。例如,当无法序列化消息时会出现SerializationException,如果缓冲区已满会出现BufferExhaustedException或TimeoutException,或者如果发送线程被中断会出现InterruptException。
同步发送消息
同步发送消息很简单,但仍允许生产者在 Kafka 响应生产请求时出现错误或发送重试次数耗尽时捕获异常。涉及的主要权衡是性能。根据 Kafka 集群的繁忙程度,代理可能需要 2 毫秒到几秒钟的时间来响应生产请求。如果您同步发送消息,发送线程将花费这段时间等待,不做其他任何事情,甚至不发送其他消息。这会导致性能非常差,因此通常不会在生产应用程序中使用同步发送(但在代码示例中非常常见)。
同步发送消息的最简单方法如下:
ProducerRecord<String, String> record =
new ProducerRecord<>("CustomerCountry", "Precision Products", "France");
try {
producer.send(record).get(); // ①
} catch (Exception e) {
e.printStackTrace(); // ②
}
①
在这里,我们使用Future.get()来等待 Kafka 的回复。如果记录未成功发送到 Kafka,此方法将抛出异常。如果没有错误,我们将获得一个RecordMetadata对象,可以用它来检索消息写入的偏移量和其他元数据。
②
如果在发送记录到 Kafka 之前或期间出现任何错误,我们将遇到异常。在这种情况下,我们只需打印我们遇到的任何异常。
KafkaProducer有两种类型的错误。可重试错误是可以通过重新发送消息来解决的错误。例如,连接错误可以解决,因为连接可能会重新建立。当为分区选举新领导者并刷新客户端元数据时,“非分区领导者”错误可以解决。KafkaProducer可以配置为自动重试这些错误,因此应用程序代码只有在重试次数耗尽且错误未解决时才会收到可重试的异常。有些错误不会通过重试解决,例如“消息大小过大”。在这些情况下,KafkaProducer不会尝试重试,并将立即返回异常。
异步发送消息
假设我们的应用程序与 Kafka 集群之间的网络往返时间为 10 毫秒。如果我们在发送每条消息后等待回复,发送 100 条消息将花费大约 1 秒钟。另一方面,如果我们只发送所有消息而不等待任何回复,那么发送 100 条消息几乎不需要任何时间。在大多数情况下,我们确实不需要回复,Kafka 在写入记录后会发送主题、分区和偏移量,通常发送应用程序不需要。另一方面,我们确实需要知道何时无法完全发送消息,以便我们可以抛出异常、记录错误,或者将消息写入“错误”文件以供以后分析。
要异步发送消息并仍然处理错误情况,生产者支持在发送记录时添加回调。以下是我们如何使用回调的示例:
private class DemoProducerCallback implements Callback { // ①
@Override
public void onCompletion(RecordMetadata recordMetadata, Exception e) {
if (e != null) {
e.printStackTrace(); // ②
}
}
}
ProducerRecord<String, String> record =
new ProducerRecord<>("CustomerCountry", "Biomedical Materials", "USA"); // ③
producer.send(record, new DemoProducerCallback()); // ④
①
要使用回调,您需要一个实现org.apache.kafka.clients.producer.Callback接口的类,该接口具有一个函数onCompletion()。
②
如果 Kafka 返回错误,onCompletion()将有一个非空异常。在这里,我们通过打印来“处理”它,但生产代码可能会有更健壮的错误处理函数。
③
记录与以前相同。
④
并在发送记录时传递一个Callback对象。
警告
回调在生产者的主线程中执行。这保证了当我们连续向同一分区发送两条消息时,它们的回调将按照我们发送它们的顺序执行。但这也意味着回调应该相当快,以避免延迟生产者并阻止其他消息的发送。不建议在回调中执行阻塞操作。相反,您应该使用另一个线程并发执行任何阻塞操作。
配置生产者
到目前为止,我们对生产者的配置参数很少——只有强制的bootstrap.servers URI 和序列化器。
生产者有大量的配置参数,这些参数在Apache Kafka 文档中有记录,许多参数都有合理的默认值,因此没有理由去调整每个参数。然而,一些参数对生产者的内存使用、性能和可靠性有重大影响。我们将在这里进行审查。
client.id
client.id是客户端和所使用的应用程序的逻辑标识符。这可以是任何字符串,并将被经纪人用于识别从客户端发送的消息。它用于日志记录和指标以及配额。选择一个好的客户端名称将使故障排除变得更容易——这是“我们看到 IP 104.27.155.134 的身份验证失败率很高”和“看起来订单验证服务无法进行身份验证——你能让劳拉来看一下吗?”之间的区别。
acks
acks参数控制生产者在可以考虑写入成功之前必须接收记录的分区副本数量。默认情况下,Kafka 将在领导者接收记录后回复记录已成功写入(预计 Apache Kafka 的 3.0 版本将更改此默认值)。此选项对写入消息的持久性有重大影响,根据您的用例,可能默认值不是最佳选择。第七章深入讨论了 Kafka 的可靠性保证,但现在让我们回顾一下acks参数的三个允许值:
acks=0
生产者在假定消息成功发送之前不会等待经纪人的回复。这意味着如果出现问题,经纪人没有收到消息,生产者将不会知道,消息将丢失。然而,由于生产者不等待服务器的任何响应,它可以以网络支持的速度发送消息,因此可以使用此设置来实现非常高的吞吐量。
acks=1
生产者将在领导者副本接收到消息时从经纪人那里收到成功响应。如果消息无法写入领导者(例如,如果领导者崩溃并且尚未选举出新的领导者),生产者将收到错误响应,并可以重试发送消息,避免数据的潜在丢失。如果领导者崩溃并且最新的消息尚未复制到新的领导者,消息仍可能丢失。
acks=all
一旦所有同步副本接收到消息,生产者将从经纪人那里收到成功响应。这是最安全的模式,因为您可以确保不止一个经纪人收到了消息,并且即使发生崩溃,消息也会存活下来(有关此信息的更多信息,请参见第六章)。然而,我们在acks=1情况下讨论的延迟将更高,因为我们将等待不止一个经纪人接收消息。
提示
您会发现,使用较低且不太可靠的acks配置,生产者将能够更快地发送记录。这意味着您在可靠性和生产者延迟之间进行权衡。但是,端到端延迟是从记录生成到可供消费者读取的时间,并且对于所有三个选项都是相同的。原因是,为了保持一致性,Kafka 不会允许消费者读取记录,直到它们被写入所有同步副本。因此,如果您关心端到端延迟,而不仅仅是生产者延迟,那么就没有权衡可做:如果选择最可靠的选项,您将获得相同的端到端延迟。
消息传递时间
生产者具有多个配置参数,这些参数相互作用以控制开发人员最感兴趣的行为之一:直到send()调用成功或失败需要多长时间。这是我们愿意花费的时间,直到 Kafka 成功响应,或者我们愿意放弃并承认失败。
多年来,配置及其行为已经多次修改。我们将在这里描述最新的实现,即 Apache Kafka 2.1 中引入的实现。
自 Apache Kafka 2.1 以来,我们将发送ProduceRecord的时间分为两个分别处理的时间间隔:
-
从调用
send()的异步调用返回的时间。在此期间,调用send()的线程将被阻塞。 -
从异步调用
send()成功返回直到触发回调(成功或失败)的时间。这与从将ProduceRecord放入批处理以进行发送直到 Kafka 以成功、不可重试的失败或我们用于发送的时间用完为止是相同的。
注意
如果您同步使用send(),发送线程将连续阻塞两个时间间隔,并且您将无法知道每个时间间隔花费了多少时间。我们将讨论常见和推荐的情况,即异步使用send(),并带有回调。
生产者内部数据流以及不同配置参数如何相互影响的流程可以在图 3-2 中总结。¹

图 3-2:Kafka 生产者内传递时间分解的序列图
我们将介绍用于控制在这两个时间间隔中等待的时间以及它们如何相互作用的不同配置参数。
max.block.ms
此参数控制在调用send()时和通过partitionsFor()显式请求元数据时,生产者可能阻塞的时间。当生产者的发送缓冲区已满或元数据不可用时,这些方法可能会阻塞。当达到max.block.ms时,将抛出超时异常。
delivery.timeout.ms
此配置将限制从记录准备发送(send()成功返回并将记录放入批处理)的时间,直到经纪人响应或客户端放弃,包括重试所花费的时间。如您在图 3-2 中所见,此时间应大于linger.ms和request.timeout.ms。如果尝试使用不一致的超时配置创建生产者,将会收到异常。消息可以成功发送的速度远快于delivery.timeout.ms,通常会更快。
如果生产者在重试时超过delivery.timeout.ms,则回调将使用与重试前经纪人返回的错误对应的异常进行调用。如果在记录批次仍在等待发送时超过delivery.timeout.ms,则回调将使用超时异常进行调用。
提示
您可以将交付超时配置为您希望等待消息发送的最长时间,通常是几分钟,然后保持默认的重试次数(几乎是无限的)。使用这个配置,只要有时间继续尝试(或者直到成功),生产者将一直重试。这是一个更合理的重试方式。我们通常调整重试的过程是:“在发生代理崩溃的情况下,通常需要 30 秒才能完成领导者选举,所以让我们保持重试 120 秒,以防万一。”而不是将这种心理对话转化为重试次数和重试之间的时间,您只需将“deliver.timeout.ms”配置为 120。
request.timeout.ms
这个参数控制生产者在发送数据时等待服务器回复的时间。请注意,这是在每个生产者请求等待回复的时间,而不包括重试、发送前的等待等。如果超时而没有回复,生产者将要么重试发送,要么用“TimeoutException”完成回调。
重试和 retry.backoff.ms
当生产者从服务器收到错误消息时,错误可能是暂时的(例如,分区没有领导者)。在这种情况下,“重试”参数的值将控制生产者在放弃并通知客户端出现问题之前重试发送消息的次数。默认情况下,生产者在重试之间会等待 100 毫秒,但您可以使用“retry.backoff.ms”参数来控制这一点。
我们建议不要在当前版本的 Kafka 中使用这些参数。相反,测试从崩溃的代理中恢复需要多长时间(即直到所有分区获得新领导者),并设置“delivery.timeout.ms”,使得重试的总时间长于 Kafka 集群从崩溃中恢复所需的时间——否则,生产者会放弃得太早。
并非所有的错误都会被生产者重试。一些错误不是暂时的,不会导致重试(例如,“消息过大”错误)。一般来说,因为生产者为您处理重试,所以在您自己的应用逻辑中处理重试是没有意义的。您将希望将精力集中在处理不可重试的错误或重试尝试耗尽的情况上。
提示
如果您想完全禁用重试,将“retries=0”设置为唯一的方法。
linger.ms
“linger.ms”控制在发送当前批次之前等待额外消息的时间。默认情况下,生产者会在当前批次已满或达到“linger.ms”限制时发送消息。默认情况下,只要有发送线程可用来发送消息,生产者就会立即发送消息,即使批次中只有一条消息。通过将“linger.ms”设置为大于 0,我们指示生产者在将批次发送到代理之前等待几毫秒以添加额外的消息到批次中。这会稍微增加延迟,并显著增加吞吐量——每条消息的开销要低得多,如果启用了压缩,压缩效果会更好。
buffer.memory
这个配置设置了生产者用来缓冲等待发送到代理的消息的内存量。如果应用程序发送消息的速度比它们被传递到服务器的速度快,生产者可能会用完空间,而额外的“send()”调用将会阻塞“max.block.ms”并等待空间释放,然后才会抛出异常。请注意,与大多数生产者异常不同,这个超时是由“send()”而不是由结果“Future”抛出的。
compression.type
默认情况下,消息是未压缩的。此参数可以设置为snappy、gzip、lz4或zstd,在这种情况下,将使用相应的压缩算法对数据进行压缩,然后将其发送到经纪人。Snappy 压缩是由 Google 发明的,以提供良好的压缩比和低 CPU 开销以及良好的性能,因此在性能和带宽都受到关注的情况下建议使用。Gzip 压缩通常会使用更多的 CPU 和时间,但会产生更好的压缩比,因此在网络带宽更受限制的情况下建议使用。通过启用压缩,可以减少网络利用率和存储,这在向 Kafka 发送消息时通常是瓶颈。
batch.size
当多条记录发送到同一分区时,生产者将它们批量处理在一起。此参数控制每个批次将用于的字节内存量(而不是消息!)。当批次满了,批次中的所有消息将被发送。但是,这并不意味着生产者会等待批次变满。生产者将发送半满的批次,甚至只有一条消息的批次。因此,将批次大小设置得太大不会导致发送消息的延迟;它只会使用更多的内存用于批次。将批次大小设置得太小会增加一些开销,因为生产者需要更频繁地发送消息。
max.in.flight.requests.per.connection
这控制生产者在未收到响应的情况下向服务器发送多少消息批次。较高的设置可以增加内存使用量,同时提高吞吐量。Apache 的维基实验显示,在单个 DC 环境中,通过只有 2 个飞行请求可以实现最大吞吐量;然而,默认值为 5 并显示类似的性能。
顺序保证
Apache Kafka 保留分区内消息的顺序。这意味着如果消息按特定顺序从生产者发送,经纪人将按照该顺序将它们写入分区,并且所有消费者将按照该顺序读取它们。对于某些用例,顺序非常重要。在账户中存入 100 美元并稍后取款,与相反的顺序之间存在很大的区别!然而,某些用例则不太敏感。
将“重试”参数设置为非零,并将“每个连接的最大飞行请求数”设置为大于 1 意味着可能会发生经纪人无法写入第一批消息,成功写入第二批(已经在飞行中),然后重试第一批并成功,从而颠倒顺序。
由于出于性能原因,我们希望至少有两个飞行请求,并出于可靠性原因,希望有较高数量的重试,因此最佳解决方案是设置enable.idempotence=true。这可以保证最多有五个飞行请求的消息排序,并且保证重试不会引入重复。第八章深入讨论了幂等生产者。
max.request.size
此设置控制生产者发送的生产请求的大小。它限制了可以发送的最大消息的大小以及生产者可以在一个请求中发送的消息数量。例如,默认的最大请求大小为 1 MB,您可以发送的最大消息为 1 MB,或者生产者可以将 1,024 条大小为 1 KB 的消息批量处理成一个请求。此外,经纪人对其将接受的最大消息大小也有限制(message.max.bytes)。通常最好将这些配置匹配起来,这样生产者就不会尝试发送经纪人拒绝的大小的消息。
receive.buffer.bytes 和 send.buffer.bytes
这些是在写入和读取数据时套接字使用的 TCP 发送和接收缓冲区的大小。如果将它们设置为-1,将使用操作系统的默认值。当生产者或消费者与不同数据中心的代理进行通信时,建议增加这些值,因为这些网络链接通常具有更高的延迟和较低的带宽。
enable.idempotence
从 0.11 版本开始,Kafka 支持仅一次语义。仅一次是一个相当大的主题,我们将专门为此撰写一整章,但幂等生产者是其中一个简单且非常有益的部分。
假设您配置生产者以最大化可靠性:acks=all和一个相当大的delivery.timeout.ms以允许足够的重试。这样可以确保每条消息至少会被写入 Kafka 一次。在某些情况下,这意味着消息将被写入 Kafka 多次。例如,假设代理从生产者接收到一条记录,将其写入本地磁盘,并成功地复制到其他代理,但然后第一个代理在发送响应给生产者之前崩溃了。生产者将等待直到达到request.timeout.ms然后重试。重试将发送到已经成功复制了此记录的新领导者。现在您有了一个重复的记录。
为了避免这种情况,您可以设置enable.idempotence=true。启用幂等生产者后,生产者将为发送的每条记录附加一个序列号。如果代理接收到具有相同序列号的记录,它将拒绝第二份副本,生产者将收到无害的DuplicateSequenceException。
注意
启用幂等性要求max.in.flight.requests.per.connection小于或等于 5,retries大于 0,acks=all。如果设置了不兼容的值,将抛出ConfigException。
序列化程序
正如在之前的例子中所看到的,生产者配置包括强制性的序列化程序。我们已经看到了如何使用默认的String序列化程序。Kafka 还包括整数、ByteArrays等许多序列化程序,但这并不能涵盖大多数用例。最终,您将希望能够序列化更通用的记录。
我们将首先展示如何编写自己的序列化程序,然后介绍 Avro 序列化程序作为一个推荐的替代方案。
自定义序列化程序
当您需要发送到 Kafka 的对象不是简单的字符串或整数时,您可以选择使用通用序列化库(如 Avro、Thrift 或 Protobuf)创建记录,或者为您已经使用的对象创建自定义序列化。我们强烈建议使用通用序列化库。为了理解序列化程序的工作原理以及为什么使用序列化库是一个好主意,让我们看看编写自己的自定义序列化程序需要做些什么。
假设我们不仅仅记录客户的姓名,而是创建一个简单的类来表示客户:
public class Customer {
private int customerID;
private String customerName;
public Customer(int ID, String name) {
this.customerID = ID;
this.customerName = name;
}
public int getID() {
return customerID;
}
public String getName() {
return customerName;
}
}
现在假设我们想为这个类创建一个自定义的序列化程序。它看起来会像这样:
import org.apache.kafka.common.errors.SerializationException;
import java.nio.ByteBuffer;
import java.util.Map;
public class CustomerSerializer implements Serializer<Customer> {
@Override
public void configure(Map configs, boolean isKey) {
// nothing to configure
}
@Override
/**
We are serializing Customer as:
4 byte int representing customerId
4 byte int representing length of customerName in UTF-8 bytes (0 if
name is Null)
N bytes representing customerName in UTF-8
**/
public byte[] serialize(String topic, Customer data) {
try {
byte[] serializedName;
int stringSize;
if (data == null)
return null;
else {
if (data.getName() != null) {
serializedName = data.getName().getBytes("UTF-8");
stringSize = serializedName.length;
} else {
serializedName = new byte[0];
stringSize = 0;
}
}
ByteBuffer buffer = ByteBuffer.allocate(4 + 4 + stringSize);
buffer.putInt(data.getID());
buffer.putInt(stringSize);
buffer.put(serializedName);
return buffer.array();
} catch (Exception e) {
throw new SerializationException(
"Error when serializing Customer to byte[] " + e);
}
}
@Override
public void close() {
// nothing to close
}
}
使用这个CustomerSerializer配置生产者将允许您定义ProducerRecord<String, Customer>,并发送Customer数据并直接将Customer对象传递给生产者。这个例子很简单,但您可以看到代码是多么脆弱。例如,如果我们有太多的客户,并且需要将customerID更改为Long,或者如果我们决定向Customer添加一个startDate字段,那么在维护旧消息和新消息之间的兼容性方面将会出现严重问题。在不同版本的序列化程序和反序列化程序之间调试兼容性问题是相当具有挑战性的:您需要比较原始字节数组。更糟糕的是,如果同一家公司的多个团队最终都向 Kafka 写入Customer数据,他们都需要使用相同的序列化程序并同时修改代码。
因此,我们建议使用现有的序列化器和反序列化器,如 JSON、Apache Avro、Thrift 或 Protobuf。在接下来的部分中,我们将描述 Apache Avro,然后展示如何序列化 Avro 记录并将其发送到 Kafka。
使用 Apache Avro 进行序列化
Apache Avro 是一种语言中立的数据序列化格式。该项目由 Doug Cutting 创建,旨在为大众提供一种共享数据文件的方式。
Avro 数据是用语言无关的模式描述的。模式通常用 JSON 描述,序列化通常是到二进制文件,尽管也支持序列化到 JSON。Avro 假定在读取和写入文件时存在模式,通常是通过将模式嵌入文件本身来实现。
Avro 最有趣的特性之一,也是使其适合在 Kafka 等消息系统中使用的原因之一,是当编写消息的应用程序切换到新的但兼容的模式时,读取数据的应用程序可以继续处理消息而无需任何更改或更新。
假设原始模式是:
{"namespace": "customerManagement.avro",
"type": "record",
"name": "Customer",
"fields": [
{"name": "id", "type": "int"},
{"name": "name", "type": "string"},
{"name": "faxNumber", "type": ["null", "string"], "default": "null"} // ①
]
}
①
id和name字段是必需的,而faxNumber是可选的,默认为null。
我们在这个模式下使用了几个月,并以这种格式生成了几 TB 的数据。现在假设我们决定在新版本中,我们将升级到 21 世纪,不再包含传真号码字段,而是使用电子邮件字段。
新模式将是:
{"namespace": "customerManagement.avro",
"type": "record",
"name": "Customer",
"fields": [
{"name": "id", "type": "int"},
{"name": "name", "type": "string"},
{"name": "email", "type": ["null", "string"], "default": "null"}
]
}
现在,在升级到新版本后,旧记录将包含faxNumber,新记录将包含email。在许多组织中,升级是缓慢进行的,需要花费很多个月的时间。因此,我们需要考虑如何处理仍然使用传真号码的升级前应用程序和使用电子邮件的升级后应用程序在 Kafka 中的所有事件。
读取应用程序将包含类似于getName()、getId()和getFaxNumber()的方法调用。如果遇到使用新模式编写的消息,getName()和getId()将继续工作而无需修改,但getFaxNumber()将返回null,因为消息不包含传真号码。
现在假设我们升级了我们的读取应用程序,它不再具有getFaxNumber()方法,而是getEmail()。如果遇到使用旧模式编写的消息,getEmail()将返回null,因为旧消息不包含电子邮件地址。
这个例子说明了使用 Avro 的好处:即使我们在消息中改变了模式,而不改变所有读取数据的应用程序,也不会出现异常或破坏错误,也不需要昂贵的现有数据更新。
然而,这种情况有两个注意事项:
-
写入数据使用的模式和读取应用程序期望的模式必须是兼容的。Avro 文档包括兼容性规则。
-
反序列化器将需要访问写入数据时使用的模式,即使它与应用程序期望的模式不同。在 Avro 文件中,写入模式包含在文件本身中,但对于 Kafka 消息,有一种更好的处理方式。我们将在下面看到这一点。
使用 Avro 记录与 Kafka
与 Avro 文件不同,将整个模式存储在数据文件中与相当合理的开销相关联,将整个模式存储在每个记录中通常会使记录大小增加一倍以上。但是,Avro 仍然要求在读取记录时整个模式都存在,因此我们需要在其他地方定位模式。为了实现这一点,我们遵循一个常见的架构模式,并使用 模式注册表。模式注册表不是 Apache Kafka 的一部分,但有几个开源选项可供选择。我们将在此示例中使用 Confluent Schema Registry。您可以在 GitHub 上找到模式注册表代码,或者您可以将其作为 Confluent Platform 的一部分安装。如果决定使用模式注册表,我们建议查看 Confluent 上的文档。
将所有用于将数据写入 Kafka 的模式存储在注册表中。然后我们只需在我们产生到 Kafka 的记录中存储模式的标识符。消费者随后可以使用标识符从模式注册表中提取记录并反序列化数据。关键在于所有这些工作——将模式存储在注册表中并在需要时提取模式——都是在序列化器和反序列化器中完成的。将数据生成到 Kafka 的代码就像使用任何其他序列化器一样使用 Avro 序列化器。图 3-3 展示了这个过程。

图 3-3. Avro 记录的序列化和反序列化流程
以下是如何将生成的 Avro 对象发送到 Kafka 的示例(请参阅 Avro 文档 了解如何从 Avro 模式生成对象):
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("key.serializer",
"io.confluent.kafka.serializers.KafkaAvroSerializer");
props.put("value.serializer",
"io.confluent.kafka.serializers.KafkaAvroSerializer"); // ①
props.put("schema.registry.url", schemaUrl); // ②
String topic = "customerContacts";
Producer<String, Customer> producer = new KafkaProducer<>(props); // ③
// We keep producing new events until someone ctrl-c
while (true) {
Customer customer = CustomerGenerator.getNext(); // ④
System.out.println("Generated customer " +
customer.toString());
ProducerRecord<String, Customer> record =
new ProducerRecord<>(topic, customer.getName(), customer); // ⑤
producer.send(record); // ⑥
}
①
我们使用 KafkaAvroSerializer 来使用 Avro 序列化我们的对象。请注意,KafkaAvroSerializer 也可以处理原始类型,这就是为什么我们后来可以使用 String 作为记录键,而我们的 Customer 对象作为值。
②
schema.registry.url 是 Avro 序列化器的配置,将被生产者传递给序列化器。它简单地指向我们存储模式的位置。
③
Customer 是我们生成的对象。我们告诉生产者我们的记录将包含 Customer 作为值。
④
Customer 类不是常规的 Java 类(普通的旧的 Java 对象,或 POJO),而是一个专门的 Avro 对象,使用 Avro 代码生成从模式生成。Avro 序列化器只能序列化 Avro 对象,而不是 POJO。生成 Avro 类可以使用 avro-tools.jar 或 Avro Maven 插件来完成,这两者都是 Apache Avro 的一部分。有关如何生成 Avro 类的详细信息,请参阅 Apache Avro 入门(Java)指南。
⑤
我们还使用 Customer 作为值类型来实例化 ProducerRecord,并在创建新记录时传递一个 Customer 对象。
⑥
就是这样。我们发送包含我们的 Customer 对象的记录,KafkaAvroSerializer 将处理其余部分。
Avro 还允许您使用通用 Avro 对象,这些对象用作键值映射,而不是具有与用于生成它们的模式匹配的 getter 和 setter 的生成的 Avro 对象。要使用通用 Avro 对象,您只需要提供模式:
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("key.serializer",
"io.confluent.kafka.serializers.KafkaAvroSerializer"); // ①
props.put("value.serializer",
"io.confluent.kafka.serializers.KafkaAvroSerializer");
props.put("schema.registry.url", url); // ②
String schemaString =
"{\"namespace\": \"customerManagement.avro\",
"\"type\": \"record\", " + // ③
"\"name\": \"Customer\"," +
"\"fields\": [" +
"{\"name\": \"id\", \"type\": \"int\"}," +
"{\"name\": \"name\", \"type\": \"string\"}," +
"{\"name\": \"email\", \"type\": " + "[\"null\",\"string\"], " +
"\"default\":\"null\" }" +
"]}";
Producer<String, GenericRecord> producer =
new KafkaProducer<String, GenericRecord>(props); // ④
Schema.Parser parser = new Schema.Parser();
Schema schema = parser.parse(schemaString);
for (int nCustomers = 0; nCustomers < customers; nCustomers++) {
String name = "exampleCustomer" + nCustomers;
String email = "example " + nCustomers + "@example.com";
GenericRecord customer = new GenericData.Record(schema); // ⑤
customer.put("id", nCustomers);
customer.put("name", name);
customer.put("email", email);
ProducerRecord<String, GenericRecord> data =
new ProducerRecord<>("customerContacts", name, customer);
producer.send(data);
}
①
我们仍然使用相同的KafkaAvroSerializer。
②
我们提供相同模式注册表的 URI。
③
但现在我们还需要提供 Avro 模式,因为它不是由 Avro 生成的对象提供的。
④
我们的对象类型是 Avro GenericRecord,我们使用我们的模式和我们想要写入的数据初始化它。
⑤
然后,ProducerRecord的值只是包含我们的模式和数据的GenericRecord。序列化程序将知道如何从此记录中获取模式,将其存储在模式注册表中,并对对象数据进行序列化。
分区
在先前的示例中,我们创建的ProducerRecord对象包括主题名称、键和值。Kafka 消息是键值对,虽然可以只使用主题和值创建ProducerRecord,并且默认情况下将键设置为null,但大多数应用程序都会生成带有键的记录。键有两个目标:它们是存储在消息中的附加信息,通常也用于决定消息将写入哪个主题分区(键在压缩主题中也起着重要作用,我们将在第六章中讨论这些内容)。具有相同键的所有消息将进入同一分区。这意味着如果进程只读取主题中的一部分分区(有关详细信息,请参阅第四章),则单个键的所有记录将由同一进程读取。要创建键值记录,只需创建ProducerRecord如下所示:
ProducerRecord<String, String> record =
new ProducerRecord<>("CustomerCountry", "Laboratory Equipment", "USA");
在创建具有空键的消息时,可以简单地将键省略:
ProducerRecord<String, String> record =
new ProducerRecord<>("CustomerCountry", "USA"); // ①
①
在这里,键将简单地设置为null。
当键为null且使用默认分区器时,记录将随机发送到主题的可用分区之一。循环算法将用于在分区之间平衡消息。从 Apache Kafka 2.4 生产者开始,默认分区器在处理空键时使用的循环算法是粘性的。这意味着它将在切换到下一个分区之前填充发送到单个分区的一批消息。这允许以更少的请求将相同数量的消息发送到 Kafka,从而降低延迟并减少代理上的 CPU 利用率。
如果存在键并且使用默认分区器,则 Kafka 将对键进行哈希处理(使用自己的哈希算法,因此当 Java 升级时哈希值不会更改),并使用结果将消息映射到特定分区。由于关键始终映射到同一分区很重要,因此我们使用主题中的所有分区来计算映射,而不仅仅是可用分区。这意味着如果在写入数据时特定分区不可用,则可能会出现错误。这是相当罕见的,正如您将在第七章中看到的,当我们讨论 Kafka 的复制和可用性时。
除了默认的分区器,Apache Kafka 客户端还提供了RoundRobinPartitioner和UniformStickyPartitioner。即使消息具有键,这些分配随机分区和粘性随机分区分配。当键对于消费应用程序很重要时(例如,有一些 ETL 应用程序使用 Kafka 记录的键作为从 Kafka 加载数据到关系数据库时的主键),但工作负载可能会倾斜,因此单个键可能具有不成比例的大工作负载。使用UniformStickyPartitioner将导致工作负载均匀分布在所有分区上。
当使用默认分区器时,键到分区的映射仅在主题中的分区数量不变时才是一致的。因此,只要分区数量保持不变,您可以确保,例如,关于用户 045189 的记录将始终被写入分区 34。这允许在从分区读取数据时进行各种优化。然而,一旦您向主题添加新的分区,这就不再保证——旧记录将保留在分区 34,而新记录可能会被写入不同的分区。当分区键很重要时,最简单的解决方案是创建具有足够分区的主题(Confluent 博客包含有关如何选择分区数量的建议),并且永远不要添加分区。
实施自定义分区策略
到目前为止,我们已经讨论了默认分区器的特性,这是最常用的分区器。然而,Kafka 并不限制您只能使用哈希分区,有时分区数据的原因也是很好的。例如,假设您是一家 B2B 供应商,您最大的客户是一家制造名为 Bananas 的手持设备的公司。假设您与客户“Banana”的业务量如此之大,以至于您每天超过 10%的交易都与该客户进行。如果您使用默认的哈希分区,Banana 记录将被分配到与其他帐户相同的分区,导致一个分区比其他分区大得多。这可能导致服务器空间不足,处理速度变慢等问题。我们真正想要的是给 Banana 分配自己的分区,然后使用哈希分区将其余的帐户映射到所有其他分区。
以下是一个自定义分区器的示例:
import org.apache.kafka.clients.producer.Partitioner;
import org.apache.kafka.common.Cluster;
import org.apache.kafka.common.PartitionInfo;
import org.apache.kafka.common.record.InvalidRecordException;
import org.apache.kafka.common.utils.Utils;
public class BananaPartitioner implements Partitioner {
public void configure(Map<String, ?> configs) {} // ①
public int partition(String topic, Object key, byte[] keyBytes,
Object value, byte[] valueBytes,
Cluster cluster) {
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
int numPartitions = partitions.size();
if ((keyBytes == null) || (!(key instanceOf String))) // ②
throw new InvalidRecordException("We expect all messages " +
"to have customer name as key");
if (((String) key).equals("Banana"))
return numPartitions - 1; // Banana will always go to last partition
// Other records will get hashed to the rest of the partitions
return Math.abs(Utils.murmur2(keyBytes)) % (numPartitions - 1);
}
public void close() {}
}
①
分区器接口包括configure、partition和close方法。在这里,我们只实现了partition,尽管我们真的应该通过configure传递特殊的客户名称,而不是在partition中硬编码它。
②
我们只期望String键,因此如果不是这种情况,我们会抛出异常。
标题
记录除了键和值之外,还可以包括头。记录头使您能够向 Kafka 记录添加一些关于元数据的信息,而不向记录本身的键/值对添加任何额外信息。头通常用于表示记录中数据的来源的谱系,并根据头信息路由或跟踪消息,而无需解析消息本身(也许消息是加密的,路由器没有权限访问数据)。
头被实现为键/值对的有序集合。键始终是String,值可以是任何序列化对象,就像消息值一样。
以下是一个小例子,展示了如何向ProduceRecord添加头:
ProducerRecord<String, String> record =
new ProducerRecord<>("CustomerCountry", "Precision Products", "France");
record.headers().add("privacy-level","YOLO".getBytes(StandardCharsets.UTF_8));
拦截器
有时,您希望修改 Kafka 客户端应用程序的行为,而无需修改其代码,也许是因为您希望向组织中的所有应用程序添加相同的行为。或者您可能无法访问原始代码。
Kafka 的ProducerInterceptor拦截器包括两个关键方法:
ProducerRecord<K, V> onSend(ProducerRecord<K, V> record)
在将生成的记录发送到 Kafka 之前,甚至在序列化之前,将调用此方法。重写此方法时,您可以捕获有关发送记录的信息,甚至修改它。只需确保从此方法返回有效的ProducerRecord。此方法返回的记录将被序列化并发送到 Kafka。
void onAcknowledgement(RecordMetadata metadata, Exception exception)
如果 Kafka 响应发送的消息,则将调用此方法。该方法不允许修改来自 Kafka 的响应,但可以捕获有关响应的信息。
生产者拦截器的常见用例包括捕获监视和跟踪信息;增强消息的标准头,特别是用于血统跟踪目的;以及删除敏感信息。
这是一个非常简单的生产者拦截器示例。这个示例只是在特定时间窗口内计算发送的消息和接收的确认:
public class CountingProducerInterceptor implements ProducerInterceptor {
ScheduledExecutorService executorService =
Executors.newSingleThreadScheduledExecutor();
static AtomicLong numSent = new AtomicLong(0);
static AtomicLong numAcked = new AtomicLong(0);
public void configure(Map<String, ?> map) {
Long windowSize = Long.valueOf(
(String) map.get("counting.interceptor.window.size.ms")); // ①
executorService.scheduleAtFixedRate(CountingProducerInterceptor::run,
windowSize, windowSize, TimeUnit.MILLISECONDS);
}
public ProducerRecord onSend(ProducerRecord producerRecord) {
numSent.incrementAndGet();
return producerRecord; // ②
}
public void onAcknowledgement(RecordMetadata recordMetadata, Exception e) {
numAcked.incrementAndGet(); // ③
}
public void close() {
executorService.shutdownNow(); // ④
}
public static void run() {
System.out.println(numSent.getAndSet(0));
System.out.println(numAcked.getAndSet(0));
}
}
①
ProducerInterceptor是一个Configurable接口。您可以重写configure方法并在调用任何其他方法之前进行设置。此方法接收整个生产者配置,您可以访问任何配置参数。在这种情况下,我们添加了自己的配置,并在此引用。
②
发送记录时,我们增加记录计数并返回记录而不修改它。
③
当 Kafka 响应确认时,我们增加确认计数,不需要返回任何内容。
④
当生产者关闭时,将调用此方法,让我们有机会清理拦截器状态。在这种情况下,我们关闭了创建的线程。如果您打开了文件句柄、连接到远程数据存储或类似的内容,这是关闭所有内容并避免泄漏的地方。
正如我们之前提到的,生产者拦截器可以在不更改客户端代码的情况下应用。要在kafka-console-producer中使用前面的拦截器,这是一个随 Apache Kafka 一起提供的示例应用程序,请按照以下三个简单步骤操作:
- 将您的 jar 添加到类路径:
export CLASSPATH=$CLASSPATH:~./target/CountProducerInterceptor-1.0-SNAPSHOT.jar
- 创建包含以下内容的配置文件:
interceptor.classes=com.shapira.examples.interceptors.CountProducerInterceptor counting.interceptor.window.size.ms=10000
- 像通常一样运行应用程序,但确保包含在上一步中创建的配置:
bin/kafka-console-producer.sh --broker-list localhost:9092 --topic interceptor-test --producer.config producer.config
配额和限流
Kafka 代理有能力限制消息的生产和消费速率。这是通过配额机制完成的。Kafka 有三种配额类型:生产、消费和请求。生产和消费配额限制客户端发送和接收数据的速率,以每秒字节数为单位。请求配额限制代理处理客户端请求的时间百分比。
可以通过设置默认配额、特定客户端 ID、特定用户或两者来应用配额到所有客户端。用户特定的配额只在配置了安全性并且客户端进行身份验证的集群中才有意义。
应用于所有客户端的默认生产和消费配额是 Kafka 代理配置文件的一部分。例如,要限制每个生产者平均发送不超过 2 MBps,将以下配置添加到代理配置文件中:quota.producer.default=2M。
虽然不建议,但您也可以为某些客户端配置特定的配额,这些配额会覆盖代理配置文件中的默认配额。要允许 clientA 产生 4 MBps 和 clientB 10 MBps,您可以使用以下命令:quota.producer.override="clientA:4M,clientB:10M"
在 Kafka 的配置文件中指定的配额是静态的,您只能通过更改配置然后重新启动所有代理来修改它们。由于新客户端可以随时到达,这非常不方便。因此,将配额应用于特定客户端的常规方法是通过可以使用kafka-config.sh或 AdminClient API 设置的动态配置。
让我们看几个例子:
bin/kafka-configs --bootstrap-server localhost:9092 --alter --add-config 'producer_byte_rate=1024' --entity-name clientC --entity-type clients // ①
bin/kafka-configs --bootstrap-server localhost:9092 --alter --add-config 'producer_byte_rate=1024,consumer_byte_rate=2048' --entity-name user1 --entity-type users // ②
bin/kafka-configs --bootstrap-server localhost:9092 --alter --add-config 'consumer_byte_rate=2048' --entity-type users // ③
①
将 clientC(通过客户端 ID 标识)限制为每秒只能产生 1024 字节
②
将 user1(通过经过身份验证的主体标识)限制为每秒只能产生 1024 字节和每秒只能消耗 2048 字节。
③
将所有用户限制为每秒只能消耗 2048 字节,除了具有更具体覆盖的用户。这是动态修改默认配额的方法。
当客户端达到配额时,代理将开始限制客户端的请求,以防止其超出配额。这意味着代理将延迟响应客户端的请求;在大多数客户端中,这将自动降低请求速率(因为在飞行请求的数量受限),并将客户端流量降至配额允许的水平。为了保护代理免受在被限制时发送额外请求的不良客户端,代理还将在所需时间内静音与客户端的通信通道,以达到符合配额的目的。
通过produce-throttle-time-avg、produce-throttle-time-max、fetch-throttle-time-avg和fetch-throttle-time-max向客户端公开了限流行为,这是由于限流而延迟生产请求和获取请求的平均和最大时间量。请注意,此时间可以代表由于生产和消费吞吐量配额、请求时间配额或两者而导致的限流。其他类型的客户端请求只能由于请求时间配额而被限流,这些请求也将通过类似的指标公开。
警告
如果您使用异步Producer.send()并继续以高于代理可以接受的速率发送消息(无论是因为配额还是容量不足),消息将首先排队在客户端内存中。如果发送速率继续高于接受消息的速率,客户端最终将耗尽用于存储多余消息的缓冲空间,并阻塞下一个Producer.send()调用。如果超时延迟不足以让代理赶上生产者并在缓冲区中清理一些空间,最终Producer.send()将抛出TimeoutException。或者,一些已经放入批处理中的记录将等待的时间超过delivery.timeout.ms并过期,导致使用TimeoutException调用send()回调。因此,重要的是要计划和监视,以确保代理的容量随时间匹配生产者发送数据的速率。
摘要
我们从一个简单的生产者示例开始,只有 10 行代码将事件发送到 Kafka。我们通过添加错误处理和尝试同步和异步生产来扩展了简单示例。然后,我们探讨了最重要的生产者配置参数,并看到它们如何修改生产者的行为。我们讨论了序列化器,它让我们控制写入 Kafka 的事件的格式。我们深入研究了 Avro,这是序列化事件的许多方式之一,但在 Kafka 中非常常用。我们在本章中讨论了 Kafka 中的分区以及高级自定义分区技术的示例。
现在我们知道如何将事件写入 Kafka,在第四章中,我们将学习有关从 Kafka 消费事件的所有内容。
¹ 图像由 Sumant Tambe 根据 ASLv2 许可条款为 Apache Kafka 项目做出贡献。
第四章:Kafka 消费者:从 Kafka 读取数据
需要从 Kafka 读取数据的应用程序使用KafkaConsumer订阅 Kafka 主题,并从这些主题接收消息。从 Kafka 读取数据与从其他消息系统读取数据有些不同,并涉及一些独特的概念和想法。如果不先了解这些概念,就很难理解如何使用消费者 API。我们将首先解释一些重要的概念,然后通过一些示例来展示不同的消费者 API 可以用于实现具有不同要求的应用程序的方式。
Kafka 消费者概念
要理解如何从 Kafka 读取数据,首先需要了解其消费者和消费者组。以下部分涵盖了这些概念。
消费者和消费者组
假设您有一个应用程序需要从 Kafka 主题中读取消息,对其进行一些验证,并将结果写入另一个数据存储。在这种情况下,您的应用程序将创建一个消费者对象,订阅适当的主题,并开始接收消息,验证它们,并写入结果。这可能在一段时间内运行良好,但是如果生产者写入主题的速率超过了您的应用程序验证消息的速率,会怎么样呢?如果您只能使用一个消费者读取和处理数据,您的应用程序可能会越来越落后,无法跟上消息的到达速率。显然,有必要从主题中扩展消费。就像多个生产者可以写入同一个主题一样,我们需要允许多个消费者从同一个主题中读取数据,并将数据分割给它们。
Kafka 消费者通常是消费者组的一部分。当多个消费者订阅一个主题并属于同一个消费者组时,组中的每个消费者将从主题的不同子集中接收消息。
假设有一个具有四个分区的主题 T1。现在假设我们创建了一个新的消费者 C1,它是组 G1 中唯一的消费者,并使用它订阅主题 T1。消费者 C1 将从 T1 的所有四个分区接收所有消息。参见图 4-1。

图 4-1:一个消费者组有四个分区
如果我们将另一个消费者 C2 添加到组 G1 中,每个消费者将只收到来自两个分区的消息。也许分区 0 和 2 的消息会发送给 C1,分区 1 和 3 的消息会发送给消费者 C2。参见图 4-2。

图 4-2:四个分区分配给一个组中的两个消费者
如果 G1 有四个消费者,那么每个消费者将从单个分区读取消息。参见图 4-3。

图 4-3:一个组中有四个消费者,每个消区一个
如果我们向单个主题的单个组中添加更多的消费者,超过分区数量,一些消费者将处于空闲状态,根本收不到任何消息。参见图 4-4。

图 4-4:组中的消费者多于分区意味着有空闲的消费者
我们扩展 Kafka 主题的数据消费的主要方法是向消费者组添加更多的消费者。Kafka 消费者通常执行高延迟操作,例如向数据库写入或对数据进行耗时计算。在这些情况下,单个消费者不可能跟上数据流入主题的速度,通过添加更多的消费者来共享负载,使每个消费者仅拥有分区和消息的子集是我们扩展的主要方法。这是创建具有大量分区的主题的一个很好的理由——它允许在负载增加时添加更多的消费者。请记住,在主题中添加更多的消费者是没有意义的——一些消费者将处于空闲状态。第二章包括一些建议,关于如何选择主题中的分区数量。
除了添加消费者以扩展单个应用程序之外,非常常见的是有多个应用程序需要从同一个主题中读取数据。事实上,Kafka 的主要设计目标之一是使生产到 Kafka 主题的数据对组织中的许多用例都可用。在这些情况下,我们希望每个应用程序获取主题中的所有消息,而不仅仅是一个子集。为了确保应用程序获取主题中的所有消息,请确保应用程序有自己的消费者组。与许多传统的消息系统不同,Kafka 可以扩展到大量的消费者和消费者组,而不会降低性能。
在前面的例子中,如果我们添加一个新的消费者组(G2)并有一个单独的消费者,这个消费者将独立于 G1 的操作获取主题 T1 中的所有消息。G2 可以有多个消费者,这样它们将分别获取分区的子集,就像我们为 G1 所示的那样,但是 G2 作为一个整体仍将获取所有消息,而不受其他消费者组的影响。参见图 4-5。

图 4-5:添加一个新的消费者组,两个组都接收所有消息
总之,为每个需要从一个或多个主题中获取所有消息的应用程序创建一个新的消费者组。向现有的消费者组添加消费者以扩展对主题中消息的读取和处理,因此组中的每个额外的消费者将只获取消息的子集。
消费者组和分区重新平衡
正如我们在前一节中看到的,消费者组中的消费者共享他们订阅的主题中分区的所有权。当我们向组中添加新的消费者时,它开始消费先前由另一个消费者消费的分区的消息。当消费者关闭或崩溃时也会发生同样的情况;它离开了组,它以前消费的分区将被剩下的消费者之一消费。当消费者组正在消费的主题被修改时(例如,如果管理员添加了新的分区),分区也会重新分配给消费者。
将分区所有权从一个消费者转移到另一个消费者称为重新平衡。重新平衡很重要,因为它为消费者组提供了高可用性和可伸缩性(允许我们轻松安全地添加和删除消费者),但在正常情况下,它们可能是相当不受欢迎的。
有两种重新平衡的类型,取决于消费者组使用的分区分配策略:¹
急切的重新平衡
在急切重新平衡期间,所有消费者停止消费,放弃它们对所有分区的所有权,重新加入消费者组,并获得全新的分区分配。这实质上是整个消费者组的短暂不可用窗口。这个窗口的长度取决于消费者组的大小以及几个配置参数。图 4-6 显示了急切重新平衡有两个明显的阶段:首先,所有消费者放弃它们的分区分配,然后,在它们都完成这一步并重新加入组后,它们获得新的分区分配并可以继续消费。

图 4-6. 急切重新平衡会撤销所有分区,暂停消费,并重新分配它们
合作重新平衡
合作重新平衡(也称为增量重新平衡)通常涉及将一小部分分区从一个消费者重新分配给另一个消费者,并允许消费者继续处理未被重新分配的所有分区的记录。这是通过分两个或更多阶段进行重新平衡来实现的。最初,消费者组领导者通知所有消费者它们将失去对一部分分区的所有权,然后消费者停止从这些分区消费并放弃它们的所有权。在第二阶段,消费者组领导者将这些现在被遗弃的分区分配给它们的新所有者。这种增量方法可能需要几次迭代,直到实现稳定的分区分配,但它避免了急切方法中发生的完全“停止世界”不可用性。这在大型消费者组中尤为重要,因为重新平衡可能需要大量时间。图 4-7 显示了合作重新平衡是增量的,只涉及部分消费者和分区。

图 4-7. 合作重新平衡只暂停将被重新分配的分区的消费
消费者通过向被指定为组协调者的 Kafka 代理发送心跳来维护消费者组的成员资格和分配给它们的分区的所有权(对于不同的消费者组,这个代理可能是不同的)。消费者通过后台线程发送心跳,只要消费者以固定的间隔发送心跳,就假定它是活动的。
如果消费者停止发送心跳足够长的时间,它的会话将超时,组协调者将认为它已经死亡并触发重新平衡。如果消费者崩溃并停止处理消息,组协调者将需要几秒钟没有心跳来判断它已经死亡并触发重新平衡。在这几秒钟内,来自已死亡消费者所拥有的分区的消息将不会被处理。当消费者正常关闭时,消费者将通知组协调者它正在离开,组协调者将立即触发重新平衡,减少处理的间隙。在本章的后面,我们将讨论控制心跳频率、会话超时和其他配置参数的配置选项,这些配置选项可以用来微调消费者的行为。
如何将分区分配给消费者的过程是如何工作的?
当消费者想要加入一个组时,它会向组协调者发送一个“JoinGroup”请求。第一个加入组的消费者成为组的领导者。领导者从组协调者那里接收到组中所有消费者的列表(这将包括最近发送心跳并因此被认为是活动的所有消费者),并负责将一部分分区分配给每个消费者。它使用PartitionAssignor的实现来决定哪些分区应该由哪些消费者处理。
Kafka 有一些内置的分区分配策略,我们将在配置部分更深入地讨论。在决定分区分配之后,消费者组领导者将分配的列表发送给GroupCoordinator,后者将此信息发送给所有消费者。每个消费者只能看到自己的分配 - 领导者是唯一拥有完整消费者列表及其分配的客户端进程。每次发生重新平衡时,这个过程都会重复。
静态组成员资格
默认情况下,消费者作为其消费者组的成员的身份是瞬时的。当消费者离开消费者组时,分配给消费者的分区将被撤销,当它重新加入时,通过重新平衡协议,它将被分配一个新的成员 ID 和一组新的分区。
除非您配置具有唯一group.instance.id的消费者,否则所有这些都是真实的,这使得消费者成为组的静态成员。当消费者首次以组的静态成员身份加入消费者组时,它将根据组使用的分区分配策略被分配一组分区,就像正常情况下一样。然而,当此消费者关闭时,它不会自动离开组 - 直到其会话超时之前,它仍然是组的成员。当消费者重新加入组时,它将以其静态身份被识别,并且重新分配之前持有的相同分区,而不会触发重新平衡。缓存组的协调者不需要触发重新平衡,而只需将缓存分配发送给重新加入的静态成员。
如果两个消费者以相同的group.instance.id加入同一组,第二个消费者将收到一个错误,指出已经存在具有此 ID 的消费者。
静态组成员资格在您的应用程序维护由分配给每个消费者的分区填充的本地状态或缓存时非常有用。当重新创建此缓存需要耗费时间时,您不希望每次消费者重新启动时都发生这个过程。另一方面,重要的是要记住,当消费者重新启动时,每个消费者拥有的分区将不会重新分配。在一定的时间内,没有消费者会从这些分区中消费消息,当消费者最终重新启动时,它将落后于这些分区中的最新消息。您应该确信,拥有这些分区的消费者将能够在重新启动后赶上滞后。
重要的是要注意,消费者组的静态成员在关闭时不会主动离开组,而是依赖于session.timeout.ms配置来检测它们何时“真正离开”。您需要将其设置得足够高,以避免在简单应用程序重新启动时触发重新平衡,但又要足够低,以允许在有更长时间停机时自动重新分配它们的分区,以避免处理这些分区时出现较大的间隙。
创建 Kafka 消费者
开始消费记录的第一步是创建一个KafkaConsumer实例。创建KafkaConsumer与创建KafkaProducer非常相似 - 您需要创建一个带有要传递给消费者的属性的 Java Properties实例。我们将在本章后面详细讨论所有属性。首先,我们只需要使用三个必需的属性:bootstrap.servers,key.deserializer和value.deserializer。
第一个属性bootstrap.servers是连接到 Kafka 集群的连接字符串。它的使用方式与KafkaProducer中的完全相同(有关其定义的详细信息,请参阅第三章)。另外两个属性key.deserializer和value.deserializer与生产者定义的serializers类似,但不是指定将 Java 对象转换为字节数组的类,而是需要指定能够将字节数组转换为 Java 对象的类。
还有第四个属性,虽然不是严格必需的,但非常常用。该属性是group.id,它指定Kafka``Consumer实例所属的消费者组。虽然可以创建不属于任何消费者组的消费者,但这是不常见的,因此在本章的大部分内容中,我们将假设消费者是消费者组的一部分。
以下代码片段显示了如何创建KafkaConsumer:
Properties props = new Properties();
props.put("bootstrap.servers", "broker1:9092,broker2:9092");
props.put("group.id", "CountryCounter");
props.put("key.deserializer",
"org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer",
"org.apache.kafka.common.serialization.StringDeserializer");
KafkaConsumer<String, String> consumer =
new KafkaConsumer<String, String>(props);
如果您阅读过第三章中关于创建生产者的内容,那么您在这里看到的大部分内容应该是熟悉的。我们假设我们消费的记录将作为记录的键和值都是String对象。这里唯一的新属性是group.id,它是这个消费者所属的消费者组的名称。
订阅主题
创建消费者后,下一步是订阅一个或多个主题。subscribe()方法接受一个主题列表作为参数,所以使用起来非常简单:
consumer.subscribe(Collections.singletonList("customerCountries")); // ①
①
在这里,我们只是创建了一个包含单个元素的列表:主题名称customerCountries。
还可以使用正则表达式调用subscribe。表达式可以匹配多个主题名称,如果有人创建了一个与名称匹配的新主题,重新平衡将几乎立即发生,消费者将开始从新主题中消费。这对需要从多个主题中消费并且可以处理主题将包含的不同类型数据的应用程序非常有用。使用正则表达式订阅多个主题最常用于在 Kafka 和另一个系统之间复制数据或流处理应用程序的应用程序。
例如,要订阅所有测试主题,我们可以调用:
consumer.subscribe(Pattern.compile("test.*"));
警告
如果您的 Kafka 集群有大量分区,可能是 30,000 个或更多,您应该知道订阅主题的过滤是在客户端上完成的。这意味着当您通过正则表达式订阅主题的子集而不是通过显式列表时,消费者将定期请求来自代理的所有主题及其分区的列表。然后客户端将使用此列表来检测应该包括在其订阅中的新主题并订阅它们。当主题列表很大且有许多消费者时,主题和分区列表的大小是显著的,并且正则表达式订阅对代理、客户端和网络有显著的开销。有些情况下,主题元数据使用的带宽大于发送数据使用的带宽。这也意味着为了使用正则表达式订阅,客户端需要有权限描述集群中的所有主题——也就是说,对整个集群的完整describe授权。
轮询循环
消费者 API 的核心是一个简单的循环,用于从服务器轮询更多数据。消费者的主体将如下所示:
Duration timeout = Duration.ofMillis(100);
while (true) { // ①
ConsumerRecords<String, String> records = consumer.poll(timeout); // ②
for (ConsumerRecord<String, String> record : records) { // ③
System.out.printf("topic = %s, partition = %d, offset = %d, " +
"customer = %s, country = %s\n",
record.topic(), record.partition(), record.offset(),
record.key(), record.value());
int updatedCount = 1;
if (custCountryMap.containsKey(record.value())) {
updatedCount = custCountryMap.get(record.value()) + 1;
}
custCountryMap.put(record.value(), updatedCount);
JSONObject json = new JSONObject(custCountryMap);
System.out.println(json.toString()); // ④
}
}
①
这确实是一个无限循环。消费者通常是长时间运行的应用程序,不断地轮询 Kafka 以获取更多数据。我们将在本章后面展示如何清洁地退出循环并关闭消费者。
②
这是本章中最重要的一行。就像鲨鱼必须不断移动,否则它们就会死一样,消费者必须不断轮询 Kafka,否则它们将被视为死亡,并且它们正在消费的分区将被交给组中的另一个消费者继续消费。我们传递给poll()的参数是超时间隔,控制如果消费者缓冲区中没有数据,poll()将阻塞多长时间。如果设置为 0 或者已经有记录可用,poll()将立即返回;否则,它将等待指定的毫秒数。
③
poll()返回一个记录列表。每个记录包含记录来自的主题和分区,记录在分区内的偏移量,当然还有记录的键和值。通常,我们希望遍历列表并逐个处理记录。
④
处理通常以在数据存储中写入结果或更新存储的记录结束。在这里,目标是保持每个国家客户的运行计数,因此我们更新哈希表并将结果打印为 JSON。一个更现实的例子会将更新的结果存储在数据存储中。
poll循环做的远不止获取数据。第一次使用新的消费者调用poll()时,它负责找到GroupCoordinator,加入消费者组,并接收分区分配。如果触发了重新平衡,它也将在poll循环中处理,包括相关的回调。这意味着几乎所有可能出错的消费者或其监听器中使用的回调都可能显示为poll()抛出的异常。
请记住,如果poll()的调用时间超过max.poll.interval.ms,则消费者将被视为死亡并从消费者组中驱逐,因此避免在poll循环内部阻塞不可预测的时间。
线程安全
您不能在一个线程中拥有属于同一组的多个消费者,也不能安全地让多个线程使用同一个消费者。每个线程一个消费者是规则。要在同一应用程序中运行同一组中的多个消费者,您需要在每个消费者中运行各自的线程。将消费者逻辑封装在自己的对象中,然后使用 Java 的ExecutorService启动多个线程,每个线程都有自己的消费者是很有用的。Confluent 博客有一个教程展示了如何做到这一点。
警告
在较旧版本的 Kafka 中,完整的方法签名是poll(long);这个签名现在已经被弃用,新的 API 是poll(Duration)。除了参数类型的更改,方法阻塞的语义也略有变化。原始方法poll(long)将阻塞,直到从 Kafka 获取所需的元数据,即使这比超时持续时间更长。新方法poll(Duration)将遵守超时限制,不会等待元数据。如果您有现有的消费者代码,使用poll(0)作为一种强制 Kafka 获取元数据而不消耗任何记录的方法(这是一种相当常见的黑客行为),您不能只是将其更改为poll(Duration.ofMillis(0))并期望相同的行为。您需要找出一种新的方法来实现您的目标。通常的解决方案是将逻辑放在rebalanceListener.onPartitionAssignment()方法中,在分配分区的元数据后但记录开始到达之前,这个方法保证会被调用。Jesse Anderson 在他的博客文章“Kafka’s Got a Brand-New Poll”中也记录了另一种解决方案。
另一种方法是让一个消费者填充一个事件队列,并让多个工作线程从这个队列中执行工作。您可以在Igor Buzatović的博客文章中看到这种模式的示例。
配置消费者
到目前为止,我们已经专注于学习消费者 API,但我们只看了一些配置属性——只是强制性的bootstrap.servers、group.id、key.deserializer和value.deserializer。所有的消费者配置都在Apache Kafka 文档中有记录。大多数参数都有合理的默认值,不需要修改,但有些对消费者的性能和可用性有影响。让我们来看看一些更重要的属性。
fetch.min.bytes
这个属性允许消费者指定从代理获取记录时要接收的最小数据量,默认为一个字节。如果代理收到来自消费者的记录请求,但新的记录量少于fetch.min.bytes,代理将等待更多消息可用后再将记录发送回消费者。这减少了在主题没有太多新活动(或者在一天中活动较少的时间)的情况下,消费者和代理处理来回消息的负载。如果消费者在没有太多数据可用时使用了太多 CPU,或者在有大量消费者时减少了对代理的负载,您将希望将此参数设置得比默认值更高——尽管请记住,增加此值可能会增加低吞吐量情况下的延迟。
fetch.max.wait.ms
通过设置fetch.min.bytes,您告诉 Kafka 在响应消费者之前等待足够的数据。fetch.max.wait.ms允许您控制等待的时间。默认情况下,Kafka 将等待最多 500 毫秒。如果 Kafka 主题中没有足够的数据流动以满足返回的最小数据量,这将导致最多 500 毫秒的额外延迟。如果要限制潜在的延迟(通常是由 SLA 控制应用程序的最大延迟引起的),可以将fetch.max.wait.ms设置为较低的值。如果将fetch.max.wait.ms设置为 100 毫秒,并将fetch.min.bytes设置为 1 MB,Kafka 将从消费者接收获取请求,并在有 1 MB 数据要返回或者在 100 毫秒后,以先到者为准,响应数据。
fetch.max.bytes
这个属性允许您指定 Kafka 在消费者轮询代理时将返回的最大字节数(默认为 50 MB)。它用于限制消费者用于存储从服务器返回的数据的内存大小,而不管返回了多少个分区或消息。请注意,记录是以批量形式发送到客户端的,如果代理必须发送的第一个记录批次超过了这个大小,批次将被发送并且限制将被忽略。这保证了消费者可以继续取得进展。值得注意的是,还有一个匹配的代理配置,允许 Kafka 管理员限制最大获取大小。代理配置可能很有用,因为对大量数据的请求可能导致从磁盘读取大量数据,并且在网络上传输时间较长,这可能会导致争用并增加代理的负载。
max.poll.records
这个属性控制了单次poll()调用将返回的最大记录数。使用它来控制应用程序在一次轮询循环中需要处理的数据量(但不是数据大小)。
max.partition.fetch.bytes
这个属性控制服务器每个分区返回的最大字节数(默认为 1 MB)。当 KafkaConsumer.poll() 返回 ConsumerRecords 时,记录对象将使用分配给消费者的每个分区的最多 max.partition.fetch.bytes。请注意,使用这个配置来控制内存使用可能非常复杂,因为您无法控制经纪人响应中将包括多少分区。因此,我们强烈建议使用 fetch.max.bytes,除非您有特殊原因要尝试从每个分区处理类似数量的数据。
会话超时时间和心跳间隔时间
消费者在与经纪人失去联系时被认为仍然存活的时间默认为 10 秒。如果消费者在没有向组协调器发送心跳的情况下经过了超过 session.timeout.ms 的时间,那么它被认为已经死亡,组协调器将触发消费者组的重新平衡,将死亡消费者的分区分配给组中的其他消费者。这个属性与 heartbeat.interval.ms 密切相关,它控制 Kafka 消费者向组协调器发送心跳的频率,而 session.timeout.ms 控制消费者可以多久不发送心跳。因此,这两个属性通常一起修改——heartbeat.interval.ms 必须低于 session.timeout.ms,通常设置为超时值的三分之一。因此,如果 session.timeout.ms 是 3 秒,heartbeat.interval.ms 应该是 1 秒。将 session.timeout.ms 设置得比默认值低将允许消费者组更快地检测和从故障中恢复,但也可能导致不必要的重新平衡。将 session.timeout.ms 设置得更高将减少意外重新平衡的机会,但也意味着检测真正故障的时间会更长。
最大轮询间隔时间
这个属性允许您设置消费者在轮询之前可以多久不进行轮询被认为已经死亡的时间。正如前面提到的,心跳和会话超时是 Kafka 检测死亡消费者并夺走它们分区的主要机制。然而,我们也提到了心跳是由后台线程发送的。主线程从 Kafka 消费可能被死锁,但后台线程仍在发送心跳。这意味着由该消费者拥有的分区的记录没有被处理。了解消费者是否仍在处理记录的最简单方法是检查它是否正在请求更多记录。然而,请求更多记录之间的间隔很难预测,取决于可用数据的数量,消费者所做的处理类型,有时还取决于其他服务的延迟。在需要对返回的每条记录进行耗时处理的应用程序中,max.poll.records 用于限制返回的数据量,从而限制应用程序在再次可用于 poll() 之前的持续时间。即使定义了 max.poll.records,poll() 调用之间的间隔也很难预测,max.poll.interval.ms 用作故障安全或后备。它必须是足够大的间隔,以至于健康的消费者很少会达到,但又足够低,以避免悬挂消费者的重大影响。默认值为 5 分钟。当超时时,后台线程将发送“离开组”请求,让经纪人知道消费者已经死亡,组必须重新平衡,然后停止发送心跳。
默认 API 超时时间
这是一个超时时间,适用于消费者在调用 API 时没有指定显式超时的情况下(几乎)所有 API 调用。默认值为 1 分钟,由于它高于请求超时的默认值,因此在需要时会包括重试。使用此默认值的 API 的一个显着例外是始终需要显式超时的poll()方法。
request.timeout.ms
这是消费者等待来自代理的响应的最长时间。如果代理在此时间内没有响应,客户端将假定代理根本不会响应,关闭连接并尝试重新连接。此配置默认为 30 秒,建议不要降低它。在放弃之前,留给代理足够的时间来处理请求非常重要——重新发送请求到已经过载的代理并没有太多好处,而断开连接和重新连接会增加更多的开销。
auto.offset.reset
此属性控制消费者在开始读取没有提交偏移量的分区时的行为,或者如果其已提交的偏移量无效(通常是因为消费者停机时间太长,以至于该偏移量的记录已经从代理中删除)。默认值为“latest”,这意味着在缺少有效偏移量时,消费者将从最新的记录开始读取(在消费者开始运行后编写的记录)。另一种选择是“earliest”,这意味着在缺少有效偏移量时,消费者将从分区中读取所有数据,从最开始开始。将auto.offset.reset设置为none将导致在尝试从无效偏移量处消费时抛出异常。
enable.auto.commit
此参数控制消费者是否会自动提交偏移量,默认为true。如果您希望控制何时提交偏移量,以最小化重复数据和避免丢失数据,则将其设置为false是必要的。如果将enable.auto.commit设置为true,则可能还希望使用auto.commit.interval.ms控制偏移量的提交频率。我们将在本章后面更深入地讨论提交偏移量的不同选项。
partition.assignment.strategy
我们了解到分区是分配给消费者组中的消费者的。PartitionAssignor是一个类,它根据消费者和它们订阅的主题决定将哪些分区分配给哪些消费者。默认情况下,Kafka 具有以下分配策略:
Range
为每个消费者分配其订阅主题的连续子集的分区。因此,如果消费者 C1 和 C2 订阅了两个主题 T1 和 T2,并且每个主题都有三个分区,那么 C1 将从主题 T1 和 T2 分配分区 0 和 1,而 C2 将从这些主题分配分区 2。由于每个主题的分区数量不均匀,并且分配是针对每个主题独立进行的,第一个消费者最终会比第二个消费者拥有更多的分区。当使用 Range 分配并且消费者的数量不能完全整除每个主题的分区数量时,就会发生这种情况。
RoundRobin
从所有订阅的主题中获取所有分区,并将它们依次分配给消费者。如果先前描述的 C1 和 C2 使用了 RoundRobin 分配,C1 将从主题 T1 获取分区 0 和 2,从主题 T2 获取分区 1。C2 将从主题 T1 获取分区 1,从主题 T2 获取分区 0 和 2。通常情况下,如果所有消费者都订阅了相同的主题(这是一个非常常见的场景),RoundRobin 分配将导致所有消费者拥有相同数量的分区(或者最多有一个分区的差异)。
Sticky
粘性分配器有两个目标:第一个是尽可能平衡的分配,第二个是在重新平衡的情况下,尽可能保留尽可能多的分配,最小化将分区分配从一个消费者移动到另一个消费者所带来的开销。在所有消费者都订阅相同主题的常见情况下,粘性分配器的初始分配将与 RoundRobin 分配器一样平衡。后续的分配将同样平衡,但会减少分区移动的数量。在同一组中的消费者订阅不同主题的情况下,粘性分配器实现的分配比 RoundRobin 分配器更加平衡。
合作粘性
这种分配策略与粘性分配器相同,但支持合作重新平衡,消费者可以继续从未重新分配的分区中消费。请参阅“消费者组和分区重新平衡”以了解更多关于合作重新平衡的信息,并注意,如果您正在从早于 2.3 版本升级,您需要按照特定的升级路径来启用合作粘性分配策略,因此请特别注意升级指南。
partition.assignment.strategy允许您选择分区分配策略。默认值为org.apache.kafka.clients.consumer.RangeAssignor,它实现了前面描述的 Range 策略。您可以将其替换为org.apache.kafka.clients.consumer.RoundRobinAssignor、org.apache.kafka.clients.consumer.StickyAssignor或org.apache.kafka.clients.consumer.CooperativeStickyAssignor。更高级的选项是实现自己的分配策略,在这种情况下,partition.assignment.strategy应指向您的类的名称。
client.id
这可以是任何字符串,并将被代理用于识别从客户端发送的请求,例如获取请求。它用于记录和度量,以及配额。
client.rack
默认情况下,消费者将从每个分区的领导副本获取消息。但是,当集群跨越多个数据中心或多个云可用区时,从与消费者位于同一区域的副本获取消息在性能和成本上都有优势。要启用从最近的副本获取消息,您需要设置client.rack配置,并标识客户端所在的区域。然后,您可以配置代理将默认的replica.selector.class替换为org.apache.kafka.common.replica.RackAwareReplicaSelector。
您还可以使用自己的replica.selector.class实现自定义逻辑,根据客户端元数据和分区元数据选择最佳副本进行消费。
group.instance.id
这可以是任何唯一的字符串,用于提供消费者静态组成员身份。
receive.buffer.bytes 和 send.buffer.bytes
这些是在写入和读取数据时套接字使用的 TCP 发送和接收缓冲区的大小。如果将它们设置为-1,则将使用操作系统默认值。当生产者或消费者与不同数据中心的代理进行通信时,增加这些值可能是个好主意,因为这些网络链接通常具有更高的延迟和较低的带宽。
offsets.retention.minutes
这是一个代理配置,但由于对消费者行为的影响,了解这一点很重要。只要消费者组有活跃成员(即通过发送心跳来积极维护组成员资格的成员),组对每个分区提交的最后偏移量将被 Kafka 保留,以便在重新分配或重新启动时检索。然而,一旦组变为空,Kafka 只会保留其提交的偏移量到此配置设置的持续时间——默认为 7 天。一旦偏移量被删除,如果组再次变为活跃,它将表现得像一个全新的消费者组,对其过去消费的任何内容一无所知。请注意,这种行为已经多次更改,因此如果您使用的是早于 2.1.0 版本的版本,请查看您版本的文档以了解预期的行为。
提交和偏移量
每当我们调用poll()时,它会返回写入 Kafka 的记录,这些记录消费者组中的消费者尚未读取。这意味着我们有一种跟踪消费者组中哪些记录被消费者读取的方法。正如前面讨论的,Kafka 的一个独特特性是它不像许多 JMS 队列那样跟踪消费者的确认。相反,它允许消费者使用 Kafka 来跟踪它们在每个分区中的位置(偏移量)。
我们称更新分区中的当前位置为偏移量提交。与传统的消息队列不同,Kafka 不会逐个提交记录。相反,消费者提交他们从分区中成功处理的最后一条消息,并隐含地假设在最后一条消息之前的每条消息也都被成功处理。
消费者如何提交偏移量?它向 Kafka 发送一条消息,更新一个特殊的__consumer_offsets主题,其中包含每个分区的提交偏移量。只要您的所有消费者都在运行并不断工作,这不会产生影响。但是,如果一个消费者崩溃或新的消费者加入消费者组,这将触发重新平衡。重新平衡后,每个消费者可能被分配一个新的分区集,而不是之前处理的分区集。为了知道从哪里开始工作,消费者将读取每个分区的最新提交偏移量,并从那里继续。
如果提交的偏移量小于客户端处理的最后一条消息的偏移量,那么在最后处理的偏移量和提交的偏移量之间的所有消息将被处理两次。参见图 4-8。

图 4-8:重新处理的消息
如果提交的偏移量大于客户端实际处理的最后一条消息的偏移量,那么在最后处理的偏移量和提交的偏移量之间的所有消息都将被消费者组错过。参见图 4-9。

图 4-9:偏移量之间的遗漏消息
显然,管理偏移量对客户端应用程序有很大影响。KafkaConsumer API 提供了多种提交偏移量的方式。
提交的是哪个偏移量?
当自动提交偏移量或者没有指定预期偏移量时,默认行为是在poll()返回的最后一个偏移量之后提交偏移量。在尝试手动提交特定偏移量或寻求提交特定偏移量时,这一点很重要。然而,反复阅读“提交比客户端从poll()接收到的最后一个偏移量大一”的说明也很繁琐,而且 99%的情况下并不重要。因此,当我们提到默认行为时,我们将写“提交最后一个偏移量”,如果您需要手动操作偏移量,请记住这一点。
自动提交
提交偏移量的最简单方法是允许消费者为您执行。如果配置了enable.auto.commit=true,那么每五秒消费者将提交客户端从poll()接收到的最新偏移量。五秒的间隔是默认值,并通过设置auto.commit.interval.ms来控制。就像消费者中的其他所有内容一样,自动提交是由轮询循环驱动的。每次轮询时,消费者都会检查是否是提交的时间,如果是,它将提交上次轮询返回的偏移量。
然而,在使用这个方便的选项之前,了解后果是很重要的。
默认情况下,自动提交每五秒发生一次。假设我们在最近一次提交后三秒钟消费者崩溃了。重新平衡后,幸存的消费者将开始消费之前由崩溃的代理拥有的分区。但他们将从最后提交的偏移量开始。在这种情况下,偏移量已经过去了三秒,所以在这三秒内到达的所有事件将被处理两次。可以配置提交间隔以更频繁地提交并减少记录重复的时间窗口,但无法完全消除它们。
启用自动提交时,当需要提交偏移量时,下一次轮询将提交上一次轮询返回的最后偏移量。它不知道哪些事件实际上已经被处理,因此在再次调用poll()之前,始终要处理poll()返回的所有事件是至关重要的。(就像poll()一样,close()也会自动提交偏移量。)通常情况下这不是问题,但在处理异常或过早退出轮询循环时要注意。
自动提交很方便,但它们不给开发人员足够的控制来避免重复的消息。
提交当前偏移量
大多数开发人员对提交偏移量的时间有更多的控制——既可以消除错过消息的可能性,也可以减少重新平衡期间重复消息的数量。消费者 API 有一个选项,可以在应用程序开发人员认为合适的时间点提交当前偏移量,而不是基于定时器。
通过设置enable.auto.commit=false,偏移量只有在应用程序明确选择时才会被提交。最简单和最可靠的提交 API 是commitSync()。这个 API 将提交poll()返回的最新偏移量,并在偏移量提交后返回,如果由于某种原因提交失败,则抛出异常。
重要的是要记住commitSync()将提交poll()返回的最新偏移量,因此如果在处理集合中的所有记录之前调用commitSync(),则有可能错过已提交但未处理的消息,如果应用程序崩溃。如果应用程序在仍在处理集合中的记录时崩溃,从最近一批开始直到重新平衡的时间,所有消息将被处理两次——这可能是可取的,也可能不是。
以下是我们如何使用commitSync在完成处理最新一批消息后提交偏移量的方式:
Duration timeout = Duration.ofMillis(100);
while (true) {
ConsumerRecords<String, String> records = consumer.poll(timeout);
for (ConsumerRecord<String, String> record : records) {
System.out.printf("topic = %s, partition = %d, offset =
%d, customer = %s, country = %s\n",
record.topic(), record.partition(),
record.offset(), record.key(), record.value()); // ①
}
try {
consumer.commitSync(); // ②
} catch (CommitFailedException e) {
log.error("commit failed", e) // ③
}
}
①
假设通过打印记录的内容,我们已经完成了对其的处理。您的应用程序可能会对记录进行更多的操作——修改它们,丰富它们,聚合它们,在仪表板上显示它们,或者通知用户重要事件。您应该根据您的用例确定何时对记录进行“完成”处理。
②
一旦我们完成了当前批次中所有记录的“处理”,我们调用commitSync来提交批次中的最后一个偏移量,然后再轮询获取额外的消息。
③
commitSync在没有无法恢复的错误时重试提交。如果发生这种情况,除了记录错误外,我们无能为力。
异步提交
手动提交的一个缺点是应用程序会被阻塞,直到代理响应提交请求。这将限制应用程序的吞吐量。通过减少提交的频率可以提高吞吐量,但这样会增加重新平衡可能创建的潜在重复数量。
另一个选择是异步提交 API。我们不等待代理响应提交,只是发送请求并继续进行:
Duration timeout = Duration.ofMillis(100);
while (true) {
ConsumerRecords<String, String> records = consumer.poll(timeout);
for (ConsumerRecord<String, String> record : records) {
System.out.printf("topic = %s, partition = %s,
offset = %d, customer = %s, country = %s\n",
record.topic(), record.partition(), record.offset(),
record.key(), record.value());
}
consumer.commitAsync(); // ①
}
①
提交最后的偏移量并继续进行。
缺点是,虽然commitSync()会重试提交,直到成功或遇到不可重试的失败,但commitAsync()不会重试。它不重试的原因是,当commitAsync()从服务器接收到响应时,可能已经有一个后续的提交成功了。假设我们发送了一个提交偏移量 2000 的请求。出现了临时通信问题,因此代理从未收到请求,也从未响应。与此同时,我们处理了另一个批次,并成功提交了偏移量 3000。如果commitAsync()现在重试之前失败的提交,它可能会在处理和提交偏移量 3000 之后成功提交偏移量 2000。在重新平衡的情况下,这将导致更多的重复。
我们提到这个复杂性和正确的提交顺序的重要性,因为commitAsync()还提供了一个选项,可以传递一个回调,当代理响应时将触发该回调。通常使用回调来记录提交错误或在指标中计数,但如果要使用回调进行重试,就需要注意提交顺序的问题。
Duration timeout = Duration.ofMillis(100);
while (true) {
ConsumerRecords<String, String> records = consumer.poll(timeout);
for (ConsumerRecord<String, String> record : records) {
System.out.printf("topic = %s, partition = %s,
offset = %d, customer = %s, country = %s\n",
record.topic(), record.partition(), record.offset(),
record.key(), record.value());
}
consumer.commitAsync(new OffsetCommitCallback() {
public void onComplete(Map<TopicPartition,
OffsetAndMetadata> offsets, Exception e) {
if (e != null)
log.error("Commit failed for offsets {}", offsets, e);
}
}); // ①
}
①
我们发送提交并继续进行,但如果提交失败,将记录失败和偏移量。
重试异步提交
为了正确处理异步重试的提交顺序,一个简单的模式是使用单调递增的序列号。每次提交时增加序列号,并在提交时将序列号添加到commitAsync回调中。当准备发送重试时,检查回调得到的提交序列号是否等于实例变量;如果是,表示没有更新的提交,可以安全地重试。如果实例序列号更高,则不要重试,因为已经发送了更新的提交。
结合同步和异步提交
通常,偶尔的提交失败而不重试并不是一个很大的问题,因为如果问题是暂时的,后续的提交将成功。但是,如果我们知道这是在关闭消费者之前的最后一次提交,或者在重新平衡之前,我们希望确保提交成功。
因此,一个常见的模式是在关闭之前将commitAsync()与commitSync()结合在一起。下面是它的工作原理(当我们讨论重新平衡监听器时,我们将讨论如何在重新平衡之前提交):
Duration timeout = Duration.ofMillis(100);
try {
while (!closing) {
ConsumerRecords<String, String> records = consumer.poll(timeout);
for (ConsumerRecord<String, String> record : records) {
System.out.printf("topic = %s, partition = %s, offset = %d,
customer = %s, country = %s\n",
record.topic(), record.partition(),
record.offset(), record.key(), record.value());
}
consumer.commitAsync(); // ①
}
consumer.commitSync(); // ②
} catch (Exception e) {
log.error("Unexpected error", e);
} finally {
consumer.close();
}
①
一切正常时,我们使用commitAsync。它更快,如果一个提交失败,下一个提交将作为重试。
②
但是如果我们正在关闭,就没有“下一个提交”。我们调用commitSync(),因为它会重试直到成功或遇到无法恢复的失败。
指定偏移量
仅提交最新的偏移量只允许您在完成处理批次时提交。但是,如果您想更频繁地提交呢?如果poll()返回一个巨大的批次,并且您希望在批次中间提交偏移量,以避免在重新平衡发生时再次处理所有这些行怎么办?您不能只调用commitSync()或commitAsync()——这将提交您尚未处理的最后一个偏移量。
幸运的是,消费者 API 允许您调用commitSync()和commitAsync()并传递您希望提交的分区和偏移量的映射。如果您正在处理一批记录,并且您从主题“customers”的分区 3 中得到的最后一条消息的偏移量为 5000,您可以调用commitSync()来提交主题“customers”中分区 3 的偏移量 5001。由于您的消费者可能会消费多个分区,因此您需要跟踪所有分区的偏移量,这会增加代码的复杂性。
这是提交特定偏移量的样子:
private Map<TopicPartition, OffsetAndMetadata> currentOffsets =
new HashMap<>(); // ①
int count = 0;
....
Duration timeout = Duration.ofMillis(100);
while (true) {
ConsumerRecords<String, String> records = consumer.poll(timeout);
for (ConsumerRecord<String, String> record : records) {
System.out.printf("topic = %s, partition = %s, offset = %d,
customer = %s, country = %s\n",
record.topic(), record.partition(), record.offset(),
record.key(), record.value()); // ②
currentOffsets.put(
new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset()+1, "no metadata")); // ③
if (count % 1000 == 0) // ④
consumer.commitAsync(currentOffsets, null); // ⑤
count++;
}
}
①
这是我们将用于手动跟踪偏移量的映射。
②
记住,println是您为消耗的记录执行的任何处理的替代品。
③
在读取每条记录后,我们使用下一条消息的偏移量更新偏移量映射。提交的偏移量应始终是您的应用程序将读取的下一条消息的偏移量。这是我们下次开始阅读的地方。
④
在这里,我们决定每 1,000 条记录提交当前偏移量。在您的应用程序中,您可以根据时间或记录的内容进行提交。
⑤
我选择调用commitAsync()(没有回调,因此第二个参数是null),但在这里也完全有效的是commitSync()。当然,当提交特定的偏移量时,您仍然需要执行我们在前几节中看到的所有错误处理。
重新平衡监听器
正如我们在前一节关于提交偏移量中提到的,消费者在退出之前和分区重新平衡之前都希望进行一些清理工作。
如果您知道您的消费者即将失去对分区的所有权,您将希望提交您已处理的最后一个事件的偏移量。也许您还需要关闭文件句柄、数据库连接等。
消费者 API 允许您在从消费者中添加或删除分区时运行自己的代码。您可以通过在调用我们之前讨论的subscribe()方法时传递ConsumerRebalanceListener来实现这一点。ConsumerRebalanceListener有三种方法可以实现:
public void onPartitionsAssigned(Collection<TopicPartition> partitions)
在将分区重新分配给消费者但在消费者开始消费消息之前调用。这是您准备或加载要与分区一起使用的任何状态,如果需要,寻找正确的偏移量或类似操作的地方。在这里做的任何准备工作都应该保证在max.poll.timeout.ms内返回,以便消费者可以成功加入组。
public void onPartitionsRevoked(Collection<TopicPartition> partitions)
当消费者必须放弃之前拥有的分区时调用——无论是作为重新平衡的结果还是消费者被关闭的结果。在通常情况下,当使用急切的重新平衡算法时,这个方法在重新平衡开始之前和消费者停止消费消息之后被调用。如果使用合作式重新平衡算法,这个方法在重新平衡结束时被调用,只有消费者必须放弃的分区的子集。这是你想要提交偏移量的地方,所以下一个得到这个分区的人将知道从哪里开始。
public void onPartitionsLost(Collection<TopicPartition> partitions)
只有在使用合作式重新平衡算法,并且在分区被重新分配给其他消费者之前没有被重新平衡算法撤销的异常情况下才会被调用(在正常情况下,将会调用onPartitionsRevoked())。这是你清理任何与这些分区使用的状态或资源的地方。请注意,这必须小心进行——分区的新所有者可能已经保存了自己的状态,你需要避免冲突。请注意,如果你不实现这个方法,将会调用onPartitionsRevoked()。
提示
如果你使用合作式重新平衡算法,请注意:
-
onPartitionsAssigned()将在每次重新平衡时被调用,作为通知消费者重新平衡发生的方式。然而,如果没有新的分区分配给消费者,它将被调用并传入一个空集合。 -
onPartitionsRevoked()将在正常的重新平衡条件下被调用,但只有在消费者放弃分区所有权时才会被调用。它不会被传入一个空集合。 -
onPartitionsLost()将在异常的重新平衡条件下被调用,而在方法被调用时,集合中的分区已经有了新的所有者。
如果你实现了所有三种方法,你可以确保在正常的重新平衡过程中,onPartitionsAssigned()将被重新分配的分区的新所有者调用,只有在之前的所有者完成了onPartitionsRevoked()并放弃了它的所有权之后才会被调用。
这个例子将展示如何使用onPartitionsRevoked()在失去分区所有权之前提交偏移量:
private Map<TopicPartition, OffsetAndMetadata> currentOffsets =
new HashMap<>();
Duration timeout = Duration.ofMillis(100);
private class HandleRebalance implements ConsumerRebalanceListener { // ①
public void onPartitionsAssigned(Collection<TopicPartition>
partitions) { // ②
}
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
System.out.println("Lost partitions in rebalance. " +
"Committing current offsets:" + currentOffsets);
consumer.commitSync(currentOffsets); // ③
}
}
try {
consumer.subscribe(topics, new HandleRebalance()); // ④
while (true) {
ConsumerRecords<String, String> records = consumer.poll(timeout);
for (ConsumerRecord<String, String> record : records) {
System.out.printf("topic = %s, partition = %s, offset = %d,
customer = %s, country = %s\n",
record.topic(), record.partition(), record.offset(),
record.key(), record.value());
currentOffsets.put(
new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset()+1, null));
}
consumer.commitAsync(currentOffsets, null);
}
} catch (WakeupException e) {
// ignore, we're closing
} catch (Exception e) {
log.error("Unexpected error", e);
} finally {
try {
consumer.commitSync(currentOffsets);
} finally {
consumer.close();
System.out.println("Closed consumer and we are done");
}
}
①
我们首先实现一个ConsumerRebalanceListener。
②
在这个例子中,当我们获得一个新的分区时,我们不需要做任何事情;我们将直接开始消费消息。
③
然而,当我们即将因重新平衡而失去一个分区时,我们需要提交偏移量。我们为所有分区提交偏移量,而不仅仅是我们即将失去的分区——因为这些偏移量是已经处理过的事件,所以没有害处。我们使用commitSync()来确保在重新平衡进行之前提交偏移量。
④
最重要的部分:将ConsumerRebalanceListener传递给subscribe()方法,这样它将被消费者调用。
使用特定偏移量消费记录
到目前为止,我们已经看到如何使用poll()从每个分区的最后提交的偏移量开始消费消息,并按顺序处理所有消息。然而,有时你想从不同的偏移量开始读取。Kafka 提供了各种方法,可以使下一个poll()从不同的偏移量开始消费。
如果你想从分区的开头开始读取所有消息,或者你想跳过到分区的末尾并只开始消费新消息,有专门的 API 可以实现:seekToBeginning(Collection<TopicPartition> tp)和seekToEnd(Collection<TopicPartition> tp)。
Kafka API 还允许您寻找特定的偏移量。这种能力可以以各种方式使用;例如,一个时间敏感的应用程序在落后时可以跳过几条记录,或者将数据写入文件的消费者可以在特定时间点重置以恢复数据,如果文件丢失。
以下是如何将所有分区的当前偏移量设置为特定时间点产生的记录的快速示例:
Long oneHourEarlier = Instant.now().atZone(ZoneId.systemDefault())
.minusHours(1).toEpochSecond();
Map<TopicPartition, Long> partitionTimestampMap = consumer.assignment()
.stream()
.collect(Collectors.toMap(tp -> tp, tp -> oneHourEarlier)); // ①
Map<TopicPartition, OffsetAndTimestamp> offsetMap
= consumer.offsetsForTimes(partitionTimestampMap); // ②
for(Map.Entry<TopicPartition,OffsetAndTimestamp> entry: offsetMap.entrySet()) {
consumer.seek(entry.getKey(), entry.getValue().offset()); // ③
}
①
我们从分配给该消费者的所有分区(通过consumer.assignment())创建一个映射,以便将消费者恢复到我们想要的时间戳。
②
然后我们得到了这些时间戳的当前偏移量。这种方法会向经纪人发送一个请求,其中时间戳索引用于返回相关的偏移量。
③
最后,我们将每个分区的偏移量重置为上一步返回的偏移量。
但是我们如何退出?
在本章的前面,当我们讨论轮询循环时,我们告诉您不要担心消费者在无限循环中轮询的事实,并且我们将讨论如何干净地退出循环。因此,让我们讨论如何干净地退出。
当您决定关闭消费者,并且希望立即退出,即使消费者可能正在等待长时间的poll(),您需要另一个线程调用consumer.wakeup()。如果您在主线程中运行消费者循环,可以从ShutdownHook中完成。请注意,consumer.wakeup()是唯一可以从不同线程调用的消费者方法。调用wakeup将导致poll()以WakeupException退出,或者如果在线程不在等待poll时调用了consumer.wakeup(),则在下一次迭代调用poll()时将抛出异常。不需要处理WakeupException,但在退出线程之前,您必须调用consumer.close()。关闭消费者将提交偏移量(如果需要),并向组协调器发送一条消息,说明消费者正在离开该组。消费者协调器将立即触发重新平衡,您无需等待会话超时,以便将要关闭的消费者的分区分配给组中的另一个消费者。
如果消费者在主应用程序线程中运行,退出代码将如下所示。这个例子有点截断,但你可以在GitHub上查看完整的例子:
Runtime.getRuntime().addShutdownHook(new Thread() {
public void run() {
System.out.println("Starting exit...");
consumer.wakeup(); // ①
try {
mainThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
...
Duration timeout = Duration.ofMillis(10000); // ②
try {
// looping until ctrl-c, the shutdown hook will cleanup on exit
while (true) {
ConsumerRecords<String, String> records =
movingAvg.consumer.poll(timeout);
System.out.println(System.currentTimeMillis() +
"-- waiting for data...");
for (ConsumerRecord<String, String> record : records) {
System.out.printf("offset = %d, key = %s, value = %s\n",
record.offset(), record.key(), record.value());
}
for (TopicPartition tp: consumer.assignment())
System.out.println("Committing offset at position:" +
consumer.position(tp));
movingAvg.consumer.commitSync();
}
} catch (WakeupException e) {
// ignore for shutdown // ③
} finally {
consumer.close(); // ④
System.out.println("Closed consumer and we are done");
}
①
ShutdownHook在单独的线程中运行,因此您可以采取的唯一安全操作是调用wakeup以退出poll循环。
②
特别长的轮询超时。如果轮询循环足够短,而且您不介意在退出之前等待一会儿,您不需要调用wakeup——只需在每次迭代中检查原子布尔值就足够了。长轮询超时在消费低吞吐量主题时非常有用;这样,客户端在经纪人没有新数据返回时使用更少的 CPU 不断循环。
③
另一个调用wakeup的线程将导致poll抛出WakeupException。您需要捕获异常以确保应用程序不会意外退出,但不需要对其进行任何处理。
④
在退出消费者之前,请确保干净地关闭它。
反序列化器
如前一章所讨论的,Kafka 生产者需要序列化器将对象转换为字节数组,然后将其发送到 Kafka。同样,Kafka 消费者需要反序列化器将从 Kafka 接收的字节数组转换为 Java 对象。在先前的示例中,我们只假设每条消息的键和值都是字符串,并且在消费者配置中使用了默认的StringDeserializer。
在第三章中关于 Kafka 生产者,我们看到了如何序列化自定义类型以及如何使用 Avro 和AvroSerializers从模式定义生成 Avro 对象,然后在生产消息到 Kafka 时对它们进行序列化。我们现在将看看如何为您自己的对象创建自定义反序列化器以及如何使用 Avro 及其反序列化器。
显然,用IntSerializer进行序列化然后用StringDeserializer进行反序列化将不会有好结果。这意味着作为开发人员,您需要跟踪用于写入每个主题的序列化器,并确保每个主题只包含您使用的反序列化器可以解释的数据。这是使用 Avro 和模式注册表进行序列化和反序列化的好处之一——AvroSerializer可以确保写入特定主题的所有数据与主题的模式兼容,这意味着它可以与匹配的反序列化器和模式进行反序列化。生产者或消费者端的任何兼容性错误都将很容易地通过适当的错误消息捕获,这意味着您不需要尝试调试字节数组以解决序列化错误。
我们将首先快速展示如何编写自定义反序列化器,尽管这是较少见的方法,然后我们将继续介绍如何使用 Avro 来反序列化消息键和值的示例。
自定义反序列化器
让我们使用在第三章中序列化的相同自定义对象,并为其编写一个反序列化器:
public class Customer {
private int customerID;
private String customerName;
public Customer(int ID, String name) {
this.customerID = ID;
this.customerName = name;
}
public int getID() {
return customerID;
}
public String getName() {
return customerName;
}
}
自定义反序列化器将如下所示:
import org.apache.kafka.common.errors.SerializationException;
import java.nio.ByteBuffer;
import java.util.Map;
public class CustomerDeserializer implements Deserializer<Customer> { // ①
@Override
public void configure(Map configs, boolean isKey) {
// nothing to configure
}
@Override
public Customer deserialize(String topic, byte[] data) {
int id;
int nameSize;
String name;
try {
if (data == null)
return null;
if (data.length < 8)
throw new SerializationException("Size of data received " +
"by deserializer is shorter than expected");
ByteBuffer buffer = ByteBuffer.wrap(data);
id = buffer.getInt();
nameSize = buffer.getInt();
byte[] nameBytes = new byte[nameSize];
buffer.get(nameBytes);
name = new String(nameBytes, "UTF-8");
return new Customer(id, name); // ②
} catch (Exception e) {
throw new SerializationException("Error when deserializing " + "byte[] to Customer " + e);
}
}
@Override
public void close() {
// nothing to close
}
}
①
消费者还需要Customer类的实现,类和序列化器在生产和消费应用程序中需要匹配。在一个拥有许多消费者和生产者共享数据访问权限的大型组织中,这可能会变得具有挑战性。
②
我们在这里只是颠倒了序列化器的逻辑——我们从字节数组中获取客户 ID 和名称,并使用它们构造我们需要的对象。
使用此反序列化器的消费者代码将类似于以下示例:
Duration timeout = Duration.ofMillis(100);
Properties props = new Properties();
props.put("bootstrap.servers", "broker1:9092,broker2:9092");
props.put("group.id", "CountryCounter");
props.put("key.deserializer",
"org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer",
CustomerDeserializer.class.getName());
KafkaConsumer<String, Customer> consumer =
new KafkaConsumer<>(props);
consumer.subscribe(Collections.singletonList("customerCountries"))
while (true) {
ConsumerRecords<String, Customer> records = consumer.poll(timeout);
for (ConsumerRecord<String, Customer> record : records) {
System.out.println("current customer Id: " +
record.value().getID() + " and
current customer name: " + record.value().getName());
}
consumer.commitSync();
}
再次强调,不建议实现自定义序列化器和反序列化器。它会紧密耦合生产者和消费者,并且容易出错。更好的解决方案是使用标准消息格式,如 JSON、Thrift、Protobuf 或 Avro。现在我们将看看如何在 Kafka 消费者中使用 Avro 反序列化器。有关 Apache Avro、其模式和模式兼容性能力的背景,请参阅第三章。
使用 Avro 反序列化与 Kafka 消费者
假设我们正在使用在第三章中展示的 Avro 中的Customer类的实现。为了从 Kafka 中消费这些对象,您需要实现类似于以下的消费应用程序:
Duration timeout = Duration.ofMillis(100);
Properties props = new Properties();
props.put("bootstrap.servers", "broker1:9092,broker2:9092");
props.put("group.id", "CountryCounter");
props.put("key.deserializer",
"org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer",
"io.confluent.kafka.serializers.KafkaAvroDeserializer"); // ①
props.put("specific.avro.reader","true");
props.put("schema.registry.url", schemaUrl); // ②
String topic = "customerContacts"
KafkaConsumer<String, Customer> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Collections.singletonList(topic));
System.out.println("Reading topic:" + topic);
while (true) {
ConsumerRecords<String, Customer> records = consumer.poll(timeout); // ③
for (ConsumerRecord<String, Customer> record: records) {
System.out.println("Current customer name is: " +
record.value().getName()); // ④
}
consumer.commitSync();
}
①
我们使用KafkaAvroDeserializer来反序列化 Avro 消息。
②
schema.registry.url是一个新参数。这只是指向我们存储模式的位置。这样,消费者可以使用生产者注册的模式来反序列化消息。
③
我们将生成的类Customer指定为记录值的类型。
④
record.value()是一个Customer实例,我们可以相应地使用它。
独立消费者:为什么以及如何使用没有组的消费者
到目前为止,我们已经讨论了消费者组,这是分区自动分配给消费者并在从组中添加或删除消费者时自动重新平衡的地方。通常,这种行为正是您想要的,但在某些情况下,您可能需要更简单的东西。有时,您知道您有一个单一的消费者,它总是需要从主题中的所有分区或特定分区中读取数据。在这种情况下,没有组或重新平衡的原因-只需分配特定于消费者的主题和/或分区,消耗消息,并偶尔提交偏移量(尽管您仍然需要配置group.id来提交偏移量,而不调用subscribe消费者不会加入任何组)。
当您确切地知道消费者应该读取哪些分区时,您不会订阅主题,而是分配给自己一些分区。消费者可以订阅主题(并成为消费者组的一部分)或分配自己的分区,但不能同时两者兼而有之。
以下是一个消费者如何分配给自己特定主题的所有分区并从中消费的示例:
Duration timeout = Duration.ofMillis(100);
List<PartitionInfo> partitionInfos = null;
partitionInfos = consumer.partitionsFor("topic"); // ①
if (partitionInfos != null) {
for (PartitionInfo partition : partitionInfos)
partitions.add(new TopicPartition(partition.topic(),
partition.partition()));
consumer.assign(partitions); // ②
while (true) {
ConsumerRecords<String, String> records = consumer.poll(timeout);
for (ConsumerRecord<String, String> record: records) {
System.out.printf("topic = %s, partition = %s, offset = %d,
customer = %s, country = %s\n",
record.topic(), record.partition(), record.offset(),
record.key(), record.value());
}
consumer.commitSync();
}
}
①
我们首先向集群询问主题中可用的分区。如果您只打算消费特定分区,可以跳过此部分。
②
一旦我们知道我们想要哪些分区,我们就用列表调用assign()。
除了缺少重新平衡和手动查找分区之外,其他一切都是照常进行。请记住,如果有人向主题添加新分区,消费者将不会收到通知。您需要通过定期检查consumer.partitionsFor()或简单地在添加分区时重新启动应用程序来处理此问题。
总结
我们从深入解释 Kafka 的消费者组以及它们允许多个消费者共享从主题中读取事件的工作的理论讨论开始了这一章。我们在理论讨论之后,通过一个消费者订阅主题并持续读取事件的实际示例,来了解消费者配置参数的最重要部分以及它们如何影响消费者行为。我们在这一章的大部分时间都用来讨论偏移量以及消费者如何跟踪它们。了解消费者如何提交偏移量在编写可靠的消费者时至关重要,因此我们花时间解释了可以完成此操作的不同方式。然后,我们讨论了消费者 API 的其他部分,处理重新平衡和关闭消费者。
最后,我们讨论了消费者使用的反序列化器,将存储在 Kafka 中的字节转换为应用程序可以处理的 Java 对象。我们详细讨论了 Avro 反序列化器,尽管它们只是您可以使用的一种反序列化器类型,因为这些是与 Kafka 一起最常用的。
¹ Sophie Blee-Goldman 的图表,取自她 2020 年 5 月的博客文章“从渴望到更聪明的 Apache Kafka 消费者再平衡”。
第五章:以编程方式管理 Apache Kafka
有许多用于管理 Kafka 的 CLI 和 GUI 工具(我们将在第九章中讨论它们),但有时您也希望从客户端应用程序中执行一些管理命令。根据用户输入或数据按需创建新主题是一个特别常见的用例:物联网(IoT)应用程序经常从用户设备接收事件,并根据设备类型将事件写入主题。如果制造商生产了一种新类型的设备,您要么必须通过某种流程记住也创建一个主题,要么应用程序可以在收到未识别设备类型的事件时动态创建一个新主题。第二种选择有缺点,但在适当的场景中避免依赖其他流程生成主题是一个吸引人的特性。
Apache Kafka 在 0.11 版本中添加了 AdminClient,以提供用于管理功能的编程 API,以前是在命令行中完成的:列出、创建和删除主题;描述集群;管理 ACL;以及修改配置。
这里有一个例子。您的应用程序将向特定主题生成事件。这意味着在生成第一个事件之前,主题必须存在。在 Apache Kafka 添加 AdminClient 之前,几乎没有什么选择,而且没有一个特别用户友好的选择:您可以从“producer.send()”方法捕获“UNKNOWN_TOPIC_OR_PARTITION”异常,并让用户知道他们需要创建主题,或者您可以希望您写入的 Kafka 集群启用了自动主题创建,或者您可以尝试依赖内部 API 并处理没有兼容性保证的后果。现在 Apache Kafka 提供了 AdminClient,有一个更好的解决方案:使用 AdminClient 检查主题是否存在,如果不存在,立即创建它。
在本章中,我们将概述 AdminClient,然后深入探讨如何在应用程序中使用它的细节。我们将重点介绍最常用的功能:主题、消费者组和实体配置的管理。
AdminClient 概述
当您开始使用 Kafka AdminClient 时,了解其核心设计原则将有所帮助。当您了解了 AdminClient 的设计方式以及应该如何使用时,每种方法的具体内容将更加直观。
异步和最终一致的 API
也许关于 Kafka 的 AdminClient 最重要的一点是它是异步的。每个方法在将请求传递给集群控制器后立即返回,并且每个方法返回一个或多个“Future”对象。“Future”对象是异步操作的结果,并且具有用于检查异步操作状态、取消异步操作、等待其完成以及在其完成后执行函数的方法。Kafka 的 AdminClient 将“Future”对象封装到“Result”对象中,提供了等待操作完成的方法和用于常见后续操作的辅助方法。例如,“KafkaAdminClient.createTopics”返回“CreateTopicsResult”对象,该对象允许您等待所有主题创建完成,单独检查每个主题的状态,并在创建后检索特定主题的配置。
由于 Kafka 从控制器到代理的元数据传播是异步的,AdminClient API 返回的“Futures”在控制器状态完全更新后被视为完成。在那时,不是每个代理都可能意识到新状态,因此“listTopics”请求可能会由一个不是最新的代理处理,并且不会包含最近创建的主题。这种属性也被称为最终一致性:最终每个代理都将了解每个主题,但我们无法保证这将在何时发生。
选项
AdminClient 中的每个方法都接受一个特定于该方法的Options对象作为参数。例如,listTopics方法将ListTopicsOptions对象作为参数,describeCluster将DescribeClusterOptions作为参数。这些对象包含了请求将由代理如何处理的不同设置。所有 AdminClient 方法都具有的一个设置是timeoutMs:这控制客户端在抛出TimeoutException之前等待来自集群的响应的时间。这限制了您的应用程序可能被 AdminClient 操作阻塞的时间。其他选项包括listTopics是否还应返回内部主题,以及describeCluster是否还应返回客户端被授权在集群上执行哪些操作。
扁平层次结构
Apache Kafka 协议支持的所有管理操作都直接在KafkaAdminClient中实现。没有对象层次结构或命名空间。这有点有争议,因为接口可能相当庞大,也许有点令人不知所措,但主要好处是,如果您想知道如何在 Kafka 上以编程方式执行任何管理操作,您只需搜索一个 JavaDoc,并且您的 IDE 自动完成将非常方便。您不必纠结于是否只是错过了查找的正确位置。如果不在 AdminClient 中,那么它还没有被实现(但欢迎贡献!)。
提示
如果您有兴趣为 Apache Kafka 做出贡献,请查看我们的“如何贡献”指南。在着手进行对架构或协议的更重大更改之前,先从较小的、不具争议的错误修复和改进开始。也鼓励非代码贡献,如错误报告、文档改进、回答问题和博客文章。
附加说明
修改集群状态的所有操作——创建、删除和更改——都由控制器处理。读取集群状态的操作——列出和描述——可以由任何代理处理,并且会被定向到最不负载的代理(基于客户端所知)。这不应影响您作为 API 用户,但如果您发现意外行为,注意到某些操作成功而其他操作失败,或者如果您试图弄清楚为什么某个操作花费太长时间,这可能是有好处的。
在我们撰写本章时(Apache Kafka 2.5 即将发布),大多数管理操作可以通过 AdminClient 或直接通过修改 ZooKeeper 中的集群元数据来执行。我们强烈建议您永远不要直接使用 ZooKeeper,如果您绝对必须这样做,请将其报告为 Apache Kafka 的错误。原因是在不久的将来,Apache Kafka 社区将删除对 ZooKeeper 的依赖,每个使用 ZooKeeper 直接进行管理操作的应用程序都必须进行修改。另一方面,AdminClient API 将保持完全相同,只是在 Kafka 集群内部有不同的实现。
AdminClient 生命周期:创建、配置和关闭
要使用 Kafka 的 AdminClient,您首先必须构建 AdminClient 类的实例。这非常简单:
Properties props = new Properties();
props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
AdminClient admin = AdminClient.create(props);
// TODO: Do something useful with AdminClient
admin.close(Duration.ofSeconds(30));
静态的create方法接受一个配置了Properties对象的参数。唯一必需的配置是集群的 URI:一个逗号分隔的要连接的代理列表。通常在生产环境中,您希望至少指定三个代理,以防其中一个当前不可用。我们将在第十一章中讨论如何单独配置安全和经过身份验证的连接。
如果您启动了 AdminClient,最终您会想要关闭它。重要的是要记住,当您调用close时,可能仍然有一些 AdminClient 操作正在进行中。因此,close方法接受一个超时参数。一旦您调用close,就不能调用任何其他方法或发送任何其他请求,但客户端将等待响应直到超时到期。超时到期后,客户端将中止所有正在进行的操作,并释放所有资源。在没有超时的情况下调用close意味着客户端将等待所有正在进行的操作完成。
您可能还记得第三章和第四章中提到的KafkaProducer和KafkaConsumer有许多重要的配置参数。好消息是 AdminClient 要简单得多,没有太多需要配置的地方。您可以在Kafka 文档中阅读所有配置参数。在我们看来,重要的配置参数在以下部分中有描述。
client.dns.lookup
这个配置是在 Apache Kafka 2.1.0 版本中引入的。
默认情况下,Kafka 根据引导服务器配置中提供的主机名(以及稍后在advertised.listeners配置中由代理返回的名称)验证、解析和创建连接。这种简单的模型在大多数情况下都有效,但未能涵盖两个重要的用例:使用 DNS 别名,特别是在引导配置中,以及使用映射到多个 IP 地址的单个 DNS。这些听起来相似,但略有不同。让我们更详细地看看这两种互斥的情况。
使用 DNS 别名
假设您有多个代理,命名规则如下:broker1.hostname.com,broker2.hostname.com等。您可能希望创建一个单个的 DNS 别名,将所有这些代理映射到一个别名上,而不是在引导服务器配置中指定所有这些代理,这可能很难维护。您将使用all-brokers.hostname.com进行引导,因为您实际上并不关心哪个代理从客户端获得初始连接。这一切都非常方便,除非您使用 SASL 进行身份验证。如果使用 SASL,客户端将尝试对all-brokers.hostname.com进行身份验证,但服务器主体将是broker2.hostname.com。如果名称不匹配,SASL 将拒绝进行身份验证(代理证书可能是中间人攻击),连接将失败。
在这种情况下,您将希望使用client.dns.lookup=resolve_canonical_bootstrap_servers_only。通过这种配置,客户端将“展开”DNS 别名,结果将与在原始引导列表中将 DNS 别名连接到的所有代理作为代理一样。
具有多个 IP 地址的 DNS 名称
在现代网络架构中,通常会将所有代理放在代理或负载均衡器后面。如果您使用 Kubernetes,这种情况尤其常见,因为负载均衡器是必要的,以允许来自 Kubernetes 集群外部的连接。在这些情况下,您不希望负载均衡器成为单点故障。因此,非常常见的是broker1.hostname.com指向一组 IP,所有这些 IP 都解析为负载均衡器,并且所有这些 IP 都将流量路由到同一个代理。这些 IP 也可能随时间而变化。默认情况下,Kafka 客户端将尝试连接主机名解析的第一个 IP。这意味着如果该 IP 不可用,客户端将无法连接,即使代理完全可用。因此,强烈建议使用client.dns.lookup=use_all_dns_ips,以确保客户端不会错过高可用负载均衡层的好处。
request.timeout.ms
这个配置限制了应用程序等待 AdminClient 响应的时间。这包括在客户端收到可重试错误时重试的时间。
默认值是 120 秒,这相当长,但某些 AdminClient 操作,特别是消费者组管理命令,可能需要一段时间才能响应。正如我们在“AdminClient Overview”中提到的,每个 AdminClient 方法都接受一个Options对象,其中可以包含一个特定于该调用的超时值。如果 AdminClient 操作对于您的应用程序的关键路径,您可能希望使用较低的超时值,并以不同的方式处理来自 Kafka 的及时响应的缺乏。一个常见的例子是,服务在首次启动时尝试验证特定主题的存在,但如果 Kafka 花费超过 30 秒来响应,您可能希望继续启动服务器,并稍后验证主题的存在(或完全跳过此验证)。
基本主题管理
现在我们已经创建并配置了一个 AdminClient,是时候看看我们可以用它做什么了。Kafka 的 AdminClient 最常见的用例是主题管理。这包括列出主题、描述主题、创建主题和删除主题。
让我们首先列出集群中的所有主题:
ListTopicsResult topics = admin.listTopics();
topics.names().get().forEach(System.out::println);
请注意,admin.listTopics()返回ListTopicsResult对象,它是对Futures集合的薄包装。还要注意,topics.name()返回name的Future集。当我们在这个Future上调用get()时,执行线程将等待服务器响应一组主题名称,或者我们收到超时异常。一旦我们得到列表,我们遍历它以打印所有主题名称。
现在让我们尝试一些更有雄心的事情:检查主题是否存在,如果不存在则创建。检查特定主题是否存在的一种方法是获取所有主题的列表,并检查您需要的主题是否在列表中。在大型集群上,这可能效率低下。此外,有时您希望检查的不仅仅是主题是否存在 - 您希望确保主题具有正确数量的分区和副本。例如,Kafka Connect 和 Confluent Schema Registry 使用 Kafka 主题存储配置。当它们启动时,它们会检查配置主题是否存在,它只有一个分区以确保配置更改按严格顺序到达,它有三个副本以确保可用性,并且主题是压缩的,因此旧配置将被无限期保留:
DescribeTopicsResult demoTopic = admin.describeTopics(TOPIC_LIST); // ①
try {
topicDescription = demoTopic.values().get(TOPIC_NAME).get(); // ②
System.out.println("Description of demo topic:" + topicDescription);
if (topicDescription.partitions().size() != NUM_PARTITIONS) { // ③
System.out.println("Topic has wrong number of partitions. Exiting.");
System.exit(-1);
}
} catch (ExecutionException e) { // ④
// exit early for almost all exceptions
if (! (e.getCause() instanceof UnknownTopicOrPartitionException)) {
e.printStackTrace();
throw e;
}
// if we are here, topic doesn't exist
System.out.println("Topic " + TOPIC_NAME +
" does not exist. Going to create it now");
// Note that number of partitions and replicas is optional. If they are
// not specified, the defaults configured on the Kafka brokers will be used
CreateTopicsResult newTopic = admin.createTopics(Collections.singletonList(
new NewTopic(TOPIC_NAME, NUM_PARTITIONS, REP_FACTOR))); // ⑤
// Check that the topic was created correctly:
if (newTopic.numPartitions(TOPIC_NAME).get() != NUM_PARTITIONS) { // ⑥
System.out.println("Topic has wrong number of partitions.");
System.exit(-1);
}
}
①
验证主题是否以正确的配置存在,我们使用要验证的主题名称列表调用describeTopics()。这将返回DescribeTopicResult对象,其中包装了主题名称到Future描述的映射。
②
我们已经看到,如果我们等待Future完成,使用get()我们可以得到我们想要的结果,在这种情况下是TopicDescription。但也有可能服务器无法正确完成请求 - 如果主题不存在,服务器无法响应其描述。在这种情况下,服务器将返回错误,并且Future将通过抛出ExecutionException完成。服务器发送的实际错误将是异常的cause。由于我们想要处理主题不存在的情况,我们处理这些异常。
③
如果主题存在,Future将通过返回TopicDescription来完成,其中包含主题所有分区的列表,以及每个分区中作为领导者的经纪人的副本列表和同步副本列表。请注意,这不包括主题的配置。我们将在本章后面讨论配置。
④
请注意,当 Kafka 响应错误时,所有 AdminClient 结果对象都会抛出ExecutionException。这是因为 AdminClient 结果被包装在Future对象中,而这些对象包装了异常。您总是需要检查ExecutionException的原因以获取 Kafka 返回的错误。
⑤
如果主题不存在,我们将创建一个新主题。在创建主题时,您可以仅指定名称并对所有细节使用默认值。您还可以指定分区数、副本数和配置。
⑥
最后,您希望等待主题创建完成,并可能验证结果。在此示例中,我们正在检查分区数。由于我们在创建主题时指定了分区数,我们相当确定它是正确的。如果您在创建主题时依赖经纪人默认值,则更常见地检查结果。请注意,由于我们再次调用get()来检查CreateTopic的结果,此方法可能会抛出异常。在这种情况下,TopicExistsException很常见,您需要处理它(也许通过描述主题来检查正确的配置)。
现在我们有了一个主题,让我们删除它:
admin.deleteTopics(TOPIC_LIST).all().get();
// Check that it is gone. Note that due to the async nature of deletes,
// it is possible that at this point the topic still exists
try {
topicDescription = demoTopic.values().get(TOPIC_NAME).get();
System.out.println("Topic " + TOPIC_NAME + " is still around");
} catch (ExecutionException e) {
System.out.println("Topic " + TOPIC_NAME + " is gone");
}
此时代码应该相当熟悉。我们使用deleteTopics方法删除一个主题名称列表,并使用get()等待完成。
警告
尽管代码很简单,请记住,在 Kafka 中,删除主题是最终的——没有回收站或垃圾桶可以帮助您恢复已删除的主题,也没有检查来验证主题是否为空,以及您是否真的想要删除它。删除错误的主题可能意味着无法恢复的数据丢失,因此请特别小心处理此方法。
到目前为止,所有示例都使用了不同AdminClient方法返回的Future上的阻塞get()调用。大多数情况下,这就是您所需要的——管理操作很少,等待操作成功或超时通常是可以接受的。有一个例外:如果您要写入一个预期处理大量管理请求的服务器。在这种情况下,您不希望在等待 Kafka 响应时阻塞服务器线程。您希望继续接受用户的请求并将其发送到 Kafka,当 Kafka 响应时,将响应发送给客户端。在这些情况下,KafkaFuture的多功能性就变得非常有用。这是一个简单的例子。
vertx.createHttpServer().requestHandler(request -> { // ①
String topic = request.getParam("topic"); // ②
String timeout = request.getParam("timeout");
int timeoutMs = NumberUtils.toInt(timeout, 1000);
DescribeTopicsResult demoTopic = admin.describeTopics( // ③
Collections.singletonList(topic),
new DescribeTopicsOptions().timeoutMs(timeoutMs));
demoTopic.values().get(topic).whenComplete( // ④
new KafkaFuture.BiConsumer<TopicDescription, Throwable>() {
@Override
public void accept(final TopicDescription topicDescription,
final Throwable throwable) {
if (throwable != null) {
request.response().end("Error trying to describe topic "
+ topic + " due to " + throwable.getMessage()); // ⑤
} else {
request.response().end(topicDescription.toString()); // ⑥
}
}
});
}).listen(8080);
①
我们使用 Vert.x 创建一个简单的 HTTP 服务器。每当此服务器收到请求时,它将调用我们在这里定义的requestHandler。
②
请求包括一个主题名称作为参数,我们将用这个主题的描述作为响应。
③
我们像往常一样调用AdminClient.describeTopics并获得包装的Future作为响应。
④
我们不使用阻塞的get()调用,而是构造一个在Future完成时将被调用的函数。
⑤
如果Future完成时出现异常,我们会将错误发送给 HTTP 客户端。
⑥
如果Future成功完成,我们将使用主题描述回复客户端。
关键在于我们不会等待 Kafka 的响应。当来自 Kafka 的响应到达时,DescribeTopicResult将向 HTTP 客户端发送响应。与此同时,HTTP 服务器可以继续处理其他请求。您可以通过使用SIGSTOP来暂停 Kafka(不要在生产环境中尝试!)并向 Vert.x 发送两个 HTTP 请求来检查此行为:一个具有较长的超时值,一个具有较短的超时值。即使您在第一个请求之后发送了第二个请求,由于较低的超时值,它将更早地响应,并且不会在第一个请求之后阻塞。
配置管理
配置管理是通过描述和更新ConfigResource集合来完成的。配置资源可以是代理、代理记录器和主题。通常使用kafka-config.sh或其他 Kafka 管理工具来检查和修改代理和代理记录器配置,但从使用它们的应用程序中检查和更新主题配置是非常常见的。
例如,许多应用程序依赖于正确操作的压缩主题。有意义的是,定期(比默认保留期更频繁,以确保安全)这些应用程序将检查主题是否确实被压缩,并采取行动来纠正主题配置(如果未被压缩)。
以下是一个示例:
ConfigResource configResource =
new ConfigResource(ConfigResource.Type.TOPIC, TOPIC_NAME); // ①
DescribeConfigsResult configsResult =
admin.describeConfigs(Collections.singleton(configResource));
Config configs = configsResult.all().get().get(configResource);
// print nondefault configs
configs.entries().stream().filter(
entry -> !entry.isDefault()).forEach(System.out::println); // ②
// Check if topic is compacted
ConfigEntry compaction = new ConfigEntry(TopicConfig.CLEANUP_POLICY_CONFIG,
TopicConfig.CLEANUP_POLICY_COMPACT);
if (!configs.entries().contains(compaction)) {
// if topic is not compacted, compact it
Collection<AlterConfigOp> configOp = new ArrayList<AlterConfigOp>();
configOp.add(new AlterConfigOp(compaction, AlterConfigOp.OpType.SET)); // ③
Map<ConfigResource, Collection<AlterConfigOp>> alterConf = new HashMap<>();
alterConf.put(configResource, configOp);
admin.incrementalAlterConfigs(alterConf).all().get();
} else {
System.out.println("Topic " + TOPIC_NAME + " is compacted topic");
}
①
如上所述,有几种类型的ConfigResource;在这里,我们正在检查特定主题的配置。您可以在同一请求中指定来自不同类型的多个不同资源。
②
describeConfigs的结果是从每个ConfigResource到一组配置的映射。每个配置条目都有一个isDefault()方法,让我们知道哪些配置已被修改。如果用户配置了主题以具有非默认值,或者如果修改了代理级别配置并且创建的主题从代理继承了此非默认值,则认为主题配置是非默认的。
③
要修改配置,指定要修改的ConfigResource的映射和一组操作。每个配置修改操作由配置条目(配置的名称和值;在本例中,cleanup.policy是配置名称,compacted是值)和操作类型组成。在 Kafka 中,有四种类型的操作可以修改配置:SET,用于设置配置值;DELETE,用于删除值并重置为默认值;APPEND;和SUBSTRACT。最后两种仅适用于具有List类型的配置,并允许添加和删除值,而无需每次都将整个列表发送到 Kafka。
在紧急情况下,描述配置可能会非常方便。我们记得有一次在升级过程中,代理的配置文件被意外替换为损坏的副本。在重新启动第一个代理后发现它无法启动。团队没有办法恢复原始配置,因此我们准备进行大量的试错,试图重建正确的配置并使代理恢复正常。一位站点可靠性工程师(SRE)通过连接到剩余的代理之一并使用 AdminClient 转储其配置来挽救了当天。
消费者组管理
我们之前提到过,与大多数消息队列不同,Kafka 允许你以先前消费和处理数据的确切顺序重新处理数据。在第四章中,我们讨论了消费者组时,解释了如何使用消费者 API 从主题中返回并重新读取旧消息。但是使用这些 API 意味着你需要提前将重新处理数据的能力编程到你的应用程序中。你的应用程序本身必须暴露“重新处理”功能。
有几种情况下,你会想要导致应用程序重新处理消息,即使这种能力事先没有内置到应用程序中。在事故期间排除应用程序故障是其中一种情况。另一种情况是在灾难恢复故障转移场景中准备应用程序在新集群上运行(我们将在第九章中更详细地讨论这一点,当我们讨论灾难恢复技术时)。
在本节中,我们将看看如何使用 AdminClient 来以编程方式探索和修改消费者组以及这些组提交的偏移量。在第十章中,我们将看看可用于执行相同操作的外部工具。
探索消费者组
如果你想要探索和修改消费者组,第一步是列出它们:
admin.listConsumerGroups().valid().get().forEach(System.out::println);
通过使用valid()方法,get()将返回的集合只包含集群返回的没有错误的消费者组,如果有的话。任何错误将被完全忽略,而不是作为异常抛出。errors()方法可用于获取所有异常。如果像我们在其他示例中所做的那样使用all(),集群返回的第一个错误将作为异常抛出。这种错误的可能原因是授权,即你没有权限查看该组,或者某些消费者组的协调者不可用。
如果我们想要更多关于某些组的信息,我们可以描述它们:
ConsumerGroupDescription groupDescription = admin
.describeConsumerGroups(CONSUMER_GRP_LIST)
.describedGroups().get(CONSUMER_GROUP).get();
System.out.println("Description of group " + CONSUMER_GROUP
+ ":" + groupDescription);
描述包含了关于该组的大量信息。这包括了组成员、它们的标识符和主机、分配给它们的分区、用于分配的算法,以及组协调者的主机。在故障排除消费者组时,这个描述非常有用。关于消费者组最重要的信息之一在这个描述中缺失了——不可避免地,我们会想知道该组对于它正在消费的每个分区最后提交的偏移量是多少,以及它落后于日志中最新消息的数量。
过去,获取这些信息的唯一方法是解析消费者组写入内部 Kafka 主题的提交消息。虽然这种方法达到了其目的,但 Kafka 不保证内部消息格式的兼容性,因此不推荐使用旧方法。我们将看看 Kafka 的 AdminClient 如何允许我们检索这些信息:
Map<TopicPartition, OffsetAndMetadata> offsets =
admin.listConsumerGroupOffsets(CONSUMER_GROUP)
.partitionsToOffsetAndMetadata().get(); // ①
Map<TopicPartition, OffsetSpec> requestLatestOffsets = new HashMap<>();
for(TopicPartition tp: offsets.keySet()) {
requestLatestOffsets.put(tp, OffsetSpec.latest()); // ②
}
Map<TopicPartition, ListOffsetsResult.ListOffsetsResultInfo> latestOffsets =
admin.listOffsets(requestLatestOffsets).all().get();
for (Map.Entry<TopicPartition, OffsetAndMetadata> e: offsets.entrySet()) { // ③
String topic = e.getKey().topic();
int partition = e.getKey().partition();
long committedOffset = e.getValue().offset();
long latestOffset = latestOffsets.get(e.getKey()).offset();
System.out.println("Consumer group " + CONSUMER_GROUP
+ " has committed offset " + committedOffset
+ " to topic " + topic + " partition " + partition
+ ". The latest offset in the partition is "
+ latestOffset + " so consumer group is "
+ (latestOffset - committedOffset) + " records behind");
}
①
我们获取消费者组处理的所有主题和分区的映射,以及每个分区的最新提交的偏移量。请注意,与describeConsumerGroups不同,listConsumerGroupOffsets只接受单个消费者组,而不是集合。
②
对于结果中的每个主题和分区,我们想要获取分区中最后一条消息的偏移量。OffsetSpec有三种非常方便的实现:earliest()、latest()和forTimestamp(),它们允许我们获取分区中的最早和最新的偏移量,以及在指定时间之后或立即之后写入的记录的偏移量。
③
最后,我们遍历所有分区,并对于每个分区打印最后提交的偏移量、分区中的最新偏移量以及它们之间的滞后。
修改消费者组
到目前为止,我们只是探索了可用的信息。AdminClient 还具有修改消费者组的方法:删除组、删除成员、删除提交的偏移量和修改偏移量。这些通常由 SRE 用于构建临时工具,以从紧急情况中恢复。
在所有这些情况中,修改偏移量是最有用的。删除偏移量可能看起来像是让消费者“从头开始”的简单方法,但这实际上取决于消费者的配置 - 如果消费者启动并且找不到偏移量,它会从头开始吗?还是跳到最新的消息?除非我们有auto.offset.reset的值,否则我们无法知道。显式地修改提交的偏移量为最早可用的偏移量将强制消费者从主题的开头开始处理,并且基本上会导致消费者“重置”。
请记住,消费者组在偏移量在偏移量主题中发生变化时不会收到更新。它们只在分配新分区给消费者或启动时读取偏移量。为了防止您对消费者不会知道的偏移量进行更改(因此将覆盖),Kafka 将阻止您在消费者组活动时修改偏移量。
还要记住,如果消费者应用程序维护状态(大多数流处理应用程序都会维护状态),重置偏移量并导致消费者组从主题的开头开始处理可能会对存储的状态产生奇怪的影响。例如,假设您有一个流应用程序,不断计算商店销售的鞋子数量,并且假设在早上 8:00 发现输入错误,并且您想要完全重新计算自 3:00 a.m.以来的数量。如果您将偏移量重置为 3:00 a.m.,而没有适当修改存储的聚合,您将计算今天卖出的每双鞋子两次(您还将处理 3:00 a.m.和 8:00 a.m.之间的所有数据,但让我们假设这是必要的来纠正错误)。您需要小心相应地更新存储的状态。在开发环境中,我们通常在重置偏移量到输入主题的开头之前完全删除状态存储。
在脑海中牢记所有这些警告,让我们来看一个例子:
Map<TopicPartition, ListOffsetsResult.ListOffsetsResultInfo> earliestOffsets =
admin.listOffsets(requestEarliestOffsets).all().get(); // ①
Map<TopicPartition, OffsetAndMetadata> resetOffsets = new HashMap<>();
for (Map.Entry<TopicPartition, ListOffsetsResult.ListOffsetsResultInfo> e:
earliestOffsets.entrySet()) {
resetOffsets.put(e.getKey(), new OffsetAndMetadata(e.getValue().offset())); // ②
}
try {
admin.alterConsumerGroupOffsets(CONSUMER_GROUP, resetOffsets).all().get(); // ③
} catch (ExecutionException e) {
System.out.println("Failed to update the offsets committed by group "
+ CONSUMER_GROUP + " with error " + e.getMessage());
if (e.getCause() instanceof UnknownMemberIdException)
System.out.println("Check if consumer group is still active."); // ④
}
①
要重置消费者组,以便它将从最早的偏移量开始处理,我们需要首先获取最早的偏移量。获取最早的偏移量类似于获取上一个示例中显示的最新的偏移量。
②
在这个循环中,我们将listOffsets返回的带有ListOffsetsResultInfo值的映射转换为alterConsumerGroupOffsets所需的带有OffsetAndMetadata值的映射。
③
调用alterConsumerGroupOffsets之后,我们正在等待Future完成,以便我们可以看到它是否成功完成。
④
alterConsumerGroupOffsets失败的最常见原因之一是我们没有首先停止消费者组(这必须通过直接关闭消费应用程序来完成;没有用于关闭消费者组的管理命令)。如果组仍处于活动状态,我们尝试修改偏移量将被消费者协调器视为不是该组成员的客户端提交了该组的偏移量。在这种情况下,我们将收到UnknownMemberIdException。
集群元数据
应用程序很少需要显式发现连接的集群的任何信息。您可以生产和消费消息,而无需了解存在多少个代理和哪一个是控制器。Kafka 客户端会将这些信息抽象化,客户端只需要关注主题和分区。
但是,以防您好奇,这段小片段将满足您的好奇心:
DescribeClusterResult cluster = admin.describeCluster();
System.out.println("Connected to cluster " + cluster.clusterId().get()); // ①
System.out.println("The brokers in the cluster are:");
cluster.nodes().get().forEach(node -> System.out.println(" * " + node));
System.out.println("The controller is: " + cluster.controller().get());
①
集群标识符是 GUID,因此不适合人类阅读。检查您的客户端是否连接到正确的集群仍然是有用的。
高级管理操作
在本节中,我们将讨论一些很少使用但在需要时非常有用的方法。这些对于 SRE 在事故期间非常重要,但不要等到发生事故才学会如何使用它们。在为时已晚之前阅读和练习。请注意,这里的方法除了它们都属于这个类别之外,几乎没有任何关联。
向主题添加分区
通常在创建主题时会设置主题的分区数。由于每个分区的吞吐量可能非常高,因此很少会遇到主题容量限制的情况。此外,如果主题中的消息具有键,则消费者可以假定具有相同键的所有消息将始终进入同一分区,并且将由同一消费者按相同顺序处理。
出于这些原因,很少需要向主题添加分区,并且可能会有风险。您需要检查该操作是否不会破坏从主题中消费的任何应用程序。然而,有时您确实会达到现有分区可以处理的吞吐量上限,并且别无选择,只能添加一些分区。
您可以使用createPartitions方法向一组主题添加分区。请注意,如果尝试一次扩展多个主题,则可能会成功扩展其中一些主题,而其他主题将失败。
Map<String, NewPartitions> newPartitions = new HashMap<>();
newPartitions.put(TOPIC_NAME, NewPartitions.increaseTo(NUM_PARTITIONS+2)); // ①
admin.createPartitions(newPartitions).all().get();
①
在扩展主题时,您需要指定主题在添加分区后将拥有的总分区数,而不是新分区的数量。
提示
由于createPartition方法将主题中新分区添加后的总分区数作为参数,因此您可能需要描述主题并找出在扩展之前存在多少分区。
从主题中删除记录
当前的隐私法律规定了数据的特定保留政策。不幸的是,虽然 Kafka 有主题的保留政策,但它们并没有以确保合法合规的方式实施。如果一个主题的保留政策是 30 天,如果所有数据都适合每个分区中的单个段,那么它可以存储旧数据。
deleteRecords方法将标记所有偏移量早于调用该方法时指定的偏移量的记录为已删除,并使它们对 Kafka 消费者不可访问。该方法返回最高的已删除偏移量,因此我们可以检查删除是否确实按预期发生。磁盘上的完全清理将异步进行。请记住,listOffsets方法可用于获取在特定时间之后或立即之后编写的记录的偏移量。这些方法可以一起用于删除早于任何特定时间点的记录:
Map<TopicPartition, ListOffsetsResult.ListOffsetsResultInfo> olderOffsets =
admin.listOffsets(requestOlderOffsets).all().get();
Map<TopicPartition, RecordsToDelete> recordsToDelete = new HashMap<>();
for (Map.Entry<TopicPartition, ListOffsetsResult.ListOffsetsResultInfo> e:
olderOffsets.entrySet())
recordsToDelete.put(e.getKey(),
RecordsToDelete.beforeOffset(e.getValue().offset()));
admin.deleteRecords(recordsToDelete).all().get();
领导者选举
这种方法允许您触发两种不同类型的领导者选举:
首选领导者选举
每个分区都有一个被指定为首选领导者的副本。它是首选的,因为如果所有分区都使用其首选领导者副本作为领导者,每个代理上的领导者数量应该是平衡的。默认情况下,Kafka 每五分钟会检查首选领导者副本是否确实是领导者,如果不是但有资格成为领导者,它将选举首选领导者副本为领导者。如果auto.leader.rebalance.enable为false,或者如果您希望此过程更快地发生,electLeader()方法可以触发此过程。
非干净领导者选举
如果分区的领导者副本变得不可用,并且其他副本没有资格成为领导者(通常是因为它们缺少数据),那么该分区将没有领导者,因此不可用。解决此问题的一种方法是触发非干净领导者选举,这意味着将一个否则没有资格成为领导者的副本选举为领导者。这将导致数据丢失——所有写入旧领导者但尚未复制到新领导者的事件将丢失。electLeader()方法也可以用于触发非干净领导者选举。
该方法是异步的,这意味着即使在成功返回后,直到所有代理都意识到新状态并调用describeTopics()后,调用可能会返回不一致的结果。如果触发多个分区的领导者选举,可能会对一些分区成功,对另一些分区失败:
Set<TopicPartition> electableTopics = new HashSet<>();
electableTopics.add(new TopicPartition(TOPIC_NAME, 0));
try {
admin.electLeaders(ElectionType.PREFERRED, electableTopics).all().get(); // ①
} catch (ExecutionException e) {
if (e.getCause() instanceof ElectionNotNeededException) {
System.out.println("All leaders are preferred already"); // ②
}
}
①
我们正在为特定主题的单个分区选举首选领导者。我们可以指定任意数量的分区和主题。如果您使用null而不是分区集合调用该命令,它将触发您选择的所有分区的选举类型。
②
如果集群处于健康状态,该命令将不起作用。只有当首选领导者以外的副本是当前领导者时,首选领导者选举和非干净领导者选举才会生效。
重新分配副本
有时,您可能不喜欢某些副本的当前位置。也许一个代理已经过载,您想要移动一些副本。也许您想要添加更多的副本。也许您想要从一个代理中移动所有副本,以便您可以移除该机器。或者也许一些主题太吵了,您需要将它们与其余工作负载隔离开来。在所有这些情况下,alterPartitionReassignments可以让您对每个分区的每个副本的放置进行精细控制。请记住,将副本从一个代理重新分配到另一个代理可能涉及从一个代理复制大量数据到另一个代理。请注意可用的网络带宽,并根据需要使用配额限制复制;配额是代理配置,因此您可以使用AdminClient来描述它们并更新它们。
在本例中,假设我们有一个 ID 为 0 的单个代理。我们的主题有几个分区,每个分区都有一个副本在这个代理上。添加新代理后,我们希望使用它来存储主题的一些副本。我们将以稍微不同的方式为主题中的每个分区分配副本:
Map<TopicPartition, Optional<NewPartitionReassignment>> reassignment = new HashMap<>();
reassignment.put(new TopicPartition(TOPIC_NAME, 0),
Optional.of(new NewPartitionReassignment(Arrays.asList(0,1)))); // ①
reassignment.put(new TopicPartition(TOPIC_NAME, 1),
Optional.of(new NewPartitionReassignment(Arrays.asList(1)))); // ②
reassignment.put(new TopicPartition(TOPIC_NAME, 2),
Optional.of(new NewPartitionReassignment(Arrays.asList(1,0)))); // ③
reassignment.put(new TopicPartition(TOPIC_NAME, 3), Optional.empty()); // ④
admin.alterPartitionReassignments(reassignment).all().get();
System.out.println("currently reassigning: " +
admin.listPartitionReassignments().reassignments().get()); // ⑤
demoTopic = admin.describeTopics(TOPIC_LIST);
topicDescription = demoTopic.values().get(TOPIC_NAME).get();
System.out.println("Description of demo topic:" + topicDescription); // ⑥
①
我们已经向分区 0 添加了另一个副本,将新副本放在了 ID 为 1 的新代理上,但保持领导者不变。
②
我们没有向分区 1 添加任何副本;我们只是将现有的一个副本移动到新代理上。由于我们只有一个副本,它也是领导者。
③
我们已经向分区 2 添加了另一个副本,并将其设置为首选领导者。下一个首选领导者选举将把领导权转移到新经纪人上的新副本。现有副本将成为跟随者。
④
分区 3 没有正在进行的重新分配,但如果有的话,这将取消它并将状态返回到重新分配操作开始之前的状态。
⑤
我们可以列出正在进行的重新分配。
⑥
我们也可以打印新状态,但请记住,直到显示一致的结果可能需要一段时间。
测试
Apache Kafka 提供了一个测试类MockAdminClient,您可以用任意数量的经纪人初始化它,并用它来测试您的应用程序是否正确运行,而无需运行实际的 Kafka 集群并真正执行管理操作。虽然MockAdminClient不是 Kafka API 的一部分,因此可能会在没有警告的情况下发生变化,但它模拟了公共方法,因此方法签名将保持兼容。在这个类的便利性是否值得冒这个风险的问题上存在一些权衡,所以请记住这一点。
这个测试类特别引人注目的地方在于一些常见方法有非常全面的模拟:您可以使用MockAdminClient创建主题,然后调用listTopics()将列出您“创建”的主题。
但并非所有方法都被模拟。如果您使用版本为 2.5 或更早的AdminClient并调用MockAdminClient的incrementalAlterConfigs(),您将收到一个UnsupportedOperationException,但您可以通过注入自己的实现来处理这个问题。
为了演示如何使用MockAdminClient进行测试,让我们从实现一个类开始,该类实例化为一个管理客户端,并使用它来创建主题:
public TopicCreator(AdminClient admin) {
this.admin = admin;
}
// Example of a method that will create a topic if its name starts with "test"
public void maybeCreateTopic(String topicName)
throws ExecutionException, InterruptedException {
Collection<NewTopic> topics = new ArrayList<>();
topics.add(new NewTopic(topicName, 1, (short) 1));
if (topicName.toLowerCase().startsWith("test")) {
admin.createTopics(topics);
// alter configs just to demonstrate a point
ConfigResource configResource =
new ConfigResource(ConfigResource.Type.TOPIC, topicName);
ConfigEntry compaction =
new ConfigEntry(TopicConfig.CLEANUP_POLICY_CONFIG,
TopicConfig.CLEANUP_POLICY_COMPACT);
Collection<AlterConfigOp> configOp = new ArrayList<AlterConfigOp>();
configOp.add(new AlterConfigOp(compaction, AlterConfigOp.OpType.SET));
Map<ConfigResource, Collection<AlterConfigOp>> alterConf =
new HashMap<>();
alterConf.put(configResource, configOp);
admin.incrementalAlterConfigs(alterConf).all().get();
}
}
这里的逻辑并不复杂:如果主题名称以“test”开头,maybeCreateTopic将创建主题。我们还修改了主题配置,以便演示我们如何处理我们使用的方法在模拟客户端中未实现的情况。
注意
我们使用Mockito测试框架来验证MockAdminClient方法是否按预期调用,并填充未实现的方法。Mockito 是一个相当简单的模拟框架,具有良好的 API,非常适合用于单元测试的小例子。
我们将通过实例化我们的模拟客户端来开始测试:
@Before
public void setUp() {
Node broker = new Node(0,"localhost",9092);
this.admin = spy(new MockAdminClient(Collections.singletonList(broker),
broker)); // ①
// without this, the tests will throw
// `java.lang.UnsupportedOperationException: Not implemented yet`
AlterConfigsResult emptyResult = mock(AlterConfigsResult.class);
doReturn(KafkaFuture.completedFuture(null)).when(emptyResult).all();
doReturn(emptyResult).when(admin).incrementalAlterConfigs(any()); // ②
}
①
MockAdminClient是用经纪人列表(这里我们只使用一个)和一个将成为我们控制器的经纪人实例化的。经纪人只是经纪人 ID,主机名和端口 - 当然都是假的。在执行这些测试时,不会运行任何经纪人。我们将使用 Mockito 的spy注入,这样我们以后可以检查TopicCreator是否执行正确。
②
在这里,我们使用 Mockito 的doReturn方法来确保模拟管理客户端不会抛出异常。我们正在测试的方法期望具有AlterConfigResult对象,该对象具有返回KafkaFuture的all()方法。我们确保虚假的incrementalAlterConfigs确实返回了这一点。
现在我们有了一个适当的虚假 AdminClient,我们可以使用它来测试maybeCreateTopic()方法是否正常工作:
@Test
public void testCreateTestTopic()
throws ExecutionException, InterruptedException {
TopicCreator tc = new TopicCreator(admin);
tc.maybeCreateTopic("test.is.a.test.topic");
verify(admin, times(1)).createTopics(any()); // ①
}
@Test
public void testNotTopic() throws ExecutionException, InterruptedException {
TopicCreator tc = new TopicCreator(admin);
tc.maybeCreateTopic("not.a.test");
verify(admin, never()).createTopics(any()); // ②
}
①
主题名称以“test”开头,因此我们期望maybeCreateTopic()创建一个主题。我们检查createTopics()是否被调用了一次。
②
当主题名称不以“test”开头时,我们验证createTopics()根本没有被调用。
最后一点说明:Apache Kafka 发布了MockAdminClient在一个测试 jar 中,所以确保你的pom.xml包含一个测试依赖:
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>2.5.0</version>
<classifier>test</classifier>
<scope>test</scope>
</dependency>
总结
AdminClient 是 Kafka 开发工具包中很有用的工具。对于想要动态创建主题并验证其配置是否正确的应用程序开发人员来说,它非常有用。对于运维人员和 SREs 来说,他们想要围绕 Kafka 创建工具和自动化,或者需要从事故中恢复时,AdminClient 也非常有用。AdminClient 有很多有用的方法,SREs 可以把它看作是 Kafka 操作的瑞士军刀。
在本章中,我们涵盖了使用 Kafka 的 AdminClient 的所有基础知识:主题管理、配置管理和消费者组管理,以及一些其他有用的方法,这些方法在需要时都很有用。
第六章:Kafka 内部
要在生产环境中运行 Kafka 或编写使用 Kafka 的应用程序并不一定需要严格了解 Kafka 的内部工作原理。然而,了解 Kafka 的工作方式确实在故障排除或尝试理解 Kafka 行为的原因时提供了背景。由于本书的范围无法涵盖每一个实现细节和设计决策,因此在本章中,我们专注于一些对 Kafka 从业者特别相关的主题:
-
Kafka 控制器
-
Kafka 复制是如何工作的
-
Kafka 如何处理来自生产者和消费者的请求
-
Kafka 如何处理存储,比如文件格式和索引
深入了解这些主题在调整 Kafka 时将会特别有用——了解调整旋钮控制的机制对于有意识地使用它们而不是随机摆弄它们有很大帮助。
集群成员资格
Kafka 使用 Apache ZooKeeper 来维护当前集群成员的代理列表。每个代理都有一个唯一的标识符,可以在代理配置文件中设置,也可以自动生成。每次代理进程启动时,它都会通过在 ZooKeeper 中创建一个临时节点来注册自己的 ID。Kafka 代理、控制器和一些生态系统工具订阅 ZooKeeper 中的/brokers/ids路径,代理在这里注册,以便在代理被添加或移除时得到通知。
如果你尝试使用相同的 ID 启动另一个代理,你将会收到一个错误——新代理将尝试注册但失败,因为我们已经有了相同代理 ID 的 ZooKeeper 节点。
当代理失去与 ZooKeeper 的连接(通常是由于代理停止,但这也可能是由于网络分区或长时间的垃圾回收暂停导致的),代理在启动时创建的临时节点将自动从 ZooKeeper 中删除。监视代理列表的 Kafka 组件将收到通知,表示代理已经离开。
即使代理停止时代表代理的节点消失了,代理 ID 仍然存在于其他数据结构中。例如,每个主题的副本列表(参见“复制”)包含了副本的代理 ID。这样,如果你完全丢失了一个代理并启动一个具有相同 ID 的全新代理,它将立即加入集群,取代缺失的代理,并分配给它相同的分区和主题。
控制器
控制器是 Kafka 代理之一,除了通常的代理功能外,还负责选举分区领导者。在集群中启动的第一个代理成为控制器,通过在 ZooKeeper 中创建一个名为/controller的临时节点来实现。当其他代理启动时,它们也会尝试创建这个节点,但会收到“节点已经存在”的异常,这会导致它们“意识到”控制器节点已经存在,集群已经有了一个控制器。代理在控制器节点上创建一个ZooKeeper watch,以便在此节点发生变化时得到通知。这样,我们保证集群一次只有一个控制器。
当控制器代理停止或失去与 ZooKeeper 的连接时,临时节点将消失。这包括任何情况,其中控制器使用的 ZooKeeper 客户端停止向 ZooKeeper 发送心跳超过zookeeper.session.timeout.ms。当临时节点消失时,集群中的其他代理将通过 ZooKeeper 监视被通知控制器已经消失,并将尝试在 ZooKeeper 中自行创建控制器节点。在 ZooKeeper 中创建新控制器的第一个节点将成为下一个控制器,而其他节点将收到“节点已存在”异常并在新控制器节点上重新创建监视。每次选举控制器时,它都会通过 ZooKeeper 条件递增操作接收一个新的更高控制器时代编号。代理知道当前的控制器时代,如果它们从具有较旧编号的控制器接收到消息,它们会忽略它。这很重要,因为控制器代理可能由于长时间的垃圾回收暂停而断开与 ZooKeeper 的连接 - 在此暂停期间,将选举新的控制器。在暂停后,前一领导者恢复操作时,它可以继续向代理发送消息,而不知道有一个新的控制器 - 在这种情况下,旧的控制器被视为僵尸。消息中的控制器时代允许代理忽略来自旧控制器的消息,这是一种僵尸围栏。
当控制器首次启动时,必须从 ZooKeeper 中读取最新的副本状态映射,然后才能开始管理集群元数据并执行领导者选举。加载过程使用异步 API,并将读取请求管道化到 ZooKeeper 以隐藏延迟。但即使如此,在具有大量分区的集群中,加载过程可能需要几秒钟 - 在Apache Kafka 1.1.0 博客文章中描述了几个测试和比较。
当控制器注意到代理离开集群(通过监视相关的 ZooKeeper 路径或因为它收到了来自代理的ControlledShutdownRequest),它知道所有在该代理上有领导者的分区都需要新的领导者。它遍历所有需要新领导者的分区,并确定新领导者应该是谁(简单地是该分区副本列表中的下一个副本)。然后它将新状态持久化到 ZooKeeper(再次使用管道化的异步请求以减少延迟),然后向包含这些分区副本的所有代理发送LeaderAndISR请求。该请求包含有关分区的新领导者和跟随者的信息。这些请求被批处理以提高效率,因此每个请求都包括对同一代理上具有副本的多个分区的新领导信息。每个新领导者都知道它需要开始为来自客户端的生产者和消费者请求提供服务,而跟随者知道它们需要开始复制来自新领导者的消息。由于集群中的每个代理都有一个包含集群中所有代理和所有副本映射的MetadataCache,因此控制器向所有代理发送有关领导变更的信息以便它们可以更新它们的缓存。当代理重新启动时,类似的过程重复进行 - 主要区别在于代理中的所有副本都作为跟随者开始,并且需要在有资格被选举为领导者之前赶上领导者。
总之,Kafka 使用 ZooKeeper 的临时节点功能来选举控制器,并在节点加入和离开集群时通知控制器。控制器负责在注意到节点加入和离开集群时在分区和副本之间选举领导者。控制器使用时代编号来防止“脑裂”情况,其中两个节点相信彼此是当前控制器。
KRaft:Kafka 的新基于 Raft 的控制器
从 2019 年开始,Apache Kafka 社区开始了一项雄心勃勃的项目:从基于 ZooKeeper 的控制器转移到基于 Raft 的控制器仲裁。新控制器的预览版本名为 KRaft,是 Apache Kafka 2.8 版本的一部分。计划于 2021 年中期发布的 Apache Kafka 3.0 版本将包括 KRaft 的首个生产版本,Kafka 集群将能够同时运行传统的基于 ZooKeeper 的控制器或 KRaft。
为什么 Kafka 社区决定替换控制器?Kafka 现有的控制器已经经历了几次重写,但尽管改进了它使用 ZooKeeper 存储主题、分区和副本信息的方式,但明显地现有模型无法扩展到我们希望 Kafka 支持的分区数量。几个已知的问题促使了这一变化:
-
元数据更新同步写入 ZooKeeper,但异步发送到代理。此外,从 ZooKeeper 接收更新也是异步的。所有这些导致了元数据在代理、控制器和 ZooKeeper 之间不一致的边缘情况。这些情况很难检测。
-
每当控制器重新启动时,它必须从 ZooKeeper 中读取所有代理和分区的所有元数据,然后将这些元数据发送给所有代理。尽管经过多年的努力,这仍然是一个主要瓶颈——随着分区和代理数量的增加,重新启动控制器变得更慢。
-
关于元数据所有权的内部架构并不理想——一些操作是通过控制器完成的,另一些是通过任何代理完成的,还有一些是直接在 ZooKeeper 上完成的。
-
ZooKeeper 是自己的分布式系统,就像 Kafka 一样,需要一些专业知识来操作。想要使用 Kafka 的开发人员因此需要学习两个分布式系统,而不仅仅是一个。
考虑到所有这些问题,Apache Kafka 社区决定替换现有的基于 ZooKeeper 的控制器。
在现有架构中,ZooKeeper 有两个重要功能:用于选举控制器和存储集群元数据——注册的代理、配置、主题、分区和副本。此外,控制器本身管理元数据——用于选举领导者、创建和删除主题以及重新分配副本。所有这些功能都将在新控制器中替换。
新控制器设计的核心思想是,Kafka 本身具有基于日志的架构,其中用户将状态表示为事件流。这种表示的好处在社区中得到了充分理解——多个消费者可以通过重放事件快速追上最新状态。日志建立了事件之间的明确顺序,并确保消费者始终沿着单一时间线移动。新的控制器架构为 Kafka 的元数据管理带来了相同的好处。
在新的架构中,控制器节点是一个 Raft 仲裁,负责管理元数据事件日志。该日志包含有关集群元数据每个更改的信息。目前存储在 ZooKeeper 中的所有内容,如主题、分区、ISR、配置等,都将存储在此日志中。
使用 Raft 算法,控制器节点将从其中选举出一个领导者,而无需依赖任何外部系统。元数据日志的活跃控制器称为活跃控制器。活跃控制器处理来自代理的所有 RPC。跟随者控制器复制写入活跃控制器的数据,并在活跃控制器失败时作为热备份。因为控制器现在都跟踪最新状态,控制器故障转移将不需要一个漫长的重新加载期,在此期间我们将所有状态转移到新控制器。
控制器不再推送更新到其他经纪人,而是这些经纪人将通过新的MetadataFetch API 从活动控制器获取更新。与获取请求类似,经纪人将跟踪其获取的最新元数据更改的偏移量,并且只会从控制器请求更新的元数据。经纪人将将元数据持久化到磁盘,这将使它们能够快速启动,即使有数百万个分区。
经纪人将向控制器仲裁注册,并将保持注册,直到被管理员注销,因此一旦经纪人关闭,它就是离线但仍然注册。在线但未与最新元数据同步的经纪人将被隔离,并且将无法为客户端请求提供服务。新的隔离状态将防止客户端向不再是领导者但过时以至于不知道自己不是领导者的经纪人产生事件的情况。
作为迁移到控制器仲裁的一部分,以前涉及客户端或经纪人直接与 ZooKeeper 通信的所有操作将通过控制器路由。这将允许通过替换控制器来实现无缝迁移,而无需更改任何经纪人上的任何内容。
新架构的整体设计在KIP-500中有描述。有关如何为 Kafka 调整 Raft 协议的详细信息在KIP-595中有描述。有关新控制器仲裁的详细设计,包括控制器配置和用于与集群元数据交互的新 CLI,可在KIP-631中找到。
复制
复制是 Kafka 架构的核心。事实上,Kafka 经常被描述为“分布式、分区、复制的提交日志服务”。复制是至关重要的,因为这是 Kafka 在个别节点不可避免地失败时保证可用性和耐久性的方式。
正如我们已经讨论过的那样,Kafka 中的数据是按主题组织的。每个主题都被分区,每个分区可以有多个副本。这些副本存储在经纪人上,每个经纪人通常存储属于不同主题和分区的数百甚至数千个副本。
有两种类型的副本:
领导者副本
每个分区都有一个被指定为领导者的副本。所有生成请求都通过领导者进行以保证一致性。客户端可以从领导副本或其跟随者中消费。
追随者副本
对于不是领导者的分区的所有副本都被称为跟随者。除非另有配置,否则跟随者不会为客户端请求提供服务;它们的主要工作是从领导者复制消息并保持与领导者最近的消息同步。如果分区的领导者副本崩溃,其中一个跟随者副本将被提升为该分区的新领导者。
领导者还负责知道哪个跟随者副本与领导者保持同步。跟随者尝试通过复制领导者的所有消息来保持同步,但由于各种原因,例如网络拥塞减慢了复制速度,或者经纪人崩溃并且该经纪人上的所有副本开始落后,直到我们启动经纪人并且它们可以再次开始复制时,它们可能无法保持同步。
为了与领导者保持同步,副本发送领导者Fetch请求,这与消费者为了消费消息发送的请求类型相同。作为对这些请求的响应,领导者将消息发送给副本。这些Fetch请求包含副本想要接收的消息的偏移量,并且始终是有序的。这意味着领导者可以知道副本获取了所有消息直到副本获取的最后一条消息,并且没有获取之后的消息。通过查看每个副本请求的最后偏移量,领导者可以知道每个副本落后多少。如果一个副本在 10 秒内没有请求消息,或者如果它请求了消息但在 10 秒内没有赶上最新的消息,那么该副本被认为是不同步的。如果一个副本无法跟上领导者,它在故障发生时将不再能成为新的领导者——毕竟,它不包含所有的消息。
这个的反义词,一直在请求最新消息的副本被称为同步副本。只有同步副本有资格在现有领导者失败的情况下被选举为分区领导者。
在副本被认为是不同步之前可以不活动或落后的时间由replica.lag.time.max.ms配置参数控制。这种允许的滞后对客户端行为和领导者选举期间的数据保留有影响。我们在第七章中深入讨论这个问题,当我们讨论可靠性保证时。
除了当前领导者,每个分区都有一个首选领导者——当主题最初创建时是领导者的副本。它是首选的,因为当分区首次创建时,领导者在代理之间是平衡的。因此,我们期望当首选领导者确实是集群中所有分区的领导者时,负载将在代理之间平衡。默认情况下,Kafka 配置为auto.leader.rebalance.enable=true,它将检查首选领导者副本是否不是当前领导者但是同步的,并将触发领导者选举使首选领导者成为当前领导者。
查找首选领导者
识别当前首选领导者的最佳方法是查看分区的副本列表。(您可以在kafka-topics.sh工具的输出中查看分区和副本的详细信息。我们将在第十三章中讨论这个和其他管理工具。)列表中的第一个副本始终是首选领导者。这一点是正确的,无论当前领导者是谁,甚至如果副本被重新分配到不同的代理使用副本重新分配工具。事实上,如果您手动重新分配副本,重要的是要记住您指定的第一个副本将是首选副本,因此请确保将它们分布在不同的代理上,以避免一些代理负载过重,而其他代理没有处理他们公平份额的工作。
请求处理
Kafka 代理大部分工作是处理来自客户端、分区副本和控制器的请求发送到分区领导者。Kafka 有一个二进制协议(通过 TCP)规定了请求的格式以及代理如何响应这些请求——无论是请求成功处理还是代理在处理请求时遇到错误时。
Apache Kafka 项目包括由 Apache Kafka 项目的贡献者实现和维护的 Java 客户端;还有其他语言的客户端,如 C、Python、Go 等。您可以在 Apache Kafka 网站上看到完整的列表。它们都使用这个协议与 Kafka 代理进行通信。
客户端始终发起连接并发送请求,代理处理请求并对其做出响应。从特定客户端发送到代理的所有请求将按照接收顺序进行处理,这个保证使得 Kafka 能够作为消息队列并对其存储的消息提供顺序保证。
所有请求都有一个包括以下内容的标准头部:
-
请求类型(也称为API 密钥)
-
请求版本(以便代理可以处理不同版本的客户端并相应地做出响应)
-
关联 ID:唯一标识请求的编号,也出现在响应和错误日志中(用于故障排除)
-
客户端 ID:用于标识发送请求的应用程序
我们不会在这里描述协议,因为它在Kafka 文档中有详细描述。然而,了解请求是如何由代理处理的是有帮助的——稍后,当我们讨论如何监视 Kafka 和各种配置选项时,您将了解指标和配置参数所指的队列和线程的上下文。
对于代理监听的每个端口,代理运行一个接收器线程来创建连接并将其交给一个处理器线程进行处理。处理器线程(也称为网络线程)的数量是可配置的。网络线程负责从客户端连接接收请求,将其放入请求队列,并从响应队列中取出响应并将其发送回客户端。有时,对客户端的响应必须延迟——只有在数据可用时,消费者才会收到响应,而管理员客户端在主题删除进行中时才会收到DeleteTopic请求的响应。延迟的响应被保存在炼狱中,直到可以完成。请参阅图 6-1 以了解此过程的可视化。

图 6-1:Apache Kafka 内部的请求处理
一旦请求被放置在请求队列中,I/O 线程(也称为请求处理程序线程)负责接收并处理它们。最常见的客户端请求类型有:
生产请求
由生产者发送,并包含客户端写入 Kafka 代理的消息
获取请求
当消费者和追随者副本从 Kafka 代理读取消息时发送
管理员请求
在执行元数据操作时由管理员客户端发送,例如创建和删除主题
生产请求和获取请求都必须发送到分区的领导副本。如果代理接收到特定分区的生产请求,而该分区的领导者在另一个代理上,发送生产请求的客户端将收到“不是分区领导者”的错误响应。如果特定分区的获取请求到达一个没有该分区领导者的代理,将发生相同的错误。Kafka 的客户端负责将生产和获取请求发送到包含请求相关分区的领导者的代理。
客户端如何知道要发送请求的位置?Kafka 客户端使用另一种称为元数据请求的请求类型,其中包括客户端感兴趣的主题列表。服务器响应指定了主题中存在的分区,每个分区的副本,以及哪个副本是领导者。元数据请求可以发送到任何代理,因为所有代理都有包含此信息的元数据缓存。
客户端通常会缓存此信息,并使用它来指导生成和获取请求到每个分区的正确代理。他们还需要偶尔刷新此信息(刷新间隔由metadata.max.age.ms配置参数控制),通过发送另一个元数据请求,以便知道主题元数据是否发生变化,例如,如果添加了新的代理或某些副本被移动到新的代理(图 6-2)。此外,如果客户端收到“不是领导者”错误的请求之一,它将在再次尝试发送请求之前刷新其元数据,因为该错误表明客户端正在使用过时的信息,并且正在向错误的代理发送请求。

图 6-2:客户端路由请求
生成请求
正如我们在第三章中看到的,一个名为acks的配置参数是在被认为是成功写入之前需要确认接收消息的代理数量。生产者可以配置为在消息被领导者接受时就将消息视为“成功写入”(acks=1),或者在所有同步副本都接受时将消息视为“成功写入”(acks=all),或者在消息被发送时就将消息视为“成功写入”,而不必等待代理接受它(acks=0)。
当包含分区领导副本的代理接收到该分区的生成请求时,它将首先运行一些验证:
-
发送数据的用户是否对主题具有写权限?
-
请求中指定的
acks数量是否有效(只允许 0、1 和“all”)? -
如果
acks设置为all,是否有足够的同步副本可以安全地写入消息?(代理可以配置为在同步副本数量低于可配置数量时拒绝新消息;我们将在第七章中更详细地讨论这一点,当我们讨论 Kafka 的持久性和可靠性保证时。)
然后代理将新消息写入本地磁盘。在 Linux 上,消息被写入文件系统缓存,并且不能保证何时将其写入磁盘。Kafka 不会等待数据持久化到磁盘上,它依赖于消息的复制来保证消息的持久性。
一旦消息被写入分区的领导者,代理会检查acks配置:如果acks设置为 0 或 1,代理将立即响应;如果acks设置为all,请求将被存储在一个名为炼狱的缓冲区中,直到领导者观察到跟随者副本复制了消息,然后才会向客户端发送响应。
获取请求
代理处理获取请求的方式与处理生成请求的方式非常相似。客户端发送请求,要求代理从一系列主题、分区和偏移量中发送消息,类似于“请给我发送从主题 Test 的分区 0 的偏移量 53 开始的消息,以及从主题 Test 的分区 3 的偏移量 64 开始的消息。”客户端还会为每个分区指定代理可以返回的数据量的限制。这个限制很重要,因为客户端需要分配内存来保存从代理返回的响应。如果没有这个限制,代理可能会发送足够大的回复,导致客户端内存耗尽。
正如我们之前讨论的,请求必须到达请求中指定的分区的领导者,并且客户端将进行必要的元数据请求,以确保正确路由获取请求。当领导者收到请求时,它首先检查请求是否有效——对于这个特定的分区,这个偏移量是否存在?如果客户端要求一个如此古老以至于已经从分区中删除或者偏移量尚不存在的消息,代理将会以错误响应。
如果偏移存在,经纪人将从分区中读取消息,直到请求中客户端设置的限制,并将消息发送给客户端。Kafka 以“零拷贝”方法向客户端发送消息而闻名——这意味着 Kafka 直接从文件(或更可能是 Linux 文件系统缓存)将消息发送到网络通道,而无需任何中间缓冲区。这与大多数数据库不同,大多数数据库在发送给客户端之前将数据存储在本地缓存中。这种技术消除了复制字节和管理内存缓冲区的开销,并显著提高了性能。
除了设置经纪人可以返回的数据量的上限外,客户端还可以设置返回的数据量的下限。例如,将下限设置为 10K 是客户端告诉经纪人的方式,“只有在你至少有 10K 字节要发送给我的时候才返回结果”。这是在客户端从未看到太多流量的主题中减少 CPU 和网络利用率的好方法。客户端不再每隔几毫秒向经纪人发送请求请求数据,并且几乎没有或没有消息返回,而是客户端发送请求,经纪人等待直到有足够的数据,然后返回数据,然后客户端才会请求更多(图 6-3)。总体上读取的数据量是相同的,但是往返要少得多,因此开销也更少。

图 6-3: 经纪人延迟响应,直到积累足够的数据
当然,我们不希望客户端永远等待经纪人有足够的数据。过了一会儿,最好只是获取已经存在的数据并处理,而不是等待更多。因此,客户端还可以定义超时时间,告诉经纪人,“如果你在x毫秒内没有满足发送最小数据量的要求,就发送你得到的数据。”
有趣的是,并非分区领导者上存在的所有数据都可以供客户端读取。大多数客户端只能读取写入所有同步副本的消息(即使它们是消费者,从者副本不受此限制——否则复制将无法工作)。我们已经讨论过,分区的领导者知道哪些消息被复制到了哪个副本,直到消息被写入所有同步副本,它才会被发送给消费者——尝试获取这些消息将导致空响应而不是错误。
这种行为的原因是,尚未复制到足够多副本的消息被视为“不安全”——如果领导者崩溃并且另一个副本取代了它,这些消息将不再存在于 Kafka 中。如果我们允许客户端读取仅存在于领导者上的消息,我们可能会看到不一致的行为。例如,如果消费者读取了一条消息,领导者崩溃并且没有其他经纪人包含此消息,那么消息就消失了。没有其他消费者将能够读取此消息,这可能会导致与读取它的消费者不一致。相反,我们等到所有同步副本都收到消息,然后才允许消费者读取它(图 6-4)。这种行为还意味着,如果经纪人之间的复制因某种原因变慢,新消息到达消费者的时间将更长(因为我们要等待消息首先复制)。此延迟受到replica.lag.time.max.ms的限制——即副本在被认为是同步的情况下可以延迟复制新消息的时间量。

图 6-4: 消费者只能看到复制到同步副本的消息
在某些情况下,消费者从大量分区中消费事件。在每个请求中将其感兴趣的所有分区列表发送给代理,并让代理发送所有其元数据回来可能非常低效 - 分区集合很少改变,它们的元数据很少改变,并且在许多情况下没有太多数据返回。为了最小化这种开销,Kafka 有fetch session cache。消费者可以尝试创建一个缓存会话,存储它们正在消费的分区列表及其元数据。一旦创建了会话,消费者就不再需要在每个请求中指定所有分区,并且可以使用增量获取请求。如果有任何更改,代理将只在响应中包含元数据。会话缓存的空间有限,Kafka 优先考虑追随者副本和具有大量分区的消费者,因此在某些情况下可能不会创建或将被驱逐。在这两种情况下,代理将向客户端返回适当的错误,并且消费者将透明地重新使用包含所有分区元数据的完整获取请求。
其他请求
我们刚讨论了 Kafka 客户端使用的最常见的请求类型:Metadata,Produce和Fetch。Kafka 协议目前处理61 种不同的请求类型,并将添加更多。仅消费者就使用了 15 种请求类型来形成组,协调消费,并允许开发人员管理消费者组。还有大量与元数据管理和安全性相关的请求。
此外,相同的协议用于 Kafka 代理之间的通信。这些请求是内部的,不应该被客户端使用。例如,当控制器宣布分区有一个新的领导者时,它会向新的领导者发送LeaderAndIsr请求(以便它知道开始接受客户端请求),并发送给追随者(以便它们知道跟随新的领导者)。
协议不断发展演变 - 随着 Kafka 社区增加更多的客户端功能,协议也在不断演变以匹配。例如,在过去,Kafka 消费者使用 Apache ZooKeeper 来跟踪它们从 Kafka 接收的偏移量。因此,当消费者启动时,它可以检查 ZooKeeper 中从其分区读取的最后一个偏移量,并知道从哪里开始处理。出于各种原因,社区决定停止使用 ZooKeeper,并将这些偏移量存储在一个特殊的 Kafka 主题中。为了做到这一点,贡献者们不得不向协议中添加几个请求:OffsetCommitRequest,OffsetFetchRequest和ListOffsetsRequest。现在,当应用程序调用客户端 API 来提交消费者偏移量时,客户端不再写入 ZooKeeper;相反,它将OffsetCommitRequest发送到 Kafka。
主题创建过去是由命令行工具处理的,这些工具直接更新 ZooKeeper 中的主题列表。Kafka 社区后来添加了CreateTopic请求,以及用于管理 Kafka 元数据的类似请求。Java 应用程序通过 Kafka 的AdminClient执行这些元数据操作,在第五章中有详细记录。由于这些操作现在是 Kafka 协议的一部分,它允许没有 ZooKeeper 库的语言的客户端直接向 Kafka 代理请求创建主题。
除了通过添加新的请求类型来发展协议外,Kafka 开发人员有时选择修改现有请求以添加一些功能。例如,在 Kafka 0.9.0 和 Kafka 0.10.0 之间,他们决定通过将信息添加到Metadata响应来让客户端知道当前的控制器是谁。因此,Metadata请求和响应中添加了一个新版本。现在,0.9.0 客户端发送版本 0 的Metadata请求(因为 0.9.0 客户端中不存在版本 1),无论是 0.9.0 还是 0.10.0 的代理都知道要响应版本 0 的响应,该响应不包含控制器信息。这没问题,因为 0.9.0 客户端不期望控制器信息,也不知道如何解析它。如果您有 0.10.0 客户端,它将发送版本 1 的Metadata请求,而 0.10.0 代理将响应包含控制器信息的版本 1 响应,0.10.0 客户端可以使用该信息。如果 0.10.0 客户端向 0.9.0 代理发送版本 1 的Metadata请求,代理将不知道如何处理较新版本的请求,并将以错误响应。这就是我们建议在升级任何客户端之前先升级代理的原因——新代理知道如何处理旧请求,但反之则不然。
在 0.10.0 版本中,Kafka 社区添加了ApiVersionRequest,允许客户端询问代理支持的每个请求的版本,并相应地使用正确的版本。正确使用这种新功能的客户端将能够通过使用代理支持的协议版本与旧代理通信。目前正在进行工作,以添加 API,允许客户端发现代理支持哪些功能,并允许代理对存在于特定版本中的功能进行控制。这项改进是在KIP-584中提出的,目前看来很可能会成为 3.0.0 版本的一部分。
物理存储
Kafka 的基本存储单元是分区副本。分区不能在多个代理之间拆分,甚至不能在同一代理的多个磁盘之间拆分。因此,分区的大小受单个挂载点上可用空间的限制。(如果使用 JBOD 配置,挂载点可以是单个磁盘,如果配置了 RAID,则可以是多个磁盘。请参见第二章。)
在配置 Kafka 时,管理员定义了分区将存储在其中的目录列表——这是log.dirs参数(不要与 Kafka 存储其错误日志的位置混淆,该位置在log4j.properties文件中配置)。通常的配置包括 Kafka 将使用的每个挂载点的目录。
让我们看看 Kafka 如何使用可用目录来存储数据。首先,我们要看看数据如何分配给集群中的代理和代理中的目录。然后我们将看看代理如何管理文件——特别是如何处理保留保证。然后,我们将深入文件内部,查看文件和索引格式。最后,我们将看看日志压缩,这是一个高级功能,允许您将 Kafka 转换为长期数据存储,并描述其工作原理。
分层存储
从 2018 年底开始,Apache Kafka 社区开始合作一个雄心勃勃的项目,为 Kafka 添加分层存储功能。该项目的工作正在进行中,计划在 3.0 版本中发布。
动机相当简单:Kafka 目前用于存储大量数据,要么是由于高吞吐量,要么是由于长期保留期。这引入了以下问题:
-
您在分区中可以存储的数据量是有限的。因此,最大保留和分区计数不仅受产品要求驱动,还受物理磁盘大小的限制。
-
您对磁盘和集群大小的选择受存储需求驱动。集群通常比如果延迟和吞吐量是主要考虑因素时要大,这会增加成本。
-
例如,在扩展或缩小集群时,将分区从一个经纪人移动到另一个经纪人所需的时间取决于分区的大小。大分区会使集群的弹性降低。如今,架构设计朝向最大弹性,利用灵活的云部署选项。
在分层存储方法中,Kafka 集群配置为具有两个存储层级:本地和远程。本地层级与当前的 Kafka 存储层级相同——它使用 Kafka 经纪人上的本地磁盘来存储日志段。新的远程层级使用专用存储系统,例如 HDFS 或 S3,来存储已完成的日志段。
Kafka 用户可以选择为每个层级设置单独的存储保留策略。由于本地存储通常比远程层级昂贵得多,因此本地层级的保留期通常只有几个小时甚至更短,而远程层级的保留期可以更长——可以是几天,甚至几个月。
本地存储的延迟明显低于远程存储。这很有效,因为对延迟敏感的应用程序执行尾读取,并且从本地层级提供服务,因此它们可以从现有的 Kafka 机制中受益,该机制可以有效地使用页面缓存来提供数据。从远程层级提供服务的应用程序包括回填和其他需要比本地层级中的数据更旧的数据的应用程序。
分层存储中使用的双层架构允许在 Kafka 集群中独立于内存和 CPU 扩展存储,这使得 Kafka 成为长期存储解决方案。这也减少了在 Kafka 经纪人上本地存储的数据量,因此在恢复和重新平衡期间需要复制的数据量也减少了。在远程层级可用的日志段无需在经纪人上恢复,或者可以进行延迟恢复,并且可以从远程层级提供服务。由于并非所有数据都存储在经纪人上,因此增加保留期不再需要扩展 Kafka 集群存储并添加新节点。同时,整体数据保留仍然可以更长,从而消除了从 Kafka 复制数据到外部存储的需要,这是当前许多部署中所做的。
分层存储的设计在KIP-405中有详细记录,其中包括一个新组件——RemoteLogManager以及与现有功能的交互,例如副本追赶领导者和领导者选举。
在 KIP-405 中记录的一个有趣的结果是分层存储的性能影响。实施分层存储的团队在几种用例中测量了性能。第一种是使用 Kafka 通常的高吞吐量工作负载。在这种情况下,延迟略有增加(从 p99 的 21 毫秒到 25 毫秒),因为经纪人还必须将段发送到远程存储。第二种用例是一些消费者正在读取旧数据。如果没有分层存储,读取旧数据的消费者会对延迟产生很大影响(p99 的 21 毫秒对比 60 毫秒),但启用分层存储后,影响显著降低(p99 的 25 毫秒对比 42 毫秒);这是因为分层存储读取是通过网络路径从 HDFS 或 S3 读取的。网络读取不会与磁盘 I/O 或页面缓存上的本地读取竞争,并且会保持页面缓存完整并具有新鲜数据。
这意味着,除了无限存储、更低的成本和弹性之外,分层存储还提供了历史读取和实时读取之间的隔离。
分区分配
当你创建一个主题时,Kafka 首先决定如何在代理之间分配分区。假设你有 6 个代理,并决定创建一个有 10 个分区和 3 个副本因子的主题。Kafka 现在有 30 个分区副本要分配给 6 个代理。在进行分配时,目标是:
-
为了在代理之间均匀分布副本——在我们的例子中,确保我们为每个代理分配五个副本。
-
为了确保对于每个分区,每个副本都在不同的代理上。如果分区 0 的领导者在代理 2 上,我们可以将跟随者放在代理 3 和 4 上,但不能放在代理 2 上,也不能都放在代理 3 上。
-
如果代理有机架信息(在 Kafka 0.10.0 及更高版本中可用),则尽可能将每个分区的副本分配到不同的机架上。这确保了导致整个机架停机的事件不会导致分区完全不可用。
为了做到这一点,我们从一个随机的代理开始(比如说 4),并以循环方式为每个代理分配分区,以确定领导者的位置。所以分区 0 的领导者将在代理 4 上,分区 1 的领导者将在代理 5 上,分区 2 将在代理 0 上(因为我们只有 6 个代理),依此类推。然后,对于每个分区,我们将副本放在领导者的递增偏移量上。如果分区 0 的领导者在代理 4 上,第一个跟随者将在代理 5 上,第二个将在代理 0 上。分区 1 的领导者在代理 5 上,所以第一个副本在代理 0 上,第二个在代理 1 上。
当考虑机架感知时,我们不是按照数字顺序选择代理,而是准备一个交替机架的代理列表。假设我们知道代理 0 和 1 在同一个机架上,代理 2 和 3 在另一个机架上。我们不是按照 0 到 3 的顺序选择代理,而是按照 0、2、1、3 的顺序排列它们——每个代理后面都跟着一个来自不同机架的代理(图 6-5)。在这种情况下,如果分区 0 的领导者在代理 2 上,第一个副本将在代理 1 上,这是一个完全不同的机架。这很好,因为如果第一个机架下线,我们知道我们仍然有一个存活的副本,因此分区仍然可用。对于所有的副本都是如此,所以在机架故障的情况下,我们有保证的可用性。

图 6-5:分区和副本分配给不同机架上的代理
一旦我们为每个分区和副本选择了正确的代理,就该决定为新分区使用哪个目录了。我们对每个分区都独立进行这个操作,规则非常简单:我们计算每个目录上的分区数量,并将新分区添加到分区最少的目录上。这意味着如果你添加了一个新的磁盘,所有新的分区都将在该磁盘上创建。这是因为在平衡之前,新磁盘总是有最少的分区。
注意磁盘空间
请注意,分配分区给代理时不考虑可用空间或现有负载,分配分区给磁盘时考虑分区的数量,但不考虑分区的大小。这意味着如果一些代理的磁盘空间比其他的多(也许是因为集群是由新旧服务器混合组成的),一些分区异常大,或者你在同一个代理上有不同大小的磁盘,你需要小心处理分区的分配。
文件管理
保留是 Kafka 中的一个重要概念——Kafka 不会永久保留数据,也不会等待所有消费者读取消息后再删除它。相反,Kafka 管理员为每个主题配置一个保留期限——存储消息的时间或存储多少数据后删除旧消息。
因为在大文件中查找需要清除的消息然后删除文件的一部分既耗时又容易出错,我们改为将每个分区分割成段。默认情况下,每个段包含 1GB 的数据或一周的数据,以较小者为准。当 Kafka 代理写入分区时,如果达到段限制,它将关闭文件并开始新文件。
我们当前正在写入的段称为活动段。活动段永远不会被删除,因此,如果您将日志保留设置为仅存储一天的数据,但每个段包含五天的数据,您实际上将保留五天的数据,因为我们不能在段关闭之前删除数据。如果您选择存储一周的数据并且每天滚动一个新段,您将看到每天我们将滚动一个新段,同时删除最旧的段——因此大部分时间分区将有七个段。
正如您在第二章中学到的,Kafka 代理将保持对每个分区中每个段的打开文件句柄,即使是非活动段。这导致打开文件句柄的数量通常很高,操作系统必须相应地进行调整。
文件格式
每个段都存储在单个数据文件中。在文件内部,我们存储 Kafka 消息及其偏移量。磁盘上的数据格式与我们从生产者发送到代理,以及后来从代理发送到消费者的消息格式相同。在磁盘和网络上传输相同的消息格式是 Kafka 能够在向消费者发送消息时使用零拷贝优化,并且避免解压和重新压缩生产者已经压缩的消息。因此,如果我们决定更改消息格式,网络协议和磁盘格式都需要更改,Kafka 代理需要知道如何处理包含两种格式消息的文件的情况。
Kafka 消息由用户有效载荷和系统头组成。用户有效载荷包括可选的键、值和可选的头集合,其中每个头都是其自己的键/值对。
从版本 0.11 开始(以及 v2 消息格式),Kafka 生产者始终以批处理方式发送消息。如果发送单个消息,批处理会增加一些开销。但是对于每批次两条或更多消息,批处理可以节省空间,从而减少网络和磁盘使用。这是 Kafka 在linger.ms=10时表现更好的原因之一——小延迟增加了更多消息一起发送的机会。由于 Kafka 为每个分区创建单独的批次,因此写入较少分区的生产者也将更有效。请注意,Kafka 生产者可以在同一生产请求中包含多个批次。这意味着,如果您在生产者上使用压缩(建议!),发送更大的批次意味着在网络和代理磁盘上都会获得更好的压缩。
消息批处理头包括:
-
指示消息格式当前版本的魔术数字(这里我们正在记录 v2)。
-
批处理中第一条消息的偏移量以及与最后一条消息偏移量的差异——即使批处理后来被压缩并删除了一些消息,这些偏移量也会被保留。当生产者创建并发送批处理时,第一条消息的偏移量设置为 0。首次持久化此批处理的代理(分区领导者)将其替换为真实偏移量。
-
第一条消息的时间戳和批处理中最高时间戳。如果时间戳类型设置为追加时间而不是创建时间,代理可以设置时间戳。
-
批处理的大小,以字节为单位。
-
用于验证批处理是否损坏的校验和。
-
十六位表示不同的属性:压缩类型、时间戳类型(时间戳可以在客户端或经纪人处设置),以及批次是否属于事务或是控制批次。
-
生产者 ID、生产者时代和批次中的第一个序列——这些都用于确保精确一次。
-
当然,批次中包含的消息集。
正如您所看到的,批处理头包含了大量信息。记录本身也有系统头(不要与用户设置的头混淆)。每个记录包括:
-
记录的大小,以字节为单位
-
属性——目前没有记录级属性,因此这不会被使用
-
当前记录的偏移量与批次中第一个偏移量之间的差异
-
该记录时间戳与批次中第一个时间戳之间的差异(以毫秒为单位)
-
用户有效负载:键、值和头
请注意,每个记录的开销非常小,大部分系统信息都在批处理级别。在头部存储批处理的第一个偏移量和时间戳,并且仅在每个记录中存储差异,大大减少了每个记录的开销,使得更大的批次更有效。
除了包含用户数据的消息批次外,Kafka 还有控制批次,例如表示事务提交。这些由消费者处理,不会传递给用户应用程序,目前它们包括版本和类型指示器:0 表示中止事务,1 表示提交。
如果您希望自己查看所有这些内容,Kafka 经纪人附带了DumpLogSegment工具,允许您查看文件系统中的分区段并检查其内容。您可以使用以下命令运行该工具:
bin/kafka-run-class.sh kafka.tools.DumpLogSegments
如果选择--deep-iteration参数,它将向您显示包装消息内部压缩的消息的信息。
消息格式向下转换
早些时候记录的消息格式是在 0.11 版本中引入的。由于 Kafka 支持在所有客户端升级之前升级经纪人,因此它必须支持经纪人、生产者和消费者之间的任何版本组合。大多数组合都可以正常工作——新经纪人将理解生产者的旧消息格式,并且新生产者将知道将旧格式的消息发送到旧经纪人。但是当新生产者向新经纪人发送 v2 消息时,就会出现一个具有挑战性的情况:消息以 v2 格式存储,但不支持 v2 格式的旧消费者尝试读取它。在这种情况下,经纪人将需要将消息从 v2 格式转换为 v1 格式,以便消费者能够解析它。这种转换比正常消费使用更多的 CPU 和内存,因此最好避免。KIP-188引入了几个重要的健康指标,其中包括FetchMessageConversionsPerSec和MessageConversionsTimeMs。如果您的组织仍在使用旧客户端,我们建议检查这些指标,并尽快升级客户端。
索引
Kafka 允许消费者从任何可用的偏移量开始获取消息。这意味着,如果消费者要求从偏移量 100 开始获取 1MB 的消息,那么经纪人必须能够快速定位偏移量 100 的消息(可能在分区的任何段中),并从该偏移量开始读取消息。为了帮助经纪人快速定位给定偏移量的消息,Kafka 为每个分区维护一个索引。该索引将偏移量映射到段文件和文件内的位置。
类似地,Kafka 还有第二个索引,将时间戳映射到消息偏移量。在按时间戳搜索消息时使用此索引。Kafka Streams 广泛使用此查找,并且在某些故障转移场景中也很有用。
索引也被分成段,因此我们可以在消息被清除时删除旧的索引条目。Kafka 不会尝试维护索引的校验和。如果索引损坏,它将从匹配的日志段中重新生成,只需重新读取消息并记录偏移和位置。如果需要,管理员完全可以安全地(尽管可能导致长时间的恢复)删除索引段——它们将自动重新生成。
压缩
通常情况下,Kafka 会存储一定时间的消息,并清除超过保留期的消息。然而,想象一种情况,你使用 Kafka 来存储客户的送货地址。在这种情况下,存储每个客户的最后地址而不是仅仅存储最近一周或一年的数据更有意义。这样,你就不必担心旧地址,而且仍然保留了长时间未搬迁的客户的地址。另一个用例可能是一个使用 Kafka 来存储其当前状态的应用程序。每当状态发生变化时,应用程序就会将新状态写入 Kafka。在从崩溃中恢复时,应用程序会从 Kafka 中读取这些消息以恢复其最新状态。在这种情况下,它只关心崩溃前的最新状态,而不关心在其运行时发生的所有变化。
Kafka 通过允许在主题上设置保留策略为delete(删除保留时间之前的事件)或者compact(仅存储主题中每个键的最新值)来支持这种用例。显然,将策略设置为 compact 只对包含键和值的事件的主题有意义。如果主题包含null键,压缩将失败。
主题也可以有一个delete.and.compact策略,它将压缩和保留期结合在一起。超过保留期的消息将被删除,即使它们是键的最新值。这个策略可以防止被压缩的主题变得过大,并且在业务需要在一定时间后删除记录时也会用到。
压缩的工作原理
每个日志被视为分为两个部分(见图 6-6):
干净
在之前已经压缩过的消息。这个部分只包含每个键的最新值,即上一次压缩时的最新值。
脏
在最后一次压缩之后写入的消息。

图 6-6:具有干净和脏部分的分区
如果 Kafka 在启动时启用了压缩(使用名为log.cleaner.enabled的配置),每个代理将启动一个压缩管理器线程和一些压缩线程。这些线程负责执行压缩任务。每个线程选择具有脏消息占总分区大小比例最高的分区,并清理该分区。
为了压缩一个分区,清理线程会读取分区的脏部分并创建一个内存映射。每个映射条目由消息键的 16 字节哈希和具有相同键的上一条消息的 8 字节偏移组成。这意味着每个映射条目只使用 24 字节。如果我们看一个 1GB 的段,并假设段中的每条消息占用 1KB,那么段将包含 100 万条这样的消息,我们只需要一个 24MB 的映射来压缩段(我们可能需要更少——如果键重复,我们将经常重用相同的哈希条目并使用更少的内存)。这是非常高效的!
在配置 Kafka 时,管理员配置了压缩线程可以使用的偏移量映射的内存量。尽管每个线程都有自己的映射,但配置是针对所有线程的总内存。如果为压缩偏移量映射配置了 1 GB,并且有 5 个清理线程,则每个线程将获得 200 MB 的自己的偏移量映射。Kafka 不需要将分区的整个脏段都适应于为该映射分配的大小,但至少一个完整段必须适应。如果不适应,Kafka 将记录错误,并且管理员将需要为偏移量映射分配更多内存或使用更少的清理线程。如果只有少数段适应,Kafka 将从最旧的适应于映射的段开始压缩。其余的将保持脏状态,并等待下一次压缩。
一旦清理线程构建偏移量映射,它将开始读取干净的段,从最旧的段开始,并检查它们的内容与偏移量映射。对于每条消息,它会检查消息的键是否存在于偏移量映射中。如果键不存在于映射中,则刚读取的消息的值仍然是最新的,并且消息将复制到替换段中。如果键存在于映射中,则将省略该消息,因为分区中稍后具有相同键但更新值的消息。一旦所有仍包含其键的最新值的消息都被复制过去,替换段将与原始段交换,并继续到下一个段。在该过程结束时,我们将得到每个键的一条消息,即具有最新值的消息。参见图 6-7。

图 6-7:压缩前后的分区段
删除事件
如果我们始终保留每个键的最新消息,那么当我们真正想要删除特定键的所有消息时,比如如果用户离开我们的服务并且我们有法律义务从系统中删除该用户的所有痕迹时,我们该怎么办?
要完全从系统中删除键,甚至不保存最后一条消息,应用程序必须生成包含该键和空值的消息。当清理线程找到这样的消息时,它首先会进行正常的压缩,并仅保留具有空值的消息。它将保留此特殊消息(称为墓碑)一段可配置的时间。在此期间,消费者将能够看到此消息,并知道该值已删除。因此,如果消费者将数据从 Kafka 复制到关系数据库,它将看到墓碑消息,并知道需要从数据库中删除用户。在此一定时间后,清理线程将删除墓碑消息,并且键将从 Kafka 的分区中消失。给消费者足够的时间来看到墓碑消息非常重要,因为如果我们的消费者关闭了几个小时并错过了墓碑消息,它将简单地在消费时看不到该键,因此不知道它已从 Kafka 中删除或需要从数据库中删除。
值得记住的是,Kafka 的管理客户端还包括一个deleteRecords方法。该方法删除指定偏移量之前的所有记录,并使用完全不同的机制。当调用此方法时,Kafka 将移动低水位标记,即分区的第一个偏移量的记录,到指定的偏移量。这将阻止消费者消费新低水位标记下方的记录,并有效地使这些记录无法访问,直到它们被清理线程删除。此方法可用于具有保留策略和压缩主题的主题。
何时进行主题压缩?
与“删除”策略永远不会删除当前活动段的方式相同,“压缩”策略永远不会压缩当前段。消息只有在非活动段上才有资格进行压缩。
默认情况下,当主题包含脏记录的比例达到 50%时,Kafka 将开始紧缩。目标不是经常进行紧缩(因为紧缩可能会影响主题的读/写性能),但也不要留下太多脏记录(因为它们会占用磁盘空间)。在主题使用的磁盘空间的 50%上浪费脏记录,然后一次性进行紧缩似乎是一个合理的折衷方案,并且可以由管理员进行调整。
此外,管理员可以通过两个配置参数控制紧缩的时间:
-
min.compaction.lag.ms可用于保证消息写入后必须经过的最短时间,然后才能进行紧缩。 -
max.compaction.lag.ms可用于保证消息写入后到达紧缩资格的最大延迟时间。这种配置通常用于需要在一定时间内保证紧缩的业务场景;例如,GDPR 要求在收到删除请求后的 30 天内删除某些信息。
总结
Kafka 显然不仅仅是我们在本章中所涵盖的内容,但我们希望这能让您对 Kafka 社区在开发项目时所做的设计决策和优化有所了解,并且或许解释了您在使用 Kafka 时遇到的一些更加晦涩的行为和配置。
如果您真的对 Kafka 内部感兴趣,那么没有什么比阅读代码更好的了。Kafka 开发者邮件列表(dev@kafka.apache.org)是一个非常友好的社区,总会有人愿意回答关于 Kafka 实际运行方式的问题。而且在阅读代码的同时,也许您可以修复一个或两个 bug——开源项目总是欢迎贡献。
第七章:可靠数据传递
可靠性是系统的属性,而不是单个组件的属性,因此当我们谈论 Apache Kafka 的可靠性保证时,我们需要牢记整个系统及其用例。在可靠性方面,与 Kafka 集成的系统和 Kafka 本身一样重要。由于可靠性是一个系统问题,它不能仅仅是一个人的责任。每个人——Kafka 管理员、Linux 管理员、网络和存储管理员以及应用程序开发人员——都必须共同努力构建一个可靠的系统。
Apache Kafka 在可靠数据传递方面非常灵活。我们知道 Kafka 有许多用例,从跟踪网站上的点击到信用卡支付。一些用例要求最高的可靠性,而其他一些则将速度和简单性置于可靠性之上。Kafka 被设计为足够可配置,其客户端 API 足够灵活,以允许各种可靠性权衡。
由于其灵活性,使用 Kafka 时也很容易不小心自食其果,认为我们的系统是可靠的,而实际上并非如此。在本章中,我们将首先讨论可靠性的不同类型及其在 Apache Kafka 环境中的含义。然后我们将讨论 Kafka 的复制机制以及它如何促进系统的可靠性。接下来我们将讨论 Kafka 的代理和主题以及它们在不同用例中的配置方式。然后我们将讨论客户端、生产者和消费者,以及它们在不同可靠性场景中的使用方式。最后,我们将讨论验证系统可靠性的话题,因为仅仅相信系统是可靠的是不够的——假设必须经过彻底的测试。
可靠性保证
当我们谈论可靠性时,通常是以保证的术语来谈论的,这些保证是系统在不同情况下保证保持的行为。
可能最为人熟知的可靠性保证是 ACID,这是关系数据库普遍支持的标准可靠性保证。ACID 代表原子性、一致性、隔离性和持久性。当供应商解释他们的数据库符合 ACID 时,这意味着数据库保证了关于事务行为的某些行为。
这些保证是人们信任关系数据库的原因,他们知道系统承诺了什么以及在不同条件下它将如何行为。他们理解这些保证,并且可以依靠这些保证编写安全的应用程序。
理解 Kafka 提供的保证对于那些希望构建可靠应用程序的人来说至关重要。这种理解使系统的开发人员能够弄清在不同的故障条件下它将如何行为。那么,Apache Kafka 提供了什么样的保证呢?
-
Kafka 提供了分区中消息的顺序保证。如果消息 B 是在消息 A 之后使用相同的生产者在同一分区中写入的,那么 Kafka 保证消息 B 的偏移量将高于消息 A,并且消费者将在消息 A 之后读取消息 B。
-
当消息被写入所有其同步副本的分区时(但不一定刷新到磁盘),产生的消息被视为“已提交”。生产者可以选择在消息完全提交时、在消息被写入领导者时或在消息被发送到网络时接收发送消息的确认。
-
只要至少有一个副本保持存活,已提交的消息就不会丢失。
-
消费者只能读取已提交的消息。
这些基本保证可以在构建可靠系统时使用,但它们本身并不能使系统完全可靠。在构建可靠系统时涉及到权衡,Kafka 被设计为允许管理员和开发人员通过提供配置参数来控制这些权衡,从而决定他们需要多少可靠性。这些权衡通常涉及到可靠和一致地存储消息的重要性与其他重要考虑因素之间的权衡,比如可用性、高吞吐量、低延迟和硬件成本。
接下来我们将回顾 Kafka 的复制机制,介绍术语,并讨论可靠性是如何内置到 Kafka 中的。之后,我们将讨论刚才提到的配置参数。
复制
Kafka 的复制机制,以及每个分区的多个副本,是 Kafka 所有可靠性保证的核心。在多个副本中写入消息是 Kafka 在发生崩溃时提供消息持久性的方式。
我们在第六章中深入解释了 Kafka 的复制机制,但让我们在这里回顾一下要点。
每个 Kafka 主题都被分解成分区,这是基本的数据构建块。一个分区存储在一个磁盘上。Kafka 保证分区内事件的顺序,并且一个分区可以是在线的(可用的)或离线的(不可用的)。每个分区可以有多个副本,其中一个是指定的领导者。所有事件都是由领导者副本产生的,并且通常也是从领导者副本消费的。其他副本只需要与领导者保持同步,并及时复制所有最近的事件。如果领导者不可用,一个同步的副本将成为新的领导者(这条规则有一个例外,我们在第六章中讨论过)。
如果一个副本是分区的领导者,或者是一个从者,它被认为是同步的,如果它在最后 10 秒内:
-
与 ZooKeeper 有一个活动会话——意味着它在最近 6 秒内向 ZooKeeper 发送了心跳(可配置)。
-
在最后 10 秒内从领导者获取的消息(可配置)。
-
从领导者那里获取了最近的消息。也就是说,从者仍然从领导者那里获取消息是不够的;它必须在最近的 10 秒内至少一次没有滞后(可配置)。
如果一个副本失去与 ZooKeeper 的连接,停止获取新消息,或者落后并且无法在 10 秒内赶上,那么该副本被认为是失步的。当失步的副本再次连接到 ZooKeeper 并赶上最近写入领导者的消息时,它就会重新同步。这通常在暂时的网络故障修复后很快发生,但如果存储副本的代理服务器长时间宕机,可能需要一段时间才能发生。
失步的副本
在较旧版本的 Kafka 中,看到一个或多个副本在同步和不同步状态之间迅速切换并不罕见。这是集群出现问题的明显迹象。一个相对常见的原因是较大的最大请求大小和大的 JVM 堆,需要调整以防止长时间的垃圾回收暂停,导致经纪人暂时断开与 ZooKeeper 的连接。如今,这个问题非常罕见,特别是在使用 Apache Kafka 2.5.0 及更高版本时,其默认配置用于 ZooKeeper 连接超时和最大副本滞后。使用 JVM 8 及以上版本(现在是 Kafka 支持的最低版本)与G1 垃圾收集器有助于遏制这个问题,尽管对于大消息可能仍需要调整。一般来说,自第一版书籍出版以来,Kafka 的复制协议在这些年里变得更加可靠。有关 Kafka 复制协议演变的详细信息,请参考 Jason Gustafson 的出色演讲“Hardening Apache Kafka Replication”,以及 Gwen Shapira 对 Kafka 改进的概述“Please Upgrade Apache Kafka Now”。
稍有滞后的同步副本会减慢生产者和消费者的速度——因为它们等待所有同步副本收到消息后才会提交。一旦副本不再同步,我们就不再等待它接收消息。它仍然滞后,但现在没有性能影响。问题在于,同步副本越少,分区的有效复制因子就越低,因此停机或数据丢失的风险就越高。
在下一节中,我们将看看这在实践中意味着什么。
经纪人配置
经纪人中有三个配置参数,它们改变了 Kafka 关于可靠消息存储的行为。像许多经纪人配置变量一样,这些可以应用于经纪人级别,控制系统中所有主题的配置,也可以应用于主题级别,控制特定主题的行为。
能够在主题级别控制可靠性权衡意味着同一个 Kafka 集群可以用来托管可靠和不可靠的主题。例如,在银行,管理员可能希望为整个集群设置非常可靠的默认值,但对于存储客户投诉的主题做出例外,其中一些数据丢失是可以接受的。
让我们逐一查看这些配置参数,看看它们如何影响 Kafka 中消息存储的可靠性以及涉及的权衡考虑。
复制因子
主题级别的配置是replication.factor。在经纪人级别,我们控制自动创建主题的default.replication.factor。
在本书的这一部分,我们假设主题的复制因子为 3,这意味着每个分区在三个不同的经纪人上被复制三次。这是一个合理的假设,因为这是 Kafka 的默认设置,但这是用户可以修改的配置。即使主题存在后,我们也可以选择添加或删除副本,从而使用 Kafka 的副本分配工具修改复制因子。
N的复制因子允许我们失去N-1 个经纪人,同时仍能够读写数据到主题。因此,更高的复制因子会导致更高的可用性、更高的可靠性和更少的灾难。另一方面,对于N的复制因子,我们将需要至少N个经纪人,并且我们将存储N份数据,这意味着我们将需要N倍的磁盘空间。基本上,我们在可用性和硬件之间进行交易。
那么,我们如何确定主题的正确副本数量呢?有一些关键考虑因素:
可用性
只有一个副本的分区即使在单个经纪人的例行重启期间也将变得不可用。我们拥有的副本越多,我们就可以期望更高的可用性。
耐久性
每个副本都是分区中所有数据的副本。如果一个分区只有一个副本,并且由于任何原因磁盘变得无法使用,我们将丢失分区中的所有数据。拥有更多副本,特别是在不同的存储设备上,减少了丢失所有副本的可能性。
吞吐量
每增加一个副本,我们就会增加代理之间的流量。如果我们以每秒 10MB 的速率向分区生成数据,那么单个副本将不会产生任何复制流量。如果我们有 2 个副本,那么我们将有 10MBps 的复制流量,有 3 个副本则为 20MBps,有 5 个副本则为 40MBps。在规划集群大小和容量时,我们需要考虑这一点。
端到端延迟
每个生成的记录在可供消费者使用之前必须被复制到所有同步的副本中。理论上,拥有更多副本会增加其中一个副本速度较慢的概率,因此会减慢消费者的速度。实际上,如果一个代理因任何原因变慢,它将减慢每个尝试使用它的客户端的速度,无论复制因子如何。
成本
这是使用非关键数据的复制因子低于 3 的最常见原因。我们拥有的数据副本越多,存储和网络成本就越高。由于许多存储系统已经将每个块复制 3 次,因此通过配置 Kafka 的复制因子为 2 来降低成本有时是有意义的。请注意,与复制因子为 3 相比,这仍会降低可用性,但存储设备将保证耐久性。
副本的放置也非常重要。Kafka 将始终确保分区的每个副本位于不同的代理上。在某些情况下,这还不够安全。如果分区的所有副本都放置在同一机架上的代理上,并且顶部交换机出现故障,我们将失去分区的可用性,无论复制因子如何。为了防止机架级别的不幸,我们建议将代理放置在多个机架上,并使用broker.rack代理配置参数为每个代理配置机架名称。如果配置了机架名称,Kafka 将确保分区的副本分布在多个机架上,以确保更高的可用性。在云环境中运行 Kafka 时,通常将可用性区域视为单独的机架。在第六章中,我们提供了有关 Kafka 如何在代理和机架上放置副本的详细信息。
不洁净的领导者选举
此配置仅在代理(实际上是在整个集群范围内)级别可用。参数名称为unclean.leader.election.enable,默认设置为false。
如前所述,当分区的领导者不再可用时,将选择一个同步的副本作为新的领导者。这种领导者选举是“干净的”,因为它保证没有丢失已提交的数据——根据定义,已提交的数据存在于所有同步的副本上。
但是当除了刚刚变得不可用的领导者之外,没有同步的副本存在时,我们该怎么办呢?
这种情况可能发生在以下两种情况之一:
-
分区有三个副本,两个跟随者变得不可用(假设两个代理崩溃)。在这种情况下,当生产者继续写入领导者时,所有消息都被确认和提交(因为领导者是唯一的同步副本)。现在假设领导者不再可用(哎呀,又一个代理崩溃了)。在这种情况下,如果一个不同步的跟随者首先启动,我们将有一个不同步的副本作为分区的唯一可用副本。
-
分区有三个副本,由于网络问题,两个跟随者落后,即使它们已经上线并复制,它们也不再同步。领导者继续接受消息作为唯一的同步副本。现在,如果领导者不可用,只有不同步的副本可以成为领导者。
在这两种情况下,我们需要做出一个困难的决定:
-
如果我们不允许不同步的副本成为新的领导者,分区将保持离线,直到我们将旧领导者(和最后一个同步副本)重新上线。在某些情况下(例如,内存芯片需要更换),这可能需要很多小时。
-
如果我们允许不同步的副本成为新的领导者,我们将丢失所有在旧领导者不同步时写入的消息,并且还会导致一些消费者的不一致。为什么?想象一下,当副本 0 和 1 不可用时,我们将偏移量为 100-200 的消息写入副本 2(然后成为领导者)。现在副本 2 不可用,副本 0 重新上线。副本 0 只有消息 0-100,而没有 100-200。如果我们允许副本 0 成为新的领导者,它将允许生产者写入新消息,并允许消费者读取它们。因此,现在新的领导者完全有新的消息 100-200。首先,让我们注意到一些消费者可能已经读取了旧消息 100-200,一些消费者得到了新的 100-200,一些得到了混合的。当涉及到下游报告等事项时,这可能会导致非常糟糕的后果。此外,副本 2 将重新上线并成为新领导者的跟随者。在那时,它将删除任何它得到的但在当前领导者上不存在的消息。这些消息将不会对未来的任何消费者可用。
总之,如果我们允许不同步的副本成为领导者,我们就面临着数据丢失和不一致的风险。如果我们不允许它们成为领导者,我们将面临较低的可用性,因为我们必须等待原始领导者恢复正常,分区才能重新上线。
默认情况下,unclean.leader.election.enable被设置为 false,这将不允许不同步的副本成为领导者。这是最安全的选择,因为它提供了最好的保证来防止数据丢失。这意味着在我们之前描述的极端不可用的情况下,一些分区将保持不可用,直到手动恢复。管理员始终可以查看情况,决定接受数据丢失以使分区可用,并在启动集群之前将此配置切换为 true。只是不要忘记在集群恢复后将其切换回 false。
最小同步副本
主题和经纪人级别的配置都称为min.insync.replicas。
正如我们所看到的,有些情况下,即使我们配置了一个主题有三个副本,我们可能只剩下一个同步副本。如果这个副本不可用,我们可能不得不在可用性和一致性之间做出选择。这从来都不是一个容易的选择。请注意,问题的一部分是,根据 Kafka 的可靠性保证,数据被认为是已提交的,当它被写入所有同步副本时,即使“所有”只意味着一个副本,如果该副本不可用,数据可能会丢失。
当我们想要确保提交的数据被写入多个副本时,我们需要将最小同步副本的数量设置为更高的值。如果一个主题有三个副本,我们将min.insync.replicas设置为2,那么生产者只能在至少两个副本中有同步的情况下写入主题中的分区。
当三个副本都同步时,一切都会正常进行。如果其中一个副本不可用,情况也是如此。但是,如果三个副本中有两个不可用,经纪人将不再接受生产请求。相反,试图发送数据的生产者将收到NotEnoughReplicasException。消费者可以继续读取现有数据。实际上,使用这种配置,一个单独的同步副本将变为只读。这可以防止产生和消费数据,然后在发生不干净的选举时消失的不良情况。为了从这种只读状态中恢复,我们必须使两个不可用的分区中的一个再次可用(可能重新启动经纪人),并等待它赶上并同步。
保持副本同步
如前所述,不同步的副本会降低整体可靠性,因此尽量避免这种情况非常重要。我们还解释了副本可能以两种方式之一变得不同步:要么失去与 ZooKeeper 的连接,要么无法跟上领导者并积累复制延迟。Kafka 有两个经纪人配置,用于控制集群对这两种情况的敏感度。
zookeeper.session.timeout.ms是 Kafka 经纪人可以在此期间停止向 ZooKeeper 发送心跳而 ZooKeeper 不认为经纪人已死并将其从集群中移除的时间间隔。在 2.5.0 版本中,这个值从 6 秒增加到 18 秒,以增加云环境中 Kafka 集群的稳定性,其中网络延迟显示更高的方差。通常,我们希望这个时间足够长,以避免由垃圾收集或网络条件引起的随机波动,但仍然足够低,以确保实际上被冻结的经纪人将及时被检测到。
如果副本未从领导者那里获取消息,或者未赶上领导者的最新消息时间超过replica.lag.time.max.ms,它将变得不同步。在 2.5.0 版本中,这个值从 10 秒增加到 30 秒,以提高集群的弹性并避免不必要的波动。请注意,这个更高的值也会影响消费者的最大延迟——使用更高的值可能需要长达 30 秒的时间,直到所有副本都收到消息并允许消费。
持久化到磁盘
我们已经多次提到,Kafka 将确认未持久化到磁盘的消息,仅取决于接收消息的副本数量。Kafka 将在旋转段(默认大小为 1GB)之前和重新启动之前将消息刷新到磁盘,但在其他情况下,将依赖于 Linux 页面缓存在其变满时刷新消息。其背后的想法是,在分开的机架或可用区域中有三台机器,每台机器都有数据的副本,比在领导者上将消息写入磁盘更安全,因为两个不同机架或区域的同时故障是如此不太可能。但是,也可以配置经纪人更频繁地将消息持久化到磁盘。配置参数flush.messages允许我们控制未同步到磁盘的最大消息数量,flush.ms允许我们控制同步到磁盘的频率。在使用此功能之前,值得阅读“fsync如何影响 Kafka 的吞吐量以及如何减轻其缺点”。
在可靠系统中使用生产者
即使我们将经纪人配置为可能的最可靠配置,如果我们不配置生产者也是可靠的,整个系统仍然可能会丢失数据。
以下是两个示例场景,以演示这一点:
-
我们使用三个副本配置了代理,并且禁用了不洁净的领导者选举。因此,我们不应该丢失提交到 Kafka 集群的任何单个消息。然而,我们配置了生产者使用
acks=1发送消息。我们从生产者发送了一条消息,它被写入了领导者,但尚未被写入同步副本。领导者向生产者发送了一个响应,表示“消息已成功写入”,然后立即在数据被复制到其他副本之前崩溃。其他副本仍然被认为是同步的(记住,在我们宣布副本不同步之前需要一段时间),其中一个将成为领导者。由于消息未被写入副本,因此丢失了。但生产应用程序认为它已成功写入。系统是一致的,因为没有消费者看到消息(因为副本从未收到消息而未提交),但从生产者的角度来看,消息丢失了。 -
我们使用三个副本配置了代理,并且禁用了不洁净的领导者选举。我们从错误中吸取教训,并开始使用
acks=all来生产消息。假设我们试图向 Kafka 写入消息,但我们要写入的分区的领导者刚刚崩溃,新的领导者仍在选举中。Kafka 将回复“领导者不可用”。此时,如果生产者没有正确处理错误并重试直到写入成功,消息可能会丢失。再次强调,这不是代理可靠性问题,因为代理从未收到消息;也不是一致性问题,因为消费者也从未收到消息。但如果生产者没有正确处理错误,可能会导致消息丢失。
正如示例所示,每个编写生产到 Kafka 的应用程序的人都必须注意两件重要的事情:
-
使用正确的
acks配置以满足可靠性要求 -
在配置和代码中正确处理错误
我们在第三章中深入讨论了生产者配置,但让我们再次重点介绍一些重要的内容。
发送确认
生产者可以在三种不同的确认模式之间进行选择:
acks=0
这意味着如果生产者成功将消息发送到网络上,那么消息被认为已成功写入 Kafka。如果我们发送的对象无法序列化,或者网络卡失败,我们仍会收到错误,但如果分区脱机,领导者选举正在进行,甚至整个 Kafka 集群不可用,我们将不会收到任何错误。使用acks=0可以降低生产延迟(这就是为什么我们看到很多基准测试使用这种配置),但它不会改善端到端延迟(记住,消费者在所有可用副本中复制之前将看不到消息)。
acks=1
这意味着领导者在收到消息并将其写入分区数据文件时(但不一定同步到磁盘),将发送确认或错误。如果领导者关闭或崩溃,并且在崩溃之前成功写入领导者并得到确认的一些消息未被复制到跟随者,我们可能会丢失数据。使用这种配置,还可能会比领导者更快地写入领导者,导致副本不足,因为领导者在复制消息之前将从生产者那里确认消息。
acks=all
这意味着领导者将等待直到所有同步副本收到消息,然后才发送确认或错误。结合代理上的min.insync.replicas配置,这让我们控制在消息被确认之前有多少副本收到消息。这是最安全的选项——生产者在消息完全提交之前不会停止尝试发送消息。这也是生产者延迟最长的选项——生产者等待所有同步副本收到所有消息,然后才能将消息批次标记为“完成”并继续进行。
配置生产者重试
在生产者中处理错误有两个部分:生产者自动处理的错误和我们作为使用生产者库的开发人员必须处理的错误。
生产者可以处理可重试错误。当生产者向代理发送消息时,代理可以返回成功或错误代码。这些错误代码属于两类——可以在重试后解决的错误和不会解决的错误。例如,如果代理返回错误代码LEADER_NOT_AVAILABLE,生产者可以尝试再次发送消息——也许新的代理被选举出来,第二次尝试会成功。这意味着LEADER_NOT_AVAILABLE是一个可重试错误。另一方面,如果代理返回INVALID_CONFIG异常,再次尝试发送相同的消息不会改变配置。这是一个不可重试错误的例子。
总的来说,当我们的目标是永远不丢失消息时,我们最好的方法是配置生产者在遇到可重试错误时继续尝试发送消息。而在第三章中推荐的重试最佳方法是将重试次数保持在当前默认值(MAX_INT,或者实际上是无限)并使用delivery.timout.ms来配置我们愿意等待放弃发送消息的最长时间——生产者将在此时间间隔内尽可能多次地重试发送消息。
重试发送失败消息包括一个风险,即两条消息都成功写入代理,导致重复。重试和谨慎的错误处理可以保证每条消息将被存储至少一次,但不是确切一次。使用enable.idempotence=true将导致生产者在其记录中包含额外的信息,代理将使用这些信息来跳过由重试引起的重复消息。在第八章中,我们详细讨论了这是如何工作的。
额外的错误处理
使用内置的生产者重试是一种正确处理各种错误而不丢失消息的简单方法,但作为开发人员,我们仍然必须能够处理其他类型的错误。这些包括:
-
不可重试的代理错误,例如有关消息大小、授权错误等的错误。
-
在消息发送到代理之前发生的错误,例如序列化错误
-
当生产者耗尽所有重试尝试或由于使用所有内存来存储消息而填满生产者可用内存时发生的错误
-
超时
在第三章中,我们讨论了如何为同步和异步发送消息的方法编写错误处理程序。这些错误处理程序的内容是特定于应用程序及其目标的——我们要丢弃“坏消息”吗?记录错误?停止从源系统读取消息?对源系统施加反压力,暂停发送消息一段时间?将这些消息存储在本地磁盘上的目录中?这些决定取决于架构和产品要求。只需注意,如果错误处理程序所做的只是重试发送消息,那么我们最好依赖生产者的重试功能。
在可靠系统中使用消费者
现在我们已经学会了如何在考虑 Kafka 的可靠性保证的情况下生成数据,是时候看看如何消费数据了。
正如我们在本章的第一部分中所看到的,数据只有在提交到 Kafka 之后才对消费者可用——这意味着它已被写入到所有的同步副本。这意味着消费者获取的数据是保证一致的。消费者唯一需要做的就是确保他们跟踪已经读取的消息和尚未读取的消息。这对于在消费消息时不丢失消息至关重要。
当从分区中读取数据时,消费者会获取一批消息,检查批中的最后偏移量,然后请求从上次接收到的偏移量开始的另一批消息。这保证了 Kafka 消费者始终以正确的顺序获取新数据,而不会错过任何消息。
当一个消费者停止时,另一个消费者需要知道从哪里开始工作——前一个消费者在停止之前处理的最后偏移量是多少?“其他”消费者甚至可以是重新启动后的原始消费者。这并不重要——某个消费者将从该分区开始消费,并且需要知道从哪个偏移量开始。这就是为什么消费者需要“提交”它们的偏移量。对于它正在消费的每个分区,消费者都会存储其当前位置,因此它或其他消费者将知道在重新启动后从哪里继续。消费者可能丢失消息的主要方式是在提交已读取但尚未完全处理的事件的偏移量时。这样,当另一个消费者接管工作时,它将跳过这些消息,它们将永远不会被处理。这就是为什么仔细关注偏移量何时以及如何提交是至关重要的。
已提交的消息与已提交的偏移量
这与已提交的消息不同,如之前讨论的,已提交的消息是写入所有同步副本并对消费者可用的消息。已提交的偏移量是消费者发送给 Kafka 以确认它已接收并处理了分区中到特定偏移量的所有消息。
在第四章中,我们详细讨论了消费者 API,并涵盖了提交偏移量的许多方法。在这里,我们将介绍一些重要的考虑和选择,但是有关使用 API 的详细信息,请参考第四章。
可靠处理的重要消费者配置属性
有四个消费者配置属性对于理解如何配置我们的消费者以获得所需的可靠性行为是重要的。
第一个是group.id,如第四章中详细解释的那样。基本思想是,如果两个消费者具有相同的组 ID 并订阅相同的主题,每个消费者将被分配主题中分区的一个子集,因此每个消费者将单独读取一部分消息(但整个组将读取所有消息)。如果我们需要一个消费者能够独立地看到其订阅的主题中的每条消息,它将需要一个唯一的group.id。
第二个相关的配置是auto.offset.reset。该参数控制当没有提交偏移量时消费者将会做什么(例如,当消费者首次启动时),或者当消费者请求在代理中不存在的偏移量时(第四章解释了这种情况)。这里只有两个选项。如果我们选择earliest,消费者将从分区的开头开始,每当它没有有效的偏移量时。这可能导致消费者处理很多消息两次,但它保证了最小化数据丢失。如果我们选择latest,消费者将从分区的末尾开始。这最小化了消费者的重复处理,但几乎肯定会导致一些消息被消费者错过。
第三个相关配置是enable.auto.commit。这是一个重大决定:我们是否打算让消费者根据计划为我们提交偏移量,还是打算在我们的代码中手动提交偏移量?自动偏移量提交的主要好处是在我们的应用程序中使用消费者时,这是一件少了的事情需要担心。当我们在消费者轮询循环内处理所有消费记录时,自动偏移量提交可以保证我们永远不会意外提交我们没有处理的偏移量。自动偏移量提交的主要缺点是,我们无法控制应用程序可能处理的重复记录数量,因为它在处理一些记录后停止,但在自动提交生效之前。当应用程序有更复杂的处理,例如将记录传递到另一个线程在后台处理时,除了使用手动偏移量提交外别无选择,因为自动提交可能会提交消费者已读取但可能尚未处理的记录的偏移量。
第四个相关配置auto.commit.``interval.ms与第三个相关。如果我们选择自动提交偏移量,这个配置让我们配置它们的提交频率。默认值是每五秒一次。一般来说,更频繁地提交会增加开销,但会减少消费者停止时可能发生的重复数量。
虽然与可靠的数据处理没有直接关系,但如果消费者经常停止消费以进行重新平衡,很难认为它是可靠的。第四章包括如何配置消费者以最小化不必要的重新平衡和在重新平衡时最小化暂停的建议。
在消费者中明确提交偏移量
如果我们决定需要更多控制并选择手动提交偏移量,我们需要关注正确性和性能影响。
我们不会在这里详细介绍提交偏移量涉及的机制和 API,因为它们在第四章中已经深入讨论过。相反,我们将回顾在开发消费者处理数据时的重要考虑因素。我们将从简单而明显的观点开始,然后转向更复杂的模式。
在处理消息后始终提交偏移量
如果我们在轮询循环内进行所有处理,并且在轮询循环之间不保持状态(例如,用于聚合),这应该很容易。我们可以使用自动提交配置,在轮询循环结束时提交偏移量,或者在循环内以平衡开销和缺乏重复处理的要求提交偏移量。如果涉及额外的线程或有状态的处理,这将变得更加复杂,特别是因为消费者对象不是线程安全的。在第四章中,我们讨论了如何做到这一点,并提供了更多示例的参考。
提交频率是性能和在发生崩溃时重复事件数量之间的权衡。
即使在最简单的情况下,我们在轮询循环内进行所有处理,并且在轮询循环之间不保持状态(例如,用于聚合),我们可以选择在循环内多次提交或者选择每隔几个循环才提交一次。提交会带来显著的性能开销。这类似于使用acks=all进行生产,但单个消费者组的所有偏移量提交都会发送到同一个代理,这可能会导致负载过重。提交频率必须平衡性能要求和重复处理的要求。在非常低吞吐量的主题上,应该只在每条消息之后才进行提交。
在正确的时间提交正确的偏移量
在轮询循环中提交时的一个常见陷阱是在轮询时意外提交了最后读取的偏移量,而不是最后处理的偏移量之后的偏移量。请记住,始终要为处理后的消息提交偏移量——提交读取但未处理的消息的偏移量可能导致消费者丢失消息。第四章中有示例,展示了如何做到这一点。
重新平衡
在设计应用程序时,我们需要记住消费者重新平衡会发生,并且需要正确处理它们。第四章包含一些示例。通常这涉及在分区被撤销之前提交偏移量,并在分配新分区时清理应用程序维护的任何状态。
消费者可能需要重试
在某些情况下,在调用轮询并处理记录后,一些记录可能没有完全处理,需要稍后处理。例如,我们可能尝试将 Kafka 中的记录写入数据库,但发现数据库此时不可用,需要稍后重试。请注意,与传统的发布/订阅消息系统不同,Kafka 消费者提交偏移量而不是“确认”单个消息。这意味着如果我们未能处理记录#30 并成功处理记录#31,我们不应该提交偏移量#31——这将导致标记为已处理所有记录直到#31,包括#30,这通常不是我们想要的。相反,尝试遵循以下两种模式之一。
当我们遇到可重试错误时的一个选择是提交我们成功处理的最后一条记录。然后,我们将仍需要处理的记录存储在缓冲区中(以便下一次轮询不会覆盖它们),使用消费者的pause()方法确保额外的轮询不会返回数据,并继续尝试处理记录。
遇到可重试错误时的第二个选择是将其写入到一个单独的主题中并继续。可以使用单独的消费者组来处理重试主题中的重试,或者一个消费者可以订阅主题和重试主题,但在重试之间暂停重试主题。这种模式类似于许多消息系统中使用的死信队列系统。
消费者可能需要维护状态
在一些应用程序中,我们需要在多次轮询之间保持状态。例如,如果我们想要计算移动平均值,我们将希望在每次轮询 Kafka 获取新消息后更新平均值。如果我们的进程重新启动,我们不仅需要从最后的偏移量开始消费,还需要恢复匹配的移动平均值。一种方法是在应用程序提交偏移量的同时将最新累积值写入“结果”主题。这意味着当线程启动时,它可以在启动时获取最新的累积值,并从上次离开的地方继续。在第八章中,我们讨论了应用程序如何在单个事务中写入结果和提交偏移量。一般来说,这是一个相当复杂的问题,我们建议查看像 Kafka Streams 或 Flink 这样的库,它们提供了高级 DSL 样式的 API,用于聚合、连接、窗口和其他复杂的分析。
验证系统可靠性
一旦我们经历了确定我们的可靠性要求、配置代理、配置客户端以及以最佳方式使用 API 来满足我们的用例的过程,我们就可以放心地在生产环境中运行一切,确信不会错过任何事件,对吗?
我们建议首先进行一些验证,并建议三层验证:验证配置、验证应用程序,并在生产中监视应用程序。让我们看看每个步骤,并了解我们需要验证什么以及如何验证。
验证配置
从应用逻辑中隔离出来测试代理和客户端配置很容易,也建议出于两个原因这样做:
-
测试我们选择的配置是否能满足我们的要求是有帮助的。
-
推理系统的预期行为是一个很好的练习。
Kafka 包括两个重要的工具来帮助进行验证。org.apache.kafka.tools包括VerifiableProducer和VerifiableConsumer类。这些可以作为命令行工具运行,也可以嵌入到自动化测试框架中。
这个想法是,可验证的生产者产生一个包含从 1 到我们选择的值的数字序列的消息。我们可以像配置自己的生产者一样配置可验证的生产者,设置正确数量的ack、retries、delivery.timeout.ms,以及消息产生的速率。当我们运行它时,它将根据接收到的ack为每条发送到代理的消息打印成功或错误。可验证的消费者执行补充检查。它消费事件(通常是可验证的生产者产生的事件),并按顺序打印出它消费的事件。它还打印有关提交和重新平衡的信息。
重要的是要考虑我们想要运行哪些测试。例如:
-
领导者选举:如果我们杀死领导者会发生什么?生产者和消费者需要多长时间才能像往常一样开始工作?
-
控制器选举:系统在控制器重启后需要多长时间才能恢复?
-
滚动重启:我们能否逐个重启代理而不丢失任何消息?
-
非干净的领导者选举测试:当我们逐个杀死一个分区的所有副本(以确保每个副本都不同步),然后启动一个不同步的代理时会发生什么?为了恢复操作需要发生什么?这是可以接受的吗?
然后我们选择一个场景,启动可验证的生产者,启动可验证的消费者,并运行该场景——例如,杀死我们正在生产数据的分区的领导者。如果我们期望有一个短暂的暂停,然后一切都能正常恢复而没有消息丢失,我们需要确保生产者产生的消息数量和消费者消费的消息数量匹配。
Apache Kafka 源代码库包括一个广泛的测试套件。套件中的许多测试都基于相同的原则,并使用可验证的生产者和消费者来确保滚动升级正常工作。
验证应用程序
一旦我们确定代理和客户端配置符合我们的要求,就是测试应用程序是否提供我们需要的保证的时候了。这将检查诸如自定义错误处理代码、偏移提交、重新平衡监听器以及应用程序逻辑与 Kafka 客户端库交互的类似位置。
自然地,由于应用逻辑可能会有很大的变化,我们只能提供有限的关于如何测试的指导。我们建议将应用程序作为任何开发过程的一部分进行集成测试,并建议在各种故障条件下运行测试:
-
客户端失去与其中一个代理的连接
-
客户端和代理之间的高延迟
-
磁盘已满
-
挂起的磁盘(也称为“停电”)
-
领导者选举
-
代理的滚动重启
-
消费者的滚动重启
-
生产者的滚动重启
有许多工具可用于引入网络和磁盘故障,其中许多都非常出色,因此我们不会尝试提出具体建议。Apache Kafka 本身包括Trogdor 测试框架用于故障注入。对于每种情况,我们将有预期行为,这是我们在开发应用程序时计划看到的情况。然后我们运行测试,看看实际发生了什么。例如,当计划对消费者进行滚动重启时,我们计划进行短暂暂停,因为消费者会重新平衡,然后继续消费,最多不超过 1,000 个重复值。我们的测试将显示应用程序提交偏移量和处理重新平衡的方式是否真的如此运行。
在生产环境中监控可靠性
测试应用程序很重要,但这并不能取代持续监控生产系统以确保数据流如预期般顺畅。第十二章将详细介绍如何监控 Kafka 集群,但除了监控集群的健康状况外,还要监控客户端和数据流通过系统的情况。
Kafka 的 Java 客户端包括 JMX 指标,允许监控客户端状态和事件。对于生产者来说,可靠性最重要的两个指标是每条记录的错误率和重试率(汇总)。要密切关注这些指标,因为错误率或重试率的上升可能表明系统存在问题。还要监控生产者日志,查看在发送事件时记录为WARN级别的错误,内容类似于“在主题-分区 [topic-1,3] 上使用相关 ID 5689 产生错误的响应,正在重试(还剩两次尝试)。错误:…”当我们看到剩余尝试次数为 0 的事件时,表示生产者的重试次数已用尽。在第三章中,我们讨论了如何配置delivery.timeout.ms和retries以改进生产者的错误处理,并避免过早用尽重试次数。当然,解决导致错误的问题才是更好的选择。生产者的ERROR级别日志消息可能表明由于不可重试的错误、用尽重试次数的可重试错误或超时而完全发送消息失败。在适用的情况下,经纪人的确切错误也将被记录。
在消费者方面,最重要的指标是消费者滞后。该指标表示消费者距离经纪人分区上最新提交的消息有多远。理想情况下,滞后应始终为零,消费者将始终读取最新的消息。实际上,因为调用poll()会返回多条消息,然后消费者会花时间处理它们,然后再获取更多消息,滞后值会有所波动。重要的是确保消费者最终能够赶上,而不是越来越落后。由于消费者滞后的预期波动,设置传统的警报指标可能会有挑战性。Burrow是 LinkedIn 开发的消费者滞后检查工具,可以简化这一过程。
监控数据流也意味着确保所有生成的数据及时被消费(“及时”通常基于业务需求)。为了确保数据及时被消费,我们需要知道数据是何时生成的。Kafka 在这方面提供了帮助:从 0.10.0 版本开始,所有消息都包括一个时间戳,指示事件生成的时间(尽管请注意,如果应用程序发送事件或经纪人自身配置为这样做,时间戳可以被覆盖)。
为了确保所有生成的消息在合理的时间内被消耗,我们需要应用程序记录生成的事件数量(通常以每秒事件数的形式)。消费者需要记录每个时间单位消耗的事件数量,以及使用事件时间戳记录事件产生和消耗之间的滞后时间。然后,我们需要一个系统来协调生产者和消费者的每秒事件数量(以确保消息在传输过程中没有丢失),并确保生产时间和消费时间之间的间隔是合理的。这种端到端的监控系统可能具有挑战性,并且实施起来可能耗时。据我们所知,目前没有开源实现这种类型系统的,但 Confluent 作为Confluent Control Center的一部分提供了商业实现。
除了监控客户端和端到端数据流之外,Kafka 代理还包括指示从代理发送到客户端的错误响应速率的指标。我们建议收集kafka.server:type=BrokerTopicMetrics,name=FailedProduceRequestsPerSec和kafka.server:type=BrokerTopicMetrics,name=FailedFetchRequestsPerSec。有时,预期会出现一定级别的错误响应,例如,如果我们关闭代理进行维护,并在另一个代理上选举新的领导者,那么预期生产者将收到NOT_LEADER_FOR_PARTITION错误,这将导致它们在继续正常生产事件之前请求更新的元数据。无法解释的失败请求增加应该始终进行调查。为了帮助进行此类调查,失败请求指标附带了代理发送的具体错误响应标记。
总结
正如我们在本章开头所说的,可靠性不仅仅是特定 Kafka 功能的问题。我们需要构建一个完整可靠的系统,包括应用程序架构、应用程序使用生产者和消费者 API 的方式、生产者和消费者配置、主题配置和代理配置。使系统更加可靠总是会在应用程序复杂性、性能、可用性或磁盘空间使用等方面进行权衡。通过了解所有选项和常见模式,并了解每种用例的要求,我们可以就应用程序和 Kafka 部署需要多可靠以及哪些权衡是合理的做出明智的决策。
第八章:精确一次语义
在第七章中,我们讨论了配置参数和最佳实践,使 Kafka 用户能够控制 Kafka 的可靠性保证。我们专注于至少一次交付——Kafka 不会丢失已确认提交的消息的保证。这仍然存在重复消息的可能性。
在简单的系统中,消息由各种应用程序生成和消费,重复是一个相当容易处理的烦恼。大多数现实世界的应用程序包含消费应用程序可以使用的唯一标识符来对消息进行去重。
当我们查看聚合事件的流处理应用程序时,情况变得更加复杂。当检查一个消费事件、计算平均值并产生结果的应用程序时,往往无法检测到结果不正确,因为在计算平均值时事件被处理了两次。在这些情况下,提供更强的保证——精确一次处理语义是很重要的。
在本章中,我们将讨论如何使用具有精确一次语义的 Kafka,推荐的用例以及限制。与至少一次保证一样,我们将深入一点,提供一些洞察力和直觉,以了解此保证是如何实现的。在首次阅读本章时,可以跳过这些细节,但在使用该功能之前理解这些细节将是有用的——它将有助于澄清不同配置和 API 的含义以及如何最好地使用它们。
Kafka 中的精确一次语义是两个关键特性的组合:幂等生产者,它有助于避免由生产者重试引起的重复,以及事务语义,它保证流处理应用程序中的精确一次处理。我们将从更简单和更普遍有用的幂等生产者开始讨论这两个特性。
幂等生产者
如果执行相同操作多次的结果与执行一次相同操作的结果相同,则称该服务为幂等。在数据库中,通常可以通过UPDATE t SET x=x+1 where y=5和UPDATE t SET x=18 where y=5之间的差异来演示。第一个例子不是幂等的;如果我们调用它三次,最终的结果将与我们只调用一次时的结果大不相同。第二个例子是幂等的——无论我们运行这个语句多少次,x都将等于 18。
这与 Kafka 生产者有什么关系?如果我们将生产者配置为具有至少一次语义而不是幂等语义,这意味着在不确定的情况下,生产者将重试发送消息,以便至少到达一次。这些重试可能导致重复。
经典案例是分区领导者从生产者接收记录,成功地将其复制到跟随者,然后领导者所在的代理在发送响应给生产者之前崩溃。生产者在一段时间内没有收到响应后,将重新发送消息。消息将到达新的领导者,他已经从先前的尝试中拥有了消息的副本——导致重复。
在某些应用中,重复并不重要,但在其他应用中,它们可能导致库存错误计数、糟糕的财务报表,或者向某人发送两把雨伞而不是他们订购的一把。
Kafka 的幂等生产者通过自动检测和解决这些重复来解决这个问题。
幂等生产者是如何工作的?
当我们启用幂等生产者时,每条消息将包括一个唯一的生产者 ID(PID)和一个序列号。这些,连同目标主题和分区一起,唯一标识每条消息。经纪人使用这些唯一标识来跟踪经纪人上每个分区产生的最后五条消息。为了限制必须跟踪每个分区的先前序列号的数量,我们还要求生产者使用max.inflight.requests=5或更低(默认为 5)。
当经纪人接收到它之前已经接受过的消息时,它将拒绝重复的消息并返回适当的错误。这个错误被生产者记录并反映在其指标中,但不会引起任何异常,也不应引起任何警报。在生产者客户端,它将被添加到record-error-rate指标中。在经纪人端,它将成为RequestMetrics类型的ErrorsPerSec指标的一部分,其中包括每种错误类型的单独计数。
如果经纪人收到一个意外高的序列号会怎么样?经纪人期望消息编号 2 后面是消息编号 3;如果经纪人收到的是消息编号 27 呢?在这种情况下,经纪人将以“顺序错误”错误响应,但如果我们使用幂等生产者而不使用事务,这个错误可以被忽略。
警告
尽管生产者在遇到“顺序号错误”异常后将继续正常运行,但这种错误通常表明生产者和经纪人之间丢失了消息 - 如果经纪人接收到消息编号 2,然后是消息编号 27,那么消息 3 到 26 之间一定发生了什么。在日志中遇到这样的错误时,值得重新审视生产者和主题配置,并确保生产者配置了高可靠性的推荐值,并检查是否发生了不洁净的领导者选举。
与分布式系统一样,考虑幂等生产者在失败条件下的行为是很有趣的。考虑两种情况:生产者重新启动和经纪人失败。
生产者重新启动
当生产者失败时,通常会创建一个新的生产者来替代它 - 无论是人工重新启动机器,还是使用更复杂的框架如 Kubernetes 提供自动故障恢复。关键点在于,当生产者启动时,如果启用了幂等生产者,生产者将初始化并联系 Kafka 经纪人生成生产者 ID。每次初始化生产者都会导致一个全新的 ID(假设我们没有启用事务)。这意味着如果一个生产者失败,替换它的生产者发送了之前由旧生产者发送的消息,经纪人将不会检测到重复 - 两条消息将具有不同的生产者 ID 和不同的序列号,并将被视为两条不同的消息。请注意,如果旧生产者冻结然后在其替代品启动后恢复,情况也是如此 - 原始生产者不被识别为僵尸,因为我们有两个完全不同的具有不同 ID 的生产者。
经纪人失败
当经纪人失败时,控制器会为失败的经纪人上原本有领导者的分区选举新的领导者。假设我们有一个生产者向主题 A、分区 0 产生消息,该分区的领导副本在经纪人 5 上,跟随副本在经纪人 3 上。在经纪人 5 失败后,经纪人 3 成为新的领导者。生产者将通过元数据协议发现新的领导者是经纪人 3,并开始向其生产。但经纪人 3 如何知道哪些序列已经被生产,以拒绝重复的消息呢?
领导者每次生产新消息时都会更新其内存中的生产者状态的最后五个序列 ID。从属副本在每次从领导者复制新消息时都会更新自己的内存缓冲区。这意味着当从属副本成为领导者时,它已经在内存中具有最新的序列号,并且可以继续验证新生产的消息而不会出现任何问题或延迟。
但是当旧的 leader 回来时会发生什么?重新启动后,旧的内存中的生产者状态将不再存在于内存中。为了帮助恢复,当代理关闭或每次创建一个段时,代理将生产者状态的快照保存到文件中。代理启动时,它会从文件中读取最新状态。新重新启动的代理通过从当前 leader 进行复制来追赶,并在准备再次成为 leader 时,它在内存中具有最新的序列 ID。
如果代理崩溃且最后的快照未更新会发生什么?生产者 ID 和序列 ID 也是写入 Kafka 日志的消息格式的一部分。在崩溃恢复期间,将通过读取旧的快照和每个分区的最新段中的消息来恢复生产者状态。一旦恢复过程完成,将存储一个新的快照。
一个有趣的问题是如果没有消息会发生什么?想象一下某个主题有两个小时的保留时间,但在过去的两个小时内没有新消息到达——如果代理崩溃,将没有消息用于恢复状态。幸运的是,没有消息也意味着没有重复。我们将立即开始接受消息(同时记录有关状态缺失的警告),并从到达的新消息中创建生产者状态。
幂等生产者的限制
Kafka 的幂等生产者只能防止由生产者内部逻辑引起的重试情况中的重复。调用producer.send()两次发送相同的消息将创建重复消息,而幂等生产者无法阻止这种情况发生。这是因为生产者无法知道发送的两条记录实际上是相同的记录。最好使用生产者的内置重试机制,而不是捕获生产者异常并从应用程序本身重试;幂等生产者使这种模式更加吸引人——这是避免重试时重复的最简单方法。
还相当常见的是有多个实例或甚至一个实例有多个生产者的应用程序。如果这些生产者中的两个尝试发送相同的消息,幂等生产者将无法检测到重复。这种情况在从源获取数据的应用程序中非常常见——例如一个带有文件的目录,并将其生产到 Kafka。如果应用程序恰好有两个实例读取同一个文件并将记录生产到 Kafka,那么我们将在该文件中获得记录的多个副本。
提示
幂等生产者只会防止由生产者自身的重试机制引起的重复,无论重试是由生产者、网络还是代理错误引起的。但其他情况不会。
如何使用 Kafka 幂等生产者?
这是简单的部分。将enable.idempotence=true添加到生产者配置中。如果生产者已经配置为acks=all,性能不会有任何差异。通过启用幂等生产者,以下事情将发生变化:
-
为了检索生产者 ID,生产者在启动时将进行一次额外的 API 调用。
-
每个发送的记录批次将包括生产者 ID 和批次中第一条消息的序列 ID(批次中每条消息的序列 ID 是从第一条消息的序列 ID 加上一个增量得到的)。这些新字段为每个记录批次增加了 96 位(生产者 ID 是一个长整型,序列是一个整数),对于大多数工作负载来说,这几乎没有任何开销。
-
代理将验证来自任何单个生产者实例的序列号,并保证没有重复消息。
-
将保证每个分区产生的消息顺序,即使
max.in.flight.requests.per.connection设置为大于 1(5 是默认值,也是幂等生产者支持的最高值)。
注意
幂等生产者逻辑和错误处理在 2.5 版本中得到了显着改进(生产者端和代理端都是如此),这是 KIP-360 的结果。在 2.5 版本发布之前,生产者状态并不总是维持足够长的时间,这导致在各种场景中出现致命的 UNKNOWN_PRODUCER_ID 错误(分区重新分配存在一个已知的边缘情况,即在特定生产者的任何写操作发生之前,新副本就成为了领导者,这意味着新领导者对该分区没有状态)。此外,以前的版本在某些错误场景下尝试重写序列 ID,这可能导致重复。在更新版本中,如果我们遇到记录批次的致命错误,这个批次和所有正在传输的批次都将被拒绝。编写应用程序的用户可以处理异常并决定是跳过这些记录还是重试并冒重复和重新排序的风险。
交易
正如我们在本章介绍中提到的,Kafka 添加了交易以确保使用 Kafka Streams 开发的应用程序的正确性。为了使流处理应用程序生成正确的结果,每个输入记录必须被精确处理一次,并且其处理结果将被精确反映一次,即使发生故障也是如此。Apache Kafka 中的交易允许流处理应用程序生成准确的结果。这反过来使开发人员能够在准确性是关键要求的用例中使用流处理应用程序。
重要的是要记住,Kafka 中的交易是专门为流处理应用程序开发的。因此,它们被构建为与形成流处理应用程序基础的“消费-处理-生产”模式一起工作。在这种情况下,使用交易可以保证一次性语义——每个输入记录的处理在应用程序的内部状态更新并成功产生到输出主题后被视为完成。在“交易解决不了哪些问题?”中,我们将探讨一些 Kafka 的一次性保证不适用的情况。
注意
交易是底层机制的名称。一次性语义或一次性保证是流处理应用程序的行为。Kafka Streams 使用交易来实现其一次性保证。其他流处理框架,如 Spark Streaming 或 Flink,使用不同的机制来为其用户提供一次性语义。
交易使用案例
交易对于任何重视准确性的流处理应用程序都是有用的,特别是当流处理包括聚合和/或连接时。如果流处理应用程序只执行单个记录的转换和过滤,那么没有内部状态需要更新,即使在过程中引入了重复,也很容易将它们从输出流中过滤掉。当流处理应用程序将多个记录聚合为一个记录时,要检查结果记录是否错误要困难得多,因为某些输入记录被计算了多次;在不重新处理输入的情况下无法纠正结果。
金融应用程序是复杂流处理应用程序的典型例子,其中使用一次性能力来保证准确的聚合。然而,因为任何 Kafka Streams 应用程序都可以相当轻松地配置为提供一次性保证,我们已经看到它在更普通的用例中启用,包括例如聊天机器人。
事务解决了什么问题?
考虑一个简单的流处理应用程序:它从源主题中读取事件,可能对其进行处理,并将结果写入另一个主题。我们希望确保我们处理的每条消息的结果只被写入一次。可能会发生什么问题?
事实证明,有很多事情可能会出错。让我们看看两种情况。
由应用程序崩溃引起的重新处理
从源集群中消费一条消息并处理后,应用程序必须做两件事:将结果生成到输出主题,并提交我们消费的消息的偏移量。假设这两个独立的操作按照这个顺序发生。如果应用程序在生成输出后但在提交输入的偏移量之前崩溃会发生什么?
在第四章中,我们讨论了消费者崩溃时会发生什么。几秒钟后,缺少心跳将触发重新平衡,并且消费者正在消费的分区将重新分配给另一个消费者。该消费者将开始从这些分区中消费记录,从上次提交的偏移量开始。这意味着在上次提交的偏移量和崩溃之间应用程序处理的所有记录将被再次处理,并且结果将再次写入输出主题,导致重复。
由僵尸应用程序引起的重新处理
如果我们的应用程序刚刚从 Kafka 中消费了一批记录,然后在对这批记录进行任何其他操作之前冻结或失去与 Kafka 的连接,会发生什么?
就像在先前的情景中一样,如果错过了几次心跳,应用程序将被认为已经死亡,并且其分区将重新分配给消费者组中的另一个消费者。该消费者将重新读取该批记录,处理它,将结果生成到输出主题,并继续进行。
与此同时,第一个应用程序实例——冻结的那个——可能会恢复其活动:处理它最近消费的一批记录,并将结果生成到输出主题。它可以在轮询 Kafka 记录或发送心跳并发现自己应该死亡并且另一个实例现在拥有这些分区之前完成所有这些操作。
一个死亡但不知道自己已经死亡的消费者被称为僵尸。在这种情况下,我们可以看到,如果没有额外的保证,僵尸可能会向输出主题生成数据,并导致重复的结果。
事务如何保证一次性?
以我们的简单流处理应用程序为例。它从一个主题中读取数据,处理它,并将结果写入另一个主题。一次性处理意味着消费、处理和生成都是原子的。要么原始消息的偏移量被提交,结果被成功生成,要么这两件事都不会发生。我们需要确保部分结果——偏移量已提交但结果未生成,或者反之亦然——不会发生。
为了支持这种行为,Kafka 事务引入了原子多分区写的概念。这个想法是,提交偏移量和生成结果都涉及将消息写入分区。然而,结果被写入输出主题,偏移量被写入_consumer_offsets主题。如果我们可以开启一个事务,写入这两条消息,并且如果两者都成功写入则提交,或者中止以重试,我们将得到我们想要的一次性语义。
图 8-1 说明了一个简单的流处理应用程序,执行原子多分区写入到两个分区,同时提交了它消费的事件的偏移量。

图 8-1:具有原子多分区写入的事务性生产者
要使用事务并执行原子多分区写入,我们使用事务性生产者。事务性生产者只是一个配置了transactional.id并使用initTransactions()进行初始化的 Kafka 生产者。与 Kafka 代理自动生成的producer.id不同,transactional.id是生产者配置的一部分,并且预期在重新启动之间持久存在。事实上,transactional.id的主要作用是在重新启动时标识相同的生产者。Kafka 代理维护transactional.id到producer.id的映射,因此如果使用现有的transactional.id再次调用initTransactions(),生产者将被分配相同的producer.id而不是一个新的随机数。
防止应用程序的僵尸实例创建重复需要一种僵尸围栏机制,或者防止应用程序的僵尸实例将结果写入输出流。这里使用的通常的围栏僵尸的方法是使用一个时代。当调用initTransaction()初始化事务性生产者时,Kafka 会增加与transactional.id关联的时代编号。具有相同transactional.id但较低时代的生产者的发送、提交和中止请求将被拒绝,并显示FencedProducer错误。旧的生产者将无法写入输出流,并将被强制close(),防止僵尸实例引入重复记录。在 Apache Kafka 2.5 及更高版本中,还有一个选项可以将消费者组元数据添加到事务元数据中。这些元数据也将用于围栏,这将允许具有不同事务 ID 的生产者写入相同的分区,同时仍然防止僵尸实例。
事务在很大程度上是生产者的一个特性——我们创建一个事务性生产者,开始事务,将记录写入多个分区,生成偏移量以标记记录已经被处理,并提交或中止事务。我们都是从生产者端完成的。然而,这还不够——即使是最终中止的事务中写入的记录,也会像任何其他记录一样写入分区。消费者需要配置正确的隔离保证,否则我们将无法获得预期的一次性保证。
我们通过设置isolation.level配置来控制事务写入的消息的消费。如果设置为read_committed,在订阅一组主题后调用consumer.poll()将返回成功提交事务的消息或非事务写入的消息;它不会返回已中止事务或仍处于打开状态的事务的消息。默认的isolation.level值read_uncommitted将返回所有记录,包括属于打开或中止事务的记录。配置read_committed模式并不保证应用程序将获得特定事务的所有消息。可能只订阅了事务的一部分主题,因此只会获得消息的一部分。此外,应用程序无法知道事务何时开始或结束,或者哪些消息属于哪个事务。
图 8-2 显示了以read_committed模式的消费者与默认的read_uncommitted模式的消费者相比可见的记录。

图 8-2:以read_committed模式的消费者将落后于默认配置的消费者
为了保证消息按顺序读取,“read_committed”模式不会返回在第一个仍处于打开状态的事务开始之后产生的消息(称为最后稳定偏移量或 LSO)。这些消息将被保留,直到该事务由生产者提交或中止,或者直到它们达到“transaction.timeout.ms”(默认为 15 分钟)并被代理中止。保持事务长时间打开将通过延迟消费者引入更高的端到端延迟。
即使输入是非事务写入的,我们简单的流处理作业的输出也将具有一次性保证。原子多分区生产保证,如果输出记录已提交到输出主题,则输入记录的偏移量也已为该消费者提交,因此输入记录将不会再次被处理。
事务解决不了哪些问题?
正如前面解释的那样,Kafka 添加了事务以提供多分区原子写入(但不是读取)以及在流处理应用程序中隔离僵尸生产者。因此,当在消费-处理-生产流处理任务链中使用时,它们提供了一次性保证。在其他情况下,事务要么根本无法工作,要么需要额外的努力才能实现我们想要的保证。
两个主要错误是假设一次性保证适用于除向 Kafka 生产之外的其他操作,并且消费者始终读取整个事务并具有有关事务边界的信息。
以下是一些 Kafka 事务无法帮助实现一次性保证的情况。
流处理时的副作用
假设我们流处理应用程序中的记录处理步骤包括向用户发送电子邮件。在我们的应用程序中启用一次性语义将不会保证电子邮件只会发送一次。保证仅适用于写入 Kafka 的记录。使用序列号去重记录或使用标记来中止或取消事务在 Kafka 内部有效,但不会取消发送的电子邮件。对于在流处理应用程序中执行的具有外部影响的任何操作都是如此:调用 REST API,写入文件等。
从 Kafka 主题读取并写入数据库
在这种情况下,应用程序将写入外部数据库而不是 Kafka。在这种情况下,没有生产者参与——记录是使用数据库驱动程序(可能是 JDBC)写入数据库的,并且偏移量在消费者内部提交到 Kafka。没有机制允许在单个事务内将结果写入外部数据库并将偏移量提交到 Kafka。相反,我们可以在数据库中管理偏移量(如第四章中所述),并在单个事务中提交数据和偏移量到数据库——这将依赖于数据库的事务保证而不是 Kafka 的。
注意
微服务通常需要在单个原子事务中更新数据库并发布消息到 Kafka,因此要么两者都会发生,要么两者都不会发生。正如我们在最后两个示例中所解释的,Kafka 事务无法做到这一点。
这个常见问题的常见解决方案被称为“outbox 模式”。微服务只将消息发布到 Kafka 主题(“outbox”),而单独的消息中继服务从 Kafka 读取事件并更新数据库。因为正如我们刚才看到的,Kafka 不会保证对数据库的一次性更新,因此重要的是确保更新是幂等的。
使用这种模式可以保证消息最终会到达 Kafka、主题消费者和数据库,或者一个都不会到达。
反向模式——其中数据库表用作发件箱,中继服务确保对表的更新也将作为消息到达 Kafka——也被使用。当内置的 RDBMS 约束,如唯一性和外键,是有用的时,这种模式是首选。Debezium 项目发布了一篇关于发件箱模式的深入博客文章,其中包含详细的示例。
从数据库中读取数据,写入 Kafka,然后从那里写入另一个数据库
很容易相信我们可以构建一个应用程序,从数据库中读取数据,识别数据库事务,将记录写入 Kafka,然后从那里将记录写入另一个数据库,仍然保持源数据库的原始事务。
不幸的是,Kafka 事务没有必要的功能来支持这些端到端的保证。除了在同一事务中提交记录和偏移量的问题外,还存在另一个困难:Kafka 消费者中的read_committed保证对于保留数据库事务来说太弱。是的,消费者不会看到未提交的记录。但它不能保证已经看到了事务中提交的所有记录,因为它可能在某些主题上滞后;它没有信息来识别事务边界,因此无法知道事务何时开始和结束,以及它是否已经看到了一些、没有或所有的记录。
从一个 Kafka 集群复制数据到另一个集群
这更加微妙——在从一个 Kafka 集群复制数据到另一个集群时,可以支持精确一次性保证。在 Kafka 改进提案中描述了如何在镜像制造者 2.0 中添加精确一次性功能。在撰写本文时,该提案仍处于草案阶段,但算法已经清楚描述。该提案包括保证源集群中的每个记录将被精确地复制到目标集群中一次。
然而,这并不保证事务是原子的。如果一个应用程序以事务方式生成多个记录和偏移量,然后 MirrorMaker 2.0 将它们复制到另一个 Kafka 集群,事务属性和保证将在复制过程中丢失。当从 Kafka 复制数据到关系型数据库时,由于消费者从 Kafka 读取数据时无法知道或保证它是否获取了事务中的所有事件,同样的原因也会丢失。例如,如果只订阅了一部分主题,它可能会复制事务的一部分。
发布/订阅模式
这是一个稍微微妙的情况。我们已经讨论了在消费-处理-生产模式的上下文中的精确一次性,但发布/订阅模式是一个非常常见的用例。在发布/订阅用例中使用事务提供了一些保证:配置为read_committed模式的消费者不会看到作为中止事务的一部分发布的记录。但这些保证还不足以达到精确一次性。消费者可能会根据自己的偏移提交逻辑多次处理消息。
Kafka 在这种情况下提供的保证类似于 JMS 事务提供的保证,但依赖于read_committed模式的消费者来保证未提交的事务将保持不可见。JMS 代理会向所有消费者隐藏未提交的事务。
警告
要避免的一个重要模式是发布消息,然后等待另一个应用程序在提交事务之前做出响应。在事务提交之后,其他应用程序将无法接收到消息,导致死锁。
我如何使用事务?
事务是代理功能和 Kafka 协议的一部分,因此有多个客户端支持事务。
在 Kafka Streams 中启用事务的最常见和最推荐的方法是实现精准一次的保证。这样,我们将根本不直接使用事务,而是 Kafka Streams 将在幕后使用它们为我们提供所需的保证。事务是为这种用例而设计的,因此通过 Kafka Streams 使用它们是最简单且最有可能按预期工作的方法。
要为 Kafka Streams 应用程序启用精确一次的保证,我们只需将processing.guarantee配置设置为exactly_once或exactly_once_beta。就是这样。
注意
exactly_once_beta是一种稍微不同的处理应用实例崩溃或挂起的方法,它在发布 2.5 中引入到 Kafka 代理中,在发布 2.6 中引入到 Kafka Streams 中。这种方法的主要优点是能够使用单个事务性生产者处理多个分区,从而创建更可扩展的 Kafka Streams 应用程序。有关这些更改的更多信息,请参阅Kafka 改进提案。
但是,如果我们想要在不使用 Kafka Streams 的情况下实现精确一次的保证呢?在这种情况下,我们将直接使用事务性 API。以下是一个显示这将如何工作的片段。在 Apache Kafka GitHub 中有一个完整的示例,其中包括一个演示驱动程序和一个简单的精确一次处理器,它们在单独的线程中运行:
Properties producerProps = new Properties();
producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
producerProps.put(ProducerConfig.CLIENT_ID_CONFIG, "DemoProducer");
producerProps.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, transactionalId); // ①
producer = new KafkaProducer<>(producerProps);
Properties consumerProps = new Properties();
consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false"); // ②
consumerProps.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed"); // ③
consumer = new KafkaConsumer<>(consumerProps);
producer.initTransactions(); // ④
consumer.subscribe(Collections.singleton(inputTopic)); // ⑤
while (true) {
try {
ConsumerRecords<Integer, String> records =
consumer.poll(Duration.ofMillis(200));
if (records.count() > 0) {
producer.beginTransaction(); // ⑥
for (ConsumerRecord<Integer, String> record : records) {
ProducerRecord<Integer, String> customizedRecord = transform(record); // ⑦
producer.send(customizedRecord);
}
Map<TopicPartition, OffsetAndMetadata> offsets = consumerOffsets();
producer.sendOffsetsToTransaction(offsets, consumer.groupMetadata()); // ⑧
producer.commitTransaction(); // ⑨
}
} catch (ProducerFencedException|InvalidProducerEpochException e) { // ⑩
throw new KafkaException(String.format(
"The transactional.id %s is used by another process", transactionalId));
} catch (KafkaException e) {
producer.abortTransaction(); // ⑪
resetToLastCommittedPositions(consumer);
}}
① (#co_exactly_once_semantics_CO1-1)
使用transactional.id配置生产者使其成为能够生成原子多分区写入的事务性生产者。事务 ID 必须是唯一且长期存在的。它本质上定义了应用程序的一个实例。
② (#co_exactly_once_semantics_CO1-2)
作为事务的一部分的消费者不提交自己的偏移量——生产者在事务的一部分写入偏移量。因此,偏移提交应该被禁用。
③ (#co_exactly_once_semantics_CO1-3)
在这个例子中,消费者从一个输入主题中读取。我们将假设输入主题中的记录也是由一个事务性生产者写入的(只是为了好玩——对于输入并没有这样的要求)。为了干净地读取事务(即忽略正在进行中和已中止的事务),我们将把消费者隔离级别设置为read_committed。请注意,消费者仍然会读取非事务性写入,除了读取已提交的事务。
④ (#co_exactly_once_semantics_CO1-4)
事务性生产者必须做的第一件事是初始化。这会注册事务 ID,增加时代以保证具有相同 ID 的其他生产者将被视为僵尸,并中止来自相同事务 ID 的旧的正在进行中的事务。
⑤ (#co_exactly_once_semantics_CO1-5)
在这里,我们使用subscribe消费者 API,这意味着分配给应用程序实例的分区可能会因重新平衡而在任何时候发生变化。在发布 2.5 之前,这是更具挑战性的。事务性生产者必须静态地分配一组分区,因为事务围栏机制依赖于相同的事务 ID 用于相同的分区(如果事务 ID 更改,则没有僵尸围栏保护)。KIP-447 添加了新的 API,用于此示例中,它将消费者组信息附加到事务中,并且此信息用于围栏。使用此方法时,当相关分区被撤销时,提交事务也是有意义的。
// ⑥ (#co_exactly_once_semantics_CO1-6)
我们消费了记录,现在我们想要处理它们并产生结果。这种方法保证了从调用它的时间开始,直到事务被提交或中止,产生的所有内容都是作为单个原子事务的一部分。
⑦
这是我们处理记录的地方——所有的业务逻辑都在这里。
⑧
正如我们在本章前面解释的那样,将偏移量作为事务的一部分进行提交非常重要。这可以确保如果我们未能产生结果,我们不会提交那些实际上未被处理的记录的偏移量。这种方法将偏移量作为事务的一部分进行提交。请注意,重要的是不要以任何其他方式提交偏移量——禁用偏移自动提交,并且不要调用任何消费者提交 API。通过任何其他方法提交偏移量都无法提供事务性保证。
⑨
我们产生了我们需要的一切,我们将偏移量作为事务的一部分进行了提交,现在是时候提交事务并敲定交易了。一旦这个方法成功返回,整个事务就完成了,我们可以继续读取和处理下一批事件。
⑩
如果我们遇到了这个异常,这意味着我们是僵尸。不知何故,我们的应用程序冻结或断开连接,而有一个具有我们事务 ID 的新应用程序实例正在运行。很可能我们启动的事务已经被中止,其他人正在处理这些记录。除了优雅地死去外,没有别的办法。
// ⑪
如果在写入事务时出现错误,我们可以中止事务,将消费者位置设置回去,然后重试。
事务 ID 和围栏
为生产者选择事务 ID 非常重要,比看起来更具挑战性。错误地分配事务 ID 可能导致应用程序错误或丢失精确一次性保证。关键要求是事务 ID 在应用程序的相同实例之间重启时保持一致,并且对于应用程序的不同实例是不同的,否则代理将无法将僵尸实例围栏起来。
在 2.5 版本之前,保证围栏的唯一方法是将事务 ID 静态映射到分区。这可以保证每个分区始终使用相同的事务 ID 进行消费。如果具有事务 ID A 的生产者处理了来自主题 T 的消息并丢失了连接,替换它的新生产者具有事务 ID B,稍后生产者 A 作为僵尸回来,僵尸 A 将不会被围栏,因为 ID 与新生产者 B 不匹配。我们希望生产者 A 始终被生产者 A 替换,新的生产者 A 将具有更高的时代编号,僵尸 A 将被正确地围栏。在这些版本中,先前的示例将是不正确的——事务 ID 会随机分配给线程,而不会确保始终使用相同的事务 ID 写入同一分区。
在 Apache Kafka 2.5 中,KIP-447 引入了基于消费者组元数据的围栏的第二种方法,用于除了事务 ID 之外的围栏。我们使用生产者偏移量提交方法,并将消费者组元数据作为参数传递,而不仅仅是消费者组 ID。
假设我们有一个名为 T1 的主题,有两个分区 t-0 和 t-1。每个分区都由同一组中的不同消费者消费;每个消费者将记录传递给匹配的事务性生产者——一个具有事务 ID A,另一个具有事务 ID B;它们分别将输出写入到主题 T2 的分区 0 和 1。图 8-3 说明了这种情况。

图 8-3:事务记录处理器
如图 8-4 所示,如果具有消费者 A 和生产者 A 的应用实例变成僵尸,消费者 B 将开始处理来自两个分区的记录。如果我们想要保证没有僵尸写入分区 0,消费者 B 不能只是开始从分区 0 读取并使用事务 ID B 写入分区 0。相反,应用程序将需要实例化一个新的生产者,使用事务 ID A,以安全地写入分区 0 并隔离旧的事务 ID A。这是浪费的。相反,我们在事务中包括消费者组信息。来自生产者 B 的事务将显示它们来自消费者组的新一代,因此它们将通过,而来自现在僵尸的生产者 A 的事务将显示消费者组的旧一代,并将被隔离。

图 8-4:重新平衡后的事务记录处理器
事务如何工作
我们可以通过调用 API 来使用事务,而无需了解它们的工作原理。但是,对于不符合预期行为的应用程序,了解底层发生的事情会有助于我们进行故障排除。
Kafka 中事务的基本算法受到了 Chandy-Lamport 快照的启发,其中“标记”控制消息被发送到通信通道中,并且基于标记的到达确定一致状态。Kafka 事务使用标记消息来指示跨多个分区的事务是已提交还是已中止——当生产者决定提交事务时,它向事务协调者发送一个“提交”消息,然后事务协调者将提交标记写入涉及事务的所有分区。但是,如果生产者在仅向部分分区写入提交消息后崩溃会发生什么?Kafka 事务通过使用两阶段提交和事务日志来解决这个问题。在高层次上,该算法将:
-
记录正在进行的事务的存在,包括涉及的分区
-
记录提交或中止的意图——一旦记录了这一点,我们注定要最终提交或中止
-
将所有事务标记写入所有分区
-
记录事务的完成
为了实现这个基本算法,Kafka 需要一个事务日志。我们使用一个名为__transaction_state的内部主题。
通过查看我们在前面的代码片段中使用的事务 API 调用的内部工作,让我们看看这个算法是如何在实践中工作的。
在开始第一个事务之前,生产者需要通过调用initTransaction()来注册为事务性。这个请求被发送到一个代理,这个代理将是这个事务性生产者的事务协调者。每个代理都是一组生产者的事务协调者的一部分,就像每个代理都是一组消费者的消费者组协调者的一部分一样。每个事务 ID 的事务协调者是事务日志的分区的领导者。
initTransaction() API 向协调者注册一个新的事务 ID,或者增加现有事务 ID 的时代,以隔离可能已经变成僵尸的先前生产者。当时代增加时,挂起的事务将被中止。
生产者的下一步是调用beginTransaction()。这个 API 调用不是协议的一部分——它只是告诉生产者现在有一个正在进行的事务。代理端的事务协调者仍然不知道事务已经开始。然而,一旦生产者开始发送记录,每次生产者检测到自己正在向一个新的分区发送记录时,它还会向代理发送AddPartitionsToTxnRequest,通知它这个生产者正在进行一个事务,并且额外的分区是事务的一部分。这些信息将被记录在事务日志中。
当我们完成生成结果并准备提交时,我们首先要为此事务中已处理的记录提交偏移量。可以在任何时候提交偏移量,但必须在提交事务之前完成。调用sendOffsetsToTransaction()将向事务协调器发送一个请求,其中包括偏移量和消费者组 ID。事务协调器将使用消费者组 ID 来查找组协调器,并像消费者组一样提交偏移量。
现在是时候提交或中止了。调用commitTransaction()或abortTransaction()将向事务协调器发送一个EndTransactionRequest。事务协调器将提交或中止意图记录到事务日志中。一旦此步骤成功,事务协调器就有责任完成提交(或中止)过程。它会向涉及事务的所有分区写入提交标记,然后写入事务日志,表明提交已成功完成。请注意,如果事务协调器在记录提交意图后关闭或崩溃,将会选举新的事务协调器,从事务日志中获取提交意图,并完成该过程。
如果在transaction.timeout.ms内未提交或中止事务,事务协调器将自动中止它。
警告
每个从事事务性或幂等性生产者接收记录的经纪人都会将生产者/事务性 ID 与生产者发送的最后五个批次的相关状态一起存储在内存中:序列号、偏移量等。在生产者停止活动后(默认为七天),此状态将存储transactional.id.expiration.ms毫秒。这允许生产者在不遇到“UNKNOWN_PRODUCER_ID”错误的情况下恢复活动。通过以非常高的速率创建新的幂等性生产者或新的事务性 ID 但从不重用它们,可能会导致经纪人出现类似内存泄漏的情况。在一周的时间内累积每秒三个新的幂等性生产者,将导致 180 万个生产者状态条目,总共存储了 900 万个批次元数据,占用大约 5GB 的 RAM。这可能会导致经纪人出现内存不足或严重的垃圾回收问题。我们建议在应用程序启动时初始化一些长期存在的生产者,然后在应用程序的整个生命周期内重复使用它们。如果这不可能(作为服务的功能会使这变得困难),我们建议降低transactional.id.expiration.ms,以便 ID 会更快地过期,因此永远不会被重用的旧状态不会占用经纪人内存的重要部分。
交易的性能
事务会给生产者增加适度的开销。在生产者生命周期中,注册事务 ID 的请求只会发生一次。作为事务的一部分注册分区的额外调用最多每个分区一次,然后每个事务发送一个提交请求,这会导致在每个分区上写入额外的提交标记。事务初始化和事务提交请求是同步的,因此在它们成功完成、失败或超时之前不会发送任何数据,这会进一步增加开销。
请注意,生产者的事务开销与事务中的消息数量无关。因此,每个事务中的消息数量增加会减少相对开销,并减少同步停止的次数,从而提高整体吞吐量。
在消费者方面,阅读提交标记涉及一些开销。事务对消费者性能的主要影响是由于“读取提交”模式下的消费者不会返回属于未提交事务的记录。事务提交之间的长时间间隔意味着消费者需要等待更长时间才能返回消息,结果端到端的延迟会增加。
然而,需要注意的是,消费者不需要缓冲属于未提交事务的消息。代理不会在响应消费者的抓取请求时返回这些消息。由于消费者在读取事务时没有额外的工作,因此吞吐量也不会减少。
总结
在 Kafka 中的确切一次语义与国际象棋相反:它很难理解,但易于使用。
本章涵盖了在 Kafka 中提供确切一次保证的两个关键机制:幂等生产者,它避免了重试机制引起的重复,以及事务,它构成了 Kafka Streams 中确切一次语义的基础。
两者可以在单个配置中启用,并允许我们将 Kafka 用于需要更少重复和更强正确性保证的应用程序。
我们深入讨论了特定场景和用例,展示了预期的行为,甚至查看了一些实现细节。当故障排除应用程序或直接使用事务 API 时,这些细节是重要的。
通过了解 Kafka 在哪些用例中确切的一次语义保证,我们可以设计应用程序在必要时使用确切一次。应用程序行为不应该令人惊讶,本章中的信息将帮助我们避免意外。
第九章:构建数据管道
当人们讨论使用 Apache Kafka 构建数据管道时,他们通常指的是一些用例。第一种是构建一个数据管道,其中 Apache Kafka 是两个端点之一,例如,将数据从 Kafka 传输到 S3,或者将数据从 MongoDB 传输到 Kafka。第二种用例涉及在两个不同系统之间构建管道,但使用 Kafka 作为中间件。一个例子是通过首先将数据从 Twitter 发送到 Kafka,然后从 Kafka 发送到 Elasticsearch,从而将数据从 Twitter 传输到 Elasticsearch。
当我们在 Apache Kafka 的 0.9 版本中添加 Kafka Connect 时,是因为我们看到 Kafka 在 LinkedIn 和其他大型组织中都被用于这两种用例。我们注意到将 Kafka 集成到数据管道中存在特定的挑战,每个组织都必须解决这些挑战,因此决定向 Kafka 添加 API,以解决其中一些挑战,而不是强迫每个组织从头开始解决这些挑战。
Kafka 为数据管道提供的主要价值在于其作为管道中各个阶段之间的非常大的、可靠的缓冲区。这有效地解耦了管道内的数据生产者和消费者,并允许在多个目标应用程序和系统中使用相同数据源的数据,这些目标应用程序和系统具有不同的及时性和可用性要求。这种解耦,加上可靠性、安全性和效率,使 Kafka 非常适合大多数数据管道。
将数据集成放入背景中
一些组织认为 Kafka 是管道的端点。他们关注的问题是“我如何将数据从 Kafka 传输到 Elastic?”这是一个值得问的问题,特别是如果你需要 Elastic 中的数据,而它目前在 Kafka 中,我们将看看如何做到这一点。但我们将从在至少包括两个(可能更多)不是 Kafka 本身的端点的更大背景中开始讨论 Kafka 的使用。我们鼓励面临数据集成问题的任何人考虑更大的背景,而不仅仅关注于即时端点。专注于短期集成是导致复杂且昂贵的数据集成混乱的原因。
在本章中,我们将讨论构建数据管道时需要考虑的一些常见问题。这些挑战并不特定于 Kafka,而是一般的数据集成问题。尽管如此,我们将展示为什么 Kafka 非常适合数据集成用例,以及它如何解决许多这些挑战。我们将讨论 Kafka Connect API 与普通的生产者和消费者客户端的不同之处,以及何时应该使用每种客户端类型。然后我们将深入讨论 Kafka Connect 的一些细节。虽然本章不涉及 Kafka Connect 的全面讨论,但我们将展示基本用法的示例,以帮助您入门,并指导您在哪里学习更多。最后,我们将讨论其他数据集成系统以及它们如何与 Kafka 集成。
构建数据管道时的考虑因素
虽然我们不会在这里详细讨论构建数据管道的所有细节,但我们想强调在设计软件架构时需要考虑的一些最重要的事情,目的是集成多个系统。
及时性
一些系统期望它们的数据一天一次以大批量到达;其他系统期望数据在生成后的几毫秒内到达。大多数数据管道都处于这两个极端之间的某个位置。良好的数据集成系统可以支持不同管道的不同及时性要求,并且在业务需求变化时也可以更容易地迁移不同的时间表。Kafka 作为一个具有可扩展和可靠存储的流数据平台,可以用于支持从几乎实时的管道到每日批处理的任何需求。生产者可以根据需要频繁或不频繁地写入 Kafka,消费者也可以在事件到达时读取和传递最新的事件。或者消费者可以批量处理:每小时运行一次,连接到 Kafka,并读取前一个小时积累的事件。
在这种情况下,看待 Kafka 的一个有用方式是它充当了一个巨大的缓冲区,解耦了生产者和消费者之间的时间敏感性要求。生产者可以实时写入事件,而消费者可以处理事件批次,反之亦然。这也使得施加反压变得微不足道——Kafka 本身对生产者施加反压(在需要时延迟确认),因为消费速率完全由消费者驱动。
可靠性
我们希望避免单点故障,并允许从各种故障事件中快速且自动地恢复。数据管道通常是数据到达业务关键系统的方式;故障超过几秒钟可能会带来巨大的破坏,特别是当时效性要求更接近光谱的几毫秒端时。可靠性的另一个重要考虑因素是交付保证——一些系统可以承受数据丢失,但大多数情况下都需要至少一次交付,这意味着源系统的每个事件都将到达其目的地,但有时重试会导致重复。通常,甚至需要恰好一次的交付——源系统的每个事件都将无法丢失或重复地到达目的地。
我们在第七章中深入讨论了 Kafka 的可用性和可靠性保证。正如我们所讨论的,Kafka 可以单独提供至少一次的保证,并且当与具有事务模型或唯一键的外部数据存储结合时,可以提供恰好一次的保证。由于许多终点是提供恰好一次交付语义的数据存储,基于 Kafka 的管道通常可以实现恰好一次的交付。值得强调的是,Kafka 的 Connect API 通过提供与处理偏移量时与外部系统集成的 API,使连接器更容易构建端到端的恰好一次管道。事实上,许多可用的开源连接器支持恰好一次的交付。
高吞吐量和变化吞吐量
我们正在构建的数据管道应该能够扩展到非常高的吞吐量,这在现代数据系统中经常需要。更重要的是,如果吞吐量突然增加,它们应该能够适应。
有了 Kafka 作为生产者和消费者之间的缓冲区,我们不再需要将消费者的吞吐量与生产者的吞吐量耦合在一起。我们也不再需要实现复杂的反压机制,因为如果生产者的吞吐量超过消费者的吞吐量,数据将在 Kafka 中积累,直到消费者赶上。Kafka 通过独立添加消费者或生产者来扩展的能力,使我们能够动态地独立地扩展管道的任一侧,以满足不断变化的需求。
Kafka 是一个高吞吐量的分布式系统,即使在较小的集群上,也能每秒处理数百兆字节的数据,因此我们不必担心我们的管道在需求增长时无法扩展。此外,Kafka Connect API 专注于并行化工作,可以根据系统要求在单个节点上进行工作,也可以进行扩展。我们将在接下来的章节中描述平台如何允许数据源和目标在多个执行线程中分割工作,并在单台机器上运行时利用可用的 CPU 资源。
Kafka 还支持多种类型的压缩,允许用户和管理员在吞吐量要求增加时控制网络和存储资源的使用。
数据格式
数据管道中最重要的考虑之一是协调不同的数据格式和数据类型。不同数据库和其他存储系统支持的数据类型各不相同。您可能会将 XML 和关系数据加载到 Kafka 中,在 Kafka 中使用 Avro,然后在将数据写入 Elasticsearch 时需要将数据转换为 JSON,在将数据写入 HDFS 时需要将数据转换为 Parquet,在将数据写入 S3 时需要将数据转换为 CSV。
Kafka 本身和 Connect API 在数据格式方面完全是不可知的。正如我们在前几章中看到的,生产者和消费者可以使用任何序列化器来表示适合您的任何格式的数据。Kafka Connect 有自己的内存对象,包括数据类型和模式,但正如我们将很快讨论的那样,它允许可插拔的转换器来允许以任何格式存储这些记录。这意味着无论您使用 Kafka 的哪种数据格式,它都不会限制您选择的连接器。
许多来源和目标都有模式;我们可以从数据源中读取模式,存储它,并用它来验证兼容性,甚至在目标数据库中更新模式。一个经典的例子是从 MySQL 到 Snowflake 的数据管道。如果有人在 MySQL 中添加了一列,一个优秀的管道将确保该列也被添加到 Snowflake 中,因为我们正在向其中加载新数据。
此外,在将数据从 Kafka 写入外部系统时,接收连接器负责将数据写入外部系统的格式。一些连接器选择使此格式可插拔。例如,S3 连接器允许在 Avro 和 Parquet 格式之间进行选择。
支持不同类型的数据是不够的。通用数据集成框架还应处理各种来源和目标之间的行为差异。例如,Syslog 是一个推送数据的来源,而关系数据库需要框架从中提取数据。HDFS 是追加写入的,我们只能向其写入数据,而大多数系统允许我们追加数据和更新现有记录。
转换
转换比其他要求更有争议。通常有两种构建数据管道的方法:ETL 和 ELT。ETL 代表提取-转换-加载,意味着数据管道负责在数据通过时对数据进行修改。它被认为可以节省时间和存储空间,因为您不需要存储数据,修改数据,然后再次存储数据。根据转换的不同,这种好处有时是真实的,但有时会将计算和存储的负担转移到数据管道本身,这可能是不可取的。这种方法的主要缺点是,在管道中对数据进行的转换可能会限制希望在管道下游进一步处理数据的人的手段。如果在 MongoDB 和 MySQL 之间构建管道的人决定过滤某些事件或从记录中删除字段,那么所有访问 MySQL 中数据的用户和应用程序只能访问部分数据。如果他们需要访问缺失的字段,就需要重建管道,并且历史数据需要重新处理(假设可用)。
ELT 代表提取-加载-转换,意味着数据管道只进行最小的转换(主要是数据类型转换),目标是确保到达目标系统的数据尽可能与源数据相似。在这些系统中,目标系统收集“原始数据”,所有必需的处理都在目标系统中完成。这里的好处是系统为目标系统的用户提供了最大的灵活性,因为他们可以访问所有数据。这些系统也往往更容易进行故障排除,因为所有数据处理都限制在一个系统中,而不是在管道和其他应用程序之间分开。缺点是转换会在目标系统消耗 CPU 和存储资源。在某些情况下,这些系统是昂贵的,因此有强烈的动机尽可能将计算从这些系统中移出。
Kafka Connect 包括单条消息转换功能,它在从源到 Kafka 或从 Kafka 到目标系统的复制过程中转换记录。这包括将消息路由到不同的主题,过滤消息,更改数据类型,删除特定字段等。通常使用 Kafka Streams 进行涉及连接和聚合的更复杂的转换,我们将在单独的章节中详细探讨这些内容。
警告
在使用 Kafka 构建 ETL 系统时,请记住,Kafka 允许您构建一对多的管道,其中源数据只写入 Kafka 一次,然后被多个应用程序消费,并写入多个目标系统。预期会进行一些预处理和清理,例如标准化时间戳和数据类型,添加谱系,以及可能删除个人信息 - 这些转换将使所有数据的消费者受益。但是不要在摄取时过早地清理和优化数据,因为它可能在其他地方需要不太精细的数据。
安全
安全始终应该是一个关注点。在数据管道方面,主要的安全问题通常是:
-
谁可以访问被摄入到 Kafka 中的数据?
-
我们能确保通过管道传输的数据是加密的吗?这主要是对跨数据中心边界的数据管道的关注。
-
谁有权对管道进行修改?
-
如果数据管道需要从受访控制的位置读取或写入,它能够正确进行身份验证吗?
-
我们处理的个人可识别信息(PII)是否符合有关其存储、访问和使用的法律和法规?
Kafka 允许在数据传输过程中对数据进行加密,从数据源到 Kafka,再从 Kafka 到目标系统。它还支持身份验证(通过 SASL)和授权,因此您可以确保敏感信息不会被未经授权的人传输到不太安全的系统中。Kafka 还提供审计日志以跟踪访问 - 无权和有权的访问。通过一些额外的编码,还可以跟踪每个主题中事件的来源和修改者,以便为每条记录提供完整的谱系。
Kafka 安全性在第十一章中有详细讨论。但是,Kafka Connect 及其连接器需要能够连接到外部数据系统,并对外部数据系统的连接进行身份验证,连接器的配置将包括用于与外部数据系统进行身份验证的凭据。
这些天不建议将凭据存储在配置文件中,因为这意味着配置文件必须小心处理并且受到限制的访问。一个常见的解决方案是使用外部秘密管理系统,比如HashiCorp Vault。Kafka Connect 包括对外部秘密配置的支持。Apache Kafka 只包括允许引入可插拔外部配置提供程序的框架,一个从文件中读取配置的示例提供程序,还有社区开发的外部配置提供程序,可以与 Vault、AWS 和 Azure 集成。
故障处理
假设所有数据始终完美是危险的。提前规划故障处理是很重要的。我们能防止有错误的记录进入管道吗?我们能从无法解析的记录中恢复吗?坏记录可以被修复(也许由人类)并重新处理吗?如果坏事件看起来和正常事件完全一样,而你只在几天后发现了问题呢?
由于 Kafka 可以配置为长时间存储所有事件,因此可以在需要时回溯并从错误中恢复。这也允许重放存储在 Kafka 中的事件到目标系统,如果它们丢失了。
耦合和灵活性
数据管道实现的一个理想特征是解耦数据源和数据目标。意外耦合可能发生的多种方式:
特别的管道
一些公司最终为他们想要连接的每一对应用程序构建自定义管道。例如,他们使用 Logstash 将日志转储到 Elasticsearch,使用 Flume 将日志转储到 HDFS,使用 Oracle GoldenGate 从 Oracle 获取数据到 HDFS,使用 Informatica 从 MySQL 和 XML 获取数据到 Oracle 等。这将数据管道紧密耦合到特定的端点,并创建了一堆集成点,需要大量的部署、维护和监控工作。这也意味着公司采用的每个新系统都将需要构建额外的管道,增加了采用新技术的成本,并抑制了创新。
元数据丢失
如果数据管道不保留模式元数据并且不允许模式演变,你最终会将产生数据的软件和目标使用数据的软件紧密耦合在一起。没有模式信息,两个软件产品都需要包含如何解析数据和解释数据的信息。如果数据从 Oracle 流向 HDFS,而 DBA 在 Oracle 中添加了一个新字段而没有保留模式信息并允许模式演变,那么从 HDFS 读取数据的每个应用程序都会崩溃,或者所有开发人员都需要同时升级他们的应用程序。这两种选择都不够灵活。通过管道支持模式演变,每个团队都可以以自己的速度修改他们的应用程序,而不必担心以后会出现问题。
极端处理
正如我们在讨论数据转换时提到的,一些数据处理是数据管道固有的。毕竟,我们正在将数据在不同的系统之间移动,不同的数据格式是有意义的,并且支持不同的用例。然而,过多的处理会将所有下游系统都与构建管道时所做的决定联系起来,包括保留哪些字段,如何聚合数据等。这经常导致管道的不断变化,因为下游应用的需求变化,这不够灵活、高效或安全。更灵活的方式是尽可能保留原始数据,并允许下游应用,包括 Kafka Streams 应用,自行决定数据处理和聚合。
何时使用 Kafka Connect 与生产者和消费者
在写入 Kafka 或从 Kafka 读取时,您可以选择使用传统的生产者和消费者客户端,如第三章和第四章中所述,或者使用 Kafka Connect API 和连接器,我们将在接下来的章节中描述。在我们开始深入了解 Kafka Connect 的细节之前,您可能已经在想,“我应该在什么时候使用哪个呢?”
正如我们所见,Kafka 客户端是嵌入在您自己的应用程序中的客户端。它允许您的应用程序将数据写入 Kafka 或从 Kafka 读取数据。当您可以修改要将应用程序连接到的应用程序的代码,并且希望将数据推送到 Kafka 或从 Kafka 拉取数据时,请使用 Kafka 客户端。
您将使用 Connect 将 Kafka 连接到您没有编写并且无法或不会修改其代码或 API 的数据存储。Connect 将用于将数据从外部数据存储拉取到 Kafka,或者将数据从 Kafka 推送到外部存储。要使用 Kafka Connect,您需要连接到要连接的数据存储的连接器,如今这些连接器已经很丰富。这意味着在实践中,Kafka Connect 的用户只需要编写配置文件。
如果您需要将 Kafka 连接到数据存储,并且尚不存在连接器,您可以选择使用 Kafka 客户端或 Connect API 编写应用程序。推荐使用 Connect,因为它提供了诸如配置管理、偏移存储、并行化、错误处理、支持不同数据类型和标准管理 REST API 等开箱即用的功能。编写一个将 Kafka 连接到数据存储的小应用听起来很简单,但实际上需要处理许多关于数据类型和配置的细节,使任务变得复杂。此外,您还需要维护这个管道应用并对其进行文档化,您的团队成员需要学习如何使用它。Kafka Connect 是 Kafka 生态系统的标准部分,它为您处理了大部分工作,使您能够专注于在外部存储之间传输数据。
Kafka Connect
Kafka Connect 是 Apache Kafka 的一部分,提供了一种可扩展和可靠的方式在 Kafka 和其他数据存储之间复制数据。它提供了 API 和运行时来开发和运行连接器插件——Kafka Connect 执行的库,负责移动数据。Kafka Connect 作为一组worker 进程运行。您可以在工作节点上安装连接器插件,然后使用 REST API 配置和管理连接器,这些连接器使用特定配置运行。连接器启动额外的任务以并行移动大量数据,并更有效地利用工作节点上的可用资源。源连接器任务只需要从源系统读取数据并向工作进程提供 Connect 数据对象。汇连接器任务从工作进程获取连接器数据对象,并负责将其写入目标数据系统。Kafka Connect 使用转换器来支持以不同格式存储这些数据对象——JSON 格式支持是 Apache Kafka 的一部分,Confluent Schema Registry 提供了 Avro、Protobuf 和 JSON Schema 转换器。这使用户可以选择在 Kafka 中存储数据的格式,而不受其使用的连接器的影响,以及如何处理数据的模式(如果有)。
本章无法涵盖 Kafka Connect 及其众多连接器的所有细节。这本身就可以填满一整本书。然而,我们将概述 Kafka Connect 及其使用方法,并指向其他参考资源。
运行 Kafka Connect
Kafka Connect 随 Apache Kafka 一起提供,因此无需单独安装。对于生产使用,特别是如果您计划使用 Connect 来移动大量数据或运行许多连接器,您应该将 Connect 运行在与 Kafka 代理不同的服务器上。在这种情况下,在所有机器上安装 Apache Kafka,并在一些服务器上启动代理,然后在其他服务器上启动 Connect。
启动 Connect worker 与启动代理非常相似-您使用属性文件调用启动脚本:
bin/connect-distributed.sh config/connect-distributed.properties
Connect worker 的一些关键配置:
bootstrap.servers
Kafka Connect 将与之一起工作的 Kafka 代理的列表。连接器将把它们的数据传输到这些代理中的一个或多个。您不需要指定集群中的每个代理,但建议至少指定三个。
group.id
具有相同组 ID 的所有 worker 都属于同一个 Connect 集群。在集群上启动的连接器将在任何 worker 上运行,其任务也将在任何 worker 上运行。
plugin.path
Kafka Connect 使用可插拔架构,其中连接器、转换器、转换和秘密提供者可以被下载并添加到平台上。为了做到这一点,Kafka Connect 必须能够找到并加载这些插件。
我们可以配置一个或多个目录作为连接器及其依赖项的位置。例如,我们可以配置plugin.path=/opt/connectors,/home/gwenshap/connectors。在这些目录中的一个中,我们通常会为每个连接器创建一个子目录,因此在前面的示例中,我们将创建/opt/connectors/jdbc和/opt/connectors/elastic。在每个子目录中,我们将放置连接器 jar 本身及其所有依赖项。如果连接器作为uberJar发货并且没有依赖项,它可以直接放置在plugin.path中,不需要子目录。但请注意,将依赖项放在顶级路径将不起作用。
另一种方法是将连接器及其所有依赖项添加到 Kafka Connect 类路径中,但这并不推荐,如果您使用一个带有与 Kafka 的依赖项冲突的依赖项的连接器,可能会引入错误。推荐的方法是使用plugin.path配置。
key.converter和value.converter
Connect 可以处理存储在 Kafka 中的多种数据格式。这两个配置设置了将存储在 Kafka 中的消息的键和值部分的转换器。默认值是使用 Apache Kafka 中包含的JSONConverter的 JSON 格式。这些配置也可以设置为AvroConverter、ProtobufConverter或JscoSchemaConverter,这些都是 Confluent Schema Registry 的一部分。
一些转换器包括特定于转换器的配置参数。您需要使用key.converter.或value.converter.作为前缀来设置这些参数,具体取决于您是要将其应用于键还是值转换器。例如,JSON 消息可以包含模式或无模式。为了支持任一种情况,您可以分别设置key.converter.schemas.enable=true或false。相同的配置也可以用于值转换器,通过将value.converter.schemas.enable设置为true或false。Avro 消息也包含模式,但您需要使用key.converter.schema.registry.url和value.converter.schema.registry.url来配置模式注册表的位置。
rest.host.name和rest.port
连接器通常通过 Kafka Connect 的 REST API 进行配置和监视。您可以为 REST API 配置特定的端口。
一旦工作人员上岗并且您有一个集群,请通过检查 REST API 确保其正常运行:
$ curl http://localhost:8083/
{"version":"3.0.0-SNAPSHOT","commit":"fae0784ce32a448a","kafka_cluster_id":"pfkYIGZQSXm8RylvACQHdg"}%
访问基本 REST URI 应返回您正在运行的当前版本。我们正在运行 Kafka 3.0.0 的快照(预发布)。我们还可以检查可用的连接器插件:
$ curl http://localhost:8083/connector-plugins
[
{
"class": "org.apache.kafka.connect.file.FileStreamSinkConnector",
"type": "sink",
"version": "3.0.0-SNAPSHOT"
},
{
"class": "org.apache.kafka.connect.file.FileStreamSourceConnector",
"type": "source",
"version": "3.0.0-SNAPSHOT"
},
{
"class": "org.apache.kafka.connect.mirror.MirrorCheckpointConnector",
"type": "source",
"version": "1"
},
{
"class": "org.apache.kafka.connect.mirror.MirrorHeartbeatConnector",
"type": "source",
"version": "1"
},
{
"class": "org.apache.kafka.connect.mirror.MirrorSourceConnector",
"type": "source",
"version": "1"
}
]
我们正在运行纯粹的 Apache Kafka,因此唯一可用的连接器插件是文件源、文件接收器,以及 MirrorMaker 2.0 的一部分连接器。
让我们看看如何配置和使用这些示例连接器,然后我们将深入更高级的示例,这些示例需要设置外部数据系统来连接。
独立模式
请注意,Kafka Connect 还有一个独立模式。它类似于分布式模式——你只需要运行bin/connect-standalone.sh而不是bin/connect-distributed.sh。您还可以通过命令行传递连接器配置文件,而不是通过 REST API。在这种模式下,所有的连接器和任务都在一个独立的工作节点上运行。它用于需要在特定机器上运行连接器和任务的情况(例如,syslog连接器监听一个端口,所以你需要知道它在哪些机器上运行)。
连接器示例:文件源和文件接收器
这个例子将使用 Apache Kafka 的文件连接器和 JSON 转换器。要跟着做,请确保您的 ZooKeeper 和 Kafka 已经运行起来。
首先,让我们运行一个分布式的 Connect 工作节点。在真实的生产环境中,您至少需要运行两到三个这样的节点,以提供高可用性。在这个例子中,我们只会启动一个:
bin/connect-distributed.sh config/connect-distributed.properties &
现在是时候启动一个文件源了。举个例子,我们将配置它来读取 Kafka 配置文件——基本上是将 Kafka 的配置导入到一个 Kafka 主题中:
echo '{"name":"load-kafka-config", "config":{"connector.class":
"FileStreamSource","file":"config/server.properties","topic":
"kafka-config-topic"}}' | curl -X POST -d @- http://localhost:8083/connectors
-H "Content-Type: application/json"
{
"name": "load-kafka-config",
"config": {
"connector.class": "FileStreamSource",
"file": "config/server.properties",
"topic": "kafka-config-topic",
"name": "load-kafka-config"
},
"tasks": [
{
"connector": "load-kafka-config",
"task": 0
}
],
"type": "source"
}
要创建一个连接器,我们编写了一个 JSON,其中包括一个连接器名称load-kafka-config,以及一个连接器配置映射,其中包括连接器类、我们要加载的文件和我们要将文件加载到的主题。
让我们使用 Kafka 控制台消费者来检查我们是否已经将配置加载到一个主题中:
gwen$ bin/kafka-console-consumer.sh --bootstrap-server=localhost:9092
--topic kafka-config-topic --from-beginning
如果一切顺利,你应该会看到类似以下的内容:
{"schema":{"type":"string","optional":false},"payload":"# Licensed to the Apache Software Foundation (ASF) under one or more"}
<more stuff here>
{"schema":{"type":"string","optional":false},"payload":"############################# Server Basics #############################"}
{"schema":{"type":"string","optional":false},"payload":""}
{"schema":{"type":"string","optional":false},"payload":"# The id of the broker. This must be set to a unique integer for each broker."}
{"schema":{"type":"string","optional":false},"payload":"broker.id=0"}
{"schema":{"type":"string","optional":false},"payload":""}
<more stuff here>
这实际上是config/server.properties文件的内容,因为它被逐行转换为 JSON 并放置在kafka-config-topic中。请注意,默认情况下,JSON 转换器在每条记录中放置一个模式。在这种特定情况下,模式非常简单——只有一个名为payload的列,类型为string,每条记录中包含一个文件的单行。
现在让我们使用文件接收器转换器将该主题的内容转储到一个文件中。生成的文件应该与原始的server.properties文件完全相同,因为 JSON 转换器将 JSON 记录转换回简单的文本行:
echo '{"name":"dump-kafka-config", "config":{"connector.class":"FileStreamSink","file":"copy-of-server-properties","topics":"kafka-config-topic"}}' | curl -X POST -d @- http://localhost:8083/connectors --header "content-Type:application/json"
{"name":"dump-kafka-config","config":{"connector.class":"FileStreamSink","file":"copy-of-server-properties","topics":"kafka-config-topic","name":"dump-kafka-config"},"tasks":[]}
注意源配置的变化:我们现在使用的类是FileStreamSink而不是FileStreamSource。我们仍然有一个文件属性,但现在它指的是目标文件而不是记录的源,而且不再指定topic,而是指定topics。注意复数形式——你可以用 sink 将多个主题写入一个文件,而源只允许写入一个主题。
如果一切顺利,你应该有一个名为copy-of-server-properties的文件,它与我们用来填充kafka-config-topic的config/server.properties完全相同。
要删除一个连接器,您可以运行:
curl -X DELETE http://localhost:8083/connectors/dump-kafka-config
警告
这个例子使用了 FileStream 连接器,因为它们简单且内置于 Kafka 中,允许您在安装 Kafka 之外创建您的第一个管道。这些不应该用于实际的生产管道,因为它们有许多限制和没有可靠性保证。如果您想从文件中摄取数据,可以使用几种替代方案:FilePulse Connector, FileSystem Connector, 或 SpoolDir。
连接器示例:MySQL 到 Elasticsearch
现在我们有一个简单的例子正在运行,让我们做一些更有用的事情。让我们将一个 MySQL 表流式传输到一个 Kafka 主题,然后从那里将其加载到 Elasticsearch 并索引其内容。
我们正在 MacBook 上运行测试。要安装 MySQL 和 Elasticsearch,只需运行:
brew install mysql
brew install elasticsearch
下一步是确保您有这些连接器。有几个选项:
-
使用Confluent Hub 客户端下载和安装。
-
从Confluent Hub网站(或您感兴趣的连接器托管的任何其他网站)下载。
-
从源代码构建。为此,您需要:
-
克隆连接器源代码:
git clone https://github.com/confluentinc/kafka-connect-elasticsearch
```
1. 运行“mvn install -DskipTests”来构建项目。
1. 使用[JDBC 连接器](https://oreil.ly/yXg0S)重复。
现在我们需要加载这些连接器。创建一个目录,比如*/opt/connectors*,并更新*config/connect-distributed.properties*以包括`plugin.path=/opt/connectors`。
然后将在构建每个连接器的“target”目录下创建的 jar 文件及其依赖项复制到“plugin.path”的适当子目录中:
```java
gwen$ mkdir /opt/connectors/jdbc
gwen$ mkdir /opt/connectors/elastic
gwen$ cp .../kafka-connect-jdbc/target/kafka-connect-jdbc-10.3.x-SNAPSHOT.jar /opt/connectors/jdbc
gwen$ cp ../kafka-connect-elasticsearch/target/kafka-connect-elasticsearch-11.1.0-SNAPSHOT.jar /opt/connectors/elastic
gwen$ cp ../kafka-connect-elasticsearch/target/kafka-connect-elasticsearch-11.1.0-SNAPSHOT-package/share/java/kafka-connect-elasticsearch/* /opt/connectors/elastic
此外,由于我们不仅需要连接到任何数据库,而是特别需要连接到 MySQL,因此您需要下载并安装 MySQL JDBC 驱动程序。出于许可证原因,驱动程序不随连接器一起提供。您可以从MySQL 网站下载驱动程序,然后将 jar 文件放在/opt/connectors/jdbc中。
重新启动 Kafka Connect 工作程序,并检查新的连接器插件是否已列出:
gwen$ bin/connect-distributed.sh config/connect-distributed.properties &
gwen$ curl http://localhost:8083/connector-plugins
[
{
"class": "io.confluent.connect.elasticsearch.ElasticsearchSinkConnector",
"type": "sink",
"version": "11.1.0-SNAPSHOT"
},
{
"class": "io.confluent.connect.jdbc.JdbcSinkConnector",
"type": "sink",
"version": "10.3.x-SNAPSHOT"
},
{
"class": "io.confluent.connect.jdbc.JdbcSourceConnector",
"type": "source",
"version": "10.3.x-SNAPSHOT"
}
我们可以看到我们现在在我们的“Connect”集群中有更多的连接器插件可用。
下一步是在 MySQL 中创建一个表,我们可以使用我们的 JDBC 连接器将其流式传输到 Kafka 中:
gwen$ mysql.server restart
gwen$ mysql --user=root
mysql> create database test;
Query OK, 1 row affected (0.00 sec)
mysql> use test;
Database changed
mysql> create table login (username varchar(30), login_time datetime);
Query OK, 0 rows affected (0.02 sec)
mysql> insert into login values ('gwenshap', now());
Query OK, 1 row affected (0.01 sec)
mysql> insert into login values ('tpalino', now());
Query OK, 1 row affected (0.00 sec)
正如您所看到的,我们创建了一个数据库和一个表,并插入了一些行作为示例。
下一步是配置我们的 JDBC 源连接器。我们可以通过查看文档找出可用的配置选项,但我们也可以使用 REST API 来查找可用的配置选项:
gwen$ curl -X PUT -d '{"connector.class":"JdbcSource"}' localhost:8083/connector-plugins/JdbcSourceConnector/config/validate/ --header "content-Type:application/json"
{
"configs": [
{
"definition": {
"default_value": "",
"dependents": [],
"display_name": "Timestamp Column Name",
"documentation": "The name of the timestamp column to use
to detect new or modified rows. This column may not be
nullable.",
"group": "Mode",
"importance": "MEDIUM",
"name": "timestamp.column.name",
"order": 3,
"required": false,
"type": "STRING",
"width": "MEDIUM"
},
<more stuff>
我们要求 REST API 验证连接器的配置,并向其发送了一个仅包含类名的配置(这是必需的最低配置)。作为响应,我们得到了所有可用配置的 JSON 定义。
有了这些信息,现在是时候创建和配置我们的 JDBC 连接器了:
echo '{"name":"mysql-login-connector", "config":{"connector.class":"JdbcSourceConnector","connection.url":"jdbc:mysql://127.0.0.1:3306/test?user=root","mode":"timestamp","table.whitelist":"login","validate.non.null":false,"timestamp.column.name":"login_time","topic.prefix":"mysql."}}' | curl -X POST -d @- http://localhost:8083/connectors --header "content-Type:application/json"
{
"name": "mysql-login-connector",
"config": {
"connector.class": "JdbcSourceConnector",
"connection.url": "jdbc:mysql://127.0.0.1:3306/test?user=root",
"mode": "timestamp",
"table.whitelist": "login",
"validate.non.null": "false",
"timestamp.column.name": "login_time",
"topic.prefix": "mysql.",
"name": "mysql-login-connector"
},
"tasks": []
}
让我们通过从“mysql.login”主题中读取数据来确保它起作用:
gwen$ bin/kafka-console-consumer.sh --bootstrap-server=localhost:9092 --topic mysql.login --from-beginning
如果您收到错误消息说主题不存在或看不到数据,请检查 Connect worker 日志以查找错误,例如:
[2016-10-16 19:39:40,482] ERROR Error while starting connector mysql-login-connector (org.apache.kafka.connect.runtime.WorkerConnector:108)
org.apache.kafka.connect.errors.ConnectException: java.sql.SQLException: Access denied for user 'root;'@'localhost' (using password: NO)
at io.confluent.connect.jdbc.JdbcSourceConnector.start(JdbcSourceConnector.java:78)
其他问题可能涉及类路径中驱动程序的存在或读取表的权限。
一旦连接器运行,如果您在“login”表中插入了额外的行,您应该立即在“mysql.login”主题中看到它们的反映。
变更数据捕获和 Debezium 项目
我们正在使用的 JDBC 连接器使用 JDBC 和 SQL 来扫描数据库表中的新记录。它通过使用时间戳字段或递增的主键来检测新记录。这是一个相对低效且有时不准确的过程。所有关系型数据库都有一个事务日志(也称为重做日志、binlog 或预写日志)作为其实现的一部分,并且许多允许外部系统直接从其事务日志中读取数据——这是一个更准确和高效的过程,称为“变更数据捕获”。大多数现代 ETL 系统依赖于变更数据捕获作为数据源。Debezium 项目提供了一系列高质量的开源变更捕获连接器,适用于各种数据库。如果您计划将数据从关系型数据库流式传输到 Kafka,我们强烈建议如果您的数据库存在的话使用 Debezium 变更捕获连接器。此外,Debezium 文档是我们见过的最好的文档之一——除了记录连接器本身外,它还涵盖了与变更数据捕获相关的有用设计模式和用例,特别是在微服务的背景下。
将 MySQL 数据传输到 Kafka 本身就很有用,但让我们通过将数据写入 Elasticsearch 来增加乐趣。
首先,我们启动 Elasticsearch 并通过访问其本地端口来验证它是否正常运行:
gwen$ elasticsearch &
gwen$ curl http://localhost:9200/
{
"name" : "Chens-MBP",
"cluster_name" : "elasticsearch_gwenshap",
"cluster_uuid" : "X69zu3_sQNGb7zbMh7NDVw",
"version" : {
"number" : "7.5.2",
"build_flavor" : "default",
"build_type" : "tar",
"build_hash" : "8bec50e1e0ad29dad5653712cf3bb580cd1afcdf",
"build_date" : "2020-01-15T12:11:52.313576Z",
"build_snapshot" : false,
"lucene_version" : "8.3.0",
"minimum_wire_compatibility_version" : "6.8.0",
"minimum_index_compatibility_version" : "6.0.0-beta1"
},
"tagline" : "You Know, for Search"
}
现在创建并启动连接器:
echo '{"name":"elastic-login-connector", "config":{"connector.class":"ElasticsearchSinkConnector","connection.url":"http://localhost:9200","type.name":"mysql-data","topics":"mysql.login","key.ignore":true}}' | curl -X POST -d @- http://localhost:8083/connectors --header "content-Type:application/json"
{
"name": "elastic-login-connector",
"config": {
"connector.class": "ElasticsearchSinkConnector",
"connection.url": "http://localhost:9200",
"topics": "mysql.login",
"key.ignore": "true",
"name": "elastic-login-connector"
},
"tasks": [
{
"connector": "elastic-login-connector",
"task": 0
}
]
}
这里有一些我们需要解释的配置。connection.url只是我们之前配置的本地 Elasticsearch 服务器的 URL。Kafka 中的每个主题默认情况下将成为一个单独的 Elasticsearch 索引,与主题名称相同。我们写入 Elasticsearch 的唯一主题是mysql.login。JDBC 连接器不会填充消息键。因此,Kafka 中的事件具有空键。因为 Kafka 中的事件缺少键,我们需要告诉 Elasticsearch 连接器使用主题名称、分区 ID 和偏移量作为每个事件的键。这是通过将key.ignore配置设置为true来完成的。
让我们检查一下是否已创建了具有mysql.login数据的索引:
gwen$ curl 'localhost:9200/_cat/indices?v'
health status index uuid pri rep docs.count docs.deleted store.size pri.store.size
yellow open mysql.login wkeyk9-bQea6NJmAFjv4hw 1 1 2 0 3.9kb 3.9kb
如果索引不存在,请查看 Connect worker 日志中的错误。缺少配置或库是错误的常见原因。如果一切正常,我们可以搜索索引以查找我们的记录:
gwen$ curl -s -X "GET" "http://localhost:9200/mysql.login/_search?pretty=true"
{
"took" : 40,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 2,
"relation" : "eq"
},
"max_score" : 1.0,
"hits" : [
{
"_index" : "mysql.login",
"_type" : "_doc",
"_id" : "mysql.login+0+0",
"_score" : 1.0,
"_source" : {
"username" : "gwenshap",
"login_time" : 1621699811000
}
},
{
"_index" : "mysql.login",
"_type" : "_doc",
"_id" : "mysql.login+0+1",
"_score" : 1.0,
"_source" : {
"username" : "tpalino",
"login_time" : 1621699816000
}
}
]
}
}
如果您在 MySQL 表中添加新记录,它们将自动出现在 Kafka 的mysql.login主题中,并出现在相应的 Elasticsearch 索引中。
现在我们已经看到如何构建和安装 JDBC 源和 Elasticsearch 接收器,我们可以构建和使用任何适合我们用例的连接器对。Confluent 维护了一组自己预构建的连接器,以及来自社区和其他供应商的一些连接器,位于Confluent Hub。您可以选择列表中的任何连接器进行尝试,下载它,配置它 - 要么基于文档,要么通过从 REST API 中提取配置 - 并在您的 Connect worker 集群上运行它。
构建您自己的连接器
连接器 API 是公开的,任何人都可以创建新的连接器。因此,如果您希望集成的数据存储没有现有的连接器,我们鼓励您编写自己的连接器。然后,您可以将其贡献给 Confluent Hub,以便其他人可以发现并使用它。本章的范围超出了讨论构建连接器所涉及的所有细节,但有多篇博客文章解释了如何做到这一点,以及来自Kafka Summit NY 2019、Kafka Summit London 2018和ApacheCon的精彩演讲。我们还建议查看现有的连接器作为起点,并可能使用Apache Maven archtype来加速。我们始终鼓励您在 Apache Kafka 社区邮件列表(users@kafka.apache.org)上寻求帮助或展示您最新的连接器,或者将其提交到 Confluent Hub,以便它们可以轻松被发现。
单消息转换
将记录从 MySQL 复制到 Kafka,然后再复制到 Elastic 本身就很有用,但 ETL 管道通常涉及转换步骤。在 Kafka 生态系统中,我们将转换分为单消息转换(SMT),它们是无状态的,以及流处理,它可以是有状态的。SMT 可以在 Kafka Connect 中完成,转换消息而无需编写任何代码。更复杂的转换通常涉及连接或聚合,将需要有状态的 Kafka Streams 框架。我们将在后面的章节中讨论 Kafka Streams。
Apache Kafka 包括以下 SMT:
转换
更改字段的数据类型。
MaskField
用 null 替换字段的内容。这对于删除敏感或个人识别数据非常有用。
过滤器
删除或包含符合特定条件的所有消息。内置条件包括匹配主题名称、特定标头,或消息是否为墓碑(即具有空值)。
扁平化
将嵌套数据结构转换为扁平结构。这是通过将路径中所有字段的名称连接到特定值来完成的。
HeaderFrom
将消息中的字段移动或复制到标头中。
插入标头
向每条消息的标头添加静态字符串。
插入字段
向消息添加一个新字段,可以使用其偏移等元数据的值,也可以使用静态值。
正则路由器
使用正则表达式和替换字符串更改目标主题。
替换字段
删除或重命名消息中的字段。
时间戳转换器
修改字段的时间格式 - 例如,从 Unix Epoch 到字符串。
时间戳路由器
根据消息时间戳修改主题。这在接收连接器中非常有用,当我们想要根据时间戳将消息复制到特定的表分区时,主题字段用于在目标系统中找到等效的数据集。
此外,转换是从主要 Apache Kafka 代码库之外的贡献者那里获得的。这些可以在 GitHub(Lenses.io、Aiven和Jeremy Custenborder有有用的集合)或Confluent Hub上找到。
要了解更多关于 Kafka Connect SMT 的信息,您可以阅读“SMT 十二天”博客系列中的详细示例。此外,您还可以通过教程和深入探讨来学习如何编写自己的转换。
例如,假设我们想要为之前创建的 MySQL 连接器生成的每条记录添加记录标头。标头将指示该记录是由此 MySQL 连接器创建的,这在审计人员想要检查这些记录的渊源时非常有用。
为此,我们将用以下内容替换以前的 MySQL 连接器配置:
echo '{
"name": "mysql-login-connector",
"config": {
"connector.class": "JdbcSourceConnector",
"connection.url": "jdbc:mysql://127.0.0.1:3306/test?user=root",
"mode": "timestamp",
"table.whitelist": "login",
"validate.non.null": "false",
"timestamp.column.name": "login_time",
"topic.prefix": "mysql.",
"name": "mysql-login-connector",
"transforms": "InsertHeader",
"transforms.InsertHeader.type":
"org.apache.kafka.connect.transforms.InsertHeader",
"transforms.InsertHeader.header": "MessageSource",
"transforms.InsertHeader.value.literal": "mysql-login-connector"
}}' | curl -X POST -d @- http://localhost:8083/connectors --header "content-Type:application/json"
现在,如果您向我们在上一个示例中创建的 MySQL 表中插入几条记录,您将能够看到mysql.login主题中的新消息具有标头(请注意,您需要 Apache Kafka 2.7 或更高版本才能在控制台消费者中打印标头):
bin/kafka-console-consumer.sh --bootstrap-server=localhost:9092 --topic mysql.login --from-beginning --property print.headers=true
NO_HEADERS {"schema":{"type":"struct","fields":[{"type":"string","optional":true,"field":"username"},{"type":"int64","optional":true,"name":"org.apache.kafka.connect.data.Timestamp","version":1,"field":"login_time"}],"optional":false,"name":"login"},"payload":{"username":"tpalino","login_time":1621699816000}}
MessageSource:mysql-login-connector {"schema":{"type":"struct","fields":
[{"type":"string","optional":true,"field":"username"},{"type":"int64","optional":true,"name":"org.apache.kafka.connect.data.Timestamp","version":1,"field":"login_time"}],"optional":false,"name":"login"},"payload":{"username":"rajini","login_time":1621803287000}}
如您所见,旧记录显示“NO_HEADERS”,而新记录显示“MessageSource:mysql-login-connector”。
错误处理和死信队列
转换是一个连接器配置的示例,它不特定于一个连接器,但可以在任何连接器的配置中使用。另一个非常有用的连接器配置是error.tolerance - 您可以配置任何连接器静默丢弃损坏的消息,或将它们路由到一个名为“死信队列”的特殊主题。您可以在“Kafka Connect 深入探讨-错误处理和死信队列”博客文章中找到更多详细信息。
深入了解 Kafka Connect
要理解 Kafka Connect 的工作原理,您需要了解三个基本概念以及它们之间的相互作用。正如我们之前解释过并通过示例演示的那样,要使用 Kafka Connect,您需要运行一个工作节点集群并创建/删除连接器。我们之前没有深入探讨的一个额外细节是转换器处理数据的方式 - 这些是将 MySQL 行转换为 JSON 记录的组件,连接器将其写入 Kafka。
让我们更深入地了解每个系统以及它们之间的相互作用。
连接器和任务
连接器插件实现了连接器 API,其中包括两个部分:
连接器
连接器负责三件重要的事情:
-
确定连接器将运行多少任务
-
决定如何在任务之间分配数据复制工作
-
从工作节点获取任务的配置并将其传递
例如,JDBC 源连接器将连接到数据库,发现现有表以进行复制,并根据此决定需要多少任务——选择“tasks.max”配置和表的数量中的较小值。一旦决定了将运行多少任务,它将为每个任务生成一个配置——使用连接器配置(例如,“connection.url”)和为每个任务分配的表列表。 “taskConfigs()”方法返回映射列表(即,我们要运行的每个任务的配置)。然后工作人员负责启动任务,并为每个任务提供其自己独特的配置,以便它将从数据库复制一组唯一的表。请注意,当您通过 REST API 启动连接器时,它可能在任何节点上启动,并且随后启动的任务也可能在任何节点上执行。
任务
任务实际上负责将数据进出 Kafka。所有任务都是通过从工作人员接收上下文来初始化的。源上下文包括一个允许源任务存储源记录的偏移量的对象(例如,在文件连接器中,偏移量是文件中的位置;在 JDBC 源连接器中,偏移量可以是表中的时间戳列)。汇接器的上下文包括允许连接器控制从 Kafka 接收的记录的方法,用于诸如施加反压、重试和外部存储偏移以实现精确一次交付等操作。任务初始化后,它们将使用包含任务的连接器为其创建的配置的“属性”对象启动。任务启动后,源任务轮询外部系统并返回记录列表,工作人员将这些记录发送到 Kafka 代理。汇任务通过工作人员从 Kafka 接收记录,并负责将记录写入外部系统。
工人
Kafka Connect 的工作进程是执行连接器和任务的“容器”进程。它们负责处理定义连接器及其配置的 HTTP 请求,以及将连接器配置存储在内部 Kafka 主题中,启动连接器及其任务,并传递适当的配置。如果工作进程停止或崩溃,Connect 集群中的其他工作人员将通过 Kafka 的消费者协议中的心跳检测到这一点,并将在该工作人员上运行的连接器和任务重新分配给剩余的工作人员。如果新的工作人员加入 Connect 集群,其他工作人员将注意到这一点,并分配连接器或任务给它,以确保所有工作人员之间的负载均衡。工作人员还负责自动将源和汇连接器的偏移量提交到内部 Kafka 主题,并在任务抛出错误时处理重试。
了解工作人员的最佳方法是意识到连接器和任务负责数据集成的“移动数据”部分,而工作人员负责 REST API、配置管理、可靠性、高可用性、扩展和负载平衡。
这种关注点的分离是使用 Connect API 与经典的消费者/生产者 API 的主要优势。有经验的开发人员知道,编写从 Kafka 读取数据并将其插入数据库的代码可能需要一两天,但如果需要处理配置、错误、REST API、监控、部署、扩展和处理故障,可能需要几个月才能做到一切正确。大多数数据集成管道不仅涉及一个源或目标。因此,考虑一下为数据库集成而花费的工作量,为其他技术重复多次。如果您使用连接器实现数据复制,您的连接器将插入处理一堆复杂操作问题的工作人员,这些问题您无需担心。
转换器和 Connect 的数据模型
Connect API 谜题的最后一部分是连接器数据模型和转换器。Kafka 的 Connect API 包括一个数据 API,其中包括数据对象和描述该数据的模式。例如,JDBC 源从数据库中读取列,并基于数据库返回的列的数据类型构造一个Connect Schema对象。然后使用模式构造包含数据库记录中所有字段的Struct。对于每一列,我们存储列名和该列中的值。每个源连接器都会做类似的事情——从源系统中读取事件并生成Schema和Value对。汇连接器则相反——获取Schema和Value对,并使用Schema解析值并将其插入目标系统。
尽管源连接器知道如何基于数据 API 生成对象,但仍然存在一个问题,即Connect工作程序如何将这些对象存储在 Kafka 中。这就是转换器发挥作用的地方。当用户配置工作程序(或连接器)时,他们选择要使用哪种转换器将数据存储在 Kafka 中。目前,可用的选择包括原始类型、字节数组、字符串、Avro、JSON、JSON 模式或 Protobufs。JSON 转换器可以配置为在结果记录中包含模式或不包含模式,因此我们可以支持结构化和半结构化数据。当连接器将 Data API 记录返回给工作程序时,工作程序然后使用配置的转换器将记录转换为 Avro 对象、JSON 对象或字符串,然后将结果存储到 Kafka 中。
对于汇连接器,相反的过程发生。当 Connect 工作程序从 Kafka 中读取记录时,它使用配置的转换器将记录从 Kafka 中的格式(即原始类型、字节数组、字符串、Avro、JSON、JSON 模式或 Protobufs)转换为 Connect 数据 API 记录,然后将其传递给汇连接器,汇连接器将其插入目标系统中。
这使得 Connect API 能够支持存储在 Kafka 中的不同类型的数据,而与连接器实现无关(即,只要有可用的转换器,任何连接器都可以与任何记录类型一起使用)。
偏移量管理
偏移量管理是工作程序为连接器执行的方便服务之一(除了通过 REST API 进行部署和配置管理)。其想法是,连接器需要知道它们已经处理了哪些数据,并且它们可以使用 Kafka 提供的 API 来维护已经处理的事件的信息。
对于源连接器来说,这意味着连接器返回给 Connect 工作程序的记录包括逻辑分区和逻辑偏移量。这些不是 Kafka 分区和 Kafka 偏移量,而是源系统中需要的分区和偏移量。例如,在文件源中,分区可以是一个文件,偏移量可以是文件中的行号或字符号。在 JDBC 源中,分区可以是数据库表,偏移量可以是表中记录的 ID 或时间戳。编写源连接器时涉及的最重要的设计决策之一是决定在源系统中对数据进行分区和跟踪偏移量的良好方式,这将影响连接器可以实现的并行级别以及是否可以提供至少一次或精确一次语义。
当源连接器返回包含每条记录的源分区和偏移量的记录列表时,工作程序将记录发送到 Kafka 代理。如果代理成功确认记录,工作程序将存储发送到 Kafka 的记录的偏移量。这允许连接器在重新启动或崩溃后从最近存储的偏移量开始处理事件。存储机制是可插拔的,通常是一个 Kafka 主题;您可以使用offset.storage.topic配置控制主题名称。此外,Connect 使用 Kafka 主题存储我们创建的所有连接器的配置和每个连接器的状态,这些主题的名称分别由config.storage.topic和status.storage.topic配置。
接收连接器具有相反但类似的工作流程:它们读取 Kafka 记录,这些记录已经有了主题、分区和偏移标识符。然后它们调用connector的put()方法,应该将这些记录存储在目标系统中。如果连接器报告成功,它们将提交它们给连接器的偏移量回到 Kafka,使用通常的消费者提交方法。
框架本身提供的偏移跟踪应该使开发人员更容易编写连接器,并在使用不同的连接器时保证一定程度的一致行为。
Kafka Connect 的替代方案
到目前为止,我们已经非常详细地查看了 Kafka 的 Connect API。虽然我们喜欢 Connect API 提供的便利和可靠性,但这并不是将数据输入和输出 Kafka 的唯一方法。让我们看看其他替代方案以及它们通常在何时使用。
其他数据存储的摄入框架
虽然我们喜欢认为 Kafka 是宇宙的中心,但有些人不同意。有些人大部分的数据架构都是围绕 Hadoop 或 Elasticsearch 等系统构建的。这些系统有它们自己的数据摄入工具——Hadoop 的 Flume,Elasticsearch 的 Logstash 或 Fluentd。如果您实际上正在构建一个以 Hadoop 为中心或以 Elastic 为中心的系统,而 Kafka 只是该系统的许多输入之一,那么使用 Flume 或 Logstash 是有意义的。
基于 GUI 的 ETL 工具
像 Informatica 这样的老式系统,像 Talend 和 Pentaho 这样的开源替代方案,甚至像 Apache NiFi 和 StreamSets 这样的较新替代方案,都支持 Apache Kafka 作为数据源和目的地。如果您已经在使用这些系统,比如您已经使用 Pentaho 做了所有事情,您可能不会对为了 Kafka 而添加另一个数据集成系统感兴趣。如果您正在使用基于 GUI 的方法构建 ETL 管道,这也是有道理的。这些系统的主要缺点是它们通常是为复杂的工作流程而构建的,如果您只是想将数据输入和输出 Kafka,它们将是一个相当沉重和复杂的解决方案。我们认为数据集成应该专注于在所有条件下忠实地传递消息,而大多数 ETL 工具增加了不必要的复杂性。
我们鼓励您将 Kafka 视为一个可以处理数据集成(使用 Connect)、应用程序集成(使用生产者和消费者)和流处理的平台。Kafka 可能是 ETL 工具的可行替代,该工具仅集成数据存储。
流处理框架
几乎所有的流处理框架都包括从 Kafka 读取事件并将其写入其他几个系统的能力。如果您的目标系统得到支持,并且您已经打算使用该流处理框架来处理来自 Kafka 的事件,那么使用相同的框架进行数据集成似乎是合理的。这通常可以节省流处理工作流程中的一步(无需将处理过的事件存储在 Kafka 中,只需读取它们并将其写入另一个系统),但缺点是可能更难排除诸如丢失和损坏的消息等问题。
摘要
在本章中,我们讨论了使用 Kafka 进行数据集成。从使用 Kafka 进行数据集成的原因开始,我们涵盖了数据集成解决方案的一般考虑因素。我们展示了为什么我们认为 Kafka 及其 Connect API 是一个很好的选择。然后,我们给出了几个不同场景下如何使用 Kafka Connect 的示例,花了一些时间看 Connect 是如何工作的,然后讨论了一些 Kafka Connect 的替代方案。
无论您最终选择哪种数据集成解决方案,最重要的特性始终是其在所有故障条件下传递所有消息的能力。我们相信 Kafka Connect 非常可靠——基于其与 Kafka 经过验证的可靠性特性的集成——但重要的是您测试您选择的系统,就像我们一样。确保您选择的数据集成系统可以在停止进程、崩溃的机器、网络延迟和高负载的情况下生存而不会丢失消息。毕竟,在本质上,数据集成系统只有一个任务——传递这些消息。
当然,可靠性通常是集成数据系统时最重要的要求,但这只是一个要求。在选择数据系统时,首先审查您的要求是很重要的(参考“构建数据管道时的考虑因素”以获取示例),然后确保您选择的系统满足这些要求。但这还不够——您还必须充分了解您的数据集成解决方案,以确保您使用的方式支持您的要求。Kafka 支持至少一次语义是不够的;您必须确保您没有意外地配置它以确保完全的可靠性。
第十章 跨集群数据镜像
在大部分的书中,我们讨论了单个 Kafka 集群的设置、维护和使用。然而,有一些情况下,架构可能需要不止一个集群。
在某些情况下,集群是完全分离的。它们属于不同的部门或不同的用例,没有理由将数据从一个集群复制到另一个集群。有时,不同的 SLA 或工作负载使得很难调整单个集群以服务多个用例。其他时候,存在不同的安全要求。这些用例相当容易——管理多个不同的集群与多次运行单个集群是一样的。
在其他用例中,不同的集群是相互依存的,管理员需要在集群之间持续复制数据。在大多数数据库中,不断地在数据库服务器之间复制数据被称为复制。由于我们已经使用复制来描述 Kafka 节点之间的数据移动,我们将称在 Kafka 集群之间复制数据为镜像。Apache Kafka 内置的跨集群复制器称为MirrorMaker。
在本章中,我们将讨论所有或部分数据的跨集群镜像。我们将首先讨论一些跨集群镜像的常见用例。然后,我们将展示用于实现这些用例的一些架构,并讨论每种架构模式的优缺点。然后我们将讨论 MirrorMaker 本身以及如何使用它。我们将分享操作提示,包括部署和性能调优。最后,我们将讨论一些 MirrorMaker 的替代方案。
跨集群镜像的用例
以下是跨集群镜像将被使用的一些示例列表:
区域和中央集群
在某些情况下,公司在不同的地理区域、城市或大陆拥有一个或多个数据中心。每个数据中心都有自己的 Kafka 集群。一些应用程序可以通过与本地集群通信来工作,但有些应用程序需要来自多个数据中心的数据(否则,您不会寻找跨数据中心复制解决方案)。有许多情况下这是一个要求,但经典的例子是一家根据供需情况调整价格的公司。这家公司可以在其存在的每个城市都有一个数据中心,收集有关当地供需情况的信息,并相应地调整价格。然后所有这些信息将被镜像到一个中央集群,业务分析师可以在其收入上运行公司范围的报告。
高可用性(HA)和灾难恢复(DR)
应用程序仅在一个 Kafka 集群上运行,不需要来自其他位置的数据,但您担心由于某种原因整个集群可能变得不可用。为了冗余,您希望有第二个 Kafka 集群,其中包含第一个集群中存在的所有数据,以便在紧急情况下,您可以将应用程序指向第二个集群并继续正常运行。
监管合规性
在不同国家运营的公司可能需要使用不同的配置和政策,以符合每个国家的法律和监管要求。例如,一些数据集可能存储在具有严格访问控制的单独集群中,其中数据子集被复制到其他具有更广泛访问权限的集群。为了遵守各个地区规定的保留期限的监管政策,数据集可能存储在具有不同配置的不同地区的集群中。
云迁移
如今,许多公司在自己的数据中心和云服务提供商中运行业务。通常,应用程序在云服务提供商的多个区域运行,以实现冗余,有时会使用多个云服务提供商。在这些情况下,通常每个自有数据中心和每个云区域至少有一个 Kafka 集群。这些 Kafka 集群被每个数据中心和区域的应用程序用来在数据中心之间高效地传输数据。例如,如果在云中部署了一个新应用程序,但需要一些由在自有数据中心运行并存储在自有数据库中的应用程序更新的数据,您可以使用 Kafka Connect 捕获数据库更改并将这些更改镜像到云 Kafka 集群,新应用程序可以使用这些更改。这有助于控制跨数据中心流量的成本,以及改善流量的治理和安全性。
边缘集群数据聚合
包括零售、电信、交通运输和医疗保健在内的几个行业从具有有限连接性的小型设备生成数据。可以使用具有高可用性的聚合集群来支持来自大量边缘集群的数据的分析和其他用例。这减少了对低占地面积的边缘集群的连接性、可用性和耐久性要求,例如在物联网用例中。高可用的聚合集群即使在边缘集群离线时也提供业务连续性,并简化了不必直接处理大量具有不稳定网络的边缘集群的应用程序的开发。
多集群架构
既然我们已经看到了一些需要多个 Kafka 集群的用例,让我们来看一些我们在实现这些用例时成功使用的常见架构模式。在我们讨论架构之前,我们将简要概述跨数据中心通信的现实情况。我们将讨论的解决方案可能在特定网络条件下代表了权衡,因此可能看起来过于复杂。
跨数据中心通信的一些现实情况
以下是在跨数据中心通信时需要考虑的一些事项:
高延迟
两个 Kafka 集群之间的通信延迟随着两个集群之间的距离和网络跳数的增加而增加。
有限的带宽
广域网(WAN)通常比单个数据中心内的可用带宽要低得多,可用带宽可能会每分钟都有所变化。此外,更高的延迟使得更具挑战性地利用所有可用带宽。
更高的成本
无论您是在本地运行 Kafka 还是在云中运行 Kafka,跨集群通信的成本都更高。这部分是因为带宽有限,增加带宽可能成本过高,另一部分是因为供应商对数据中心、区域和云之间的数据传输收费。
Apache Kafka 的代理和客户端是在单个数据中心内设计、开发、测试和调整的。我们假设代理和客户端之间的延迟低,带宽高。这在默认超时和各种缓冲区的大小中是显而易见的。因此,不建议(除非在特定情况下,我们稍后会讨论)在一个数据中心安装一些 Kafka 代理,而在另一个数据中心安装其他 Kafka 代理。
在大多数情况下,最好避免向远程数据中心生成数据,如果必须这样做,需要考虑更高的延迟和更多网络错误的可能性。您可以通过增加生产者重试的次数来处理错误,并通过增加保存记录的缓冲区的大小来处理更高的延迟,以便在尝试发送记录时进行处理。
如果我们需要在集群之间进行任何形式的复制,并且排除了经纪人之间的通信和生产者-经纪人之间的通信,那么我们必须允许经纪人-消费者之间的通信。事实上,这是跨集群通信中最安全的形式,因为在阻止消费者读取数据的网络分区事件发生时,记录仍然安全地保存在 Kafka 经纪人中,直到通信恢复并且消费者可以读取它们。由于网络带宽有限,如果一个数据中心中有多个应用程序需要从另一个数据中心的 Kafka 经纪人读取数据,我们更倾向于在每个数据中心安装一个 Kafka 集群,并在它们之间镜像必要的数据,而不是让多个应用程序通过广域网消费相同的数据。
我们将更多地讨论调整 Kafka 以进行跨数据中心通信,但以下原则将指导我们将讨论的大多数架构:
-
每个数据中心至少有一个集群。
-
在每对数据中心之间精确复制每个事件一次(除了由于错误而重试)。
-
在可能的情况下,从远程数据中心消费,而不是向远程数据中心生产。
集线器和辐射架构
这种架构适用于存在多个本地 Kafka 集群和一个中央 Kafka 集群的情况。参见图 10-1。

图 10-1:集线器和辐射架构
还有一种更简单的变体,只有两个集群:一个领导者和一个追随者。参见图 10-2。

图 10-2:集线器和辐射架构的简化版本
当数据在多个数据中心产生并且一些消费者需要访问整个数据集时,使用这种架构。该架构还允许每个数据中心的应用程序仅处理特定数据中心的本地数据。但它不允许每个数据中心从整个数据集中获取数据。
这种架构的主要好处是数据始终产生在本地数据中心,并且每个数据中心的事件只被镜像一次 - 到中央数据中心。从单个数据中心处理数据的应用程序可以位于该数据中心。需要从多个数据中心处理数据的应用程序将位于所有事件都被镜像的中央数据中心。由于复制始终是单向的,并且每个消费者始终从同一集群中读取,因此这种架构易于部署、配置和监视。
这种架构的主要缺点直接源于其好处和简单性。一个区域数据中心的处理器无法访问另一个数据中心的数据。为了更好地理解这是一个限制,让我们看一个这种架构的例子。
假设我们是一家大型银行,在多个城市设有分支机构。假设我们决定将用户配置文件和其账户历史存储在每个城市的 Kafka 集群中。我们将所有这些信息复制到一个用于运行银行业务分析的中央集群。当用户连接到银行网站或访问他们的本地分支机构时,他们被路由到发送事件到他们的本地集群并从同一本地集群读取事件。然而,假设用户访问另一个城市的分支机构。因为用户信息不存在于他们正在访问的城市,分支机构将被迫与远程集群交互(不建议)或无法访问用户的信息(非常尴尬)。因此,通常仅将此模式用于可以在区域数据中心之间完全分离的数据集的部分。
在实施这种架构时,对于每个区域数据中心,您至少需要一个在中央数据中心的镜像过程。此过程将从每个远程区域集群中获取数据并将其生成到中央集群。如果在多个数据中心中存在相同的主题,您可以将此主题的所有事件写入到中央集群中具有相同名称的一个主题中,或者将每个数据中心的事件写入到一个单独的主题中。
主动-主动架构
当两个或更多个数据中心共享部分或全部数据,并且每个数据中心都能够生成和消费事件时,将使用此架构。参见图 10-3。

图 10-3:主动-主动架构模型
这种架构的主要优点是能够从附近的数据中心为用户提供服务,这通常具有性能优势,而不会因数据的有限可用性(正如我们在集线器和辐射架构中看到的那样)而牺牲功能。第二个好处是冗余和弹性。由于每个数据中心都具有所有功能,因此如果一个数据中心不可用,您可以将用户引导到剩余的数据中心。这种故障转移只需要对用户进行网络重定向,通常是最简单和最透明的故障转移类型。
这种架构的主要缺点是在数据在多个位置异步读取和更新时避免冲突的挑战。这包括在镜像事件方面的技术挑战,例如,我们如何确保相同的事件不会无休止地来回镜像?但更重要的是,在两个数据中心之间保持数据一致性将是困难的。以下是您将遇到的一些困难的几个例子:
-
如果用户将事件发送到一个数据中心,并从另一个数据中心读取事件,那么他们写入的事件可能尚未到达第二个数据中心。对用户来说,看起来就像他们刚刚将一本书添加到愿望清单并单击了愿望清单,但书并不在那里。因此,当使用这种架构时,开发人员通常会找到一种方法来将每个用户“粘附”到特定的数据中心,并确保他们大部分时间使用相同的集群(除非他们从远程位置连接或数据中心不可用)。
-
一个数据中心的事件表示用户订购了书 A,第二个数据中心在更多或更少相同的时间内的事件表示同一用户订购了书 B。在镜像之后,两个数据中心都有了这两个事件,因此我们可以说每个数据中心都有两个冲突的事件。两个数据中心上的应用程序需要知道如何处理这种情况。我们选择一个事件作为“正确”的事件吗?如果是这样,我们需要一致的规则来选择一个事件,这样两个数据中心上的应用程序将得出相同的结论。我们决定两者都是真实的,只是给用户发送两本书,并让另一个部门处理退货?亚马逊过去是以这种方式解决冲突的,但是处理股票交易等问题的组织不能这样做。最小化冲突和处理冲突发生时的具体方法是特定于每个用例的。重要的是要记住,如果您使用这种架构,您将会有冲突,并且需要处理它们。
如果您找到了处理从多个位置异步读取和写入相同数据集的挑战的方法,那么强烈推荐使用这种架构。这是我们所知道的最具可扩展性、有弹性、灵活和经济有效的选择。因此,值得努力找出避免复制周期、使用户大部分时间保持在同一个数据中心,并在发生冲突时处理冲突的解决方案。
主动-主备镜像的挑战之一,特别是在有两个以上的数据中心时,是您将需要为每对数据中心和每个方向设置镜像任务。如今许多镜像工具可以共享进程,例如使用相同的进程进行所有镜像到目标集群。
此外,您将希望避免同一事件在不同数据中心之间无休止地来回镜像。您可以通过为每个“逻辑主题”在每个数据中心分配一个单独的主题,并确保避免复制源自远程数据中心的主题来实现这一点。例如,逻辑主题用户在一个数据中心将是SF.users,在另一个数据中心将是NYC.users。镜像过程将从 SF 到 NYC 镜像主题SF.users,从 NYC 到 SF 镜像主题NYC.users。因此,每个事件只会被镜像一次,但每个数据中心将包含SF.users和NYC.users,这意味着每个数据中心都将包含所有用户的信息。消费者需要从**.users*中消费事件,如果他们希望消费所有用户事件。另一种思考这种设置的方式是将其视为每个数据中心的单独命名空间,其中包含特定数据中心的所有主题。在我们的例子中,我们将有 NYC 和 SF 命名空间。一些镜像工具如 MirrorMaker 使用类似的命名约定来防止复制循环。
Apache Kafka 0.11.0 版本引入的记录头使事件可以被标记其来源数据中心。头信息也可以用于避免无休止的镜像循环,并允许分别处理来自不同数据中心的事件。您还可以通过使用结构化数据格式(Avro 是我们最喜欢的例子)来实现此功能,并将标签和头信息包含在事件本身中。然而,这需要在镜像时额外的工作,因为没有现有的镜像工具会支持您的特定头格式。
主备架构
在某些情况下,多个集群的唯一要求是支持某种灾难情景。也许您在同一个数据中心中有两个集群。您使用一个集群来运行所有应用程序,但您希望有第二个集群包含(几乎)原始集群中的所有事件,以便在原始集群完全不可用时使用。或者您可能需要地理弹性。您的整个业务都是从加利福尼亚州的一个数据中心运行的,但您需要德克萨斯州的第二个数据中心,通常不做太多事情,以防发生地震时使用。德克萨斯数据中心可能会有所有应用程序的非活动(“冷”)副本,管理员可以在紧急情况下启动,并且将使用第二个集群(图 10-4)。这通常是法律要求,而不是业务实际计划要做的事情,但您仍然需要做好准备。

图 10-4. 主备架构
这种设置的好处是设置简单,并且几乎可以用于任何用例。您只需安装第二个集群,并设置一个镜像过程,将所有事件从一个集群流式传输到另一个集群。无需担心数据访问、处理冲突和其他架构复杂性。
缺点是浪费一个好的集群,并且 Kafka 集群之间的故障转移实际上比看起来要困难得多。最重要的是,目前在 Kafka 中执行集群故障转移时要么会丢失数据,要么会有重复事件。通常两者都有。您可以将它们最小化,但永远无法完全消除。
显而易见的是,一个除了等待灾难之外什么也不做的集群是资源的浪费。由于灾难是(或应该是)罕见的,大部分时间我们看到的是一组机器的集群根本什么也不做。一些组织试图通过拥有比生产集群小得多的 DR(灾难恢复)集群来解决这个问题。但这是一个冒险的决定,因为您无法确定这个尺寸最小的集群在紧急情况下是否能够支撑住。其他组织更喜欢在非灾难期间使集群有用,通过将一些只读工作负载转移到 DR 集群上运行,这意味着他们实际上正在运行一个带有单个辐条的小型版本的集线器和辐条架构。
更严重的问题是,如何在 Apache Kafka 中切换到 DR 集群?
首先,毋庸置疑,无论选择哪种故障切换方法,您的 SRE 团队都必须定期进行实践。今天有效的计划可能在升级后停止工作,或者新的用例使现有的工具过时。每季度通常是故障切换实践的最低要求。强大的 SRE 团队经常进行实践。Netflix 著名的混沌猴(Chaos Monkey)是极端的例子 - 任何一天都可能成为故障切换实践的日子。
现在,让我们来看看故障切换涉及哪些内容。
灾难恢复规划
在灾难恢复规划时,重要的是考虑两个关键指标。恢复时间目标(RTO)定义了灾难后所有服务必须恢复的最长时间。恢复点目标(RPO)定义了可能丢失数据的最长时间。RTO 越低,避免手动流程和应用程序重新启动就越重要,因为只有通过自动故障切换才能实现非常低的 RTO。低 RPO 需要低延迟的实时镜像,RPO=0需要同步复制。
未经计划的故障切换中的数据丢失和不一致性
因为 Kafka 的各种镜像解决方案都是异步的(我们将在下一节讨论同步解决方案),DR 集群将不会拥有来自主要集群的最新消息。您应始终监视 DR 集群的落后程度,并且永远不要让它落后太远。但在繁忙的系统中,您应该预期 DR 集群落后主要集群几百甚至几千条消息。如果您的 Kafka 集群每秒处理 100 万条消息,并且主要集群和 DR 集群之间的滞后时间为 5 毫秒,那么在最佳情况下,您的 DR 集群将落后于主要集群 5,000 条消息。因此,准备好未经计划的故障切换包括一些数据丢失。在计划的故障切换中,您可以停止主要集群,并等待镜像过程在故障切换应用程序到 DR 集群之前镜像剩余的消息,从而避免这种数据丢失。当发生未经计划的故障切换并丢失了几千条消息时,请注意,镜像解决方案目前不支持事务,这意味着如果多个主题中的某些事件相关(例如销售和行项目),您可以在故障切换时有一些事件及时到达 DR 站点,而其他事件则没有。您的应用程序需要能够处理故障切换到 DR 集群后没有相应销售的行项目。
故障切换后应用程序的起始偏移
在切换到另一个集群时,一个具有挑战性的任务是确保应用程序知道从何处开始消费数据。有几种常见的方法。有些方法简单,但可能会导致额外的数据丢失或重复处理;其他方法更复杂,但可以最小化额外的数据丢失和重新处理。让我们来看看其中的一些:
自动偏移重置
Apache Kafka 消费者有一个配置,用于在它们没有先前提交的偏移量时如何行为——它们要么从分区的开头开始读取,要么从分区的末尾开始读取。如果您没有将这些偏移量作为 DR 计划的一部分进行镜像,您需要选择其中一种选项。要么从可用数据的开头开始读取并处理大量重复,要么跳到结尾并错过未知(希望是少量)事件。如果您的应用程序可以处理重复而没有问题,或者丢失一些数据不是什么大问题,那么这个选项是最简单的。在故障转移时简单地跳到主题的末尾是一种受欢迎的故障转移方法,因为它简单易行。
复制偏移量主题
如果您使用的是 0.9.0 版本及更高版本的 Kafka 消费者,消费者将会将它们的偏移量提交到一个特殊的主题:“__consumer_offsets”。如果您将这个主题镜像到 DR 集群,当消费者开始从 DR 集群消费时,它们将能够恢复其旧的偏移量,并从上次离开的地方继续。这很简单,但涉及到一长串注意事项。
首先,不能保证主集群中的偏移量与辅助集群中的偏移量匹配。假设您只在主集群中存储数据三天,而且在创建主题一周后开始镜像。在这种情况下,主集群中可用的第一个偏移量可能是偏移量 57,000,000(较早的事件来自前 4 天,已经被删除),但 DR 集群中的第一个偏移量将为 0。因此,试图从 DR 集群读取偏移量 57,000,003(因为这是它要读取的下一个事件)的消费者将无法做到这一点。
其次,即使在主题创建时立即开始镜像,并且主题和 DR 主题都从 0 开始,生产者重试可能导致偏移量发散。我们将在本章末讨论一种替代的镜像解决方案,该解决方案在主 DR 集群之间保留偏移量。
第三,即使偏移量完全保留,由于主 DR 集群之间的滞后以及镜像解决方案目前不支持事务,Kafka 消费者提交的偏移量可能会比具有此偏移量的记录提前或滞后到达。进行故障转移的消费者可能会发现已提交的偏移量没有匹配的记录。或者可能发现 DR 站点上的最新提交的偏移量比主站点上的最新提交的偏移量旧。参见图 10-5。

图 10-5:故障转移导致已提交的偏移量没有匹配的记录
在这些情况下,如果 DR 站点上的最新提交的偏移量比主站点上的偏移量旧,或者由于重试而导致 DR 站点中的记录偏移量超过主站点,您需要接受一些重复。您还需要想办法处理 DR 站点上最新提交的偏移量没有匹配记录的情况——您是从主题的开头开始处理,还是跳到结尾?
正如您所看到的,这种方法有其局限性。不过,与其他方法相比,这个选项让您能够在故障转移到另一个 DR 时,出现的重复或丢失事件数量减少,同时实现起来也很简单。
基于时间的故障转移
从 0.10.0 版本开始,每条消息都包括一个时间戳,指示消息发送到 Kafka 的时间。从 0.10.1.0 版本开始,代理包括一个索引和一个用于按时间戳查找偏移的 API。因此,如果您切换到 DR 集群,并且知道您的问题是在凌晨 4:05 开始的,您可以告诉消费者从凌晨 4:03 开始处理数据。这两分钟内会有一些重复,但这可能比其他选择更好,并且行为更容易向公司中的每个人解释——“我们回滚到了凌晨 4:03”听起来比“我们回滚到可能是最新提交的偏移”好。因此,这通常是一个很好的折衷方案。唯一的问题是:我们如何告诉消费者从凌晨 4:03 开始处理数据?
一种选择是将其直接嵌入到您的应用程序中。具有用户可配置选项来指定应用程序的启动时间。如果配置了这个选项,应用程序可以使用新的 API 来按时间获取偏移,寻找到那个时间,并从正确的位置开始消费,像往常一样提交偏移。
如果您事先没有编写所有应用程序,这个选项就很好。Apache Kafka 提供了 kafka-consumer-groups 工具,根据一系列选项重置偏移,包括在 0.11.0 版本中添加的基于时间戳的重置。在运行此类型的工具时,应该停止消费者组,并在之后立即启动。例如,以下命令将为属于特定组的所有主题重置消费者偏移到特定时间:
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --reset-offsets --all-topics --group my-group --to-datetime 2021-03-31T04:03:00.000 --execute
这个选项建议在需要保证故障切换的确定性的部署中使用。
偏移翻译
在讨论镜像偏移主题时,最大的挑战之一是主要和 DR 集群中的偏移可能会发散。过去,一些组织选择使用外部数据存储,如 Apache Cassandra,来存储从一个集群到另一个集群的偏移映射。每当事件被生产到 DR 集群时,当偏移发散时,镜像工具会将两个偏移发送到外部数据存储。如今,包括 MirrorMaker 在内的镜像解决方案使用 Kafka 主题来存储偏移翻译元数据。偏移存储在两个偏移之间的差异发生变化时。例如,如果主要偏移 495 映射到 DR 集群的偏移 500,我们将在外部存储或偏移翻译主题中记录(495,500)。如果由于重复而导致差异发生变化,偏移 596 映射到 600,那么我们将记录新的映射(596,600)。我们不需要存储 495 到 596 之间的所有偏移映射;我们只是假设差异保持不变,因此主要集群中的偏移 550 将映射到 DR 中的偏移 555。然后当发生故障切换时,我们不是将时间戳(始终有点不准确)映射到偏移,而是将主要偏移映射到 DR 偏移并使用它们。之前列出的两种技术之一可以用于强制消费者开始使用映射的新偏移。这仍然存在偏移提交的问题,这些提交在记录本身之前到达,并且偏移提交未及时镜像到 DR,但它涵盖了一些情况。
故障切换后
假设故障切换成功了。 DR 集群上的一切都运行正常。现在我们需要对主要集群做些什么。也许把它变成一个 DR。
简单地修改镜像进程以颠倒其方向并从新的主要集群开始镜像到旧的主要集群是很诱人的。然而,这引发了两个重要问题:
-
我们如何知道从哪里开始镜像?我们需要解决与镜像应用程序本身的所有消费者相同的问题。请记住,我们所有的解决方案都有可能导致重复或丢失数据的情况——有时两者都有。
-
此外,出于我们之前讨论的原因,你原始的主集群可能会有 DR 集群没有的事件。如果你只是开始镜像新数据回来,额外的历史将保留下来,两个集群将不一致。
因此,对于一些关键的一致性和顺序保证的场景,最简单的解决方案是首先清除原始集群-删除所有数据和已提交的偏移量-然后从新的主集群开始镜像到现在的新 DR 集群。这给了你一个与新主集群相同的干净的基础。
关于集群发现的几句话
在规划备用集群时需要考虑的一个重要点是,如果发生故障转移,你的应用程序将需要知道如何开始与故障转移集群通信。如果你在生产者和消费者属性中硬编码了主集群经纪人的主机名,这将是具有挑战性的。大多数组织都会简化并创建一个通常指向主经纪人的 DNS 名称。在紧急情况下,DNS 名称可以指向备用集群。发现服务(DNS 或其他)不需要包括所有经纪人-Kafka 客户端只需要成功访问一个经纪人,以获取有关集群的元数据并发现其他经纪人。因此,通常只包括三个经纪人就可以了。无论发现方法如何,大多数故障转移场景都需要在故障转移后重新启动消费者应用程序,以便它们可以找到需要开始消费的新偏移量。为了实现非常低的 RTO 而进行自动故障转移而无需应用程序重新启动,故障转移逻辑应该内置到客户端应用程序中。
拉伸集群
主备架构用于通过将应用程序移动到另一个集群来保护业务免受 Kafka 集群故障的影响。拉伸集群旨在在数据中心宕机期间保护 Kafka 集群免受故障的影响。这是通过在多个数据中心安装单个 Kafka 集群来实现的。
拉伸集群与其他多数据中心场景有根本的不同。首先,它们不是多集群-它只是一个集群。因此,我们不需要镜像过程来保持两个集群同步。正常情况下,Kafka 的复制机制被使用来保持集群中所有经纪人的同步。这种设置可以包括同步复制。生产者通常在成功写入 Kafka 后从 Kafka 经纪人那里收到确认。在拉伸集群的情况下,我们可以配置确认在消息成功写入两个数据中心的 Kafka 经纪人后发送。这涉及使用机架定义来确保每个分区在多个数据中心中都有副本,并使用min.insync.replicas和acks=all来确保每次写入都从至少两个数据中心得到确认。从 2.4.0 版本开始,经纪人还可以配置为启用消费者使用机架定义从最近的副本获取数据。经纪人将其机架与消费者的机架匹配,以找到最新的本地副本,如果适当的本地副本不可用,则返回到领导者。从本地数据中心获取数据的消费者通过减少跨数据中心的流量实现更高的吞吐量,更低的延迟和更低的成本。
这种架构的优势在于同步复制-一些类型的业务简单地要求他们的 DR 站点始终与主站点 100%同步。这通常是法律要求,并适用于公司内的任何数据存储-Kafka 也包括在内。另一个优势是使用了两个数据中心和集群中的所有经纪人。没有像我们在主备架构中看到的浪费。
这种架构在其保护的灾难类型方面存在局限性。它只能保护数据中心的故障,而不能保护任何类型的应用程序或 Kafka 故障。操作复杂性也受到限制。这种架构需要物理基础设施,而并非所有公司都能提供。
如果您的公司拥有三栋建筑位于同一条街上,或者更常见的是,在云服务提供商的一个区域内使用三个可用区,那么在至少三个数据中心中安装 Kafka(和 ZooKeeper)是可行的,且它们之间具有高带宽和低延迟。
三个数据中心之所以重要,是因为 ZooKeeper 在集群中需要一个不均匀数量的节点,并且如果大多数节点可用,它将保持可用。有了两个数据中心和不均匀数量的节点,一个数据中心将始终包含大多数节点,这意味着如果这个数据中心不可用,ZooKeeper 就不可用,Kafka 也不可用。有了三个数据中心,您可以轻松地分配节点,以便没有一个单一的数据中心拥有大多数节点。因此,如果一个数据中心不可用,大多数节点存在于其他两个数据中心中,ZooKeeper 集群将保持可用。因此,Kafka 集群也将保持可用。
2.5 DC 架构
一个流行的拉伸集群模型是 2.5 DC(数据中心)架构,其中 Kafka 和 ZooKeeper 都在两个数据中心运行,并且第三个“0.5”数据中心有一个 ZooKeeper 节点,以提供如果一个数据中心失败的话,提供法定人数。
可以在两个数据中心中运行 ZooKeeper 和 Kafka,使用 ZooKeeper 组配置允许在两个数据中心之间进行手动故障转移。然而,这种设置并不常见。
Apache Kafka 的 MirrorMaker
Apache Kafka 包含一个名为 MirrorMaker 的工具,用于在两个数据中心之间镜像数据。早期版本的 MirrorMaker 使用了一个消费者组的集合,这些消费者是消费者组的成员,用于从一组源主题中读取数据,并且每个 MirrorMaker 进程中都有一个共享的 Kafka 生产者,用于将这些事件发送到目标集群。虽然这足以在某些情况下在集群之间镜像数据,但它存在一些问题,特别是随着配置更改和新主题的添加导致的延迟峰值,会导致停止世界的重新平衡。MirrorMaker 2.0 是基于 Kafka Connect 框架的下一代多集群镜像解决方案,克服了其前身的许多缺点。可以轻松配置复杂的拓扑结构,以支持灾难恢复、备份、迁移和数据聚合等各种用例。
关于 MirrorMaker 的更多信息
MirrorMaker 听起来很简单,但因为我们试图非常高效并且非常接近精确一次交付,所以正确实现它变得棘手。MirrorMaker 已经被多次重写。这里的描述和以下部分的细节适用于 MirrorMaker 2.0,该版本在 2.4.0 中引入。
MirrorMaker 使用源连接器从另一个 Kafka 集群中消费数据,而不是从数据库中。使用 Kafka Connect 框架可以最大程度地减少繁忙企业 IT 部门的管理开销。如果您还记得第九章中的 Kafka Connect 架构,您会记得每个连接器将工作分配给可配置数量的任务。在 MirrorMaker 中,每个任务都是一个消费者和一个生产者对。Connect 框架根据需要将这些任务分配给不同的 Connect 工作节点——因此您可以在一个服务器上拥有多个任务,或者将任务分散到多个服务器上。这取代了手动计算每个实例应运行多少 MirrorMaker 流以及每台机器上应运行多少实例的工作。Connect 还具有 REST API 来集中管理连接器和任务的配置。如果我们假设大多数 Kafka 部署都包括 Kafka Connect 出于其他原因(将数据库更改事件发送到 Kafka 是一个非常受欢迎的用例),那么通过在 Connect 中运行 MirrorMaker,我们可以减少需要管理的集群数量。
MirrorMaker 均匀分配分区给任务,而不使用 Kafka 的消费者组管理协议,以避免由于添加新主题或分区而导致的重新平衡而产生的延迟峰值。源集群中每个分区的事件都被镜像到目标集群中的相同分区,保留语义分区和维护每个分区的事件顺序。如果在源主题中添加了新分区,它们将自动创建在目标主题中。除了数据复制,MirrorMaker 还支持消费者偏移、主题配置和主题 ACL 的迁移,使其成为多集群部署的完整镜像解决方案。复制流定义了从源集群到目标集群的方向流的配置。可以为 MirrorMaker 定义多个复制流,以定义复杂的拓扑,包括我们之前讨论的架构模式,如集线器-辐射、主备和主-主架构。图 10-6 显示了在主备架构中使用 MirrorMaker 的情况。

图 10-6:Kafka 中的 MirrorMaker 进程
配置 MirrorMaker
MirrorMaker 是高度可配置的。除了用于定义拓扑、Kafka Connect 和连接器设置的集群设置外,MirrorMaker 使用的底层生产者、消费者和管理客户端的每个配置属性都可以自定义。我们将在这里展示一些示例,并突出一些重要的配置选项,但是 MirrorMaker 的详尽文档不在我们的范围之内。
有了这个想法,让我们来看一个 MirrorMaker 的例子。以下命令使用属性文件中指定的配置选项启动 MirrorMaker:
bin/connect-mirror-maker.sh etc/kafka/connect-mirror-maker.properties
让我们来看一些 MirrorMaker 的配置选项:
复制流
以下示例显示了在纽约和伦敦两个数据中心之间设置主备复制流的配置选项:
clusters = NYC, LON // ①
NYC.bootstrap.servers = kafka.nyc.example.com:9092 // ②
LON.bootstrap.servers = kafka.lon.example.com:9092
NYC->LON.enabled = true // ③
NYC->LON.topics = .* // ④
①
为复制流中使用的集群定义别名。
②
为每个集群配置引导程序,使用集群别名作为前缀。
③
使用前缀source->target在一对集群之间启用复制流。此流的所有配置选项都使用相同的前缀。
④
为此复制流配置要镜像的主题。
镜像主题
如示例所示,对于每个复制流,可以为将被镜像的主题名称指定一个正则表达式。在这个例子中,我们选择复制每个主题,但通常最好使用类似prod.**的内容,并避免复制测试主题。还可以指定一个单独的主题排除列表,其中包含不需要镜像的主题名称或类似test.**的模式。目标主题名称默认会自动添加源集群别名的前缀。例如,在主动-主动架构中,MirrorMaker 从 NYC 数据中心复制主题到 LON 数据中心,将会把 NYC 的orders主题镜像到 LON 的NYC.orders主题。这种默认命名策略可以防止复制循环,在主动-主动模式下,如果主题从 NYC 到 LON 以及从 LON 到 NYC 都进行了镜像,事件将在两个集群之间无休止地进行镜像。本地和远程主题之间的区别也支持聚合用例,因为消费者可以选择订阅模式,从而只消费来自本地区域的数据,或者订阅来自所有区域的主题,以获取完整的数据集。
MirrorMaker 定期检查源集群中的新主题,并在它们匹配配置的模式时自动开始镜像这些主题。如果源主题添加了更多的分区,相同数量的分区将自动添加到目标主题,确保源主题中的事件以相同的顺序出现在目标主题的相同分区中。
消费者偏移迁移
MirrorMaker 包含一个名为RemoteClusterUtils的实用类,以便消费者在从主集群故障转移时进行偏移转换,以便定位到 DR 集群中最后一次检查的偏移。在 2.7.0 中添加了对消费者偏移的定期迁移支持,以自动提交转换后的偏移到目标__consumer_offsets主题,以便切换到 DR 集群的消费者可以从主集群中离开的地方重新开始,而不会丢失数据并且最小化重复处理。可以自定义迁移偏移的消费者组,并且为了增加保护,MirrorMaker 不会覆盖偏移,如果目标集群上的消费者正在积极使用目标消费者组,从而避免任何意外冲突。
主题配置和 ACL 迁移
除了镜像数据记录,MirrorMaker 还可以配置为镜像主题配置和主题的访问控制列表(ACL),以保留镜像主题的相同行为。默认配置启用了合理的周期性刷新间隔,这在大多数情况下可能已经足够了。源主题的大多数主题配置设置都会应用到目标主题上,但像min.insync.replicas这样的一些设置默认情况下不会应用。排除配置的列表可以进行自定义。
只有与被镜像的主题匹配的文字主题 ACL 才会被迁移,因此如果您使用了前缀或通配符 ACL 或替代授权机制,您需要在目标集群上明确配置这些内容。Topic:Write的 ACL 不会被迁移,以确保只有 MirrorMaker 被允许写入目标主题。在故障转移时必须明确授予适当的访问权限,以确保应用程序能够与辅助集群一起工作。
连接器任务
配置选项tasks.max限制了与 MirrorMaker 关联的连接器可能使用的最大任务数。默认值为 1,但建议至少为 2。当复制大量主题分区时,如果可能的话应该使用更高的值以增加并行性。
配置前缀
MirrorMaker 支持对其所有组件(包括连接器、生产者、消费者和管理客户端)的配置选项进行自定义。Kafka Connect 和连接器配置可以在没有任何前缀的情况下指定。但由于 MirrorMaker 配置可以包括多个集群的配置,因此可以使用前缀来指定特定于集群的配置或特定复制流的配置。正如我们之前在示例中看到的那样,集群使用别名来标识,这些别名用作与该集群相关的选项的配置前缀。前缀可用于构建分层配置,具有更具体前缀的配置优先级高于不太具体或无前缀的配置。MirrorMaker 使用以下前缀:
-
{cluster}.
-
{cluster}.admin.
-
{source_cluster}.consumer.
-
{target_cluster}.producer.
-
{source_cluster}->{target_cluster}.
多集群复制拓扑
我们已经看到了一个简单的主动-备用复制流的 MirrorMaker 示例配置。现在让我们看一下如何扩展配置以支持其他常见的架构模式。
通过在两个方向上启用复制流,可以配置纽约和伦敦之间的主动-主动拓扑。在这种情况下,即使从 NYC 的所有主题都被镜像到 LON,反之亦然,MirrorMaker 确保相同的事件不会在一对集群之间不断地来回镜像,因为远程主题使用集群别名作为前缀。最佳做法是使用包含完整复制拓扑的相同配置文件来为不同的 MirrorMaker 进程进行配置,因为这样可以避免在目标数据中心使用内部配置主题共享配置时出现冲突。可以通过使用--clusters选项在目标数据中心启动 MirrorMaker 进程时使用共享配置文件来指定目标集群:
clusters = NYC, LON
NYC.bootstrap.servers = kafka.nyc.example.com:9092
LON.bootstrap.servers = kafka.lon.example.com:9092
NYC->LON.enabled = true // ①
NYC->LON.topics = .* // ②
LON->NYC.enabled = true // ③
LON->NYC.topics = .* // ④
①
从纽约到伦敦启用复制。
②
指定从纽约到伦敦复制的主题。
③
从伦敦到纽约启用复制。
④
指定从伦敦到纽约复制的主题。
还可以向拓扑中添加具有额外源或目标集群的更多复制流。例如,可以通过为 SF 添加新的复制流来扩展配置,以支持从 NYC 到 SF 和 LON 的扇出:
clusters = NYC, LON, SF
SF.bootstrap.servers = kafka.sf.example.com:9092
NYC->SF.enabled = true
NYC->SF.topics = .*
保护 MirrorMaker
对于生产集群,确保所有跨数据中心流量都是安全的非常重要。用于保护 Kafka 集群的选项在第十一章中有描述。MirrorMaker 必须配置为在源和目标集群中使用安全代理侦听器,并且必须为 MirrorMaker 配置每个集群的客户端端安全选项,以使其能够建立经过身份验证的连接。应使用 SSL 加密所有跨数据中心流量。例如,可以使用以下配置来配置 MirrorMaker 的凭据:
NYC.security.protocol=SASL_SSL // ①
NYC.sasl.mechanism=PLAIN
NYC.sasl.jaas.config=org.apache.kafka.common.security.plain.PlainLoginModule \
required username="MirrorMaker" password="MirrorMaker-password"; // ②
①
安全协议应与为集群指定的引导服务器对应的代理侦听器的安全协议相匹配。建议使用SSL或SASL_SSL。
②
在这里使用 JAAS 配置指定了 MirrorMaker 的凭据,因为使用了 SASL。对于 SSL,如果启用了相互客户端身份验证,则应指定密钥库。
如果集群上启用了授权,则还必须为 MirrorMaker 关联的主体在源和目标集群上授予适当的权限。必须为 MirrorMaker 进程授予 ACL,以用于:
-
在源集群上使用
Topic:Read从源主题消费;在目标集群上使用Topic:Create和Topic:Write创建和生产到目标主题。 -
在源集群上使用
Topic:DescribeConfigs获取源主题配置;在目标集群上使用Topic:AlterConfigs更新目标主题配置。 -
在目标集群上使用
Topic:Alter添加分区,如果检测到新的源分区。 -
在源集群上使用
Group:Describe获取源消费者组元数据,包括偏移量;在目标集群上使用Group:Read提交这些消费者组在目标集群中的偏移量。 -
在源集群上使用
Cluster:Describe获取源主题 ACL;在目标集群上使用Cluster:Alter更新目标主题 ACL。 -
在源和目标集群中为内部 MirrorMaker 主题授予
Topic:Create和Topic:Write权限。
在生产环境中部署 MirrorMaker
在前面的示例中,我们在命令行上以专用模式启动了 MirrorMaker。您可以启动任意数量的这些进程,以形成一个可扩展和容错的专用 MirrorMaker 集群。向同一集群镜像的进程将自动找到彼此并在它们之间自动平衡负载。通常在生产环境中运行 MirrorMaker 时,您将希望将 MirrorMaker 作为服务运行,后台运行,并将其控制台输出重定向到日志文件。该工具还具有-daemon作为命令行选项,应该为您执行这些操作。大多数使用 MirrorMaker 的公司都有自己的启动脚本,其中还包括他们使用的配置参数。生产部署系统如 Ansible、Puppet、Chef 和 Salt 通常用于自动化部署和管理许多配置选项。MirrorMaker 也可以在 Docker 容器中运行。MirrorMaker 是完全无状态的,不需要任何磁盘存储(所有数据和状态都存储在 Kafka 本身)。
由于 MirrorMaker 基于 Kafka Connect,所有 Connect 的部署模式都可以与 MirrorMaker 一起使用。独立模式可用于 MirrorMaker 在单台机器上作为独立的 Connect 工作进程运行的开发和测试。MirrorMaker 也可以作为现有分布式 Connect 集群中的连接器运行,通过显式配置连接器。对于生产使用,我们建议以分布式模式运行 MirrorMaker,可以作为专用 MirrorMaker 集群或共享的分布式 Connect 集群。
如果可能的话,在目标数据中心运行 MirrorMaker。因此,如果你从纽约发送数据到旧金山,MirrorMaker 应该在旧金山运行,并从纽约跨越美国消费数据。这样做的原因是远程网络可能比数据中心内部的网络不太可靠。如果发生网络分区并且在数据中心之间失去连接,有一个无法连接到集群的消费者要比无法连接的生产者更安全。如果消费者无法连接,它就无法读取事件,但事件仍将存储在源 Kafka 集群中,并且可以在那里保留很长时间。不会有丢失事件的风险。另一方面,如果事件已经被消费并且 MirrorMaker 由于网络分区而无法生产它们,那么这些事件有可能会被 MirrorMaker 意外丢失。因此,远程消费比远程生产更安全。
何时必须在本地消费并远程生产?答案是当您需要在数据中心之间传输数据时加密数据,但您不需要在数据中心内加密数据时。消费者在使用 SSL 加密连接到 Kafka 时会受到显着的性能影响——比生产者更多。这是因为使用 SSL 需要复制数据进行加密,这意味着消费者不再享受通常的零拷贝优化的性能优势。这种性能影响也会影响 Kafka 代理本身。如果您的跨数据中心流量需要加密,但本地流量不需要,那么您可能最好将 MirrorMaker 放置在源数据中心,让它在本地消费未加密的数据,然后通过 SSL 加密连接将其生产到远程数据中心。这样,生产者使用 SSL 连接到 Kafka,但消费者不会受到太大的性能影响。如果您使用这种本地消费和远程生产的方法,请确保 MirrorMaker 的 Connect 生产者配置为通过配置acks=all和足够数量的重试来永远不会丢失事件。此外,当无法发送事件时,配置 MirrorMaker 使用errors.tolerance=none来快速失败,这通常比继续并冒着数据丢失的风险更安全。请注意,较新版本的 Java 具有显着提高的 SSL 性能,因此即使使用加密,本地生产和远程消费也可能是一个可行的选项。
另一个可能需要远程生产和本地消费的情况是混合场景,当从本地集群到云集群进行镜像时。安全的本地集群可能落后于不允许来自云端的传入连接的防火墙。在本地运行 MirrorMaker 允许所有连接都是从本地到云端。
在生产环境中部署 MirrorMaker 时,重要的是要记住要监视它如下:
Kafka Connect 监控
Kafka Connect 提供了广泛的指标来监控不同方面,如连接器指标用于监控连接器状态,源连接器指标用于监控吞吐量,工作器指标用于监控重新平衡延迟。Connect 还提供了一个 REST API 来查看和管理连接器。
MirrorMaker 指标监控
除了来自 Connect 的指标外,MirrorMaker 还添加了用于监控镜像吞吐量和复制延迟的指标。复制延迟指标replication-latency-ms显示记录时间戳和成功生产到目标集群的时间之间的时间间隔。这对于检测目标是否及时跟上源是很有用的。在高峰时段增加的延迟可能是可以接受的,如果有足够的容量来稍后赶上,但持续增加的延迟可能表明容量不足。其他指标,如record-age-ms(显示复制时记录的年龄)、byte-rate(显示复制吞吐量)和checkpoint-latency-ms(显示偏移迁移延迟)也可能非常有用。MirrorMaker 还默认定期发出心跳,可用于监视其健康状况。
滞后监控
您肯定会想知道目标集群是否落后于源集群。滞后是源 Kafka 集群中最新消息与目标集群中最新消息之间偏移量的差异。请参阅图 10-7。

图 10-7:监控偏移量差异的滞后
在图 10-7 中,源集群中的最后偏移量为 7,目标集群中的最后偏移量为 5,意味着有 2 条消息的滞后。
有两种方法可以跟踪这种滞后,但都不是完美的:
-
检查 MirrorMaker 提交到源 Kafka 集群的最新偏移量。您可以使用
kafka-consumer-groups工具来检查 MirrorMaker 正在读取的每个分区的偏移量 - 分区中最后一个事件的偏移量,MirrorMaker 提交的最后一个偏移量以及它们之间的滞后。这个指标并不是 100%准确,因为 MirrorMaker 并不总是提交偏移量。它默认每分钟提交一次偏移量,所以你会看到滞后在一分钟内增长,然后突然下降。在图表中,实际滞后是 2,但kafka-consumer-groups工具将报告滞后为 5,因为 MirrorMaker 尚未提交更近期的消息的偏移量。LinkedIn 的 Burrow 监视相同的信息,但有一种更复杂的方法来确定滞后是否代表真正的问题,因此您不会收到错误警报。 -
检查 MirrorMaker 读取的最新偏移量(即使没有提交)。MirrorMaker 嵌入的消费者在 JMX 中发布关键指标。其中之一是消费者的最大滞后(在它正在消费的所有分区上)。这个滞后也不是 100%准确,因为它是基于消费者读取的内容更新的,但并不考虑生产者是否成功将这些消息发送到目标 Kafka 集群以及它们是否被成功确认。在这个例子中,MirrorMaker 消费者将报告 1 条消息的滞后,而不是 2 条,因为它已经读取了消息 6 - 即使消息尚未被生产到目标。
请注意,如果 MirrorMaker 跳过或丢弃消息,这两种方法都无法检测到问题,因为它们只跟踪最新的偏移量。Confluent Control Center是一个商业工具,用于监视消息计数和校验和,并弥补了这一监控差距。
生产者和消费者指标监控
MirrorMaker 使用的 Kafka Connect 框架包含生产者和消费者。两者都有许多可用的指标,我们建议收集和跟踪它们。Kafka 文档列出了所有可用的指标。以下是一些有用于调整 MirrorMaker 性能的指标:
消费者
fetch-size-avg、fetch-size-max、fetch-rate、fetch-throttle-time-avg和fetch-throttle-time-max
生产者
batch-size-avg、batch-size-max、requests-in-flight和record-retry-rate
两者
io-ratio和io-wait-ratio
金丝雀
如果您监控其他所有内容,那么金丝雀并不是绝对必要的,但我们喜欢添加它以进行多层监控。它提供了一个进程,每分钟向源集群的一个特殊主题发送一个事件,并尝试从目标集群读取该事件。如果事件到达所需的时间超过可接受的时间,它还会向您发出警报。这可能意味着 MirrorMaker 滞后,或者根本不可用。
调整 MirrorMaker
MirrorMaker 是横向可扩展的。MirrorMaker 集群的大小取决于您需要的吞吐量和可以容忍的滞后。如果不能容忍任何滞后,您必须为 MirrorMaker 分配足够的容量,以跟上您的最高吞吐量。如果可以容忍一些滞后,您可以将 MirrorMaker 的大小设置为 95-99%的时间内使用 75-80%的利用率。然后,在达到最大吞吐量时,预计会出现一些滞后。因为 MirrorMaker 大部分时间都有多余的容量,一旦高峰过去,它就会赶上来。
然后,您需要测量使用不同数量的连接器任务(使用tasks.max参数配置)从 MirrorMaker 获得的吞吐量。这在很大程度上取决于您的硬件、数据中心或云提供商,因此您需要进行自己的测试。Kafka 附带了kafka-performance-producer工具。使用它在源集群上生成负载,然后连接 MirrorMaker 并开始镜像此负载。使用 1、2、4、8、16、24 和 32 个任务测试 MirrorMaker。观察性能何时下降,并将tasks.max设置为略低于此点。如果您正在消费或生产压缩事件(建议这样做,因为带宽是跨数据中心镜像的主要瓶颈),MirrorMaker 将不得不解压缩和重新压缩事件。这会消耗大量 CPU,因此随着任务数量的增加,要密切关注 CPU 利用率。使用此过程,您将找到单个 MirrorMaker 工作程序可以获得的最大吞吐量。如果不够,您需要尝试使用额外的工作程序。如果您在现有的 Connect 集群上运行 MirrorMaker,并且有其他连接器,请确保在调整集群大小时也考虑这些连接器的负载。
此外,您可能希望将敏感主题(绝对需要低延迟并且镜像必须尽可能接近源的主题)分开到一个单独的 MirrorMaker 集群。这将防止膨胀的主题或失控的生产者拖慢最敏感的数据管道。
这基本上是您可以对 MirrorMaker 本身进行的所有调整。但是,您仍然可以增加每个任务和每个 MirrorMaker 工作程序的吞吐量。
如果您在数据中心之间运行 MirrorMaker,则调整 TCP 堆栈可以帮助增加有效带宽。在第三章和第四章中,我们看到 TCP 缓冲区大小可以使用send.buffer.bytes和receive.buffer.bytes为生产者和消费者进行配置。同样,经纪人端的缓冲区大小可以使用经纪人上的socket.send.buffer.bytes和socket.receive.buffer.bytes进行配置。这些配置选项应与 Linux 中的网络配置优化相结合,如下所示:
-
增加 TCP 缓冲区大小(
net.core.rmem_default,net.core.rmem_max,net.core.wmem_default,net.core.wmem_max和net.core.optmem_max) -
启用自动窗口缩放(
sysctl –w net.ipv4.tcp_window_scaling=1或将net.ipv4.tcp_window_scaling=1添加到/etc/sysctl.conf) -
减少 TCP 慢启动时间(将
/proc/sys/net/ipv4/tcp_slow_start_after_idle设置为0)
请注意,调整 Linux 网络是一个庞大且复杂的主题。要了解更多关于这些参数和其他参数的信息,我们建议阅读《Linux 服务器性能调优》(Sandra K. Johnson 等著,IBM Press)等网络调优指南。
此外,您可能希望调整 MirrorMaker 的基础生产者和消费者。首先,您需要决定生产者或消费者哪一个是瓶颈 - 生产者是否在等待消费者带来更多数据,还是反之亦然?决定的一种方法是查看您正在监控的生产者和消费者指标。如果一个进程处于空闲状态,而另一个进程被充分利用,您就知道哪个需要调整。另一种方法是进行几次线程转储(使用 jstack),看 MirrorMaker 线程是否大部分时间都在 poll 或 send 中度过 - 大部分时间花在 poll 上通常意味着消费者是瓶颈,而更多时间花在发送上则指向生产者。
如果您需要调整生产者,以下配置设置可能会有用:
linger.ms和batch.size
如果您的监控显示生产者持续发送部分空批次(即batch-size-avg和batch-size-max指标低于配置的batch.size),您可以通过引入一些延迟来增加吞吐量。增加linger.ms,生产者将等待几毫秒,直到批次填满后再发送它们。如果您正在发送完整的批次并且有可用内存,您可以增加batch.size并发送更大的批次。
最大飞行请求数量每个连接
限制每个连接的飞行请求数量为 1 目前是 MirrorMaker 保证消息顺序保持的唯一方法,如果一些消息在成功确认之前需要多次重试。但这意味着生产者发送的每个请求都必须在下一条消息发送之前由目标集群确认。这可能会限制吞吐量,特别是在代理确认消息之前存在显着的延迟的情况下。如果消息顺序对您的用例不重要,那么使用max.in.flight.requests.per.connection的默认值 5 可以显著提高吞吐量。
以下消费者配置可以增加消费者的吞吐量:
fetch.max.bytes
如果您收集的指标显示fetch-size-avg和fetch-size-max接近fetch.max.bytes的配置,那么消费者正在从代理中读取的数据量已经达到了允许的最大值。如果有可用内存,尝试增加fetch.max.bytes以允许消费者在每个请求中读取更多的数据。
fetch.min.bytes和fetch.max.wait.ms
如果您在消费者指标中看到fetch-rate很高,那么消费者向代理发送了太多的请求,而在每个请求中没有收到足够的数据。尝试增加fetch.min.bytes和fetch.max.wait.ms,这样消费者将在每个请求中接收更多的数据,而代理将等待足够的数据可用后再响应消费者的请求。
其他跨集群镜像解决方案
我们深入研究了 MirrorMaker,因为这种镜像软件作为 Apache Kafka 的一部分提供。然而,在实际使用中,MirrorMaker 也存在一些限制。值得看看一些替代 MirrorMaker 以及它们如何解决 MirrorMaker 的限制和复杂性的方法。我们描述了来自 Uber 和 LinkedIn 的一些开源解决方案,以及 Confluent 的商业解决方案。
Uber uReplicator
Uber 在非常大规模上运行传统的 MirrorMaker,随着主题和分区数量的增加以及集群吞吐量的增加,它开始遇到了一些问题。正如我们之前所看到的,传统的 MirrorMaker 使用属于单个消费者组的消费者从源主题中消费。添加 MirrorMaker 线程,添加 MirrorMaker 实例,重启 MirrorMaker 实例,甚至添加与包含过滤器中使用的正则表达式匹配的新主题都会导致消费者重新平衡。正如我们在第四章中看到的那样,重新平衡会使所有消费者停止,直到可以将新分区分配给每个消费者为止。对于非常多的主题和分区,这可能需要一段时间。当像 Uber 一样使用旧的消费者时,情况尤其如此。在某些情况下,这导致 5-10 分钟的不活动时间,导致镜像落后并积累大量待镜像的事件,这需要很长时间才能恢复。这导致了消费者从目标集群中读取事件的非常高的延迟。为了避免在有人添加与主题包含过滤器匹配的主题时重新平衡,Uber 决定维护一个确切的要镜像的主题名称列表,而不是使用正则表达式过滤器。但是这很难维护,因为所有 MirrorMaker 实例都必须重新配置和重启以添加新主题。如果操作不正确,这可能导致无休止的重新平衡,因为消费者无法就他们订阅的主题达成一致。
考虑到这些问题,Uber 决定编写自己的 MirrorMaker 克隆版本,称为uReplicator。 Uber 决定使用 Apache Helix 作为中央(但高可用)控制器,以管理主题列表和分配给每个 uReplicator 实例的分区。管理员使用 REST API 将新主题添加到 Helix 的列表中,uReplicator 负责将分区分配给不同的消费者。为了实现这一点,Uber 用称为 Helix 消费者的 Kafka 消费者替换了 MirrorMaker 中使用的 Kafka 消费者。该消费者从 Apache Helix 控制器获取其分区分配,而不是根据消费者之间的协议达成的结果(有关在 Kafka 中如何完成此操作的详细信息,请参见第四章)。因此,Helix 消费者可以避免重新平衡,而是监听从 Helix 到达的分配分区的更改。
Uber 工程撰写了一篇博客文章,更详细地描述了架构并展示了他们所经历的改进。uReplicator 对 Apache Helix 的依赖引入了一个新的要学习和管理的组件,增加了任何部署的复杂性。正如我们之前所看到的,MirrorMaker 2.0 解决了许多传统 MirrorMaker 的可伸缩性和容错性问题,而没有任何外部依赖。
领英 Brooklin
与 Uber 一样,LinkedIn 也在使用传统的 MirrorMaker 在 Kafka 集群之间传输数据。随着数据规模的增长,它也遇到了类似的可伸缩性问题和操作挑战。因此,LinkedIn 在其数据流系统 Brooklin 之上构建了一个镜像解决方案。Brooklin 是一个分布式服务,可以在不同的异构数据源和目标系统之间流式传输数据,包括 Kafka。作为一个通用的数据摄取框架,可以用来构建数据管道,Brooklin 支持多种用例:
-
数据桥将数据从不同数据源传送到流处理系统
-
从不同数据存储中流式捕获(CDC)事件
-
Kafka 的跨集群镜像解决方案
Brooklin 是一个可伸缩的分布式系统,专为高可靠性而设计,并已经在规模上与 Kafka 进行了测试。它用于每天镜像数万亿条消息,并已经针对稳定性、性能和可操作性进行了优化。Brooklin 配备了用于管理操作的 REST API。它是一个共享服务,可以处理大量的数据管道,使同一服务能够在多个 Kafka 集群之间镜像数据。
Confluent 跨数据中心镜像解决方案
与 Uber 同时开发其 uReplicator,Confluent 独立开发了 Confluent Replicator。尽管名称相似,但这两个项目几乎没有任何共同之处-它们是解决两组不同 MirrorMaker 问题的不同解决方案。与后来推出的 MirrorMaker 2.0 一样,Confluent 的 Replicator 基于 Kafka Connect 框架开发,旨在解决企业客户在使用传统 MirrorMaker 管理其多集群部署时遇到的问题。
对于使用拉伸集群以实现操作简便性和低 RTO 和 RPO 的客户,Confluent 将多区域集群(MRC)作为 Confluent Server 的内置功能添加到了 Confluent 平台的商业组件。 MRC 通过使用异步副本来限制对延迟和吞吐率的影响,扩展了 Kafka 对拉伸集群的支持。与拉伸集群一样,这适用于可用性区域或延迟低于 50 毫秒的区域之间的复制,并受益于透明客户端故障转移。对于网络不太可靠的远程集群,最近在 Confluent Server 中添加了一个名为 Cluster Linking 的新内置功能。Cluster Linking 将 Kafka 的保留偏移量的集群内复制协议扩展到集群之间镜像数据。
让我们看看每个解决方案支持的功能:
Confluent 复制器
Confluent Replicator 是一个类似于 MirrorMaker 的镜像工具,它依赖于 Kafka Connect 框架进行集群管理,并可以在现有的 Connect 集群上运行。两者都支持不同拓扑的数据复制,以及消费者偏移和主题配置的迁移。两者之间有一些功能上的差异。例如,MirrorMaker 支持 ACL 迁移和任何客户端的偏移量转换,但 Replicator 不迁移 ACL 并且仅支持 Java 客户端的偏移量转换(使用时间戳拦截器)。Replicator 没有像 MirrorMaker 那样的本地和远程主题的概念,但它支持聚合主题。与 MirrorMaker 一样,Replicator 也避免了复制循环,但是使用来源头来实现。Replicator 提供了一系列指标,如复制延迟,并可以使用其 REST API 或 Control Center UI 进行监控。它还支持集群之间的模式迁移,并可以执行模式转换。
多区域集群(MRC)
我们之前看到,拉伸集群为客户端提供了简单透明的故障转移和故障恢复,而无需进行偏移量转换或客户端重启。但是拉伸集群要求数据中心彼此靠近,并提供稳定的低延迟网络,以实现数据中心之间的同步复制。MRC 也仅适用于延迟在 50 毫秒内的数据中心,但它使用同步和异步复制的组合来限制对生产者性能的影响,并提供更高的网络容错性。
正如我们之前所看到的,Apache Kafka 支持从跟随者获取以使客户端能够基于机架 ID 从最近的代理获取数据,从而减少跨数据中心的流量。Confluent Server 还引入了观察者的概念,它们是异步副本,不加入 ISR,因此对使用acks=all的生产者没有影响,但能够将记录传递给消费者。运营商可以在区域内配置同步复制和区域间的异步复制,以同时获得低延迟和高耐久性的好处。Confluent Server 中的副本放置约束允许您使用机架 ID 指定每个区域的最小副本数量,以确保副本分布在各个区域,以保证耐久性。Confluent Platform 6.1 还增加了可配置标准的自动观察者晋升,实现快速故障转移而无需数据丢失。当min.insync.replicas低于配置的最小同步副本数量时,已追赶上的观察者将自动晋升,使它们能够加入 ISR,将 ISR 数量恢复到所需的最小值。晋升的观察者使用同步复制,可能会影响吞吐量,但即使一个区域失败,集群仍然可以正常运行而不会丢失数据。当失败的区域恢复时,观察者会自动降级,使集群恢复到正常的性能水平。
集群链接
集群链接是 Confluent Platform 6.0 中作为预览功能引入的,它直接将集群间复制内置到 Confluent Server 中。通过使用与集群内部的 broker 复制相同的协议,集群链接在集群之间执行保留偏移量的复制,实现了客户端的无缝迁移,无需进行偏移量转换。主题配置、分区、消费者偏移量和 ACL 在两个集群之间保持同步,以便在发生灾难时实现低 RTO 的故障切换。集群链接定义了从源集群到目标集群的方向流的配置。目标集群中的镜像分区的领导者代理从相应的源领导者获取分区数据,而目标中的跟随者则使用 Kafka 中的标准复制机制从其本地领导者复制。在目标中,镜像主题被标记为只读,以防止对这些主题进行本地生产,确保镜像主题在逻辑上与其源主题相同。
集群链接提供了操作上的简单性,无需像 Connect 集群那样使用单独的集群,并且比外部工具更高效,因为它在镜像过程中避免了解压缩和重新压缩。与 MRC 不同,没有同步复制的选项,客户端故障转移是一个需要客户端重新启动的手动过程。但是集群链接可以与不稳定的高延迟网络的远程数据中心一起使用,并通过在数据中心之间仅进行一次复制来减少跨数据中心的流量。它适用于集群迁移和主题共享的用例。
总结
我们首先描述了您可能需要管理多个 Kafka 集群的原因,然后描述了几种常见的多集群架构,从简单到非常复杂。我们详细介绍了为 Kafka 实现故障转移架构的细节,并比较了当前可用的不同选项。然后我们讨论了可用的工具。从 Apache Kafka 的 MirrorMaker 开始,我们详细介绍了在生产中使用它的许多细节。最后,我们回顾了解决 MirrorMaker 可能遇到的一些问题的替代选项。
无论您最终使用哪种架构和工具,都要记住,多集群配置和镜像管道应该像您投入生产的其他一切一样进行监控和测试。因为在 Kafka 中管理多集群可能比在关系型数据库中更容易,一些组织将其视为事后思考,并忽视了适当的设计、规划、测试、部署自动化、监控和维护。通过认真对待多集群管理,最好作为整个组织的灾难或地理多样性计划的一部分,涉及多个应用程序和数据存储,您将大大增加成功管理多个 Kafka 集群的机会。
第十一章:保护 Kafka
Kafka 用于各种用例,从网站活动跟踪和指标管道到患者记录管理和在线支付。每种用例在安全性、性能、可靠性和可用性方面都有不同的要求。虽然始终最好使用最强大和最新的安全功能,但通常需要权衡,因为增加的安全性会影响性能、成本和用户体验。Kafka 支持几种标准安全技术,并提供一系列配置选项,以将安全性调整到每种用例。
与性能和可靠性一样,安全是系统的一个方面,必须针对整个系统而不是逐个组件来解决。系统的安全性只有最薄弱的环节一样强大,安全流程和政策必须在整个系统中执行,包括底层平台。Kafka 中可定制的安全功能使其能够与现有安全基础设施集成,构建一个适用于整个系统的一致安全模型。
在本章中,我们将讨论 Kafka 中的安全功能,并了解它们如何解决安全的不同方面,并为 Kafka 安装的整体安全做出贡献。在整个章节中,我们将分享最佳实践、潜在威胁以及减轻这些威胁的技术。我们还将审查可以采用的其他措施,以保护 ZooKeeper 和平台的其余部分。
锁定 Kafka
Kafka 使用一系列安全程序来建立和维护数据的机密性、完整性和可用性:
-
身份验证确定您是谁并确定您的身份。
-
授权确定您被允许做什么。
-
加密保护您的数据免受窃听和篡改。
-
审计跟踪您已经做过或尝试做过的事情。
-
配额控制您可以利用多少资源。
要了解如何锁定 Kafka 部署,让我们首先看一下数据如何在 Kafka 集群中流动。图 11-1 显示了示例数据流中的主要步骤。在本章中,我们将使用这个示例流程来检查 Kafka 可以配置的不同方式,以保护每个步骤的数据,以确保整个部署的安全性。

图 11-1. Kafka 集群中的数据流
-
Alice 向名为
customerOrders的主题的一个分区生成客户订单记录。记录被发送到分区的领导者。 -
领导经纪人将记录写入其本地日志文件。
-
跟随者经纪人从领导者那里获取消息,并将其写入其本地副本日志文件。
-
领导经纪人更新 ZooKeeper 中的分区状态,以更新同步副本(如果需要)。
-
Bob 从主题
customerOrders中消费客户订单记录。Bob 接收到 Alice 生成的记录。 -
一个内部应用程序处理到达
customerOrders的所有消息,以生成热门产品的实时指标。
安全部署必须保证:
客户端真实性
当 Alice 建立与经纪人的客户端连接时,经纪人应对客户进行身份验证,以确保消息确实来自 Alice。
服务器真实性
在向领导经纪人发送消息之前,Alice 的客户端应验证连接是否真实。
数据隐私
消息流动的所有连接以及存储消息的所有磁盘都应该加密或物理上保护,以防窃听者读取数据,并确保数据不会被窃取。
数据完整性
应在通过不安全网络传输的数据中包含消息摘要以检测篡改。
访问控制
在将消息写入日志之前,领导经纪人应验证 Alice 是否有权写入customerOrders。在将消息返回给 Bob 的消费者之前,经纪人应验证 Bob 是否有权从主题中读取消息。如果 Bob 的消费者使用组管理,则经纪人还应验证 Bob 是否有权访问消费者组。
审计性
应记录经纪人、Alice、Bob 和其他客户执行的所有操作的审计跟踪。
可用性
经纪人应该应用配额和限制,以避免一些用户占用所有可用带宽或用拒绝服务攻击压倒经纪人。应该锁定 ZooKeeper 以确保 Kafka 集群的可用性,因为经纪人的可用性取决于 ZooKeeper 的可用性和 ZooKeeper 中存储的元数据的完整性。
在接下来的章节中,我们将探讨 Kafka 安全功能,这些功能可用于提供这些保证。我们首先介绍 Kafka 连接模型以及与客户端到 Kafka 经纪人的连接相关的安全协议。然后,我们详细查看每个安全协议,并检查每个协议的身份验证能力,以确定客户端真实性和服务器真实性。我们审查了不同阶段的加密选项,包括某些安全协议中数据在传输过程中的内置加密,以解决数据隐私和数据完整性问题。然后,我们探讨了 Kafka 中可定制的授权,以管理访问控制和有助于审计的主要日志。最后,我们审查了系统的其他安全性,包括 ZooKeeper 和必须维护可用性的平台。有关配额的详细信息,配额有助于通过在用户之间公平分配资源来提供服务的可用性,请参阅第三章。
安全协议
Kafka 经纪人配置了一个或多个端点的侦听器,并在这些侦听器上接受客户端连接。每个侦听器可以配置自己的安全设置。在物理上受保护并且只对授权人员可访问的私有内部侦听器的安全要求可能与可通过公共互联网访问的外部侦听器的安全要求不同。安全协议的选择确定了数据在传输过程中的身份验证和加密级别。
Kafka 使用两种标准技术支持四种安全协议,即 TLS 和 SASL。传输层安全性(TLS),通常称为其前身安全套接字层(SSL),支持加密以及客户端和服务器身份验证。简单认证和安全层(SASL)是提供使用不同机制进行身份验证的框架,用于连接导向的协议。每个 Kafka 安全协议都将传输层(PLAINTEXT 或 SSL)与可选的身份验证层(SSL 或 SASL)结合在一起:
PLAINTEXT
PLAINTEXT 传输层,无身份验证。仅适用于私有网络中处理非敏感数据,因为没有使用身份验证或加密。
SSL
SSL 传输层,带有可选的 SSL 客户端身份验证。适用于在不安全的网络中使用,因为支持客户端和服务器身份验证以及加密。
SASL_PLAINTEXT
PLAINTEXT 传输层,带有 SASL 客户端身份验证。一些 SASL 机制也支持服务器身份验证。不支持加密,因此仅适用于私有网络中使用。
SASL_SSL
SSL 传输层,带有 SASL 身份验证。适用于在不安全的网络中使用,因为支持客户端和服务器身份验证以及加密。
TLS/SSL
TLS 是公共互联网上最广泛使用的加密协议之一。应用程序协议如 HTTP、SMTP 和 FTP 依赖于 TLS 来提供数据在传输过程中的隐私和完整性。TLS 依赖于公钥基础设施(PKI)来创建、管理和分发数字证书,这些证书可用于非对称加密,避免了在服务器和客户端之间分发共享密钥的需要。TLS 握手期间生成的会话密钥使得后续数据传输可以使用更高性能的对称加密。
用于经纪人间通信的侦听器可以通过配置inter.broker.listener.name或security.inter.broker.protocol来选择。对于用于经纪人间通信的安全协议,必须在经纪人配置中提供服务器端和客户端端的配置选项。这是因为经纪人需要为该侦听器建立客户端连接。以下示例配置了 SSL 用于经纪人间和内部侦听器,以及 SASL_SSL 用于外部侦听器:
listeners=EXTERNAL://:9092,INTERNAL://10.0.0.2:9093,BROKER://10.0.0.2:9094
advertised.listeners=EXTERNAL://broker1.example.com:9092,INTERNAL://broker1.local:9093,BROKER://broker1.local:9094
listener.security.protocol.map=EXTERNAL:SASL_SSL,INTERNAL:SSL,BROKER:SSL
inter.broker.listener.name=BROKER
客户端配置了安全协议和引导服务器,确定经纪人侦听器。返回给客户端的元数据仅包含与引导服务器相同侦听器对应的端点:
security.protocol=SASL_SSL
bootstrap.servers=broker1.example.com:9092,broker2.example.com:9092
在下一节中,我们将审查经纪人和客户端针对每种安全协议的特定于协议的配置选项。
认证
认证是建立客户端和服务器身份以验证客户端真实性和服务器真实性的过程。当爱丽丝的客户端连接到领导经纪人以生成客户订单记录时,服务器认证使客户端能够确定客户端正在与实际经纪人交谈的服务器。客户端认证通过验证爱丽丝的凭据(如密码或数字证书)来验证爱丽丝的身份,以确定连接是来自爱丽丝而不是冒名顶替者。一旦经过身份验证,爱丽丝的身份将与连接的整个生命周期相关联。Kafka 使用KafkaPrincipal的实例来表示客户端身份,并使用此主体为具有该客户端身份的连接授予访问资源和分配配额。每个连接的KafkaPrincipal在身份验证期间基于身份验证协议进行建立。例如,基于基于密码的身份验证提供的用户名,可以为爱丽丝使用主体User:Alice。KafkaPrincipal可以通过为经纪人配置principal.builder.class来进行自定义。
匿名连接
主体User:ANONYMOUS用于未经身份验证的连接。这包括 PLAINTEXT 侦听器上的客户端以及 SSL 侦听器上的未经身份验证的客户端。
SSL
当 Kafka 配置为 SSL 或 SASL_SSL 作为侦听器的安全协议时,TLS 用作该侦听器上连接的安全传输层。建立 TLS 连接时,TLS 握手过程执行身份验证,协商加密参数,并生成用于加密的共享密钥。客户端验证服务器的数字证书以建立服务器的身份。如果启用 SSL 进行客户端身份验证,则服务器还验证客户端的数字证书以建立客户端的身份。所有 SSL 流量都是加密的,适用于不安全的网络。
SSL 性能
SSL 通道是加密的,因此在 CPU 使用方面引入了明显的开销。目前不支持 SSL 的零拷贝传输。根据流量模式,开销可能高达 20-30%。
配置 TLS
当使用 SSL 或 SASL_SSL 为经纪人侦听器启用 TLS 时,经纪人应配置具有经纪人私钥和证书的密钥库,客户端应配置具有经纪人证书或签署经纪人证书的证书颁发机构(CA)的信任库。经纪人证书应包含经纪人主机名作为主题替代名称(SAN)扩展或作为通用名称(CN),以使客户端能够验证服务器主机名。通配符证书可用于简化管理,方法是为域中的所有经纪人使用相同的密钥库。
服务器主机名验证
默认情况下,Kafka 客户端验证存储在服务器证书中的主机名是否与客户端正在连接的主机匹配。连接主机名可以是客户端配置的引导服务器,也可以是经纪人在元数据响应中返回的广告侦听器主机名。主机名验证是服务器身份验证的关键部分,可防止中间人攻击,因此在生产系统中不应禁用。
通过设置经纪人配置选项ssl.client.auth=required,可以配置经纪人使用 SSL 作为安全协议对连接到侦听器的客户端进行身份验证。客户端应配置具有密钥库,经纪人应配置具有客户端证书或签署客户端证书的 CA 证书的信任库。如果 SSL 用于经纪人之间的通信,经纪人信任库应包括经纪人证书的 CA 以及客户端证书的 CA。默认情况下,客户端证书的可分辨名称(DN)用作授权和配额的KafkaPrincipal。配置选项ssl.principal.mapping.rules可用于提供一系列规则以自定义主体。使用 SASL_SSL 的侦听器禁用 TLS 客户端身份验证,并依赖于 SASL 身份验证和由 SASL 建立的KafkaPrincipal。
SSL 客户端身份验证
通过设置ssl.client.auth=requested,可以将 SSL 客户端身份验证设置为可选。在这种情况下,未配置密钥库的客户端将完成 TLS 握手,但将被分配主体User:ANONYMOUS。
以下示例显示了如何使用自签名 CA 为服务器和客户端身份验证创建密钥库和信任库。
为经纪人生成自签名 CA 密钥对:
$keytool-genkeypair-keyalgRSA-keysize2048-keystoreserver.ca.p12\
-storetype PKCS12 -storepass server-ca-password -keypass server-ca-password \ -alias ca -dname "CN=BrokerCA" -ext bc=ca:true -validity 365 ①$keytool-export-fileserver.ca.crt-keystoreserver.ca.p12\
-storetype PKCS12 -storepass server-ca-password -alias ca -rfc // ②
①
为 CA 创建密钥对,并将其存储在 PKCS12 文件 server.ca.p12 中。我们将用它来签署证书。
②
将 CA 的公共证书导出到 server.ca.crt。这将包含在信任库和证书链中。
使用由自签名 CA 签名的证书为经纪人创建密钥库。如果使用通配符主机名,可以为所有经纪人使用相同的密钥库。否则,为每个经纪人创建一个具有其完全限定域名(FQDN)的密钥库:
$keytool-genkey-keyalgRSA-keysize2048-keystoreserver.ks.p12\
-storepass server-ks-password -keypass server-ks-password -alias server \ -storetype PKCS12 -dname "CN=Kafka,O=Confluent,C=GB" -validity 365 ①$keytool-certreq-fileserver.csr-keystoreserver.ks.p12-storetypePKCS12\
-storepass server-ks-password -keypass server-ks-password -alias server ②$keytool-gencert-infileserver.csr-outfileserver.crt\
-keystore server.ca.p12 -storetype PKCS12 -storepass server-ca-password \ -alias ca -ext SAN=DNS:broker1.example.com -validity 365 ③$catserver.crtserver.ca.crt>serverchain.crt$keytool-importcert-fileserverchain.crt-keystoreserver.ks.p12\
-storepass server-ks-password -keypass server-ks-password -alias server \ -storetype PKCS12 -noprompt // ④
①
为经纪人生成私钥,并将其存储在 PKCS12 文件 server.ks.p12 中。
②
生成证书签名请求。
③
使用 CA 密钥库签署经纪人的证书。签署的证书存储在 server.crt 中。
④
将经纪人的证书链导入经纪人的密钥库。
如果 TLS 用于经纪人之间的通信,请为经纪人创建一个信任库,其中包含经纪人的 CA 证书,以使经纪人能够相互进行身份验证:
$ keytool -import -file server.ca.crt -keystore server.ts.p12 \
-storetype PKCS12 -storepass server-ts-password -alias server -noprompt
为客户端生成一个信任库,其中包含经纪人的 CA 证书:
$ keytool -import -file server.ca.crt -keystore client.ts.p12 \
-storetype PKCS12 -storepass client-ts-password -alias ca -noprompt
如果启用了 TLS 客户端身份验证,则必须为客户端配置密钥存储。以下脚本为客户端生成一个自签名的 CA,并创建一个由客户端 CA 签名的客户端密钥存储。客户端 CA 被添加到经纪人信任存储中,以便经纪人可以验证客户端的真实性:
#Generateself-signedCAkey-pairforclientskeytool -genkeypair -keyalg RSA -keysize 2048 -keystore client.ca.p12 \
-storetype PKCS12 -storepass client-ca-password -keypass client-ca-password \ -alias ca -dname CN=ClientCA -ext bc=ca:true -validity 365 ①keytool -export -file client.ca.crt -keystore client.ca.p12 -storetype PKCS12 \
-storepass client-ca-password -alias ca -rfc #Createkeystoreforclientskeytool -genkey -keyalg RSA -keysize 2048 -keystore client.ks.p12 \
-storepass client-ks-password -keypass client-ks-password -alias client \ -storetype PKCS12 -dname "CN=Metrics App,O=Confluent,C=GB" -validity 365 ②keytool -certreq -file client.csr -keystore client.ks.p12 -storetype PKCS12 \
-storepass client-ks-password -keypass client-ks-password -alias client keytool -gencert -infile client.csr -outfile client.crt \
-keystore client.ca.p12 -storetype PKCS12 -storepass client-ca-password \ -alias ca -validity 365 cat client.crt client.ca.crt > clientchain.crt keytool -importcert -file clientchain.crt -keystore client.ks.p12 \
-storepass client-ks-password -keypass client-ks-password -alias client \ -storetype PKCS12 -noprompt ③#AddclientCAcertificatetobroker'struststorekeytool -import -file client.ca.crt -keystore server.ts.p12 -alias client \
-storetype PKCS12 -storepass server-ts-password -noprompt // ④
①
在本示例中,我们为客户端创建了一个新的 CA。
②
使用此证书进行身份验证的客户端默认使用User:CN=Metrics App,O=Confluent,C=GB作为主体。
③
我们将客户端证书链添加到客户端密钥存储中。
④
经纪人的信任存储应包含所有客户端的 CA。
一旦我们有了密钥和信任存储,我们就可以为经纪人配置 TLS。只有在 TLS 用于经纪人之间的通信或启用了客户端身份验证时,经纪人才需要信任存储:
ssl.keystore.location=/path/to/server.ks.p12
ssl.keystore.password=server-ks-password
ssl.key.password=server-ks-password
ssl.keystore.type=PKCS12
ssl.truststore.location=/path/to/server.ts.p12
ssl.truststore.password=server-ts-password
ssl.truststore.type=PKCS12
ssl.client.auth=required
客户端配置了生成的信任存储。如果需要客户端身份验证,则应为客户端配置密钥存储。
ssl.truststore.location=/path/to/client.ts.p12
ssl.truststore.password=client-ts-password
ssl.truststore.type=PKCS12
ssl.keystore.location=/path/to/client.ks.p12
ssl.keystore.password=client-ks-password
ssl.key.password=client-ks-password
ssl.keystore.type=PKCS12
信任存储
信任存储配置在经过知名受信任的机构签名的证书的经纪人和客户端中可以省略。在这种情况下,Java 安装中的默认信任存储将足以建立信任。安装步骤在第二章中有描述。
必须定期更新密钥存储和信任存储,以避免 TLS 握手失败。经纪人 SSL 存储可以通过修改相同的文件或将配置选项设置为新的带版本的文件来动态更新。在这两种情况下,可以使用 Admin API 或 Kafka 配置工具来触发更新。以下示例使用配置工具更新经纪人 ID 为0的经纪人的外部侦听器的密钥存储:
$ bin/kafka-configs.sh --bootstrap-server localhost:9092 \
--command-config admin.props \
--entity-type brokers --entity-name 0 --alter --add-config \
'listener.name.external.ssl.keystore.location=/path/to/server.ks.p12'
安全注意事项
TLS 广泛用于为多种协议提供传输层安全性,包括 HTTPS。与任何安全协议一样,重要的是在采用协议用于关键任务的应用程序时了解潜在的威胁和缓解策略。Kafka 默认只启用较新的协议 TLSv1.2 和 TLSv1.3,因为较旧的协议如 TLSv1.1 存在已知的漏洞。由于存在不安全的重新协商问题,Kafka 不支持 TLS 连接的重新协商。默认情况下启用主机名验证以防止中间人攻击。可以通过限制密码套件进一步加强安全性。具有至少 256 位加密密钥大小的强密码套件可防止密码攻击,并在通过不安全网络传输数据时确保数据完整性。一些组织要求 TLS 协议和密码套件受限以符合 FIPS 140-2 等安全标准。
由于默认情况下包含私钥的密钥存储存储在文件系统上,因此通过文件系统权限限制对密钥存储文件的访问至关重要。标准 Java TLS 功能可用于在私钥受损时启用证书吊销。在这种情况下,可以使用短寿命密钥来减少风险。
TLS 握手在经纪人的网络线程上消耗大量时间,是昂贵的。在不安全的网络上使用 TLS 的侦听器应受到连接配额和限制的保护,以保护经纪人的可用性免受拒绝服务攻击。经纪人配置选项connection.failed.authentication.delay.ms可用于在身份验证失败时延迟失败响应,以减少客户端重试身份验证失败的速率。
SASL
Kafka 协议支持使用 SASL 进行身份验证,并内置支持几种常用的 SASL 机制。SASL 可以与 TLS 结合使用作为传输层,以提供具有身份验证和加密的安全通道。SASL 身份验证通过服务器挑战和客户端响应的序列执行,其中 SASL 机制定义了挑战和响应的序列和线路格式。Kafka 经纪人直接支持以下 SASL 机制,并具有可定制的回调,以与现有安全基础设施集成:
GSSAPI
Kerberos 身份验证使用 SASL/GSSAPI 进行支持,并可用于与 Active Directory 或 OpenLDAP 等 Kerberos 服务器集成。
PLAIN
使用自定义服务器端回调来验证来自外部密码存储的密码的用户名/密码身份验证。
SCRAM-SHA-256 和 SCRAM-SHA-512
Kafka 可以直接使用用户名/密码进行身份验证,无需额外的密码存储。
OAUTHBEARER
使用 OAuth 令牌进行身份验证,通常与自定义回调一起使用,以获取和验证标准 OAuth 服务器授予的令牌。
每个启用 SASL 的监听器上可以通过为该监听器配置sasl.enabled.mechanisms来启用一个或多个 SASL 机制。客户端可以通过配置sasl.mechanism选择任何已启用的机制。
Kafka 使用 Java 身份验证和授权服务(JAAS)来配置 SASL。配置选项sasl.jaas.config包含一个单个 JAAS 配置条目,指定登录模块及其选项。在配置sasl.jaas.config时,经纪人使用listener和mechanism前缀。例如,listener.name.external.gssapi.sasl.jaas.config配置了名为EXTERNAL的监听器上 SASL/GSSAPI 的 JAAS 配置条目。经纪人和客户端上的登录过程使用 JAAS 配置来确定用于身份验证的公共和私有凭据。
JAAS 配置文件
还可以使用 Java 系统属性java.security.auth.login.config在配置文件中指定 JAAS 配置。但是,建议使用 Kafka 选项sasl.jaas.config,因为它支持密码保护,并且在监听器上启用多个机制时为每个 SASL 机制单独配置。
Kafka 支持的 SASL 机制可以定制,以与第三方身份验证服务器集成,使用回调处理程序。可以为经纪人或客户端提供登录回调处理程序,以自定义登录过程,例如获取用于身份验证的凭据。可以提供服务器回调处理程序来执行客户端凭据的身份验证,例如使用外部密码服务器验证密码。可以提供客户端回调处理程序来注入客户端凭据,而不是将它们包含在 JAAS 配置中。
在接下来的小节中,我们将更详细地探讨 Kafka 支持的 SASL 机制。
SASL/GSSAPI
Kerberos 是一种广泛使用的网络身份验证协议,使用强加密来支持在不安全网络上进行安全的相互身份验证。通用安全服务应用程序接口(GSS-API)是一个框架,用于为使用不同身份验证机制的应用程序提供安全服务。RFC-4752介绍了使用 GSS-API 的 Kerberos V5 机制进行身份验证的 SASL 机制 GSSAPI。开源和企业级商业实现的 Kerberos 服务器的可用性使 Kerberos 成为许多具有严格安全要求的部门身份验证的流行选择。Kafka 支持使用 SASL/GSSAPI 进行 Kerberos 身份验证。
配置 SASL/GSSAPI
Kafka 使用 Java 运行时环境中包含的 GSSAPI 安全提供程序来支持使用 Kerberos 进行安全认证。GSSAPI 的 JAAS 配置包括包含主体与其长期密钥的映射的密钥表文件的路径。要为代理配置 GSSAPI,需要为每个代理创建一个包含代理主机名的主体的密钥表。客户端通过验证代理主机名来确保服务器的真实性并防止中间人攻击。Kerberos 在认证期间需要安全的 DNS 服务来查找主机名。在前向和反向查找不匹配的部署中,可以在客户端的 Kerberos 配置文件* krb5.conf *中配置rdns=false来禁用反向查找。每个代理的 JAAS 配置应包括 Java 运行时环境中的 Kerberos V5 登录模块,密钥表文件的路径和完整的代理主体:
sasl.enabled.mechanisms=GSSAPI
listener.name.external.gssapi.sasl.jaas.config=\ // ①
com.sun.security.auth.module.Krb5LoginModule required \
useKeyTab=true storeKey=true \
keyTab="/path/to/broker1.keytab" \ // ②
principal="kafka/broker1.example.com@EXAMPLE.COM"; // ③
①
我们使用以侦听器前缀为前缀的sasl.jaas.config,其中包含侦听器名称和小写的 SASL 机制。
②
代理进程必须能够读取密钥表文件。
③
代理的服务主体应包括代理主机名。
如果 SASL/GSSAPI 用于代理间通信,则还应为代理配置代理间 SASL 机制和 Kerberos 服务名称:
sasl.mechanism.inter.broker.protocol=GSSAPI
sasl.kerberos.service.name=kafka
客户端应在 JAAS 配置和sasl.kerberos.service.name中配置自己的密钥表和主体,以指示它们正在连接的服务的名称:
sasl.mechanism=GSSAPI
sasl.kerberos.service.name=kafka // ①
sasl.jaas.config=com.sun.security.auth.module.Krb5LoginModule required \
useKeyTab=true storeKey=true \
keyTab="/path/to/alice.keytab" \
principal="Alice@EXAMPLE.COM"; // ②
①
Kafka 服务的服务名称应该为客户端指定。
②
客户端可以在没有主机名的情况下使用主体。
默认情况下,主体的短名称用作客户端标识。例如,在示例中,User:Alice是客户端主体,User:kafka是代理主体。代理配置sasl.kerberos.principal.to.local.rules可用于应用一系列规则来将完全限定的主体转换为自定义主体。
安全注意事项
在使用 Kerberos 保护认证流和认证后的连接数据流的生产部署中,建议使用 SASL_SSL。如果不使用 TLS 提供安全传输层,网络上的窃听者可能会获得足够的信息来发动字典攻击或暴力攻击以窃取客户端凭据。与使用易于破解的密码生成的密钥相比,更安全的做法是为代理使用随机生成的密钥。应避免使用 DES-MD5 等弱加密算法,而应使用更强大的算法。必须使用文件系统权限限制对密钥表文件的访问,因为拥有该文件的任何用户都可以冒充用户。
SASL/GSSAPI 需要安全的 DNS 服务进行服务器认证。由于针对 KDC 或 DNS 服务的拒绝服务攻击可能导致客户端的认证失败,因此有必要监视这些服务的可用性。Kerberos 还依赖于具有可配置变化性的宽松同步时钟来检测重放攻击。确保时钟同步安全非常重要。
SASL/PLAIN
RFC-4616定义了一种简单的用户名/密码认证机制,可与 TLS 一起使用以提供安全认证。在认证期间,客户端向服务器发送用户名和密码,服务器使用其密码存储验证密码。Kafka 具有内置的 SASL/PLAIN 支持,可以与安全的外部密码数据库集成,使用自定义回调处理程序。
配置 SASL/PLAIN
SASL/PLAIN 的默认实现使用经纪人的 JAAS 配置作为密码存储。所有客户端用户名和密码都包括在登录选项中,经纪人验证客户端在认证期间提供的密码是否与这些条目中的一个匹配。只有在用于经纪人间通信的 SASL/PLAIN 时才需要经纪人用户名和密码:
sasl.enabled.mechanisms=PLAIN
sasl.mechanism.inter.broker.protocol=PLAIN
listener.name.external.plain.sasl.jaas.config=\
org.apache.kafka.common.security.plain.PlainLoginModule required \
username="kafka" password="kafka-password" \ // ①
user_kafka="kafka-password" \
user_Alice="Alice-password"; // ②
①
经纪人发起的经纪人间连接所使用的用户名和密码。
②
当 Alice 的客户端连接到经纪人时,Alice 提供的密码将与经纪人配置中的密码进行验证。
客户端必须配置用户名和密码进行身份验证:
sasl.mechanism=PLAIN
sasl.jaas.config=org.apache.kafka.common.security.plain.PlainLoginModule \
required username="Alice" password="Alice-password";
内置实现将所有密码存储在每个经纪人的 JAAS 配置中,这是不安全的,也不够灵活,因为所有经纪人都需要重新启动以添加或删除用户。在生产环境中使用 SASL/PLAIN 时,可以使用自定义服务器回调处理程序将经纪人与安全的第三方密码服务器集成。自定义回调处理程序还可以用于支持密码轮换。在服务器端,服务器回调处理程序应支持新旧密码在重叠期间的使用,直到所有客户端切换到新密码。以下示例显示了一个回调处理程序,用于验证使用 Apache 工具htpasswd生成的文件中的加密密码:
publicclassPasswordVerifierextendsPlainServerCallbackHandler{privatefinalList<String>passwdFiles=newArrayList<>();①@Overridepublicvoidconfigure(Map<String,?>configs,Stringmechanism,List<AppConfigurationEntry>jaasEntries){Map<String,?>loginOptions=jaasEntries.get(0).getOptions();Stringfiles=(String)loginOptions.get("password.files");②Collections.addAll(passwdFiles,files.split(","));}@Overrideprotectedbooleanauthenticate(Stringuser,char[]password){returnpasswdFiles.stream()③.anyMatch(file->authenticate(file,user,password));}privatebooleanauthenticate(Stringfile,Stringuser,char[]password){try{Stringcmd=String.format("htpasswd -vb %s %s %s",④file,user,newString(password));returnRuntime.getRuntime().exec(cmd).waitFor()==0;}catch(Exceptione){returnfalse;}}}
①
我们使用多个密码文件,以便支持密码轮换。
②
我们在经纪人配置的 JAAS 选项中传递密码文件的路径名。也可以使用自定义经纪人配置选项。
③
我们检查密码是否匹配任何文件,允许在一段时间内使用旧密码和新密码。
④
我们使用htpasswd来简化。生产部署可以使用安全数据库。
经纪人配置了密码验证回调处理程序及其选项:
listener.name.external.plain.sasl.jaas.config=\
org.apache.kafka.common.security.plain.PlainLoginModule required \
password.files="/path/to/htpassword.props,/path/to/oldhtpassword.props";
listener.name.external.plain.sasl.server.callback.handler.class=\
com.example.PasswordVerifier
在客户端端,可以使用实现org.apache.kafka.common.security.auth.AuthenticateCallbackHandler的客户端回调处理程序,在建立连接时动态加载密码,而不是在启动期间从 JAAS 配置中静态加载。密码可以从加密文件或使用外部安全服务器加载,以提高安全性。以下示例使用 Kafka 中的配置类动态从文件加载密码:
@Overridepublicvoidhandle(Callback[]callbacks)throwsIOException{Propertiesprops=Utils.loadProps(passwdFile);①PasswordConfigconfig=newPasswordConfig(props);Stringuser=config.getString("username");Stringpassword=config.getPassword("password").value();②for(Callbackcallback:callbacks){if(callbackinstanceofNameCallback)((NameCallback)callback).setName(user);elseif(callbackinstanceofPasswordCallback){((PasswordCallback)callback).setPassword(password.toCharArray());}}}privatestaticclassPasswordConfigextendsAbstractConfig{staticConfigDefCONFIG=newConfigDef().define("username",STRING,HIGH,"User name").define("password",PASSWORD,HIGH,"User password");③PasswordConfig(Propertiesprops){super(CONFIG,props,false);}}
①
我们在回调函数中加载配置文件,以确保我们使用最新的密码来支持密码轮换。
②
即使密码是外部化的,底层配置库也会返回实际的密码值。
③
我们使用PASSWORD类型定义密码配置,以确保密码不包含在日志条目中。
客户端和经纪人都可以配置使用客户端回调来进行 SASL/PLAIN 的经纪人间通信:
sasl.jaas.config=org.apache.kafka.common.security.plain.PlainLoginModule \
required file="/path/to/credentials.props";
sasl.client.callback.handler.class=com.example.PasswordProvider
安全考虑
由于 SASL/PLAIN 在传输中传输明文密码,因此应仅在使用 SASL_SSL 进行加密时启用 PLAIN 机制,以提供安全的传输层。在经纪人和客户端的 JAAS 配置中存储的明文密码是不安全的,因此请考虑在安全密码存储中加密或外部化这些密码。不要使用内置密码存储,该存储将所有客户端密码存储在经纪人的 JAAS 配置中,而是使用安全的外部密码服务器,该服务器安全地存储密码并强制执行强密码策略。
明文密码
即使可以使用文件系统权限保护文件,也应避免在配置文件中使用明文密码。考虑将密码外部化或加密,以确保密码不会被意外暴露。Kafka 的密码保护功能将在本章后面进行描述。
SASL/SCRAM
RFC-5802介绍了一种安全的用户名/密码身份验证机制,解决了像 SASL/PLAIN 这样的密码身份验证机制发送密码的安全问题。盐挑战响应身份验证机制(SCRAM)避免传输明文密码,并以一种使得冒充客户端变得不切实际的格式存储密码。盐化将密码与一些随机数据结合,然后应用单向加密哈希函数以安全地存储密码。Kafka 具有内置的 SCRAM 提供程序,可在具有安全 ZooKeeper 的部署中使用,无需额外的密码服务器。Kafka 提供者支持 SCRAM 机制SCRAM-SHA-256和SCRAM-SHA-512。
配置 SASL/SCRAM
在启动代理之前,可以在启动 ZooKeeper 之前创建一组初始用户。代理在启动期间将 SCRAM 用户元数据加载到内存缓存中,确保所有用户,包括代理用户进行代理间通信,都可以成功进行身份验证。用户可以随时添加或删除。代理使用基于 ZooKeeper watcher 的通知来保持缓存的最新状态。在此示例中,我们为 SASL 机制SCRAM-SHA-512创建一个具有主体User:Alice和密码Alice-password的用户:
$ bin/kafka-configs.sh --zookeeper localhost:2181 --alter --add-config \
'SCRAM-SHA-512=[iterations=8192,password=Alice-password]' \
--entity-type users --entity-name Alice
可以通过在代理上配置机制来启用一个或多个 SCRAM 机制。只有在监听器用于代理间通信时,才需要为代理配置用户名和密码:
sasl.enabled.mechanisms=SCRAM-SHA-512
sasl.mechanism.inter.broker.protocol=SCRAM-SHA-512
listener.name.external.scram-sha-512.sasl.jaas.config=\
org.apache.kafka.common.security.scram.ScramLoginModule required \
username="kafka" password="kafka-password"; // ①
①
代理发起的代理间连接的用户名和密码。
必须配置客户端以使用代理上启用的 SASL 机制之一,并且客户端 JAAS 配置必须包括用户名和密码:
sasl.mechanism=SCRAM-SHA-512
sasl.jaas.config=org.apache.kafka.common.security.scram.ScramLoginModule \
required username="Alice" password="Alice-password";
您可以使用--add-config添加新的 SCRAM 用户,并使用--delete-config选项删除用户。删除现有用户后,无法为该用户建立新连接,但用户的现有连接将继续工作。可以为代理配置重新认证间隔,以限制用户删除后现有连接可以继续操作的时间。以下示例删除了Alice的SCRAM-SHA-512配置,以删除该机制的 Alice 凭据:
$ bin/kafka-configs.sh --zookeeper localhost:2181 --alter --delete-config \
'SCRAM-SHA-512' --entity-type users --entity-name Alice
安全注意事项
SCRAM 对密码应用单向加密哈希函数,结合随机盐,以避免实际密码在传输过程中或存储在数据库中。然而,任何基于密码的系统只有密码强度高时才是安全的。必须执行强密码策略,以保护系统免受暴力或字典攻击。Kafka 通过仅支持强哈希算法 SHA-256 和 SHA-512,并避免像 SHA-1 这样的较弱算法来提供保障。这与默认迭代次数为 4,096 和每个存储密钥的唯一随机盐相结合,以限制如果 ZooKeeper 安全性受到损害的影响。
在握手期间传输的密钥和存储在 ZooKeeper 中的密钥需要采取额外的预防措施,以防止暴力攻击。SCRAM 必须与SASL_SSL一起使用作为安全协议,以避免窃听者在身份验证期间获取对哈希密钥的访问。ZooKeeper 还必须启用 SSL,并且必须使用磁盘加密来保护 ZooKeeper 数据,以确保即使存储被破坏,也无法检索存储的密钥。在没有安全 ZooKeeper 的部署中,可以使用 SCRAM 回调来与安全的外部凭据存储集成。
SASL/OAUTHBEARER
OAuth 是一种授权框架,使应用程序能够获取对 HTTP 服务的有限访问权限。RFC-7628定义了 OAUTHBEARER SASL 机制,该机制使得使用 OAuth 2.0 获取的凭据能够访问非 HTTP 协议中的受保护资源。OAUTHBEARER 通过使用 OAuth 2.0 承载令牌,具有较短的生命周期和有限的资源访问权限,避免了使用长期密码的机制中的安全漏洞。Kafka 支持 SASL/OAUTHBEARER 用于客户端身份验证,从而使其能够与第三方 OAuth 服务器集成。内置的 OAUTHBEARER 实现使用不安全的 JSON Web 令牌(JWT),不适合生产使用。可以添加自定义回调以与标准 OAuth 服务器集成,以在生产部署中使用 OAUTHBEARER 机制进行安全身份验证。
配置 SASL/OAUTHBEARER
Kafka 中的 SASL/OAUTHBEARER 的内置实现不验证令牌,因此只需要在 JAAS 配置中指定登录模块。如果监听器用于经纪人之间的通信,则还必须提供经纪人发起的客户端连接所使用的令牌的详细信息。选项unsecuredLoginStringClaim_sub是默认情况下确定连接的KafkaPrincipal的主题声明:
sasl.enabled.mechanisms=OAUTHBEARER
sasl.mechanism.inter.broker.protocol=OAUTHBEARER
listener.name.external.oauthbearer.sasl.jaas.config=\
org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule \
required unsecuredLoginStringClaim_sub="kafka"; // ①
① (#co_securing_kafka_CO10-1)
用于经纪人连接的令牌的主题声明。
客户端必须配置主题声明选项unsecuredLoginStringClaim_sub。还可以配置其他声明和令牌的生命周期:
sasl.mechanism=OAUTHBEARER
sasl.jaas.config=\
org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule \
required unsecuredLoginStringClaim_sub="Alice"; // ①
① (#co_securing_kafka_CO11-1)
User:Alice是使用此配置进行连接的默认KafkaPrincipal。
为了将 Kafka 与第三方 OAuth 服务器集成,以在生产中使用承载令牌,Kafka 客户端必须配置sasl.login.callback.handler.class,以使用长期密码或刷新令牌从 OAuth 服务器获取令牌。如果 OAUTHBEARER 用于经纪人之间的通信,则还必须为经纪人配置登录回调处理程序,以获取经纪人为经纪人通信创建的客户端连接的令牌:
@Overridepublicvoidhandle(Callback[]callbacks)throwsUnsupportedCallbackException{OAuthBearerTokentoken=null;for(Callbackcallback:callbacks){if(callbackinstanceofOAuthBearerTokenCallback){token=acquireToken();①((OAuthBearerTokenCallback)callback).token(token);}elseif(callbackinstanceofSaslExtensionsCallback){②((SaslExtensionsCallback)callback).extensions(processExtensions(token));}elsethrownewUnsupportedCallbackException(callback);}}
① (#co_securing_kafka_CO12-1)
客户端必须从 OAuth 服务器获取令牌,并在回调中设置有效的令牌。
② (#co_securing_kafka_CO12-2)
客户端还可以包括可选的扩展。
经纪人还必须配置使用listener.name.<listener-name>.oauthbearer.sasl.server.callback.handler.class的服务器回调处理程序来验证客户端提供的令牌:
@Overridepublicvoidhandle(Callback[]callbacks)throwsUnsupportedCallbackException{for(Callbackcallback:callbacks){if(callbackinstanceofOAuthBearerValidatorCallback){OAuthBearerValidatorCallbackcb=(OAuthBearerValidatorCallback)callback;try{cb.token(validatedToken(cb.tokenValue()));①}catch(OAuthBearerIllegalTokenExceptione){OAuthBearerValidationResultr=e.reason();cb.error(errorStatus(r),r.failureScope(),r.failureOpenIdConfig());}}elseif(callbackinstanceofOAuthBearerExtensionsValidatorCallback){OAuthBearerExtensionsValidatorCallbackecb=(OAuthBearerExtensionsValidatorCallback)callback;ecb.inputExtensions().map().forEach((k,v)->ecb.valid(validateExtension(k,v)));②}else{thrownewUnsupportedCallbackException(callback);}}}
① (#co_securing_kafka_CO13-1)
OAuthBearerValidatorCallback包含来自客户端的令牌。经纪人验证此令牌。
② (#co_securing_kafka_CO13-2)
经纪人验证来自客户端的任何可选扩展。
安全注意事项
由于 SASL/OAUTHBEARER 客户端通过网络发送 OAuth 2.0 承载令牌,并且这些令牌可能被用于冒充客户端,因此必须启用 TLS 以加密身份验证流量。如果令牌泄露,可以使用短暂的令牌来限制暴露。可以在经纪人上配置重新验证以防止连接超过用于身份验证的令牌的生命周期。经纪人上配置的重新验证间隔,结合令牌吊销支持,限制了现有连接在吊销后继续使用令牌的时间。
委托令牌
委托令牌是 Kafka 代理和客户端之间的共享秘密,提供了一种轻量级的配置机制,无需将 SSL 密钥存储或 Kerberos 密钥表分发给客户端应用程序。委托令牌可用于减少身份验证服务器的负载,例如 Kerberos 密钥分发中心(KDC)。像 Kafka Connect 这样的框架可以使用委托令牌来简化工作人员的安全配置。已经使用 Kafka 代理进行身份验证的客户端可以为相同的用户主体创建委托令牌,并将这些令牌分发给工作人员,然后工作人员可以直接与 Kafka 代理进行身份验证。每个委托令牌由令牌标识符和用作共享秘密的基于哈希的消息认证码(HMAC)组成。使用委托令牌进行客户端身份验证时,使用令牌标识符作为用户名,HMAC 作为密码,使用 SASL/SCRAM 进行身份验证。
委托令牌可以使用 Kafka Admin API 或delegation-tokens命令创建或更新。要为主体User:Alice创建委托令牌,客户端必须使用 Alice 的凭据进行身份验证,除了委托令牌以外的任何身份验证协议。使用委托令牌进行身份验证的客户端无法创建其他委托令牌:
$bin/kafka-delegation-tokens.sh--bootstrap-serverlocalhost:9092\
--command-config admin.props --create --max-life-time-period -1 \ --renewer-principal User:Bob ①$bin/kafka-delegation-tokens.sh--bootstrap-serverlocalhost:9092\ ② --command-config admin.props --renew --renew-time-period -1 --hmac c2VjcmV0
①
如果 Alice 运行此命令,则生成的令牌可用于冒充 Alice。此令牌的所有者是User:Alice。我们还将User:Bob配置为令牌更新者。
②
续订命令可以由令牌所有者(Alice)或令牌更新者(Bob)运行。
配置委托令牌
要创建和验证委托令牌,所有代理必须使用配置选项delegation.token.master.key配置相同的主密钥。只有通过重新启动所有代理才能旋转此密钥。在更新主密钥之前,应删除所有现有令牌,因为它们将不再可用,并且在所有代理上更新密钥后应创建新令牌。
至少必须在代理上启用 SASL/SCRAM 机制之一,以支持使用委托令牌进行身份验证。客户端应配置为使用带有令牌标识符的 SCRAM 作为用户名,令牌 HMAC 作为密码。使用此配置进行连接的KafkaPrincipal将是与令牌关联的原始主体,例如User:Alice:
sasl.mechanism=SCRAM-SHA-512
sasl.jaas.config=org.apache.kafka.common.security.scram.ScramLoginModule \
required tokenauth="true" username="MTIz" password="c2VjcmV0"; // ①
①
使用tokenauth的 SCRAM 配置用于配置委托令牌。
安全考虑
与内置 SCRAM 实现一样,委托令牌仅适用于 ZooKeeper 安全的部署中的生产使用。SCRAM 下描述的所有安全考虑也适用于委托令牌。
代理用于生成令牌的主密钥必须使用加密或通过将密钥外部化到安全密码存储中进行保护。如果令牌泄露,可以使用短暂的委托令牌来限制暴露。可以在代理中启用重新认证,以防止使用过期令牌的连接,并限制删除令牌后现有连接继续运行的时间。
重新认证
正如我们之前所看到的,Kafka 代理在客户端建立连接时执行客户端身份验证。代理验证客户端凭据,并且如果凭据在那时是有效的,连接将成功进行身份验证。一些安全机制(如 Kerberos 和 OAuth)使用具有有限生命周期的凭据。Kafka 使用后台登录线程在旧凭据到期之前获取新凭据,但默认情况下新凭据仅用于验证新连接。使用旧凭据进行身份验证的现有连接将继续处理请求,直到由于请求超时、空闲超时或网络错误而发生断开连接。长期存在的连接可能会在用于身份验证的凭据到期后继续处理请求。Kafka 代理支持使用配置选项connections.max.reauth.ms对使用 SASL 进行身份验证的连接进行重新认证。当将此选项设置为正整数时,Kafka 代理确定 SASL 连接的会话生命周期,并在 SASL 握手期间通知客户端此生命周期。会话生命周期是凭据剩余生命周期或connections.max.reauth.ms的较小值。在此间隔内不重新认证的任何连接都将被代理终止。客户端使用后台登录线程获取的最新凭据或使用自定义回调注入的凭据进行重新认证。重新认证可用于在几种情况下加强安全性:
-
对于像 GSSAPI 和 OAUTHBEARER 这样使用具有有限生命周期凭据的 SASL 机制,重新认证可以保证所有活动连接都与有效凭据关联。短期凭据限制了在凭据受损的情况下的暴露。
-
基于密码的 SASL 机制(如 PLAIN 和 SCRAM)可以通过添加定期登录来支持密码轮换。重新认证限制了使用旧密码进行身份验证的连接上处理请求的时间。自定义服务器回调可以在一段时间内同时使用旧密码和新密码,以避免所有客户端迁移到新密码之前的中断。
-
connections.max.reauth.ms强制所有 SASL 机制重新认证,包括那些具有永不过期凭据的机制。这限制了凭据在被吊销后与活动连接关联的时间。 -
不支持 SASL 重新认证的客户端的连接在会话到期时终止,迫使客户端重新连接和重新进行身份验证,从而为过期或被吊销的凭据提供相同的安全保证。
受损用户
如果用户受到威胁,必须立即采取行动将用户从系统中移除。一旦用户从认证服务器中移除,所有新连接将无法通过 Kafka 代理进行身份验证。现有连接将继续处理请求,直到下一次重新认证超时。如果未配置connections.max.reauth.ms,则不会应用超时,并且现有连接可能会继续长时间使用受损用户的身份。Kafka 不支持 SSL 重新协商,因为在旧的 SSL 协议中重新协商存在已知的漏洞。更新的协议如 TLSv1.3 不支持重新协商。因此,现有的 SSL 连接可能会继续使用已吊销或过期的证书。用户主体的“拒绝”ACL 可以用于阻止这些连接执行任何操作。由于 ACL 更改在所有代理中的延迟非常小,这是禁用受损用户访问的最快方法。
无停机的安全更新
Kafka 部署需要定期维护以轮换密钥、应用安全修复程序并更新到最新的安全协议。许多这些维护任务是使用滚动更新来执行的,其中一个接一个地关闭经纪人,并使用更新的配置重新启动。一些任务,如更新 SSL 密钥存储和信任存储,可以使用动态配置更新而无需重新启动经纪人。
在现有部署中添加新的安全协议时,可以在经纪人上添加一个新的监听器以使用新协议,同时保留旧协议的旧监听器,以确保客户端应用程序在更新期间可以继续使用旧监听器。例如,可以使用以下顺序从 PLAINTEXT 切换到 SASL_SSL:
-
使用 Kafka 配置工具在每个经纪人上添加一个新的监听器到一个新的端口。使用单个配置更新命令来更新
listeners和advertised.listeners,包括旧的监听器以及新的监听器,并提供新的 SASL_SSL 监听器的所有配置选项。 -
修改所有客户端应用程序以使用新的 SASL_SSL 监听器。
-
如果正在更新经纪人间通信以使用新的 SASL_SSL 监听器,请执行经纪人的滚动更新,使用新的
inter.broker.listener.name。 -
使用配置工具从
listeners和advertised.listeners中移除旧的监听器,并移除旧监听器的任何未使用的配置选项。
可以在现有的 SASL 监听器上添加或移除 SASL 机制,而无需停机,使用相同的监听器端口进行滚动更新。以下顺序将机制从 PLAIN 切换到 SCRAM-SHA-256:
-
使用 Kafka 配置工具将所有现有用户添加到 SCRAM 存储中。
-
设置
sasl.enabled.mechanisms=PLAIN,SCRAM-SHA-256,为监听器配置listener.name.<_listener-name_>.scram-sha-256.sasl.jaas.config,并执行经纪人的滚动更新。 -
修改所有客户端应用程序以使用
sasl.mechanism=SCRAM-SHA-256,并更新sasl.jaas.config以使用 SCRAM。 -
如果监听器用于经纪人间通信,请使用滚动更新经纪人来设置
sasl.mechanism.inter.broker.protocol=SCRAM-SHA-256。 -
执行经纪人的另一个滚动更新以移除 PLAIN 机制。设置
sasl.enabled.mechanisms=SCRAM-SHA-256并移除listener.name.<listener-name>.plain.sasl.jaas.config和任何其他 PLAIN 的配置选项。
加密
加密用于保护数据隐私和数据完整性。正如我们之前讨论的那样,使用 SSL 和 SASL_SSL 安全协议的 Kafka 监听器使用 TLS 作为传输层,提供安全加密通道,保护在不安全网络上传输的数据。TLS 密码套件可以受限以加强安全性,并符合诸如联邦信息处理标准(FIPS)之类的安全要求。
必须采取额外措施来保护静态数据,以确保即使是具有物理访问权限的用户也无法检索敏感数据,即使磁盘被盗,也要避免安全漏洞,可以使用整个磁盘加密或卷加密来加密物理存储。
虽然在许多部署中,传输层和数据存储的加密可能提供足够的保护,但可能需要额外的保护措施,以避免自动授予平台管理员对数据的访问权限。存储在经纪人内存中的未加密数据可能会出现在堆转储中,直接访问磁盘的管理员将能够访问这些数据,以及包含潜在敏感数据的 Kafka 日志。在具有高度敏感数据或个人身份信息(PII)的部署中,需要额外的措施来保护数据隐私。为了符合监管要求,特别是在云部署中,有必要保证机密数据无论如何都不能被平台管理员或云提供商访问。可以将自定义加密提供程序插入 Kafka 客户端,以实现端到端加密,从而保证整个数据流都是加密的。
端到端加密
在 Kafka 生产者的第三章中,我们看到序列化器用于将消息转换为存储在 Kafka 日志中的字节数组,在 Kafka 消费者的第四章中,我们看到反序列化器将字节数组转换回消息。序列化器和反序列化器可以与加密库集成,以在序列化期间对消息进行加密,并在反序列化期间进行解密。消息加密通常使用像 AES 这样的对称加密算法。存储在密钥管理系统(KMS)中的共享加密密钥使生产者能够加密消息,消费者能够解密消息。经纪人不需要访问加密密钥,也永远不会看到消息的未加密内容,使得这种方法在云环境中使用是安全的。解密消息所需的加密参数可以存储在消息头中,或者如果旧的消费者不支持头部支持,则可以存储在消息有效负载中。数字签名也可以包含在消息头中,以验证消息的完整性。
图 11-2 显示了具有端到端加密的 Kafka 数据流。

图 11-2. 端到端加密
-
我们使用 Kafka 生产者发送消息。
-
生产者使用来自 KMS 的加密密钥对消息进行加密。
-
加密消息被发送到经纪人。经纪人将加密消息存储在分区日志中。
-
经纪人将加密消息发送给消费者。
-
消费者使用来自 KMS 的加密密钥解密消息。
生产者和消费者必须配置凭据,以从 KMS 获取共享密钥。建议定期进行密钥轮换以加强安全性,因为频繁的轮换限制了在发生违规时受损消息的数量,并且还可以防止暴力攻击。在旧密钥的保留期内,消费必须支持旧密钥和新密钥。许多 KMS 系统支持对称加密的优雅密钥轮换,无需在 Kafka 客户端中进行任何特殊处理。对于紧凑型主题,使用旧密钥加密的消息可能会被保留很长时间,可能需要重新加密旧消息。为了避免干扰较新的消息,在此过程中生产者和消费者必须处于离线状态。
加密消息的压缩
在加密后压缩消息不太可能在减少空间方面提供任何好处,与加密之前的压缩相比。 序列化器可以配置为在加密消息之前执行压缩,或者可以配置应用程序在生成消息之前执行压缩。 在任何情况下,最好在 Kafka 中禁用压缩,因为它会增加开销而不提供任何额外的好处。 对于通过不安全的传输层传输的消息,还必须考虑压缩加密消息的已知安全漏洞。
在许多环境中,特别是在使用 TLS 作为传输层时,消息键不需要加密,因为它们通常不包含像消息有效负载那样的敏感数据。 但在某些情况下,明文密钥可能不符合监管要求。 由于消息键用于分区和压缩,因此必须保留密钥的所需哈希等价性,以确保即使更改加密参数,密钥仍保留相同的哈希值。 一种方法是将原始密钥的安全哈希存储为消息密钥,并将加密的消息密钥存储在消息有效负载或标头中。 由于 Kafka 独立序列化消息键和值,因此可以使用生产者拦截器执行此转换。
授权
授权是确定您可以在哪些资源上执行哪些操作的过程。 Kafka 代理使用可定制的授权器管理访问控制。 我们之前看到,每当从客户端到代理建立连接时,代理都会对客户端进行身份验证,并将代表客户端身份的KafkaPrincipal与连接关联起来。 处理请求时,代理会验证与连接关联的主体是否被授权执行该请求。 例如,当 Alice 的生产者尝试将新的客户订单记录写入主题customerOrders时,代理会验证User:Alice是否被授权写入该主题。
Kafka 具有内置的授权器AclAuthorizer,可以通过配置授权器类名来启用,如下所示:
authorizer.class.name=kafka.security.authorizer.AclAuthorizer
SimpleAclAuthorizer
AclAuthorizer在 Apache Kafka 2.3 中引入。 从 0.9.0.0 版本开始,旧版本具有内置的授权器kafka.security.auth.SimpleAclAuthorizer,该授权器已被弃用,但仍受支持。
AclAuthorizer
AclAuthorizer支持使用访问控制列表(ACL)对 Kafka 资源进行细粒度访问控制。 ACL 存储在 ZooKeeper 中,并且每个代理都会将其缓存在内存中,以便对请求进行授权进行高性能查找。 当代理启动时,ACL 将加载到缓存中,并且使用基于 ZooKeeper watcher 的通知来保持缓存的最新状态。 通过验证与连接关联的KafkaPrincipal是否具有执行所请求操作的权限来授权每个 Kafka 请求。
每个 ACL 绑定包括:
-
资源类型:
Cluster|Topic|Group|TransactionalId|DelegationToken -
模式类型:
Literal|Prefixed -
资源名称:资源或前缀的名称,或通配符
* -
操作:
Describe|Create|Delete|Alter|Read|Write|DescribeConfigs|AlterConfigs -
权限类型:
Allow|Deny;Deny具有更高的优先级。 -
主体:Kafka 主体表示为
: ,例如, User:Bob或Group:Sales。 ACL 可以使用User:*来授予所有用户访问权限。 -
主机:客户端连接的源 IP 地址,或者如果所有主机都被授权,则为
*。
例如,ACL 可以指定:
User:Alice has Allow permission for Write to Prefixed Topic:customer from 192.168.0.1
如果没有与操作匹配的Deny ACL,并且至少有一个与操作匹配的Allow ACL,则AclAuthorizer会授权操作。 如果授予Read、Write、Alter或Delete权限,则隐含授予Describe权限。 如果授予AlterConfigs权限,则隐含授予DescribeConfigs权限。
通配符 ACL
使用模式类型Literal和资源名称*的 ACL 用作通配符 ACL,匹配资源类型的所有资源名称。
必须授予代理Cluster:ClusterAction访问权限,以授权控制器请求和副本获取请求。生产者需要Topic:Write来生产到主题。对于无事务的幂等生产,生产者还必须被授予Cluster:IdempotentWrite。事务性生产者需要对事务 IS 的TransactionalId:Write访问以及对消费者组的Group:Read以提交偏移量。消费者需要Topic:Read来从主题中消费,以及使用组管理或偏移管理时的消费者组的Group:Read。管理操作需要适当的Create,Delete,Describe,Alter,DescribeConfigs或AlterConfigs访问权限。表 11-1 显示了每个 ACL 应用的 Kafka 请求。
表 11-1. 每个 Kafka ACL 授予的访问权限
| ACL | Kafka 请求 | 备注 |
|---|---|---|
Cluster:ClusterAction |
代理间请求,包括控制器请求和用于复制的追随者获取请求 | 只应授予代理。 |
Cluster:Create |
CreateTopics 和自动主题创建 |
使用Topic:Create进行细粒度访问控制以创建特定主题。 |
Cluster:Alter |
CreateAcls,DeleteAcls,AlterReplicaLogDirs,ElectReplicaLeader,AlterPartitionReassignments |
|
Cluster:AlterConfigs |
代理和代理记录器的AlterConfigs 和 IncrementalAlterConfigs,AlterClientQuotas |
|
Cluster:Describe |
DescribeAcls,DescribeLogDirs,ListGroups,ListPartitionReassignments,描述元数据请求中集群的授权操作 |
使用Group:Describe进行细粒度访问控制以列出组。 |
Cluster:DescribeConfigs |
代理和代理记录器的DescribeConfigs,DescribeClientQuotas |
|
Cluster:IdempotentWrite |
幂等的InitProducerId 和 Produce 请求 |
仅对非事务幂等生产者需要。 |
Topic:Create |
CreateTopics 和自动主题创建 |
|
Topic:Delete |
DeleteTopics,DeleteRecords |
|
Topic:Alter |
CreatePartitions |
|
Topic:AlterConfigs |
主题的AlterConfigs 和 IncrementalAlterConfigs |
|
Topic:Describe |
主题的元数据请求,OffsetForLeaderEpoch,ListOffset,OffsetFetch |
|
Topic:DescribeConfigs |
主题的DescribeConfigs,用于在CreateTopics响应中返回配置 |
|
Topic:Read |
Consumer Fetch,OffsetCommit,TxnOffsetCommit,OffsetDelete |
应授予消费者。 |
Topic:Write |
Produce,AddPartitionToTxn |
应授予生产者。 |
Group:Read |
JoinGroup,SyncGroup,LeaveGroup,Heartbeat,OffsetCommit,AddOffsetsToTxn,TxnOffsetCommit |
消费者使用消费者组管理或基于 Kafka 的偏移管理时需要。事务性生产者在事务中提交偏移量时也需要。 |
Group:Describe |
FindCoordinator,DescribeGroup,ListGroups,OffsetFetch |
|
Group:Delete |
DeleteGroups,OffsetDelete |
|
TransactionalId:Write |
Produce 和 InitProducerId 与事务,AddPartitionToTxn,AddOffsetsToTxn,TxnOffsetCommit,EndTxn |
事务性生产者所需。 |
TransactionalId:Describe |
事务协调器的FindCoordinator |
|
DelegationToken:Describe |
DescribeTokens |
Kafka 提供了一个工具,用于使用在代理中配置的授权者来管理 ACL。 ACL 也可以直接在 ZooKeeper 中创建。这对于在启动代理之前创建代理 ACL 非常有用:
$bin/kafka-acls.sh--add--cluster--operationClusterAction\
--authorizer-properties zookeeper.connect=localhost:2181 \ ① --allow-principal User:kafka $bin/kafka-acls.sh--bootstrap-serverlocalhost:9092\
--command-config admin.props --add --topic customerOrders \ ② --producer --allow-principal User:Alice $bin/kafka-acls.sh--bootstrap-serverlocalhost:9092\
--command-config admin.props --add --resource-pattern-type PREFIXED \ ③ --topic customer --operation Read --allow-principal User:Bob
①
代理用户的 ACL 直接在 ZooKeeper 中创建。
②
默认情况下,ACLs 命令授予文字 ACLs。User:Alice被授予对主题customerOrders的写入访问权限。
③
前缀 ACL 授予 Bob 读取以customer开头的所有主题的权限。
AclAuthorizer有两个配置选项,用于授予资源或主体广泛访问权限,以简化 ACL 的管理,特别是在首次向现有集群添加授权时:
super.users=User:Carol;User:Admin
allow.everyone.if.no.acl.found=true
超级用户被授予对所有资源的所有操作的访问权限,没有任何限制,并且不能使用“拒绝”ACL 拒绝访问。如果 Carol 的凭据被泄露,必须将 Carol 从super.users中移除,并且必须重新启动代理以应用更改。在生产系统中更安全的做法是使用 ACL 向用户授予特定访问权限,以确保可以轻松地撤销访问权限(如果需要)。
超级用户分隔符
与 Kafka 中的其他列表配置不同,super.users是用分号分隔的,因为用户主体(例如来自 SSL 证书的可分辨名称)通常包含逗号。
如果启用了allow.everyone.if.no.acl.found,则所有用户都将被授予对资源的访问权限,而无需任何 ACL。此选项在首次在集群中启用授权或在开发过程中可能会有用,但不适用于生产环境,因为可能会意外地向新资源授予访问权限。如果不再满足no.acl.found条件,则当为匹配前缀或通配符添加 ACL 时,访问权限也可能会意外地被移除。
自定义授权
Kafka 中的授权可以定制以实现额外的限制或添加新类型的访问控制,如基于角色的访问控制。
以下自定义授权器将某些请求的使用限制在内部侦听器上。为简单起见,这里将请求和侦听器名称硬编码,但可以改为使用自定义授权器属性进行配置,以实现灵活性:
publicclassCustomAuthorizerextendsAclAuthorizer{privatestaticfinalSet<Short>internalOps=Utils.mkSet(CREATE_ACLS.id,DELETE_ACLS.id);privatestaticfinalStringinternalListener="INTERNAL";@OverridepublicList<AuthorizationResult>authorize(AuthorizableRequestContextcontext,List<Action>actions){if(!context.listenerName().equals(internalListener)&&①internalOps.contains((short)context.requestType()))returnCollections.nCopies(actions.size(),DENIED);elsereturnsuper.authorize(context,actions);②}}
①
授权器使用包含侦听器名称、安全协议、请求类型等元数据的请求上下文,使得自定义授权器可以根据上下文添加或删除限制。
②
我们重复使用内置 Kafka 授权器的功能,使用公共 API。
Kafka 授权器还可以与外部系统集成,以支持基于组的访问控制或基于角色的访问控制。可以使用不同的主体类型为组主体或角色主体创建 ACL。例如,下面的 Scala 类中的角色和组可以定期从 LDAP 服务器中填充,以支持不同级别的Allow ACL:
classRbacAuthorizerextendsAclAuthorizer{@volatileprivatevargroups=Map.empty[KafkaPrincipal,Set[KafkaPrincipal]].withDefaultValue(Set.empty)①@volatileprivatevarroles=Map.empty[KafkaPrincipal,Set[KafkaPrincipal]].withDefaultValue(Set.empty)②overridedefauthorize(context:AuthorizableRequestContext,actions:util.List[Action]):util.List[AuthorizationResult]={valprincipals=groups(context.principal)+context.principalvalallPrincipals=principals.flatMap(roles)++principals③valcontexts=allPrincipals.map(authorizeContext(context,_))actions.asScala.map{action=>valauthorized=contexts.exists(super.authorize(_,List(action).asJava).get(0)==ALLOWED)if(authorized)ALLOWEDelseDENIED④}.asJava}privatedefauthorizeContext(context:AuthorizableRequestContext,contextPrincipal:KafkaPrincipal):AuthorizableRequestContext={newAuthorizableRequestContext{⑤overridedefprincipal()=contextPrincipaloverridedefclientId()=context.clientIdoverridedefrequestType()=context.requestTypeoverridedefrequestVersion()=context.requestVersionoverridedefcorrelationId()=context.correlationIdoverridedefsecurityProtocol()=context.securityProtocoloverridedeflistenerName()=context.listenerNameoverridedefclientAddress()=context.clientAddress}}}
①
每个用户所属的组,从 LDAP 等外部来源填充。
②
每个用户关联的角色,从 LDAP 等外部来源填充。
③
我们为用户以及用户的所有组和角色执行授权。
④
如果任何上下文得到授权,我们返回ALLOWED。请注意,此示例不支持对组或角色的Deny ACL。
⑤
我们为每个主体创建一个授权上下文,其元数据与原始上下文相同。
可以使用标准 Kafka ACL 工具为组Sales或角色Operator分配 ACL。
$bin/kafka-acls.sh--bootstrap-serverlocalhost:9092\
--command-config admin.props --add --topic customer --producer \ --resource-pattern-type PREFIXED --allow-principal Group:Sales ①$bin/kafka-acls.sh--bootstrap-serverlocalhost:9092\
--command-config admin.props --add --cluster --operation Alter \ --allow-principal=Role:Operator // ②
①
我们使用主体Group:Sales和自定义主体类型Group创建一个适用于属于组Sales的用户的 ACL。
②
我们使用主体Role:Operator和自定义主体类型Role创建一个适用于具有角色Operator的用户的 ACL。
安全考虑
由于AclAuthorizer将 ACL 存储在 ZooKeeper 中,因此应限制对 ZooKeeper 的访问。没有安全 ZooKeeper 的部署可以实现自定义授权者,将 ACL 存储在安全的外部数据库中。
在拥有大量用户的大型组织中,管理单个资源的 ACL 可能变得非常繁琐。为不同部门保留不同的资源前缀可以使用前缀 ACL,从而最小化所需的 ACL 数量。这可以与基于组或角色的 ACL 结合使用,如前面的示例所示,以进一步简化大型部署中的访问控制。
使用最小特权原则限制用户访问可以在用户受到威胁时限制暴露。这意味着仅授予每个用户主体执行其操作所需的资源的访问权限,并在不再需要时删除 ACL。例如,当一个人离开组织时,应立即删除 ACL。长时间运行的应用程序可以配置为使用服务凭据而不是与特定用户关联的凭据,以避免员工离开组织时的任何中断。由于长时间运行的连接可能会在用户从系统中删除后继续处理请求,因此可以使用Deny ACL 来确保不会通过带有通配符主体的 ACL 意外授予主体访问权限。如果可能的话,应避免重用主体,以防止使用旧版本的主体授予连接访问权限。
审计
Kafka 经纪人可以配置为生成用于审计和调试的全面log4j日志。日志级别以及用于记录日志的 appender 及其配置选项可以在log4j.properties中指定。用于授权日志记录的 logger 实例kafka.authorizer.logger和用于请求日志记录的 kafka.request.logger 可以独立配置,以定制日志级别和审计日志的保留。生产系统可以使用 Elastic Stack 等框架来分析和可视化这些日志。
授权者为每次被拒绝访问的操作生成INFO级别的日志条目,并为每次被授予访问权限的操作生成DEBUG级别的日志条目。例如:
DEBUG Principal = User:Alice is Allowed Operation = Write from host = 127.0.0.1 on resource = Topic:LITERAL:customerOrders for request = Produce with resourceRefCount = 1 (kafka.authorizer.logger)
INFO Principal = User:Mallory is Denied Operation = Describe from host = 10.0.0.13 on resource = Topic:LITERAL:customerOrders for request = Metadata with resourceRefCount = 1 (kafka.authorizer.logger)
以DEBUG级别生成的请求日志还包括用户主体和客户端主机的详细信息。如果请求记录器配置为以TRACE级别记录日志,则还包括请求的完整详细信息。例如:
DEBUG Completed request:RequestHeader(apiKey=PRODUCE, apiVersion=8, clientId=producer-1, correlationId=6) -- {acks=-1,timeout=30000,partitionSizes=[customerOrders-0=15514]},response:{responses=[{topic=customerOrders,partition_responses=[{partition=0,error_code=0,base_offset=13,log_append_time=-1,log_start_offset=0,record_errors=[],error_message=null}]}],throttle_time_ms=0} from connection 127.0.0.1:9094-127.0.0.1:61040-0;totalTime:2.42,requestQueueTime:0.112,localTime:2.15,remoteTime:0.0,throttleTime:0,responseQueueTime:0.04,sendTime:0.118,securityProtocol:SASL_SSL,principal:User:Alice,listener:SASL_SSL,clientInformation:ClientInformation(softwareName=apache-kafka-java, softwareVersion=2.7.0-SNAPSHOT) (kafka.request.logger)
可以分析授权者和请求日志以检测可疑活动。跟踪身份验证失败的指标以及授权失败日志可能对审计非常有用,并在发生攻击或未经授权的访问事件时提供有价值的信息。为了实现端到端的审计性和消息的可追溯性,当消息被生产时,审计元数据可以包含在消息头中。端到端加密可用于保护此元数据的完整性。
保护 ZooKeeper
ZooKeeper 存储对于维护 Kafka 集群的可用性至关重要的 Kafka 元数据,因此除了保护 Kafka 外,还必须保护 ZooKeeper。ZooKeeper 支持使用 SASL/GSSAPI 进行 Kerberos 身份验证和使用 SASL/DIGEST-MD5 进行用户名/密码身份验证。ZooKeeper 在 3.5.0 中还添加了 TLS 支持,实现了数据在传输过程中的相互认证和加密。请注意,SASL/DIGEST-MD5 应仅与 TLS 加密一起使用,并且由于已知的安全漏洞,不适合生产使用。
SASL
ZooKeeper 的 SASL 配置使用 Java 系统属性java.security.auth.login.config提供。该属性必须设置为包含具有适当登录模块及其选项的 ZooKeeper 服务器的登录部分的 JAAS 配置文件。Kafka 经纪人必须配置具有用于与启用 SASL 的 ZooKeeper 服务器通信的 ZooKeeper 客户端的客户端登录部分。随后的Server部分提供了用于启用 Kerberos 身份验证的 ZooKeeper 服务器的 JAAS 配置:
Server {
com.sun.security.auth.module.Krb5LoginModule required
useKeyTab=true storeKey=true
keyTab="/path/to/zk.keytab"
principal="zookeeper/zk1.example.com@EXAMPLE.COM";
};
要在 ZooKeeper 服务器上启用 SASL 身份验证,需要在 ZooKeeper 配置文件中配置身份验证提供程序:
authProvider.sasl=org.apache.zookeeper.server.auth.SASLAuthenticationProvider
kerberos.removeHostFromPrincipal=true
kerberos.removeRealmFromPrincipal=true
经纪人主体
默认情况下,ZooKeeper 使用完整的 Kerberos 主体,例如,kafka/broker1.example.com@EXAMPLE.COM,作为客户端身份。当为 ZooKeeper 授权启用 ACL 时,应该配置 ZooKeeper 服务器为kerberos.removeHostFromPrincipal=true和kerberos.removeRealmFromPrincipal=true,以确保所有经纪人都具有相同的主体。
Kafka 经纪人必须配置为使用具有为经纪人提供客户端凭据的 JAAS 配置文件的 SASL 进行对 ZooKeeper 进行身份验证:
Client {
com.sun.security.auth.module.Krb5LoginModule required
useKeyTab=true storeKey=true
keyTab="/path/to/broker1.keytab"
principal="kafka/broker1.example.com@EXAMPLE.COM";
};
SSL
SSL 可以在任何使用 SASL 身份验证的 ZooKeeper 端点上启用。与 Kafka 一样,SSL 可以配置为启用客户端身份验证,但与 Kafka 不同,同时使用 SASL 和 SSL 客户端身份验证的连接使用两种协议进行身份验证,并将多个主体与连接关联。如果与连接关联的任何主体具有访问权限,则 ZooKeeper 授权程序将授予对资源的访问权限。
要为 ZooKeeper 服务器配置 SSL,应该配置具有服务器主机名或通配符主机的密钥存储。如果启用了客户端身份验证,则还需要一个用于验证客户端证书的信任存储:
secureClientPort=2181
serverCnxnFactory=org.apache.zookeeper.server.NettyServerCnxnFactory
authProvider.x509=org.apache.zookeeper.server.auth.X509AuthenticationProvider
ssl.keyStore.location=/path/to/zk.ks.p12
ssl.keyStore.password=zk-ks-password
ssl.keyStore.type=PKCS12
ssl.trustStore.location=/path/to/zk.ts.p12
ssl.trustStore.password=zk-ts-password
ssl.trustStore.type=PKCS12
要为 Kafka 连接到 ZooKeeper 配置 SSL,经纪人应该配置信任存储以验证 ZooKeeper 证书。如果启用了客户端身份验证,则还需要一个密钥存储:
zookeeper.ssl.client.enable=true
zookeeper.clientCnxnSocket=org.apache.zookeeper.ClientCnxnSocketNetty
zookeeper.ssl.keystore.location=/path/to/zkclient.ks.p12
zookeeper.ssl.keystore.password=zkclient-ks-password
zookeeper.ssl.keystore.type=PKCS12
zookeeper.ssl.truststore.location=/path/to/zkclient.ts.p12
zookeeper.ssl.truststore.password=zkclient-ts-password
zookeeper.ssl.truststore.type=PKCS12
授权
可以通过为路径设置 ACL 来为 ZooKeeper 节点启用授权。当经纪人配置为zookeeper.set.acl=true时,经纪人在创建节点时为 ZooKeeper 节点设置 ACL。默认情况下,元数据节点对所有人都是可读的,但只有经纪人才能修改。如果需要,可以为可能需要直接在 ZooKeeper 中更新元数据的内部管理员用户添加其他 ACL。默认情况下,诸如包含 SCRAM 凭据的节点等敏感路径不是默认情况下对所有人可读的。
保护平台
在前面的部分中,我们讨论了锁定对 Kafka 和 ZooKeeper 的访问权限的选项,以保护 Kafka 部署。生产系统的安全设计应该使用威胁模型,不仅解决单个组件的安全威胁,还要解决整个系统的安全威胁。威胁模型构建了系统的抽象,并确定潜在的威胁和相关的风险。一旦评估、记录并根据风险进行优先排序,必须为每个潜在的威胁实施缓解策略,以确保整个系统受到保护。在评估潜在威胁时,重要的是要考虑外部威胁以及内部威胁。对于存储个人身份信息(PII)或其他敏感数据的系统,还必须实施符合监管政策的额外措施。本章不涉及标准威胁建模技术的深入讨论。
除了使用安全认证、授权和加密保护 Kafka 中的数据和 ZooKeeper 中的元数据之外,还必须采取额外的步骤来确保平台的安全。防御措施可能包括网络防火墙解决方案以保护网络和加密以保护物理存储。包含用于认证的凭据的密钥库、信任库和 Kerberos 密钥表文件必须使用文件系统权限进行保护。对包含安全关键信息(如凭据)的配置文件的访问必须受到限制。由于即使访问受限,明文存储在配置文件中的密码也是不安全的,因此 Kafka 支持将密码外部化存储在安全存储中。
密码保护
可以为 Kafka 经纪人和客户端配置可定制的配置提供程序,以从安全的第三方密码存储中检索密码。密码也可以以加密形式存储在配置文件中,使用自定义配置提供程序执行解密。
接下来的自定义配置提供程序使用工具gpg来解密存储在文件中的经纪人或客户端属性:
publicclassGpgProviderimplementsConfigProvider{@Overridepublicvoidconfigure(Map<String,?>configs){}@OverridepublicConfigDataget(Stringpath){try{Stringpassphrase=System.getenv("PASSPHRASE");①Stringdata=Shell.execCommand(②"gpg","--decrypt","--passphrase",passphrase,path);Propertiesprops=newProperties();props.load(newStringReader(data));③Map<String,String>map=newHashMap<>();for(Stringname:props.stringPropertyNames())map.put(name,props.getProperty(name));returnnewConfigData(map);}catch(IOExceptione){thrownewRuntimeException(e);④}}@OverridepublicConfigDataget(Stringpath,Set<String>keys){⑤ConfigDataconfigData=get(path);Map<String,String>data=configData.data().entrySet().stream().filter(e->keys.contains(e.getKey())).collect(Collectors.toMap(Map.Entry::getKey,Map.Entry::getValue));returnnewConfigData(data,configData.ttl());}@Overridepublicvoidclose(){}}
①
我们将解码密码的密码短语提供给进程的环境变量PASSPHRASE。
②
我们使用gpg解密配置。返回值包含完整的解密配置集。
③
我们将data中的配置解析为 Java 属性。
④
如果遇到错误,我们会使用RuntimeException快速失败。
⑤
调用者可以从路径中请求密钥的子集;在这里,我们获取所有值并返回请求的子集。
您可能还记得在 SASL/PLAIN 部分,我们使用标准的 Kafka 配置类从外部文件加载凭据。现在我们可以使用gpg加密该文件:
gpg --symmetric --output credentials.props.gpg \
--passphrase "$PASSPHRASE" credentials.props
现在我们将间接配置和配置提供程序选项添加到原始属性文件中,以便 Kafka 客户端从加密文件中加载其凭据:
username=${gpg:/path/to/credentials.props.gpg:username}
password=${gpg:/path/to/credentials.props.gpg:password}
config.providers=gpg
config.providers.gpg.class=com.example.GpgProvider
还可以使用 Kafka 配置工具将敏感的经纪人配置选项加密存储在 ZooKeeper 中,而无需使用自定义提供程序。在启动经纪人之前,可以执行以下命令将经纪人在 ZooKeeper 中的 SSL 密钥库密码存储为加密形式。密码编码器秘钥必须在每个经纪人的配置文件中配置以解密该值:
$ bin/kafka-configs.sh --zookeeper localhost:2181 --alter \
--entity-type brokers --entity-name 0 --add-config \
'listener.name.external.ssl.keystore.password=server-ks-password,password.encoder.secret=encoder-secret'
总结
随着过去十年中数据泄露的频率和规模不断增加,网络攻击变得越来越复杂。除了隔离和解决泄露的巨大成本以及在应用安全修复之前的停机成本之外,数据泄露还可能导致监管处罚和品牌声誉的长期损害。在本章中,我们探讨了为保证 Kafka 中存储的数据的机密性、完整性和可用性而提供的广泛选择。
本章开始时的示例数据流,我们回顾了整个流程中可用的安全性方面的选项:
客户端真实性
当 Alice 的客户端与 Kafka 经纪人建立连接时,使用 SASL 或 SSL 进行客户端认证的侦听器可以验证连接是否真的来自 Alice 而不是冒名顶替者。可以配置重新认证以限制用户受到威胁的暴露。
服务器真实性
Alice 的客户端可以通过使用 SSL 进行主机名验证或使用具有相互认证的 SASL 机制(如 Kerberos 或 SCRAM)来验证其连接是否真实连接到经纪人。
数据隐私
使用 SSL 加密数据传输可以保护数据免受窃听者。磁盘或卷加密即使磁盘被盗也可以保护静态数据。对于高度敏感的数据,端到端加密提供了细粒度的数据访问控制,并确保云提供商和具有网络和磁盘物理访问权限的平台管理员无法访问数据。
数据完整性
SSL 可用于检测在不安全网络上的数据篡改。数字签名可以包含在消息中,以在使用端到端加密时验证完整性。
访问控制
Alice、Bob 甚至经纪人执行的每个操作都是使用可自定义的授权器进行授权的。Kafka 具有内置的授权器,可以使用 ACL 进行细粒度的访问控制。
可审计性
授权器日志和请求日志可用于跟踪操作和尝试的操作,用于审计和异常检测。
可用性
可以使用配额和配置选项的组合来管理连接,以保护经纪人免受拒绝服务攻击。可以使用 SSL、SASL 和 ACL 来保护 ZooKeeper,以确保确保 Kafka 经纪人的可用性所需的元数据是安全的。
在安全领域有很多选择,为每种情况选择合适的选项可能是一项艰巨的任务。我们审查了每种安全机制需要考虑的安全问题,以及可以采用的控制和政策,以限制潜在的攻击面。我们还审查了锁定 ZooKeeper 和平台其余部分所需的额外措施。Kafka 支持的标准安全技术以及与组织现有安全基础设施集成的各种扩展点,使您能够构建一致的安全解决方案,以保护整个平台。
第十二章:管理 Kafka
管理 Kafka 集群需要额外的工具来执行对主题、配置等的管理更改。Kafka 提供了几个命令行接口(CLI)实用程序,用于对集群进行管理更改。这些工具是以 Java 类实现的,并且提供了一组本地脚本来正确调用这些类。虽然这些工具提供了基本功能,但您可能会发现它们在更复杂的操作或在更大规模上使用时存在不足。本章将仅描述作为 Apache Kafka 开源项目一部分提供的基本工具。有关社区中开发的高级工具的更多信息,可以在Apache Kafka 网站上找到。
授权管理操作
虽然 Apache Kafka 实现了身份验证和授权来控制主题操作,但默认配置不限制这些工具的使用。这意味着这些 CLI 工具可以在不需要任何身份验证的情况下使用,这将允许执行诸如主题更改之类的操作,而无需进行安全检查或审计。始终确保对部署中的此工具的访问受到限制,仅限管理员才能防止未经授权的更改。
主题操作
kafka-topics.sh工具提供了对大多数主题操作的简单访问。它允许您在集群中创建、修改、删除和列出有关主题的信息。虽然通过此命令可能可以进行一些主题配置,但这些配置已被弃用,建议使用更健壮的方法使用kafka-config.sh工具进行配置更改。要使用kafka-topics.sh命令,必须通过--bootstrap-server选项提供集群连接字符串和端口。在接下来的示例中,集群连接字符串在 Kafka 集群中的一个主机上本地运行,并且我们将使用localhost:9092。
在本章中,所有工具都将位于目录/usr/local/kafka/bin/中。本节中的示例命令将假定您在此目录中,或者已将该目录添加到您的$PATH中。
检查版本
Kafka 的许多命令行工具对 Kafka 运行的版本有依赖,以正确运行。这包括一些命令可能会将数据存储在 ZooKeeper 中,而不是直接连接到经纪人本身。因此,重要的是确保您使用的工具版本与集群中经纪人的版本匹配。最安全的方法是在 Kafka 经纪人上运行工具,使用部署的版本。
创建新主题
通过--create命令创建新主题时,在集群中创建新主题需要几个必需的参数。使用此命令时必须提供这些参数,即使其中一些可能已经配置了经纪人级别的默认值。此时还可以使用--config选项进行其他参数和配置覆盖,但这将在本章后面进行介绍。以下是三个必需参数的列表:
--topic
您希望创建的主题名称。
--replication-factor
主题在集群中维护的副本数量。
--partitions
为主题创建的分区数。
良好的主题命名实践
主题名称可以包含字母数字字符、下划线、破折号和句点;但不建议在主题名称中使用句点。Kafka 内部度量标准将句点字符转换为下划线字符(例如,“topic.1”在度量计算中变为“topic_1”),这可能导致主题名称冲突。
另一个建议是避免使用双下划线来开始你的主题名称。按照惯例,Kafka 操作内部的主题使用双下划线命名约定创建(比如__consumer_offsets主题,用于跟踪消费者组偏移存储)。因此,不建议使用以双下划线命名约定开头的主题名称,以防混淆。
创建一个新主题很简单。运行kafka-topics.sh如下:
# kafka-topics.sh --bootstrap-server <connection-string>:<port> --create --topic <string>
--replication-factor <integer> --partitions <integer>
#
该命令将导致集群创建一个具有指定名称和分区数的主题。对于每个分区,集群将适当地选择指定数量的副本。这意味着如果集群设置为机架感知副本分配,每个分区的副本将位于不同的机架上。如果不希望使用机架感知分配,指定--disable-rack-aware命令行参数。
例如,创建一个名为“my-topic”的主题,其中每个有两个副本的八个分区:
# kafka-topics.sh --bootstrap-server localhost:9092 --create
--topic my-topic --replication-factor 2 --partitions 8
Created topic "my-topic".
#
正确使用 if-exists 和 if-not-exists 参数
在自动化中使用kafka-topics.sh时,创建新主题时可能希望使用--if-not-exists参数,如果主题已经存在,则不返回错误。
虽然--alter命令提供了一个--if-exists参数,但不建议使用它。使用这个参数会导致命令在被更改的主题不存在时不返回错误。这可能掩盖了应该创建但不存在的主题的问题。
列出集群中的所有主题
--list命令列出集群中的所有主题。列表格式化为每行一个主题,没有特定顺序,这对于生成完整的主题列表很有用。
以下是使用--list选项列出集群中所有主题的示例:
# kafka-topics.sh --bootstrap-server localhost:9092 --list
__consumer_offsets
my-topic
other-topic
您会注意到内部的__consumer_offsets主题在这里列出。使用--exclude-internal运行命令将从列表中删除所有以前提到的双下划线开头的主题,这可能是有益的。
描述主题详细信息
还可以获取集群中一个或多个主题的详细信息。输出包括分区计数、主题配置覆盖,以及每个分区及其副本分配的列表。通过向命令提供--topic参数,可以将其限制为单个主题。
例如,在集群中描述我们最近创建的“my-topic”:
# kafka-topics.sh --boostrap-server localhost:9092 --describe --topic my-topic
Topic: my-topic PartitionCount: 8 ReplicationFactor: 2 Configs: segment.bytes=1073741824
Topic: my-topic Partition: 0 Leader: 1 Replicas: 1,0 Isr: 1,0
Topic: my-topic Partition: 1 Leader: 0 Replicas: 0,1 Isr: 0,1
Topic: my-topic Partition: 2 Leader: 1 Replicas: 1,0 Isr: 1,0
Topic: my-topic Partition: 3 Leader: 0 Replicas: 0,1 Isr: 0,1
Topic: my-topic Partition: 4 Leader: 1 Replicas: 1,0 Isr: 1,0
Topic: my-topic Partition: 5 Leader: 0 Replicas: 0,1 Isr: 0,1
Topic: my-topic Partition: 6 Leader: 1 Replicas: 1,0 Isr: 1,0
Topic: my-topic Partition: 7 Leader: 0 Replicas: 0,1 Isr: 0,1
#
--describe命令还有几个有用的选项用于过滤输出。这些对于更容易诊断集群问题很有帮助。对于这些命令,我们通常不指定--topic参数,因为意图是找到所有符合条件的集群中的主题或分区。这些选项不适用于list命令。以下是一些有用的配对列表:
--topics-with-overrides
这将仅描述与集群默认配置不同的主题。
--exclude-internal
前面提到的命令将从列表中删除所有以双下划线命名约定开头的主题。
以下命令用于帮助查找可能存在问题的主题分区:
--under-replicated-partitions
这显示了所有副本中有一个或多个与领导者不同步的所有分区。这不一定是坏事,因为集群维护、部署和重新平衡会导致副本不足的分区(或 URP),但需要注意。
--at-min-isr-partitions
这显示了所有副本数(包括领导者)与最小同步副本(ISRs)设置完全匹配的所有分区。这些主题仍然可供生产者或消费者客户端使用,但所有冗余已经丢失,它们有可能变得不可用。
--under-min-isr-partitions
这显示了所有 ISR 数量低于成功生产操作所需的最小配置的所有分区。这些分区实际上处于只读模式,无法进行生产操作。
--unavailable-partitions
这显示了所有没有领导者的主题分区。这是一个严重的情况,表明该分区已脱机,对生产者或消费者客户端不可用。
以下是一个查找处于最小 ISR 设置的主题的示例。在此示例中,主题配置为最小 ISR 为 1,并且副本因子(RF)为 2。主机 0 在线,主机 1 已停机进行维护:
# kafka-topics.sh --bootstrap-server localhost:9092 --describe --at-min-isr-partitions
Topic: my-topic Partition: 0 Leader: 0 Replicas: 0,1 Isr: 0
Topic: my-topic Partition: 1 Leader: 0 Replicas: 0,1 Isr: 0
Topic: my-topic Partition: 2 Leader: 0 Replicas: 0,1 Isr: 0
Topic: my-topic Partition: 3 Leader: 0 Replicas: 0,1 Isr: 0
Topic: my-topic Partition: 4 Leader: 0 Replicas: 0,1 Isr: 0
Topic: my-topic Partition: 5 Leader: 0 Replicas: 0,1 Isr: 0
Topic: my-topic Partition: 6 Leader: 0 Replicas: 0,1 Isr: 0
Topic: my-topic Partition: 7 Leader: 0 Replicas: 0,1 Isr: 0
#
添加分区
有时需要增加主题的分区数。分区是主题在集群中扩展和复制的方式。增加分区计数的最常见原因是通过减少单个分区的吞吐量来横向扩展主题跨多个经纪人。如果消费者需要扩展以在单个消费者组中运行更多副本,则还可以增加主题。因为一个分区只能被消费者组中的一个成员消费。
以下是一个示例,使用--alter命令将名为“my-topic”的主题的分区数增加到 16,然后验证它是否起作用:
# kafka-topics.sh --bootstrap-server localhost:9092
--alter --topic my-topic --partitions 16
# kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic my-topic
Topic: my-topic PartitionCount: 16 ReplicationFactor: 2 Configs: segment.bytes=1073741824
Topic: my-topic Partition: 0 Leader: 1 Replicas: 1,0 Isr: 1,0
Topic: my-topic Partition: 1 Leader: 0 Replicas: 0,1 Isr: 0,1
Topic: my-topic Partition: 2 Leader: 1 Replicas: 1,0 Isr: 1,0
Topic: my-topic Partition: 3 Leader: 0 Replicas: 0,1 Isr: 0,1
Topic: my-topic Partition: 4 Leader: 1 Replicas: 1,0 Isr: 1,0
Topic: my-topic Partition: 5 Leader: 0 Replicas: 0,1 Isr: 0,1
Topic: my-topic Partition: 6 Leader: 1 Replicas: 1,0 Isr: 1,0
Topic: my-topic Partition: 7 Leader: 0 Replicas: 0,1 Isr: 0,1
Topic: my-topic Partition: 8 Leader: 1 Replicas: 1,0 Isr: 1,0
Topic: my-topic Partition: 9 Leader: 0 Replicas: 0,1 Isr: 0,1
Topic: my-topic Partition: 10 Leader: 1 Replicas: 1,0 Isr: 1,0
Topic: my-topic Partition: 11 Leader: 0 Replicas: 0,1 Isr: 0,1
Topic: my-topic Partition: 12 Leader: 1 Replicas: 1,0 Isr: 1,0
Topic: my-topic Partition: 13 Leader: 0 Replicas: 0,1 Isr: 0,1
Topic: my-topic Partition: 14 Leader: 1 Replicas: 1,0 Isr: 1,0
Topic: my-topic Partition: 15 Leader: 0 Replicas: 0,1 Isr: 0,1
#
调整带键主题
使用带有键消息的主题可能非常难以从消费者的角度添加分区。这是因为当分区数更改时,键到分区的映射将发生变化。因此,建议在创建主题时为包含键消息的主题设置分区数一次,并避免调整主题的大小。
减少分区
不可能减少主题的分区数。从主题中删除一个分区也会导致该主题中的部分数据被删除,这在客户端的角度来看是不一致的。此外,尝试将数据重新分配到剩余的分区将会很困难,并导致消息的顺序混乱。如果需要减少分区数,建议删除主题并重新创建它,或者(如果无法删除)创建现有主题的新版本,并将所有生产流量转移到新主题(例如“my-topic-v2”)。
删除主题
即使没有消息的主题也会使用磁盘空间、打开文件句柄和内存等集群资源。控制器还必须保留其必须了解的垃圾元数据,这可能会在大规模时影响性能。如果不再需要主题,则可以删除以释放这些资源。要执行此操作,集群中的经纪人必须配置delete.topic.enable选项设置为true。如果设置为false,则将忽略删除主题的请求,并且不会成功。
主题删除是一个异步操作。这意味着运行此命令将标记一个主题以进行删除,但删除可能不会立即发生,这取决于所需的数据量和清理。控制器将尽快通知经纪人有关即将删除的信息(在现有控制器任务完成后),然后经纪人将使主题的元数据无效并从磁盘中删除文件。强烈建议操作员不要一次删除一个或两个以上的主题,并且在删除其他主题之前给予充分的时间来完成,因为控制器执行这些操作的方式存在限制。在本书示例中显示的小集群中,主题删除几乎会立即发生,但在较大的集群中可能需要更长的时间。
数据丢失
删除主题也将删除其所有消息。这是一个不可逆的操作。请确保谨慎执行。
以下是使用--delete参数删除名为“my-topic”的主题的示例。根据 Kafka 的版本,将会有一条说明,让您知道如果没有设置其他配置,则该参数将不起作用:
# kafka-topics.sh --bootstrap-server localhost:9092
--delete --topic my-topic
Note: This will have no impact if delete.topic.enable is not set
to true.
#
您会注意到没有明显的反馈表明主题删除是否成功完成。通过运行--list或--describe选项来验证删除是否成功,以查看主题是否不再存在于集群中。
消费者组
消费者组是协调的 Kafka 消费者组,从主题或单个主题的多个分区中消费。kafka-consumer-groups.sh工具有助于管理和了解从集群中的主题中消费的消费者组。它可用于列出消费者组,描述特定组,删除消费者组或特定组信息,或重置消费者组偏移信息。
基于 ZooKeeper 的消费者组
在较旧版本的 Kafka 中,可以在 ZooKeeper 中管理和维护消费者组。此行为在 0.11.0.*版本及更高版本中已弃用,不再使用旧的消费者组。提供的某些脚本的某些版本可能仍然显示已弃用的--zookeeper连接字符串命令,但不建议使用它们,除非您的旧环境中有一些消费者组尚未升级到 Kafka 的较新版本。
列出和描述组
要列出消费者组,请使用--bootstrap-server和--list参数。使用kafka-consumer-groups.sh脚本的特定消费者将显示为消费者列表中的console-consumer-*<generated_id>*:
# kafka-consumer-groups.sh --bootstrap-server localhost:9092 --list
console-consumer-95554
console-consumer-9581
my-consumer
#
对于列出的任何组,可以通过将--list参数更改为--describe并添加--group参数来获取更多详细信息。这将列出该组正在从中消费的所有主题和分区,以及其他信息,例如每个主题分区的偏移量。表 12-1 对输出中提供的所有字段进行了全面描述。
例如,获取名为“my-consumer”的特定组的消费者组详细信息:
# kafka-consumer-groups.sh --bootstrap-server localhost:9092
--describe --group my-consumer
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER-ID HOST CLIENT-ID
my-consumer my-topic 0 2 4 2 consumer-1-029af89c-873c-4751-a720-cefd41a669d6 /127.0.0.1 consumer-1
my-consumer my-topic 1 2 3 1 consumer-1-029af89c-873c-4751-a720-cefd41a669d6 /127.0.0.1 consumer-1
my-consumer my-topic 2 2 3 1 consumer-2-42c1abd4-e3b2-425d-a8bb-e1ea49b29bb2 /127.0.0.1 consumer-2
#
表 12-1。为名为“my-consumer”的组提供的字段
| 字段 | 描述 |
|---|---|
| GROUP | 消费者组的名称。 |
| TOPIC | 正在消费的主题的名称。
| PARTITION | 正在消费的分区的 ID 号。
| CURRENT-OFFSET | 消费者组为此主题分区要消费的下一个偏移量。这是消费者在分区内的位置。
| LOG-END-OFFSET | 主题分区的经纪人的当前高水位偏移。这是下一条消息要被生产到这个分区的偏移量。
| LAG | 消费者当前偏移和经纪人日志结束偏移之间的差异,用于此主题分区。
| CONSUMER-ID | 基于提供的客户端 ID 生成的唯一消费者 ID。
| HOST | 消费者组正在读取的主机的地址。
| CLIENT-ID | 客户端提供的标识客户端的字符串。
删除组
可以使用--delete参数执行消费者组的删除。这将删除整个组,包括该组正在消费的所有主题的所有存储偏移量。要执行此操作,组中的所有消费者都应该关闭,因为消费者组不应该有任何活跃成员。如果尝试删除一个不为空的组,将抛出一个错误,指出“该组不为空”,并且不会发生任何事情。还可以使用相同的命令通过添加--topic参数并指定要删除的主题偏移量来删除组正在消费的单个主题的偏移量,而不删除整个组。
以下是删除名为“my-consumer”的整个消费者组的示例:
# kafka-consumer-groups.sh --bootstrap-server localhost:9092 --delete --group my-consumer
Deletion of requested consumer groups ('my-consumer') was successful.
#
偏移管理
除了显示和删除消费者组的偏移量之外,还可以批量检索偏移量并存储新的偏移量。当消费者出现需要重新读取消息的问题时,或者需要推进偏移量并跳过消费者无法处理的消息时(例如,如果有一条格式错误的消息),这对于重置消费者的偏移量非常有用。
导出偏移量
要将消费者组的偏移量导出到 CSV 文件中,请使用--reset-offsets参数和--dry-run选项。这将允许我们创建当前偏移量的导出文件格式,以便以后可以重用导入或回滚偏移量。CSV 格式的导出将采用以下配置:
在不使用--dry-run选项运行相同命令将完全重置偏移量,因此要小心。
以下是导出主题“my-topic”被消费者组“my-consumer”消费的偏移量的示例,导出到名为offsets.csv的文件中:
# kafka-consumer-groups.sh --bootstrap-server localhost:9092
--export --group my-consumer --topic my-topic
--reset-offsets --to-current --dry-run > offsets.csv
# cat offsets.csv
my-topic,0,8905
my-topic,1,8915
my-topic,2,9845
my-topic,3,8072
my-topic,4,8008
my-topic,5,8319
my-topic,6,8102
my-topic,7,12739
#
导入偏移量
导入偏移量工具是导出的相反。它接受导出上一节中偏移量生成的文件,并使用它来设置消费者组的当前偏移量。一种常见做法是导出消费者组的当前偏移量,复制文件(以便保留备份),并编辑副本以替换偏移量为所需值。
首先停止消费者
在执行此步骤之前,重要的是停止组中的所有消费者。如果在消费者组处于活动状态时编写,它们将不会读取新的偏移量。消费者将只覆盖导入的偏移量。
在以下示例中,我们从上一个示例中创建的名为offsets.csv的文件中导入名为“my-consumer”的消费者组的偏移量:
# kafka-consumer-groups.sh --bootstrap-server localhost:9092
--reset-offsets --group my-consumer
--from-file offsets.csv --execute
TOPIC PARTITION NEW-OFFSET
my-topic 0 8905
my-topic 1 8915
my-topic 2 9845
my-topic 3 8072
my-topic 4 8008
my-topic 5 8319
my-topic 6 8102
my-topic 7 12739
#
动态配置更改
有大量的配置适用于主题、客户端、代理等,可以在运行时动态更新,而无需关闭或重新部署集群。kafka-configs.sh是修改这些配置的主要工具。目前有四种主要的动态配置更改的实体类型,即entity-types:topics、brokers、users和clients。对于每种实体类型,都有可以覆盖的特定配置。随着每个 Kafka 版本的发布,不断添加新的动态配置,因此最好确保您使用与运行的 Kafka 版本相匹配的工具版本。为了通过自动化方便地设置这些配置,可以使用--add-config-file参数,并使用预先格式化的文件来管理和更新所有要管理和更新的配置。
覆盖主题配置默认值
有许多配置是为主题默认设置的,这些配置在静态代理配置文件中定义(例如,保留时间策略)。通过动态配置,我们可以覆盖集群级别的默认值,以适应单个集群中不同用例的不同主题。表 12-2 显示了可以动态更改的主题的有效配置键。
更改主题配置的命令格式如下:
kafka-configs.sh --bootstrap-server localhost:9092
--alter --entity-type topics --entity-name <topic-name>
--add-config <key>=<value>[,<key>=<value>...]
以下是将名为“my-topic”的主题保留设置为 1 小时(3,600,000 毫秒)的示例:
# kafka-configs.sh --bootstrap-server localhost:9092
--alter --entity-type topics --entity-name my-topic
--add-config retention.ms=3600000
Updated config for topic: "my-topic".
#
表 12-2. 主题的有效键
| 配置键 | 描述 |
|---|---|
cleanup.policy |
如果设置为compact,则此主题中的消息将被丢弃,只保留具有给定键的最新消息(日志压缩)。 |
compression.type |
代理在将此主题的消息批次写入磁盘时使用的压缩类型。 |
delete.retention.ms |
以毫秒为单位,删除的墓碑将保留在此主题中的时间。仅适用于日志压缩主题。 |
file.delete.delay.ms |
从磁盘中删除此主题的日志段和索引之前等待的时间,以毫秒为单位。 |
flush.messages |
在强制将此主题的消息刷新到磁盘之前接收多少消息。 |
flush.ms |
强制将此主题的消息刷新到磁盘之前的时间,以毫秒为单位。 |
follower.replication.throttled.replicas |
应该由追随者限制日志复制的副本列表。 |
index.interval.bytes |
日志段索引中可以在消息之间产生多少字节。 |
leader.replication.throttled.replica |
领导者应该限制日志复制的副本列表。 |
max.compaction.lag.ms |
消息在日志中不符合压缩的最长时间限制。 |
max.message.bytes |
此主题单个消息的最大大小,以字节为单位。 |
message.downconversion.enable |
如果启用,允许将消息格式版本降级为上一个版本,但会带来一些开销。 |
message.format.version |
经纪人在将消息写入磁盘时将使用的消息格式版本。必须是有效的 API 版本号。 |
message.timestamp.difference.max.ms |
消息时间戳和经纪人时间戳之间的最大允许差异,以毫秒为单位。仅当message.timestamp.type设置为CreateTime时有效。 |
message.timestamp.type |
写入磁盘时要使用的时间戳。当前值为CreateTime表示客户端指定的时间戳,LogAppendTime表示经纪人将消息写入分区的时间。 |
min.cleanable.dirty.ratio |
日志压缩器尝试压缩此主题分区的频率,表示为未压缩日志段数与总日志段数的比率。仅适用于日志压缩主题。 |
min.compaction.lag.ms |
消息在日志中保持未压缩的最短时间。 |
min.insync.replicas |
必须同步的最小副本数,才能认为主题的分区可用。 |
preallocate |
如果设置为true,则在滚动新段时应预先分配此主题的日志段。 |
retention.bytes |
保留此主题的消息字节数。 |
retention.ms |
此主题的消息应保留的时间,以毫秒为单位。 |
segment.bytes |
应该写入分区中单个日志段的消息字节数。 |
segment.index.bytes |
单个日志段索引的最大大小,以字节为单位。 |
segment.jitter.ms |
在滚动日志段时,随机添加到segment.ms的最大毫秒数。 |
segment.ms |
每个分区的日志段应该旋转的频率,以毫秒为单位。 |
unclean.leader.election.enable |
如果设置为false,则不允许为此主题进行不干净的领导者选举。 |
覆盖客户端和用户配置默认值
对于 Kafka 客户端和用户,只有少数配置可以被覆盖,它们基本上都是配额的类型。更常见的两个要更改的配置是允许每个经纪人的特定客户端 ID 的生产者和消费者的字节/秒速率。可以为用户和客户端修改的共享配置的完整列表显示在表 12-3 中。
在负载不平衡的集群中不均匀的限流行为
因为限流是基于每个代理的基础,所以跨集群的分区领导权的平衡变得尤为重要,以便正确执行这一点。如果您在集群中有 5 个代理,并且为客户端指定了 10 MBps 的生产者配额,那么该客户端将被允许同时在每个代理上生产 10 MBps,总共 50 MBps,假设所有 5 个主机上的领导权是平衡的。但是,如果每个分区的领导权都在代理 1 上,那么同一个生产者只能生产最大 10 MBps。
表 12-3。客户端的配置(键)
| 配置键 | 描述 |
|---|---|
consumer_bytes_rate |
允许单个客户端 ID 在一秒内从单个代理消费的字节数。 |
producer_bytes_rate |
允许单个客户端 ID 在一秒内向单个代理生产的字节数。 |
controller_mutations_rate |
接受创建主题请求、创建分区请求和删除主题请求的变异速率。速率是由创建或删除的分区数量累积而成。 |
request_percentage |
用户或客户端请求在配额窗口内的百分比(总数为(num.io.threads + num.network.threads)× 100%)。 |
客户端 ID 与消费者组
客户端 ID 不一定与消费者组名称相同。消费者可以设置自己的客户端 ID,您可能有许多消费者在不同的组中指定相同的客户端 ID。为每个消费者组设置唯一标识该组的客户端 ID 被认为是最佳实践。这允许单个消费者组共享配额,并且更容易在日志中识别负责请求的组。
可以一次指定兼容的用户和客户端配置更改,以适用于两者的兼容配置。以下是一种在一个配置步骤中更改用户和客户端的控制器变异速率的命令示例:
# kafka-configs.sh --bootstrap-server localhost:9092
--alter --add-config "controller_mutations_rate=10"
--entity-type clients --entity-name <client ID>
--entity-type users --entity-name <user ID>
#
覆盖代理配置默认值
主题和集群级别的配置主要在集群配置文件中静态配置,但是有大量的配置可以在运行时进行覆盖,而无需重新部署 Kafka。超过 80 个覆盖可以使用kafka-configs.sh进行更改。因此,我们不会在本书中列出所有这些配置,但可以通过--help命令进行引用,或者在开源文档中找到。特别值得指出的一些重要配置是:
min.insync.replicas
调整需要确认写入的最小副本数,以使生产请求在生产者将 acks 设置为all(或–1)时成功。
unclean.leader.election.enable
即使导致数据丢失,也允许副本被选举为领导者。当允许有一些有损数据或者在短时间内无法避免不可恢复的数据丢失时,这是有用的。
max.connections
任何时候允许连接到代理的最大连接数。我们还可以使用max.connections.per.ip和max.connections.per.ip.overrides进行更精细的限制。
描述配置覆盖
所有配置覆盖都可以使用kafka-config.sh工具列出。这将允许您检查主题、代理或客户端的具体配置。与其他工具类似,这是使用--describe命令完成的。
在以下示例中,我们可以获取名为“my-topic”的主题的所有配置覆盖,我们观察到只有保留时间:
# kafka-configs.sh --bootstrap-server localhost:9092
--describe --entity-type topics --entity-name my-topic
Configs for topics:my-topic are
retention.ms=3600000
#
仅主题覆盖
配置描述仅显示覆盖项-不包括集群默认配置。没有办法动态发现经纪人自己的配置。这意味着在使用此工具自动发现主题或客户端设置时,用户必须单独了解集群默认配置。
删除配置覆盖
动态配置可以完全删除,这将导致实体恢复到集群默认设置。要删除配置覆盖,使用--alter命令以及--delete-config参数。
例如,删除名为“my-topic”的主题的retention.ms的配置覆盖:
# kafka-configs.sh --bootstrap-server localhost:9092
--alter --entity-type topics --entity-name my-topic
--delete-config retention.ms
Updated config for topic: "my-topic".
#
生产和消费
在使用 Kafka 时,通常需要手动生产或消费一些样本消息,以验证应用程序的运行情况。提供了两个实用程序来帮助处理这个问题,kafka-console-consumer.sh和kafka-console-producer.sh,这在第二章中简要提到过,用于验证我们的安装。这些工具是主要 Java 客户端库的包装器,允许您与 Kafka 主题进行交互,而无需编写整个应用程序来完成。
将输出导入另一个应用程序
虽然可以编写包装控制台消费者或生产者的应用程序(例如,消费消息并将其导入另一个应用程序进行处理),但这种类型的应用程序非常脆弱,应该避免使用。很难与控制台消费者进行交互而不丢失消息。同样,控制台生产者不允许使用所有功能,并且正确发送字节很棘手。最好直接使用 Java 客户端库或使用 Kafka 协议的其他语言的第三方客户端库。
控制台生产者
kakfa-console-producer.sh工具可用于将消息写入集群中的 Kafka 主题。默认情况下,消息每行读取一条,键和值之间用制表符分隔(如果没有制表符,则键为 null)。与控制台消费者一样,生产者使用默认序列化器读取和生成原始字节(即DefaultEncoder)。
控制台生产者要求提供至少两个参数,以知道要连接到哪个 Kafka 集群以及在该集群中要生产到哪个主题。第一个是我们习惯使用的--bootstrap-server连接字符串。在完成生产后,发送文件结束(EOF)字符以关闭客户端。在大多数常见的终端中,可以使用 Control-D 来完成这个操作。
在这里,我们可以看到一个将四条消息发送到名为“my-topic”的主题的示例:
# kafka-console-producer.sh --bootstrap-server localhost:9092 --topic my-topic
>Message 1
>Test Message 2
>Test Message 3
>Message 4
>^D
#
使用生产者配置选项
可以将普通生产者配置选项传递给控制台生产者。有两种方法可以做到这一点,取决于您需要传递多少选项以及您喜欢如何传递。第一种方法是通过指定--producer.config *<config-file>*来提供生产者配置文件,其中*<config-file>*是包含配置选项的文件的完整路径。另一种方法是在命令行上指定选项,格式为--producer-property *<key>*=*<value>*,其中*<key>*是配置选项名称,*<value>*是要设置的值。这对于生产者选项(如linger.ms或batch.size)可能很有用。
混淆的命令行选项
--property命令行选项适用于控制台生产者和控制台消费者,但这不应与--producer-property或--consumer-property选项混淆。--property选项仅用于将配置传递给消息格式化程序,而不是客户端本身。
控制台生产者有许多命令行参数可用于与--producer-property选项一起使用,以调整其行为。一些更有用的选项包括:
--batch-size
指定如果不同步发送,则在单个批次中发送的消息数。
--timeout
如果生产者以异步模式运行,则在生产以避免在低产出主题上长时间等待之前等待批处理大小的最大时间。
--compression-codec <string>
指定在生成消息时要使用的压缩类型。有效类型可以是以下之一:none,gzip,snappy,zstd或lz4。默认值为gzip。
--sync
同步生成消息,等待每条消息在发送下一条消息之前得到确认。
行读取器选项
kafka.tools.ConsoleProducer$LineMessageReader类负责读取标准输入并创建生产者记录,还有一些有用的选项可以通过--property命令行选项传递给控制台生产者:
ignore.error
将parse.key设置为true时,设置为false以在不存在键分隔符时抛出异常。默认为true。
parse.key
将键始终设置为 null 时,设置为false。默认为true。
key.separator
指定在读取时在消息键和消息值之间使用的分隔符字符。默认为制表符。
更改行读取行为
您可以为 Kafka 提供自己的类,用于自定义读取行的方法。您创建的类必须扩展kafka.common.MessageReader,并将负责创建ProducerRecord。在命令行上使用--line-reader选项指定您的类,并确保包含您的类的 JAR 在类路径中。默认值为kafka.tools.ConsoleProducer$LineMessageReader。
在生成消息时,LineMessageReader将在第一个key.separator的实例上拆分输入。如果在此之后没有剩余字符,则消息的值将为空。如果行上不存在键分隔符字符,或者parse.key为 false,则键将为 null。
控制台消费者
kafka-console-consumer.sh工具提供了一种从 Kafka 集群中的一个或多个主题中消费消息的方法。消息以标准输出形式打印,以新行分隔。默认情况下,它输出消息中的原始字节,不包括键,没有格式(使用DefaultFormatter)。与生产者类似,需要一些基本选项才能开始:连接到集群的连接字符串,要从中消费的主题以及要消费的时间范围。
检查工具版本
使用与 Kafka 集群相同版本的消费者非常重要。较旧的控制台消费者可能通过与集群或 ZooKeeper 的不正确交互来损坏集群。
与其他命令一样,连接到集群的连接字符串将是--bootstrap-server选项;但是,您可以从两个选项中选择要消费的主题:
--topic
指定要从中消费的单个主题。
--whitelist
匹配要从中消费的所有主题的正则表达式(记得正确转义正则表达式,以免被 shell 错误处理)。
先前的选项中只能选择并使用一个。一旦控制台消费者启动,工具将继续尝试消费,直到给出 shell 转义命令(在这种情况下为 Ctrl-C)。以下是一个示例,消费与前缀my匹配的集群中的所有主题(在此示例中只有一个,“my-topic”):
# kafka-console-consumer.sh --bootstrap-server localhost:9092
--whitelist 'my.*' --from-beginning
Message 1
Test Message 2
Test Message 3
Message 4
^C
#
使用消费者配置选项
除了这些基本的命令行选项之外,还可以将普通的消费者配置选项传递给控制台消费者。与 kafka-console-producer.sh 工具类似,可以通过两种方式来实现,具体取决于需要传递多少选项以及您更喜欢的方式。第一种是通过指定 --consumer.config *<config-file>* 来提供一个消费者配置文件,其中 *<config-file>* 是包含配置选项的文件的完整路径。另一种方式是在命令行上指定选项,格式为 --consumer-property *<key>*=*<value>*,其中 *<key>* 是配置选项名称,*<value>* 是要设置的值。
还有一些常用的控制台消费者选项,对于了解和熟悉它们很有帮助:
--formatter *<classname>*
指定要用于解码消息的消息格式化类。默认为 kafka.tools.DefaultMessageFormatter。
--from-beginning
从最旧的偏移量开始消费指定主题中的消息。否则,消费将从最新的偏移量开始。
--max-messages <int>
在退出之前要消费的最大消息数。
--partition <int>
仅从具有给定 ID 的分区中消费。
--offset
要从中消费的偏移量 ID(如果提供)(<int>)。其他有效选项是 earliest,它将从开头消费,以及 latest,它将从最新的偏移量开始消费。
--skip-message-on-error
如果在处理时出现错误,则跳过消息而不是停止。用于调试。
消息格式化选项
除了默认值之外,还有三种可用的消息格式化器:
kafka.tools.LoggingMessageFormatter
使用记录器输出消息,而不是标准输出。消息以 INFO 级别打印,并包括时间戳、键和值。
kafka.tools.ChecksumMessageFormatter
仅打印消息校验和。
kafka.tools.NoOpMessageFormatter
消费消息但不输出它们。
以下是一个示例,消费与之前相同的消息,但使用 kafka.tools.ChecksumMessageFormatter 而不是默认值:
# kafka-console-consumer.sh --bootstrap-server localhost:9092
--whitelist 'my.*' --from-beginning
--formatter kafka.tools.ChecksumMessageFormatter
checksum:0
checksum:0
checksum:0
checksum:0
#
kafka.tools.DefaultMessageFormatter 还有一些有用的选项,可以使用 --property 命令行选项传递,如 表 12-4 中所示。
表 12-4。消息格式化属性
| 属性 | 描述 |
|---|---|
print.timestamp |
设置为 true 以显示每条消息的时间戳(如果可用)。 |
print.key |
设置为 true 以显示消息键以及值。 |
print.offset |
设置为 true 以显示消息偏移量以及值。 |
print.partition |
设置为 true 以显示消息所消费的主题分区。 |
key.separator |
指定在打印时在消息键和消息值之间使用的分隔符字符。 |
line.separator |
指定在消息之间使用的分隔符字符。 |
key.deserializer |
提供一个类名,用于在打印之前对消息键进行反序列化。 |
value.deserializer |
提供一个类名,用于在打印之前对消息值进行反序列化。 |
反序列化类必须实现 org.apache.kafka.common.serialization.Deserializer,控制台消费者将调用它们的 toString 方法来获取要显示的输出。通常,您会将这些反序列化器实现为一个 Java 类,然后通过在执行 kafka_console_consumer.sh 之前设置 CLASSPATH 环境变量来将其插入到控制台消费者的类路径中。
消费偏移量主题
有时候查看集群的消费者组提交了哪些偏移量是有用的。您可能想要查看特定组是否根本没有提交偏移量,或者偏移量提交的频率如何。这可以通过使用控制台消费者来消费名为__consumer_offsets的特殊内部主题来完成。所有消费者偏移量都被写入此主题作为消息。为了解码此主题中的消息,必须使用格式化类kafka.coordinator.group.GroupMetadataManager$OffsetsMessageFormatter。
将我们学到的所有知识整合在一起,以下是从__consumer_offsets主题中消费最早消息的示例:
# kafka-console-consumer.sh --bootstrap-server localhost:9092
--topic __consumer_offsets --from-beginning --max-messages 1
--formatter "kafka.coordinator.group.GroupMetadataManager\$OffsetsMessageFormatter"
--consumer-property exclude.internal.topics=false
[my-group-name,my-topic,0]::[OffsetMetadata[1,NO_METADATA]
CommitTime 1623034799990 ExpirationTime 1623639599990]
Processed a total of 1 messages
#
分区管理
默认的 Kafka 安装还包含一些用于管理分区的脚本。其中一个工具允许重新选举领导副本;另一个是一个用于将分区分配给经纪人的低级实用程序。这些工具共同可以帮助在需要更多手动干预来平衡 Kafka 经纪人集群中的消息流量的情况下进行操作。
首选副本选举
如第七章所述,为了可靠性,分区可以具有多个副本。重要的是要理解,在任何给定时间点,这些副本中只有一个可以成为分区的领导者,并且所有的生产和消费操作都发生在该经纪人上。保持分区的副本在哪个经纪人上拥有领导权的平衡对于确保负载通过整个 Kafka 集群的分布是必要的。
在 Kafka 中,领导者被定义为副本列表中的第一个同步副本。但是,当经纪人停止或失去与集群其余部分的连接时,领导权将转移到另一个同步副本,并且原始副本不会自动恢复任何分区的领导权。如果未启用自动领导平衡,这可能会导致在整个集群上部署后出现极其低效的平衡。因此,建议确保启用此设置,或者使用其他开源工具(如 Cruise Control)来确保始终保持良好的平衡。
如果发现 Kafka 集群的平衡不佳,可以执行一个轻量级、通常不会产生影响的过程,称为首选领导者选举。这告诉集群控制器选择分区的理想领导者。客户端可以自动跟踪领导权的变化,因此它们将能够移动到领导权被转移的集群中的新经纪人。可以使用kafka-leader-election.sh实用程序手动触发此操作。这个工具的旧版本称为kafka-preferred-replica-election.sh也可用,但已被弃用,而新工具允许更多的自定义,比如指定我们是否需要“首选”或“不洁选举”类型。
作为一个例子,在集群中为所有主题启动首选领导者选举可以使用以下命令执行:
# kafka-leader-election.sh --bootstrap-server localhost:9092
--election-type PREFERRED --all-topic-partitions
#
也可以在特定分区或主题上启动选举。这可以通过使用--topic选项和--partition选项直接传入主题名称和分区来完成。还可以传入要选举的多个分区的列表。这可以通过配置一个我们称之为partitions.json的 JSON 文件来完成:
{
"partitions": [
{
"partition": 1,
"topic": "my-topic"
},
{
"partition": 2,
"topic": "foo"
}
]
}
在这个例子中,我们将使用名为partitions.json的文件启动首选副本选举,指定了一个分区列表:
# kafka-leader-election.sh --bootstrap-server localhost:9092
--election-type PREFERRED --path-to-json-file partitions.json
#
更改分区的副本
偶尔可能需要手动更改分区的副本分配。可能需要这样做的一些例子是:
-
经纪人的负载不均匀,自动领导者分配处理不正确。
-
如果经纪人被下线并且分区处于副本不足状态。
-
如果添加了新的经纪人,并且我们希望更快地平衡新的分区。
-
你想要调整主题的复制因子。
kafka-reassign-partitions.sh可用于执行此操作。这是一个多步过程,用于生成移动集并执行提供的移动集提议。首先,我们要使用经纪人列表和主题列表生成一组移动的提议。这将需要生成一个 JSON 文件,其中包含要提供的主题列表。下一步执行先前提议生成的移动。最后,该工具可以与生成的列表一起使用,以跟踪和验证分区重新分配的进度或完成情况。
让我们假设一个场景,你有一个由四个经纪人组成的 Kafka 集群。最近,你添加了两个新的经纪人,总数增加到了六个,你想把两个主题移动到第五和第六个经纪人上。
要生成一组分区移动,首先必须创建一个包含列出主题的 JSON 对象的文件。JSON 对象的格式如下(版本号目前始终为 1):
{
"topics": [
{
"topic": "foo1"
},
{
"topic": "foo2"
}
],
"version": 1
}
一旦我们定义了 JSON 文件,我们可以使用它来生成一组分区移动,将文件topics.json中列出的主题移动到 ID 为 5 和 6 的经纪人:
# kafka-reassign-partitions.sh --bootstrap-server localhost:9092
--topics-to-move-json-file topics.json
--broker-list 5,6 --generate
{"version":1,
"partitions":[{"topic":"foo1","partition":2,"replicas":[1,2]},
{"topic":"foo1","partition":0,"replicas":[3,4]},
{"topic":"foo2","partition":2,"replicas":[1,2]},
{"topic":"foo2","partition":0,"replicas":[3,4]},
{"topic":"foo1","partition":1,"replicas":[2,3]},
{"topic":"foo2","partition":1,"replicas":[2,3]}]
}
Proposed partition reassignment configuration
{"version":1,
"partitions":[{"topic":"foo1","partition":2,"replicas":[5,6]},
{"topic":"foo1","partition":0,"replicas":[5,6]},
{"topic":"foo2","partition":2,"replicas":[5,6]},
{"topic":"foo2","partition":0,"replicas":[5,6]},
{"topic":"foo1","partition":1,"replicas":[5,6]},
{"topic":"foo2","partition":1,"replicas":[5,6]}]
}
#
这里提出的输出格式正确,我们可以保存两个新的 JSON 文件,我们将它们称为revert-reassignment.json和expand-cluster-reassignment.json。第一个文件可用于将分区移动回原始位置,如果有必要进行回滚。第二个文件可用于下一步,因为这只是一个提议,尚未执行任何操作。你会注意到输出中领导权的平衡不够好,因为提议将导致所有领导权移动到经纪人 5。我们现在将忽略这一点,并假设集群自动领导权平衡已启用,这将有助于稍后进行分发。值得注意的是,如果你确切地知道要将分区移动到哪里,并且手动编写 JSON 来移动分区,第一步可以跳过。
要执行文件expand-cluster-reassignment.json中提出的分区重新分配,运行以下命令:
# kafka-reassign-partitions.sh --bootstrap-server localhost:9092
--reassignment-json-file expand-cluster-reassignment.json
--execute
Current partition replica assignment
{"version":1,
"partitions":[{"topic":"foo1","partition":2,"replicas":[1,2]},
{"topic":"foo1","partition":0,"replicas":[3,4]},
{"topic":"foo2","partition":2,"replicas":[1,2]},
{"topic":"foo2","partition":0,"replicas":[3,4]},
{"topic":"foo1","partition":1,"replicas":[2,3]},
{"topic":"foo2","partition":1,"replicas":[2,3]}]
}
Save this to use as the --reassignment-json-file option during rollback
Successfully started reassignment of partitions
{"version":1,
"partitions":[{"topic":"foo1","partition":2,"replicas":[5,6]},
{"topic":"foo1","partition":0,"replicas":[5,6]},
{"topic":"foo2","partition":2,"replicas":[5,6]},
{"topic":"foo2","partition":0,"replicas":[5,6]},
{"topic":"foo1","partition":1,"replicas":[5,6]},
{"topic":"foo2","partition":1,"replicas":[5,6]}]
}
#
这将开始将指定分区副本重新分配到新的经纪人。输出与生成的提议验证相同。集群控制器通过将新副本添加到每个分区的副本列表来执行此重新分配操作,这将暂时增加这些主题的复制因子。然后,新副本将从当前领导者复制每个分区的所有现有消息。根据磁盘上分区的大小,这可能需要大量时间,因为数据通过网络复制到新副本。复制完成后,控制器通过将旧副本从副本列表中删除来将复制因子减少到原始大小,并删除旧副本。
以下是命令的其他一些有用功能,你可以利用:
--additional
这个选项允许你添加到现有的重新分配中,这样它们可以继续执行而不中断,并且无需等待原始移动完成才能开始新的批处理。
--disable-rack-aware
有时,由于机架感知设置,提案的最终状态可能是不可能的。如果有必要,可以使用此命令覆盖。
--throttle
这个值以字节/秒为单位。重新分配分区对集群的性能有很大影响,因为它们会导致内存页缓存的一致性发生变化,并使用网络和磁盘 I/O。限制分区移动可以有助于防止这个问题。这可以与--additional标记结合使用,以限制可能导致问题的已启动重新分配过程。
在重新分配副本时改善网络利用率
当从单个代理中删除许多分区时,例如如果该代理正在从集群中移除,首先从代理中删除所有领导可能是有用的。这可以通过手动将领导权移出代理来完成;然而,使用前面的工具来做这件事是很困难的。其他开源工具,如 Cruise Control,包括代理“降级”等功能,可以安全地将领导权从代理转移出去,这可能是最简单的方法。
但是,如果您没有访问这样的工具,简单地重新启动代理就足够了。当代理准备关闭时,该特定代理上的所有分区的领导权将移动到集群中的其他代理。这可以显著提高重新分配的性能,并减少对集群的影响,因为复制流量将分布到许多代理中。但是,如果在代理弹出后启用了自动领导重新分配,领导权可能会返回到该代理,因此暂时禁用此功能可能是有益的。
要检查分区移动的进度,可以使用该工具来验证重新分配的状态。这将显示当前正在进行的重新分配,已完成的重新分配以及(如果有错误)失败的重新分配。为此,您必须拥有在执行步骤中使用的 JSON 对象的文件。
以下是在运行前面的分区重新分配时使用--verify选项时的潜在结果的示例,文件名为expand-cluster-reassignment.json:
# kafka-reassign-partitions.sh --bootstrap-server localhost:9092
--reassignment-json-file expand-cluster-reassignment.json
--verify
Status of partition reassignment:
Status of partition reassignment:
Reassignment of partition [foo1,0] completed successfully
Reassignment of partition [foo1,1] is in progress
Reassignment of partition [foo1,2] is in progress
Reassignment of partition [foo2,0] completed successfully
Reassignment of partition [foo2,1] completed successfully
Reassignment of partition [foo2,2] completed successfully
#
更改复制因子
kafka-reassign-partitions.sh工具也可以用于增加或减少分区的复制因子(RF)。在分区使用错误的 RF 创建时,扩展集群时需要增加冗余性,或者为了节省成本而减少冗余性的情况下,可能需要这样做。一个明显的例子是,如果调整了集群 RF 默认设置,现有主题将不会自动增加。该工具可用于增加现有分区的 RF。
例如,如果我们想要将上一个示例中的主题“foo1”从 RF = 2 增加到 RF = 3,那么我们可以制作一个类似于之前使用的执行提案的 JSON,只是我们会在副本集中添加一个额外的代理 ID。例如,我们可以构造一个名为increase-foo1-RF.json的 JSON,在其中我们将代理 4 添加到我们已经拥有的 5,6 的现有集合中:
{
{"version":1,
"partitions":[{"topic":"foo1","partition":1,"replicas":[5,6,4]},
{"topic":"foo1","partition":2,"replicas":[5,6,4]},
{"topic":"foo1","partition":3,"replicas":[5,6,4]},
}
}
然后,我们将使用之前显示的命令来执行此提案。当完成时,我们可以使用--verify标志或使用kafka-topics.sh脚本来描述主题来验证 RF 是否已经增加:
# kafka-topics.sh --bootstrap-server localhost:9092 --topic foo1 --describe
Topic:foo1 PartitionCount:3 ReplicationFactor:3 Configs:
Topic: foo1 Partition: 0 Leader: 5 Replicas: 5,6,4 Isr: 5,6,4
Topic: foo1 Partition: 1 Leader: 5 Replicas: 5,6,4 Isr: 5,6,4
Topic: foo1 Partition: 2 Leader: 5 Replicas: 5,6,4 Isr: 5,6,4
#
取消副本重新分配
过去取消副本重新分配是一个危险的过程,需要通过删除/admin/reassign_partitions znode 来不安全地手动操作 ZooKeeper 节点(或 znode)。幸运的是,现在不再是这种情况。kafka-reassign-partitions.sh脚本(以及它作为包装器的 AdminClient)现在支持--cancel选项,该选项将取消正在进行的集群中的活动重新分配。在停止正在进行的分区移动时,--cancel命令旨在将副本集恢复到重新分配启动之前的状态。因此,如果从死掉的代理或负载过重的代理中删除副本,可能会使集群处于不良状态。还不能保证恢复的副本集将与以前的顺序相同。
转储日志段
偶尔您可能需要读取消息的特定内容,也许是因为您的主题中出现了一个损坏的“毒丸”消息,您的消费者无法处理它。提供了kafka-dump-log.sh工具来解码分区的日志段。这将允许您查看单个消息,而无需消费和解码它们。该工具将作为参数接受一个逗号分隔的日志段文件列表,并可以打印出消息摘要信息或详细消息数据。
在此示例中,我们将从一个名为“my-topic”的示例主题中转储日志,其中只有四条消息。首先,我们将简单地解码名为00000000000000000000.log的日志段文件,并检索有关每条消息的基本元数据信息,而不实际打印消息内容。在我们的示例 Kafka 安装中,Kafka 数据目录设置为/tmp/kafka-logs。因此,我们用于查找日志段的目录将是/tmp/kafka-logs/
# kafka-dump-log.sh --files /tmp/kafka-logs/my-topic-0/00000000000000000000.log
Dumping /tmp/kafka-logs/my-topic-0/00000000000000000000.log
Starting offset: 0
baseOffset: 0 lastOffset: 0 count: 1 baseSequence: -1 lastSequence: -1
producerId: -1 producerEpoch: -1 partitionLeaderEpoch: 0
isTransactional: false isControl: false position: 0
CreateTime: 1623034799990 size: 77 magic: 2
compresscodec: NONE crc: 1773642166 isvalid: true
baseOffset: 1 lastOffset: 1 count: 1 baseSequence: -1 lastSequence: -1
producerId: -1 producerEpoch: -1 partitionLeaderEpoch: 0
isTransactional: false isControl: false position: 77
CreateTime: 1623034803631 size: 82 magic: 2
compresscodec: NONE crc: 1638234280 isvalid: true
baseOffset: 2 lastOffset: 2 count: 1 baseSequence: -1 lastSequence: -1
producerId: -1 producerEpoch: -1 partitionLeaderEpoch: 0
isTransactional: false isControl: false position: 159
CreateTime: 1623034808233 size: 82 magic: 2
compresscodec: NONE crc: 4143814684 isvalid: true
baseOffset: 3 lastOffset: 3 count: 1 baseSequence: -1 lastSequence: -1
producerId: -1 producerEpoch: -1 partitionLeaderEpoch: 0
isTransactional: false isControl: false position: 241
CreateTime: 1623034811837 size: 77 magic: 2
compresscodec: NONE crc: 3096928182 isvalid: true
#
在下一个示例中,我们添加了--print-data-log选项,这将为我们提供实际的有效载荷信息和更多内容:
# kafka-dump-log.sh --files /tmp/kafka-logs/my-topic-0/00000000000000000000.log --print-data-log
Dumping /tmp/kafka-logs/my-topic-0/00000000000000000000.log
Starting offset: 0
baseOffset: 0 lastOffset: 0 count: 1 baseSequence: -1 lastSequence: -1
producerId: -1 producerEpoch: -1 partitionLeaderEpoch: 0
isTransactional: false isControl: false position: 0
CreateTime: 1623034799990 size: 77 magic: 2
compresscodec: NONE crc: 1773642166 isvalid: true
| offset: 0 CreateTime: 1623034799990 keysize: -1 valuesize: 9
sequence: -1 headerKeys: [] payload: Message 1
baseOffset: 1 lastOffset: 1 count: 1 baseSequence: -1 lastSequence: -1
producerId: -1 producerEpoch: -1 partitionLeaderEpoch: 0
isTransactional: false isControl: false position: 77
CreateTime: 1623034803631 size: 82 magic: 2
compresscodec: NONE crc: 1638234280 isvalid: true
| offset: 1 CreateTime: 1623034803631 keysize: -1 valuesize: 14
sequence: -1 headerKeys: [] payload: Test Message 2
baseOffset: 2 lastOffset: 2 count: 1 baseSequence: -1 lastSequence: -1
producerId: -1 producerEpoch: -1 partitionLeaderEpoch: 0
isTransactional: false isControl: false position: 159
CreateTime: 1623034808233 size: 82 magic: 2
compresscodec: NONE crc: 4143814684 isvalid: true
| offset: 2 CreateTime: 1623034808233 keysize: -1 valuesize: 14
sequence: -1 headerKeys: [] payload: Test Message 3
baseOffset: 3 lastOffset: 3 count: 1 baseSequence: -1 lastSequence: -1
producerId: -1 producerEpoch: -1 partitionLeaderEpoch: 0
isTransactional: false isControl: false position: 241
CreateTime: 1623034811837 size: 77 magic: 2
compresscodec: NONE crc: 3096928182 isvalid: true
| offset: 3 CreateTime: 1623034811837 keysize: -1 valuesize: 9
sequence: -1 headerKeys: [] payload: Message 4
#
该工具还包含一些其他有用的选项,例如验证与日志段一起使用的索引文件。索引用于在日志段中查找消息,如果损坏,将导致消费中的错误。验证是在代理以不洁净状态启动时执行的(即,它没有正常停止),但也可以手动执行。有两个选项用于检查索引,取决于您想要进行多少检查。选项“--index-sanity-check”将仅检查索引是否处于可用状态,而“--verify-index-only”将检查索引中的不匹配项,而不打印出所有索引条目。另一个有用的选项“--value-decoder-class”允许通过传递解码器对序列化消息进行反序列化。
副本验证
分区复制类似于常规 Kafka 消费者客户端:跟随者代理从最旧的偏移开始复制,并定期将当前偏移检查点到磁盘。当复制停止并重新启动时,它将从上次检查点继续。以前复制的日志段可能会从代理中删除,在这种情况下,跟随者不会填补这些间隙。
要验证主题分区的副本在集群中是否相同,可以使用kafka-replica-verification.sh工具进行验证。此工具将从给定一组主题分区的所有副本中获取消息,检查所有消息是否存在于所有副本中,并打印出给定分区的最大滞后。此过程将在循环中持续运行,直到取消。为此,您必须提供一个显式的逗号分隔的代理列表以连接到。默认情况下,将验证所有主题;但是,您还可以提供一个正则表达式,以匹配您希望验证的主题。
注意:集群影响
副本验证工具将对您的集群产生与重新分配分区类似的影响,因为它必须从最旧的偏移读取所有消息以验证副本。此外,它会并行从分区的所有副本中读取,因此应谨慎使用。
例如,在 kafka 代理 1 和 2 上验证以my开头的主题的副本,其中包含“my-topic”的分区 0:
# kafka-replica-verification.sh --broker-list kafka.host1.domain.com:9092,kafka.host2.domain.com:9092
--topic-white-list 'my.*'
2021-06-07 03:28:21,829: verification process is started.
2021-06-07 03:28:51,949: max lag is 0 for partition my-topic-0 at offset 4 among 1 partitions
2021-06-07 03:29:22,039: max lag is 0 for partition my-topic-0 at offset 4 among 1 partitions
...
#
其他工具
Kafka 发行版中还包含了几个未在本书中深入介绍的工具,这些工具可以用于管理 Kafka 集群以满足特定用例。有关它们的更多信息可以在Apache Kafka 网站上找到:
客户端 ACL
提供了一个命令行工具kafka-acls.sh,用于与 Kafka 客户端的访问控制进行交互。这包括了完整的授权者属性功能,设置拒绝或允许原则,集群或主题级别的限制,ZooKeeper TLS 文件配置等等。
轻量级 MirrorMaker
提供了一个轻量级的kafka-mirror-maker.sh脚本用于数据镜像。关于复制的更深入的内容可以在第十章中找到。
测试工具
还有一些其他用于测试 Kafka 或帮助执行功能升级的脚本。kafka-broker-api-versions.sh有助于在从一个 Kafka 版本升级到另一个版本时轻松识别可用 API 元素的不同版本,并检查兼容性问题。还有生产者和消费者性能测试脚本。还有一些脚本用于帮助管理 ZooKeeper。还有trogdor.sh,这是一个旨在运行基准测试和其他工作负载以尝试对系统进行压力测试的测试框架。
不安全的操作
有一些管理任务在技术上是可能的,但除非在极端情况下,不应尝试执行。通常情况下,这是在诊断问题并且已经没有其他选择时,或者发现了需要临时解决的特定错误时。这些任务通常是未记录的,不受支持的,并对应用程序造成一定风险。
这里记录了一些常见的任务,以便在紧急情况下有可能进行恢复。在正常的集群操作下,不建议使用它们,并且在执行之前应仔细考虑。
危险:这里有危险
本节中的操作通常涉及直接使用存储在 ZooKeeper 中的集群元数据。这可能是非常危险的操作,因此除非另有说明,否则必须非常小心,不要直接修改 ZooKeeper 中的信息。
移动集群控制器
每个 Kafka 集群都有一个被指定为控制器的单个代理。控制器有一个特殊的线程负责监督集群操作以及正常的代理工作。通常情况下,控制器选举是通过短暂的 ZooKeeper znode 监视自动完成的。当控制器关闭或不可用时,其他代理会尽快自我提名,因为一旦控制器关闭,znode 就会被删除。
在偶尔的情况下,当排除集群或代理的故障时,可能有必要强制将控制器移动到另一个代理,而不关闭主机。一个这样的例子是当控制器遇到异常或其他问题导致其运行但不起作用时。在这些情况下移动控制器通常不会有很高的风险,但由于这不是正常的任务,不应经常执行。
要强制移动控制器,需要手动删除/admin/controller下的 ZooKeeper znode,这将导致当前控制器辞职,并且集群将随机选择一个新的控制器。目前在 Apache Kafka 中没有办法指定特定的代理作为控制器。
删除待删除的主题
在尝试删除 Kafka 中的主题时,ZooKeeper 节点会请求创建删除。一旦每个副本完成主题的删除并确认删除完成,znode 将被删除。在正常情况下,集群会非常快地执行此操作。然而,有时这个过程可能出现问题。以下是一些删除请求可能被卡住的情况:
-
请求者无法知道集群中是否启用了主题删除,并且可以请求从禁用删除的集群中删除主题。
-
有一个非常大的主题要求被删除,但在处理请求之前,一个或多个副本集由于硬件故障而下线,删除无法完成,因为控制器无法确认删除是否成功完成。
要“解除”主题删除,首先删除/admin/delete_topic/
手动删除主题
如果您运行的是禁用删除主题的集群,或者发现自己需要在正常操作流程之外删除一些主题,那么可以手动从集群中删除它们。但是,这需要完全关闭集群中的所有经纪人,并且不能在集群中的任何经纪人运行时执行。
首先关闭经纪人
在集群在线时修改 ZooKeeper 中的集群元数据是非常危险的操作,可能会使集群处于不稳定状态。永远不要在集群在线时尝试删除或修改 ZooKeeper 中的主题元数据。
从集群中删除主题:
-
关闭集群中的所有经纪人。
-
从 Kafka 集群路径中删除 ZooKeeper 路径/brokers/topics/
。请注意,此节点有必须首先删除的子节点。 -
从每个经纪人的日志目录中删除分区目录。这些目录将被命名为
<topic>-<int>,其中<int>是分区 ID。 -
重新启动所有经纪人。
总结
运行 Kafka 集群可能是一项艰巨的任务,需要进行大量配置和维护任务,以确保系统以最佳性能运行。在本章中,我们讨论了许多常规任务,比如管理主题和客户端配置,这些是您经常需要处理的。我们还涵盖了一些更神秘的任务,这些任务是您需要用来调试问题的,比如检查日志段。最后,我们涵盖了一些操作,虽然不安全或常规,但可以帮助您摆脱困境。总的来说,这些工具将帮助您管理 Kafka 集群。随着您开始扩展 Kafka 集群的规模,即使使用这些工具也可能变得艰难和难以管理。强烈建议与开源 Kafka 社区合作,并利用生态系统中的许多其他开源项目,以帮助自动化本章中概述的许多任务。
现在我们对管理和管理我们的集群所需的工具有信心,但是如果没有适当的监控,这仍然是不可能的。第十三章将讨论监控经纪人和集群健康和运行状况的方法,以便您可以确保 Kafka 运行良好(并知道何时不是)。我们还将提供监控客户端的最佳实践,包括生产者和消费者。
第十三章:监控 Kafka
Apache Kafka 应用程序有许多用于其操作的测量值,事实上有很多,以至于很容易变得令人困惑,不知道要观察什么是重要的,什么可以搁置。这些范围从关于流量总体速率的简单指标,到每种请求类型的详细定时指标,再到每个主题和每个分区的指标。它们提供了对代理中每个操作的详细视图,但也可能使您成为负责管理监控系统的人的梦魇。
本章将详细介绍始终监控的最关键指标以及如何对其做出响应。我们还将描述在调试问题时手头上最重要的一些指标。然而,这不是一个详尽的可用指标列表,因为列表经常变化,许多指标只对硬核 Kafka 开发人员有用。
指标基础知识
在进入由 Kafka 代理和客户端提供的具体指标之前,让我们讨论一下如何监控 Java 应用程序的基础知识以及一些关于监控和警报的最佳实践。这将为理解如何监控应用程序以及为什么后面描述的特定指标被选择为最重要的提供基础。
指标在哪里?
Kafka 公开的所有指标都可以通过 Java 管理扩展(JMX)接口访问。在外部监控系统中使用它们的最简单方法是使用监控系统提供的收集代理,并将其附加到 Kafka 进程上。这可能是在系统上运行并连接到 JMX 接口的单独进程,例如 Nagios XI 的check_jmx插件或jmxtrans。您还可以利用直接在 Kafka 进程中运行的 JMX 代理通过 HTTP 连接访问指标,例如 Jolokia 或 MX4J。
如何设置监控代理的深入讨论超出了本章的范围,而且有太多选择,无法公平地对所有选择进行公正。如果您的组织目前没有监控 Java 应用程序的经验,可能值得考虑监控作为一项服务。有许多公司提供监控代理、指标收集点、存储、绘图和警报的服务包。他们可以帮助您进一步设置所需的监控代理。
查找 JMX 端口
为了帮助配置直接连接到 Kafka 代理的应用程序(如监控系统),代理在存储在 ZooKeeper 中的代理信息中设置了配置的 JMX 端口。/brokers/ids/<ID> znode 包含代理的 JSON 格式数据,包括hostname和jmx_port键。但是,应该注意的是,出于安全原因,Kafka 默认情况下禁用了远程 JMX。如果您要启用它,必须正确配置端口的安全性。这是因为 JMX 不仅允许查看应用程序的状态,还允许执行代码。强烈建议您使用加载到应用程序中的 JMX 指标代理。
非应用程序指标
并非所有指标都来自 Kafka 本身。您可以从五个一般分组中获取指标。表 13-1 描述了我们在监控 Kafka 代理时的类别。
表 13-1。指标来源
| 类别 | 描述 |
|---|---|
| 应用程序指标 | 这些是您从 Kafka 本身获取的指标,来自 JMX 接口。 |
| 日志 | 来自 Kafka 本身的另一种监控数据类型。因为它是某种形式的文本或结构化数据,而不仅仅是一个数字,所以需要更多的处理。 |
| 基础设施指标 | 这些指标来自于您在 Kafka 前面的系统,但仍然在请求路径内并在您的控制范围内。一个例子是负载均衡器。 |
| 合成客户端 | 这是来自与您的 Kafka 部署外部工具的数据,就像客户端一样,但在您的直接控制下,通常不执行与您的客户端相同的工作。像 Kafka Monitor 这样的外部监视器属于这一类别。 |
| 客户端指标 | 这些是由连接到您的集群的 Kafka 客户端公开的指标。 |
Kafka 生成的日志将在本章后面讨论,客户端指标也是如此。我们还将简要涉及合成指标。然而,基础设施指标取决于您的特定环境,并且超出了这里讨论的范围。在您的 Kafka 旅程中越深入,这些指标来源对于充分了解应用程序的运行方式就越重要,因为在列表中越靠后,它们提供的对 Kafka 的客观视图就越多。例如,在开始阶段依赖经纪人的指标就足够了,但以后您会希望更客观地了解它们的表现。客观测量价值的一个熟悉例子是监控网站的健康状况。Web 服务器正常运行,并且它报告的所有指标都表明它正在工作。然而,您的 Web 服务器和外部用户之间的网络存在问题,这意味着您的用户无法访问 Web 服务器。在您的网络之外运行的合成客户端将检测到这一情况并向您发出警报。
我需要哪些指标?
对您重要的具体指标几乎与您要使用的最佳编辑器一样重要。这将大大取决于您打算如何使用它们,您有哪些可用于收集数据的工具,您在使用 Kafka 方面的进展如何,以及您有多少时间可用于围绕 Kafka 构建基础设施。一个经纪人内部开发人员的需求将远远不同于运行 Kafka 部署的站点可靠性工程师的需求。
警报还是调试?
您应该问自己的第一个问题是,您的主要目标是在 Kafka 出现问题时警报您,还是调试出现的问题。答案通常会涉及两者,但知道一个指标是用于哪个目的将使您在收集后对其进行不同处理。
用于警报的指标在很短的时间内非常有用——通常不会超过解决问题所需的时间。您可以测量几个小时,或者可能几天。这些指标将被自动化消耗,自动化将为您响应已知问题,以及在自动化尚不存在的情况下由人工操作员消耗。这些指标通常更为客观,因为不影响客户端的问题远不及影响客户端的问题严重。
主要用于调试的数据具有更长的时间范围,因为您经常诊断已经存在一段时间的问题,或者深入研究更复杂的问题。这些数据将需要在收集后的几天或几周内保持可用。通常还会是更主观的测量,或者来自 Kafka 应用程序本身的数据。请记住,不一定需要将这些数据收集到监控系统中。如果指标用于现场调试问题,则在需要时可用即可。您无需通过持续收集成千上万个值来压倒监控系统。
历史指标
最终,您还将需要应用程序的历史数据。历史数据最常见的用途是用于容量管理,因此包括有关使用的资源的信息,包括计算资源、存储和网络。这些指标需要长时间存储,以年为单位。您还可能需要收集额外的元数据来将指标放入上下文中,例如代理何时添加到集群或从集群中删除。
自动化还是人类?
还需要考虑的一个问题是指标的使用者是谁。如果指标由自动化程序使用,它们应该非常具体。拥有大量描述细节的指标是可以接受的,因为这正是计算机存在的原因:处理大量数据。数据越具体,就越容易创建基于其操作的自动化程序,因为数据不会留下太多关于其含义的解释空间。另一方面,如果指标将由人类使用,呈现大量指标将会令人不知所措。在基于这些测量值定义警报时,这变得更加重要。很容易陷入“警报疲劳”,因为有太多警报响起,很难知道问题有多严重。正确定义每个指标的阈值并使其保持最新也很困难。当警报过多或经常不正确时,我们开始不相信警报是否正确描述了我们应用程序的状态。
想想汽车的运行。为了在汽车运行时正确调整空气与燃料的比例,计算机需要对空气密度、燃料、排气和发动机运行等细微之处进行多次测量。然而,这些测量对车辆的人类操作者来说将是不堪重负的。相反,我们有一个“发动机故障”指示灯。一个指示器告诉您有问题,并且有一种方法可以获取更详细的信息,告诉您问题的确切所在。在本章中,我们将确定提供最高覆盖率的指标,以保持您的警报简单。
应用程序健康检查
无论您如何从 Kafka 收集指标,都应确保有一种方法来通过简单的健康检查监控应用程序进程的整体健康状况。这可以通过两种方式实现:
-
报告代理是否正常运行的外部过程(健康检查)
-
对 Kafka 代理报告的指标缺失进行警报(有时称为陈旧指标)
尽管第二种方法有效,但它可能会使难以区分 Kafka 代理的故障和监控系统本身的故障。
对于 Kafka 代理,这可以简单地连接到外部端口(客户端用于连接代理的相同端口)以检查其响应。对于客户端应用程序,可能会更复杂,从简单检查进程是否正在运行,到确定应用程序健康状况的内部方法。
服务级目标
监控的一个特别关键的领域是基础设施服务,比如 Kafka,其中的服务级目标或 SLO。这是我们向客户传达基础设施服务可以提供的服务水平。客户希望能够将 Kafka 等服务视为不透明系统:他们不希望也不需要了解其内部工作原理,只需要了解他们正在使用的接口,并知道它将按照他们的需求进行操作。
服务级定义
在讨论 Kafka 中的 SLO 之前,必须就所使用的术语达成一致。经常会听到工程师、经理、高管和其他人在“服务级”领域错误地使用术语,这导致对实际讨论的内容产生困惑。
服务级指标(SLI)是描述服务可靠性的指标。它应该与客户的体验密切相关,因此通常情况下,这些测量越客观,它们就越好。在请求处理系统(如 Kafka)中,通常最好将这些测量表达为良好事件数量与总事件数量之间的比率,例如,返回 2xx、3xx 或 4xx 响应的网页服务器请求的比例。
服务级目标(SLO),也可以称为服务级阈值(SLT),将 SLI 与目标值结合起来。表达目标的常见方式是通过数量的 nines(99.9%是“三个 nines”),尽管这并不是必需的。SLO 还应包括在其上进行测量的时间范围,通常在天的时间尺度上。例如,在 7 天内,网页服务器的请求中必须返回 2xx、3xx 或 4xx 响应的 99%。
服务级协议(SLA)是服务提供商和客户之间的合同。它通常包括几个 SLO,以及有关如何测量和报告它们、客户如何从服务提供商那里寻求支持以及服务提供商如果未能在 SLA 范围内执行将受到的处罚的详细信息。例如,前述 SLO 的 SLA 可能规定,如果服务提供商未能在 SLO 范围内运营,他们将退还客户支付的服务期间的所有费用。
运营级别协议
运营级别协议(OLA)这个术语使用得较少。它描述了在 SLA 的整体交付中多个内部服务或支持提供者之间的协议。目标是确保履行 SLA 所必需的多个活动在日常运营中得到适当描述和核算。
人们经常谈论 SLA 时实际上是指 SLO。虽然向付费客户提供服务的人可能与这些客户有 SLA,但负责运行应用程序的工程师很少负责超出 SLO 范围的任何事情。此外,只有内部客户(即为更大的服务运行 Kafka 作为内部数据基础设施的人)通常不与这些内部客户有 SLA。然而,这不应阻止您设定和传达 SLO,因为这样做将减少客户对他们认为 Kafka 应该表现如何的假设。
什么指标可以成为良好的 SLI?
一般来说,SLI 的指标应该使用 Kafka 经纪人之外的东西进行收集。原因是 SLO 应该描述您的服务的典型用户是否满意,而您无法主观地衡量这一点。您的客户不在乎您是否认为您的服务正在正确运行;重要的是他们的体验(总体而言)。这意味着基础设施指标是可以的,合成客户端是好的,而客户端指标对于大多数 SLI 来说可能是最好的。
虽然这并不是一个详尽的列表,但在请求/响应和数据存储系统中使用的最常见的 SLI 在表 13-2 中。
客户总是想要更多
有一些 SLO 可能会引起客户的兴趣,但这些 SLO 对他们很重要,但不在你的控制范围内。例如,他们可能会关心 Kafka 生成的数据的正确性或新鲜度。不要同意支持你不负责的 SLO,因为这只会导致承担削弱保持 Kafka 正常运行的核心工作的工作。确保将他们与适当的团队联系起来,以建立对这些额外要求的理解和协议。
表 13-2. SLI 的类型
| 可用性 | 客户能否发出请求并获得响应? |
|---|---|
| 延迟 | 响应返回的速度有多快? |
| 质量 | 响应是否包含适当的响应? |
| 安全性 | 请求和响应是否得到适当的保护,无论是授权还是加密? |
| 吞吐量 | 客户端是否能够快速获取足够的数据? |
请记住,通常最好让您的 SLI 基于落在 SLO 阈值内的事件计数。这意味着理想情况下,应逐个检查每个事件,以查看它是否满足 SLO 的阈值。这排除了分位数指标作为良好 SLI 的可能性,因为这些指标只会告诉您 90%的事件低于给定值,而不允许您控制该值是多少。然而,将值聚合到桶中(例如,“小于 10 毫秒”,“10-50 毫秒”,“50-100 毫秒”等)在处理 SLO 时可能很有用,特别是当您还不确定良好阈值是什么时。这将使您了解 SLO 范围内事件的分布,并且您可以配置桶,使边界值成为 SLO 阈值的合理值。
使用警报中的 SLO
简而言之,SLO 应该为您的主要警报提供信息。原因是 SLO 从客户的角度描述了问题,这些问题应该是您首先关心的问题。一般来说,如果问题不影响您的客户,那么它就不需要在夜间叫醒您。SLO 还将告诉您有关您不知道如何检测的问题,因为您以前从未见过它们。它们不会告诉您这些问题是什么,但它们会告诉您这些问题存在。
挑战在于直接将 SLO 用作警报非常困难。SLO 最适合长时间尺度,例如一周,因为我们希望以可消化的方式向管理层和客户报告它们。此外,当 SLO 警报触发时,为时已晚 - 您已经在 SLO 范围之外运行。有些人会使用导数值提供预警,但使用 SLO 进行警报的最佳方法是观察您在其时间范围内通过 SLO 的速率。
例如,假设您的 Kafka 集群每周接收一百万个请求,并且您定义了一个 SLO,规定 99.9%的请求必须在 10 毫秒内发送第一个响应字节。这意味着在一周内,您最多可以有一千个请求的响应速度慢于此,而一切仍将正常。通常,您每小时会看到一个这样的请求,这大约是每周 168 个不良请求,从周日到周六进行测量。您有一个指标显示这是 SLO 燃烧速率,每小时一个请求在一百万个请求每周中是 0.1%的燃烧速率。
在周二上午 10 点,您的指标发生变化,现在显示燃烧速率为每小时 0.4%。这不是很好,但这还不是问题,因为到本周末时,您将远远在 SLO 范围内。您打开了一个工单来查看问题,但又回到了一些更高优先级的工作。在周三下午 2 点,燃烧速率跳升到每小时 2%,您的警报响了。您知道以这个速度,您将在周五中午之前违反 SLO。放下一切,您诊断了问题,大约 4 个小时后,您将燃烧速率降至每小时 0.4%,并且一直保持在这个水平。通过使用燃烧速率,您成功避免了本周违反 SLO。
有关利用 SLO 和燃烧速率进行警报的更多信息,您会发现Site Reliability Engineering和The Site Reliability Workbook是优秀的资源,两者均由 Betsy Beyer 等人编辑(O'Reilly)。
Kafka Broker Metrics
有许多 Kafka 经纪人指标。其中许多是低级测量,由开发人员在调查特定问题或预期以后需要调试信息时添加的。这些指标提供有关经纪人内几乎每个功能的信息,但最常见的指标提供了日常运行 Kafka 所需的信息。
谁来监视监视者?
许多组织使用 Kafka 收集应用程序指标、系统指标和日志,供中央监控系统使用。这是将应用程序与监控系统解耦的绝佳方式,但对于 Kafka 本身来说,也提出了特定的问题。如果您使用相同的系统来监视 Kafka 本身,很可能您永远不会知道 Kafka 何时出现故障,因为监控系统的数据流也会中断。
有许多方法可以解决这个问题。一种方法是为 Kafka 使用一个不依赖于 Kafka 的单独监控系统。另一种方法是,如果您有多个数据中心,要确保数据中心 A 的 Kafka 集群的指标被生成到数据中心 B,反之亦然。无论您决定如何处理,都要确保 Kafka 的监控和警报不依赖于 Kafka 的正常工作。
在本节中,我们将首先讨论诊断 Kafka 集群问题的高级工作流程,参考有用的指标。这些指标和其他指标将在本章后面更详细地描述。这绝不是经纪人指标的详尽清单,而是检查经纪人和集群健康状况的几个“必备”指标。最后,我们将讨论日志记录,然后转向客户端指标。
诊断集群问题
在涉及 Kafka 集群的问题时,有三个主要类别:
-
单经纪人问题
-
超载的集群
-
控制器问题
与单个经纪人的问题相比,诊断和响应集群问题要容易得多。这些问题将显示为集群指标中的异常值,并且通常与存储设备的缓慢或故障,或系统中其他应用程序的计算限制有关。要检测它们,确保您正在监视单个服务器的可用性,以及存储设备的状态,利用操作系统(OS)指标。
然而,在操作系统或硬件级别没有发现问题的情况下,问题几乎总是 Kafka 集群负载不平衡。虽然 Kafka 试图使集群中的数据均匀分布在所有经纪人之间,但这并不意味着客户端对该数据的访问是均匀分布的。它也无法检测到热分区等问题。强烈建议您始终使用外部工具来保持集群的平衡。其中一个工具是Cruise Control,这是一个不断监视集群并在其中重新平衡分区的应用程序。它还提供许多其他管理功能,例如添加和删除经纪人。
首选副本选举
在进一步诊断问题之前的第一步是确保您最近已运行了首选副本选举(参见第十二章)。Kafka 经纪人在释放领导权后(例如,当经纪人失败或关闭时),不会自动重新接管分区领导权(除非启用了自动领导者重新平衡)。这意味着领导副本在集群中很容易变得不平衡。首选副本选举是安全且易于运行的,因此最好先这样做,看看问题是否消失。
过载的集群是另一个容易检测到的问题。如果集群是平衡的,并且许多代理显示出增加的请求延迟或低请求处理程序池空闲比率,那么您的代理已经达到了为该集群提供流量的极限。在深入检查后,您可能会发现有一个客户端改变了其请求模式,现在导致了问题。然而,即使发生这种情况,您可能无法改变客户端。您可以采取的解决方案要么是减少对集群的负载,要么是增加代理的数量。
Kafka 集群中控制器的问题要难以诊断得多,通常属于 Kafka 本身的错误类别。这些问题表现为代理元数据不同步、代理离线时代理似乎正常,以及主题控制操作(如创建)未能正确进行。如果您在集群中遇到问题并说“这真的很奇怪”,那么很有可能是因为控制器做了一些不可预测且不好的事情。监控控制器的方法并不多,但监控活动控制器计数以及控制器队列大小将为您提供一个高级别的指标,以判断是否存在问题。
未复制的分区的艺术
监控 Kafka 时使用的最流行的指标之一是未复制的分区。在集群中的每个代理上提供的这个度量值,给出了代理是领导副本的分区数量,其中跟随者副本没有赶上。这个单一的度量值可以揭示 Kafka 集群的许多问题,从代理宕机到资源耗尽。由于这个度量值可以指示的问题种类繁多,因此值得深入研究如何应对非零值。本章后面将描述用于诊断这些问题的许多度量值。有关未复制的分区的更多详细信息,请参见表 13-3。
表 13-3。度量值及其对应的未复制的分区
| 指标名称 | 未复制的分区 |
|---|---|
| JMX MBean | kafka.server:type=ReplicaManager,name=UnderReplicatedPartitions |
| 值范围 | 整数,零或更大 |
未复制的分区警报陷阱
在本书的上一版以及许多会议演讲中,作者们长时间地谈到了未复制的分区(URP)度量应该是您的主要警报度量,因为它描述了多少问题。这种方法存在大量问题,其中最主要的问题之一是,未复制的分区度量通常由于良性原因而频繁出现非零值。这意味着作为 Kafka 集群的运维人员,您将收到错误警报,从而忽略警报。这也需要相当多的知识才能理解度量值告诉您的信息。因此,我们不再建议使用 URP 进行警报。相反,您应该依赖基于 SLO 的警报来检测未知问题。
集群中许多代理报告的未复制的分区数量保持稳定(不变),通常表明集群中的一个代理已经离线。整个集群中的未复制的分区数量将等于分配给该代理的分区数量,而宕机的代理将不会报告度量值。在这种情况下,您需要调查发生了什么,并解决这种情况。这通常是硬件故障,但也可能是导致问题的操作系统或 Java 问题。
如果未复制的分区数量波动,或者数量稳定但没有经纪人离线,通常表明集群中存在性能问题。由于这些问题的多样性,这些类型的问题更难诊断,但有几个步骤可以帮助您缩小问题的可能原因。第一步是尝试确定问题是与单个经纪人相关还是与整个集群相关。有时这可能是一个难以回答的问题。如果未复制的分区在单个经纪人上,就像下面的例子一样,那么通常这个经纪人就是问题所在。错误显示其他经纪人在复制来自该经纪人的消息时出现问题。
如果有几个经纪人有未复制的分区,这可能是一个集群问题,但仍可能是一个单个经纪人的问题。在这种情况下,这可能是因为一个单个经纪人在复制来自任何地方的消息时出现问题,您将不得不找出是哪个经纪人。一种方法是获取集群的未复制分区列表,并查看是否有一个特定的经纪人是所有未复制分区的共同线索。使用kafka-topics.sh工具(在第十二章中有详细讨论),您可以获取未复制分区的列表,以寻找一个共同的线索。
例如,在集群中列出未复制的分区:
# kafka-topics.sh --bootstrap-server kafka1.example.com:9092/kafka-cluster
--describe --under-replicated
Topic: topicOne Partition: 5 Leader: 1 Replicas: 1,2 Isr: 1
Topic: topicOne Partition: 6 Leader: 3 Replicas: 2,3 Isr: 3
Topic: topicTwo Partition: 3 Leader: 4 Replicas: 2,4 Isr: 4
Topic: topicTwo Partition: 7 Leader: 5 Replicas: 5,2 Isr: 5
Topic: topicSix Partition: 1 Leader: 3 Replicas: 2,3 Isr: 3
Topic: topicSix Partition: 2 Leader: 1 Replicas: 1,2 Isr: 1
Topic: topicSix Partition: 5 Leader: 6 Replicas: 2,6 Isr: 6
Topic: topicSix Partition: 7 Leader: 7 Replicas: 7,2 Isr: 7
Topic: topicNine Partition: 1 Leader: 1 Replicas: 1,2 Isr: 1
Topic: topicNine Partition: 3 Leader: 3 Replicas: 2,3 Isr: 3
Topic: topicNine Partition: 4 Leader: 3 Replicas: 3,2 Isr: 3
Topic: topicNine Partition: 7 Leader: 3 Replicas: 2,3 Isr: 3
Topic: topicNine Partition: 0 Leader: 3 Replicas: 2,3 Isr: 3
Topic: topicNine Partition: 5 Leader: 6 Replicas: 6,2 Isr: 6
#
在这个例子中,常见的经纪人是编号 2。这表明这个经纪人在消息复制方面存在问题,将导致我们将调查重点放在这个经纪人身上。如果没有常见的经纪人,那么很可能是整个集群出现了问题。
集群级问题
集群问题通常分为两类:
-
不平衡的负载
-
资源耗尽
第一个问题,不平衡的分区或领导权,是最容易发现的,尽管修复它可能是一个复杂的过程。为了诊断这个问题,您需要从集群中的经纪人获取几个指标:
-
分区计数
-
领导分区计数
-
所有主题的消息输入速率
-
所有主题的字节输入速率
-
所有主题的字节输出速率
检查这些指标。在一个完全平衡的集群中,所有经纪人的数字将是均匀的,如表 13-4 中所示。
表 13-4。利用率指标
| 经纪人 | 分区 | 领导者 | 消息数 | 字节输入 | 字节输出 |
|---|---|---|---|---|---|
| 1 | 100 | 50 | 13130 msg/s | 3.56 MBps | 9.45 MBps |
| 2 | 101 | 49 | 12842 msg/s | 3.66 MBps | 9.25 MBps |
| 3 | 100 | 50 | 13086 msg/s | 3.23 MBps | 9.82 MBps |
这表明所有经纪人大约承担着相同数量的流量。假设您已经运行了首选副本选举,大的偏差表明集群内的流量不平衡。为了解决这个问题,您需要将分区从负载较重的经纪人移动到负载较轻的经纪人。这是使用kafka-reassign-partitions.sh工具来完成的,该工具在第十二章中有描述。
用于平衡集群的辅助工具
Kafka 经纪人本身不提供集群中分区的自动重新分配。这意味着在 Kafka 集群中平衡流量可能是一个令人昏昏欲睡的过程,需要手动审查大量的指标列表,并尝试找出有效的副本分配。为了帮助解决这个问题,一些组织已经开发了自动化工具来执行这项任务。其中一个例子是 LinkedIn 在 GitHub 上发布的kafka-assigner工具(https://oreil.ly/8ilPw)。一些 Kafka 支持的企业产品也提供了这个功能。
另一个常见的集群性能问题是超出代理提供请求的能力。可能会有许多可能的瓶颈会减慢速度:CPU、磁盘 IO 和网络吞吐量是最常见的几个。磁盘利用率不在其中,因为代理将正常运行,直到磁盘填满,然后磁盘将突然失败。为了诊断容量问题,您可以在操作系统级别跟踪许多指标,包括:
-
CPU 利用率
-
入站网络吞吐量
-
出站网络吞吐量
-
磁盘平均等待时间
-
磁盘百分比利用率
耗尽任何这些资源通常会表现为相同的问题:副本不足的分区。重要的是要记住,代理复制过程的操作方式与其他 Kafka 客户端完全相同。如果您的集群在复制方面出现问题,那么您的客户在生产和消费消息方面也会出现问题。在集群正常运行时,为这些指标制定基线,然后设置指示出现问题的阈值,远在容量耗尽之前就能指示出问题的发展。您还需要查看这些指标随着时间推移而增加到集群的流量。就 Kafka 代理指标而言,“所有主题字节输入速率”是显示集群使用情况的良好指南。
主机级问题
如果 Kafka 的性能问题不是整个集群中存在,并且可以隔离到一个或两个代理,那么是时候检查该服务器,看看它与集群的其他部分有何不同了。这些问题属于几个一般类别:
-
硬件故障
-
网络
-
与另一个进程冲突
-
本地配置差异
典型服务器和问题
服务器及其操作系统是一个复杂的机器,有成千上万个组件,任何一个都可能出现问题,导致完全故障或性能下降。我们不可能在本书中涵盖所有可能出现故障的内容——已经有许多卷的书籍,而且将继续有关于这个主题的书籍。但我们可以讨论一些最常见的问题。本节将重点讨论运行 Linux 操作系统的典型服务器的问题。
硬件故障有时很明显,比如服务器突然停止工作,但是导致性能问题的是不太明显的问题。这些通常是允许系统继续运行但降低操作的软故障。这可能是一小部分内存出现问题,系统已检测到问题并绕过该段(减少了总可用内存)。CPU 故障也可能发生相同的情况。对于这类问题,您应该使用硬件提供的设施,例如智能平台管理接口(IPMI)来监控硬件健康状况。当存在活动问题时,查看使用dmesg的内核环形缓冲区将帮助您查看被抛到系统控制台的日志消息。
导致 Kafka 性能下降的更常见的硬件故障是磁盘故障。Apache Kafka 依赖磁盘来持久化消息,生产者的性能直接取决于磁盘提交写入的速度。任何偏差都会表现为生产者和副本获取者性能的问题。后者是导致副本不足的分区。因此,随时监控磁盘的健康状况并及时解决任何问题非常重要。
一个坏蛋
单个经纪人的单个磁盘故障可能破坏整个集群的性能。这是因为生产者客户端将连接到为主题引导分区的所有经纪人,如果您遵循最佳实践,这些分区将均匀分布在整个集群上。如果一个经纪人开始表现不佳并减慢生产请求,这将导致生产者中的背压,减慢对所有经纪人的请求。
首先,确保您正在监视来自 IPMI 或硬件提供的接口的磁盘的硬件状态信息。此外,在操作系统中,您应该定期运行 SMART(自我监控、分析和报告技术)工具来监视和测试磁盘。这将警示您即将发生的故障。另外,重要的是要密切关注磁盘控制器,特别是如果它具有 RAID 功能,无论您是使用硬件 RAID 还是其他方式。许多控制器具有内置缓存,仅在控制器健康且电池备份单元(BBU)正常工作时才会使用。BBU 的故障可能导致缓存被禁用,降低磁盘性能。
网络是另一个部分故障会导致问题的领域。其中一些问题是硬件问题,例如糟糕的网络电缆或连接器。有些是配置问题,通常是连接的速度或双工设置的更改,无论是在服务器端还是在网络硬件上游。网络配置问题也可能是操作系统问题,例如网络缓冲区过小或太多的网络连接占用了整体内存占用量的太多。在这个领域问题的一个关键指标将是网络接口上检测到的错误数量。如果错误计数正在增加,那么可能存在未解决的问题。
如果没有硬件问题,要查找的另一个常见问题是系统上运行的另一个应用程序正在消耗资源并对 Kafka 经纪人施加压力。这可能是错误安装的东西,也可能是应该运行的进程,例如监控代理,但出现了问题。使用系统上的工具,如top,来识别是否有进程使用的 CPU 或内存超出预期。
如果其他选项已经耗尽,但您尚未找到主机上差异的来源,那么很可能是出现了配置差异,无论是经纪人还是系统本身。考虑到任何单个服务器上运行的应用程序数量以及每个应用程序的配置选项数量,要找到差异可能是一项艰巨的任务。这就是为什么您必须利用配置管理系统(如Chef或Puppet)来维护 OS 和应用程序(包括 Kafka)之间的一致配置的原因。
经纪人指标
除了副本不足的分区之外,还有其他在整体经纪人级别存在的指标应该被监视。虽然您可能不倾向于为所有这些指标设置警报阈值,但它们提供了有关您的经纪人和集群的宝贵信息。它们应该出现在您创建的任何监控仪表板中。
活动控制器计数
活动控制器计数指标指示经纪人当前是否是集群的控制器。该指标将是 0 或 1,其中 1 表示经纪人当前是控制器。始终只有一个经纪人应该是控制器,并且集群中必须始终有一个经纪人是控制器。如果两个经纪人表示它们当前是控制器,这意味着您遇到了一个控制器线程应该退出但却被卡住的问题。这可能导致无法正确执行管理任务,例如分区移动。为了解决这个问题,您至少需要重新启动两个经纪人。然而,当集群中有额外的控制器时,通常会出现无法安全关闭经纪人的问题,您将需要强制停止经纪人。有关活动控制器计数的更多详细信息,请参见表 13-5。
表 13-5。活动控制器计数指标详细信息
| 指标名称 | 活动控制器计数 |
|---|---|
| JMX MBean | kafka.controller:type=KafkaController,name=ActiveControllerCount |
| 值范围 | 零或一 |
如果没有经纪人声称是集群中的控制器,集群将无法在状态更改时正确响应,包括主题或分区创建或经纪人故障。在这种情况下,您必须进一步调查为什么控制器线程无法正常工作。例如,来自 ZooKeeper 集群的网络分区可能导致这样的问题。一旦解决了潜在的问题,明智的做法是重新启动集群中的所有经纪人,以重置控制器线程的状态。
控制器队列大小
控制器队列大小指标指示控制器当前正在等待为经纪人处理多少请求。该指标将是 0 或更多,其值会随着来自经纪人的新请求和管理操作(例如创建分区、移动分区和处理领导者更改)的发生而经常波动。指标的波动是可以预期的,但如果该值持续增加,或者保持在一个高值并且不下降,这表明控制器可能被卡住。这可能导致无法正确执行管理任务的问题。为了解决这个问题,您需要将控制器移动到另一个经纪人,这需要关闭当前是控制器的经纪人。然而,当控制器被卡住时,通常会出现无法受控地关闭任何经纪人的问题。有关控制器队列大小的更多详细信息,请参见表 13-6。
表 13-6。控制器队列大小指标详细信息
| 指标名称 | 控制器队列大小 |
|---|---|
| JMX MBean | kafka.controller:type=ControllerEventManager,name=EventQueueSize |
| 值范围 | 整数,零或更多 |
请求处理程序空闲比率
Kafka 使用两个线程池来处理所有客户端请求:网络线程和请求处理程序线程(也称为I/O 线程)。网络线程负责在网络上读取和写入客户端的数据。这不需要大量处理,这意味着网络线程的耗尽不太值得关注。然而,请求处理程序线程负责为客户端请求本身提供服务,包括将消息读取或写入磁盘。因此,随着经纪人负载更重,这个线程池会受到重大影响。有关请求处理程序空闲比率的更多详细信息,请参见表 13-7。
表 13-7。请求处理程序空闲比率详细信息
| 指标名称 | 请求处理程序平均空闲百分比 |
|---|---|
| JMX MBean | kafka.server:type=KafkaRequestHandlerPool,name=RequestHandlerAvgIdlePercent |
| 值范围 | 浮点数,介于零和一之间 |
智能线程使用
虽然看起来您可能需要数百个请求处理程序线程,但实际上您不需要配置比经纪人中的 CPU 更多的线程。Apache Kafka 在使用请求处理程序的方式上非常聪明,确保将需要很长时间处理的请求转移到“炼狱”,例如在报价请求或需要多个生产请求确认时使用。
请求处理程序空闲比指标表示请求处理程序未使用的时间百分比。这个数字越低,代表经纪人的负载越重。经验告诉我们,空闲比低于 20%表示潜在问题,低于 10%通常表示活跃的性能问题。除了集群容量不足外,这个池中高线程利用率的原因有两个。第一个是池中的线程不够。通常情况下,您应该将请求处理程序线程的数量设置为系统中处理器的数量(包括超线程处理器)。
请求处理程序线程利用率较高的另一个常见原因是线程对每个请求都在做不必要的工作。在 Kafka 0.10 之前,请求处理程序线程负责解压每个传入的消息批次,验证消息并分配偏移量,然后在将消息批次写入磁盘之前重新压缩带有偏移量的消息批次。更糟糕的是,所有压缩方法都在同步锁后面。从 0.10 版本开始,有一种新的消息格式允许消息批次中的相对偏移量。这意味着更新的生产者将在发送消息批次之前设置相对偏移量,这样经纪人就可以跳过消息批次的重新压缩。您可以做的最大的性能改进之一是确保所有生产者和消费者客户端都支持 0.10 消息格式,并将经纪人上的消息格式版本也更改为 0.10。这将大大减少请求处理程序线程的利用率。
所有主题字节
以每秒字节为单位的所有主题字节速率对于衡量经纪人从生产客户端接收的消息流量非常有用。这是一个很好的指标,可以帮助您确定何时需要扩展集群或进行其他与增长相关的工作。它还有助于评估集群中的一个经纪人是否比其他经纪人接收更多的流量,这表明有必要重新平衡集群中的分区。有关更多详细信息,请参见表 13-8。
表 13-8。所有主题字节指标详情
| 指标名称 | 每秒字节 |
|---|---|
| JMX MBean | kafka.server:type=BrokerTopicMetrics,name=BytesInPerSec |
| 值范围 | 速率为双精度,计数为整数 |
由于这是讨论的第一个速率指标,值得简要讨论一下这些类型指标提供的属性。所有速率指标都有七个属性,选择使用哪些取决于您想要的测量类型。这些属性提供了事件的离散计数,以及在不同时间段内事件数量的平均值。确保适当使用指标,否则您将得到有缺陷的经纪人视图。
前两个属性不是测量,但它们将帮助您理解您正在查看的指标:
EventType
这是所有属性的测量单位。在这种情况下,它是“字节”。
RateUnit
对于速率属性,这是速率的时间段。在这种情况下,它是“秒”。
这两个描述性属性告诉我们,无论它们平均的时间段是多长,速率都以每秒字节的值呈现。提供了四个不同粒度的速率属性:
OneMinuteRate
过去 1 分钟的平均值
FiveMinuteRate
过去 5 分钟的平均值
FifteenMinuteRate
过去 15 分钟的平均值
MeanRate
自经纪人启动以来的平均值
OneMinuteRate将快速波动,并提供更多“即时”视图的测量。这对于查看交通短暂激增很有用。MeanRate几乎不会变化,并提供总体趋势。虽然MeanRate有其用途,但它可能不是您想要收到警报的指标。FiveMinuteRate和FifteenMinuteRate在两者之间提供了一个折衷方案。
除了速率属性之外,还有一个Count属性。这是自经纪人启动以来该指标的不断增加的值。对于此指标,所有主题的字节输入,Count表示自进程启动以来发送到经纪人的总字节数。与支持计数器指标的度量系统一起使用,这可以让您绝对查看测量结果,而不是平均速率。
所有主题的字节输出
所有主题的字节输出速率,类似于字节输入速率,是另一个总体增长指标。在这种情况下,字节输出速率显示消费者读取消息的速率。出站字节速率可能与入站字节速率不同,这要归功于 Kafka 轻松处理多个消费者的能力。有许多 Kafka 部署,其中出站速率很容易是入站速率的六倍!这就是为什么单独观察和趋势出站字节速率很重要。有关更多详细信息,请参见表 13-9。
表 13-9。所有主题字节输出指标详细信息
| 指标名称 | 每秒输出的字节 |
|---|
JMX MBean | kafka.server:type=BrokerTopicMetrics,name=BytesOutPerSec
|值范围|速率为双倍,计数为整数|
包括副本获取器
出站字节速率还包括副本流量。这意味着如果所有主题的配置副本因子为 2,则当没有消费者客户端时,您将看到与字节输入速率相等的字节输出速率。如果有一个消费者客户端读取集群中的所有消息,则字节输出速率将是字节输入速率的两倍。如果您不知道计数的内容,这在查看指标时可能会让人困惑。
所有主题的消息输入
虽然先前描述的字节速率显示了字节的绝对值,但消息输入速率显示了每秒产生的个别消息的数量,而不考虑其大小。这作为生长指标很有用,作为生产者流量的不同度量。它也可以与字节输入速率一起使用,以确定平均消息大小。您还可能会看到经纪人的不平衡,就像字节输入速率一样,这将提醒您需要进行必要的维护工作。有关更多详细信息,请参见表 13-10。
表 13-10。所有主题消息输入指标详细信息
| 指标名称 | 每秒消息输入 |
|---|
JMX MBean | kafka.server:type=BrokerTopicMetrics,name=MessagesInPerSec
|值范围|速率为双倍,计数为整数|
为什么没有消息输出?
人们经常问为什么 Kafka 经纪人没有消息输出指标。原因是当消息被消耗时,经纪人只是将下一批消息发送给消费者,而不会扩展以找出其中有多少消息。因此,经纪人实际上不知道发送了多少消息。唯一可以提供的指标是每秒获取的次数,这是一个请求速率,而不是消息计数。
分区计数
分区计数对于经纪人通常不会有太大变化,因为它是分配给该经纪人的分区总数。这包括经纪人拥有的每个副本,无论它是否是该分区的领导者或追随者。在启用自动主题创建的集群中监视这一点通常更有趣,因为这可能会使主题的创建超出运行集群的人的控制范围。更多详情请参见表 13-11。
表 13-11. 分区计数指标详情
| 指标名称 | 分区计数 |
|---|---|
| JMX MBean | kafka.server:type=ReplicaManager,name=PartitionCount |
| 值范围 | 整数,零或更大 |
领导者计数
领导者计数指标显示了经纪人当前担任领导者的分区数量。与经纪人中的大多数其他测量一样,这个指标在集群中的经纪人之间应该是均匀的。定期检查领导者计数非常重要,可能会对其进行警报,因为即使副本的数量和大小在整个集群中完全平衡,它也会指示集群不平衡的情况。这是因为经纪人可以因为许多原因而放弃对分区的领导地位,比如 ZooKeeper 会话过期,并且一旦恢复(除非您启用了自动领导者重新平衡),它不会自动重新担任领导地位。在这些情况下,这个指标将显示较少的领导者,或者经常是零,这表明您需要运行首选副本选举来重新平衡集群中的领导地位。更多详情请参见表 13-12。
表 13-12. 领导者计数指标详情
指标名称 | 领导者计数
| --- | --- |
| JMX MBean | kafka.server:type=ReplicaManager,name=LeaderCount |
| 值范围 | 整数,零或更大 |
使用这个指标的一个有用的方法是将它与分区计数一起使用,以显示经纪人担任领导者的分区的百分比。在使用复制因子为 2 的平衡集群中,所有经纪人应该担任大约 50%的分区的领导者。如果使用的复制因子是 3,这个百分比会下降到 33%。
脱机分区
除了未复制的分区计数之外,脱机分区计数是监控的关键指标(见表 13-13)。这个测量只由集群的控制器经纪人提供(所有其他经纪人将报告 0),显示了当前在集群中没有领导者的分区数量。没有领导者的分区可能有两个主要原因:
-
托管此分区副本的所有经纪人都已宕机
-
由于消息计数不匹配(关闭不干净的领导者选举),没有同步的副本可以领导
表 13-13. 脱机分区计数指标详情
| 指标名称 | 脱机分区计数 |
|---|---|
| JMX MBean | kafka.controller:type=KafkaController,name=OfflinePartitionsCount |
| 值范围 | 整数,零或更大 |
在生产 Kafka 集群中,脱机分区可能会影响生产者客户端,导致消息丢失或在应用程序中造成背压。这往往是一种“站点宕机”类型的问题,需要立即解决。
请求指标
Kafka 协议在第六章中描述了许多不同的请求。针对每个请求的性能提供了指标。截至 2.5.0 版本,以下请求提供了指标:
表 13-14. 请求指标名称
| AddOffsetsToTxn | AddPartitionsToTxn | AlterConfigs |
| AlterPartitionReassignments | AlterReplicaLogDirs | ApiVersions |
| ControlledShutdown | CreateAcls | CreateDelegationToken |
| CreatePartitions | CreateTopics | DeleteAcls |
| DeleteGroups | DeleteRecords | DeleteTopics |
| DescribeAcls | DescribeConfigs | DescribeDelegationToken |
| DescribeGroups | DescribeLogDirs | ElectLeaders |
| EndTxn | ExpireDelegationToken | Fetch |
| FetchConsumer | FetchFollower | FindCoordinator |
| Heartbeat | IncrementalAlterConfigs | InitProducerId |
| JoinGroup | LeaderAndIsr | LeaveGroup |
| ListGroups | ListOffsets | ListPartitionReassignments |
| Metadata | OffsetCommit | OffsetDelete |
| OffsetFetch | OffsetsForLeaderEpoch | Produce |
| RenewDelegationToken | SaslAuthenticate | SaslHandshake |
| StopReplica | SyncGroup | TxnOffsetCommit |
| UpdateMetadata | WriteTxnMarkers | |
对于每个请求,提供了八个度量标准,提供了对请求处理的每个阶段的洞察。例如,对于Fetch请求,在 Table 13-15 中显示的度量标准是可用的。
表 13-15。获取请求度量
| 名称 | JMX MBean |
|---|---|
| 总时间 | kafka.network:``type=RequestMetrics,name=TotalTimeMs,request=Fetch |
| 请求队列时间 | kafka.network:``type=RequestMetrics,name=RequestQueueTimeMs,request=Fetch |
| 本地时间 | kafka.network:``type=RequestMetrics,name=LocalTimeMs,request=Fetch |
| 远程时间 | kafka.network:``type=RequestMetrics,name=RemoteTimeMs,request=Fetch |
| 限流时间 | kafka.network:``type=RequestMetrics,name=ThrottleTimeMs,request=Fetch |
| 响应队列时间 | kafka.network:``type=RequestMetrics,name=ResponseQueueTimeMs,request=Fetch |
响应发送时间 | kafka.network:``type=RequestMetrics,name=ResponseSendTimeMs,request=Fetch |
| 每秒请求数 | kafka.network:``type=RequestMetrics,name=RequestsPerSec,request=Fetch |
每秒请求数度量是一个速率度量,如前所述,显示了在时间单位内接收和处理的该类型请求的总数。这提供了对每个请求时间频率的视图,尽管应该注意到,许多请求,如StopReplica和UpdateMetadata,是不频繁的。
七个时间度量标准为每个请求提供了一组百分位数,以及一个离散的Count属性,类似于速率度量标准。这些度量标准都是自代理启动以来计算的,因此在查看长时间不变的度量标准时要记住这一点;您的代理运行时间越长,数字就会越稳定。它们代表请求处理的部分是:
总时间
代理商处理请求所花费的总时间,从接收到发送响应给请求者
请求队列时间
请求在接收后但在处理开始之前在队列中花费的时间
本地时间
分区领导者处理请求所花费的时间,包括将其发送到磁盘(但不一定刷新它)
远程时间
在请求处理完成之前等待追随者所花费的时间
限流时间
响应必须保持的时间,以减慢请求者以满足客户端配额设置
响应队列时间
响应请求在发送给请求者之前在队列中花费的时间
响应发送时间
实际发送响应所花费的时间
每个度量标准提供的属性是:
计数
自进程启动以来请求的绝对数量
最小值
所有请求的最小值
最大值
所有请求的最大值
平均值
所有请求的平均值
标准差
请求时间测量的标准差
百分位数
50thPercentile, 75thPercentile, 95thPercentile, 98thPercentile, 99thPercentile, 999thPercentile
什么是百分位数?
百分位数是查看时间测量的常见方法。第 99 百分位数测量告诉我们,样本组(在本例中为请求时间)中 99%的所有值都小于指标的值。这意味着 1%的值大于指定的值。常见的模式是查看平均值和 99%或 99.9%的值。通过这种方式,您可以了解平均请求的性能以及异常值是什么。
在所有这些请求的指标和属性中,哪些是重要的要监视的?至少,您应该至少收集每种请求类型的总时间指标的平均值和较高百分位数(99%或 99.9%之一),以及每秒请求指标。这可以让您了解对 Kafka 经纪人的请求的整体性能。如果可以的话,您还应该为每种请求类型收集其他六个时间度量的测量,因为这将使您能够将任何性能问题缩小到请求处理的特定阶段。
对于设置警报阈值,时间度量可能会很困难。例如,Fetch请求的时间度量可能会因许多因素而大幅变化,包括客户端设置等待消息的时间、被获取的特定主题的繁忙程度以及客户端与经纪人之间的网络连接速度。然而,为至少Produce请求的总时间开发 99.9th 百分位数测量的基线值并对其进行警报可能非常有用。与未复制分区指标类似,Produce请求的 99.9th 百分位数急剧增加可能会提醒您存在各种性能问题。
主题和分区指标
除了经纪人上可用的许多指标来描述 Kafka 经纪人的运行情况外,还有特定于主题和分区的指标。在较大的集群中,这些指标可能很多,可能无法将它们全部收集到指标系统中作为正常操作的一部分。然而,它们对于调试客户端的特定问题非常有用。例如,主题指标可用于识别导致集群流量大幅增加的特定主题。还可能重要的是提供这些指标,以便 Kafka 的用户(生产者和消费者客户端)能够访问它们。无论您是否能够定期收集这些指标,您都应该知道哪些是有用的。
对于表 13-16 中的所有示例,我们将使用示例主题名称*TOPICNAME*,以及分区 0。在访问所描述的指标时,请确保替换适合您集群的主题名称和分区号。
每个主题的指标
对于所有每个主题的指标,测量非常类似于先前描述的经纪人指标。实际上,唯一的区别是提供的主题名称,以及指标将特定于命名的主题。鉴于可用的指标数量庞大,取决于集群中存在的主题数量,这些几乎肯定是您不希望为其设置监视和警报的指标。然而,它们对于提供给客户非常有用,以便他们可以评估和调试他们对 Kafka 的使用。
表 13-16。每个主题的指标
| 名称 | JMX MBean |
|---|---|
| 每秒输入字节数 | kafka.server:type=BrokerTopicMetrics,name=BytesInPerSec,topic=*TOPICNAME* |
| 每秒输出字节数 | kafka.server:type=BrokerTopicMetrics,name=BytesOutPerSec,topic=*TOPICNAME* |
| 失败获取率 | kafka.server:type=BrokerTopicMetrics,name=FailedFetchRequestsPerSec,topic=*TOPICNAME* |
| 失败生成率 | kafka.server:type=BrokerTopicMetrics,name=FailedProduceRequestsPerSec,topic=*TOPICNAME* |
| 每秒消息数 | kafka.server:type=BrokerTopicMetrics,name=MessagesInPerSec,topic=*TOPICNAME* |
| 获取请求速率 | kafka.server:type=BrokerTopicMetrics,name=TotalFetchRequestsPerSec,topic=*TOPICNAME* |
| 生产请求速率 | kafka.server:type=BrokerTopicMetrics,name=TotalProduceRequestsPerSec,topic=*TOPICNAME* |
每个分区的指标
每个分区的指标在持续基础上往往不如每个主题的指标有用。此外,它们相当多,因为数百个主题很容易成千上万个分区。尽管如此,在某些有限的情况下它们可能是有用的。特别是,分区大小指标指示当前在分区上保留在磁盘上的数据量(以字节为单位)(表 13-17)。结合起来,这些将指示单个主题保留的数据量,这对于将 Kafka 的成本分配给个别客户端可能是有用的。同一主题的两个分区大小之间的差异可能表明存在问题,即在生成时消息未均匀分布在使用的键上。日志段计数指标显示分区上磁盘上的日志段文件数。这可能与分区大小一起用于资源跟踪。
表 13-17。每个分区的指标
| 名称 | JMX MBean |
|---|---|
| 分区大小 | kafka.log:type=Log,name=Size,topic=*TOPICNAME*,partition=0 |
| 日志段计数 | kafka.log:type=Log,name=NumLogSegments,topic=*TOPICNAME*,partition=0 |
| 日志结束偏移量 | kafka.log:type=Log,name=LogEndOffset,topic=*TOPICNAME*,partition=0 |
| 日志起始偏移量 | kafka.log:type=Log,name=LogStartOffset,topic=*TOPICNAME*,partition=0 |
日志结束偏移量和日志起始偏移量指标分别是该分区中消息的最高和最低偏移量。然而,应该注意的是,这两个数字之间的差异并不一定表示分区中的消息数量,因为日志压缩可能导致已从分区中删除具有相同键的新消息而“丢失”的偏移量。在某些环境中,跟踪分区的这些偏移量可能是有用的。其中一个用例是提供时间戳到偏移量的更精细的映射,从而允许使用者客户端轻松地将偏移量回滚到特定时间(尽管在 Kafka 0.10.1 中引入了基于时间的索引搜索,这就不那么重要了)。
未复制分区指标
提供了一个每个分区的指标,用于指示分区是否未复制。一般来说,在日常操作中,这并不是非常有用,因为有太多的指标需要收集和监视。更容易的方法是监视整个代理范围内的未复制分区计数,然后使用命令行工具(在第十二章中描述)来确定未复制的特定分区。
JVM 监控
除了 Kafka 代理提供的指标之外,您还应该监视所有服务器以及 Java 虚拟机(JVM)本身的标准套件测量。这些将有助于警示您某种情况,例如增加的垃圾回收活动,这将降低代理的性能。它们还将提供有关为什么在代理中看到指标变化的见解。
垃圾回收
对于 JVM 来说,需要监控的关键事项是垃圾回收(GC)的状态。您必须监视此信息的特定 bean 将取决于您使用的特定 Java 运行时环境(JRE),以及正在使用的特定 GC 设置。对于在 Oracle Java 1.8 JRE 上运行 G1 垃圾回收的情况,应使用的 bean 显示在表 13-18 中。
表 13-18。G1 垃圾回收指标
| 名称 | JMX MBean |
|---|---|
| 完整 GC 周期 | java.lang:type=GarbageCollector,name=G1 Old Generation |
| 年轻 GC 周期 | java.lang:type=GarbageCollector,name=G1 Young Generation |
请注意,在 GC 的语义中,“Old”和“Full”是相同的。对于这些指标,要关注的两个属性是CollectionCount和CollectionTime。CollectionCount是自 JVM 启动以来该类型(完整或年轻)的 GC 周期数。CollectionTime是自 JVM 启动以来在该类型的 GC 周期中花费的时间(以毫秒为单位)。由于这些测量值是计数器,因此可以由度量系统用来告诉您每单位时间的 GC 周期数和 GC 花费的时间。它们还可以用来提供每个 GC 周期的平均时间,尽管在正常操作中这并不太有用。
每个指标还有一个LastGcInfo属性。这是一个复合值,由五个字段组成,为您提供有关由 bean 描述的 GC 类型的最后一个 GC 周期的信息。要查看的重要值是duration值,因为这告诉您上一个 GC 周期花费了多长时间(以毫秒为单位)。复合值中的其他值(GcThreadCount、id、startTime和endTime)是信息性的,并且没有太大用处。重要的是要注意,使用此属性,您将无法看到每个 GC 周期的时间,特别是年轻的 GC 周期可能经常发生。
Java 操作系统监控
JVM 可以通过java.lang:type=OperatingSystem bean 向您提供有关操作系统的一些信息。但是,这些信息有限,不能代表您需要了解的有关运行代理的系统的一切。可以在此处收集的两个有用属性,这些属性在操作系统中难以收集,分别是MaxFileDescriptorCount和OpenFileDescriptorCount属性。MaxFileDescriptorCount将告诉您 JVM 允许打开的文件描述符(FDs)的最大数量。OpenFileDescriptorCount属性告诉您当前打开的 FDs 的数量。每个日志段和网络连接都会打开 FDs,并且它们可能会迅速累积。无法正确关闭网络连接可能会导致代理迅速耗尽允许的数量。
操作系统监控
JVM 无法为我们提供关于其运行系统的所有信息。因此,我们不仅必须从代理收集经纪人的指标,还必须从操作系统本身收集指标。大多数监控系统将提供代理,这些代理将收集比您可能感兴趣的更多的操作系统信息。必须监视的主要领域是 CPU 使用率、内存使用率、磁盘使用率、磁盘 I/O 和网络使用率。
对于 CPU 利用率,至少要查看系统负载平均值。这提供了一个单一的数字,指示处理器的相对利用率。此外,捕获按类型分解的 CPU 使用率的百分比也可能很有用。根据收集方法和特定的操作系统,您可能具有以下 CPU 百分比分解中的一些或全部(使用的缩写提供):
us
在用户空间中所花费的时间
sy
在内核空间中所花费的时间
ni
低优先级进程所花费的时间
id
空闲时间
wa
在等待(在磁盘上)所花费的时间
hi
处理硬件中断所花费的时间
si
处理软件中断所花费的时间
st
等待虚拟处理器的时间
什么是系统负载?
虽然许多人知道系统负载是系统上 CPU 使用率的一个度量,但大多数人误解了它是如何被测量的。负载平均值是等待执行的进程数量的计数。Linux 还包括处于不可中断睡眠状态的线程,比如等待磁盘的线程。负载以三个数字呈现,这是在过去一分钟、5 分钟和 15 分钟内的平均计数。在单 CPU 系统中,值为 1 意味着系统负载 100%,始终有一个线程在等待执行。这意味着在多 CPU 系统上,表示 100%的负载平均数等于系统中的 CPU 数量。例如,如果系统中有 24 个处理器,100%将是 24 的负载平均值。
Kafka 代理在处理请求时使用了大量的处理。因此,在监控 Kafka 时,跟踪 CPU 利用率是很重要的。对于代理本身来说,内存跟踪不那么重要,因为 Kafka 通常会以相对较小的 JVM 堆大小运行。它将在堆之外使用少量内存进行压缩功能,但大部分系统内存将被留用于缓存。尽管如此,您应该跟踪内存利用率,以确保其他应用程序不会侵占代理。您还需要确保没有使用交换内存,通过监控总交换内存和空闲交换内存的数量。
在涉及 Kafka 时,磁盘是最重要的子系统。所有消息都持久保存在磁盘上,因此 Kafka 的性能严重依赖于磁盘的性能。监控磁盘空间和 inode(inode是 Unix 文件系统的文件和目录元数据对象)的使用情况很重要,因为您需要确保没有空间。这对于存储 Kafka 数据的分区尤为重要。还需要监控磁盘 I/O 统计信息,因为这将告诉我们磁盘是否被有效地使用。对于存储 Kafka 数据的磁盘,至少要监控每秒的读写次数,平均读写队列大小,平均等待时间和磁盘的利用率。
最后,监控代理的网络利用率。这只是入站和出站网络流量的数量,通常以每秒位数报告。请记住,发送到 Kafka 代理的每个位都将是等于主题的复制因子的出站位数,不包括消费者。根据消费者的数量,出站网络流量可能比入站流量容易大一个数量级。在设置警报阈值时要记住这一点。
日志
监控的讨论没有日志是不完整的。像许多应用程序一样,如果你允许的话,Kafka 代理将在几分钟内用日志消息填满磁盘。为了从日志中获得有用的信息,重要的是在正确的级别启用正确的记录器。通过简单地在“INFO”级别记录所有消息,您将捕获关于代理状态的大量重要信息。然而,为了提供一组更清洁的日志文件,有必要从中分离出一些记录器。
有两个记录器分别写入磁盘上的不同文件。第一个是 kafka.controller,仍然处于 INFO 级别。此记录器用于提供关于集群控制器的特定消息。任何时候,只有一个经纪人将成为控制器,因此只有一个经纪人将写入此记录器。信息包括主题创建和修改、经纪人状态更改以及集群活动,如首选副本选举和分区移动。另一个分开的记录器是 kafka.server.ClientQuotaManager,也处于 INFO 级别。此记录器用于显示与生产和消费配额活动相关的消息。虽然这是有用的信息,但最好不要将其放在主经纪人日志文件中。
记录日志以了解日志压缩线程的状态也是有帮助的。没有单个指标可以显示这些线程的健康状况,一个分区的压缩失败可能会完全停止日志压缩线程,并且悄无声息。在 DEBUG 级别启用 kafka.log.LogCleaner、kafka.log.Cleaner 和 kafka.log.LogCleanerManager 记录器将输出有关这些线程状态的信息。这将包括有关正在压缩的每个分区的信息,包括每个分区中的消息大小和数量。在正常操作下,这不是很多的日志记录,这意味着可以默认启用它而不会使您不堪重负。
还有一些日志记录可能在调试 Kafka 问题时有用。其中一个记录器是 kafka.request.logger,在 DEBUG 或 TRACE 级别打开。这将记录发送到经纪人的每个请求的信息。在 DEBUG 级别,日志包括连接端点、请求时间和摘要信息。在 TRACE 级别,它还将包括主题和分区信息——几乎所有请求信息,除了消息有效载荷本身。在任何级别,此记录器会生成大量数据,除非必要进行调试,否则不建议启用它。
客户端监控
所有应用程序都需要监控。实例化 Kafka 客户端(生产者或消费者)的应用程序具有应该捕获的特定于客户端的指标。本节涵盖了官方的 Java 客户端库,尽管其他实现应该有它们自己的可用测量。
生产者指标
Kafka 生产者客户端通过将可用的指标作为少量 JMX MBean 的属性而大大压缩了这些指标。相比之下,之前的生产者客户端(不再受支持)使用了更多的 MBean,但在许多指标中有更多的细节(提供了更多的百分位数测量和不同的移动平均值)。因此,提供的指标总数涵盖了更广泛的范围,但更难以跟踪异常值。
所有生产者指标在 bean 名称中都有生产者客户端的客户端 ID。在提供的示例中,这已被替换为 *CLIENTID*。其中 bean 名称包含经纪人 ID,这已被替换为 *BROKERID*。主题名称已被替换为 *TOPICNAME*。有关示例,请参见表 13-19。
表 13-19. Kafka 生产者指标 MBeans
| 名称 | JMX MBean |
|---|---|
| 总体生产者 | kafka.producer:type=producer-metrics,client-id=*CLIENTID* |
| 按经纪人 | kafka.producer:type=producer-node-metrics,client-id=*CLIENTID*,node-id=node-*BROKERID* |
| 按主题 | kafka.producer:type=producer-topic-metrics,client-id=*CLIENTID*,topic=*TOPICNAME* |
表 13-19 中的每个指标 bean 都有多个属性可用于描述生产者的状态。下一节将描述最有用的特定属性。在继续之前,请确保您了解生产者的工作语义,如第三章中所述。
总体生产者指标
总体生产者指标 bean 提供了描述消息批次大小到内存缓冲区利用率的属性。虽然所有这些测量都在调试中有其用处,但通常只有少数需要定期使用,其中只有少数需要监视并设置警报。请注意,虽然我们将讨论几个平均值的指标(以“-avg”结尾),但每个指标也有最大值(以“-max”结尾),其有限的用处。
record-error-rate是一个绝对需要设置警报的属性。这个指标应该始终为零,如果大于零,表示生产者正在丢弃它试图发送到 Kafka 代理的消息。生产者有一个配置的重试次数和重试之间的间隔,一旦耗尽,消息(这里称为记录)将被丢弃。还有一个 record-retry-rate属性可以进行跟踪,但它不像错误率那么关键,因为重试是正常的。
另一个要警报的指标是 request-latency-avg。这是发送到代理的生成请求所花费的平均时间。您应该能够建立正常操作中此数字应该是多少的基线值,并设置高于该值的警报阈值。请求延迟的增加意味着生成请求变慢。这可能是由于网络问题,也可能表明代理存在问题。无论哪种情况,这都是一个性能问题,会在生成应用程序中引起背压和其他问题。
除了这些关键指标之外,了解生产者发送了多少消息流量总是很有用的。三个属性将提供这方面的三种不同视图。 outgoing-byte-rate描述了每秒以字节为单位的绝对大小的消息。 record-send-rate描述了每秒产生的消息数量。最后,request-rate提供了每秒发送到代理的生成请求的数量。单个请求包含一个或多个批次。单个批次包含一个或多个消息。当然,每个消息由一些字节组成。这些指标都对应用程序仪表板上有用。
还有一些描述记录、请求和批处理大小的指标。 request-size-avg指标提供了发送到代理的生产请求的平均大小(以字节为单位)。 batch-size-avg提供了单个消息批次的平均大小(根据定义,批次由单个主题分区的消息组成),以字节为单位。 record-size-avg显示单个记录的平均大小(以字节为单位)。对于单个主题的生产者,这提供了有关生成的消息的有用信息。对于多主题的生产者,例如 MirrorMaker,这种信息就不那么有用了。除了这三个指标之外,还有一个 records-per-request-avg指标,描述了单个生成请求中的消息平均数量。
建议的最后一个总体生产者指标属性是 record-queue-time-avg。这个度量是在应用程序发送消息后,消息在生产者中等待的平均时间(以毫秒为单位),直到实际产生到 Kafka。应用程序调用生产者客户端发送消息(通过调用 send 方法)后,生产者会等待直到发生以下两种情况之一:
-
它有足够的消息来填充基于
batch.size配置的批处理。 -
根据
linger.ms配置,自上次发送批处理以来已经过了足够长的时间。
这两者中的任何一个都将导致生产者客户端关闭它正在构建的当前批次并将其发送到代理。最容易理解的方法是,对于繁忙的主题,将应用第一个条件,而对于慢速主题,将应用第二个条件。record-queue-time-avg测量将指示消息需要多长时间才能被生成,因此在调整这两个配置以满足应用程序的延迟要求时是有帮助的。
每个代理和每个主题的度量
除了整体生产者度量之外,还有度量 bean 为与每个 Kafka 代理的连接以及正在生成的每个主题提供了一组有限的属性。在某些情况下,这些测量对于调试问题是有用的,但这些不是您希望经常审查的度量。这些 bean 上的所有属性与先前描述的整体生产者 bean 的属性相同,并且具有与先前描述相同的含义(除了它们适用于特定代理或特定主题)。
每个代理生产者度量提供的最有用的度量是request-latency-avg测量。这是因为这个度量大部分时间会保持稳定(假设消息的批处理稳定),并且仍然可以显示与特定代理的连接问题。其他属性,例如outgoing-byte-rate和request-latency-avg,往往会根据每个代理领导的分区而变化。这意味着这些测量在任何时间点上“应该”是什么,可以很快地改变,具体取决于 Kafka 集群的状态。
主题度量比每个代理度量更有趣,但只对使用多个主题的生产者有用。如果生产者不使用很多主题,这些度量也只能在常规情况下使用。例如,MirrorMaker 可能会生成数百个或数千个主题。很难审查所有这些度量,并且几乎不可能对其设置合理的警报阈值。与每个代理度量一样,当调查特定问题时,每个主题的测量最好用于。例如,例如,record-send-rate和record-error-rate属性可以用于将丢弃的消息隔离到特定主题(或验证是否跨所有主题)。此外,还有一个byte-rate度量,它提供了主题每秒字节的整体消息速率。
消费者度量
与生产者客户端类似,Kafka 中的消费者将许多指标合并为几个度量 bean 上的属性。这些度量也消除了延迟的百分位数和速率的移动平均值,这些在已弃用的 Scala 消费者中呈现,类似于生产者客户端。在消费者中,由于处理消息消费的逻辑比仅仅将消息发送到 Kafka 代理更复杂,因此还有一些更多的度量要处理。有关更多信息,请参见表 13-20。
13-20 表。Kafka 消费者度量 MBeans
| 名称 | JMX MBean |
|---|---|
| 整体消费者 | kafka.consumer:type=consumer-metrics,client-id=*CLIENTID* |
| 获取管理器 | kafka.consumer:type=consumer-fetch-manager-metrics,client-id=*CLIENTID* |
| 每个主题 | kafka.consumer:type=consumer-fetch-manager-metrics,client-id=*CLIENTID*,topic=*TOPICNAME* |
| 每个代理 | kafka.consumer:type=consumer-node-metrics,client-id=*CLIENTID*,node-id=node-*BROKERID* |
| 协调员 | kafka.consumer:type=consumer-coordinator-metrics,client-id=*CLIENTID* |
获取管理器度量
在消费者客户端中,总体消费者度量 bean 对我们来说不太有用,因为我们感兴趣的度量位于fetch manager bean 中。总体消费者 bean 具有有关较低级别网络操作的度量,但 fetch manager bean 具有有关字节、请求和记录速率的度量。与生产者客户端不同,消费者提供的度量是有用的,但不适合设置警报。
对于 fetch manager,您可能希望设置监视和警报的一个属性是fetch-latency-avg。与生产者客户端中的等效request-latency-avg一样,这个度量告诉我们向经纪人发出的获取请求需要多长时间。对此度量进行警报的问题在于延迟受消费者配置fetch.min.bytes和fetch.max.wait.ms的控制。一个慢的主题将具有不稳定的延迟,因为有时经纪人会快速响应(当有消息可用时),有时它将不会在fetch.max.wait.ms内响应(当没有消息可用时)。在消费具有更规律和丰富的消息流量的主题时,这个度量可能更有用。
等等!没有滞后?
对所有消费者的最佳建议是,您必须监视消费者滞后。那么为什么我们不建议监视 fetch manager bean 上的records-lag-max属性?这个度量显示了当前滞后(消费者偏移和经纪人日志结束偏移之间的差异),对于最滞后的分区。
这个问题有两个方面:它只显示一个分区的滞后,并且依赖于消费者的正常运行。如果没有其他选择,可以使用此属性进行滞后并设置警报。但最佳做法是使用外部滞后监视,如“滞后监视”中所述。
要了解您的消费者客户端处理了多少消息流量,您应该捕获bytes-consumed-rate或records-consumed-rate,或者最好两者兼而有之。这些度量描述了此客户端实例每秒消耗的消息流量,分别以字节和每秒消息计算。一些用户对这些度量设置了最低阈值以进行警报,以便在消费者未能完成足够工作时收到通知。但是,在执行此操作时,您应该小心。Kafka 旨在解耦消费者和生产者客户端,使它们能够独立运行。消费者能够消费消息的速率通常取决于生产者是否正常工作,因此在消费者上监视这些度量会对生产者的状态进行假设。这可能会导致对消费者客户端的错误警报。
了解字节、消息和请求之间的关系也很重要,fetch manager 提供了帮助的度量。fetch-rate度量告诉我们消费者每秒执行的获取请求数量。fetch-size-avg度量给出了这些获取请求的平均大小(以字节为单位)。最后,records-per-request-avg度量给出了每个获取请求中的平均消息数。请注意,消费者没有提供与生产者record-size-avg度量相当的度量,以告诉我们消息的平均大小。如果这很重要,您需要从其他可用的度量中推断出来,或者在从消费者客户端库接收消息后在应用程序中捕获它。
每个经纪人和每个主题的度量
与生产者客户端一样,消费者客户端为每个经纪人连接和每个被消费的主题提供的指标对于调试消费问题非常有用,但可能不会是您每天审查的测量值。与获取管理器一样,由每个经纪人指标 bean 提供的request-latency-avg属性具有有限的用处,具体取决于您正在消费的主题中的消息流量。incoming-byte-rate和request-rate指标将获取管理器提供的消耗消息指标分解为每个经纪人每秒字节和每秒请求的测量值。这些可以用于帮助隔离消费者与特定经纪人连接存在的问题。
消费者客户端提供的按主题划分的指标在消费多个主题时非常有用。否则,这些指标将与获取管理器的指标相同,并且收集起来是多余的。另一方面,如果客户端正在消费多个主题(例如 Kafka MirrorMaker),这些指标将很难进行审查。如果您计划收集它们,那么收集最重要的指标是bytes-consumed-rate、records-consumed-rate和fetch-size-avg。bytes-consumed-rate显示每秒针对特定主题消耗的绝对字节大小,而records-consumed-rate显示相同信息,但是以消息数量为单位。fetch-size-avg提供了每个主题的平均获取请求大小(以字节为单位)。
消费者协调器指标
如第四章所述,消费者客户端通常作为消费者组的一部分共同工作。该组具有协调活动,例如组成员加入,以及向经纪人发送心跳消息以维护组成员资格。消费者协调器是负责处理这项工作的消费者客户端的一部分,并且它维护自己的一组指标。与所有指标一样,提供了许多数字,但只有少数几个关键数字需要定期监视。
由于协调员活动而导致消费者可能遇到的最大问题是在消费暂停时,消费者组进行同步。这是消费者组实例协商由哪些个体客户端实例消费哪些分区的过程。根据正在消费的分区数量,这可能需要一些时间。协调员提供了sync-time-avg属性,它是同步活动所需的平均时间(以毫秒为单位)。捕获sync-rate属性也很有用,它是每秒发生的组同步次数。对于稳定的消费者组,这个数字大部分时间应该是零。
消费者需要提交偏移量以检查其在消费消息时的进度,可以自动定期提交,也可以通过应用程序代码中触发的手动检查点来提交。这些提交本质上只是生产请求(尽管它们有自己的请求类型),因为偏移量提交是发送到特殊主题的消息。消费者协调器提供了commit-latency-avg属性,用于测量偏移量提交所需的平均时间。您应该像监视生产者的请求延迟一样监视这个值。应该能够建立此指标的基线预期值,并设置合理的阈值,以便在该值之上发出警报。
最后一个有用的协调器指标是assigned-partitions。这是消费者客户端(作为消费者组中的单个实例)被分配消费的分区数量。这很有帮助,因为与消费者组中其他消费者客户端的此指标相比,可以看到整个消费者组的负载平衡。我们可以使用这个来识别可能由消费者协调器用于将分区分配给组成员的算法中的问题引起的不平衡。
配额
Apache Kafka 具有限制客户端请求的能力,以防止一个客户端压倒整个集群。这对生产者和消费者客户端都是可配置的,并且以每秒允许从单个客户端 ID 到单个代理的流量量来表示。有一个代理配置,为所有客户端设置默认值,以及可以动态设置的每个客户端覆盖。当代理计算出客户端已超出其配额时,它通过将响应保持在客户端足够长的时间来减慢客户端的速度,以使客户端保持在配额以下。
Kafka 代理在响应中不使用错误代码来指示客户端被限制。这意味着应用程序在没有监视提供的指标来显示客户端被限制的时间量时,不明显地发生了限制。必须监视的指标显示在表 13-21 中。
表 13-21. 要监视的指标
| 客户端 | Bean 名称 |
|---|---|
| 消费者 | bean kafka.consumer:type=consumer-fetch-manager-metrics,client-id=CLIENTID, 属性 fetch-throttle-time-avg |
| 生产者 | bean kafka.producer:type=producer-metrics,client-id=CLIENTID, 属性 produce-throttle-time-avg |
Kafka 代理默认情况下未启用配额,但无论您当前是否使用配额,监视这些指标都是安全的。监视它们是一个好习惯,因为它们可能在将来的某个时候被启用,而且从监视它们开始要比以后添加指标更容易。
滞后监控
对于 Kafka 消费者,最重要的监控是消费者滞后。以消息数量衡量,这是特定分区中最后一条消息产生与消费者处理的最后一条消息之间的差异。虽然这个主题通常会在前一节关于消费者客户端监控中涵盖,但这是一个外部监控远远超过客户端本身提供的情况之一。如前所述,消费者客户端中有一个滞后指标,但使用它是有问题的。它只代表一个分区,即滞后最严重的分区,因此无法准确显示消费者滞后的程度。此外,它需要消费者的正常运行,因为该指标是由消费者在每次获取请求时计算的。如果消费者出现故障或离线,该指标要么不准确,要么不可用。
消费者滞后监控的首选方法是有一个外部进程,可以监视代理上分区的状态,跟踪最近生成的消息的偏移量,以及消费者的状态,跟踪消费者组为分区提交的最后偏移量。这提供了一个客观的视图,可以更新,而不受消费者本身状态的影响。必须对消费者组消费的每个分区执行此检查。对于像 MirrorMaker 这样的大型消费者,这可能意味着数万个分区。
第十二章提供了使用命令行实用程序获取消费者组信息,包括提交的偏移量和滞后的信息。然而,像这样监控滞后也存在自己的问题。首先,您必须了解对于每个分区,什么是合理的滞后量。每小时接收 100 条消息的主题将需要与每秒接收 100,000 条消息的主题不同的阈值。然后,您必须能够将所有滞后指标消耗到监控系统中,并对其设置警报。如果您有一个消费者组在 1,500 个主题上消费 100,000 个分区,您可能会发现这是一项艰巨的任务。
监控消费者组减少这种复杂性的一种方法是使用Burrow。这是一个开源应用程序,最初由 LinkedIn 开发,它通过收集集群中所有消费者组的滞后信息并计算每个组的单个状态来提供消费者状态监控,指出消费者组是否正常工作、落后或完全停滞或停止。它可以在不需要阈值的情况下监控消费者组在处理消息时的进度,尽管您也可以获得消息滞后的绝对数量。关于 Burrow 工作原理和方法背后的深入讨论可以在LinkedIn 工程博客上找到。部署 Burrow 可以轻松为集群中的所有消费者提供监控,以及在多个集群中,并且可以轻松集成到您现有的监控和警报系统中。
如果没有其他选择,消费者客户端的records-lag-max指标至少可以提供部分消费者状态的视图。然而,强烈建议您像 Burrow 这样利用外部监控系统。
端到端监控
推荐的另一种外部监控类型是端到端监控系统,它提供了客户端对 Kafka 集群健康状况的视角。消费者和生产者客户端具有可以指示 Kafka 集群可能存在问题的指标,但这可能是一个猜测游戏,增加的延迟是由于客户端、网络还是 Kafka 本身的问题。此外,这意味着如果您负责运行 Kafka 集群,而不是客户端,那么现在您还必须监控所有客户端。您真正需要知道的是:
-
我可以向 Kafka 集群生产消息吗?
-
我可以从 Kafka 集群中消费消息吗?
在理想的情况下,您将能够为每个主题单独监控这一点。然而,在大多数情况下,为了做到这一点,向每个主题注入合成流量是不合理的。然而,至少我们可以为集群中的每个代理提供这些答案,这就是Xinfra Monitor(以前称为 Kafka Monitor)所做的。这个工具是由 LinkedIn 的 Kafka 团队开源的,它不断地从跨越集群中所有代理的主题中生成和消费数据。它测量了每个代理上生产和消费请求的可用性,以及总的生产到消费延迟。这种类型的监控对于能够外部验证 Kafka 集群是否按预期运行是非常宝贵的,因为就像消费者滞后监控一样,Kafka 代理无法报告客户端是否能够正确使用集群。
总结
监控是正确运行 Apache Kafka 的关键方面,这解释了为什么许多团队花费大量时间完善运维的这一部分。许多组织使用 Kafka 来处理 PB 级别的数据流。确保数据不会停止,消息不会丢失,这是一个关键的业务需求。我们还有责任通过提供用户需要的指标来协助用户监控他们的应用程序如何使用 Kafka。
在本章中,我们介绍了如何监控 Java 应用程序的基础知识,特别是 Kafka 应用程序。我们回顾了 Kafka broker 中可用的众多指标的子集,还涉及了 Java 和操作系统的监控,以及日志记录。然后,我们详细介绍了 Kafka 客户端库中可用的监控,包括配额监控。最后,我们讨论了使用外部监控系统进行消费者滞后监控和端到端集群可用性。虽然这当然不是所有可用指标的详尽列表,但本章回顾了需要密切关注的最关键的指标。
第十四章:流处理
传统上,Kafka 被视为一个强大的消息总线,能够传递事件流,但没有处理或转换能力。Kafka 可靠的流传递能力使其成为流处理系统的完美数据源。Apache Storm、Apache Spark Streaming、Apache Flink、Apache Samza 等许多流处理系统都是以 Kafka 作为它们唯一可靠的数据源构建的。
随着 Apache Kafka 的日益流行,首先作为一个简单的消息总线,后来作为一个数据集成系统,许多公司拥有一个包含许多有趣数据流的系统,存储了很长时间并且完全有序,只等待一些流处理框架出现并处理它们。换句话说,就像在数据库发明之前数据处理要困难得多一样,流处理也因缺乏流处理平台而受到阻碍。
从版本 0.10.0 开始,Kafka 不仅为每个流行的流处理框架提供可靠的数据流源。现在,Kafka 还包括一个强大的流处理库作为其客户端库集合的一部分,称为 Kafka Streams(有时称为 Streams API)。这使开发人员可以在其自己的应用程序中消费、处理和生成事件,而无需依赖外部处理框架。
我们将从解释我们所说的流处理是什么开始这一章(因为这个术语经常被误解),然后讨论流处理的一些基本概念和所有流处理系统共同的设计模式。然后我们将深入讨论 Apache Kafka 的流处理库——它的目标和架构。我们将举一个小例子,说明如何使用 Kafka Streams 来计算股票价格的移动平均值。然后我们将讨论其他一些良好的流处理用例示例,并在本章结束时提供一些标准,供您在选择与 Apache Kafka 一起使用的流处理框架(如果有的话)时使用。
本章只是对流处理和 Kafka Streams 这个广阔而迷人的世界的一个简要介绍。有整本书专门讨论这些主题。
一些书籍从数据架构的角度涵盖了流处理的基本概念:
-
Making Sense of Stream Processing 由 Martin Kleppmann(O'Reilly)讨论了重新思考应用程序作为流处理应用程序的好处,以及如何围绕事件流的概念重新定位数据架构。
-
Streaming Systems 由 Tyler Akidau、Slava Chernyak 和 Reuven Lax(O'Reilly)是关于流处理主题的一个很好的概论,介绍了该领域的一些基本概念。
-
Flow Architectures 由 James Urquhart(O'Reilly)面向 CTO,讨论了流处理对业务的影响。
其他书籍涉及特定框架的具体细节:
-
Mastering Kafka Streams and ksqlDB 由 Mitch Seymour(O'Reilly)
-
Kafka Streams in Action 由 William P. Bejeck Jr.(Manning)
-
Event Streaming with Kafka Streams and ksqlDB 由 William P. Bejeck Jr.(Manning)
-
Stream Processing with Apache Flink 由 Fabian Hueske 和 Vasiliki Kalavri(O'Reilly)
-
Stream Processing with Apache Spark 由 Gerard Maas 和 Francois Garillot(O'Reilly)
最后,Kafka Streams 仍然是一个不断发展的框架。每个主要版本的发布都会弃用 API 并修改语义。本章记录了 Apache Kafka 2.8 的 API 和语义。我们避免使用计划在 3.0 版本中弃用的任何 API,但我们对连接语义和时间戳处理的讨论不包括计划在 3.0 版本中的任何更改。
什么是流处理?
对于流处理的含义存在很多混淆。许多定义混淆了实现细节、性能要求、数据模型和软件工程的许多其他方面。在关系数据库领域也发生了类似的事情——关系模型的抽象定义不断地与流行的数据库引擎的实现细节和特定限制纠缠在一起。
流处理的世界仍在不断发展,仅仅因为特定的流行实现以特定的方式执行任务或具有特定的限制,并不意味着这些细节是数据流处理的固有部分。
让我们从头开始:什么是数据流(也称为事件流或流数据)?首先,数据流是表示无界数据集的抽象。无界意味着无限和不断增长。数据集是无界的,因为随着时间的推移,新的记录不断到达。这个定义被Google、Amazon和几乎所有其他人使用。
请注意,这个简单的模型(事件流)可以用来表示我们关心分析的几乎每一个业务活动。我们可以查看信用卡交易流、股票交易、包裹递送、通过交换机的网络事件、制造设备传感器报告的事件、发送的电子邮件、游戏中的动作等。例子的列表是无穷无尽的,因为几乎一切都可以被看作是一系列事件。
除了其无界特性之外,事件流模型还有其他一些属性:
事件流是有序的
事件发生的先后顺序是固有的概念。在观察财务事件时,这一点最为明显。首先将钱存入账户,然后再花钱的顺序与首先花钱,然后再通过存钱来偿还债务的顺序是非常不同的。后者会产生透支费用,而前者则不会。请注意,这是事件流和数据库表之间的一个区别——表中的记录总是被视为无序的,“order by”子句不是关系模型的一部分;它是为了帮助报告而添加的。
不可变的数据记录
事件一旦发生,就永远不能被修改。取消的财务交易不会消失。相反,会向流中写入一个额外的事件,记录了之前交易的取消。当顾客将商品退回商店时,我们不会删除之前卖给他们的商品的事实,而是将退货记录为一个额外的事件。这是数据流和数据库表之间的另一个区别——我们可以删除或更新表中的记录,但这些都是在数据库中发生的额外交易,并且可以记录在记录所有交易的事件流中。如果您熟悉数据库中的 binlogs、WALs 或重做日志,您会发现,如果我们向表中插入记录,然后将其删除,表将不再包含该记录,但重做日志将包含两个事务——插入和删除。
事件流是可重放的
这是一个可取的特性。虽然很容易想象不可重放的流(通过套接字传输的 TCP 数据包通常是不可重放的),但对于大多数业务应用程序来说,能够重放发生几个月(有时甚至几年)前的原始事件流至关重要。这是为了纠正错误、尝试新的分析方法或进行审计。这就是我们认为 Kafka 在现代企业中如此成功的流处理的原因——它允许捕获和重放事件流。如果没有这种能力,流处理将不会超过数据科学家的实验室玩具。
值得注意的是,无论事件流的定义还是我们后来列出的属性都没有提到事件中包含的数据或每秒事件的数量。数据因系统而异 - 事件可以很小(有时只有几个字节),也可以很大(具有许多标头的 XML 消息);它们也可以是完全无结构的键值对、半结构化的 JSON 或结构化的 Avro 或 Protobuf 消息。虽然人们经常认为数据流是“大数据”,涉及每秒数百万事件,但我们将讨论的相同技术同样适用(而且通常更好)于每秒或每分钟只有几个事件的较小事件流。
现在我们知道了事件流是什么,是时候确保我们理解流处理了。流处理是指一个或多个事件流的持续处理。流处理是一种编程范式 - 就像请求-响应和批处理一样。让我们看看不同的编程范式如何比较,以更好地理解流处理如何融入软件架构中:
请求-响应
这是最低延迟的范式,响应时间从亚毫秒到几毫秒不等,通常期望响应时间高度一致。处理模式通常是阻塞的 - 应用程序发送请求并等待处理系统响应。在数据库世界中,这种范式被称为在线事务处理(OLTP)。销售点系统、信用卡处理和时间跟踪系统通常在这种范式下工作。
批处理
这是高延迟/高吞吐量的选项。处理系统在设定的时间唤醒 - 每天凌晨 2:00、每小时整点等。它读取所有必需的输入(自上次执行以来的所有数据,自月初以来的所有数据等),写入所有必需的输出,然后在下次计划运行之前离开。处理时间从几分钟到几小时不等,用户期望在查看结果时读取过时数据。在数据库世界中,这些是数据仓库和商业智能系统 - 数据每天一次以大批量加载,生成报告,用户在下次数据加载之前查看相同的报告。这种范式通常具有很高的效率和规模经济,但近年来,企业需要更短时间内可用的数据,以使决策更及时和高效。这给了被编写为利用规模经济而不是提供低延迟报告的系统带来巨大压力。
流处理
这是一个持续且非阻塞的选项。流处理填补了请求-响应世界和批处理世界之间的差距,在请求-响应世界中,我们等待需要两毫秒处理的事件,在批处理世界中,数据一天处理一次,需要八小时才能完成。大多数业务流程不需要在毫秒内立即响应,但也不能等到第二天。大多数业务流程是持续发生的,只要业务报告持续更新,业务应用程序可以持续响应,处理就可以进行,而无需等待特定的毫秒级响应。例如,对可疑的信用交易或网络活动进行警报、根据供需实时调整价格,或跟踪包裹的交付等业务流程都非常适合持续但非阻塞的处理。
重要的是要注意,该定义不强制使用任何特定的框架、API 或功能。只要我们不断地从无界数据集中读取数据,对其进行处理并发出输出,我们就在进行流处理。但处理必须是持续的。每天凌晨 2:00 开始的过程,从流中读取 500 条记录,输出结果,然后消失,这在流处理方面并不够。
流处理概念
流处理与任何类型的数据处理非常相似——我们编写代码接收数据,对数据进行处理(一些转换、聚合、丰富等),然后将结果放在某个地方。然而,有一些关键概念是流处理特有的,当有数据处理经验的人首次尝试编写流处理应用程序时,这些概念通常会引起混淆。让我们来看看其中的一些概念。
拓扑结构
流处理应用包括一个或多个处理拓扑。处理拓扑从一个或多个源流开始,通过连接的事件流通过流处理器的图形,直到结果被写入一个或多个汇流流。每个流处理器都是应用于事件流的计算步骤,以转换事件。我们在示例中将使用的一些流处理器的示例包括过滤器、计数、分组和左连接。我们经常通过绘制处理节点并用箭头连接它们来可视化流处理应用,以显示事件如何从一个节点流向下一个节点,同时应用正在处理数据。
时间
时间可能是流处理中最重要的概念,也是最令人困惑的概念。在讨论分布式系统时,时间可能变得非常复杂,我们建议阅读 Justin Sheehy 的优秀论文“There Is No Now”。在流处理的上下文中,具有共同的时间概念至关重要,因为大多数流应用程序对时间窗口执行操作。例如,我们的流应用程序可能计算股票价格的移动五分钟平均值。在这种情况下,当我们的生产者由于网络问题下线两个小时并返回两小时的数据时,我们需要知道该如何处理——大部分数据将与已经过去并且结果已经计算和存储的五分钟时间窗口相关。
流处理系统通常涉及以下时间概念:
事件时间
这是我们正在跟踪的事件发生的时间和记录创建的时间——测量时间、商店销售商品的时间、用户在我们网站上查看页面的时间等。在 0.10.0 版本及以后,Kafka 会自动在生产者记录中添加当前时间。如果这与应用程序的事件时间不匹配,比如在某些情况下,Kafka 记录是基于事件发生后的某个时间创建的数据库记录,那么我们建议在记录本身中添加事件时间字段,以便以后处理时两个时间戳都可用。事件时间通常是处理流数据时最重要的时间。
日志追加时间
这是事件到达 Kafka 代理并在那里存储的时间,也称为摄取时间。在 0.10.0 版本及以后,如果 Kafka 配置为这样做,或者记录来自旧的生产者并且不包含时间戳,Kafka 代理将自动将此时间添加到它们接收的记录中。这种时间概念通常对流处理来说不太相关,因为我们通常对事件发生的时间感兴趣。例如,如果我们计算每天生产的设备数量,我们希望计算实际在当天生产的设备数量,即使存在网络问题,事件直到第二天才到达 Kafka。然而,在真实事件时间未记录的情况下,日志追加时间仍然可以被一致使用,因为在记录创建后不会改变,并且假设管道没有延迟,它可以是事件时间的一个合理近似值。
处理时间
这是流处理应用程序接收事件以执行某些计算的时间。这个时间可以是事件发生后的毫秒、小时或天数。这种时间概念根据每个流处理应用程序读取事件的确切时间为同一事件分配不同的时间戳。甚至在同一应用程序的两个线程中也可能不同!因此,这种时间概念非常不可靠,最好避免使用。
Kafka Streams 根据TimestampExtractor接口为每个事件分配时间。Kafka Streams 应用程序的开发人员可以使用此接口的不同实现,这些实现可以使用先前解释的三种时间语义之一,或者完全不同的时间戳选择,包括从事件内容中提取时间戳。
当 Kafka Streams 将输出写入 Kafka 主题时,它根据以下规则为每个事件分配时间戳:
-
当输出记录直接映射到输入记录时,输出记录将使用与输入相同的时间戳。
-
当输出记录是聚合的结果时,输出记录的时间戳将是聚合中使用的最大时间戳。
-
当输出记录是两个流的连接结果时,输出记录的时间戳是两个记录中较大的时间戳。当流和表进行连接时,使用流记录的时间戳。
-
最后,如果输出记录是由 Kafka Streams 函数生成的,该函数会根据特定的时间表生成数据,而不考虑输入,例如
punctuate(),输出时间戳将取决于流处理应用程序的当前内部时间。
当使用 Kafka Streams 的低级处理 API 而不是 DSL 时,Kafka Streams 包括用于直接操作记录时间戳的 API,因此开发人员可以实现与应用程序所需业务逻辑相匹配的时间戳语义。
注意时区
在处理时间时,重要的是要注意时区。整个数据管道应该统一使用一个时区;否则,流操作的结果将会令人困惑并且通常毫无意义。如果必须处理具有不同时区的数据流,您需要确保在对时间窗口执行操作之前可以将事件转换为单一时区。通常这意味着将时区存储在记录本身中。
状态
只要我们只需要单独处理每个事件,流处理就是一项非常简单的活动。例如,如果我们只需要从 Kafka 中读取在线购物交易流,找到超过 1 万美元的交易,并向相关销售人员发送电子邮件,我们可能只需要使用 Kafka 消费者和 SMTP 库就可以写出几行代码。
当我们进行涉及多个事件的操作时,流处理变得非常有趣:按类型计数事件的数量,移动平均值,将两个流连接起来创建丰富的信息流等。在这些情况下,仅仅查看每个事件是不够的;我们需要跟踪更多的信息——我们在这个小时内看到了多少个类型的事件,所有需要连接的事件,总和,平均值等。我们称这些信息为状态。
在流处理应用程序中,通常会诱人地将状态存储在本地变量中,例如使用简单的哈希表来存储移动计数。实际上,在本书的许多示例中,我们就是这样做的。然而,这并不是流处理中管理状态的可靠方法,因为当流处理应用程序停止或崩溃时,状态会丢失,从而改变结果。这通常不是期望的结果,因此应该小心地持久化最近的状态,并在重新启动应用程序时恢复它。
流处理涉及几种类型的状态:
本地或内部状态
只能由特定流处理应用程序实例访问的状态。这种状态通常是使用嵌入式内存数据库在应用程序内部维护和管理的。本地状态的优势在于它非常快速。缺点是我们受限于可用内存的数量。因此,流处理中的许多设计模式都专注于将数据分区为可以使用有限本地状态处理的子流。
外部状态
在外部数据存储中维护的状态,通常是像 Cassandra 这样的 NoSQL 系统。外部状态的优势在于其几乎无限的大小,以及可以从应用程序的多个实例甚至不同的应用程序中访问。缺点是额外的延迟和复杂性,以及额外系统引入的可用性——应用程序需要处理外部系统不可用的可能性。大多数流处理应用程序都试图避免处理外部存储,或者至少通过在本地状态中缓存信息并尽可能少地与外部存储通信来限制延迟开销。这通常会引入在内部和外部状态之间保持一致性的挑战。
流-表二元性
我们都熟悉数据库表。表是记录的集合,每个记录由其主键标识,并包含由模式定义的一组属性。表记录是可变的(即,表允许更新和删除操作)。查询表允许检查特定时间点的数据状态。例如,通过查询数据库中的CUSTOMERS_CONTACTS表,我们期望找到所有客户的当前联系方式。除非表专门设计包含历史记录,否则我们在表中找不到他们过去的联系方式。
与表不同,流包含一系列变化的历史。流是一系列事件,其中每个事件都引起了变化。表包含了世界的当前状态,这是许多变化的结果。从这个描述中,很明显流和表是同一个硬币的两面——世界总是在变化,有时我们对引起这些变化的事件感兴趣,而其他时候我们对世界的当前状态感兴趣。允许我们在这两种数据观察方式之间进行转换的系统比只支持一种方式的系统更强大。
要将表转换为流,我们需要捕获修改表的更改。获取所有这些insert、update和delete事件,并将它们存储在流中。大多数数据库都提供了用于捕获这些更改的变更数据捕获(CDC)解决方案,还有许多 Kafka 连接器可以将这些更改传输到 Kafka 中,以便进行流处理。
要将流转换为表,我们需要应用流包含的所有更改。这也称为实现流。我们创建一个表,可以是在内存中、内部状态存储中或外部数据库中,并开始从头到尾遍历流中的所有事件,随着遍历的进行而改变状态。完成后,我们有一个表示特定时间状态的表,可以使用。
假设我们有一家出售鞋子的商店。我们零售活动的流表示可以是一系列事件的流:
-
“装运到达,带有红色、蓝色和绿色鞋子。”
-
“蓝鞋卖出。”
-
“红鞋卖出。”
-
“蓝鞋退货。”
-
“绿鞋卖出。”
如果我们想知道我们的库存现在包含什么,或者到目前为止我们赚了多少钱,我们需要实现视图。图 14-1 显示我们目前有 299 双红鞋。如果我们想知道商店有多忙,我们可以查看整个流,并看到今天有四个客户事件。我们可能还想调查为什么蓝鞋被退回。

图 14-1:材料库存变化
时间窗口
流上的大多数操作都是窗口操作,操作时间片:移动平均线,本周销售的热门产品,系统上的 99th 百分位负载等。两个流的连接操作也是窗口化的——我们连接在同一时间片发生的事件。很少有人停下来思考他们的操作需要哪种类型的窗口。例如,当计算移动平均线时,我们想知道:
窗口的大小
我们想要计算每个五分钟窗口内所有事件的平均值吗?每个 15 分钟窗口?还是整天?较大的窗口更平滑,但滞后更多——如果价格上涨,要比较小的窗口需要更长的时间才能注意到。Kafka Streams 还包括会话窗口,其中窗口的大小由不活动的时间段定义。开发人员定义了会话间隔,所有连续到达的事件,其间隔小于定义的会话间隔,都属于同一个会话。到达的间隔将定义一个新的会话,到达下一个间隔之前的所有事件将属于新的会话。
窗口移动的频率(提前间隔)
五分钟平均数可以每分钟、每秒或每次有新事件时更新。窗口的大小是固定的时间间隔,称为跳跃窗口。当提前间隔等于窗口大小时,称为滚动窗口。
窗口保持可更新的时间长度(宽限期)
我们的五分钟移动平均线计算了 00:00–00:05 窗口的平均值。现在,一个小时后,我们又得到了一些输入记录,它们的事件时间显示为 00:02。我们要更新 00:00–00:05 期间的结果吗?还是让过去的事情成为过去?理想情况下,我们将能够定义一个特定的时间段,在此期间事件将被添加到它们各自的时间片中。例如,如果事件延迟了四个小时,我们应该重新计算结果并更新。如果事件到达的时间晚于那个时间,我们可以忽略它们。
窗口可以与时钟时间对齐——即,每分钟移动一次的五分钟窗口的第一个时间片为 00:00–00:05,第二个时间片为 00:01–00:06。或者它可以是不对齐的,只是在应用程序启动时开始,然后第一个时间片可以是 03:17–03:22。参见图 14-2 了解这两种窗口类型之间的区别。

图 14-2:滚动窗口与跳跃窗口
处理保证
流处理应用程序的一个关键要求是能够确保每个记录只处理一次,而不受故障的影响。如果没有确切一次的保证,流处理无法用于需要准确结果的情况。正如在第八章中详细讨论的那样,Apache Kafka 支持具有事务性和幂等性生产者的确切一次语义。Kafka Streams 使用 Kafka 的事务来为流处理应用程序实现确切一次的保证。使用 Kafka Streams 库的每个应用程序都可以通过将processing.guarantee设置为exactly_once来启用确切一次的保证。Kafka Streams 版本 2.6 或更高版本包括一个更高效的确切一次实现,需要 Kafka 经纪人的版本为 2.5 或更高版本。可以通过将processing.guarantee设置为exactly_once_beta来启用这种高效实现。
流处理设计模式
每个流处理系统都是不同的——从基本的消费者、处理逻辑和生产者的组合,到像 Spark Streaming 这样的涉及机器学习库的集群,以及其中的许多其他系统。但是有一些基本的设计模式,这些模式是对流处理架构常见需求的已知解决方案。我们将回顾一些这些众所周知的模式,并展示它们如何在一些示例中使用。
单事件处理
流处理的最基本模式是独立处理每个事件。这也被称为map/filter 模式,因为它通常用于从流中过滤不必要的事件或转换每个事件。(map 一词基于 map/reduce 模式,其中 map 阶段转换事件,reduce 阶段聚合事件。)
在这种模式中,流处理应用程序从流中消费事件,修改每个事件,然后将事件生成到另一个流中。一个例子是一个应用程序从流中读取日志消息,并将“ERROR”事件写入高优先级流,其余事件写入低优先级流。另一个例子是一个应用程序从流中读取事件,并将其从 JSON 修改为 Avro。这些应用程序不需要在应用程序内部维护状态,因为每个事件都可以独立处理。这意味着从应用程序故障或负载平衡中恢复非常容易,因为无需恢复状态;我们可以简单地将事件交给应用程序的另一个实例来处理。
这种模式可以很容易地通过简单的生产者和消费者来处理,如图 14-3 所示。

图 14-3. 单事件处理拓扑
使用本地状态进行处理
大多数流处理应用都关注聚合信息,特别是窗口聚合。一个例子是找到每天交易的最低和最高股价,并计算移动平均值。
这些聚合需要维护一个状态。在我们的示例中,为了计算每天的最低和平均价格,我们需要存储最小值、总和和截至当前时间我们看到的记录数。
所有这些都可以使用本地状态(而不是共享状态)来完成,因为我们示例中的每个操作都是group by聚合。也就是说,我们按股票符号执行聚合,而不是在整个股票市场上执行聚合。我们使用 Kafka 分区器来确保所有具有相同股票符号的事件都写入同一个分区。然后,应用程序的每个实例将从分配给它的分区中获取所有事件(这是 Kafka 消费者的保证)。这意味着应用程序的每个实例可以为写入分配给它的分区的股票符号子集维护状态。参见图 14-4。

图 14-4. 具有本地状态的事件处理拓扑
当应用程序具有本地状态时,流处理应用程序变得更加复杂。流处理应用程序必须解决几个问题:
内存使用
本地状态理想情况下适合于应用程序实例可用的内存。一些本地存储允许溢出到磁盘,但这会对性能产生重大影响。
持久性
我们需要确保应用程序实例关闭时不会丢失状态,并且在实例再次启动或被不同实例替换时可以恢复状态。这是 Kafka Streams 非常擅长处理的事情——本地状态使用内置的 RocksDB 存储在内存中,该存储也将数据持久化到磁盘,以便在重新启动后快速恢复。但是所有对本地状态的更改也会发送到 Kafka 主题。如果流的节点关闭,本地状态不会丢失——可以通过重新读取 Kafka 主题中的事件轻松重新创建。例如,如果本地状态包含“IBM 的当前最低价=167.19”,我们将其存储在 Kafka 中,以便以后可以从这些数据重新填充本地缓存。Kafka 使用日志压缩来确保这些主题不会无休止地增长,并且重新创建状态始终是可行的。
重新平衡
有时分区会重新分配给不同的消费者。当这种情况发生时,失去分区的实例必须存储最后的良好状态,接收分区的实例必须知道如何恢复正确的状态。
流处理框架在帮助开发人员管理所需的本地状态方面存在差异。如果我们的应用程序需要维护本地状态,我们要确保检查框架及其保证。我们将在本章末尾包括一个简短的比较指南,但众所周知,软件变化迅速,流处理框架变化更快。
多阶段处理/重新分配
如果我们需要一个group by类型的聚合,本地状态非常适用。但是,如果我们需要使用所有可用信息的结果呢?例如,假设我们想要每天发布前 10 支股票——在每个交易日开盘到收盘期间获利最多的 10 支股票。显然,我们在每个应用程序实例上本地执行的操作是不够的,因为所有前 10 支股票可能分配给其他实例的分区。我们需要的是一个两阶段的方法。首先,我们计算每个股票符号的每日盈亏。我们可以在每个实例上使用本地状态来完成这个操作。然后,我们将结果写入一个新的具有单个分区的主题。这个分区将由单个应用程序实例读取,然后找到当天的前 10 支股票。第二个主题,其中只包含每个股票符号的每日摘要,显然比包含交易本身的主题要小得多,流量也要少得多,因此可以由应用程序的单个实例处理。有时需要更多的步骤才能产生结果。请参见图 14-5。
这种多阶段处理对于那些编写 MapReduce 代码的人来说非常熟悉,其中经常需要使用多个减少阶段。如果你曾经编写过 map-reduce 代码,你会记得你需要为每个减少步骤编写一个单独的应用程序。与 MapReduce 不同,大多数流处理框架允许在单个应用程序中包含所有步骤,框架处理哪个应用程序实例(或工作程序)将运行每个步骤的细节。

图 14-5:包括本地状态和重新分区步骤的拓扑
使用外部查找进行处理:流-表连接
有时,流处理需要与流外部的数据集成——针对存储在数据库中的一组规则验证交易,或者使用有关点击的用户数据丰富点击流信息。
进行数据丰富的外部查找的明显想法是这样的:对于流中的每个点击事件,查找个人资料数据库中的用户,并写入一个事件,其中包括原始点击事件以及用户的年龄和性别,写入另一个主题。见图 14-6。

图 14-6. 包括外部数据源的流处理
这个明显的想法的问题在于,外部查找会给每条记录的处理增加显著的延迟,通常在 5 到 15 毫秒之间。在许多情况下,这是不可行的。通常,这会给外部数据存储增加额外的负载,这也是不可接受的。流处理系统通常可以处理 10 万至 50 万事件每秒,但数据库可能只能以合理的性能处理大约 1 万事件每秒。还有可用性方面的复杂性——我们的应用程序需要处理外部数据库不可用的情况。
为了获得良好的性能和可用性,我们需要在流处理应用程序中缓存来自数据库的信息。然而,管理这个缓存可能会有挑战——我们如何防止缓存中的信息变得过时?如果我们太频繁地刷新事件,仍然会对数据库造成压力,而缓存并没有太大帮助。如果我们等待太久才获取新事件,我们就会使用过时的信息进行流处理。
但是,如果我们可以捕获数据库表中发生的所有更改,并将其转换为事件流,我们可以让我们的流处理作业监听此流,并根据数据库更改事件更新缓存。将数据库更改捕获为事件流称为变更数据捕获(CDC),Kafka Connect 具有多个连接器,能够执行 CDC 并将数据库表转换为更改事件流。这使我们能够保留表的私有副本,并在数据库发生更改事件时得到通知,以便我们相应地更新我们自己的副本。见图 14-7。

图 14-7. 连接表和事件流的拓扑结构,无需在流处理中涉及外部数据源。
然后,当我们收到点击事件时,我们可以在本地状态中查找user_id并丰富事件。由于我们使用的是本地状态,这样扩展得更好,不会影响数据库和其他使用它的应用程序。
我们将其称为流-表连接,因为其中一个流代表对本地缓存表的更改。
表-表连接
在前一节中,我们讨论了表和更新事件流的等效性。我们已经详细讨论了在连接流和表时的工作原理。我们没有理由不能在连接操作的两侧都有这些物化表。
连接两个表始终是非窗口化的,并在执行操作时连接两个表的当前状态。使用 Kafka Streams,我们可以执行等值连接,其中两个表具有相同的以相同方式分区的键,因此连接操作可以在大量应用程序实例和机器之间高效分布。
Kafka Streams 还支持两个表的外键连接——一个流或表的键与另一个流或表的任意字段进行连接。您可以在Kafka Summit 2020的演讲“Crossing the Streams”中了解更多信息,或者在更深入的博客文章中了解它的工作原理。
流连接
有时我们想要连接两个真实的事件流,而不是一个带有表的流。什么使一个流“真实”?如果您回忆一下本章开头的讨论,流是无界的。当我们使用流来表示表时,我们可以忽略流中的大部分历史,因为我们只关心表中的当前状态。但是当我们连接两个流时,我们正在连接整个历史,试图将一个流中的事件与另一个流中在相同时间窗口内具有相同键和发生的事件进行匹配。这就是为什么流连接也被称为窗口连接。
例如,假设我们有一个包含人们输入到我们网站的搜索查询的流,以及另一个包含点击的流,其中包括对搜索结果的点击。我们希望匹配搜索查询与他们点击的结果,以便我们知道哪个结果对于哪个查询最受欢迎。显然,我们希望基于搜索词匹配结果,但只在特定时间窗口内匹配它们。我们假设结果是在输入到我们的搜索引擎后的几秒钟内被点击的。因此,我们在每个流上保留一个小的几秒钟的窗口,并匹配每个窗口中的结果。见图 14-8。

图 14-8:连接两个事件流;这些连接总是涉及移动时间窗口
Kafka Streams 支持equi-joins,其中流、查询和点击根据相同的键进行分区,这些键也是连接键。这样,所有来自user_id:42的点击事件最终都会进入点击主题的分区 5,而所有user_id:42的搜索事件最终都会进入搜索主题的分区 5。然后,Kafka Streams 确保将两个主题的分区 5 分配给同一个任务。因此,该任务可以看到user_id:42的所有相关事件。它在其嵌入式 RocksDB 状态存储中维护了两个主题的连接窗口,这就是它执行连接的方式。
顺序错误的事件
处理在错误时间到达流的事件不仅是流处理中的挑战,也是传统 ETL 系统中的挑战。在物联网场景中,顺序错误的事件经常而且预期地发生(图 14-9)。例如,移动设备在几个小时内失去 WiFi 信号,并在重新连接时发送了几小时的事件。这也发生在监视网络设备(故障交换机在修复之前不会发送诊断信号)或制造业(工厂的网络连接在发展中国家尤其不可靠)。

图 14-9:事件的顺序错误
我们的流应用程序需要能够处理这些情况。这通常意味着应用程序必须执行以下操作:
-
认识到事件的顺序错误——这需要应用程序检查事件时间并发现它比当前时间更早。
-
定义一个时间段,在此期间将尝试协调顺序错误的事件。也许应该协调三小时的延迟,并且超过三周的事件可以被丢弃。
-
具有内部能力来协调此事件。这是流应用程序和批处理作业之间的主要区别。如果我们有一个每日批处理作业,并且在作业完成后有一些事件到达,通常我们可以重新运行昨天的作业并更新事件。对于流处理,没有“重新运行昨天的作业”——同一连续过程需要在任何给定时刻处理旧事件和新事件。
-
能够更新结果。如果流处理的结果被写入数据库,put或update就足以更新结果。如果流应用程序通过电子邮件发送结果,更新可能会更加棘手。
包括 Google 的 Dataflow 和 Kafka Streams 在内的几个流处理框架都内置了对事件时间的支持,独立于处理时间,并且能够处理事件时间早于或晚于当前处理时间的事件。这通常是通过在本地状态中维护多个可用于更新的聚合窗口,并让开发人员能够配置保留这些窗口聚合可用于更新的时间长度来实现的。当然,聚合窗口保持可用于更新的时间越长,就需要更多的内存来维护本地状态。
Kafka Streams API 总是将聚合结果写入结果主题。这些通常是压缩主题,这意味着仅保留每个键的最新值。如果需要更新聚合窗口的结果以响应延迟事件,Kafka Streams 将简单地为此聚合窗口写入新结果,这将有效地替换先前的结果。
重新处理
最后一个重要的模式是重新处理事件。这种模式有两种变体:
-
我们有一个改进版的流处理应用程序。我们希望在与旧版本相同的事件流上运行新版本的应用程序,生成一个不会替换第一个版本的新结果流,比较两个版本之间的结果,并在某个时候将客户端切换到使用新结果而不是现有结果。
-
现有的流处理应用程序存在错误。我们修复了错误,我们想重新处理事件流并重新计算结果
第一个用例之所以变得简单,是因为 Apache Kafka 在可扩展的数据存储中长时间存储事件流的全部内容。这意味着运行两个版本的流处理应用程序并写入两个结果流只需要以下步骤:
-
将新版本的应用程序作为新的消费者组启动。
-
配置新版本从输入主题的第一个偏移量开始处理(这样它将获得输入流中所有事件的副本)
-
让新应用程序继续处理,并在新版本的处理作业赶上时切换客户端应用程序到新的结果流
第二个用例更具挑战性——它需要“重置”现有应用程序,以便从输入流的开头重新开始处理,重置本地状态(这样我们就不会混合两个应用程序版本的结果),并可能清理先前的输出流。虽然 Kafka Streams 有一个用于重置流处理应用程序状态的工具,但我们建议在有足够的容量运行两个应用程序副本并生成两个结果流时尝试使用第一种方法。第一种方法更安全——它允许在多个版本之间来回切换并比较结果,而不会在清理过程中丢失关键数据或引入错误。
交互式查询
如前所述,流处理应用程序具有状态,并且这种状态可以分布在应用程序的许多实例之间。大多数情况下,流处理应用程序的用户通过从输出主题中读取结果来获取处理结果。然而,在某些情况下,希望通过快捷方式从状态存储中读取结果。当结果是一个表时(例如,畅销书的前 10 名),并且结果流实际上是对该表的更新流时,直接从流处理应用程序状态中读取表会更快、更容易。
Kafka Streams 包括灵活的 API,用于查询流处理应用程序的状态。
Kafka Streams 示例
为了演示这些模式在实践中是如何实现的,我们将展示一些使用 Apache Kafka Streams API 的示例。我们使用这个特定的 API 是因为它相对简单易用,并且它已经随 Apache Kafka 一起发布,我们已经可以访问。重要的是要记住,这些模式可以在任何流处理框架和库中实现——这些模式是通用的,但示例是具体的。
Apache Kafka 有两个流 API——低级 Processor API 和高级 Streams DSL。我们将在我们的示例中使用 Kafka Streams DSL。DSL 允许我们通过定义一系列对流中事件的转换来定义流处理应用程序。转换可以是简单的过滤器,也可以是复杂的流到流的连接。低级 API 允许我们创建自己的转换。要了解更多关于低级 Processor API 的信息,可以参考开发者指南,以及演示文稿“Beyond the DSL”是一个很好的介绍。
使用 DSL API 的应用程序始终从使用StreamsBuilder创建处理拓扑开始——一个应用于流中事件的转换的有向无环图(DAG)。然后我们从拓扑创建一个KafkaStreams执行对象。启动KafkaStreams对象将启动多个线程,每个线程将应用处理拓扑到流中的事件。当我们关闭KafkaStreams对象时,处理将结束。
我们将看一些使用 Kafka Streams 实现我们刚讨论的设计模式的示例。一个简单的词频统计示例将用于演示映射/过滤模式和简单的聚合。然后我们将转移到一个示例,其中我们计算股票交易的不同统计数据,这将允许我们演示窗口聚合。最后,我们将使用 ClickStream 增强作为一个示例来演示流连接。
词频统计
让我们通过一个简化的 Kafka Streams 词频统计示例来了解一下。你可以在GitHub上找到完整的示例。
创建流处理应用程序时的第一件事是配置 Kafka Streams。Kafka Streams 有大量可能的配置,我们在这里不讨论,但你可以在文档中找到它们。此外,你可以通过向Properties对象添加任何生产者或消费者配置来配置嵌入在 Kafka Streams 中的生产者和消费者:
public class WordCountExample {
public static void main(String[] args) throws Exception{
Properties props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG,
"wordcount"); // ①
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG,
"localhost:9092"); // ②
props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG,
Serdes.String().getClass().getName()); // ③
props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG,
Serdes.String().getClass().getName());
①
每个 Kafka Streams 应用程序都必须有一个应用程序 ID。它用于协调应用程序的实例,以及在命名内部本地存储和与之相关的主题时使用。对于与同一 Kafka 集群一起工作的每个 Kafka Streams 应用程序,此名称必须是唯一的。
②
Kafka Streams 应用程序总是从 Kafka 主题中读取数据,并将其输出到 Kafka 主题。正如我们将在后面讨论的那样,Kafka Streams 应用程序还使用 Kafka 进行协调。因此,最好告诉我们的应用程序在哪里找到 Kafka。
③
在读取和写入数据时,我们的应用程序将需要进行序列化和反序列化,因此我们提供了默认的 Serde 类。如果需要,我们可以在构建流拓扑时稍后覆盖这些默认值。
现在我们有了配置,让我们构建我们的流拓扑:
StreamsBuilder builder = new StreamsBuilder(); // ①
KStream<String, String> source =
builder.stream("wordcount-input");
final Pattern pattern = Pattern.compile("\\W+");
KStream<String, String> counts = source.flatMapValues(value->
Arrays.asList(pattern.split(value.toLowerCase()))) // ②
.map((key, value) -> new KeyValue<String,
String>(value, value))
.filter((key, value) -> (!value.equals("the"))) // ③
.groupByKey() // ④
.count().mapValues(value->
Long.toString(value)).toStream();// ⑤
counts.to("wordcount-output"); // ⑥
①
我们创建一个StreamsBuilder对象,并开始通过指向我们将用作输入的主题来定义一个流。
②
我们从源主题中读取的每个事件都是一行单词;我们使用正则表达式将其拆分为一系列单独的单词。然后我们取出每个单词(当前是事件记录的值)并将其放入事件记录键中,以便在分组操作中使用。
③
我们过滤掉了单词the,只是为了展示过滤有多容易。
④
然后我们按键分组,所以现在我们对于每个唯一单词都有一个事件集合。
⑤
我们计算每个集合中有多少事件。计数的结果是Long数据类型。我们将其转换为String,这样人类就可以更容易地阅读结果。
⑥
只剩下一件事——将结果写回 Kafka。
现在我们已经定义了应用程序将运行的转换流程,我们只需要…运行它:
KafkaStreams streams = new KafkaStreams(builder.build(), props); // ①
streams.start(); // ②
// usually the stream application would be running forever,
// in this example we just let it run for some time and stop
Thread.sleep(5000L);
streams.close(); // ③
①
基于我们定义的拓扑和属性,定义一个KafkaStreams对象。
②
启动 Kafka Streams。
③
一段时间后,停止它。
就是这样!在几行简短的代码中,我们演示了实现单个事件处理模式的简易性(我们在事件上应用了 map 和 filter)。我们通过添加 group-by 操作符重新分区数据,然后在计算每个单词作为键的记录数量时保持了简单的本地状态。然后我们在计算每个单词出现的次数时保持了简单的本地状态。
在这一点上,我们建议运行完整的示例。GitHub 存储库中的 README包含了如何运行示例的说明。
请注意,我们可以在我们的机器上运行整个示例,而无需安装除了 Apache Kafka 之外的任何东西。如果我们的输入主题包含多个分区,我们可以运行多个WordCount应用程序的实例(只需在几个不同的终端标签中运行应用程序),然后我们就有了第一个 Kafka Streams 处理集群。WordCount应用程序的实例相互交流并协调工作。对于一些流处理框架来说,最大的入门障碍之一是本地模式非常容易使用,但是要运行生产集群,我们需要安装 YARN 或 Mesos,然后在所有这些机器上安装处理框架,然后学习如何将我们的应用程序提交到集群。使用 Kafka 的 Streams API,我们只需启动多个应用程序实例,就可以得到一个集群。完全相同的应用程序在我们的开发机器上和生产环境中运行。
股票市场统计
下一个示例更加复杂——我们将读取一系列股票市场交易事件,其中包括股票代码、要价和要价大小。在股票市场交易中,要价是卖方要价,而出价是买方建议支付的价格。要价大小是卖方愿意以该价格出售的股票数量。为了简化示例,我们完全忽略了出价。我们也不会在我们的数据中包含时间戳;相反,我们将依赖由我们的 Kafka 生产者填充的事件时间。
然后我们将创建包含一些窗口统计信息的输出流:
-
每五秒窗口的最佳(即最低)要价
-
每五秒窗口的交易数量
-
每五秒窗口的平均要价
所有统计数据将每秒更新一次。
为了简化起见,我们假设我们的交易所只有 10 个股票代码在交易中。设置和配置与我们在“Word Count”中使用的非常相似。
Properties props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "stockstat");
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, Constants.BROKER);
props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG,
Serdes.String().getClass().getName());
props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG,
TradeSerde.class.getName());
主要区别在于使用的 Serde 类。在"词频统计"中,我们对键和值都使用了字符串,因此对两者都使用了Serdes.String()类作为序列化器和反序列化器。在这个例子中,键仍然是一个字符串,但值是一个包含股票代码、询价和询价大小的Trade对象。为了对这个对象进行序列化和反序列化(以及我们在这个小应用程序中使用的其他一些对象),我们使用了谷歌的 Gson 库来从我们的Java对象生成 JSON 序列化器和反序列化器。然后我们创建了一个小包装器,从中创建了一个 Serde 对象。这是我们创建 Serde 的方法:
static public final class TradeSerde extends WrapperSerde<Trade> {
public TradeSerde() {
super(new JsonSerializer<Trade>(),
new JsonDeserializer<Trade>(Trade.class));
}
}
没有什么花哨的,但请记住为要存储在 Kafka 中的每个对象提供一个 Serde 对象 - 输入、输出,有时是中间结果。为了使这更容易,我们建议通过类似 Gson、Avro、Protobuf 或类似的库生成这些 Serdes。
现在我们已经配置好了一切,是时候构建我们的拓扑了:
KStream<Windowed<String>, TradeStats> stats = source
.groupByKey() // ①
.windowedBy(TimeWindows.of(Duration.ofMillis(windowSize))
.advanceBy(Duration.ofSeconds(1))) // ②
.aggregate( // ③
() -> new TradeStats(),
(k, v, tradestats) -> tradestats.add(v), // ④
Materialized.<String, TradeStats, WindowStore<Bytes, byte[]>>
as("trade-aggregates") // ⑤
.withValueSerde(new TradeStatsSerde())) // ⑥
.toStream() // ⑦
.mapValues((trade) -> trade.computeAvgPrice()); // ⑧
stats.to("stockstats-output",
Produced.keySerde(
WindowedSerdes.timeWindowedSerdeFrom(String.class, windowSize))); // ⑨
①
我们首先从输入主题中读取事件,并执行groupByKey()操作。尽管它的名称是这样,但这个操作并不进行任何分组。相反,它确保事件流基于记录键进行分区。由于我们将数据写入一个带有键的主题,并且在调用groupByKey()之前没有修改键,所以数据仍然按其键进行分区 - 在这种情况下,此方法不起作用。
②
我们定义窗口 - 在这种情况下,是一个五秒的窗口,每秒前进一次。
③
在确保正确的分区和窗口化之后,我们开始聚合。aggregate方法将流拆分为重叠的窗口(每秒五秒窗口),然后在窗口中的所有事件上应用聚合方法。此方法的第一个参数是一个新对象,将包含聚合的结果 - 在我们的情况下是Tradestats。这是一个我们创建的对象,用于包含我们对每个时间窗口感兴趣的所有统计信息 - 最低价格、平均价格和交易数量。
④
然后我们提供一个方法来实际聚合记录 - 在这种情况下,使用Tradestats对象的add方法来更新窗口中的最低价格、交易数量和总价格。
⑤
在"流处理设计模式"中提到,窗口聚合需要维护状态和本地存储,其中状态将被维护。聚合方法的最后一个参数是状态存储的配置。Materialized是存储配置对象,我们将存储名称配置为trade-aggregates。这可以是任何唯一的名称。
⑥
作为状态存储配置的一部分,我们还提供了一个 Serde 对象,用于序列化和反序列化聚合结果(Tradestats对象)。
⑦
聚合的结果是一个表,其中股票和时间窗口是主键,聚合结果是值。我们将表转换回事件流。
⑧
最后一步是更新平均价格 - 目前聚合结果包括价格总和和交易数量。我们遍历这些记录,并使用现有的统计数据来计算平均价格,以便将其包含在输出流中。
⑨
最后,我们将结果写回stockstats-output流。由于结果是窗口操作的一部分,我们创建了一个WindowedSerde,它以包含窗口时间戳的窗口数据格式存储结果。窗口大小作为 Serde 的一部分传递,即使在序列化时没有使用窗口大小(反序列化需要窗口大小,因为输出主题中只存储窗口的开始时间)。
在定义流程之后,我们使用它来生成一个KafkaStreams对象并运行它,就像我们在“Word Count”中所做的那样。
这个示例展示了如何对流执行窗口聚合——这可能是流处理的最流行用例。需要注意的一件事是,维护聚合的本地状态所需的工作量很少——只需提供一个 Serde 并命名状态存储即可。然而,该应用程序将扩展到多个实例,并通过将一些分区的处理转移给幸存实例之一来自动从每个实例的故障中恢复。我们将在“Kafka Streams: Architecture Overview”中看到更多关于如何实现的内容。
通常情况下,您可以在GitHub上找到完整的示例,包括运行说明。
点击流丰富化
最后的示例将通过丰富网站上的点击流来演示流连接。我们将生成一个模拟点击流,一个虚构的个人资料数据库表的更新流,以及一个网络搜索流。然后,我们将连接这三个流,以获得对每个用户活动的 360 度视图。用户搜索了什么?他们点击了什么结果?他们是否在用户资料中更改了“兴趣”?这些类型的连接为分析提供了丰富的数据收集。产品推荐通常基于这种信息——用户搜索了自行车,点击了“Trek”链接,并对旅行感兴趣,因此我们可以向 Trek 推广自行车、头盔和内布拉斯加等异国情调的自行车旅行。
由于配置应用程序与之前的示例类似,让我们跳过这部分,看看连接多个流的拓扑结构:
KStream<Integer, PageView> views =
builder.stream(Constants.PAGE_VIEW_TOPIC,
Consumed.with(Serdes.Integer(), new PageViewSerde())); // ①
KStream<Integer, Search> searches =
builder.stream(Constants.SEARCH_TOPIC,
Consumed.with(Serdes.Integer(), new SearchSerde()));
KTable<Integer, UserProfile> profiles =
builder.table(Constants.USER_PROFILE_TOPIC,
Consumed.with(Serdes.Integer(), new ProfileSerde())); // ②
KStream<Integer, UserActivity> viewsWithProfile = views.leftJoin(profiles, // ③
(page, profile) -> {
if (profile != null)
return new UserActivity(
profile.getUserID(), profile.getUserName(),
profile.getZipcode(), profile.getInterests(),
"", page.getPage()); // ④
else
return new UserActivity(
-1, "", "", null, "", page.getPage());
});
KStream<Integer, UserActivity> userActivityKStream =
viewsWithProfile.leftJoin(searches, // ⑤
(userActivity, search) -> {
if (search != null)
userActivity.updateSearch(search.getSearchTerms()); // ⑥
else
userActivity.updateSearch("");
return userActivity;
},
JoinWindows.of(Duration.ofSeconds(1)).before(Duration.ofSeconds(0)), // ⑦
StreamJoined.with(Serdes.Integer(), // ⑧
new UserActivitySerde(),
new SearchSerde()));
① (#co_stream_processing_CO5-1)
首先,我们为我们想要连接的两个流创建了流对象——点击和搜索。当我们创建流对象时,我们传递输入主题以及在从主题中消费记录并将其反序列化为输入对象时将使用的键和值 Serde。
② (#co_stream_processing_CO5-2)
我们还为用户资料定义了一个KTable。KTable是通过更改流更新的物化存储。
③ (#co_stream_processing_CO5-3)
然后,我们通过将事件流与个人资料表进行连接,丰富点击流的用户资料信息。在流-表连接中,流中的每个事件都会从个人资料表的缓存副本中接收信息。我们正在进行左连接,因此将保留没有已知用户的点击。
④ (#co_stream_processing_CO5-4)
这是join方法——它接受两个值,一个来自流,一个来自记录,并返回第三个值。与数据库不同,我们可以决定如何将两个值组合成一个结果。在这种情况下,我们创建了一个包含用户详细信息和查看页面的activity对象。
⑤ (#co_stream_processing_CO5-5)
接下来,我们想要将点击信息与同一用户执行的搜索进行join。这仍然是左连接,但现在我们要连接两个流,而不是流到表。
// ⑥ (#co_stream_processing_CO5-6)
这是join方法——我们只需将搜索词添加到所有匹配的页面查看中。
// ⑦ (#co_stream_processing_CO5-7)
这是有趣的部分 - 流到流连接 是一个带有时间窗口的连接。连接每个用户的所有点击和搜索并没有太多意义 - 我们希望将每个搜索与与之相关的点击连接起来,也就是发生在搜索后的短时间内的点击。因此,我们定义了一个一秒钟的连接窗口。我们调用 of 来创建一个搜索前后一秒钟的窗口,然后我们调用 before 以零秒的间隔来确保我们只连接每次搜索后一秒钟发生的点击而不是之前的点击。结果将包括相关的点击、搜索词和用户资料。这将允许对搜索及其结果进行全面分析。
⑧
我们在这里定义连接结果的 Serde。这包括连接两侧共有的键的 Serde,以及将包含在连接结果中的两个值的 Serde。在这种情况下,键是用户 ID,所以我们使用一个简单的 Integer Serde。
在定义流程之后,我们使用它来生成一个 KafkaStreams 对象并运行它,就像我们在 “Word Count” 中所做的那样。
这个例子展示了流处理中可能存在的两种不同的连接模式。一个是将流与表连接,以丰富表中的所有流事件信息。这类似于在数据仓库上运行查询时将事实表与维度表连接。第二个例子是基于时间窗口连接两个流。这个操作是流处理中独有的。
通常情况下,您可以在 GitHub 上找到完整的示例,包括运行它的说明。
Kafka Streams:架构概述
前一节的示例演示了如何使用 Kafka Streams API 实现一些众所周知的流处理设计模式。但要更好地理解 Kafka 的 Streams 库实际上是如何工作和扩展的,我们需要窥探一下其内部,并了解 API 背后的一些设计原则。
构建拓扑
每个流应用程序都实现并执行一个拓扑。拓扑(在其他流处理框架中也称为 DAG 或有向无环图)是一组操作和转换,每个事件都从输入到输出经过。图 14-10 显示了 “Word Count” 中的拓扑。

图 14-10. 单词计数流处理示例的拓扑
即使是一个简单的应用程序也有一个非平凡的拓扑结构。拓扑由处理器组成 - 这些是拓扑图中的节点(在我们的图表中用圆圈表示)。大多数处理器实现数据操作 - 过滤、映射、聚合等。还有源处理器,它们从主题中消费数据并传递数据,以及汇处理器,它们从先前的处理器获取数据并将其生成到主题中。拓扑始终以一个或多个源处理器开始,并以一个或多个汇处理器结束。
优化拓扑
默认情况下,Kafka Streams 通过将每个 DSL 方法独立映射到较低级别的等效方法来执行使用 DSL API 构建的应用程序。通过独立评估每个 DSL 方法,错过了优化整体拓扑的机会。
然而,请注意,Kafka Streams 应用程序的执行是一个三步过程:
-
逻辑拓扑是通过创建
KStream和KTable对象并在它们上执行 DSL 操作(如过滤和连接)来定义的。 -
StreamsBuilder.build()从逻辑拓扑生成物理拓扑。 -
KafkaStreams.start()执行拓扑 - 这是数据被消费、处理和生成的地方。
第二步,从逻辑定义生成物理拓扑,是可以应用整体优化计划的地方。
目前,Apache Kafka 仅包含了一些优化,主要是围绕尽可能重用主题。这些可以通过将StreamsConfig.TOPOLOGY_OPTIMIZATION设置为StreamsConfig.OPTIMIZE并调用build(props)来启用。如果只调用build()而不传递配置,则仍然禁用优化。建议测试应用程序时启用和禁用优化,并比较执行时间和写入 Kafka 的数据量,当然,还要验证在各种已知场景中结果是否相同。
测试拓扑结构
一般来说,我们希望在重要的成功执行的场景中使用软件之前对其进行测试。自动化测试被认为是黄金标准。每次对软件应用程序或库进行更改时,都会进行可重复的测试,以实现快速迭代和更容易的故障排除。
我们希望将相同的方法论应用到我们的 Kafka Streams 应用程序中。除了自动化的端到端测试,该测试会针对一个包含生成数据的暂存环境运行流处理应用程序,我们还希望包括更快、更轻量和更易于调试的单元测试和集成测试。
Kafka Streams 应用程序的主要测试工具是TopologyTestDriver。自从 1.1.0 版本引入以来,其 API 经历了重大改进,自 2.4 版本以来变得方便且易于使用。这些测试看起来像普通的单元测试。我们定义输入数据,将其生成到模拟输入主题,使用测试驱动程序运行拓扑,从模拟输出主题中读取结果,并通过将其与预期值进行比较来验证结果。
我们建议使用TopologyTestDriver来测试流处理应用程序,但由于它不模拟 Kafka Streams 的缓存行为(这本书中未讨论的一种优化,与状态存储本身完全无关,这个框架模拟了它),因此它将无法检测到整个类别的错误。
单元测试通常与集成测试相辅相成,对于 Kafka Streams,有两种流行的集成测试框架:EmbeddedKafkaCluster和Testcontainers。前者在运行测试的 JVM 内部运行 Kafka 代理,而后者在 Docker 容器中运行 Kafka 代理(以及根据测试需要的许多其他组件)。推荐使用Testcontainers,因为它通过使用 Docker 完全隔离了 Kafka 及其依赖项和资源使用情况,使其与我们要测试的应用程序分离。
这只是 Kafka Streams 测试方法论的简要概述。我们建议阅读“测试 Kafka Streams—深入探讨”博客文章,以获取更深入的解释和拓扑结构以及测试的详细代码示例。
拓扑结构的扩展
Kafka Streams 通过允许应用程序的一个实例内的多个执行线程以及支持在分布式应用程序的分布式实例之间进行负载平衡来实现扩展。我们可以在一个机器上使用多个线程或在多台机器上运行流应用程序;在任何情况下,应用程序中的所有活动线程都将平衡数据处理所涉及的工作。
流引擎通过将拓扑结构分割成任务来并行执行。任务的数量由流引擎确定,并取决于应用程序处理的主题中的分区数量。每个任务负责一部分分区:任务将订阅这些分区并从中消费事件。对于它消费的每个事件,任务将按顺序执行适用于该分区的所有处理步骤,最终将结果写入到汇聚点。这些任务是 Kafka Streams 中的并行基本单元,因为每个任务可以独立执行。参见图 14-11。

图 14-11:运行相同拓扑的两个任务——一个用于输入主题中的每个分区
应用程序的开发人员可以选择每个应用程序实例将执行的线程数。如果有多个线程可用,每个线程将执行应用程序创建的任务的子集。如果应用程序的多个实例在多台服务器上运行,则每个服务器上的每个线程将执行不同的任务。这是流应用程序扩展的方式:我们将有与我们正在处理的主题中的分区数量相同的任务。如果我们想要更快地处理,就增加更多的线程。如果服务器资源不足,就在另一台服务器上启动应用程序的另一个实例。Kafka 将自动协调工作-它将为每个任务分配其自己的分区子集,并且每个任务将独立地处理来自这些分区的事件,并在拓扑需要时维护其自己的本地状态与相关聚合。参见图 14-12。
有时,处理步骤可能需要来自多个分区的输入,这可能会在任务之间创建依赖关系。例如,如果我们像在“ClickStream Enrichment”中的 ClickStream 示例中那样加入两个流,我们需要在可以发出结果之前从每个流的一个分区中获取数据。Kafka Streams 通过将一个连接所需的所有分区分配给同一个任务来处理这种情况,以便任务可以从所有相关分区中消费并独立执行连接。这就是为什么 Kafka Streams 目前要求参与连接操作的所有主题具有相同数量的分区,并且基于连接键进行分区。

图 14-12:流处理任务可以在多个线程和多个服务器上运行
任务之间的另一个依赖关系的例子是当我们的应用程序需要重新分配时。例如,在 ClickStream 示例中,我们所有的事件都是按用户 ID 进行分组的。但是,如果我们想要按页面或邮政编码生成统计信息怎么办?Kafka Streams 将按邮政编码重新分配数据,并对新分区的数据进行聚合。如果任务 1 处理来自分区 1 的数据并到达一个重新分配数据的处理器(groupBy操作),它将需要洗牌,或者将事件发送到其他任务。与其他流处理框架不同,Kafka Streams 通过将事件写入具有新键和分区的新主题来重新分配。然后,另一组任务从新主题中读取事件并继续处理。重新分配步骤将我们的拓扑分成两个子拓扑,每个子拓扑都有自己的任务。第二组任务依赖于第一组任务,因为它处理第一个子拓扑的结果。但是,第一组和第二组任务仍然可以独立并行运行,因为第一组任务以自己的速率将数据写入主题,而第二组从主题中消费并独立处理事件。任务之间没有通信,也没有共享资源,它们不需要在相同的线程或服务器上运行。这是 Kafka 做的更有用的事情之一-减少管道不同部分之间的依赖关系。参见图 14-13。

图 14-13:两组任务处理具有用于在它们之间重新分配事件的主题的事件
生存故障
允许我们扩展应用程序的相同模型也允许我们优雅地处理故障。首先,Kafka 具有高可用性,因此我们持久化到 Kafka 的数据也具有高可用性。因此,如果应用程序失败并需要重新启动,它可以从 Kafka 中查找其在流中的最后位置,并从失败之前提交的最后偏移量继续处理。请注意,如果本地状态存储丢失(例如,因为我们需要替换存储在其上的服务器),流应用程序始终可以从其在 Kafka 中存储的更改日志中重新创建它。
Kafka Streams 还利用 Kafka 的消费者协调来为任务提供高可用性。如果一个任务失败,但有活动的线程或其他流应用程序的实例,该任务将重新启动在其中一个可用的线程上。这类似于消费者组处理组中一个消费者失败的方式,通过将分区分配给剩余的消费者之一。Kafka Streams 受益于 Kafka 消费者组协调协议的改进,例如静态组成员资格和合作再平衡(在第四章中描述),以及 Kafka 的一次性语义的改进(在第八章中描述)。
虽然这里描述的高可用性方法在理论上运行良好,但现实引入了一些复杂性。一个重要的问题是恢复的速度。当一个线程必须开始处理以前在失败线程上运行的任务时,它首先需要恢复其保存的状态,例如当前的聚合窗口。通常,这是通过重新从 Kafka 中的内部主题读取数据来完成的,以便预热 Kafka Streams 状态存储。在恢复失败任务的状态所需的时间内,流处理作业将无法在其数据子集上取得进展,导致可用性降低和数据过时。
因此,减少恢复时间通常归结为减少恢复状态所需的时间。一个关键技术是确保所有 Kafka Streams 主题都配置为积极的压缩——通过设置较低的min.compaction.lag.ms并将段大小配置为 100 MB,而不是默认的 1 GB(请记住,每个分区中的最后一个段,即活动段,不会被压缩)。
为了更快地恢复,我们建议配置standby replica——这些任务只是在流处理应用程序中跟踪活动任务,并在不同的服务器上保持当前状态。当故障转移发生时,它们已经具有最新的状态,并几乎没有停机时间就可以继续处理。
有关 Kafka Streams 中可伸缩性和高可用性的更多信息,请参阅博客文章和有关该主题的Kafka 峰会演讲。
流处理用例
在本章中,我们学习了如何进行流处理——从一般概念和模式到 Kafka Streams 中的具体示例。在这一点上,值得看一下常见的流处理用例。正如本章开头所解释的,流处理或连续处理在我们希望事件被快速处理而不是等待几个小时直到下一批次的情况下是有用的,但也适用于我们不希望在毫秒内收到响应的情况。这一切都是正确的,但也非常抽象。让我们看一些可以通过流处理解决的真实场景:
客户服务
假设我们刚刚在一家大型连锁酒店预订了一个房间,并且我们期待收到一封确认邮件和收据。在预订后的几分钟内,当确认邮件仍未到达时,我们打电话给客服确认我们的预订。假设客服告诉我们:“我在我们的系统中找不到订单,但是从预订系统到酒店和客服台加载数据的批处理作业只运行一天一次,所以请明天再打电话。你应该在 2-3 个工作日内收到邮件。”这听起来不像是很好的服务,但我们曾经与一家大型连锁酒店进行过这样的对话。我们真正想要的是酒店连锁中的每个系统在预订后几秒钟或几分钟内得到更新,包括客服中心、酒店、发送确认邮件的系统、网站等。我们还希望客服中心能够立即查看我们在连锁酒店的任何过去访问的所有细节,并且酒店的接待处知道我们是忠实的客户,这样他们就可以给我们升级。使用流处理应用程序构建所有这些系统可以使它们几乎实时地接收和处理更新,从而提供更好的客户体验。有了这样的系统,客户将在几分钟内收到确认邮件,他们的信用卡将按时收费,收据将被发送,客服台可以立即回答他们有关预订的问题。
物联网
物联网可以意味着很多事情——从家用设备调节温度和订购洗衣液到制药生产的实时质量控制。将流处理应用于传感器和设备时,一个非常常见的用例是尝试预测何时需要进行预防性维护。这类似于应用程序监控,但应用于硬件,并且在许多行业中很常见,包括制造业、电信(识别故障的手机基站)、有线电视(在用户抱怨之前识别故障的机顶盒设备)等。每种情况都有自己的模式,但目标是相似的:处理从设备大规模到达的事件,并识别表明设备需要维护的模式。这些模式可以是交换机的丢包、制造业中需要更多力量来拧紧螺丝、或者有线电视用户更频繁地重新启动机顶盒。
欺诈检测
也被称为异常检测,这是一个非常广泛的领域,专注于在系统中捕捉“作弊者”或坏行为者。欺诈检测应用的例子包括检测信用卡欺诈、股票交易欺诈、视频游戏作弊和网络安全风险。在所有这些领域,尽早捕捉欺诈行为都有很大的好处,因此一个能够快速响应事件的准实时系统——也许可以在坏交易得到批准之前停止它——比在事实发生三天后才检测到欺诈的批处理作业要好得多,因为清理工作会更加复杂。这又是一个在大规模事件流中识别模式的问题。
在网络安全中,有一种称为信标的方法。当黑客在组织内部植入恶意软件时,它偶尔会到外部接收命令。由于它可能随时发生,频率也不确定,因此很难检测到这种活动。通常,网络对外部攻击有很好的防御,但更容易受到组织内部人员的攻击。通过处理大量的网络连接事件流并识别异常通信模式(例如,检测到该主机通常不访问那些特定的 IP),安全组织可以在更多损害发生之前及早发出警报。
如何选择流处理框架
在选择流处理框架时,重要的是考虑你计划编写的应用程序类型。不同类型的应用程序需要不同的流处理解决方案:
摄入
目标是将数据从一个系统传输到另一个系统,并对数据进行一些修改以符合目标系统的要求。
低毫秒级操作
任何需要几乎立即响应的应用程序。一些欺诈检测用例属于这一范畴。
异步微服务
这些微服务代表更大的业务流程执行简单的操作,比如更新商店的库存。这些应用程序可能需要维护本地状态以缓存事件,以提高性能。
近实时数据分析
这些流应用程序执行复杂的聚合和连接,以便对数据进行切片和切块,并生成有趣的、与业务相关的见解。
你将选择的流处理系统将在很大程度上取决于你要解决的问题:
-
如果你正在尝试解决摄入问题,你应该重新考虑是否需要一个流处理系统,或者是一个更简单的以摄入为重点的系统,比如 Kafka Connect。如果你确定需要一个流处理系统,你需要确保它既有良好的连接器选择,又有针对你目标系统的高质量连接器。
-
如果你正在尝试解决需要低毫秒级操作的问题,你也应该重新考虑你选择的流。请求-响应模式通常更适合这个任务。如果你确定需要一个流处理系统,那么你需要选择一个支持事件级低延迟模型的系统,而不是专注于微批处理的系统。
-
如果你正在构建异步微服务,你需要一个与你选择的消息总线(希望是 Kafka)很好集成的流处理系统,具有轻松将上游更改传递给微服务本地状态的变更捕获功能,并具有良好的本地存储支持,可以作为微服务数据的缓存或物化视图。
-
如果你正在构建一个复杂的分析引擎,你还需要一个具有良好本地存储支持的流处理系统——这次不是为了维护本地缓存和物化视图,而是为了支持难以实现的高级聚合、窗口和连接。API 应该包括对自定义聚合、窗口操作和多种连接类型的支持。
除了特定用例的考虑之外,还有一些全局考虑因素需要考虑:
系统的可操作性
是否容易部署到生产环境?是否容易监视和排除故障?是否容易根据需要进行扩展和缩减?是否与现有基础设施很好地集成?如果出现错误,需要重新处理数据怎么办?
API 的可用性和调试的便利性
我发现在不同版本的相同框架中编写高质量应用程序所需的时间有数量级的差异。开发时间和上市时间很重要,所以你需要选择一个能让你高效的系统。
让困难变得容易
几乎每个系统都声称可以进行高级的窗口聚合并维护本地存储,但问题是:它们是否让你轻松做到了?它们是否处理了规模和恢复方面的细节,还是提供了不完善的抽象,让你处理大部分混乱?系统越暴露干净的 API 和抽象,并自行处理繁琐的细节,开发人员就会更加高效。
社区
大多数你考虑的流处理应用都将是开源的,而且没有什么能取代充满活力和活跃的社区。良好的社区意味着你会定期获得新的和令人兴奋的功能,质量相对较好(没有人想要使用糟糕的软件),错误会被迅速修复,用户的问题也会及时得到答复。这也意味着,如果你遇到奇怪的错误并在谷歌上搜索,你会找到相关信息,因为其他人也在使用这个系统并遇到了相同的问题。
总结
我们从解释流处理开始了这一章。我们给出了一个正式的定义,并讨论了流处理范式的共同属性。我们还将其与其他编程范式进行了比较。
然后,我们讨论了重要的流处理概念。这些概念是通过使用 Kafka Streams 编写的三个示例应用程序进行演示的。
在介绍了这些示例应用程序的所有细节之后,我们概述了 Kafka Streams 的架构,并解释了它在幕后的工作原理。我们用几个流处理用例的示例和比较不同的流处理框架的建议来结束了这一章,也结束了整本书。
附录 A:在其他操作系统上安装 Kafka
Apache Kafka 主要是一个 Java 应用程序,因此应该能够在任何可以安装 JRE 的系统上运行。但是,它已经针对基于 Linux 的操作系统进行了优化,因此在那里运行效果最佳。在其他操作系统上运行可能会导致特定于操作系统的错误。因此,当在常见的桌面操作系统上用于开发或测试 Kafka 时,考虑在与最终生产环境匹配的虚拟机中运行是一个好主意。
在 Windows 上安装
截至 Microsoft Windows 10,现在有两种运行 Kafka 的方法。传统的方法是使用本机 Java 安装。Windows 10 用户还可以选择使用 Windows 子系统来运行。后一种方法是非常推荐的,因为它提供了一个更简单的设置,更接近典型的生产环境,所以我们将首先进行审查。
使用 Windows 子系统来运行 Linux
如果您正在运行 Windows 10,您可以使用 Windows 子系统来安装本机 Ubuntu 支持。在发布时,微软仍然认为 WSL 是一个实验性功能。尽管它类似于虚拟机,但它不需要完整虚拟机的资源,并且与 Windows OS 集成更加丰富。
要安装 WSL,请按照 Microsoft Developer Network 上的说明进行操作“什么是 Windows 子系统?”页面。完成后,您需要使用apt安装 JDK(假设您已经安装了 WSL 的 Ubuntu 系统包):
$ sudo apt install openjdk-16-jre-headless
[sudo] password for username:
Reading package lists... Done
Building dependency tree
Reading state information... Done
[...]
done.
$
安装完 JDK 后,您可以按照第二章中的说明安装 Apache Kafka。
使用本机 Java
对于较旧版本的 Windows,或者如果您不喜欢使用 WSL 环境,您可以在 Windows 上使用本机 Java 环境运行 Kafka。但是请注意,这可能会引入特定于 Windows 环境的错误。这些错误可能不会得到 Apache Kafka 开发社区与 Linux 上类似问题一样的关注。
在安装 ZooKeeper 和 Kafka 之前,您必须设置好 Java 环境。您应该安装最新版本的 Oracle Java 16,可以在Oracle Java SE 下载页面找到。下载一个完整的 JDK 包,以便您拥有所有的 Java 工具,并按照安装说明进行安装。
小心路径
在安装 Java 和 Kafka 时,强烈建议您坚持使用不包含空格的安装路径。虽然 Windows 允许路径中包含空格,但是设计为在 Unix 环境中运行的应用程序并不是这样设置的,指定路径将会很困难。在安装 Java 时,请确保根据这一点设置安装路径。例如,如果安装 JDK 16.0.1,一个好的选择是使用路径C:\Java\jdk-16.0.1。
安装 Java 后,设置环境变量以便使用。这是在 Windows 的控制面板中完成的,不过确切的位置将取决于您的操作系统版本。在 Windows 10 中,您必须:
-
选择“系统和安全”
-
选择系统
-
选择“高级系统设置”,这将打开系统属性窗口
-
在高级选项卡上,点击“环境变量”按钮
使用此部分添加一个名为JAVA_HOME的新用户变量(图 A-1),并将其设置为安装 Java 的路径。然后编辑名为Path的系统变量,并添加一个新条目%JAVA_HOME%\bin。保存这些设置,并退出控制面板。

图 A-1. 添加JAVA_HOME变量
现在您可以继续安装 Apache Kafka。安装包括 ZooKeeper,因此您不必单独安装它。可以在线下载Kafka 的当前版本。在出版时,该版本是 2.8.0,运行在 Scala 版本 2.13.0 下。下载的文件将被 gzip 压缩并打包为tar实用程序,因此您需要使用 Windows 应用程序(如 8 Zip)来解压缩它。与在 Linux 上安装类似,您必须选择一个目录来提取 Kafka。在本例中,我们将假设 Kafka 被提取到C:\kafka_2.13-2.8.0中。
在 Windows 下运行 ZooKeeper 和 Kafka 有点不同,因为您必须使用专为 Windows 设计的批处理文件,而不是其他平台的 shell 脚本。这些批处理文件也不支持将应用程序放入后台运行,因此您需要为每个应用程序使用单独的 shell。首先启动 ZooKeeper:
PS C:\> cd kafka_2.13-2.8.0
PS C:\kafka_2.13-2.8.0> bin\windows\zookeeper-server-start.bat C:\kafka_2.13-2.8.0\config\zookeeper.properties
[2021-07-18 17:37:12,917] INFO Reading configuration from: C:\kafka_2.13-2.8.0\config\zookeeper.properties (org.apache.zookeeper.server.quorum.QuorumPeerConfig)
[...]
[2021-07-18 17:37:13,135] INFO PrepRequestProcessor (sid:0) started, reconfigEnabled=false (org.apache.zookeeper.server.PrepRequestProcessor)
[2021-07-18 17:37:13,144] INFO Using checkIntervalMs=60000 maxPerMinute=10000 (org.apache.zookeeper.server.ContainerManager)
一旦 ZooKeeper 运行,您可以打开另一个窗口启动 Kafka:
PS C:\> cd kafka_2.13-2.8.0
PS C:\kafka_2.13-2.8.0> .\bin\windows\kafka-server-start.bat C:\kafka_2.13-2.8.0\config\server.properties
[2021-07-18 17:39:46,098] INFO Registered kafka:type=kafka.Log4jController MBean (kafka.utils.Log4jControllerRegistration$)
[...]
[2021-07-18 17:39:47,918] INFO [KafkaServer id=0] started (kafka.server.KafkaServer)
[2021-07-18 17:39:48,009] INFO [broker-0-to-controller-send-thread]: Recorded new controller, from now on will use broker 192.168.0.2:9092 (id: 0 rack: null) (kafka.server.BrokerToControllerRequestThread)
在 macOS 上安装
macOS 运行在 Darwin 上,这是一个 Unix 操作系统,部分源自 FreeBSD。这意味着许多在 Unix 操作系统上运行的期望仍然成立,并且安装为 Unix 设计的应用程序(如 Apache Kafka)并不太困难。您可以通过使用软件包管理器(如 Homebrew)来保持安装简单,也可以手动安装 Java 和 Kafka 以更好地控制版本。
使用 Homebrew
如果您已经为 macOS 安装了Homebrew,您可以使用它一步安装 Kafka。这将确保您首先安装了 Java,然后安装 Apache Kafka 2.8.0(截至撰写时)。
如果您尚未安装 Homebrew,请首先按照安装页面上的说明进行安装。然后您可以安装 Kafka 本身。Homebrew 软件包管理器将确保您首先安装所有依赖项,包括 Java:
$ brew install kafka
==> Installing dependencies for kafka: openjdk, openssl@1.1 and zookeeper
==> Installing kafka dependency: openjdk
==> Pouring openjdk--16.0.1.big_sur.bottle.tar.gz
[...]
==> Summary
/usr/local/Cellar/kafka/2.8.0: 200 files, 68.2MB
$
Homebrew 将在/usr/local/Cellar下安装 Kafka,但文件将链接到其他目录中:
-
二进制文件和脚本将位于/usr/local/bin中。
-
Kafka 配置将位于/usr/local/etc/kafka中。
-
ZooKeeper 配置将位于/usr/local/etc/zookeeper中。
-
log.dirs配置(Kafka 数据的位置)将设置为/usr/local/var/lib/kafka-logs。
安装完成后,您可以启动 ZooKeeper 和 Kafka(此示例在前台启动 Kafka):
$ /usr/local/bin/zkServer start
ZooKeeper JMX enabled by default
Using config: /usr/local/etc/zookeeper/zoo.cfg
Starting zookeeper ... STARTED
$ /usr/local/bin/kafka-server-start /usr/local/etc/kafka/server.properties
[2021-07-18 17:52:15,688] INFO Registered kafka:type=kafka.Log4jController MBean (kafka.utils.Log4jControllerRegistration$)
[...]
[2021-07-18 17:52:18,187] INFO [KafkaServer id=0] started (kafka.server.KafkaServer)
[2021-07-18 17:52:18,232] INFO [broker-0-to-controller-send-thread]: Recorded new controller, from now on will use broker 192.168.0.2:9092 (id: 0 rack: null) (kafka.server.BrokerToControllerRequestThread)
手动安装
与在 Windows OS 上手动安装类似,当在 macOS 上安装 Kafka 时,您必须首先安装 JDK。使用相同的Oracle Java SE 下载页面来获取 macOS 的适当版本。然后您可以再次类似于 Windows 下载 Apache Kafka。在本例中,我们将假设 Kafka 下载扩展到/usr/local/kafka_2.13-2.8.0目录中。
启动 ZooKeeper 和 Kafka 看起来就像在 Linux 上启动它们一样,尽管您需要确保首先设置JAVA_HOME目录:
$ export JAVA_HOME=`/usr/libexec/java_home -v 16.0.1`
$ echo $JAVA_HOME
/Library/Java/JavaVirtualMachines/jdk-16.0.1.jdk/Contents/Home
$ /usr/local/kafka_2.13-2.8.0/bin/zookeeper-server-start.sh -daemon /usr/local/kafka_2.13-2.8.0/config/zookeeper.properties
$ /usr/local/kafka_2.13-2.8.0/bin/kafka-server-start.sh /usr/local/kafka_2.13-2.8.0/config/server.properties
[2021-07-18 18:02:34,724] INFO Registered kafka:type=kafka.Log4jController MBean (kafka.utils.Log4jControllerRegistration$)
[...]
[2021-07-18 18:02:36,873] INFO [KafkaServer id=0] started (kafka.server.KafkaServer)
[2021-07-18 18:02:36,915] INFO [broker-0-to-controller-send-thread]: Recorded new controller, from now on will use broker 192.168.0.2:9092 (id: 0 rack: null) (kafka.server.BrokerToControllerRequestThread)((("macOS, installing Kafka on", startref="ix_macOS")))((("operating systems", "other than Linux, installing Kafka on", startref="ix_OSinstall")))
附录 B:其他 Kafka 工具
Apache Kafka 社区已经创建了一个强大的工具和平台生态系统,使运行和使用 Kafka 的任务变得更加容易。虽然这并不是一个详尽的列表,但这里介绍了一些较受欢迎的工具,以帮助您入门。
买方注意
尽管作者与列入此列表的一些公司和项目有所关联,但他们和 O'Reilly 并没有明确支持某个工具。请务必自行研究这些平台和工具是否适合您需要做的工作。
全面的平台
一些公司提供了完全集成的平台,用于处理 Apache Kafka。这包括所有组件的托管部署,这样您就可以专注于使用 Kafka,而不是运行它的方式。这可以为资源不足(或者您不想将资源用于学习如何正确操作 Kafka 及其所需的基础设施)的用例提供理想的解决方案。一些还提供工具,如模式管理、REST 接口,有时还提供客户端库支持,以确保组件正确地进行交互。
| 标题 | Confluent Cloud |
|---|---|
| URL | https://www.confluent.io/confluent-cloud |
| 描述 | 由一些最初的开发者创建并支持 Kafka 的公司提供了受管理的解决方案,这是非常合适的。Confluent Cloud 将许多必备工具(包括模式管理、客户端、RESTful 接口和监控)合并为一个单一的产品。它在所有三个主要的云平台(AWS、Microsoft Azure 和 Google Cloud Platform)上都可用,并得到了 Confluent 雇佣的核心 Apache Kafka 贡献者的支持。该平台包括的许多组件,如模式注册表和 REST 代理,都可以作为独立的工具使用,受Confluent 社区许可证的支持,但限制了一些使用情况。 |
| 标题 | Aiven |
| URL | https://aiven.io |
| 描述 | Aiven 为许多数据平台提供了受管理的解决方案,包括 Kafka。为了支持这一点,它开发了Karapace,这是一个与 Confluent 的组件 API 兼容的模式注册表和 REST 代理,但在Apache 2.0 许可证下受支持,不限制使用情况。除了三个主要的云提供商,Aiven 还支持DigitalOcean和UpCloud。 |
| 标题 | CloudKarafka |
| URL | https://www.cloudkarafka.com |
| 描述 | CloudKarafka 专注于提供一种受管理的 Kafka 解决方案,并集成了流行的基础设施服务(如 DataDog 或 Splunk)。它支持使用 Confluent 的 Schema Registry 和 REST 代理与其平台,但仅支持 Confluent 在许可证更改之前的 5.0 版本。CloudKarafka 在 AWS 和 Google Cloud Platform 上提供其服务。 |
| 标题 | 亚马逊托管的 Apache Kafka 流式处理(Amazon MSK) |
| URL | https://aws.amazon.com/msk |
| 描述 | 亚马逊还提供了自己的托管 Kafka 平台,仅在 AWS 上受支持。通过与AWS Glue集成提供模式支持,而 REST 代理不受直接支持。亚马逊推广使用社区工具(如 Cruise Control、Burrow 和 Confluent 的 REST 代理),但不直接支持它们。因此,MSK 的集成性比其他提供的要低一些,但仍然可以提供核心 Kafka 集群。 |
| 标题 | Azure HDInsight |
| URL | https://azure.microsoft.com/en-us/services/hdinsight |
| 描述 | Microsoft 还为 HDInsight 中的 Kafka 提供了托管平台,该平台还支持 Hadoop、Spark 和其他大数据组件。与 MSK 类似,HDInsight 专注于核心 Kafka 集群,许多其他组件(包括模式注册表和 REST 代理)需要用户提供。一些第三方提供了执行这些部署的模板,但它们不受 Microsoft 支持。 |
| 标题 | Cloudera |
| URL | https://www.cloudera.com/products/open-source/apache-hadoop/apache-kafka.html |
| Description | Cloudera 自早期以来一直是 Kafka 社区的一部分,并将托管 Kafka 作为其整体客户数据平台(CDP)产品的流数据组件。CDP 不仅专注于 Kafka,而且在公共云环境中运行,并提供私有选项。 |
集群部署和管理
在托管平台之外运行 Kafka 时,您需要一些辅助工具来正确运行集群。这包括帮助进行供应和部署、平衡数据以及可视化您的集群。
-
Title - Strimzi
-
URL - https://strimzi.io
-
Description - Strimzi 提供了用于在 Kubernetes 环境中部署 Kafka 集群的 Kubernetes 操作员,使您更容易在云中(无论是公共云还是私有云)启动和运行 Kafka。它还提供了 Strimzi Kafka Bridge,这是一个在Apache 2.0 许可证下受支持的 REST 代理实现。目前,Strimzi 不支持模式注册表,因为存在许可证方面的顾虑。
-
Title - AKHQ
-
URL - https://akhq.io
-
Description - AKHQ 是用于管理和与 Kafka 集群交互的图形用户界面。它支持配置管理,包括用户和 ACL,并为诸如模式注册表和 Kafka Connect 等组件提供一些支持。它还提供了用于与集群中数据交互的工具,作为控制台工具的替代方案。
-
Title - JulieOps
-
Description - JulieOps(前身为 Kafka 拓扑生成器)采用 GitOps 模型提供自动化管理主题和 ACL 的功能。JulieOps 不仅可以查看当前配置的状态,还可以提供声明性配置和随时间变化的主题、模式、ACL 等的变更控制。
-
Title - Cruise Control
-
Description - Cruise Control 是 LinkedIn 对如何管理数百个集群和数千个代理的答案。这个工具最初是为了自动重新平衡集群中的数据而诞生的,但已经发展到包括异常检测和管理操作,如添加和删除代理。对于任何不止是测试集群的情况,这对于任何 Kafka 操作员来说都是必不可少的。
-
Title - Conduktor
-
URL - https://www.conduktor.io
-
Description - 虽然不是开源的,但 Conduktor 是一个流行的桌面工具,用于管理和与 Kafka 集群交互。它支持许多托管平台(包括 Confluent、Aiven 和 MSK)和许多不同的组件(如 Connect、kSQL 和 Streams)。它还允许您与集群中的数据交互,而不是使用控制台工具。提供用于开发使用的免费许可证,可与单个集群一起使用。
监控和数据探索
运行 Kafka 的关键部分是确保您的集群和客户端健康。与许多应用程序一样,Kafka 公开了许多指标和其他遥测数据,但理解它可能是具有挑战性的。许多较大的监控平台(如Prometheus)可以轻松地从 Kafka 代理和客户端获取指标。还有许多可用的工具可帮助理解所有数据。
| 标题 | Xinfra 监视器 |
| URL | https://github.com/linkedin/kafka-monitor |
| 描述 | Xinfra 监视器(前身为 Kafka 监视器)是 LinkedIn 开发的,用于监视 Kafka 集群和代理的可用性。它通过使用一组主题通过集群生成合成数据并测量延迟、可用性和完整性来实现这一点。它是一个有价值的工具,可以测量 Kafka 部署的健康状况,而无需直接与客户端进行交互。 |
| 标题 | Burrow |
| URL | https://github.com/linkedin/burrow |
| 描述 | Burrow 是 LinkedIn 最初创建的另一个工具,它提供了对 Kafka 集群中消费者滞后的全面监控。它可以查看消费者的健康状况,而无需直接与它们进行交互。Burrow 得到社区的积极支持,并拥有自己的工具生态系统来将其与其他组件连接起来。 |
| 标题 | Kafka 仪表板 |
| URL | https://www.datadoghq.com/dashboards/kafka-dashboard |
| 描述 | 对于那些使用 DataDog 进行监控的人,它提供了一个出色的 Kafka 仪表板,可以帮助您将 Kafka 集群整合到您的监控堆栈中。它旨在提供对 Kafka 集群的单一视图,简化了许多指标的视图。 |
| 标题 | 流资源浏览器 |
| URL | https://github.com/bakdata/streams-explorer |
| 描述 | Streams Explorer 是一个用于可视化数据在 Kubernetes 部署中的应用程序和连接器流动的工具。虽然它在很大程度上依赖于使用 bakdata 的工具结构化部署,但它可以提供这些应用程序及其指标的易于理解的视图。 |
| 标题 | kcat |
| URL | https://github.com/edenhill/kafkacat |
| 描述 | Kcat(前身为 kafkacat)是 Apache Kafka 核心项目中的控制台生产者和消费者的备选方案。它体积小,速度快,用 C 语言编写,因此没有 JVM 开销。它还通过显示集群的元数据输出来支持对集群状态的有限视图。 |
客户端库
Apache Kafka 项目为 Java 应用程序提供了客户端库,但一种语言永远不够。市面上有许多 Kafka 客户端的实现,流行的语言如 Python、Go 和 Ruby 都有几种选择。此外,REST 代理(例如 Confluent、Strimzi 或 Karapace)可以涵盖各种用例。以下是一些经得住时间考验的客户端实现。
| 标题 | librdkafka |
| URL | https://github.com/edenhill/librdkafka |
| 描述 | librdkafka 是 Kafka 客户端的 C 库实现,被认为是性能最佳的库之一。事实上,Confluent 支持 Go、Python 和.NET 客户端,这些客户端是围绕 librdkafka 创建的包装器。它仅在两条款的 BSD 许可证下获得许可,这使得它可以轻松用于任何应用程序。 |
| 标题 | Sarama |
| URL | https://github.com/Shopify/sarama |
Shopify 创建了 Sarama 客户端作为原生的 Golang 实现。它是根据MIT 许可证发布的。
kafka-python
https://github.com/dpkp/kafka-python
kafka-python 是另一个原生的客户端实现,这次是用 Python 实现的。它是根据Apache 2.0 许可证发布的。
流处理
虽然 Apache Kafka 项目包括 Kafka Streams 用于构建应用程序,但对于从 Kafka 处理数据的流处理来说,并不是唯一的选择。
Samza
Apache Samza 是一个专为 Kafka 设计的流处理框架。虽然它早于 Kafka Streams,但它是由许多相同的人开发的,因此两者共享许多概念。然而,与 Kafka Streams 不同,Samza 在 Yarn 上运行,并为应用程序提供了一个完整的运行框架。
Spark
Spark 是另一个面向数据批处理的 Apache 项目。它通过将流视为快速微批处理来处理流。这意味着延迟略高,但容错性通过重新处理批次来简单处理,并且 Lambda 架构很容易。它还有广泛的社区支持。
Flink
Apache Flink 专门面向流处理,并具有非常低的延迟。与 Samza 一样,它支持 Yarn,但也可以与 Mesos、Kubernetes 或独立集群一起工作。它还支持 Python 和 R,并提供了高级 API。
Beam
Apache Beam 并不直接提供流处理,而是将自己宣传为批处理和流处理的统一编程模型。它利用像 Samza、Spark 和 Flink 这样的平台作为整体处理管道中组件的运行器。


浙公网安备 33010602011771号