详解大数据领域中必不可少的消息中间件 Kafka

楔子

本次来聊一聊 Kafka,相信大家都知道它是一个应用于大数据实时领域、基于发布 / 订阅模式的分布式消息中间件(或者说消息队列),能够和不同的进程进行通信,从而实现上下游之间的消息传递。有了消息队列之后,上游服务和下游服务就无需直接通信了,上游服务将消息发送到队列中,下游从队列中去取即可,从而实现上下游服务之间的 "逻辑解耦 + 物理解耦"。

但是实现解耦有什么好处呢?答案是可以实现异步处理,提高效率,举个栗子:

使用消息队列,可以把耗时任务扔到队列里面进行异步调用,从而提升效率,也就是我们所说的解耦。然而除了解耦,还有没有其它作用呢?答案显然是有的,用一个专业点的名词解释的话,就是削峰填谷。

削峰填谷,名字很形象,就是缓冲瞬时的突发流量,使其更平滑。特别是那种发送能力很强的上游系统,如果没有消息中间件的保护,脆弱的下游系统可能会被直接压垮,进而导致全链路服务雪崩。可一旦有了消息中间件,它就能有效地对抗上游的流量冲击,真正做到将上游的 "峰" 填到 "谷" 中,避免了流量的震荡。当然我们这里说的解耦也是一个优点,因为在一定程度上简化了应用的开发,减少了系统间不必要的交互。

直接解释的话,可能没有直观的感受,我们来举一个实际的例子。比如在京东购买商品,当点击购买的时候,会调用订单系统生成对应的订单。然而要处理该订单则会依次调用下游系统的多个子服务,比如查询你的登录信息、验证商品信息、确认地址信息,调用银行等支付接口进行扣款等等。显然上游的订单操作比较简单,它的 TPS 要远高于处理订单的下游服务。因此如果上游和下游直接对接,势必会出现下游服务无法及时处理上游订单从而造成订单堆积的情况,特别是当出现双十一、双十二、类似秒杀这种业务的时候,上游订单流量会瞬间增加,可能出现的结果就是直接压垮下游子系统服务。

而解决此问题的一个常见的做法就是对上游系统进行限速、或者限制请求数量,但这种做法显然是不合理的,毕竟问题不是出现在它那里。况且你要是真这么做了,别人家网站双十一成交一千万笔单子,自家网站才成交一百万笔单子,这样钱送到嘴边都赚不到。

所以更常见的办法就是引入消息中间件来对抗这种上下游系统的 TPS 不一致以及瞬时的峰值流量,引入消息中间件之后,上游系统不再直接与下游系统进行交互。当新订单生成之后它仅仅是向队列中发送一条消息,而下游消费队列中的消息,从而实现上游订单服务和下游订单处理服务的解耦。这样当出现秒杀业务的时候,能够将瞬时增加的订单流量全部以消息的形式保存在队列中,既不影响上游服务的 TPS,同时也给下游服务流出了足够的时间去消费它们,这就是消息中间件存在的最大意义所在。

消息队列的种类

那么问题来了,消息中间件、或者说消息队列都有哪些呢?它们的性能、应用场景、优缺点又如何呢?


ActiveMQ

非常老的一个消息中间件了,单机吞吐量在万级,时效性在毫秒级,可用性高。基于主从架构实现高可用性,数据丢失的概率性低。但是官网社区对 ActiveMQ 5.x 的维护越来越少,并且它的吞吐量和其它消息中间件相比其实是不高的,因此在高吞吐量场景下使用的比较少。


Kafka

大数据的杀手锏,谈到大数据领域的消息传输,必离不开 Kafka。这款为大数据而生的消息中间件,有着百分级 TPS 的吞吐量,在数据采集、传输、存储的过程中发挥至关重要的作用,任何的大公司、或者做大数据的公司都离不开 Kafka。

Kafka 的特点就是性能卓越,单机写入 TPS 在百万条每秒,时效性也在毫秒级;并且 Kafka 是分布式的,一个数据多个副本,少数的机器宕机也不会丢失数据;消费者采用 Pull 方式获取消息,消息有序、并且可以保证所有消息被消费且仅被消费一次;此外还有优秀的第三方 Kafka Web 管理界面 Kafka-Manager,在日志领域比较成熟,大数据领域的实时计算以及日志采集等场景中被大规模使用。

但是 Kafka 也有缺点,单机超过 64 个分区,CPU 使用率会发生明显的飙高现象,队列越多 CPU 使用率越高,发送消息响应时间变长;使用短轮询方式,实时性取决于轮询间隔时间,消费失败不支持重试;虽然支持消息有序,但如果某台机器宕机,就会产生消息乱序。


RocketMQ

阿里巴巴开源的一款消息中间件,用 Java 语言实现,在设计时参考了 Kafka,并做了一些改进。在阿里内部,广泛应用于订单、交易、重置、流计算、消息推送、日志流式处理、以及 binlog 分发等场景。

RocketMQ 支持单机吞吐量达到十万级,可用性非常高,分布式架构保证消息零丢失。MQ 功能较为完善,扩展性好,支持 10 亿级别的消息堆积,不会因为消息堆积导致性能下降。

但是支持的客户端语言不多,仅支持 Java 和 C++,其中 C++ 还不成熟。社区活跃度一般,没有在 MQ 核心中实现 JMS 等接口,说白了 RocketMQ 就是阿里开发出来给自己用的。


RabbitMQ

RabbitMQ 是一个在 AMQP(高级消息队列协议)基础上完成的可复用的企业消息系统,是当前最主流的消息中间件之一。

AMQP:Advanced Message Queuing Protocl,即:高级消息队列协议。它是具有现代特征的二进制协议,是一个提供统一消息服务的应用层标准高级消息队列协议,是应用层协议的一个开放标准,为面向消息的中间件设计。

RabbitMQ 采用 Erlang 语言编写,Erlang 语言最初用于交换机领域的架构模式,它有着和原生 socket 一样的延迟。因此 RabbitMQ 性能较好,吞吐量在万级,并且时效性在微妙级;功能也很完善,健壮、稳定、易用、跨平台;最重要的是支持大部分主流语言,文档也丰富,此外还提供了管理界面,并拥有非常高的社区活跃度和更新频率。

但是它的商业版是需要收费的,学习成本高。


目前主流的就是这几种消息中间件,那么我们要选择哪一种呢?

首先是 Kafka,它主要特点是基于 Pull 模式来处理消息消费,追求高吞吐量,一开始的目的就是用于日志收集和传输,高吞吐量是 Kakfka 的目标。因此如果要涉及大量数据的收集(比如日志采集),那么首选 Kafka。

然后是 RocketMQ,它天生为金融领域而生,对于可靠性要求很高的场景,尤其是电商里面的订单扣款、以及业务削峰。RocketMQ 在稳定性上绝对值得信赖,毕竟这些业务场景在阿里双十一已经经历了多次考验,如果你的业务也有类似场景,那么建议选择 RocketMQ。

最后是 RabbitMQ,结合 Erlang 语言的并发优势,性能好、时效性微妙级,社区活跃度也高,管理界面用起来非常方便。如果你的数据量没有那么大,那么建议选择 RabbitMQ,其实中小型公司选择 RabbitMQ 是一个非常好的选择。

而本篇文章我们要学习的就是 Kafka,它目前是最主流的一种消息中间件。

Kafka 真的只是一个消息中间件吗

我们说 Kafka 是一个主流的消息中间件,这是没问题的,但 Kafka 又不仅仅是消息中间件。要搞清楚这个问题,就要从 Kafka 的发展历史说起,纵观 Kafka 的发展历史,它确实是消息中间件起家的,但它不仅是一个消息中间件,同时也是一个分布式流处理平台(distributed stream processing platform),而 Kafka 官方也是这么定义 Kafka 的。

众所周知,Kafka 是 LinkedIn 公司内部孵化的项目,LinkedIn 最开始有强烈的数据强实时处理方面的需求,其内部的诸多子系统要执行多种类型的数据处理与分析,主要包括业务系统和应用程序性能监控,以及用户行为数据处理等。当时他们碰到的主要问题包括:

  • 数据正确性不足。因为数据的收集主要采用轮询(polling)的方式,如何确定轮询的时间间隔就变成了一个高度经验化的事情。虽然可以采用一些类似于启发式算法来帮助评估间隔时间,但一旦指定不当,必然会造成较大的数据偏差。
  • 系统高度定制化,维护成本高。各个业务子系统都需要对接数据收集模块,引入了大量的定制开销和人工成本。

为了解决这些问题,LinkedIn 工程师尝试过使用 ActiveMQ 来解决这些问题,但效果并不理想。显然需要有一个 "大一统" 的系统来取代现有的工作方式,而这个系统就是 Kafka。因此 Kafka 自诞生伊始是以消息中间件的面目出现在大众视野的,如果翻看比较老的 Kafka 对应的官网的话,你会发现 Kafka 社区将其清晰地定位成 "一个分布式、分区化且带备份功能的提交日志(commit log)服务"。

因此,Kafka 在设计之初就旨在提供三个方面的特性:

  • 提供一套 API 实现生产者和消费者
  • 降低网络传输和磁盘存储开销
  • 实现高伸缩架构

在现如今的大数据领域,Kafka 在承接上下游、串联数据流管道方面发挥了重要的作用:所有的数据几乎都要从一个系统流入 Kafka,然后再从 Kafka 流入下游的另一个系统中 。这样的使用方式屡见不鲜以至于引发了 Kafka 社区的思考:与其我把数据从一个系统传递到下一个系统进行处理,我为何不自己实现一套流处理框架呢?基于这个考量,Kafka 社区在 0.10.0.0 版本推出了流处理组件 Kafka Streams,也正是从这个版本开始,Kafka 正式变身为分布式的流处理平台,而不再仅仅只是消息中间件了,到今天 Kafka 已经是和 Storm、Spark、Flink 同等级的实时流处理平台了。


那么作为流处理平台,Kafka与其他大数据流式计算框架相比,优势在哪里呢?

1)更容易实现端到端的正确性(correctness)。流处理要最终替代它的兄弟批处理需要具备两个核心优势:

  • 实现正确性;
  • 提供能够推导时间的工具;

实现正确性是流处理能够匹敌批处理的基石。

正确性一直是批处理的强项,而实现正确性的基石则是要求框架能提供 '精确一次语义处理',即处理一条消息有且只有一次机会能够影响系统状态。目前主流的大数据流处理框架都宣称实现了 '精确一次语义处理',但这是有限定条件的,即它们只能实现框架内的精确一次语义处理,无法实现端到端的。这是为什么呢?因为当这些框架与外部消息中间件结合使用时,它们无法影响到外部系统的处理语义。

这里解释一下什么是精确一次语义处理。举个例子,如果我们使用 Kafka 计算某网页的 pv,那么每次网页访问都将作为一条消息发送给 Kafka,pv 的计算就是统计 Kafka 总共接收了多少条这样的消息即可。精确一次语义处理表示每次网页访问都会产生、且只产生一条消息。

所以如果你搭建了一套环境使得 Spark 或 Flink 从 Kafka 读取消息之后进行有状态的数据计算,最后再写回 Kafka,那么你只能保证在 Spark 或者 Flink 内部,这条消息对状态的影响只有一次,但计算结果却有可能多次写入 Kafka,因为它们不能控制 Kafka 的语义处理。相反地,Kafka 则不是这样,因为所有的数据流转和计算都在 Kafka 内部完成,故 Kafka 可以实现端到端的精确一次处理。

2)Kafka 自己对于流式计算的定位,官网上明确表示 Kafka Streams 是一个用于搭建实时流处理的客户端库,而非一个完整的功能系统。也就是说,你不能期望 Kafka 提供类似集群调度、弹性部署等开箱即用的运维特性,你需要自己选择合适的工具或者系统来帮助 Kafka 流处理应用实现这些功能。

可能有人觉得这怎么会是优点呢?坦率的说,这是一个双刃剑的设计,也是 Kafka 剑走偏锋不正面 pk 其它流计算框架的特意考量。大型公司的流处理平台一定是大规模部署的,因此具备集群调度功能以及灵活的部署方案是不可或缺的要素。但毕竟世界上还存在着很多中小企业,它们的流处理数据量并不巨大,逻辑也不复杂,部署几台或者十几台机器足以应付。在这样的需求下,搭建重量级的完整性平台实在是 "杀鸡用宰牛刀",而这正式 Kafka 流处理组件的用武之地。因此从这个角度来说,未来在流处理框架当中,Kafka 应该是有着一席之地的。


然后除了消息中间件和流处理平台,Kafka 还有别的用途吗?当然有,Kafka 甚至能够被用作分布式存储系统,但是实际生产中,没有人会把 Kafka 当作分布式存储系统来用的。总之 Kafka 从一个优秀的消息中间件起家,逐渐演变成现在的分布式流处理平台,我们不仅要熟练掌握它作为消息中间件的非凡特性以及使用技巧,最好还要多了解下其流处理组件的设计与案例应用。

不过说实话,虽然 Kafka 能做的事情有很多,但就目前来讲,大部分公司还是只把 Kafka 当做消息中间件来用。因此 Kafka 作为消息中间件的特性,我们必须要熟练掌握。

应该选择哪种 Kafka

前面我们谈了一下 Kafka 当前的定位问题,Kafka 不再是一个单纯的消息中间件,而是能够实现精确一次(exactly-once)语义的实时流处理平台。而我们到目前为止所说的 Kafka 都是 Apache Kafka,因为 Kafka 是 Apache 社区的一个顶级项目,如果我们把视角从流处理平台扩展到流处理生态圈,Kafka 其实还有很长的路要走,毕竟是半路出家转型成为流处理平台的。前面我们提到过 Kafka Streams 组件,正是它提供了 Kafka 实时处理流数据的能力,但是其实还有一个重要的组件没有提及,那就是 Kafka Connect。

我们在评估流处理平台的时候,框架本身的性能、提供的操作算子(operator)的丰富程度固然是重要的评判指标,但是框架与上下游交互的能力也是非常重要的。能够与之进行数据传输的外部系统越多,围绕它打造的生态圈就越牢固,因而也就有更多的人愿意去使用它,从而形成正向反馈,不断地促进该生态圈的发展。就 Kafka 而言,Kafka Connect 通过一个个具体的连接器(Connector),串联起上下游的外部系统。

说了这么多,可能会有人好奇这跟这一节的主题有什么关系呢?其实清晰地了解 Kafka 的发展脉络和生态圈现状,对于我们选择合适种类的 Kafka 大有裨益。

Kafka 的种类

由于 Kafka 分为好几种,当然这里的好几种指的是多个组织或者公司发布的不同种类的 Kafka,就像 Linux 的发行版有 Ubuntu、Centos 等等。虽说 Kafka 没有发行版的概念,但姑且可以近似认为市面上的确存在多个 Kafka "发行版",下面就来看看 Kafka 都有哪些 "发行版",以及我们应该如何选择。


Apache Kafka

Apache Kafka 是最 "正宗" 的 Kafka,自 Kafka 开源伊始,它便在 Apache 基金会孵化并最终毕业成为顶级项目,也被称之为社区版 Kafka,我们一会儿学习的也是这个版本的 Kafka。更重要的是,它是后面其它所有发行版的基础。也就是说,其它的发行版要么是原封不动地继承了 Apache Kafka,要么是在其基础之上进行了扩展、添加了新功能。总之 Apache Kafka 是我们学习和使用 Kafka 的基础。


Confluent Kafka

Confluent Kafka 是 Confluent 公司发布的 Kafka, 2014 年 Kafka 的 3 个创始人 Jay Kreps、Naha Narkhede 和饶军 离开 LinkedIn 创办了 Confluent 公司,专注于提供基于 Kafka 的企业级流处理解决方案。2019 年 1 月,Confluent 公司成功融资 1.25 亿美元,估值也到了 25 亿美元,足见资本市场的青睐。

而 Confluent Kafka 提供了 Apache Kafka 没有的高级特性,比如跨数据中心备份、Schema 注册中心以及集群监控工具等等。


Cloudera / Hortonworks Kafka

Cloudera 公司提供的 CDH 和 Hortonworks 公司提供的 HDP 是非常著名的大数据平台,里面集成了目前主流的大数据框架,能够帮助用户实现从分布式存储、集群调度、流处理到机器学习、实时数据库等全方位的数据处理。很多创业公司在搭建数据平台时首选的就是这两个产品,当然不管是 CDH 还是 HDP,里面都集成了 Apache Kafka。

在 2018 年 10 月两家公司宣布合并,共同打造世界领先的数据平台,也许以后 CDH 和 HDP 会合并成一款产品,但能肯定的是 Apache Kafka 依然会包含其中,并作为新数据平台的一部分对外提供服务。

不同种类的 Kafka 的优势与劣势

okay,说完了目前市面上的这些 Kafka,我们来对比一下它们的优势与劣势。


Apache Kafka

对于 Apache Kafka 而言,它现在依旧是开发人数最多,版本迭代速度最快的 Kafka。在 2018 年度 Apache 基金会邮件列表中开发者数量最多的 top5 排行榜中,Kafka 社区邮件组排名第二位。如果你使用 Apache Kafka 碰到任何问题并将问题提交到社区,社区都会比较及时地响应你,这对于 Kafka 普通使用者来说无疑是非常友好的。

但 Apache Kafka 的劣势在于它仅仅提供最基础的主组件,特别是对于前面提到的 Kafka Connect 而言,社区版 Kafka 只提供一种连接器,即读写磁盘文件的连接器,而没有与其它外部系统交互的连接器,在实际使用过程中需要自行编写代码实现,这是它的一个劣势。另外 Apache Kafka 没有提供任何监控框架或工具,而在线上环境不加监控肯定是不行的,因此就需要借助第三方的监控框架来对 Kafka 进行监控。好消息是目前已经有一些开源的监控框架可以用于监控 Kafka,比如 Kafka Manager。

总而言之,如果仅仅需要一个消息中间件亦或是简单的流处理应用场景,同时需要对系统有较大把控度,那么推荐使用 Apache Kafka。


Confluent Kafka

Confluent Kafka 目前分为免费版和企业版两种,免费版和 Apache Kafka 非常相像,除了常规的组件之外,免费版还包含 Schema 注册中心和 Rest Proxy 两大功能。前者是帮助你集中管理 Kafka 消息格式以实现数据向前 / 向后兼容;后者用开放的 HTTP 接口的方式允许你通过网络访问 Kafka 的各种功能,这两个都是 Apache Kafka 所没有的。除此之外,免费版还包含了更多的连接器,它们都是 Confluent 公司开发并认证过的,你可以免费使用它们。

至于企业版提供的功能就更多了,最有用的当属跨数据中心备份和集群监控两大功能了,多个数据中心之间的数据同步以及对集群的监控历来是 Kafka 的痛点,Confluent Kafka 企业版提供了强大的解决方案来帮助你干掉它们。不过 Confluent Kafka 没有发展国内业务的计划,相关资料以及技术支持都很欠缺,很多国内的使用者都无法找到对应的中文文档,因此目前 Confluent Kafka 在国内的普及率是比较低的。

一言以蔽之,如果你需要使用 Kafka 的一些高级特性,那么推荐你使用 Confluent Kafka。


Cloudera / Hortonworks Kafka

最后说说大数据云公司发布的 Kafka,这些大数据平台天然继承了 Apache Kafka,通过便捷化的界面操作将 Kafka 的安装、运维、管理、监控全部统一在控制台中。如果你是这些平台的用户一定会觉得非常方便,因为所有的操作都可以在前端 UI 界面上完成,而不必执行复杂的 Kafka 命令。另外这些平台的监控界面也非常友好,你通常不需要进行任何配置就能有效地监控 Kafka。

但凡事有利就有弊,这样做的结果就是直接降低了开发者对 Kafka 集群的掌握程度,毕竟对下层的 Kafka 集群一无所知,怎么能够做到心中有数呢?这种 Kafka 的另一个弊端在于它的滞后性,由于它有自己的发布周期,因此是否能及时地包含最新版本的 Kafka 就成了一个问题。比如 CDH 6.1.0 版本发布时 Apache Kafka 已经演进到了 2.1.0 版本,但 CDH 中的 Kafka 仍然是 2.0.0 版本,显然那些在 Kafka 2.1.0 中修复的 Bug 只能等到 CDH 下次版本更新时才有可能被真正修复。

简单来说,如果你需要快速地搭建消息中间件,或者你需要搭建的是多框架构成的数据平台且 Kafka 只是其中的一个组件,那么建议使用这些大数据云公司提供的 Kafka。


总结一下,Kafka 有不同的 "发行版",每种发行版都有自己的优缺点,根据这些优缺点,我们可以有针对性地根据实际需求选择合适的 Kafka。

  • Apache Kafka,也称社区版 Kafka。优势在于迭代速度快,社区响应度高,使用它可以让你有更高的把控度;缺陷在于仅提供最基础的核心组件,缺失一些高级特性。
  • Confluent Kafka,Confluent 公司提供的 Kafka。优势在于集成了很多高级特性且由 Kafka 原版人马打造,质量上有保证;缺陷在于相关资料不全,普及率较低,没有太多可供参考的范例。
  • CDH/HPD Kafka,大数据云公司提供的 Kafka,内嵌 Apache Kafka。优势在于操作简单,节省运维成本;缺陷在于把控度低,演进速度较慢。

Kafka 核心概念

在 Kafka 的世界中有很多概念和术语是需要我们提前理解并且熟练掌握的,下面来盘点一下。

之前我们提到过,Kafka 属于分布式消息中间件,主要功能是提供一套完善的消息发布与订阅方案。而在 Kafka 中,发布订阅的对象是主题(Topic),可以为每个业务、每个应用、甚至是每一类数据都创建专属的主题。

向主题发布消息的客户端应用程序叫做生产者(Producer),生产者通常持续不断地向一个或多个主题发送消息,而订阅这些主题并获取消息进行消费的客户端应用程序叫做消费者(Consumer)。和生产者类似,消费者也能同时订阅多个主题。

然后无论是生产者还是消费者,我们都可以称它们为客户端(Client),你可以同时运行多个生产者和消费者实例,这些实例不断地向 Kafka 集群中的多个主题生产消息和消费消息。

因此这里就引出了 Kafka 的消费模式,既然 Kafka 内部传输的是消息,那么消息如何传递也是重要的一环,而在 Kafka 内部支持两种传递模式。

点对点模式

生产者将生产的消息发送到 Topic,然后消费者再从 Topic 取出消息进行消费。消息一旦被消费,那么 Topic 中就不会再有存储,所以消费者不可能消费到已经被消费的消息。并且 Topic 支持多个消费者同时消费,但是一条消息只能被一个消费者消费,不存在多个消费者消费同一条消息。就好比电话客服服务,同一个客户呼入电话,只能被一位客服人员处理,第二个客服人员不能再为该客户服务。

发布订阅模式

该模式也有发送方和接收方,只不过叫法不一样,发送方也被称为发布者(Publisher),接收方被称为订阅者(Subscriber)。和点对点模式不一样,该模式可以存在多个发布者和多个订阅者,它们都能接收到相同主题的消息。就好比微信公众号,一个公众号可以有多个订阅者,一个订阅者也可以订阅多个公众号。

有客户端自然也就有服务端,Kafka 服务启动之后对应的进程被称为 Broker,所以一个 Kafka 集群相当于由多个 Broker 组成,Broker 负责接收和处理客户端发来的请求,以及对消息进行持久化。虽然多个 Broker 能够运行在同一台机器上,但更常见的做法是将不同的 Broker 分散运行在不同的机器上。这样即便集群中的某一台机器宕机,运行在其之上的 Broker 挂掉了,其它机器上的 Broker 也依旧能对外提供服务,这其实就是 Kafka 提供高可用的手段之一,当然任何一个分布式框架都应该具备这种手段。

而 Kafka 实现高可用的另一个手段就是备份机制(Replication),备份的思想很简单,就是把相同的数据拷贝到多台机器上,而这些相同的数据拷贝就叫做副本(Replica)。副本的数量是可以配置的,这些副本保存着相同的数据,但却有不同的角色和作用。Kafka 定义了两种副本:领导者副本(Leader Replica)追随者副本(Follower Replica),前者对外提供服务,这里的对外指的是与客户端进行交互;而后者只是被动地追随领导者副本而已,不与外界进行交互。

当然了,很多其它系统中追随者副本是可以对外提供服务的,比如 MySQL,从库是可以处理读操作的,也就是所谓的 "主写从读"。但是在 Kafka 中追随者副本不会对外提供服务,生产者向主题写的消息总是往领导者那里,消费者向主题获取的消息也都是来自于领导者。也就是说,无论是读还是写,针对的都是领导者副本,至于追随者副本,它只做一件事情,那就是向领导者副本发送请求,请求领导者副本把最新生产的消息发送给它,这样便能够保持和领导者的同步,至于 Kafka 为什么这么做我们后面说。

对了,关于领导者和追随者,之前其实是叫做主(Master)从(Slave),但是不建议使用了。因为 Slave 有奴隶的意思,政治上有点不合适,所以目前大部分的系统都改成 Leader & Follower 了。

虽然有了副本机制可以保证数据不丢失,但没有解决伸缩性的问题,伸缩性即所谓的 Scalability,是分布式系统中非常重要且必须谨慎对待的问题。什么是伸缩性呢?我们拿副本来说,虽然现在有了领导者副本和追随者副本,但倘若领导者副本积累了太多的数据以至于单台 Broker 都无法容纳了,此时应该怎么办?有个很自然的想法就是,能否把数据分割成多份保存在不同的 Broker 上?没错,Kafka 就是这么设计的。

这种机制就是所谓的分区(Partition),如果你了解其它的分布式系统,那么可能听说过分片、分区域等说法,比如 MongoDB 和 ElasticSearch 中的 Sharding、Hbase 中的 Region 等等,其实它们都是相同的原理,只是 Partition 是最标准的名称。

Kafka 中的分区机制指的是将每个主题划分为多个分区,每个分区都是一组有序的消息日志。生产者生产的每一条消息只会被发到一个分区中,也就是说如果往有两个分区的主题发送一条消息,那么这条消息要么在第一个分区中,要么在第二条分区中。而 Kafka 的分区编号是从 0 开始的,如果某个 Topic 有 100 个分区,那么它们的分区编号就是从 0 到 99。

到这里可能会有疑问,那就是刚才提到的副本如何与这里的分区联系在一起呢?实际上,副本是在分区这个层级定义的。每个分区可以配置若干个副本,其中只能有 1 个领导者副本和 N-1 个追随者副本。生产者向分区写入消息,每条消息在分区中的位置由一个叫位移(Offset)的数据来表示。分区位移总是从 0 开始,假设一个生产者向一个空分区写入了 10 条消息,那么这 10 条消息的位移依次是 0、1、2、...、9。


至此我们能完整地串联起 Kafka 的三层消息架构:

  • 第一层是主题层,每个主题可以配置 M 个分区,每个分区又可以配置 N 个副本。
  • 第二层是分区层,每个分区的 N 个副本中只能有一个副本来充当领导者角色,对外提供服务;剩余的 N-1 个副本是追随者副本,用来提供数据冗余之用。
  • 第三层是消息层,分区中包含若干条消息,每条消息的位移从 0 开始,依次递增。
  • 最后客户端程序只能与分区的领导者副本进行交互。

这里再重点说一下消费者,之前说过 Kafka 有两种消费模型,即点对点模型(peer to peer,p2p)和发布订阅模型。在点对点模型中可以存在多个消费者,但是同一条消息只能被下游的一个消费者消费,其它消费者不能染指,而这些多个消费者在 Kafka 中叫做消费者组(Consumer Group)。所谓的消费者组,指的是多个消费者实例共同组成一个组来消费一个主题,这个主题中的每个分区都只会被消费者组里面的一个消费者实例消费。至于为什么要引入消费者组,主要是为了提升消费者端的吞吐量,多个消费者实例同时消费,加速了整个消费端的吞吐量(TPS)。

关于消费者组的机制,后面会详细介绍,现在只需要知道消费者组就是多个消费者组成一个组来消费主题里面的消息、并且一条消息只会被组里面的一个消费者消费即可。

然后每个消费者在消费消息的过程中,必然需要有个字段记录它当前消费到了分区的哪个位置上,这个字段就是消费者位移(Consumer Offset)。注意,我们之前说一个主题可以有多个分区、每个分区也是用位移来表示消息的位置,但这两个位移完全不是一个概念。分区位移表示的是分区内的消息位置,它是不变的,一旦消息被成功写入到一个分区上,那么它的位置就是固定了的。而消费者位移则不同,它是消费者消费进度的指示器,记录消费者消费到了分区内的哪一条消息,显然这是一个随时变化的值。另外每个消费者都有着自己的消费者位移,因此一定要区分这两类位移的区别,一个是分区位移,另一个是消费者位移。


目前出现的概念有些多,我们总结一下:

  • 生产者(Producer):向主题发布新消息的应用程序;
  • 消费者(Consumer):从主题订阅新消息的应用程序;
  • 消息(Record):Kafka 是消息中间件,这里的消息就是 Kafka 处理的主要对象;
  • 主题(Topic):主题是承载消息的逻辑容器,一般不同的业务对应不同的主题;
  • 分区(Partition):一个有序不变的消息序列,每个主题可以有多个分区。分区编号从 0 开始单调递增,分布在不同的 Broker 上面,实现发布订阅的负载均衡。生产者将消息发送到主题的某个分区中,以分区位移(Offset)来标识一条消息在一个分区中的位置(唯一性),分区位移也是一个从 0 开始单调递增的值;
  • 分区位移(Offset):表示分区中每条消息的位置信息,是一个单调递增且不变的值;
  • 副本(Replica):Kafka 中同一条数据能够被拷贝到多个地方以提供数据冗余,这便是所谓的副本。副本分为领导者副本和追随者副本,各自有各自的功能职责。读写都是针对领导者副本来的,追随者副本只是用来和领导者副本进行数据同步、保证数据冗余、实现高可用;
  • 消费者位移(consumer offset):表示消费者消费进度,即消费到了分区的哪一条消息,每个消费者都有自己的消费者位移;
  • 消费者组(consumer group):多个消费者实例共同组成的一个组,同时消费多个分区以实现高吞吐;

我们用一张图来描述一下:

生产者向主题写入消息,消费者(组)进行消费。一个主题会被划分为多个分区,散落在不同的 Broker 上,从而实现负载均衡。然后每个分区可以配置 1 个领导者副本和 N-1 个追随者副本,而领导者副本对应的分区不会集中在同一个 Broker 上。比如图中的 Broker0,它的分区0 和 分区2 存储的是领导者副本,分区1 存储的是追随者副本;Broker1 的分区0 和 分区2 存储的是追随者副本,分区1 存储的是领导者副本。其实也很好理解,因为读写操作全部由领导者副本提供,如果对应的分区都在同一个 Broker 上,那负载均衡要从何谈起呢。

至于追随者副本既不提供写也不提供读,它只是向领导者发请求,把最新的消息同步给它,如图中箭头所示。然后是分区位移,它表示消息在分区中的位置,从 0 开始单调递增,一条消息一旦写到某个分区,那么消息在该分区内的位移(分区位移)就已经固定了。

最后是消费者位移,它是消费者消费进度的指示器,消费者消费到某条消息时,该消息的分区位移就是当前的消费者位移。所以随着消息的不断消费,消费者位移也是不断发生变化的。

总结:主题会被划分为多个分区,这些分区散落在不同的 Broker 上面,生产者往主题写消息,最终会写到主题的某一个分区当中,而分区位移则记录了消息在该分区的位置。但还需要考虑到高可用,于是又引入了副本机制,每个分区下的消息会存在冗余。比如一个主题被划分为 3 个分区,存储在 3 台 Broker 上,那么 Broker0 可以存储分区 0 和分区 1,Broker1 可以存储分区 1 和分区2,Broker2 可以存储分区 0 和分区 2。

所以分区消息会被存储多份,从而实现高可用。并且多个消息副本中,只能有一个副本对外提供服务,也就是领导者副本;至于其它的副本只是负责和领导者副本保持一致。


思考:为什么 Kafka 不像 MySQL 那样支持主写从读呢?

因为 Kafka 的主题已经被分为多个分区,分布在不同的 Broker 上,而不同的 Broker 又分布在不同的机器上,因此 Kafka 已经实现了负载均衡的效果。不像 MySQL,压力都在主上面,所以才要从读。

另外 Kafka 保存的数据和数据库的数据有着实质性的差别,Kafka 保存的数据是流数据,具有消费的概念,而且需要消费者位移。所以如果支持从读,那么消费端控制 Offset 会更复杂,而且领导者副本同步到追随者副本是需要时间的,会造成数据不一致的问题。而且对于生产者来说,Kafka 可以通过配置来控制是否等待 Follower 确认消息,如果支持从读,那么需要所有的 Follower 都确认了才可以回复生产者,造成性能下降,而且 Follower 出现了问题也不好处理。

Kafka 安装

下面我们来安装 Kafka,但在安装之前需要先说一下它和 ZooKeeper 的关系。

ZooKeeper 负责对分布式框架提供协调服务,Kafka 也一直使用 ZooKeeper 来管理集群的元数据,比如 Topic 的分区信息、Broker 的状态等。但 ZooKeeper 比较笨重,并且性能也存在问题,在处理大量的读写请求时,会显得力不从心。因此当 Kafka 的 Topic 和 Partition 达到一定规模的时候,数据同步的问题就会凸显出来。

主要是 Kafka 作为一款高性能的消息队列,不应该依赖重量级的 ZooKeeper,因为这会徒增架构的复杂性。而且想对 Kafka 进行调优,还要兼顾另一个系统,本身就不太合理。


于是 Kafka 在 2.8.0 的时候移除了 ZooKeeper,并引入了一个内部的元数据管理系统,用来接替 ZooKeeper 的工作,如集群成员管理、分区分配、以及领导选举等。这个内部系统被称为 KRaft(Kafka Raft 元数据模式),它基于 Raft 一致性算法实现。

那么这个改动可以带来哪些优化呢?

  • 简化架构:在早期版本中,Kafka 依赖 ZooKeeper 来进行集群管理、元数据存储以及一些协调任务,这意味着 Kafka 集群的运维需要同时管理 Kafka 和 Zookeeper 集群。移除 ZooKeeper 后,Kafka 能够提供一个更简化和集成的架构,降低了运维复杂度和成本。
  • 提升性能:通过内置的协调机制,Kafka 能更直接地管理分区和副本的信息,减少了与 ZooKeeper 之间的通信开销。这有助于提高整体的性能,尤其是在元数据操作频繁的场景下。
  • 增加可扩展性和可靠性:Kafka 自身的协调机制可以更好地优化和扩展,以支持大规模集群和高吞吐量的需求,此外减少对外部服务的依赖还可以提高系统的稳定性和可靠性。
  • 增强安全性:通过移除 ZooKeeper,Kafka 可以更加集中地管理安全策略和访问控制,提高了安全性。

下面我们来安装 Kafka,直接去官网下载安装包即可。

官网提供了源码版本和编译之后的二进制版本,这里我们选择二进制版本。但是里面的版本号有点意思,值得说一下。

首先 Kafka 服务端的代码完全由 Scala 语言编写,Scala 同时支持面向对象编程和函数式编程,用 Scala 写的源代码编译之后也是普通的 .class 文件,因此我们说 Scala 是 JVM 系的语言,它的很多设计思想都是为人称道的。

事实上目前 Java 新推出的很多功能都是在不断地向 Scala 靠近,比如 lambda 表达式、函数式接口、val 变量等等。一个有意思的事情是,Kafka 新版客户端代码完全由 Java 语言编写,于是有人展开了 Java vs Scala 的讨论,并从语言特性的角度尝试分析 Kafka 社区为什么放弃 Scala 转而使用 Java 编写客户端代码。其实事情远没有那么复杂,仅仅是因为社区来了一批 Java 程序员,而以前老的 Scala 程序员隐退了而已。

扯得有点远了,回到版本号上面来。版本号里面的 2.12 和 2.13 表示编译 Kafka 源代码的 Scala 语言版本,真正的 Kafka 版本号是 3.6.1。其中 3 表示大版本号,即 major version,6 表示小版本号或次版本号,即 minor version,最后的 1 表示修订版本号,即 patch version。

这里我们下载 kafka_2.13-3.6.1.tgz,完了丢到服务器上面进行安装。不过在此之前,我们需要先安装 JDK,虽然 Kafka 的源代码是 Scala 语言编写的,但实际上我们不需要安装 Scala,只需要安装 JDK 即可,版本不建议低于 1.8。

然后安装 kafka,这里我们使用的是最新版 3.6.1,当然你也可以选择别的版本。

export KAFKA_HOME=/opt/kafka_2.13-3.6.1/
export PATH=$KAFKA_HOME/bin:$PATH

配置完之后别忘记 source 一下,然后我们来看看 kafka 的安装目录里面都有哪些东西。

整个目录非常的简洁,其中 bin 目录负责存放启动脚本;config 负责存放配置文件;libs 存放依赖的 jar 包。


来看一下 bin 目录。

里面的 sh 脚本非常多,但有 5 个是最常用的。

  • kafka-console-producer.sh、kafka-console-consumer.sh:负责在控制台启动生产者和消费者,用于测试。
  • kafka-server-start.sh、kafka-server-stop.sh:负责启动和关闭 kafka 集群。
  • kafka-topics.sh:负责对主题进行相关操作。

再来看一下 config 目录:

里面的配置文件也不少,作用如下:

  • 以 source、sink 结尾的配置文件和输入、输出相关,比如读取数据源、输出到指定位置;
  • 包含 consumer、producer 的配置文件则是和通过命令行启动的消费者、生产者相关;
  • 里面还有一个 zookeeper.properties,用于 Kafka 自带的 ZooKeeper 相关的一些配置,但这个不需要关心,因为我们不使用自带的 ZooKeeper,甚至不使用 ZooKeeper;
  • 而最重要的配置文件是 server.properties,它和 Kafka 集群密切相关,用于配置 Kafka 集群的 Runtime;

然后 config 目录中还有一个 kraft 目录,它里面也有几个配置文件。

我们看到 config 和 config/kraft 目录中都有一个 server.properties,这两个文件有啥区别呢?我们知道 Kafka 有两种模式,一种是依赖 ZooKeeper 的模式,元数据由 ZK 管理;另一种是不依赖 ZK 的 KRaft 模式,元数据由 Controller 节点管理。

然后 server.properties 需要在启动 Kafka 集群的时候指定:

  • 如果启动时指定的配置文件是 config/server.properties,则表示以依赖 ZK 的模式启动;
  • 如果启动时指定的配置文件是 config/kraft/server.properties,则表示以 KRaft 模式启动;

这两个文件中关于 Broker 的配置是一样的,只是前者还包含了 ZK 的配置,而后者则是还包含了 KRaft 相关的配置。

因为我们不使用 ZooKeeper,所以后续会指定 config/kraft/server.properties。

元数据的存储位置

KRaft 模式是 Kafka 为了摆脱对 ZooKeeper 的依赖而引入的,它使用 Raft 一致性算法来同步和管理元数据,在这种模式下,元数据不再存储在 ZK 中,而是直接在 Kafka 集群内部进行管理和存储。那么问题来了,这些元数据到底存储在什么地方呢?

Kafka 内部专门提供了一个分布式日志,用来存储集群的元数据,该日志被称为元数据日志(Metadata Log)。元数据日志包含了主题、分区、副本、配置更改、ACLs(访问控制列表)等所有集群级别的元数据,并且元数据日志也是 Kafka 的一个主题,但它仅供内部使用,用于在集群中的控制器(Controller)节点之间同步状态。

什么是控制器

这里还需要了解一个组件:控制器(Controller),它是集群中的一个关键组件,负责管理集群的元数据和执行领导者选举(Leader Election)等操作。控制器的职责包括但不限于:

领导者选举(Leader Election)

  • 控制器负责为每个分区选择一个领导者(Leader)。在分区的所有副本(Replica)中,只有领导者副本可以处理客户端的读写请求,其它副本则作为追随者(Follower)同步领导者的数据。
  • 当领导者副本失败时,控制器会负责进行新的领导者选举,以确保分区的可用性和数据一致性。

分区副本分配(Partition Replication Assignment)

  • 控制器在创建新分区或者集群发生变化时(如节点加入或离开集群),负责分配分区副本到集群中的不同节点上。
  • 控制器还负责处理副本重新分配,以平衡集群负载或响应管理员的重新平衡请求。

集群元数据管理

  • 维护集群的元数据,包括分区的数量、副本的位置、主题配置等信息。
  • 处理元数据的更新,并确保这些更新被正确地应用到集群中的所有节点上。

故障检测和恢复

  • 监控集群中节点的健康状况,并在检测到故障时采取措施,比如重新分配分区的领导者副本或追随者副本。

版本升级和配置变更

  • 在 Kafka 集群升级或配置变更过程中,控制器协调这些变更的应用,确保集群的稳定和一致性。

在传统的 Kafka 集群(使用 ZooKeeper)中,有一个单独的控制器节点负责执行以上任务。如果当前控制器节点失效,集群中的其它节点会通过 ZooKeeper 选举出一个新的控制器。

而在不使用 ZooKeeper 的 KRaft 模式中,Kafka 引入了 Raft 协议来实现控制器的选举和集群管理,这意味着 Kafka 不再依赖外部的 ZooKeeper 集群来管理控制器。并且元数据也被直接存储在 Kafka 集群内部的特定主题(如 __cluster_metadata)中,这为 Kafka 提供了更高的自治性和简化了架构。

总之,控制器在 Kafka 集群中扮演着中心角色,负责集群的健康、平衡和元数据的一致性。在 KRaft 模式下,这些职责通过内部的 Raft 实现,进一步整合和简化了 Kafka 的架构。

以上我们就安装了 kafka,并了解了它的目录结构以及两种模式。

配置文件 server.properties 解析

安装完 Kafka 之后,我们来介绍一下配置文件 server.properties 里面的参数。该文件和 Kafka 集群 Runtime 息息相关,总共一百多行,但是包含了很多的注释,所以实际上配置并不是很多(因为有部分配置没有写在文件里),我们来逐一介绍。

注意:这里要介绍的是 config/kraft 里的 server.properties,因为我们不使用 ZooKeeper。


process.roles

节点的角色,Kafka 集群由 Controller(负责管理元数据和领导者选举)和 Broker(负责存储消息)组成,那么该节点是 Broker 还是 Controller 呢?因此需要通过 process.roles 参数指定。

默认值为 broker,controller,表示既是 Broker,又是 Controller。


node.id

每个节点(不论是 Broker 还是 Controller)都有一个唯一 ID 作为标识,默认值为 1。比如 Kafka 集群有三个节点,那么它们的 node.id 就分别是 1、2、3。


controller.quorum.voters

指定哪些节点参与到控制器(Controller)的选举和决策过程中,该配置针对控制器角色,也就是 process.roles 包含 controller 的节点。

# 默认值,其中 1 是节点的 id
controller.quorum.voters=1@localhost:9093
# 如果有多个控制器节点,那么之间用逗号分隔,比如
controller.quorum.voters=node_id1@ip1:9093,node_id2@ip2:9093,...

9093 是默认的控制器通信端口,你也可以指定为别的。

注意:虽然可以有多个节点具备 Controller 角色,但在任何给定的时间点,只有一个节点会被选举为活跃的 Controller 来管理集群的元数据。如果当前的 Controller 失效(比如因网络问题、节点故障等),剩余的节点会通过 Raft 协议选出一个新的 Controller,这个过程确保了集群的高可用性和元数据的一致性。所以在正常运行的 Kafka 集群中,Controller 角色可能会在不同的节点之间转移,这取决于节点的可用性和选举结果。

所以,尽管在 KRaft 模式下,可以配置多个节点承担 Controller 角色,但实际上同一时间只有一个节点会作为 Controller 来执行集群管理任务。

listeners、advertised.listeners

这两个配置放在一起解释会更容易理解,首先说一下 kafka 支持的传输协议,有以下几种:

  • PLAINTEXT:消息采用明文传输
  • SSL:消息采用使用 SSL 或 TLS 加密传输
  • SASL_PLAINTEXT:自定义用户认证权限
  • SASL_SSL:采用 SSL 根证书

至于 listeners 和 advertised.listeners 都用来设置监听的 IP 和端口,但 listeners 用于内网访问,advertised.listeners 用于外网访问。像我当前使用的是云服务器,有一个内网 IP 和一个外网 IP。

如果 Kafka 集群只有一个节点,并且客户端应用也在相同的节点上(一般只会发生在学习 Kafka 的过程中),那么只需要配置 listeners 即可,IP 指定为 localhost 或者 127.0.0.1。

# 监听端口 9092,采用明文传输。如果配置为 localhost(127.0.0.1)
# 那么客户端只能在 Broker 所在的节点上通过 localhost(127.0.0.1)访问
# 该配置是被注释掉的,因为默认监听本机的 9092 端口
listeners=PLAINTEXT://localhost:9092,CONTROLLER://localhost:9093
# 注意:如果只有一个节点,那么它除了是 Broker,也一定是 Controller
# 所以还要指定 Controller 监听的 IP 和端口

然后我们就可以在当前节点访问了,并且也只能在当前节点访问,在其它机器上则不行。

如果客户端不在当前 Broker 所在的节点上,但它们都在同一个内网中,那么仍然只需要配置 listeners,只不过此时需要将 IP 指定为内网 IP。当配置为内网 IP,客户端就可以在同一内网网段的任意节点上使用内网 IP 进行访问。

# 节点只作为 Broker
listeners=PLAINTEXT://内网IP:9092
# 节点只作为 Controller
listeners=CONTROLLER://内网IP:9093
# 节点同时作为 Broker 和 Controller
listeners=PLAINTEXT://内网IP:9092,CONTROLLER://内网IP:9093

此时客户端即可通过内网 IP 进行访问,另外,如果指定为内网 IP,那么即使是当前 Broker 所在节点的客户端,也要通过内网 IP 访问,不能使用 localhost。

目前已经有办法连接至 Kafka 了,但这显然还不足以达到我们想要的,因为我们还希望能够在外界通过公网 IP 进行访问,而无需在服务器上操作。那么这个时候就需要 advertised.listeners 出马了,advertised.listeners 是专门用来控制外界访问的,所以它需要指定公网 IP。

listeners=PLAINTEXT://内网 IP:9092,CONTROLLER://内网IP:9093
advertised.listeners=PLAINTEXT://公网 IP:9092

此时既可以在服务器上通过内网 IP 访问,也可以在任意一台有网络的机器上通过公网 IP 访问。

注意:如果希望外界能够通过公网 IP 进行访问,那么要保证端口对外开放。


listener.security.protocol.map、inter.broker.listener.name、controller.listener.names

这三个配置参数也是相互关联的,我们放在一起介绍。首先 listener.security.protocol.map 负责创建监听器和传输协议之间的映射关系,默认值如下:

listener.security.protocol.map=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,SSL:SSL,SASL_PLAINTEXT:SASL_PLAINTEXT,SASL_SSL:SASL_SSL

其中 key 是监听器,value 是传输协议,默认它们是相同的,但也可以自定义。

然后 inter.broker.listener.name 负责指定 Broker 之间进行内部通信时所使用的监听器的名称,该配置将告诉 Kafka 哪个监听器(以及对应的传输协议)要被用于 Broker 之间的通信,默认值为 PLAINTEXT。同理 controller.listener.names 负责指定 Controller 之间进行内部通信时所使用的监听器的名称,默认值为 CONTROLLER。

注意:在指定监听器的时候,要确保监听器在 listener.security.protocol.map 中已经定义了相应的传输协议。


num.network.threads

Broker 用于接收网络请求以及向网络发送响应的线程数,默认值为 3。


num.io.threads

Broker 用于处理请求的线程数,由于请求在处理过程中会涉及磁盘 IO,因此值应该不小于磁盘的数目,默认值为 8。


socket.send.buffer.bytes

Socket 服务端用于发送数据的缓冲区大小,默认值为 102400。


socket.receive.buffer.bytes

Socket 服务端用于接收数据的缓冲区大小,默认值为 102400。


socket.request.max.bytes

Socket 服务端允许接收的请求的最大字节数,默认值为 104857600。


log.dirs

用逗号分隔的一系列目录路径,用于存储日志文件,当然不止日志文件,还有暂存数据也会存在这里面。这个配置项必须要改,因为默认存储在临时目录中,这样当服务器重启,数据就没了。不过强烈建议在有条件的情况下将目录挂载到不同的磁盘上,有以下两个好处:

  • 提升读写性能:比起单块磁盘,多块物理磁盘同时读写数据会有更高的吞吐量。
  • 实现故障转移:即 failover,这是 Kafka 在 1.1 版本中引入的强大功能,因为之前只要 Broker 使用的任何一块磁盘挂掉,那么 Broker 进程就会关闭。但是从 1.1 开始这种情况被修正了,坏掉的磁盘上的数据会自动地转移到其它正常的磁盘上,而且 Broker 还可以正常工作。正是因为这个改进,Kafka 可以舍弃 raid,如果没有 failover 机制的话,那么我们只能依靠 raid 来提供保障了。

num.partitions

每个主题对应的分区数,默认值为 1。


num.recovery.threads.per.data.dir

每个 data 目录(log.dirs 配置的存储日志文件的目录)在启动时进行日志恢复、在关闭时进行刷新的线程数,默认值为 1。由于该参数只会在 Kafka 启动和关闭的时候使用,因此我们可以将值设置的大一些,比如设置为 8,表示每个目录会有 8 个线程进行处理。如果 log.dirs 配置了 5 个目录,那么总共会有 40 个线程。


log.flush.interval.messages

接收到多少条消息之后才将数据刷到磁盘,默认值为 10000。


log.flush.interval.ms

在强制刷新之前,消息可以在日志中停留的最大时间,默认值 1000 毫秒。


log.retention.hours

如果容量不够了,那么会删除一部分老的日志,但多久以前的日志才算是老日志呢?默认值为 168,表示将存在时间超过 168 小时的日志判定为老日志。


log.retention.bytes

日志能存储的最大字节数,超过这个值会对部分日志进行删除,如果为 -1 则表示没有限制。一般生产上,我们都不会设置最大字节数,所以该配置默认是被注释掉的,因此不用管它。


log.segment.bytes

Kafka 使用日志(log)来保存消息数据,一个日志就是磁盘上的一个只能追加写(append-only)的物理文件。因为只能追加写入,故避免了缓慢的随机 I/O 操作,改为性能较好的顺序 I/O 操作,这也是 Kafka 实现高吞吐量特性的一个重要手段。不过如果不停地向一个日志写入消息,最终也会耗尽所有的磁盘空间,因此 Kafka 必然要定期地删除消息以回收磁盘。怎么删除?简单来说就是通过日志段(log segment)机制。

在 Kafka 底层,一个日志又进一步细分为多个日志段,消息被追加写到当前最新的日志段中。当写满了一个日志段后,Kafka 会自动切分出一个新的日志段,并将老的日志段封存起来。Kafka 在后台还有定时任务会定期地检查老的日志段能否被删除,从而实现回收磁盘的目的。

而该配置就表示每一个日志段文件的最大字节数,默认值为 1073741824,换算之后是一个 G,当达到这个值时就会创建一个新的日志段,并将老的日志段封存起来。


log.retention.check.interval.ms

日志段文件的检查周期,查看它们是否达到了设置的删除策略(log.retention.hours 或 log.retention.bytes),默认值为 300000 毫秒。


group.initial.rebalance.delay.ms

首先来解释一下什么是重平衡,消费者组里面的消费者不仅能一起瓜分订阅主题的数据,而且它们还能彼此协助。假设组内某个消费者实例挂掉了,Kafka 能够自动检测,然后把这个 Failed 实例之前负责的分区转移给其它活着的消费者,这个过程就是重平衡(Rebalance)。但是重平衡会引发各种各样的消费者问题,而且很多重平衡的 Bug,整个社区都无力解决。

而当一个 Consumer 实例加入到空消费者组时会立即引发重平衡,但重平衡的代价又比较大,很多状态需要在重平衡之前被持久化、重平衡后被重新初始化。所以如果有大量的 Consumer 实例加入的话,那么势必要重平衡多次,这显然不是我们想要的。所以就有了该参数,假设为 10000 毫秒,那么当第一个消费者成员加入直到之后的 10 秒内,再有消费者加入将不再引发重平衡。默认值为 0,表示当新的 Consumer 实例加入时,立即引发重平衡。


offsets.topic.replication.factor

指定用于存储消费者偏移量的内部主题(__consumer_offsets)的副本因子,消费者偏移量是 Kafka 用来跟踪消费者组已经消费到了每个分区的哪一条消息。默认值为 1,但生产上建议设置为和 Broker 数量相等的值,以保证在发生故障时偏移量数据不会丢失。


transaction.state.log.replication.factor

指定用于存储事务状态信息的内部主题(__transaction_state)的副本因子,该内部主题记录了事务性消息的元数据,包括每个事务的状态和参与事务的分区信息。该参数对于支持 Kafka 的精确一次处理语义至关重要,设置足够的副本因子可以确保即使在发生节点故障的情况下,事务的状态信息也能被可靠地保留下来。


transaction.state.log.min.isr

我们说追随者副本不对外提供服务,它只负责接收领导者副本传来的最新数据,和领导者保持一致,可以在现有的领导者挂了之后上位成为新的领导者。但问题在于追随者和领导者之间的数据同步不是实时的,而且不同的追随者和领导者的同步情况也不一样,有的追随者可能远远落后于领导者。

于是 Kafka 引入了 ISR(In-Sync Replica),表示和领导者副本保持同步的副本集合。而 transaction.state.log.min.isr 参数便负责指定 __transaction_state 主题的 ISR 数量,默认值为 1,如果为 1 的话,显然 ISR 集合里面只有一个领导者。

该参数可以确保事务日志的高可用性,如果 ISR 的数量低于指定的值,那么 Kafka 会阻止新事务的提交,以保证不会因为副本数量不足而导致事务状态信息的丢失。


以上就是 server.properies 里面的相关配置,这些配置先一个印象即可,后面遇到了会结合场景详细解释。

那么我们需要修改哪些配置呢?

# 该节点既是 Broker,又是 Controller
# 当然默认就是这个,这里我们贴出来加深一下印象
process.roles=broker,controller
# 节点 id
node.id=1
# 指定都有哪些 Controller 节点
controller.quorum.voters=1@10.0.24.11:9093
# Broker 和 Controller 监听的地址
listeners=PLAINTEXT://10.0.24.11:9092,CONTROLLER://10.0.24.11:9093
# 为了让外界也能够连接上 kafka 集群,我们将 advertised.listeners 也配置一下
advertised.listeners=PLAINTEXT://82.157.146.194:9092
# 数据目录,用于消息持久化,存储日志文件
log.dirs=/opt/kafka_2.13-3.6.1/data

暂时修改以上几个配置即可,其它配置不变。

Kafka 的启动与关闭

配置文件修改之后,我们来启动 Kafka 集群。当然说是集群,其实只有一个节点,后续会演示如何搭建一个三节点的集群。

  • 启动 Kafka 集群:bin/kafka-server-start.sh config/kraft/server.properties

启动的时候需要指定配置文件 server.properties,因为它负责配置 Kafka 集群的运行时。但是注意,这种方式是以前台方式启动的,如果要以后台方式启动,那么需要加上 -daemon 参数。

  • 关闭 Kafka 集群:bin/kafka-server-stop.sh

下面启动 Kafka 集群,但是却发现报错了。

/opt/kafka_2.13-3.6.1/data 是我们通过 log.dirs 参数指定的数据目录,Kafka 提示该路径下没有 meta.properties 文件。

在 Kafka 的 KRaft 模式下,必须先用 kafka-storage.sh 脚本初始化 Kafka 数据目录,这一步骤会创建 meta.properties 文件,该文件包含了集群 ID 等重要信息。如果没有执行这个初始化步骤,Kafka 则无法启动,因为它依赖 meta.properties 里的信息来正确配置存储层。

# 生成一个集群 ID
kafka-storage.sh random-uuid
# 指定集群 ID 和配置文件路径,初始化数据目录
kafka-storage.sh format -t <cluster_id> -c $KAFKA_HOME/config/kraft/server.properties

我们测试一下:

初始化之后,meta.properties 文件已经自动生成了,然后我们再启动 Kafka 集群。

没有问题,集群的启动和关闭都是正常的,这里再将集群启动起来。

主题(Topic)相关操作

生产者发送的消息会保存在主题中,更准确的说是保存在分区中,主题是一个逻辑上的抽象概念,真正承载消息的是分区。bin 目录里面有一个 kafka-topics.sh 是专门通过命令行来操作主题的,那么下面就来看看它支持哪些操作。


查看当前都有哪些 Topic

kafka-topics.sh --bootstrap-server 10.0.24.11:9092 --list

显然当前还不存在任何的主题,需要注意的是:我们在配置文件中给 listeners 指定的 IP 是内网 IP,所以这里连接的时候也必须使用内网 IP。如果给 listeners 指定的是 localhost,那么这里就用 localhost 连接。

然后是 --bootstrap-server,它表示要连接的 Broker 的地址,可以指定多个,之间用逗号分隔;--list 则是列出都有哪些主题。


创建 Topic

kafka-topics.sh --bootstrap-server 10.0.24.11:9092 --topic 主题名 --create --partitions 分区数 --replication-factor 副本数

创建主题的时候,需要同时指定分区数与副本数,并且副本数不能超过 Broker 的数量。因为我们只有一个节点,所以副本数是 1,但是分区在一个 Broker 上是可以有多个的。

另外创建一个已存在的主题会报错:

提示我们主题 topicA 已存在。

在创建主题的时候还可以通过 --config 指定主题的一些其它属性,比如消息的最大限制。Kafka 默认允许的最大消息大小是 1M,但对于视频或者图片来说,1M 显然是不够的。

kafka-topics.sh --bootstrap-server 10.0.24.11:9092 --topic 主题名 --create --partitions 分区数 --replication-factor 副本数 --config max.message.bytes=允许每条消息占用的最大字节数

max.message.bytes 表示消息的最大限制,这个参数可以直接配置在 server.properties 里面。事实上 Kafka 有很多配置参数,并没有全部写在 server.properties 中(但都有默认值),而这些配置我们既可以在 server.properties 中指定,也可以在命令行中通过 --config 指定(或者使用编程语言操作的时候指定),都配置了则以后者为准。

注意:server.properties 里面出现的配置都是和 Kafka 集群的 Runtime 相关的,至于主题、生产者、消费者也有很多配置,这些配置虽然没有在 server.properties 中出现,但它们确实存在并且有默认值,只不过没有写在 server.properties 文件里面,因为这些配置我们也可以在创建主题、使用生产者发送消息和使用消费者接收消息时单独指定。


查看一个 Topic 的详细信息

我们使用 --list 只能查看当前有哪些 Topic,如果想查看一个 Topic 的详细信息该怎么做呢?

kafka-topics.sh --bootstrap-server 10.0.24.11:9092 --topic 主题名 --describe

主题 topicC 是我为了演示,单独创建的,命令如下:

kafka-topics.sh --bootstrap-server 10.0.24.11:9092 --topic topicC --create --partitions 2 --replication-factor 1 --config max.message.bytes=666666

其中分区数为 2,并设置了 max.message.bytes 为 666666。然后我们通过 --describe 即可查看一个主题的详细信息,解释一下输出的内容。

第一行是对该主题的一个总结:

  • Topic 表示主题名称;
  • TopicId 就是主题对应的 ID,这个是 kafka 内部生成的;
  • PartitionCount 表示分区数;
  • ReplicationFactor 表示副本系数;
  • 然后 Configs 不需要解释了,我们设置的消息最大值也体现在上面了,如果没有设置那么就只有一个 segment.bytes(日志段文件的最大字节数)。

剩余行则表示对应的分区信息:

  • Topic 表示主题名称;
  • Partition 表示分区;
  • Leader 是当前 Partition 中负责读写的节点(node.id),因为每个分区可以有多个副本,散落在不同的节点上,所以我们要知道是哪个节点上的副本负责读写。另外,每个节点都有可能成为领导者副本,由于当前只有一个节点,所以每个分区的领导者副本所在的节点的 ID 都是 1;
  • Replicas 是当前分区的副本数据所在的节点(node.id),不管该节点是否是领导者副本或者是否存活。当前只有一个节点,所以还是 1,如果副本散落在多个节点上,那么 Replicas 就会显示多个 ID,之间用逗号分隔;
  • Isr 是当前 Kafka 集群中和领导者保持同步的副本所在的节点,当前只有一个节点,所以是 1。如果有多个可用节点,那么 Isr 就会显示多个 ID,之间用逗号分隔;

修改一个 Topic

kafka-topics.sh --bootstrap-server 10.0.24.11:9092 --topic 主题名 --alter --partitions 分区数

主题的副本数不能修改,但分区数可以修改。

主题 topicA 之前的分区数是 1,现在改成了 2。但是注意:修改之后的分区数必须大于原来的分区数,否则报错。那么问题来了,为什么会有这个限制呢?

假设 kafka 的分区数可以减少,那么意味着要删除分区,但删除掉的分区中的消息要怎么办?肯定不能随着分区的删除一块烟消云散,那样消息就丢失了,必须要保留。但保留的话又要如何保留呢?如果直接追加到现有分区的尾部,那么消息的时间戳就不会递增,对于 Spark、Flink 这类需要消息时间戳(事件时间)的组件就会造成影响。如果插入到现有的分区中,那么在消息占用量很大的时候,数据的复制所占用的时间又是一个问题,最关键的是在数据复制期间,主题能不能正常对外提供服务又是一个问题。

从技术的角度上说,增加分区和减少分区本身没有太大区别,只是减少分区会带来一系列的麻烦,所以干脆 Kafka 就不提供减少分区的功能了。

除了分区数之外,还可以修改 --config 指定的一些配置,比如消息的最大大小,但是 sh 脚本需要换成 kafka-configs.sh。

命令都是相似的,然后通过 --add-config 和 --delete-config 进行指定。


删除一个 Topic

kafka-topics.sh --bootstrap-server 10.0.24.11:9092 --topic 主题名 --delete

我们看到分区已经被删除了。

思考:Kafka 的分区越多越好吗?

为了实现负载均衡,Kafka 将一个主题拆成了多个分区,每个分区散落在不同的 Broker 上,让 Producer 和 Consumer 能够多线程去处理。所以分区的本质就是把数据量很大的一组队列拆分成多组队列,形成 Producer 和 Consumer 的分流和并行,分流和并行能有效提升数据流读写的吞吐力,让队列中的数据能及时得到处理。因此在 Kafka 中,分区(Partition)是 Kafka 并行操作的最小单元,每个分区可以独立接收推送的消息以及被 Consumer 消费。

再举个生活中的栗子,如果把主题(Topic)比作高速公路,那么分区就是高速公路的一个车道,显然公路上可以只有一个车道、也可以有多个车道。每个车道的起点和终点相同,可以实现独立运输,只不过在 Kafka 中不存在变道,每辆车从始至终只能走其中的一条道。

所以在资源足够的情况下,一个 Topic 的分区越多,整个集群所能达到的吞吐量就越大。那么这是不是意味着分区就可以无限大了呢?答案显然不是的,虽然我们说分区越多,吞吐量就越大,但不要忽略一个前提:在资源足够的情况下。

假设有三个 Broker,那么至少就应该有 3 个分区,最小化保证每个 Broker 都能参与到队列分流并行的过程中。当然 Broker 所在节点的 CPU 的核数也要考虑在内,假设每个节点的 CPU 是 4 核,那么可以适当增加每个 Broker 的分区。但很明显,受限于 Broker 的数量,分区不可能无限制的增加,因为即使增加了也没有任何效果,反而还会起到反效果。原因有以下几点:


分区越多,需要打开的文件句柄就越多

在 Kafka 的 Broker 中,每个分区都会对应底层文件系统的一个目录,也就是 "数据日志文件目录"。比如有一个主题叫 topic_user,分区数为 2,那么底层就会有两个目录:topic_user-0、topic_user-1,目录里面负责保存日志数据段,每个日志数据段都会分配两个文件:一个索引文件(.index)和一个数据文件(.log)。而 Kafka 的 Controller 和 ReplicaManager 会为每个 Broker 都保存这两个文件句柄,因此随着分区的增多,需要的文件句柄数也会增多,不仅会带来资源管理上的消耗,还会突破 ulimit -n 的限制(必要时需要调整操作系统允许打开的文件句柄数)。


分区越多,端对端的延迟就越大

Kafka 端对端的延迟指的是生产者发送消息到消费者消费所需的时间,由于 Kafka 在消息正确接收后才会暴露给消费者,也就是保证 In-sync 副本(后续详细解释这个概念)复制成功之后才会暴露,而这就造成了瓶颈。因为一个 Broker 上的副本从 Leader 同步数据的时候只会开启一个线程,所以 In-sync 操作完成所需的时间是随着分区数量线性增加的。如果分区数过多,那么完成副本同步所需的时间也会越多,而在完成副本同步之前数据又无法暴露给消费者,从而造成较大的端对端延迟。


分区越多,需要的内存就越多

Kafka 可以支持批量提交和批量消费,所以 Producer 会为每个分区缓存消息,当缓存的消息达到了 batch.size(默认是 16 kb),再打包将消息批量发出。尽管这是个提升性能的设计,但很明显这个参数是分区级别的,而分区数越多,这部分缓存所需的内存占用也会越多;Consumer 也是同理,并且分区数越多,消费者的线程数也会增加,而线程切换的开销也是需要考虑的。


分区越多,故障后 Leader 重选所需的时间越长

Kafka 通过副本(Replica)机制来保证高可用,具体做法就是为每个分区保存若干个副本(replica_factor 指定副本数),每个副本保存在不同的 Broker 上。其中的一个副本充当 Leader 副本,负责处理 Producer 和 Consumer 请求,而其它副本则充当 Follower 角色,由 Kafka Controller 负责保证与 Leader 的同步。

但如果某个 Broker 挂掉了,那么该 Broker 上的领导者副本就无法对外提供服务了,于是 Kafka Controller 会借助于存储的分区元信息进行 Leader 选举,从其它的 Broker 中选择一个作为领导者副本,并且每个分区都要有这个过程。假设有 10000 个分区,那么就需要为 10000 个分区进行 Leader 选举,这显然会花费较长的时间,而在选举完成之前 Kafka 是无法对外提供服务的。并且如果挂掉的 Broker 恰好是 Kafka Controller 所在的节点,那么情况会更糟糕。


因此分区数不是越大越好,极端的分区数不仅对提升性能无任何效果,还可能导致集群不稳定,总之 Kafka 的分区规划是在性能与可靠性中找到一个平衡。

Python 连接 Kafka 操作 Topic

下面我们来看看如何使用 Python 操作 Kafka 集群上的 Topic,首先要想操作,那么必然要能够充当客户端连接到 Kafka 集群。Python 连接 kafka 集群需要使用一个第三方包 kafka-python,直接 pip3 install kafka-python 即可。

from pprint import pprint
from kafka import KafkaAdminClient
from kafka.admin import NewTopic

# 类名中间带了一个 Admin,所以它是用来操作 kafka 集群的,比如对主题进行增删查
# 除此之外还有 KafkaProducer、KafkaConsumer,它们则是单纯的生产者和消费者,只能往主题里面发消息、收消息
# 并且我们注意到这里的参数名是 bootstrap_servers,结尾带了个 s,所以也可以指定多个地址
client = KafkaAdminClient(bootstrap_servers="82.157.146.194:9092")

# 查看所有的 Topic,刚才我将主题全部删除了,所以当前是没有主题的
print(client.list_topics())  
"""
[]
"""

# 创建 Topic,可以同时创建多个
new_topics = [
    # 参数和使用命令行操作是类似的
    NewTopic("product", num_partitions=2, replication_factor=1,
             topic_configs={"max.message.bytes": 2048}),
    NewTopic("user", num_partitions=4, replication_factor=1),
    NewTopic("sales", num_partitions=1, replication_factor=1),
]
# 进行创建,参数 timeout_ms 表示在 Kafka 集群将主题创建完毕并返回之前最多等待多长时间,默认为 None
client.create_topics(new_topics=new_topics, timeout_ms=1000 * 3)
print(client.list_topics())
"""
['product', 'user', 'sales']
"""

# 查看 Topic 的详细信息,可以同时查看多个
# 会返回一个列表,列表里面是字典,每个字典就是对应主题的详细信息
pprint(client.describe_topics(["product"]))
"""
[{'error_code': 0,
  'is_internal': False,
  'partitions': [{'error_code': 0,
                  'isr': [1],
                  'leader': 1,
                  'offline_replicas': [],
                  'partition': 1,
                  'replicas': [1]},
                 {'error_code': 0,
                  'isr': [1],
                  'leader': 1,
                  'offline_replicas': [],
                  'partition': 0,
                  'replicas': [1]}],
  'topic': 'product'}]
"""

# 删除主题
client.delete_topics(["product", "user", "sales"])
print(client.list_topics())
"""
[]
"""
# 还可以通过 client.alter_configs()  修改主题

个人觉得使用 Python 操作要比使用 kafka-topics.sh 简单一些,以上就是 Kafka 主题相关的操作,还是不难的。

消息的生产与接收

下面我们来发送和接收消息,在 bin 目录里面有两个 sh 文件:kafka-console-producer.sh 用于生产消息,kafka-console-consumer.sh 用于消费消息。不过在操作之前,需要先创建一个主题,就叫 product 吧。

启动生产者

kafka-console-producer.sh --bootstrap-server 10.0.24.11:9092 --topic 主题

程序阻塞在这里了,需要我们输入消息。当我们输入消息然后回车,那么消息就会被发送到 product 主题中。

启动消费者

kafka-console-consumer.sh --bootstrap-server 10.0.24.11:9092 --topic 主题

消费者也阻塞在这里了,它会一直监听相应的主题,一旦生产发送消息,消费者就能接收到。

我们用生产者发送几条消息:

然后再来看看消费者有没有收到消息:

我们看到消费者全部都接收到了,并且数据会默认保留 7 天,超过 7 天之后就会删除。

但是有一个问题,要是消费者启动之前,生产者就发消息了,怎么办?显然此时的消费者是接收不到的。而原因也很简单,假设某个分区有 N 条消息(最大分区位移是 N - 1),那么消费者启动之后默认会从分区位移为 N 的地方开始消费,所以消费者默认只能收到自己启动之后生产者发来的消息。但我们可以加上一个 --from-beginning 参数,这样的话就会从存在最早的消息开始消费,也就是能消费掉已存在(启动之前)的消息了。

直接启动什么也收不到,但加上 --from-beginning 参数就没问题了,会从头开始消费。

关于这里的消费者,我们知道 Kafka 有一个消费者组的概念,消费者组中可以有多个消费者,它们共同消费同一个主题,每个消费者对应主题下的一个分区。但这里我们并没有指定消费者组,如果没有指定,那么 Kafka 会自动创建一个消费者组,然后将该消费者加入到组中,只不过此时组中只有这一个消费者。如果其它消费者也想加入该组,只需要在创建的时候指定相应的组 id 即可加入到指定的组中。

Kafka 的存储原理

我们知道 Kafka 是将消息存储在文件系统之上的,高度依赖文件系统来存储和缓存消息,因此可能有人觉得这样做效率是不是很低呢?因为要和磁盘打交道,而且使用的还是机械硬盘。

首先机械硬盘不适合随机读写,但如果是顺序读写,那么吞吐量实际上是不差的。而 Kafka 正是利用了这个特性,任何发布到分区的消息都会被追加到 "分区数据文件" 的尾部,这样的顺序写操作让 Kafka 的效率非常高。

另外操作系统还会将主内存剩余的空闲空间用作磁盘文件缓存(PageCache),所有的磁盘操作都会经过统一的磁盘缓存(除了直接 I/O 会绕过磁盘缓存)。

关于 PageCache,一会儿在分析 Kafka 高吞吐量的原理时会详细说。总之 Kafka 通过顺序 IO、PageCache ,以及零拷贝技术,实现了超高的吞吐量。

然后是数据的删除问题,既然 Kafka 是将消息顺序追加到磁盘,那么你觉得 Kafka 删除消息方便吗?显然是不方便的,并且这么做也很糟糕。因此 Kafka 集群会保留所有的消息(Message),不管这条消息有没有被消费过。但消息也不可能无限制地堆积,毕竟磁盘容量是有限的,因此 Kafka 也提供了可配置的保留策略去删除旧数据,或者根据分区大小去删除旧数据。

例如将保留策略设置为两天,在 Message 写入后的两天内,它可用于消费。而 Kafka 的性能和存储的数据量的大小无关,所以将数据存储很长一段时间是没有问题的。

再来说一说 Kafka 的日志文件,因为消息是要被写入到文件中的。假设现在 Kafka 集群只有一个 Broker,我们创建两个主题,名称分别为 topic1 和 topic2,其中 topic1 有一个分区,topic2 有两个分区,那么在文件系统上就会有三个目录。

| topic1-0
| topic2-0
| topic2-1

一个 Topic 会包含一个或多个分区,而每个分区都是一个目录,目录里面存储的是 "日志数据段"。由于日志数据段的大小是有上限的,比如 512M,那么当存储的消息量很大的话,日志数据段肯定不止一个。所以 Kafka 的策略是写满一个日志数据段之后,就会创建一个新的日志数据段,然后将消息追加到新的日志数据段里面。如果磁盘空间不够了,Kafka 会定期删除老的日志数据段。

关于日志数据段,它由两个文件组成,分别是索引文件(index file)和数据文件(data file),两者总是成对出现,也就是索引文件和数据文件具有相同的名称,只不过前者以 .index 结尾、后者以 .log 结尾。数据文件是真正用来存消息的,索引文件则是用来加速消息寻找的。

| topic1-0
    | 00000000000000000000.index
    | 00000000000000000000.log
    | 00000000000000368769.index
    | 00000000000000368769.log
    | 00000000000000737337.index  
    | 00000000000000737337.log
    | 00000000000001105814.index        
    | 00000000000001105814.log    
| topic2-0
| topic2-1

以上是 topic1-0 目录下的文件结构,显然里面有四个日志数据段,然后我们再来解释一下索引文件和数据文件到底存了哪些东西,以及索引文件是如何加速消息查找的。

我们以 00000000000000368769.index 和 00000000000000368769.log 为例,首先 00000000000000368769 表示数据文件里面的第一条消息(firstOffset 为 0)在当前分区里的分区位移是 368769。所以对于该数据文件而言,内部 firstOffset 为 N 的消息在当前分区里的分区位移就是 368769 + N。

注意:我们这里说的分区指的是当前这一个分区,和其它分区无关(如果有多个分区的话),分区和分区之间是相互独立的,彼此不会相互影响。

同理 00000000000000737337.log 则表示该数据文件存储的第一条消息的分区位移是 737337,firstOffset 为 N 的消息的分区位移是 737337 + N。并且 Kafka 是顺序写入的,所以我们还可以得出 00000000000000368769.log 中最后一条消息的下一条,就是 00000000000000737337.log 中的第一条。

假设我们要找分区位移为 425314 的消息,那么 Kafka 要如何快速定位到这条消息呢?此时索引文件就登场了。我们以索引文件的第二行为例,里面的内容是 2, 497,它表示如果想读取 firstOffset 为 2 的消息,那么只需从数据文件的开头向后 seek 497 个字节,再进行读取即可;同理 7, 1686 表示从数据文件的开头向后 seek 1686 个字节的话,那么会从 firstOffset 为 7 的消息开始读取;而第一行的 0, 0 表示 seek 0 字节即可读取 firstOffset 为 0 的消息(第一条消息)。

而对于当前的问题,要找分区位移为 425314 的消息,首先要判断它在哪一个文件中。由于 425314 大于 0 并且小于 737337,显然它位于 00000000000000368769.log 中,并且是 firstOffset 为 56545 的消息(425314 - 368769),然后直接去索引文件中查找该消息在数据文件中的偏移量即可。

因此索引文件记录的是消息在数据文件中的偏移量,但需要注意的是,索引文件不会为数据文件的每一条消息都记录偏移量。因为如果每条消息都记录的话,那么索引文件会非常大。所以为了减少索引文件的大小,Kafka 采用的是建立稀疏索引的方式,如果查找的消息在索引文件中有记录,那么运气不错,直接根据偏移量到数据文件中查找即可。但如果不在,那么就找离当前消息最近的一条消息的偏移量,然后根据此偏移量在数据文件中再向后多进行几次查找,即可找到对应的消息(至于怎么查找,一会说),所以相当于采用了时间换空间的做法。不过虽说是时间换空间,但减少了索引文件的大小之后,可以把索引文件映射到内存,从而降低了查询索引文件时的磁盘 IO 开销,所以算下来并没有给查询带来太多的时间消耗。

实际上 Kafka 建立的稀疏索引,其稀疏程度肯定比我们这里要高,比如每隔 100 条记录一次。

因此 Kafka 在查找消息时会经历两次二分查找,第一次二分查找是负责找到消息位于哪一个日志数据段,第二次二分查找则是读取索引文件、找到离该消息最近的消息在数据文件中的偏移量。

假设索引文件记录了 firstOffset 为 0、80、120、200、...... 的消息在数据文件中的偏移量,那么如果我想读取 firstOffset 为 115 的消息,根据之前的逻辑,会先找到 firstOffset 为 80 的消息的偏移量。然后根据此偏移量在数据文件中再向后查找,直到找到 firstOffset 为 115 的消息,然后读取出来。但这里就出现了一个问题,那就是 Kafka 怎么知道要向后查找多少次、或者说向后 seek 多少个字节才能到达 firstOffset 为 115 的消息所在的位置呢?即使找到了,那么要读取多少个字节呢,因为读少了会读不完,读多了会把 firstOffset 为 116 的消息也读进来了。

所以不用想,Kafka 的消息(Message)中一定记录了自身的大小。我们的数据在发给 Kafka 时会被包装成消息,具体做法是将消息头(字段数量固定、大小固定)和消息体(传输的数据,大小不固定)打包在一起,得到的就是消息。而 Kafka 的消息头中有一个字段专门负责记录消息的大小,因此在找到 firstOffset 为 80 的消息时,根据该消息的大小,seek 指定的字节即可找到 firstOffset 为 81 的消息;同理再根据 firstOffset 为 81 的消息所占的大小,seek 到 firstOffset 为 82 的消息,依次往复,直到 seek 到 firstOffset 为 115 的消息所在的位置。最后再根据消息的大小读取指定的字节数,即可将这条消息取出来。

补充:我们说每个日志数据段都会对应一个 .index 文件和一个 .log 文件,但除了这两个文件之外,其实还有一个 .timeindex 文件,它们都有相同的名称。而这个 .timeindex 文件和 .index 文件的作用是类似的,只不过 .timeindex 是负责支持通过时间戳来找消息,并且两者的查找方式也是类似的。

Kafka 的吞吐量为什么那么高

在众多的消息中间件中,Kafka 的性能和吞吐量绝对是顶尖级别的,那么问题来了, Kafka 是如何做到高吞吐的。在性能优化方面,它使用了哪些技巧呢?下面我们就来分析一下。

以批为单位

批量处理是一种非常有效的提升系统吞吐量的方法,操作系统提供的缓冲区也是如此。在 Kafka 内部,消息处理是以"批"为单位的,生产者、Broker、消费者,都是如此。

但在 Kafka 的客户端 SDK 中,生产者只提供了单条发送的 send() 方法,并没有提供任何批量发送的接口。原因是虽然它提供的 API 每次只能发送一条消息,但实际上 Kafka 的客户端 SDK 在实现消息发送逻辑的时候,采用了异步批量发送的机制。当你调用 send() 方法发送一条消息时,无论是同步发送还是异步发送,Kafka 都不会立即就把这条消息发送出去。它会先把这条消息放在内存中缓存起来,然后选择合适的时机把缓存中的所有消息组成一批,一次性发给 Broker。简单地说,就是攒一波一起发。

而 Kafka Broker 在收到这一批消息后,也不会将其还原成多条消息、再一条一条地处理,这样太慢了,Kafka 会直接将"批消息"作为一个整体。也就是说,在 Broker 整个处理流程中,无论是写入磁盘、从磁盘读出来、还是复制到其它副本,在这些流程中,批消息都不会被解开,而是一直作为一条"批消息"来进行处理的。

在消费时,消息同样是以批为单位进行传递的,消费者会从 Broker 拉到一批消息。然后将批消息解开,再一条一条交给用户代码处理。

比如生产者发送 30 条消息,在业务程序看来虽然是发送了 30 条消息,但对于 Kafka 的 Broker 来说,它其实就是处理了 1 条包含 30 条消息的"批消息"而已。显然处理 1 次请求要比处理 30 次请求快得多,因为构建批消息和解开批消息分别在生产者和消费者所在的客户端完成,不仅减轻了 Broker 的压力,最重要的是减少了 Broker 处理请求的次数,提升了总体的处理能力。

批处理只能算是一种常规的优化手段,它是通过减少网络 IO 来实现优化。而 Kafka 每天要处理海量日志,那么磁盘 IO 也是它的瓶颈。并且对于处在同一个内网的数据中心来说,读写磁盘是要慢于网络传输的。

接下来我们看一下,Kafka 在磁盘 IO 这块儿做了哪些优化。

磁盘顺序读写

我们知道 Kafka 是将消息存储在文件系统之上的,高度依赖文件系统来存储和缓存消息,因此可能有人觉得这样做效率是不是很低呢?因为要和磁盘打交道,而且使用的还是机械硬盘。

首先机械硬盘不适合随机读写,但如果是顺序读写,那么吞吐量实际上是不差的。在 SSD(固态硬盘)上,顺序读写的性能要比随机读写快几倍,如果是机械硬盘,这个差距会达到几十倍。因为操作系统每次从磁盘读写数据的时候,需要先寻址,也就是先要找到数据在磁盘上的物理位置,然后再进行数据读写。如果是机械硬盘,这个寻址需要比较长的时间,因为它要移动磁头,这是个机械运动,机械硬盘工作的时候会发出咔咔的声音,就是移动磁头发出的声音。

顺序读写相比随机读写省去了大部分的寻址时间,因为它只要寻址一次,就可以连续地读写下去,所以说性能要比随机读写好很多。

而 Kafka 正是利用了这个特性,任何发布到分区的消息都会被追加到 "数据文件" 的尾部,如果一个文件写满了,就创建一个新的文件继续写。消费的时候,也是从某个全局的位置开始,或者说从某一个 log 文件的某个位置开始,顺序地把消息读出来。这样的顺序写操作让 kafka 的效率非常高。

使用 PageCache

任何系统,不管大小,如果想提升性能,使用缓存永远是一个不错的选择,而 PageCache 就是操作系统在内存中给磁盘上的文件建立的缓存,它是由内核托管的。无论我们使用什么语言,编写的程序在调用系统的 API 读写文件的时候,并不会直接去读写磁盘上的文件,应用程序实际操作的都是 PageCache,也就是文件在内存中缓存的副本。

应用程序在写入文件的时候,操作系统会先把数据写入到内存中的 PageCache,然后再一批一批地写到磁盘上。读取文件的时候,也是从 PageCache 中读取数据,但这时候会出现两种可能情况。

一种是 PageCache 中有数据,那就直接读取,这样就节省了从磁盘上读取的时间;另一种情况是,PageCache 中没有数据,这时候操作系统会引发一个缺页中断,应用程序的读取线程会被阻塞,操作系统把数据从文件复制到 PageCache 中,然后应用程序再从 PageCache 继续把数据读出来,这时会真正读一次磁盘,这个读的过程就会比较慢。

用户的应用程序在使用完某块 PageCache 后,操作系统并不会立刻就清除这个 PageCache,而是尽可能地利用空闲的物理内存保存这些 PageCache,除非系统内存不够用,操作系统才会清理掉一部分 PageCache。清理的策略一般是 LRU 或它的变种算法,核心逻辑就是:优先保留最近一段时间最常使用的那些 PageCache。

另外 PageCache 还有预读功能,假设我们读取了 1M 的内容,但 Linux 实际读取的却并不止 1M,因为这样你后续再读取的时候就不需要从磁盘上加载了。因为从磁盘到内存的数据传输速度是很慢的,如果物理内存有空余,那么就可以多缓存一些内容。

而 Kafka 在读写消息文件的时候,充分利用了 PageCache 的特性。一般来说,消息刚刚写入到服务端就会被消费,读取的时候,对于这种刚刚写入的 PageCache,命中的几率会非常高。也就是说,大部分情况下,消费者读消息都会命中 PageCache,从而带来两个好处:一个是读取的速度会非常快,另外一个是,给写入消息让出磁盘的 IO 资源,间接也提升了写入的性能。

ZeroCopy(零拷贝)

Kafka 还使用了零拷贝技术,首先 Broker 将消息发送给消费者的过程如下:

  • 将指定的消息日志从文件读到内存中;
  • 将消息通过网络发送给消费者客户端;

这个过程会经历几次复制,以及用户空间和内核空间的切换,示意图如下。

整个过程大概是以上 6 个步骤,我们分别解释一下。

1)应用程序要读取磁盘文件,但只有内核才能操作硬件设备,所以此时会从用户空间切换到内核空间。


2)通过 DMA 将文件读到 PageCache 中,此时的数据拷贝是由 DMA 来做的,不耗费 CPU。关于 DMA,它是一种允许硬件系统访问计算机内存的技术,说白了就是给 CPU 打工的,帮 CPU 干一些搬运数据的简单工作。

CPU 告诉 DMA 自己需要哪些数据,然后 DMA 负责搬运到 PageCache,等搬运完成后,DMA 再通过中断通知 CPU,这样就极大地节省了 CPU 的资源。但如果要读取的内容已经命中 PageCache,那么这一步可以省略。


3)将文件内容从 PageCache 拷贝到用户空间中,因为应用程序在用户空间,所以磁盘数据必须从内核空间搬运到用户空间,应用程序才能操作它。注意:这一步的数据搬运不再由 DMA 负责,而是由 CPU 负责。

因为 DMA 主要用于硬件设备与内存之间的数据传输,例如从磁盘到 RAM,从 RAM 到网卡。虽然 DMA 可以减少 CPU 的负担,但通常不用于内核空间和用户空间之间的数据搬运,至于原因也很简单:

  • 操作系统需要保护内核空间,防止用户程序直接访问,以维护系统的安全和稳定。通过 CPU 进行数据拷贝,操作系统可以控制哪些数据和资源可以被用户程序访问。
  • CPU 可以处理复杂的逻辑和任务调度,更适合执行这种涉及系统安全和资源管理的任务。
  • 在数据从内核空间传输到用户空间的过程中,可能需要进行一些额外的处理,例如格式转换、权限检查等,这些都是 CPU 更擅长的。

另外用户空间和内核空间的切换,本质上就是 CPU 的执行上下文和权限级别发生了改变。因此这一步会涉及用户态和内核态之间的切换,和一个数据的拷贝。


4)文件内容读取之后,要通过网络发送给消费者客户端。而内核提供了一个 Socket 缓冲区,用户空间的应用程序在发送数据时,会先通过 CPU 将数据拷贝到内核空间的 Socket 缓冲区中,再由内核通过网卡发送给消费者。

同样的,当数据从网络到达时,也会先被放在 Socket 缓冲区中。然后应用程序从缓冲区读取数据,再拷贝到用户空间。

所以应用程序在通过网络收发数据时,其实都是在和 Socket 缓冲区打交道,具体的发送和接收任务都是由内核来做的,因为只有内核才能操作硬件设备。用户空间的代码要想与硬件设备交互,必须通过系统调用或操作系统提供的其它接口,然后由内核代为执行。

所以通过网络发送数据,会涉及一次数据的拷贝,以及用户空间和内核空间的切换。因为 CPU 要将数据从用户空间搬运到内核空间的 Socket 缓冲区中。


5)内核要将 Socket 缓冲区里的数据通过网卡发送出去,于是再将数据从 Socket 缓冲区搬到网卡的缓冲区里面,而这一步搬运是由 DMA 来做的。只要不涉及用户空间,大部分的数据搬运都可以由 DMA 来做,而一旦涉及到用户空间,数据搬运就必须由 CPU 来做。


6)发送完毕之后,再从内核空间切换到用户空间,应用程序继续干其它事情。


如果想要提升性能,那么关键就在于减少上下文切换的次数和数据拷贝的次数,因为用户空间和内核空间的切换是需要成本的,至于数据拷贝就更不用说了。

而整个过程涉及了 4 次的上下文切换,因为用户空间没有权限操作磁盘或网卡,这些操作都需要交由操作系统内核来完成。而通过内核去完成某些任务的时候,需要使用操作系统提供的系统调用函数。而一次系统调用必然会发生两次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由应用程序执行其它代码。

然后是数据拷贝,数据也被拷贝了 4 次,其中两次拷贝由 DMA 负责,另外两次由 CPU 负责。但很明显,CPU 的两次拷贝没有太大必要,先将数据从 PageCache 拷贝到用户空间,然后再从用户空间拷贝到 Socket 缓冲区。既然这样的话,那直接从 PageCache 拷贝到 Socket 缓冲区不行吗。如果文件在读取之后不对它进行操作,或者说不对文件数据进行加工,只是单纯地通过网卡发送出去,那么就没必要到用户空间这里绕一圈。

此时的 4 次上下文切换就变成了 2 次,因为系统调用只有 1 次。数据搬运也由 4 次变成了 3 次,所以总共减少了两次上下文切换和一次数据拷贝。而这种减少数据拷贝(特别是在用户和内核之间的数据拷贝)的技术,便称之为零拷贝。

Linux 内核提供了一个系统调用函数 sendfile(),可以实现上面这个过程。

#include <sys/sendfile.h>

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

out_fd 和 in_fd 均为文件描述符,分别代表要写入的文件和要读取的文件,offset 表示从文件的哪个位置开始读,count 表示写入多少个字节,返回值是实际写入的长度。当然像 Python、Java 都对 sendfile 进行了封装,我们在使用 Python 进行 Socket 编程时,便可以使用该方法。

当然该方法会调用 os.sendfile(),它和 C 的 sendfile() 是一致的,如果是 Linux 系统,那么不存在问题。如果是 Windows 系统,os.sendfile() 则不可用,此时 Socket 的 sendfile 会退化为 send 方法。


就目前来说,虽然实现了零拷贝,但还不是零拷贝的终极形态。我们看到 CPU 还是进行了一次拷贝,并且此时虽然不涉及用户空间,但数据搬运依旧是 CPU 来做的。因为 DMA 主要负责硬件(例如磁盘或网卡)和内存的数据传输,但不适用于内存到内存的数据拷贝。

那么问题来了,数据文件从磁盘读到 PageCache 之后,可不可以直接搬到网卡缓冲区里面呢?如果你的网卡支持 SG-DMA 技术,那么通过 CPU 将数据从 PageCache 拷贝到 socket 缓冲区这一步也可以省略。

可以通过 ethtool -k eth0 | grep scatter-gather 查看网卡是否支持 SG(scatter-gather)特性。

Linux 内核从 2.4 版本开始起,对于那些支持 SG-DMA 技术的网卡,会进一步优化 sendfile() 系统调用的过程,优化后的过程如下:

  • DMA 将数据从磁盘拷贝到 PageCache;
  • 将描述符和数据长度发送到 Socket 缓冲区,网卡的 SG-DMA 基于该信息直接将 PageCache 的数据拷贝到网卡缓冲区中;

整个过程如下:

此时便是零拷贝(Zero-copy)技术的终极形态,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。

使用零拷贝技术只需要两次上下文切换和数据拷贝,就可以完成文件的传输,因为它通过一次系统调用(sendfile 方法)将磁盘读取与网络发送两个操作给合并了,从而降低了上下文切换次数。而且两次的数据拷贝过程也不需要通过 CPU,都是由 DMA 来搬运。所以总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。

但需要注意的是,零拷贝技术不允许进程对文件内容作进一步加工,比如压缩数据再发送。如果希望对读取的文件内容做额外的操作,那么就只能拷贝到用户空间了。另外当传输大文件时,不建议使用零拷贝,因为 PageCache 可能被大文件占据,而导致「热点」小文件无法利用到 PageCache,并且大文件的缓存命中率也不高,因此这种情况建议绕过 PageCache。

使用 PageCache 的 IO 叫做缓存 IO,不使用 PageCache 的 IO 叫做直接 IO。

Kafka 的分区机制

通过设置多个分区可以实现负载均衡,那么当 Producer 发送消息时,如果有多个分区,Broker 会将消息放到哪一个分区中呢?

首先在发送消息时,如果我们指定了 Partition,那么消息肯定就会进入指定的分区中。但如果没有指定,那么 Kafka Broker 就需要亲自为我们挑选一个,因此 Broker 需要有自己的分区策略。所谓分区策略是决定生产者将消息发送到哪个分区的算法,如果分区策略设置的合理,则可以让所有的消息都均匀分布到不同的分区中,从而实现负载均衡。

那么分区策略都有哪些呢?


轮询策略

也称 Round-Robin 策略,即顺序分配。比如一个主题下有 3 个分区,那么第一条消息被发送到分区 0,第二条被发送到分区 1,第三条被发送到分区 2,以此类推。当生产第 4 条消息时又会重新开始,将其分配到分区 0,就像下面这张图展示的那样。

轮询策略有非常优秀的负载均衡表现,它总是能保证消息最大限度地被平均分配到所有分区上。


随机策略

也称 Randomness 策略。所谓随机就是我们随意地将消息放置到任意一个分区上,如下面这张图所示。

本质上看随机策略也是力求将数据均匀地打散到各个分区,但从实际表现来看,它要逊于轮询策略,所以如果追求数据的均匀分布,还是使用轮询策略比较好。


按 key 保序策略

Kafka 允许为每条消息定义消息键(key),这个 key 的作用非常大,它可以是一个有着明确业务含义的字符串,比如客户代码、部门编号或是业务 ID 等;也可以用来表征消息元数据。在早期 Kafka 不支持时间戳的时候,在一些场景中,工程师们都是直接将消息创建时间封装进 key 里面。

一旦消息被定义了 key,那么就可以保证拥有相同 key 的所有消息都进入到同一个分区里面,具体做法就是计算 key 的哈希值并对分区数取模,然后根据结果决定消息要进入哪一个分区。并且由于每个分区下的消息处理都是有顺序的,故而将该策略称为按 key 保序策略。

不指定 Partition、而是通过 key 来确定分区是非常常见的,因为 key 相同的消息肯定会进入相同的分区中。举个例子,很多公司为了保证消息的顺序只给主题设置了一个分区,原因是多个分区之间是相互独立的,每个分区的分区位移也都是从 0 开始的,如果两个分区都有消息,那么就无法得知哪个在前、哪个在后。而只设置一个分区的话,那么所有的消息就都会在一个分区内读写,所以此时可以保证消息的顺序性。

虽然这种做法无法利用多分区带来的高吞吐量和负载均衡的优势,但至少可以保证消费的消息是有序的,那么问题来了,有没有什么方式既能保证消息有序、又能使用多分区呢?那就看你的业务是不是要求消息全局有序。因为发送到同一主题的消息也是有不同种类的,而如果业务只需要同一种类的消息保持有序,那么这个时候就可以通过 key,将同一类具有因果依赖的消息发送到同一分区即可。


以上就是分区策略,分区策略还是非常重要的,如果我们在发送消息时 partition 或 key 没有指定好,那么可能会导致大量的数据进入到同一个分区中,从而造成分区的倾斜,最终影响 kafka 的吞吐量。

关于消息顺序错乱的问题

生产者如果往拥有多个分区的主题发消息的话,那么消费者在消费的时候,消息的顺序有可能会发生错乱,这是为什么呢?

假设我们按照顺序依次写入 10 条消息,分别是 1、2、3、4、5、6、7、8、9、10,那么最终的结果可能是而 1、3、5、9、10 在分区 0 中,2、4、6、7、8 在分区 1 中。那么消费者在从头读取时,会先读完一个分区,再读下一个分区,所以消息就乱掉了。

因此多个分区的话,消息无法保持全局有序。但如果一个主题下的消息有多个种类,而我们只需要相同种类的消息是有序的,那么可以给需要保证顺序的消息都设置一个相同的 key,让它们进入同一个分区。

Python 充当生产者、消费者

下面就来看看如何使用 Python 来连接至 kafka 集群进行消息的生产和消费,这里我们新创建一个主题。

kafka-topics.sh --bootstrap-server 10.0.24.11:9092 --topic coco --create --partitions 2 --replication-factor 1

先来看看消费者:

from kafka import KafkaConsumer
# 实例化一个消费者,或者说启动一个消费者线程
consumer = KafkaConsumer('coco',  # 消费的主题
                         bootstrap_servers=['82.157.146.194:9092'])

# 获取消息的逻辑位于 consumer.poll() 方法中
# 而 KafkaConsumer 内部实现了迭代器协议,会在 __next__ 中调用 poll
for message in consumer:
    info = {"topic": message.topic,
            "partition": message.partition, 
            "offset": message.offset,
            "key": message.key,
            "value": message.value.decode("utf-8")}
    print(info)

以上就是消费者的逻辑,非常的简单,在 for 循环内部会不断调用 poll 拉取消息。一旦获取到消息,就会交给 message,如果没有消息,那么程序会阻塞在内部的 __next__ 方法中。然后是 message,在 kafka-python 里面是一个 ConsumerRecord 对象( namedtuple 实例)。

可以看到属性还是比较多的,其中 topic 就是消息所在的主题、partition 是消息所在的分区、offset 是消息在分区中的偏移量、key 用于分区策略、value 显然就是我们消息的内容(生产者需要发送一个字节串,消费者收到的也是字节串)。以上几个比较重要,也是最常用的,至于其余的后面再说。

消费启动之后,再来看看生产者:

from kafka import KafkaProducer

# 创建一个生产者
producer = KafkaProducer(bootstrap_servers=['82.157.146.194:9092'])
# 往主题里面发送消息,参数一:主题名,参数二:传递的 value,当然还有其它参数我们后面说
# send 方法会返回一个 FutureRecordMetadata 对象
# 但是注意:此时消息还没有发送,而是写到了缓冲区中
future = producer.send("coco", "古明地觉".encode("utf-8"))
print(future)
"""
<kafka.producer.future.FutureRecordMetadata object at 0x7fe3f85c73a0>
"""
# 我们需要调用 future.get() 方法,然后消息才会发送给 Broker
# 里面的参数表示超时时间,这里最多等待 5 秒,消息必须在 5 秒内进入主题(分区)
record = future.get(5)
# 返回的 record 是一个 RecordMetadata 对象
# 我们可以用它来获取相关信息,比如消息写入了哪一个主题、哪一个分区、以及偏移量是多少
print(record.topic)
"""
coco
"""
print(record.partition)
"""
0
"""
print(record.offset)
"""
0
"""

# 除了上面的做法之外,kafka-python 还提供了一种更优雅的方式,那就是通过回调
def on_send_success(record):
    print(record.topic)
    print(record.partition)
    print(record.offset)

def on_send_error(exc):
    print(f"出错了:{exc}")


# 添加两个回调函数
future = producer.send(
    "coco", "地灵殿少女".encode("utf-8")
).add_callback(on_send_success).add_errback(on_send_error)
# 返回的 future 还是 FutureRecordMetadata 对象
# 注意:此时消息仍然没有发送,我们依旧需要调用 future.get() 方法,只不过此时就不需要使用 record 变量接收了
# 当消息发送成功时会自动触发回调,函数中 record 参数就是 future.get() 的返回值,即 RecordMetadata 对象
future.get(5)
# 以下是 on_send_success 函数中的输出
"""
coco
0
1
"""

执行生产者代码之后会发现两条消息都写入到主题中了,并且都写到了分区0 当中,一个偏移量为 0、另一个偏移量为 1。然后再来看看消费者有没有输出:

可以看到消费者已经接收到消息了,然后我们将生产者再多执行几次。

总共有 6 条消息写到了分区0 当中,分区位移(Offset)为 0、1、2、3、4、5,两条消息写到了分区1 当中,分区位移为 0、1。

然后我们还可以将消息发送到指定的分区,send 方法里面有一个 partition 参数,用于指定消息发送的分区。

当然我们说发消息的时候还可以指定 key,key 相同的消息会进入同一个分区,因为此时是将 key 的哈希值对分区数取模来决定消息进入哪一个分区的,下面来测试一下。

from kafka import KafkaProducer

producer = KafkaProducer(bootstrap_servers=['82.157.146.194:9092'])

# send 方法原型如下
# def send(self, topic, value=None, key=None, headers=None, partition=None, timestamp_ms=None):
# 参数 topic 就是主题,value 是消息内容,key 是分区键,partition 是分区编号,headers 和 timestamp_ms 一会儿说
producer.send("coco", "地灵殿的舞蹈者".encode("utf-8"), key=b"aaa")
producer.send("coco", "地灵殿的舞蹈者".encode("utf-8"), key=b"aaa")
producer.send("coco", "地灵殿的舞蹈者".encode("utf-8"), key=b"aaa")

producer.send("coco", "魔法森林里捡蘑菇的魔法者".encode("utf-8"), key=b"bbb")
producer.send("coco", "魔法森林里捡蘑菇的魔法者".encode("utf-8"), key=b"bbb")
producer.send("coco", "魔法森林里捡蘑菇的魔法者".encode("utf-8"), key=b"bbb")

producer.send("coco", "红魔馆里活了四百年的二小姐".encode("utf-8"), key=b"ccc")
producer.send("coco", "红魔馆里活了四百年的二小姐".encode("utf-8"), key=b"ccc")
producer.send("coco", "红魔馆里活了四百年的二小姐".encode("utf-8"), key=b"ccc")

# send 方法返回一个 FutureRecordMetadata 对象,调用它的 get 方法才会真正发送
# 因为 Kafka 处理消息是以批为单位的,消息会先写入到缓冲区中,然后攒一波一起发送
# 如果你不需要查看生产者将消息发送之后,会进入哪一个分区,以及偏移量是多少,只是希望把消息发出去
# 那么可以只调用 producer.send(),让消息进入到缓冲区,积攒起来
# 最后再调用一次 producer.flush() 将缓冲区中所有的消息都发送出去
# 因此可以像之前一样写一条发一条,也可以先往缓冲区中写,最后调用 producer.flush() 一次性全发出去
producer.flush(5)

我们看一下消费者的输出:

key 相同的消息会发送到同一个分区中,当然如果分区数比较少,那么由于哈希值的随机性,可能会出现拥有不同的 key 的消息进入到同一个分区。另外返回的 key 也是一个 bytes 对象,只不过这里我们没有解码。

如果你执行时,发现显示的 Offset 和我这里不一样,这是正常的,因为我刚才为了测试单独执行了几次生产者。

如何提高生产者的吞吐量

生产者所在的客户端会在分区级别维护一个缓冲区,发送的消息会先写到缓冲区中,当缓冲区存储的消息大小达到了 batch.size,那么会一次性批量发出。但如果一直没有达到呢?所以还有一个参数 linger.ms,该参数表示即使缓冲区的消息大小没有达到 batch.size,但如果延迟超过了 linger.ms,那么同样会将消息发给 Broker。

所以 batch.size 和 linger.ms,无论哪个条件满足,都会将消息发送出去。如果想提高吞吐量,那么就修改生产者的这两个配置。

  • batch.size 默认是 16KB,可以改成 32KB;
  • linger.ms 默认是 0ms,表示立即发送;
  • compress.type 表示消息压缩方式,可选值为 'gzip'、'snappy'、'lz4'、'zstd'、None,默认为 None;

当然啦,生产者在发送消息的时候,还有很多其它参数。

DEFAULT_CONFIG = {
    'bootstrap_servers': 'localhost',
    'client_id': None,
    'key_serializer': None,
    'value_serializer': None,
    'acks': 1,
    'bootstrap_topics_filter': set(),
    'compression_type': None,
    'retries': 0,
    'batch_size': 16384,
    'linger_ms': 0,
    'partitioner': DefaultPartitioner(),
    'buffer_memory': 33554432,
    'connections_max_idle_ms': 9 * 60 * 1000,
    'max_block_ms': 60000,
    'max_request_size': 1048576,
    'metadata_max_age_ms': 300000,
    'retry_backoff_ms': 100,
    'request_timeout_ms': 30000,
    'receive_buffer_bytes': None,
    'send_buffer_bytes': None,
    'socket_options': [(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)],
    'sock_chunk_bytes': 4096,  # undocumented experimental option
    'sock_chunk_buffer_count': 1000,  # undocumented experimental option
    'reconnect_backoff_ms': 50,
    'reconnect_backoff_max_ms': 1000,
    'max_in_flight_requests_per_connection': 5,
    'security_protocol': 'PLAINTEXT',
    'ssl_context': None,
    'ssl_check_hostname': True,
    'ssl_cafile': None,
    'ssl_certfile': None,
    'ssl_keyfile': None,
    'ssl_crlfile': None,
    'ssl_password': None,
    'ssl_ciphers': None,
    'api_version': None,
    'api_version_auto_timeout_ms': 2000,
    'metric_reporters': [],
    'metrics_num_samples': 2,
    'metrics_sample_window_ms': 30000,
    'selector': selectors.DefaultSelector,
    'sasl_mechanism': None,
    'sasl_plain_username': None,
    'sasl_plain_password': None,
    'sasl_kerberos_service_name': 'kafka',
    'sasl_kerberos_domain_name': None,
    'sasl_oauth_token_provider': None
}

在实例化 KafkaProducer 的时候,根据情况指定即可。

消费者从头开始消费

这里创建一个新的主题,主题名叫 kano。

kafka-topics.sh --bootstrap-server 10.0.24.11:9092 --topic kano --create --partitions 2 --replication-factor 1

然后生产者直接发消息:

from kafka import KafkaProducer

producer = KafkaProducer(
    bootstrap_servers=['82.157.146.194:9092'],
    # 在发消息的时候,需要给 value 参数传递字节串,但每次都手动编码就很不方便
    # 因此可以在创建生产者的时候指定 value_serializer,该参数接收一个可调用对象
    # 会自动将 value 传递进来进行调用,所以我们后续就不需要手动 encode 了
    value_serializer=lambda x: bytes(x, encoding="utf-8") if isinstance(x, str) else x
    # 同理还有 key_serializer,和 value_serializer 的作用以及用法都相同
    # 但针对的 key,因为 key 在传递的时候也要求是字节串(或 None)
)

for i in range(1, 20):
    if i % 2 == 0:
        # 发送到分区 0
        producer.send("kano", f"message{i}", partition=0)
    else:
        # 发送到分区 1
        producer.send("kano", f"message{i}", partition=1)

# 消息积压在缓冲区,刷新、全部发给 Broker
# 不传递超时时间,会一直等待,直到 Broker 全部接收
producer.flush()

启动生产者之后将消息发给 Broker,写入主题。然后启动消费者,监听 kano 主题,注意:我们需要指定从头开始消费,否则消息是接收不到的。

from kafka import KafkaConsumer

consumer = KafkaConsumer(
    'kano',
    bootstrap_servers=['82.157.146.194:9092'],
    # 和生产者的 value_serializer 作用是相反的
    # 同理还有 key_deserializer,功能和 value_deserializer 类似,但针对于 key
    value_deserializer=lambda x: str(x, encoding="utf-8") if isinstance(x, bytes) else x,
    # 等价于 --from-beginning,表示从最早的消息(分区位移最小)开始消费
    auto_offset_reset="earliest"
    # auto_offset_reset 的默认值为 "latest"
    # 表示从最新的位置(第一个没有消息的位置,比如分区里面有 5 条消息,那么就是分区位移为 5 的位置)开始消费
    # 显然这么做,消费者就只能接收之后生产者新发送的消息,启动之前发的消息就收不到了
)

for message in consumer:
    info = {"topic": message.topic,
            "partition": message.partition,
            "offset": message.offset,
            "key": message.key,
            # 我们指定了 value_deserializer 会自动解码,所以这里就不需要再 decode 了
            "value": message.value}
    print(info)

启动消费者,来看一下打印的结果:

结果正常,消费者可以消费所有的分区。另外,如果是在消费者启动之后生产者发送消息,那么哪个分区先有消息,消费者就先消费哪个分区。如果是消费者重新启动,从头消费,那么会按照分区编号顺序依次读取,先读完一个分区,再读下一个分区,直到所有分区都读取完毕。

然后我们再看看控制台:

Python 启动的消费者是先读完分区0 的数据之后,再读分区1 的数据;而控制台启动的消费者是先读完分区1 的数据之后,再读分区0 的数据。所以消费者从头消费时,分区的读取顺序是随机的,但一定是先读完一个分区,再读下一个分区。

消费者组

Kafka 中还有消费者组的概念,就是多个消费者组成一个组去监听相同主题,实现负载均衡的效果。我们之前没有指定消费者组,那么 Kafka 默认会创建一个不重名的消费者组,然后将消费者加入到该组中,当然我们也可以手动指定。

import threading
from kafka import KafkaConsumer

def consume_message():
    consumer = KafkaConsumer(
        'kano',
        # 指定消费者组
        group_id="my_group",
        bootstrap_servers=['82.157.146.194:9092'],
        auto_offset_reset="earliest"
    )

    for message in consumer:
        info = {"topic": message.topic,
                "partition": message.partition,
                "offset": message.offset,
                "key": message.key,
                "value": message.value}
        print(f"线程名:{threading.current_thread().name}", info)

# 创建 4 个消费者线程
threads = [threading.Thread(target=consume_message, name=f"consumer{i}")
           for i in range(1, 5)]
for thread in threads:
    # 启动
    thread.start()

# 主线程阻塞
for thread in threads:
    thread.join()

刚才创建了一个主题 kano,我们继续来从头消费它,然后在消费的时候指定了消费者组,并启动了 4 个消费者线程。但是分区只有两个,所以会有两个线程处于空闲状态。因此同一个消费者组里面的消费者线程数不要超过分区数,因为会有资源浪费,两者相等才是最佳的选择。当然如果分区过多的话,为避免消费者的压力,也可以适当减少线程数,让一个线程消费多个分区,但是不会出现一个分区被多个消费者(同一个消费者组中的)消费。

我们启动消费者,观察一下输出:

我们看到确实是一个线程消费一个分区,并且每个线程都是从头开始消费的,只不过分区数为 2,所以只用了两个线程,剩余的两个没用上。然后再看一下打印,我们发现是 consumer2 先打印、consumer1 后打印,不过这就不是我们需要关心的了,因为默认情况下(不使用锁之类的操作)多个线程之间是随机的,无法得知谁先输出。

问题来了,如果将上面的 group_id 给去掉,会发生什么呢?我们说不指定 Consumer Group 的时候,会默认创建一个不重名的 Consumer Group,所以最终会创建 4 个消费者组。虽然分区只能同时被多个消费者中的一个消费者消费,但这前提是多个消费者位于同一个消费者组,如果是位于不同的消费者组,那么是可以消费同一个分区的,并且每个消费者都有自己的消费者位移。所以我们将 group_id 去掉之后启动,终端会打印 76 条消息,因为总共 19 条消息,4 个消费者都打印,可以自己尝试一下。


以上我们就了解了生产者、消费者的机制,并且还介绍了消费者组,但是还没完,还有一些问题等着我们去挖掘。这里先把问题抛出来:如果将上面的消费者代码重新执行一下(代码完全不变),会发现终端没有任何输出。但如果换一个新的消费者组(将 group_id 修改一下,随便改成什么值),那么执行的时候会发现又有输出了。

换一个消费者组进行消费,此时是 consumer2 消费了分区 0,consumer4 消费了分区 1,因为我们说多个线程之间是随机的。当然这不是关键,问题的关键是为什么换一个消费者组,就能从头消费,而用之前的消费者组(消费过一次)就不可以呢。

要搞清楚这一点,我们需要了解和 kafka 消息的提交与确认相关的内容。

Kafka 中的消费者位移提交

我们上面用 Python 创建消费者的时候指定了消费者组,并重头开始消费消息。然而第一次确实可以将消息从头进行消费,但第二次就不行了,除非我们换一个消费者组。这是为啥呢?接下来就解释这个原因。

之前我们说过,Consumer 有个位移的概念,叫消费者位移,它和消息在分区中的位移不是一回事儿,虽然它们的英文都是 Offset。并且消费者位移记录的是消费者即将要消费的下一条消息的分区位移,不是最新消费的消息的分区位移。

举个栗子,假设一个分区中有 10 条消息,那么分区位移就分别是 0 到 9。某个 Consumer 消费了 5 条消息,这就说明该 Consumer 消费了分区位移为 0 到 4 的 5 条消息,那么此时消费者位移就是 5,指向了下一条消息(将要消费)的分区位移。

而消费者需要向 Broker 汇报自己的位移数据,这个汇报过程被称为提交位移(Committing Offsets),就是告诉 Broker 自己的消费进度。并且,因为消费者能够同时消费多个分区的数据,分区之间又是相互独立的,所以位移的提交实际上是在分区粒度上进行的,即消费者需要为分配给它的每个分区提交各自的位移数据。

然后重点来了,消费者一旦将自己的位移数据提交,那么以后就不可以再消费了。比如 10 条消息,你消费完 6 条之后提交了,那么即使你后续从头消费,前 6 条的数据也消费不了。所以在了解位移提交之后,会发现我们之前说指定 "earliest" 表示从头消费实际上不是很准确,应该是从 " 最近一次提交的位移 " 处开始消费。假设消费者在消费者位移为 N 的时候提交了,那么不好意思,Kafka 会认为所有分区位移小于 N 的消息都已经成功消费了,那么后续从头消费的 "这个头" 就是 N。

提交方式有手动提交和自动提交, Python 的消费者默认是自动提交的,每隔 5 秒自动提交一次。

所以当消费者从头消费一次之后,就再也消费不到了,因为位移数据已经提交了,那么之前的消息就无法再消费了。

因此我们之前的问题就算解释清楚了,但只解释了一半,因为我们换一个消费者组就又能消费了,这又是为啥呢。很明显,提交位移数据是针对消费者组而言的,因为一个分区可以被多个消费者组消费,消费者组 A 提交位移数据和消费者组 B 没有任何关系。组 A 里面的消费者提交之后,Broker 就会记住该组针对每个分区提交的位移数据(commit_offset),后续再消费的时候,如果消费者是组 A 里面的,那么会从上一次提交的位置开始消费。但这是组 A,和组 B 没有关系,组 B 里面的消费者如果没有提交位移数据,那么无论何时都可以从头消费。

那么问题来了,Kafka 为什么要设置为位移提交呢?不难想象,首先 Kafka 就是为大数据而生的,一个分区的数据量高达几百万都是非常常见的。如果在消费的时候消费者挂掉了,难道要从头再来消费一遍吗?所以通过位移提交,消费者就能从中断的位置继续消费。只不过消息什么时候提交也是有学问的,而提交方式有两种:自动提交和手动提交。

  • 自动提交:Kafka Consumer 在后台默默地为你提交位移,作为用户的你完全不必操心这些事
  • 手动提交:自己提交位移,Kafka Consumer 压根不管

一般来说,当数据量非常大的时候,我们更建议设置成手动提交。因为自动提交的话,一旦消费者挂掉,那么结局无非两种:

  • 1)换一个消费者组从头消费;
  • 2)当前的消费者组从中断的位置继续消费。

但如果数据量很大,从头消费浪费时间;如果数据之间有关联,比如每 1000 条聚合一次,那么从中断位置继续消费就容易造成数据丢失,因此这两种方式都不是很好。这个时候就需要改成手动提交,比如每 1000 条手动提交一次,这样即便挂了,假设在消费到分区位移为 9988 的消息时挂了,那么后续只需要从分区位移为 9000 的地方重新消费即可(9000 到 9988 的消息会重复消费,程序应当做好措施,保证提交之前的重复消费不会带来影响)。

因此 Kafka 的位移提交就类似于游戏中的存档点,角色挂了之后就从最近的存档点复活。自动提交等于是系统每隔一定时间(5 秒)就设置一个存档点,自动提交则是系统不管,完全由玩家来定,你愿意在哪设置存档点就可以在哪设置。

另外当改成手动提交的时候,位移提交的语义保障是由你来负责的,Kafka 只会 "无脑" 地接收你提交的位移。假设消费了 10 条消息,但你提交的位移值却是 20,那么从理论上讲,位移介于 11~19 之间的消息是有可能丢失的;相反地,如果你提交的位移值是 5,那么位移介于 5~9 之间的消息就有可能被重复消费。因此你对位移提交的管理直接影响了消费者所能提供的消息语义保障。

我们来测试一下,这里新创建了一个主题 miu,分区数为 2。

from kafka import KafkaProducer

producer = KafkaProducer(
    bootstrap_servers=['82.157.146.194:9092']
)
# 分别往分区里面各发 10 条消息
for i in range(10):
    producer.send("miu", f"message{i}".encode("utf-8"), partition=0)
    producer.send("miu", f"message{i}".encode("utf-8"), partition=1)

producer.flush()

然后消费者进行测试:

from kafka import KafkaConsumer, TopicPartition, OffsetAndMetadata


def consume_message():
    consumer = KafkaConsumer(
        'miu',
        # 指定消费者组
        group_id="my_group2",
        bootstrap_servers=['82.157.146.194:9092'],
        # 将自动提交改成 False,这样只要我们不手动提交,每次都可以从头消费
        enable_auto_commit=False,
        # auto_commit_interval_ms 表示每隔多长时间自动提交一次,默认是 5 秒
        # 当自动提交设置为 False 时,这个参数不需要关心
        # auto_commit_interval_ms=5000
        auto_offset_reset="earliest"
    )
    for message in consumer:
        info = {"topic": message.topic,
                "partition": message.partition,
                "offset": message.offset,
                "key": message.key,
                "value": message.value}
        print(info)
        # 我们发了 10 条消息,最大分区位移是 9
        # 对于分区0,在分区位移为 6 的时候提交一次
        if info["partition"] == 0 and info["offset"] == 9:
            consumer.commit({TopicPartition("miu", 0): OffsetAndMetadata(6, "")})
        # 对于分区1,在分区位移为 8 的时候提交一次
        if info["partition"] == 1 and info["offset"] == 9:
            consumer.commit({TopicPartition("miu", 1): OffsetAndMetadata(8, "")})

consume_message()

执行消费者,查看输出:

显然消息会从头开始消费,但是在消费完之后,我们手动提交了位移,那么第二次执行会有什么结果呢?

我们看到第二次消费的时候,分区 0 从 offset = 6 处开始消费,分区 1 从 offset = 8 处开始消费。因为第一次提交的时候,Broker 已经记住了该消费者组为每个分区提交的位移数据,那么当下一次消费时就从指定的位置消费。但如果我们换一个消费者组就又从头开始了,同理不指定消费者组也会从头开始,因为会默认创建一个不重名的消费者组,而 Broker 没有新的消费者组的位移提交记录,所以就真的从头开始了。

因此这就是位移的提交,一旦提交,那么后续从头消费时,就会从最近一次提交的位移开始消费,至于该位移之前的数据就看不到了。当然解决办法是重置偏移量,或者换一个消费者组。

Python 的 consumer.commit() 方法里面提交的分区位移也可以比之前的小。

自动提交与手动提交

我们说 Python 的消费者默认都是自动提交的,消费者会每隔一定时间自动提交一次,因此这个过程是 batch 化的。从逻辑上讲,消费者会先提交上一批消息的位移,然后再处理下一批消息。因此它虽然可以保证消息不丢失,但无法保证消息只被消费一次,也就是可能出现消息重新消费的情况。

默认情况下,消费者每 5 秒自动提交一次位移,现在我们假设提交位移之后的 3 秒发生了 Rebalance 操作。而在 Rebalance 之后,所有消费者从上一次提交的位移处继续消费,因此在 Rebalance 发生的前 3 秒消费的所有数据都要重新再消费一次。虽然能够通过减少 auto.commit.interval.ms 的值来提高提交频率,但这么做只能缩小重复消费的时间窗口,不可能完全消除它,因此这是自动提交机制的一个缺陷。

反观手动提交位移,它的好处就在于更加灵活,你完全能够把控位移提交的时机和频率。但也有缺陷,就是当发生网络抖动、Broker 端发生 GC 的时候,可能会提交失败,因此你需要自己实现重试机制。

Kafka 如何保证消息不丢失

下面来看一下 kafka 如何保证消息不丢失,一直以来很多人对于 kafka 丢失消息这件事情都有自己的理解,因而也就有自己的解决之道。在讨论具体的应对方法之前,我觉得我们首先要明确,在 Kafka 的世界里什么才算是消息丢失,或者说 Kafka 在什么情况下能保证消息不丢失。这点非常关键,因为很多时候我们容易混淆责任的边界,如果搞不清楚事情由谁负责,自然也就不知道由谁来给出解决方案了。

那 Kafka 到底在什么情况下才能保证消息不丢失呢?

一句话概括,Kafka 只对已提交的消息(committed message)做有限度的持久化保证。注意这里的提交和刚才的消费者提交位移数据之间没有任何关系,这里指的是生产者写入的消息能够落盘、不丢失,和消费者无关。

首先解释一下什么是 "已提交的消息",当 Kafka 的若干个 Broker 成功地接收到一条消息并写入日志文件后,它们会告诉生产者程序这条消息已成功提交。此时,这条消息在 Kafka 看来就正式变为 "已提交" 的消息了。那为什么是若干个 Broker 呢?这取决于你对 "已提交" 的定义,你可以选择只要有一个 Broker 成功保存该消息就算是已提交,也可以是令所有 Broker 都成功保存该消息才算是已提交。不论哪种情况,Kafka 只对已提交的消息做持久化保证这件事情是不变的。

然后是 "有限度的持久化保证",也就是说 Kafka 不可能保证在任何情况下都做到不丢失消息。举个极端点的例子,如果地球都不存在了,Kafka 还能保存任何消息吗?显然不能。倘若这种情况下你依然还想要 Kafka 不丢消息,那么只能在别的星球部署 Kafka Broker 服务器了。因此 Kafka 不丢消息是有前提条件的,假如你的消息保存在 N 个 Kafka Broker 上,那么前提条件就是这 N 个 Broker 中至少有 1 个存活,只要这个条件成立,Kafka 就能保证你的这条消息永远不会丢失。

总结一下:kafka 是能做到不丢失消息的,只不过这些消息必须是已提交的消息,而且还要满足一定的条件。当然,说明这件事并不是要为 kafka 推卸责任,而是为了在出现该类问题时我们能够明确责任边界。

"消息丢失" 案例

Producer 程序丢失消息,这应该算是被抱怨最多的数据丢失场景了。举个栗子:你写了一个 Producer 应用,并向 Kafka Broker 发送消息,最后发现 Kafka 没有保存,于是大骂:" Kafka 真烂,消息发送居然都能丢失,而且还不告诉我"。如果你有过这样的经历,那么请先消消气,我们来分析下可能的原因。

from kafka import KafkaProducer

producer = KafkaProducer(bootstrap_servers=['82.157.146.194:9092'])
producer.send("kano", f"message".encode("utf-8"), partition=0)

producer.flush()

以上是 Python 生产者发送消息的代码,调用 producer.send() 会将消息写入缓冲区,调用 producer.flush() 会将缓冲区里的消息全部发走。但是注意:消息虽然发走了,至于有没有被 Broker 收到就不得而知了,因此你不能认为消息就一定已经被 Broker 收到了。

比如出现网络抖动,导致消息根本没有到达 Broker 端;或者消息体太大了,导致超过 Broker 的承受能力,Broker 拒绝接收等等。而出现这些情况,那么 Kafka 根本不认为消息是已提交的,因此让 Kafka 背锅就有点冤枉它了。至于解决问题的办法也很简单,就是添加两个回调函数,一个发送成功的回调、另一个是发送失败的回调,这样我们就知道消息是否发送成功,以及发送失败也能做好相应的处理。

如果是因为那些瞬时错误,那么仅仅让 Producer 重试就可以了;如果是消息不合格造成的,那么可以调整消息格式后再次发送。总之,处理发送失败的责任在 Producer 端而非 Broker 端。

那么问题来了,发送失败就没有可能是由 Broker 端的问题造成的吗?答案是当然有可能,如果所有的 Broker 都宕机了,那么无论 Producer 端怎么重试都会失败,此时你要做的是赶快处理 Broker 端的问题。但之前说的核心论据在这里依然是成立的:Kafka 依然不认为这条消息属于已提交消息,故对它不做任何持久化保证。

生产者、Broker 参数设置

为了保证发送的消息一定能到达 Broker 端以及消息不丢失,生产者和 Broker 可以做哪些事情呢?

1)发送消息时指定回调,出现错误及时处理

# 这样写一条就会立即发送一条,并根据回调的情况决定写入是成功还是失败
producer.send("topic", b"value", partition=0).add_callback(success_cb).add_errback(error_cb)

2)设置 acks

acks 是 Producer 的一个参数,我们创建生产者的时候指定即可,它代表了你对 "已提交" 消息的定义。

  • 设置成 0:不返回任何 Response,消息是否发送成功并不知道
  • 设置成 1:消息写入到领导者副本之后返回 Response
  • 设置成 -1:消息写入到领导者副本、并被同步给 ISR 中的追随者副本之后返回 Response
producer = KafkaProducer(bootstrap_servers=['82.157.146.194:9092'], acks=-1)

因此为了最大保证消息能发给 Broker,我们可以设置成 -1,默认值为 1。

3)设置 retries

设置 retries 为一个较大的值,当网络出现瞬时抖动时,消息发送可能会失败。而 retries > 0 可以让 Producer 能自动重试消息发送,避免消息丢失,默认为 0。

producer = KafkaProducer(bootstrap_servers=['82.157.146.194:9092'], retries=8)

4)设置 unclean.leader.election.enable = false

这是 Broker 端的参数,必须在 Kafka 的配置文件 server.properties 里面指定,它控制的是 Broker 是否有资格竞选分区的 Leader。如果一个 Broker 落后原先的 Leader 太多,那么它一旦成为新的 Leader,必然会造成消息的丢失。所以一般要将该参数设置成 false,不允许这种情况发生。

5)设置 replication.factor >= 3

副本系数,可以在 server.properties 里面指定(针对所有主题),也可以在创建主题的时候指定,为了消息不丢失,最好多保存几份,前提是要有足够数量的 Broker。

6)设置 min.insync.replicas > 1

这是 Broker 端的参数,表示消息至少要被写入到多少个副本才算是 "已提交",在实际环境中不要使用默认值 1。

7)确保 replication.factor > min.insync.replicas

如果两者相等,那么只要有一个副本挂机,整个分区就无法正常工作了。我们不仅要改善消息的持久性,防止数据丢失,还要在不降低可用性的基础上完成。

8)确保消息消费完成再提交

Consumer 端有个参数 enable.auto.commit 表示是否自动提交,显然它就是 Python 创建消费者时指定的 enable_auto_commit 参数。当然 enable.auto.commit 在 Kafka 配置文件中没有出现,因为它是 consumer 端的参数,这种参数我们一般都在连接 kafka 集群的时候单独指定。最好把它设置成 false,并采用手动提交位移的方式,至于原因我们已经说过了。

增加分区带来的影响

其实 Kafka 还有一个特别隐秘的消息丢失场景,就是增加分区。我们知道生产者、消费者都启动时,消费者是能及时收到生产者发送的消息的,无论生产者往哪个分区里写,消费者都能收到,因为消费者已经监控了所有的分区。但如果在中途临时增加了一个分区(生产者、消费者仍处于运行状态),那么可能会出现生产者率先感知到新增加的分区,并往里面写入了 N 条消息之后,消费者才感知到新分区的存在。

如果消费者配置的还是从最新的位置(latest,分区位移为 N)开始消费,那么这样的话生产者往新分区写入的 N 条消息,消费者就收不到了(相当于生产者先发了 N 条消息之后消费者才启动)。不过这不是我们的问题,而是 Kafka 设计上的缺陷,但不管怎样这终究是一个问题,你要如何解决呢?

消息的幂等性以及事务

接下来我们讨论一下 Kafka 的消息交付可靠性保障,所谓消息交付可靠性保障是指 Kafka 对 Producer 和 Consumer 要处理的消息提供什么样的承诺。常见的承诺有以下三种:

  • 最多一次(at most once):消息可能会丢失,但绝不会被重复发送
  • 至少一次(at least once):消息不会丢失,但有可能被重复发送
  • 精确一次(exactly once):消息不会丢失,也不会被重复发送

目前 Kafka 默认提供的交付可靠性保障是第二种,即至少一次,前面说过消息 "已提交" 的含义,即只有 Producer 成功提交消息并且收到 Broker 的应答之后才会认为该消息成功发送。不过倘若消息成功提交,但 Broker 的应答没有成功返回给 Producer(比如网络出现瞬时抖动),那么 Producer 就无法确定消息是否真的提交成功了。因此它只能选择重试,也就是再次发送相同的消息,这就是 Kafka 默认提供 "至少一次" 可靠性保障的原因,不过这会导致消息重复发送。

Kafka 也可以提供 "最多一次" 可靠性保障,只需要让 Producer 禁止重试即可。这样一来,消息要么写入成功,要么写入失败,但绝不会重复发送。我们通常不希望出现消息丢失的情况,但一些场景里偶发的消息丢失其实是被允许的,相反,消息重复是绝对要避免的,此时使用 "最多一次" 可靠性保障最恰当。

当然无论是至少一次还是最多一次,都不如精确一次来得有吸引力。大部分用户还是希望消息只会被交付一次,这样的话,消息既不会丢失,也不会被重复处理。或者说,即使 Producer 端重复发送了相同的消息,Broker 端也能做到自动去重,这样在下游的 Consumer 看来,消息依然只有一条。

那么问题来了,Kafka 是怎么做到精确一次的呢?简单来说,是通过两种机制:幂等性(Idempotence)和事务(Transaction),我们分别介绍。

幂等性

什么是幂等性

幂等这个词原本是数学领域中的概念,指的是某些操作或函数能够被执行多次,但每次得到的结果都是不变的。举几个简单的例子说明一下:比如在乘法运算中,让数字乘以 1 就是一个幂等操作,因为不管执行多少次这样的运算,结果都是相同的。再比如调用取整函数(floor 和 ceiling)也是幂等操作,运行 1 次 floor(3.4) 和 100 次 floor(3.4),结果是一样的,都是 3。相反地,让一个数加 1 这个操作就不是幂等的,因为执行一次和执行多次的结果必然不同。

而在计算机领域中,幂等性的含义稍微有一些不同:

  • 在命令式编程语言(比如 C 语言)中,若一个子程序是幂等的,那它必然不能修改系统状态。这样不管运行这个子程序多少次,与该子程序关联的部分系统状态保持不变
  • 在函数式编程语言(比如 Scala 或 Haskell)中,很多纯函数(pure function)天然就是幂等的,它们不执行任何的 side effect

幂等性有很多好处,其最大的优势在于我们可以安全地重试任何幂等性操作,反正它们也不会破坏我们的系统状态。如果是非幂等性操作,我们还需要担心某些操作执行多次对状态的影响,但对于幂等性操作而言,则根本无需担心此事。


幂等性 Producer 实现原理

在 Kafka 中,Producer 默认不是幂等性的,但我们可以创建幂等性 Producer。它其实是 0.11.0 版本引入的新功能,在此之前,Kafka 向分区发送数据时,可能会出现同一条消息被发送了多次,导致消息重复的情况。在 0.11 之后,Producer 便支持幂等性了,Kafka 自动帮你做消息的去重。

底层具体的原理很简单,就是经典的用空间换时间的优化思路,在 Broker 端多保存一些字段。当 Producer 发送了具有相同字段值的消息后,Broker 能够自动知晓这些消息已经重复了,于是可以在后台默默地把它们丢弃掉。

开启幂等性:将 enable.idempotence 参数设置为 true 即可。

看上去,幂等性 Producer 的功能很酷,使用起来也很简单,仅仅设置一个参数就能保证消息不重复了。但实际上,我们必须要了解幂等性 Producer 的作用范围。

首先,它只能保证单分区上的幂等性,即一个幂等性 Producer 能够保证某个主题的一个分区上不出现重复消息,它无法实现多个分区的幂等性。其次,它只能实现单会话上的幂等性,不能实现跨会话的幂等性。这里的会话,你可以理解为 Producer 进程的一次运行,当你重启了 Producer 进程之后,这种幂等性保证就丧失了。至于这背后的细节到底如何,我们下面就来分析一下。

Kafka 在引入幂等性之前,Producer 向 Broker 发送消息,然后 Broker 将消息写入分区后会给 Producer 返回 Ack 信号值。但实际情况中,会出现各种不确定因素,比如在 Producer 将消息发送给 Broker、Broker 成功写入之后和 Producer 之间出现了网络异常,导致给 Producer 返回 Ack 失败了。那么 Producer 会触发重试机制,将之前已经发过的消息又发了一次,因此分区中就出现了两条一模一样的消息。而对于某些行业,允许消息丢失,但绝不允许消息重复,比如银行。

于是 Kafka 在 0.11.0 版本引入了幂等性,具体的做法是在底层架构中引入了 ProducerID 和 SequenceNumber。

  • ProducerID:在每个新的 Producer 初始化时,会被分配一个唯一的 ProducerID,这个 ProducerID 对客户端使用者是不可见的
  • SequenceNumber:Producer 发送到分区的数据都会包含一个从 0 开始单调递增的 SequenceNumber 值

而当 <ProducerID, Partition, SeqNumber> 相同时,那么 Broker 就认为消息重复了。所以幂等性只能保证消息在单分区单会话内不重复。

由于网络抖动造成 Producer 重试难以复现,这里就不演示了,只是需要注意:重复指的并不单单是消息内容重复,而是 Producer 因某些原因收不到 Ack 而将已经发过的消息又发了一遍造成的重复。如果网络正常,只是单纯的内容重复,那么 Broker 都会写入,因为它们的 SequenceNumber 是不一样的。

精确一次 = 幂等性 + 至少一次(ack = -1 && 分区副本数 >=2 && ISR 最小副本数量 >= 2)

事务

Kafka 的事务概念类似于我们熟知的数据库提供的事务。在数据库领域,事务提供的安全性保障是经典的 ACID,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。

当然,在实际场景中各家数据库对 ACID 的实现各不相同,特别是 ACID 本身就是一个有歧义的概念,比如对隔离性的理解。大体来看,隔离性非常自然和必要,但是具体到实现细节就显得不那么精确了。通常来说,隔离性表明并发执行的事务彼此相互隔离,互不影响。经典的数据库教科书把隔离性称为可串行化(serializability),即每个事务都假装它是整个数据库中唯一的事务。

提到隔离级别,这种歧义或混乱就更加明显了。很多数据库厂商对于隔离级别的实现都有自己不同的理解,比如有的数据库提供 Snapshot 隔离级别,而在另外一些数据库中,它们被称为可重复读(repeatable read)。好在对于已提交读(read committed)隔离级别的提法,各大主流数据库厂商都比较统一。所谓的 read committed,指的是当读取数据库时,你只能看到已提交的数据,即无脏读。同时,当写入数据库时,你也只能覆盖掉已提交的数据,即无脏写。

Kafka 自 0.11 版本开始也提供了对事务的支持,目前主要是在 read committed 隔离级别上做事情。它能保证多条消息原子性地写入到目标分区,这批消息要么全部写入成功,要么全部失败,同时也能保证 Consumer 只能看到事务成功提交的消息。另外事务型 Producer 也不惧进程的重启,Producer 重启回来后,Kafka 依然保证它们发送消息的精确一次处理。

总之简单来说,幂等性 Producer 和事务型 Producer 都是 Kafka 社区力图为 Kafka 实现精确一次处理语义所提供的工具,只是它们的作用范围是不同的。幂等性 Producer 只能保证单分区、单会话上的消息幂等性;而事务能够保证跨分区、跨会话间的幂等性。从交付语义上来看,自然是事务型 [roducer 能做的更多。不过切记,天下没有免费的午餐,比起幂等性 Producer,事务型 Producer 的性能要更差,在实际使用过程中,我们需要仔细评估引入事务的开销,切不可无脑地启用事务。


下面我们用 Python 来创建幂等性 Producer 并开始事务,但是注意:kafka-python 尚不支持这两个功能,我们需要使用一个新的库:confluent_kafka。它是 Confluent 公司开发的一个 Kafka 客户端库,用于 Python,不仅提供了高性能的生产者和消费者实现,并且还支持 Kafka 的高级特性。

安装:pip install confluent_kafka。

from confluent_kafka import Producer

# 接收一个字典,所有配置都写在里面
producer = Producer(
    {
        "bootstrap.servers": "82.157.146.194:9092",
        # 如果要开启事务,那么也必须开启幂等性
        "enable.idempotence": True,
        # 指定一个事务 ID
        "transactional.id": "0000001"
    }
)
# 初始化事务
producer.init_transactions()
# 开始事务
producer.begin_transaction()
# 往分区发送消息
try:
    for i in range(5):
        producer.produce(
            "marisa",
            f"message{i}",
            # key=None,
            # partition=0,
            on_delivery=lambda err, msg: print(f"Message: {msg} Error: {err}")
        )
    producer.flush()  # 刷新
    producer.commit_transaction()  # 提交事务
except Exception as e:
    # 失败则放弃事务
    producer.abort_transaction()
"""
Message: <cimpl.Message object at 0x7fa820061740> Error: None
Message: <cimpl.Message object at 0x7fa820061740> Error: None
Message: <cimpl.Message object at 0x7fa820061740> Error: None
Message: <cimpl.Message object at 0x7fa820061740> Error: None
Message: <cimpl.Message object at 0x7fa820061740> Error: None
"""

confluent_kafka 有兴趣可以去了解一下,但说实话这两个特性生产上用的并不多。

Kafka 的副本机制

下面聊一下 Kafka 的副本机制,所谓的副本机制(Replication),也可以称之为备份机制,通常是指分布式系统在多台网络互联的机器上保存有相同的数据拷贝。那么副本机制有什么好处呢?

  • 提供数据冗余:即使系统部分组件失效,系统依然能够继续运转,因而增加了整体可用性以及数据持久性
  • 提供高伸缩性:支持横向扩展,能够通过增加机器的方式来提升读性能,进而提高读操作吞吐量
  • 改善数据局部性:允许将数据放入与用户地理位置相近的地方,从而降低系统延时

这些优点都是在分布式系统教科书中最常被提及的,但是有些遗憾的是,对于 Apache Kafka 而言,目前只能享受到副本机制带来的第 1 个好处,也就是提供数据冗余实现高可用性和高持久性。至于 Kafka 没能提供第 2 点和第 3 点好处的原因,我们在后面会详细解释。

不过即便如此,副本机制依然是 Kafka 设计架构的核心所在,它也是 Kafka 确保系统高可用和消息高持久性的重要基石。

副本定义

在讨论具体的副本机制之前,我们先花一点时间明确一下副本的含义。我们之前谈到过,Kafka 是有主题概念的,每个主题又进一步划分成若干个分区,而副本的概念正是在分区层级下定义的,每个分区配置有若干个副本。

所谓副本(Replica),本质就是一个只能追加写消息的提交日志。根据 Kafka 副本机制的定义,同一个分区下的所有副本保存有相同的消息序列,这些副本分散保存在不同的 Broker 上,从而能够对抗部分 Broker 宕机带来的数据不可用。在实际生产环境中,每台 Broker 都可能保存有各个主题下不同分区的不同副本,因此单个 Broker 上存有成百上千个副本是非常正常的。

接下来我们来看一张图,它展示的是有 3 个 Broker 的 Kafka 集群的副本分布情况。从这张图中我们可以看到,主题 A 有三个分区,每个分区的副本数是 3,然后散落在 3 台 Broker 上,从而实现数据冗余。

如果上面的 Broker 不止三个呢?假设有四个 broker,我们配置的副本系数也是 4,那么就意味着至少有一个 Broker 存放的全是追随者副本。

副本角色

既然分区下能够配置多个副本,而且这些副本的内容还要一致,那么很自然的一个问题就是:我们该如何确保副本中所有的数据都是一致的呢?特别是对 Kafka 而言,当生产者发送消息到某个主题后,消息如何同步到对应的所有副本中呢?针对这个问题,最常见的解决方案就是采用 "基于领导者(Leader-based)的副本机制",Kafka 用的也是这个设计。

基于领导者的副本机制的工作原理如图所示,我们简单解释一下这张图里面的内容。

  • 第一,在 kafka 中,副本分为两类:领导者副本(Leader Replica)和追随者副本(Follower Replica)。每个分区在创建时都要选举一个副本,成为领导者副本,其余的副本自动成为追随者副本。
  • 第二,Kafka 的副本机制比其它分布式系统要更严格一些,在 kafka 中,追随者副本是不对外提供服务的,也就是说任何一个追随者副本都不能响应消费者和生产者的读写请求。所有的请求都必须由领导者副本来处理,或者说所有的读写请求都必须发往领导者副本所在的 Broker 进行处理。追随者副本不处理客户端请求,它唯一的任务就是从领导者副本 "异步拉取" 消息,并写入到自己的提交日志中,从而实现与领导者副本的同步。
  • 第三,当领导者副本挂掉了,或者说领导者副本所在的 Broker 宕机时,Kafka 通过监控功能可以实时感知到,并立即开启新一轮的领导者选举,从追随者副本中选一个作为新的领导者。而之前老的 Leader 副本重启回来后,也只能作为追随者副本加入到集群中。

一定要特别注意上面的第二点,即追随者副本是不对外提供服务的。还记得刚刚我们谈到副本机制的好处时,说过 Kafka 没能提供读操作横向扩展以及改善局部性吗?具体的原因就在于此。对于客户端用户而言,Kafka 的追随者副本没有任何作用,它既不能像 MySQL 那样帮助领导者副本"抗读",也不能实现将某些副本放到离客户端近的地方来改善数据局部性。

既然如此,kafka 为什么要这样设计呢?其实这种副本机制有两个方面的好处。


方便实现 Read-your-writes

所谓 Read-your-writes,顾名思义就是当你使用生产者 API 向 Kafka 成功写入消息后,马上使用消费者 API 去读取刚才生产的消息。

举个例子,比如你平时发微博,发完一条微博,肯定是希望能立即看到的,这就是典型的 Read-your-writes 场景。如果允许追随者副本对外提供服务,那么由于副本同步是异步的,因此可能会出现追随者副本还没有从领导者副本那里拉取到最新的消息,从而使得客户端看不到最新写入的消息。


方便实现单调读(Monotonic Reads)

什么是单调读呢?就是对于一个消费者用户而言,在多次消费消息时,它不会看到某条消息一会儿存在一会儿不存在。

如果允许追随者副本提供读服务,那么假设当前有 2 个追随者副本 Follower1 和 Follower2,它们异步地拉取领导者副本数据。倘若 Follower1 拉取了 Leader 的最新消息而 Follower2 还未及时拉取,那么当一个消费者先从 Follower1 读取消息、之后又从 Follower2 读取消息,则可能会出现这样的现象:第一次读取时看到的最新消息在第二次读取时不见了,这就不是单调读一致性。但是,如果所有的读请求都是由 Leader 来处理,那么 Kafka 就很容易实现单调读一致性。

In-sync Replicas(ISR)

我们一直反复强调,追随者副本不提供服务,只是定期地异步拉取领导者副本中的数据而已。既然是异步的,就存在着不和 Leader 同步的风险。在探讨如何正确应对这种风险之前,我们必须要精确地知道同步的含义是什么。或者说,Kafka 要明确地告诉我们,追随者副本到底在什么条件下才算与 Leader 同步。

基于这个想法,Kafka 引入了 In-sync Replicas,也就是所谓的 ISR 副本集合。ISR 中的副本都是与 Leader 同步的副本,相反,不在 ISR 中的追随者副本就被认为是与 Leader 不同步的。另外需要注意,Leader 副本天然就在 ISR 中。也就是说,ISR 不只是追随者副本集合,它必然包括 Leader 副本,甚至在某些情况下,ISR 只有 Leader 这一个副本。

和 Leader 同步的副本集合叫 ISR,不和 Leader 同步的副本集合叫 OSR,然后 AR = ISR + OSR。

那么到底什么样的副本能够进入到 ISR 中呢?显然是需要满足一定条件的,至于什么条件一会说,先看张图。

图中有 3 个副本:1 个领导者副本和 2 个追随者副本。Leader 副本当前写入了 10 条消息,Follower1 副本同步了其中的 6 条消息,而 Follower2 副本只同步了其中的 3 条消息。现在思考一下,对于这 2 个追随者副本,你觉得哪个追随者副本与 Leader 不同步?

答案是要根据具体情况来定,换成英文就是那句著名的 "It depends"。看上去好像 Follower2 的消息数比 Leader 少了很多,它是最有可能与 Leader 不同步的。的确是这样,不过也仅仅是可能。事实上,这张图中的 2 个 Follower 副本都有可能与 Leader 不同步,但也都有可能与 Leader 同步。也就是说,Kafka 判断 Follower 是否与 Leader 同步的标准,不是看相差的消息数,而是由一个参数决定的。

这个参数是 replica.lag.time.max.ms,它是 Broker 端的参数,表示 Follower 副本能够落后 Leader 副本的最长时间间隔,当前默认值是 10 秒。也就是说,只要一个 Follower 副本落后 Leader 副本的时间不连续超过 10 秒,那么 Kafka 就认为该 Follower 副本与 Leader 是同步的,即使此时 Follower 副本中保存的消息明显少于 Leader 副本中的消息。

前面说过,Follower 副本唯一的工作就是不断地从 Leader 副本拉取消息,然后写入到自己的提交日志中。如果这个同步过程的速度持续慢于 Leader 副本的消息写入速度,那么在 replica.lag.time.max.ms 时间后,该 Follower 副本就会被认为是与 Leader 副本不同步的,因此不能再放入 ISR 中。此时,Kafka 会自动收缩 ISR 集合,将该副本 "踢出" ISR。另外值得注意的是,倘若该副本后面慢慢地追上了 Leader 的进度,那么它是能够重新被加回 ISR 的,这也表明 ISR 是一个动态调整的集合,而非静态不变的。

Unclean 领导者选举(Unclean Leader Election)

既然 ISR 是可以动态调整的,那么自然就会出现这样的情形:ISR 为空。因为 Leader 副本天然就在 ISR 中,如果 ISR 为空了,就说明 Leader 副本也挂掉了,Kafka 需要重新选举一个新的 Leader。可问题是 ISR 为空,那么此时该如何选举出新 Leader 呢?

Kafka 把所有不在 ISR 中的存活副本都称为非同步副本,通常来说,非同步副本落后 Leader 太多。因此如果选择这些副本作为新 Leader,就可能出现数据的丢失,毕竟这些副本中保存的消息远远落后于老 Leader 中的消息,所以默认只会从 ISR 里面选新 Leader。但有总比没有强,如果 ISR 为空,那么只能选择不在 ISR 集合中的副本了,而选举这种副本的过程称为 Unclean 领导者选举。

Broker 端参数 unclean.leader.election.enable 表示是否允许 Unclean 领导者选举。

开启 Unclean 领导者选举可能会造成数据丢失,但好处是它使得分区 Leader 副本一直存在,不至于停止对外提供服务,因此提升了高可用性。反之,禁止 Unclean 领导者选举的好处在于维护了数据的一致性,避免了消息丢失,但牺牲了高可用性。说到这儿你肯定想到了 CAP 理论,一个分布式系统通常只能同时满足一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)中的两个。而作为一个分布式系统,P 是一定要满足的,至于 C 或 A 选择哪一个,显然 Kafka 将这个决定权交给了你。

你可以根据自身的实际业务场景决定是否开启 Unclean 领导者选举,但还是建议不要开启它,毕竟我们还可以通过其它的方式来提升高可用性。如果为了这点儿高可用性的改善,牺牲了数据一致性,那就非常不值当了。

到目前为止,我们一直强调追随者副本不对外提供服务,但有意思的是,社区一直在考虑是否要打破这个限制,即允许追随者副本处理客户端消费者发来的请求。社区之所以决定这么做,主要的考量是希望改善云上数据的局部性,更好地服务地理位置相近的客户。但如果真的允许追随者副本对外提供读服务,你觉得应该如何避免或缓解因追随者副本与领导者副本不同步而导致的数据不一致的问题呢?

请求是如何被处理的

无论是客户端(包括 Producer、Consumer)还是 Broker 端,它们之间的交互都是通过 "请求 / 响应" 的方式完成的,比如客户端会通过网络发送消息给 Broker,而 Broker 处理完成后,会发送对应的响应给客户端。而为了更好地实现,Kafka 自己定义了一组请求协议,用于实现各种各样的交互操作。比如常见的 PRODUCE 请求是用于生产消息的,FETCH 请求是用于消费消息的,METADATA 请求是用于请求 Kafka 集群元数据信息的。

总之 Kafka 定义了很多类似的请求格式,并且所有的请求都是通过 TCP 网络以 Socket 的方式进行通讯。下面就来详细讨论一下 Kafka Broker 端处理请求的全流程。

关于如何处理请求,我们很容易想到的方案有两个。

顺序处理请求,写成伪代码如下:

while (true) {
    // 接收请求
    Request request = accept(connection);
    // 处理请求
    handle(request);
}

这个方法实现简单,但是有个致命的缺陷,那就是吞吐量太差。由于只能顺序处理请求,因此每个请求都必须等待前一个请求处理完毕后才能得到处理,这种方式只适用于请求发送非常不频繁的系统。


每个请求使用单独的线程处理,也就是说我们为每个入站请求都创建一个新的线程来异步处理。

这种做法和同步方式比较类似,只不过一个请求对应一个线程,这个方法的好处是,它是完全异步的,每个请求的处理都不会阻塞下一个请求。但缺陷也同样明显,为每个请求都创建线程的做法开销极大,在某些场景下甚至会压垮整个服务。还是那句话,这个方法只适用于请求发送频率很低的业务场景。


既然这两种方案都不好,那么 Kafka 是如何处理请求的呢?用一句话概括就是,Kafka 使用的是 Reactor 模式。Reactor 是事件驱动架构的一种实现方式,特别适合应用于处理多个客户端并发向服务端发送请求的场景。

从这张图中,我们可以发现,多个客户端会发送请求给到 Reactor。Reactor 有个请求分发线程 Dispatcher,它会将不同的请求下发到多个工作线程中处理。在这个架构中,Dispatcher 线程只是用于请求分发,不涉及具体的逻辑处理,非常得轻量级,因此有很高的吞吐量表现。至于工作线程则可以根据实际业务处理需要任意增减,从而动态调节系统负载能力。

Kafka 内部使用的也是 Reactor 模式,如果我们为 Kafka 画一张类似的图的话,那它应该是这个样子的:

显然这两张图长得差不多。Kafka 的 Broker 端有个 SocketServer 组件,类似于 Reactor 模式中的 Dispatcher,它内部有一个线程池,只不过在 Kafka 中,这个线程池有个专属的名字,叫网络线程池。Kafka 提供了 Broker 端参数 num.network.threads,用于调整该网络线程池的线程数。默认值是 3,表示每台 Broker 启动时会创建 3 个网络线程,专门接收客户端发送的请求。

注意:SocketServer 里面的线程负责请求的分发,但不负责处理。实际处理请求的线程是工作线程,在 Kafka 里面也叫做 IO 线程,它的数量由参数 num.io.threads 设置。

网络线程采用轮询的方式将入站请求公平地发给所有 IO 线程,因此在实际使用的过程中,这些 IO 线程通常都有相同的几率被分配到待处理请求。这种轮询策略编写简单,同时也避免了请求处理的倾斜,有利于实现较为公平的请求处理调度。

再来看一张更加详细的示意图:

当网络线程拿到请求后,会放到一个共享请求队列中,然后 Broker 端还有个 IO 线程池,负责从该队列中取出请求,执行真正的处理。如果是 PRODUCE 请求,则将消息写入到底层的磁盘日志中;如果是 FETCH 请求,则从磁盘或页缓存中读取消息。

所以 IO 线程池中的线程才是执行请求逻辑的线程,至于线程数量由参数 num.io.threads 控制,目前该参数默认值为 8,表示每台 Broker 启动后自动创建 8 个 IO 线程处理请求,你可以根据实际硬件条件进行设置。比如你的 CPU 资源非常充裕,那么完全可以调大该参数,允许更多的并发请求被同时处理。

当 IO 线程处理完请求后,会将生成的响应发送到响应队列中,然后由对应的网络线程负责将响应返回给客户端。

所以正如我们介绍 Broker 参数时说的那样:

  • num.network.threads 用于设置网络线程的数量,网络线程负责接收并转发请求,以及返回响应。
  • num.io.threads 用于设置 IO 线程的数量,IO 线程负责处理请求。当它处理完毕后,会将响应放到队列中,由网络线程返回给客户端。

细心的你一定发现了请求队列和响应队列的差别:请求队列是所有网络线程共享的,而响应队列则是每个网络线程专属的。

然后图中还有一个叫 Purgatory 的组件,这是 Kafka 中著名的 "炼狱" 组件,它是用来缓存延时请求(Delayed Request)的。所谓延时请求,指的是那些一时未满足条件不能立刻处理的请求,比如设置了 acks=-1(WaitForAll)的 PRODUCE 请求。一旦设置了 acks=-1,那么该请求就必须等待 ISR 中所有副本都同步完消息后才能返回,此时处理该请求的 IO 线程就必须等待其它 Broker 的写入结果。当请求不能立刻处理时,它就会暂存在 Purgatory 中,稍后一旦满足了完成条件,IO 线程会继续处理该请求,并将 Response 放入对应网络线程的响应队列中。

到这里,Kafka 请求流程解析就介绍完了,相信你应该已经了解了 Broker 是如何从头到尾处理请求的。但我们不会现在就收尾,我们还要玩一点新花样。


到目前为止,我们提及的请求处理流程对于所有请求都是适用的,也就是说,Kafka Broker 对所有请求是一视同仁的。但是在 Kafka 内部,除了客户端发送的 PRODUCE 请求(生产消息)和 FETCH 请求(消费消息)之外,还有很多执行其它操作的请求类型,比如负责更新 Leader 副本、Follower 副本以及 ISR 集合的 LeaderAndIsr 请求,负责勒令副本下线的 StopReplica 请求等。与 PRODUCE 和 FETCH 请求相比,这些请求有个明显的不同:它们不是数据类的请求,而是控制类的请求。也就是说,它们并不是操作消息数据的,而是用来执行特定的 Kafka 内部动作的。

Kafka 社区把 PRODUCE 和 FETCH 这类请求称为数据类请求,把 LeaderAndIsr、StopReplica 这类请求称为控制类请求。细究起来,当前这种一视同仁的处理方式对控制类请求是不合理的,为什么呢?因为控制类请求有这样一种能力:它可以直接令数据类请求失效。

我们来举个例子说明一下。假设有个主题只有 1 个分区,该分区配置了两个副本,其中 Leader 副本保存在 Broker 0 上,Follower 副本保存在 Broker 1 上。假设 Broker 0 这台机器积压了很多的 PRODUCE 请求,此时如果你使用 Kafka 命令强制将该分区的 Leader、Follower 角色互换,那么 Kafka 内部的控制器组件(Controller)就会发送 LeaderAndIsr 请求给 Broker 0,显式地告诉它,当前它不再是 Leader,而是 Follower。然后 Broker 1 上的 Follower 副本因为被选为新的 Leader,因此会停止向 Broker 0 拉取消息。

这时一个尴尬的场面就出现了:如果刚才积压的 PRODUCE 请求都设置了 acks=-1(WaitForAll),那么这些在 LeaderAndIsr 发送之前的请求就都无法正常完成了。就像前面说的,它们会被暂存在 Purgatory 中不断重试,直到给客户端返回请求超时。

设想一下,如果 Kafka 能够优先处理 LeaderAndIsr 请求,Broker 0 就会立刻抛出 NOT_LEADER_FOR_PARTITION 异常,快速地标识这些积压的 PRODUCE 请求已失败,这样客户端不用等到 Purgatory 中的请求超时就能立刻感知,从而降低了请求的处理时间。即使 acks 不是 -1(WaitForAll) ,积压的 PRODUCE 请求能够成功写入 Leader 副本的日志,但处理 LeaderAndIsr 之后,Broker 0 上的 Leader 变为了 Follower 副本,也要执行显式的日志截断(Log Truncation,即原 Leader 副本成为 Follower 后,会将之前写入但未提交的消息全部删除),依然做了很多无用功。

那么,社区是如何解决的呢?很简单,可以再看一遍上面的流程图,社区完全拷贝了这张图中的一套组件,实现了两类请求的分离。也就是说,Broker 启动后,会在后台分别创建网络线程池和 IO 线程池,它们分别处理数据类请求和控制类请求。至于所用的 Socket 端口,自然是使用不同的端口了,你需要提供不同的 listeners 配置,显式地指定哪套端口用于处理哪类请求。

消费者组重平衡全流程解析

之前我们提到过消费者组的重平衡流程,它的作用是让组内所有的消费者实例就消费哪些分区达成一致。重平衡需要借助 Coordinator(协调者),在协调者的帮助下完成整个消费者组的分区重分配。

在 Kafka 中,有两种主要类型的协调者:

  • Group Coordinator:负责管理消费者组(Consumer Group)的状态,包括成员管理、偏移量跟踪以及消费者组的重平衡(Rebalance)操作。
  • Group Coordinator:负责管理生产者的事务状态,包括事务的开始、提交或中止。

需要注意的是,协调者不是 Kafka 集群中的一个单独组件,而是 Broker 功能的一部分。当 Broker 启动时,Coordinator 功能也随之启动,准备处理消费者组和事务相关的请求。不过虽然 Coordinator 是 Broker 的一部分,但 Kafka 设计了一套机制来确保即使 Coordinator 所在的 Broker 发生故障,消费者组和事务的管理也能快速恢复,保证了集群的稳定性和可靠性。

消费者组重平衡用的显然是 Group Coordinator,下面我们来说一说相关流程。

触发与通知

我们先来简单回顾一下重平衡的 3 个触发条件:

  • 组成员数量发生变化
  • 订阅主题数量发生变化
  • 订阅主题的分区数发生变化

在实际生产环境中,因命中第 1 个条件而引发的重平衡是最常见的。另外消费者组中的消费者实例依次启动也属于第 1 种情况,也就是说,每次消费者组启动时,必然会触发重平衡过程。

那么重平衡过程是如何通知到其它消费者实例的呢?答案是依靠消费者端的心跳线程(Heartbeat Thread)。消费者需要定期地发送心跳请求(Heartbeat Request)到 Broker 端的协调者,以表明它还存活着,在 Kafka 0.10.1 版本之前,发送心跳请求是消费者主线程完成的,也就是具体处理消息逻辑的线程。但这样有一个问题,一旦消息处理消耗了过长时间,心跳请求将无法及时发到协调者那里,导致协调者"错误地"认为该消费者已死。因此从 0.10.1 版本开始,社区引入了一个单独的心跳线程来专门执行心跳请求发送,避免了这个问题。

关于心跳请求,消费者端提供了两个参数,heartbeat.interval.ms:消费者每隔多长时间向 Kafka 集群发送一次心跳信息,默认是 3000;session.timeout.ms:消费者与 Kafka 集群失去联系多长时间后,被判定为死亡。

consumer = KafkaConsumer(
    'topic',
    group_id="group_id",
    bootstrap_servers=['47.94.174.89:9092'],
    # 默认 3000 毫秒
    heartbeat_interval_ms=3000,
    session_timeout_ms=15000,  # 设置为 15000 毫秒
)

实际工作中,session.timeout.ms 应该是 heartbeat.interval.ms 的数倍,保证即使由于网络抖动等原因错过了几次心跳,消费者也不会被判定为已经离开消费者组。


但心跳和重平衡又有什么关系呢?其实,重平衡的通知机制正是通过心跳线程来完成的。当协调者决定开启新一轮重平衡,它会将 "REBALANCE_IN_PROGRESS" 封装进心跳请求的响应中,发给消费者实例。当消费者实例发现心跳响应中包含了 "REBALANCE_IN_PROGRESS",就能立马知道重平衡开始了,这就是重平衡的通知机制。

消费者组状态机

重平衡一旦开启,Broker 的协调者就要开始忙了,主要涉及到控制消费者组的状态流转。当前 Kafka 设计了一套消费者组状态机(State Machine),来帮助协调者完成整个重平衡流程。严格来说,这套状态机属于非常底层的设计,Kafka 官网上压根就没有提到过,但最好还是了解一下,因为它能够帮助你搞懂消费者组的设计原理,比如消费者组的过期位移(Expired Offsets)删除等。

目前 Kafka 为消费者组定义了 5 种状态,它们分别是:Empty、Dead、PreparingRebalance、CompletingRebalance 和 Stable。那么,这 5 种状态的含义是什么呢?

  • Empty:组内没有任何成员,但消费者组可能存在已提交的位移数据,而且这些位移数据尚未过期。
  • Dead:同样是组内没有任何成员,但组的元数据信息已经被协调者移除。协调者保存着当前向它注册过的所有组信息,即这里的元数据信息。
  • PreparingRebalance:消费者组准备开启重平衡,此时所有成员(消费者)都要重新请求加入消费者组。
  • CompletingRebalance:消费者组下所有成员均已加入,各个成员正在等待分配方案,另外该状态在老一点的版本中被称为 AwaitingSync,和这里的 CompletingRebalance 是等价的。
  • Stable:表示消费者组处于稳定状态,该状态表明重平衡已经完成,组内各成员已经能正常消费数据了。

了解了这些状态的含义之后,我们来看一张图片,它展示了状态机的各个状态间的流转。

一个消费者组最开始是 Empty 状态,当重平衡过程开启后,它会被置于 PreparingRebalance 状态等待成员加入,之后变更到 CompletingRebalance 状态等待分配方案,最后流转到 Stable 状态完成重平衡。

当有新成员加入或有成员退出时,消费者组的状态从 Stable 直接跳到 PreparingRebalance 状态,此时所有现存成员就必须重新申请加入组。当所有成员都退出组后,消费者组状态变更为 Empty。而 Kafka 定期自动删除过期位移的条件就是,组要处于 Empty 状态。因此,如果你的消费者组停掉了很长时间(超过 7 天),那么 Kafka 很可能就把该组的位移数据删除了。我相信,你在 Kafka 的日志中一定经常看到下面这个输出:

Removed ✘✘✘ expired offsets in ✘✘✘ milliseconds.

这就是 Kafka 在定期删除过期位移,不过只有 Empty 状态下的组,才会执行过期位移删除的操作。

消费者端重平衡流程

有了上面的内容作铺垫,我们就可以开始介绍重平衡流程了,而重平衡的完整流程需要消费者和协调者共同参与才能完成,所以我们先从消费者的视角来审视一下重平衡的流程。

在消费者端,重平衡分为两个步骤:分别是 "加入组" 和 "等待领导者消费者(Leader Consumer)分配方案"。这两个步骤分别对应两类特定的请求:JoinGroup 请求和 SyncGroup 请求。

当组内成员加入组时,它会向协调者发送 JoinGroup 请求,在该请求中,每个成员都要将自己订阅的主题上报,这样协调者就能收集到所有成员的订阅信息。一旦收集了全部成员的 JoinGroup 请求后,协调者会从这些成员中选择一个担任这个消费者组的领导者。

一定要注意区分这里的领导者和之前我们介绍的领导者副本,它们不是一个概念。这里的领导者是具体的消费者实例,它既不是副本,也不是协调者。

通常情况下,第一个发送 JoinGroup 请求的成员自动成为领导者,领导者(消费者)的任务是收集所有成员的订阅信息,然后根据这些信息,制定具体的分区消费分配方案。选出领导者之后,协调者会把消费者组订阅信息封装进 JoinGroup 请求的响应体中,然后发给领导者,由领导者统一做出分配方案后,进入到下一步:发送 SyncGroup 请求。

在这一步中,领导者向协调者发送 SyncGroup 请求,将刚刚做出的分配方案发给协调者。值得注意的是,其它成员也会向协调者发送 SyncGroup 请求,只不过请求体中并没有实际的内容。这一步的主要目的是让协调者接收分配方案,然后统一以 SyncGroup 响应的方式分发给所有成员,这样组内所有成员就都知道自己该消费哪些分区了。

接下来,我们用一张图来形象地说明一下 JoinGroup 请求的处理过程。

就像前面说的,JoinGroup 请求的主要作用是将组成员订阅信息发送给领导者消费者,待领导者制定好分配方案后,重平衡流程进入到 SyncGroup 请求阶段。

SyncGroup 请求的主要目的,就是让协调者把领导者制定的分配方案下发给各个组内成员。当所有成员都成功接收到分配方案后,消费者组进入到 Stable 状态,开始正常的消费工作。

到这里消费者端的重平衡流程就已经介绍完了,接下来我们从协调者的角度来看一下重平衡是怎么执行的。

Broker 端重平衡场景剖析

要剖析协调者处理重平衡的全流程,我们必须要分几个场景来讨论。这几个场景分别是新成员加入组、组成员主动离组、组成员崩溃离组、组成员提交位移,我们一个一个讨论。


场景一:新成员入组

新成员入组是指组处于 Stable 状态后,有新成员加入,如果是全新启动一个消费者组,Kafka 会有一些自己的小优化,流程上会有些许不同。我们这里讨论的是,组稳定了之后有新成员加入的情形。

当协调者收到新的 JoinGroup 请求后,它会通过心跳请求响应的方式通知组内的所有成员,强制它们开启新一轮的重平衡。具体的过程和之前的客户端重平衡流程是一样的,我们用一张时序图来说明协调者是如何处理新成员入组的。

 

场景二:组成员主动离组

主动离组是指消费者实例所在的线程或进程调用 close() 方法,主动通知协调者自己要退出,这个场景就涉及到了第三类请求:LeaveGroup 请求。协调者收到 LeaveGroup 请求后,依然会以心跳响应的方式通知其它成员,因此就不再赘述了,还是直接用一张图来说明。

 

场景三:组成员崩溃离组

崩溃离组是指消费者实例出现严重故障,突然宕机导致的离组。它和主动离组是有区别的,因为后者是主动发起的离组,协调者能马上感知并处理。但崩溃离组是被动的,协调者通常需要等待一段时间才能感知到,等待时间由消费者参数 session.timeout.ms 控制。也就是说,Kafka 一般不会超过 session.timeout.ms 就能感知到这个崩溃。

处理崩溃离组的流程与之前是一样的,我们画一张图。

我们看到和主动离组比较类似,只是离组的方式不同,至于后续的处理没有任何区别。

 

场景四:重平衡时协调者对组内成员提交位移的处理

正常情况下,每个组内成员都会定期汇报位移给协调者。当重平衡开启时,协调者会给予成员一段缓冲时间,要求每个成员必须在这段时间内快速地上报自己的位移信息,然后再开启正常的 JoinGroup/SyncGroup 请求发送。还是老办法,我们使用一张图来说明。

 

以上就是消费者的重平衡流程,我们这里只拿两个消费者举例,至于多个消费者也是同理,毕竟它们的原理是相同的。再总结一下:

  • 重平衡的三个触发条件:组成员数量发生变化;订阅主题数量发生变化;订阅主题的分区数发生变化
  • Kafka 为消费者组定义了五种状态:Empty、Dead、PreparingRebalance、CompletingRebalance 和 Stable
  • 消费者端的重平衡的两个步骤:加入组、以及等待领导者消费者分配方案,这两个步骤分别对应 JoinGroup 请求和 SyncGroup 请求,当然协调者也会有相应的 JoinGroup 响应和 SyncGroup 响应
  • 协调者端处理重平衡的四个场景:新成员入组;组成员主动离组;组成员被动离组;重平衡时协调者对组内成员提交位移的处理

Kafka 拦截器

这是一个用的很少的功能,了解过 Flume 的话,应该不会对拦截器感到陌生。事实上拦截器还是蛮常见的,其基本思想就是允许应用程序在不修改逻辑的情况下,动态地实现一组可插拔的事件处理逻辑链。以 Python 的 Web 框架 Flask 为例,该框架提供了 before_request 函数和 after_request 函数,其中 before_request 用于在请求交给视图函数处理之前做一些事情,after_request 用于视图函数执行完之后做一些事情。

思路非常简单,视图函数执行之前,先依次执行 before_request;视图函数执行完毕之后,再依次执行 after_request 。而在 before_request 中我们可以对请求做一些预处理,在 after_request 中我们可以响应做一些修饰。这些功能都是以配置的方式动态插入到应用程序中的,故可以快速地切换而不影响主程序逻辑。Kafka 拦截器也借鉴了这样的设计思路,你可以在消息处理的前后多个时点动态植入不同的处理逻辑,比如在消息发送前或者在消息被消费后。

作为一个非常小众的功能,Kafka 拦截器自 0.10.0 版本被引入后并未得到太多的实际应用,但即便如此,在自己的 Kafka 工具箱中放入这么一个有用的东西依然是值得的。


Kafka 拦截器分为生产者拦截器和消费者拦截器

  • 生产者拦截器允许你在发送消息前以及消息提交成功后植入特定逻辑;
  • 消费者拦截器支持在消费消息前以及提交位移后植入特定逻辑。

举个例子,假设你想在生产消息前执行两个前置动作:第一个是为消息增加一个头部,封装发送该消息的时间,第二个是对保存发送消息数的字段进行更新。那么此时你就可以将这两个拦截器串联在一起,统一指定给 Producer,Producer 会按顺序执行上面的动作,然后再发送消息。

关于 Kafka 拦截器,目前 Python 客户端还不支持。

Kafka 控制器

控制器(Controller)是 Kafka 的核心组件,它的主要作用是管理和协调整个 Kafka 集群。而集群中任意一个节点都能充当控制器的角色,但是在运行过程中,只能有一个节点成为控制器,行使其管理和协调的职责。换句话说,每个正常运转的 Kafka 集群,在任意时刻都有且只有一个控制器。官网上有个名为 activeController 的 JMX 指标,可以帮助我们实时监控控制器的存活状态,这个 JMX 指标非常关键,你在实际运维的过程中,一定要实时查看这个指标的值。

然后我们说过,在早期 Kafka 使用 ZooKeeper 保存元数据,但从 2.8.0 之后改成基于 Raft 协议保存在集群内部。这个元数据非常重要,控制器选举、集群管理、分区和副本信息等等,都属于元数据。而一会儿在涉及到元数据的时候,我们会以依赖 ZooKeeper 的模式进行介绍,会更简单一些。

简单介绍一下 ZooKeeper,它是一个提供高可靠性的分布式协调服务框架,使用的数据模型类似于文件系统的树形结构,根目录也是以 / 开始。该结构上的每个节点被称为 ZNode,用来保存一些元数据协调信息。ZNode 可分为持久性 ZNode 和临时 ZNode,持久性 ZNode 不会因为 ZooKeeper 集群重启而消失,而临时 ZNode 则与创建该 ZNode 的 ZooKeeper 会话绑定,一旦会话结束,该节点会被自动删除。

ZooKeeper 还赋予了客户端监控 ZNode 变更的能力,即所谓的 Watch 通知功能。一旦 ZNode 节点被创建、删除,子节点数量发生变化,亦或是 ZNode 所存的数据本身发生变更,ZooKeeper 都会通过节点变更监听器(ChangeHandler)显式通知客户端。

依托这些功能,ZooKeeper 常被用来实现集群成员管理、分布式锁、领导者选举等功能,Kafka 控制器大量使用 Watch 功能实现对集群的协调管理。我们来看一张图,它展示的是 Kafka 在 Zookeeper 中创建的 ZNode 分布。你不用了解每个 ZNode 的作用,但你可以大致体会下 Kafka 对 ZooKeeper 的依赖。

可以看到所有的元数据都存在 ZK 中,因此早期的 Kafka 是重度依赖 ZK 的。不过从 2.8.0 开始,这些数据就可以全部存在 Kafka 集群内部了,但我们这里还是以 ZK 的模式进行介绍,因为更简单一些。

控制器是如何被选出来的?

你一定很想知道,控制器是如何被选出来的?我们前面说过,每个节点都能充当控制器,那么当集群启动后,Kafka 怎么确定控制器位于哪个节点呢?

很简单,节点会尝试去 ZooKeeper 中创建 /controller(ZNode),而 Kafka 当前选举控制器的规则是:第一个成功创建 /controller 的节点会被指定为控制器。

控制器是做什么的

我们一直说,控制器是起协调作用的组件,那么这里的协调作用到底是指什么呢?划分一下的话,控制器的职责大致可以分为 5 种,我们一起来看看。


主题管理(创建、删除、增加分区)

这里的主题管理,是指控制器帮助我们完成对 Kafka 主题的创建、删除以及分区增加的操作。换句话说,当我们执行 kafka-topics 脚本时,大部分的后台工作都是控制器来完成的。关于 kafka-topics 脚本,我们已经见过了。


分区重分配

分区重分配主要是指,kafka-reassign-partitions 脚本(关于这个脚本,后面会介绍)提供的对已有主题分区进行细粒度的分配功能,这部分功能也是控制器实现的。


Preferred 领导者选举

Preferred 领导者选举主要是 Kafka 为了避免部分 Broker 负载过重而提供的一种更换 Leader 的方案,后面我们会聊 Preferred 领导者选举,这里只需要了解这也是控制器的职责范围就可以了。


集群成员管理(新增 Broker、Broker 主动关闭、Broker 宕机)

这是控制器提供的第 4 类功能,包括自动检测新增 Broker、Broker 主动关闭及被动宕机,这种自动检测是依赖于前面提到的 Watch 功能和 ZooKeeper 临时节点组合实现的。

比如,控制器组件会利用 Watch 机制检查 ZNode 下的子 ZNode 的数量变更。当有新 Broker 启动后,会在 /brokers 下创建专属的 ZNode 节点。一旦创建完毕,ZooKeeper 会通过 Watch 机制将消息通知推送给控制器,这样控制器就能自动地感知到这个变化,进而开启后续的新增 Broker 作业。

而侦测 Broker 存活性则是依赖于刚刚提到的另一个机制:临时节点,每个 Broker 启动后,会在 /brokers/ids 下创建一个临时 ZNode。当 Broker 宕机或主动关闭后,该 Broker 与 ZooKeeper 的会话结束,这个临时 ZNode 就会被自动删除。然后 ZooKeeper 的 Watch 机制将这一变更推送给控制器,这样控制器就能知道有 Broker 关闭或宕机了,从而进行"善后"。


数据服务

控制器的最后一大类工作,就是提供数据服务。所有的 Broker 会定期接收控制器发来的元数据更新请求,从而更新其内存中的缓存数据。

控制器保存了什么数据?

接下来,我们就详细看看,控制器中到底保存了哪些数据,还是用一张图来说明一下。

图中展示的数据量还是很多的,几乎把我们能想到的所有 Kafka 集群的数据都囊括进来了。这里面比较重要的数据有:

  • 所有主题信息。包括具体的分区信息,比如领导者副本是谁,ISR 集合中有哪些副本等。
  • 所有 Broker 信息。包括当前都有哪些运行中的 Broker,哪些正在关闭中的 Broker 等。
  • 所有涉及运维任务的分区。包括当前正在进行 Preferred 领导者选举以及分区重分配的分区列表。

值得注意的是,Zookeeper 的这些数据在控制器中也保存了一份,每当控制器初始化时,它都会从 ZooKeeper 上读取对应的元数据并填充到自己的缓存中。有了这些数据,控制器就能对外提供数据服务了,当然这里的对外指的是对其它 Broker 而言,控制器通过向这些 Broker 发送请求的方式将数据同步到其它 Broker 上。

控制器故障转移(Failover)

我们前面强调过,在 Kafka 集群运行过程中,只能有一个节点会成为控制器,因此这就存在单点故障(Single Point of Failure)的风险,那么 kafka 是如何应对的呢?答案就是,为控制器提供故障转移功能,也就是所谓的 Failover。

故障转移指的是,当运行中的控制器突然宕机或意外终止时,Kafka 能够快速地感知到,并立即启用备用控制器来代替之前失败的控制器,这个过程就被称为 Failover。该过程是自动完成的,无需你手动干预。

接下来,我们一起来看一张图,它简单地展示了控制器故障转移的过程。

最开始时,节点 0 是控制器,而当节点 0 宕机后,ZooKeeper 通过 Watch 机制感知到并删除了 /controller 临时节点。之后所有存活的节点开始竞选新的控制器身份,最终节点 3 赢得了选举,成功地在 ZK 上重建了 /controller 节点。之后节点 3 会从 ZK 中读取集群元数据信息,并初始化到自己的缓存中。至此,控制器的 Failover 完成,可以行使正常的工作职责了。

控制器内部设计原理

在 kafka 0.11 版本之前,控制器的设计是相当繁琐的,代码更是有些混乱,这就导致社区中很多控制器方面的 Bug 都无法修复。控制器是多线程的设计,会在内部创建很多个线程,比如控制器需要为每个 Broker 都创建一个对应的 Socket 连接,然后再创建一个专属的线程,用于向这些 Broker 发送特定请求。如果集群中的 Broker 数量很多,那么控制器端需要创建的线程就会很多。另外,控制器连接 ZooKeeper 的会话,也会创建单独的线程来处理 Watch 机制的通知回调。除了以上这些线程,控制器还会为主题删除创建额外的 I/O 线程。

比起多线程的设计,更糟糕的是,这些线程还会访问共享的控制器缓存数据。我们都知道,多线程访问共享可变数据是维持线程安全最大的难题。为了保护数据安全性,控制器不得不在代码中大量使用 ReentrantLock 同步机制,这就进一步拖慢了整个控制器的处理速度。

鉴于这些原因,社区于 0.11 版本重构了控制器的底层设计,最大的改进就是把多线程的方案改成了单线程加事件队列的方案。

从这张图中可以看到,社区引入了一个事件处理线程,统一处理各种控制器事件。然后控制器将原来执行的操作全部建模成一个个独立的事件,发送到专属的事件队列中,供此线程消费,这就是所谓的单线程 + 队列的实现方式。

值得注意的是,这里的单线程不代表之前提到的所有线程都被 "干掉" 了,控制器只是把缓存状态变更方面的工作委托给了这个线程而已。这个方案的最大好处在于,控制器缓存中保存的状态只被一个线程处理,因此不再需要用重量级的线程同步机制来维护线程安全,Kafka 不用再担心多线程并发访问的问题,非常利于社区定位和诊断控制器的各种故障。事实上,自 0.11 版本重构控制器代码后,社区关于控制器方面的 Bug 明显少多了,这也说明了这种方案是有效的。

针对控制器的第二个改进就是,将之前同步操作 ZK 全部改为异步操作,ZK 本身的 API 提供了同步写和异步写两种方式。之前控制器操作 ZK 使用的是同步的 API,性能很差,集中表现为,当有大量主题分区发生变更时,ZK 容易成为系统的瓶颈。新版本 Kafka 修改了这部分设计,完全摒弃了之前的同步 API 调用,转而采用异步 API 写入 ZK,性能有了很大的提升。根据社区的测试,改成异步之后,ZK 写入提升了 10 倍。

但即便如此,ZK 的性能还是存在瓶颈,因此 Kafka 在 2.8.0 时移除了 ZK。

另外除了以上这些,社区又发布了一个重大的改进。之前 Broker 对接收的所有请求都是一视同仁的,不会区别对待。这种设计对于控制器发送的请求非常不公平,因为这类请求应该有更高的优先级。举个简单的例子,假设我们删除了某个主题,那么控制器就会给该主题所有副本所在的 Broker 发送一个名为 StopReplica 的请求。但如果此时 Broker 已经积压了大量的请求,那么这个 StopReplica 请求只能排队等待,要是这些请求还是生产者向该主题发送消息的话,就显得很讽刺了:主题都要被删除了,处理这些写消息的请求还有意义吗?所以此时最合理的处理顺序应该是,赋予 StopReplica 请求更高的优先级,使它能够得到抢占式的处理。

从 2.2 开始,Kafka 正式支持这种不同优先级请求的处理。简单来说,Kafka 将控制器发送的请求与普通数据类请求分开,实现了控制器请求单独处理的逻辑。


小窍门

当你觉得控制器组件出现问题时,比如主题无法删除了,或者重分区 hang 住了,你可以不用重启控制器。有一个简单快速的方式是,去 ZK 中手动删除 /controller 节点,具体命令是 rmr /controller。这样做的好处是,既可以引发控制器的重选举,又可以避免重启 Broker 导致的消息处理中断,当然这么做的前提是 Kafka 集群必须是依赖 ZK 的方式启动。

高水位和 Leader Epoch

你可能听说过高水位(High Watermark),但不一定耳闻过 Leader Epoch,前者是 Kafka 中非常重要的概念,而后者是社区在 0.11 版本中新推出的,主要是为了弥补高水位机制的一些缺陷。鉴于高水位机制在 Kafka 中举足轻重,而且深受各路面试官的喜爱,下面就来重点说说高水位。当然,我们也会花一部分时间来讨论 Leader Epoch 以及它的角色定位。

什么是高水位?

首先,我们要明确一下基本的定义:什么是高水位?或者说什么是水位?水位一词多用于流式处理领域,比如,Spark Streaming 或 Flink 框架中都有水位的概念。Streaming System 一书则是这样表述水位的:

水位是一个单调增加且表征最早未完成工作(oldest work not yet completed)的时间戳。

在 Kafka 的世界中,水位的概念有一点不同,Kafka 的水位不是时间戳,更与时间无关。它是和位置信息绑定的,具体来说它是用消息的分区位移来表征的。另外 Kafka 源码使用的表述是高水位,因此这里也会统一使用高水位或它的缩写 HW 来进行讨论。值得注意的是,Kafka 中也有低水位(Low Watermark),它是与 kafka 删除消息相关联的概念, 而低水位这里就不展开讲了。

高水位的作用

我们先以流处理系统为例,解释一下高水位。

水位是单调递增的,这意味着一旦水位推进到某个时间点,它就不会回退到更早的时间点。这是因为水位旨在反映流处理系统对事件时间进度的估计,随着时间的推移,这个估计只会向前移动。

然后水位通常用于表示系统认为所有事件时间小于等于水位的事件都已经被观测到(或处理)的时间点,这允许系统对基于时间的操作(如窗口聚合)做出决策。在很多流处理框架中,水位被用来处理时间窗口聚合操作,当水位推进到窗口结束时间之后,系统可以认为该时间窗口内的所有数据都已到达,因此可以安全地关闭窗口并进行聚合计算。


而在 Kafka 中,高水位不是一个时间点,而是消息的分区位移,它的作用主要有两个。

  • 定义消息可见性,即用来标识分区下的哪些消息是可以被消费者消费的
  • 帮助 Kafka 完成副本同步

下面这张图展示了多个与高水位相关的 Kafka 术语,我们来详细解释一下图中的内容,同时澄清一些常见的误区。

我们假设这是某个分区 Leader 副本的高水位图,首先需要注意图中的 "已提交消息" 和 "未提交消息"。我们之前在谈 Kafka 持久性保障的时候,特意对两者进行了区分,现在借用高水位再次强调一下。在分区高水位以下的消息被认为是已提交消息,反之就是未提交消息。消费者只能消费已提交消息,即图中位移小于 8 的消息。

注意,这里我们不讨论 Kafka 事务,因为事务机制会影响消费者所能看到的消息范围,它不只是简单依赖高水位来判断,它依靠一个名为 LSO(Log Stable Offset)的位移值来判断事务型消费者的可见性。

图中还有一个日志末端位移的概念,即 Log End Offset,简写是 LEO,它表示副本写入的下一条消息的位移值。注意数字 15 所在的方框是虚线,这就说明该副本当前只有 15 条消息,位移值是从 0 到 14,下一条新消息的位移是 15。显然,介于高水位和 LEO 之间的消息就属于未提交消息。这也从侧面告诉了我们一个重要的事实,那就是:同一个副本对象,其高水位值不会大于 LEO 值。

高水位和 LEO 是副本对象的两个重要属性,Kafka 所有副本都有对应的高水位和 LEO 值,而不仅仅是 Leader 副本。只不过 Leader 副本比较特殊,Kafka 使用 Leader 副本的高水位来定义所在分区的高水位,换句话说:分区的高水位就是其 Leader 副本的高水位。

高水位更新机制

现在我们知道了每个副本对象都保存了一组高水位值和 LEO 值,但实际上,在 Leader 副本所在的 Broker 上,还保存了其它 Follower 副本的 LEO 值。我们一起来看看下面这张图:

在这张图中我们可以看到,Broker 0 上保存了某分区的 Leader 副本和所有 Follower 副本的 LEO 值,而 Broker 1 上仅仅保存了该分区的某个 Follower 副本。Kafka 把 Broker 0 上保存的这些 Follower 副本又称为远程副本(Remote Replica)。Kafka 副本机制在运行过程中,会更新 Broker 1 上 Follower 副本的高水位和 LEO 值,同时也会更新 Broker 0 上 Leader 副本的高水位和 LEO 以及所有远程副本的 LEO,但它不会更新远程副本的高水位值,也就是图中标记为蓝色的部分。

为什么要在 Broker 0 上保存这些远程副本呢?其实,它们的主要作用是帮助 Leader 副本确定其高水位,也就是分区高水位。

为了更好地记忆这些值被更新的时机,我们看一张表格,只有搞清楚了更新机制,我们才能开始讨论 Kafka 副本机制的原理,以及它是如何使用高水位来执行副本消息同步的。

在这里,我稍微解释一下,什么叫与 Leader 副本保持同步,判断的条件有两个:

  • 该远程 Follower 副本在 ISR 中
  • 该远程 Follower 副本 LEO 值落后于 Leader 副本 LEO 值的时间,不超过 Broker 端参数 replica.lag.time.max.ms 的值,如果使用默认值的话,就是不超过 10 秒

乍一看,这两个条件好像是一回事,因为目前某个副本能否进入 ISR 就是靠第 2 个条件判断的。但有些时候,会发生这样的情况:即 follower 副本已经追上了 Leader 的进度,却不在 ISR 中,比如某个刚刚重启回来的副本。如果 kafka 只判断第 1 个条件的话,就可能出现某些副本具备了进入 ISR 的资格,但却尚未进入到 ISR 中的情况。此时,分区高水位值就可能超过 ISR 中副本 LEO,而高水位大于 LEO 的情形是不被允许的。

下面,我们分别从 Leader 副本和 Follower 副本两个维度,来总结一下高水位和 LEO 的更新机制。

Leader 副本

处理生产者请求的逻辑如下:

  • 写入消息到本地磁盘
  • 更新分区高水位值,分为三步:1)获取 Leader 副本所在的 Broker 端保存的所有远程副本 LEO 值 {LEO-1,LEO-2,……,LEO-n};2)获取 Leader 副本高水位值:currentHW;3)更新 currentHW = min(currentHW, LEO-1,LEO-2,……,LEO-n)

处理 Follower 副本拉取消息的逻辑如下:

  • 读取磁盘(或页缓存)中的消息数据
  • 使用 Follower 副本发送请求中的位移值更新远程副本 LEO 值
  • 更新分区高水位值(具体步骤与处理生产者请求的步骤相同)

Follower 副本

从 Leader 拉取消息的处理逻辑如下:

  • 写入消息到本地磁盘
  • 更新 LEO 值
  • 更新高水位值,分为三步:1)获取 Leader 发送的高水位值:currentHW;2)获取步骤 2 中更新过的 LEO 值:currentLEO;3)更新高水位为 min(currentHW, currentLEO)

副本同步机制解析

搞清楚了这些值的更新机制之后,我来举一个实际的例子,说明一下 Kafka 副本同步的全流程,该例子使用一个单分区且有两个副本的主题。

当生产者发送一条消息时,Leader 和 Follower 副本对应的高水位是怎么被更新的呢?我们慢慢解释。

首先是初始状态,下面这张图中的 remote LEO 就是刚才的远程副本的 LEO 值。在初始状态时,所有值都是 0。

当生产者给主题分区发送一条消息后,状态变更为:

此时,Leader 副本成功将消息写入了本地磁盘,故 LEO 值被更新为 1。Follower 再次尝试从 Leader 拉取消息,和之前不同的是,这次有消息可以拉取了,因此状态进一步变更为:

这时,Follower 副本也成功地更新 LEO 为 1。此时 Leader 和 Follower 副本的 LEO 都是 1,但各自的高水位依然是 0,还没有被更新。它们需要在下一轮的拉取中被更新,如下图所示:

在新一轮的拉取请求中,由于位移值是 0 的消息已经拉取成功,因此 Follower 副本这次请求拉取的是位移值 =1 的消息。Leader 副本接收到此请求后,更新远程副本 LEO 为 1,然后更新 Leader 高水位为 1。做完这些之后,它会将当前已更新过的高水位值 1 发送给 Follower 副本,Follower 副本接收到以后,也将自己的高水位值更新成 1。至此,一次完整的消息同步周期就结束了。事实上,Kafka 就是利用这样的机制,实现了 Leader 和 Follower 副本之间的同步。

Leader Epoch 登场

故事讲到这里似乎很完美,依托于高水位,kafka 既界定了消息的对外可见性,又实现了异步的副本同步机制。不过,我们还是要思考一下这里面存在的问题。

从刚才的分析中,我们知道 follower 副本的高水位更新需要一轮额外的拉取请求才能实现。如果把上面那个例子扩展到多个 Follower 副本,情况可能更糟,也许需要多轮拉取请求。也就是说,Leader 副本高水位更新和 Follower 副本高水位更新在时间上是存在错配的,这种错配是很多 "数据丢失" 或 "数据不一致" 问题的根源。基于此,社区在 0.11 版本正式引入了 Leader Epoch 概念,来规避因高水位更新错配导致的各种不一致问题。

所谓 Leader Epoch,我们大致可以认为是 Leader 版本,它由两部分数据组成:

  • Epoch,一个单调增加的版本号,每当副本领导权发生变更时,都会增加该版本号。小版本号的 Leader 被认为是过期 Leader,不能再行使 leader 权力
  • 起始位移(Start Offset),Leader 副本在该 Epoch 值上写入的首条消息的位移

举个例子来说明一下 Leader Epoch,假设现在有两个 Leader Epoch<0, 0> 和 <1, 120>,那么,第一个 Leader Epoch 表示版本号是 0,这个版本的 Leader 从位移 0 开始保存消息,一共保存了 120 条消息。之后 Leader 发生了变更,版本号增加到 1,新版本的起始位移是 120。

Broker 会在内存中为每个分区都缓存 Leader Epoch 数据,同时它还会定期地将这些信息持久化到一个 checkpoint 文件中。当 Leader 副本写入消息到磁盘时,Broker 会尝试更新这部分缓存,如果该 Leader 是首次写入消息,那么 Broker 会向缓存中增加一个 Leader Epoch 条目,否则就不做更新。这样,每次有 Leader 变更时,新的 Leader 副本会查询这部分缓存,取出对应的 Leader Epoch 的起始位移,以避免数据丢失和不一致的情况。

接下来,我们来看一个实际的例子,它展示的是 Leader Epoch 是如何防止数据丢失的,先看下图。

稍微解释一下,单纯依赖高水位是怎么造成数据丢失的。开始时副本 A 和副本 B 都处于正常状态,A 是 Leader 副本。某个使用了默认 acks 设置的生产者程序向 A 发送了两条消息,A 全部写入成功,此时 Kafka 会通知生产者说两条消息全部发送成功。

现在我们假设 Leader 和 Follower 都写入了这两条消息,而且 Leader 副本的高水位也已经更新了,但 Follower 副本高水位还未更新,这是可能出现的。还记得吧,Follower 端高水位的更新与 Leader 端有时间错配。倘若此时副本 B 所在的 Broker 宕机,当它重启回来后,副本 B 会执行日志截断操作,将 LEO 值调整为之前的高水位值,也就是 1。这就是说,位移值为 1 的那条消息被副本 B 从磁盘中删除,此时副本 B 的底层磁盘文件中只保存有 1 条消息,即位移值为 0 的那条消息。

当执行完截断操作后,副本 B 开始从 A 拉取消息,执行正常的消息同步。如果就在这个节骨眼上,副本 A 所在的 Broker 宕机了,那么 Kafka 就别无选择,只能让副本 B 成为新的 Leader,然后当 A 回来后,需要执行相同的日志截断操作,即将高水位调整为与 B 相同的值,也就是 1。这样操作之后,位移值为 1 的那条消息就从这两个副本中被永远地抹掉了,以上就是这张图要展示的数据丢失场景。

严格来说,这个场景发生的前提是 Broker 端参数 min.insync.replicas 设置为 1。此时一旦消息被写入到 Leader 副本的磁盘,就会被认为是 "已提交状态",但现有的时间错配问题导致 Follower 端的高水位更新是有滞后的。如果在这个短暂的滞后时间窗口内,接连发生 Broker 宕机,那么这类数据的丢失就是不可避免的。

现在,我们来看下如何利用 Leader Epoch 机制来规避这种数据丢失,我们依然用图的方式来说明。

场景和之前大致是类似的,只不过引用 Leader Epoch 机制后,Follower 副本 B 重启回来后,需要向 A 发送一个特殊的请求去获取 Leader 的 LEO 值。在这个例子中,该值为 2。当获知到 Leader LEO=2 后,B 发现该 LEO 值不比它自己的 LEO 值小,而且缓存中也没有保存任何起始位移值 > 2 的 Epoch 条目,因此 B 无需执行任何日志截断操作。这是对高水位机制的一个明显改进,即副本是否执行日志截断不再依赖于高水位进行判断。

现在,副本 A 宕机了,B 成为 Leader。同样地,当 A 重启回来后,执行与 B 相同的逻辑判断,发现也不用执行日志截断,至此位移值为 1 的那条消息在两个副本中均得到保留。后面当生产者程序向 B 写入新消息时,副本 B 所在的 Broker 缓存中,会生成新的 Leader Epoch 条目:[Epoch=1, Offset=2]。之后,副本 B 会使用这个条目帮助判断后续是否执行日志截断操作。这样,通过 Leader Epoch 机制,Kafka 完美地规避了这种数据丢失场景。

以上就是 kafka 的高水位机制以及 Leader Epoch 机制。高水位在界定 Kafka 消息对外可见性以及实现副本机制等方面起到了非常重要的作用,但其设计上的缺陷给 Kafka 留下了很多数据丢失或数据不一致的潜在风险。为此,社区引入了 Leader Epoch 机制,尝试规避掉这类风险。事实证明,它的效果不错,在 0.11 版本之后,关于副本数据不一致性方面的 Bug 的确减少了很多。如果你想深入学习 kafka 的内部原理,那么这些内容是非常值得你好好琢磨并熟练掌握的。

Kafka 调优之部署方案选择

到目前为止关于 Kafka 的基本内容我们就说完了,下面看看 Kafka 的调优。首先是部署方案,我们从操作系统、磁盘、磁盘容量和带宽等方面来讨论一下。

操作系统

这个不多说,果断选择 Linux。至于为什么?主要是在以下这三个方面:

I/O模型的使用

I/O 模型可以近似地认为是操作系统执行 I/O 指令的方法,主流的 I/O 模型有五种:阻塞式 I/O,非阻塞式 I/O,I/O 多路复用,信号驱动 I/O,异步I/O。每种 I/O 模型都有各自的使用场景,但我们想要支持高并发的话,都会选择 I/O 多路复用,至于异步 I/O,由于操作系统支持的不完美,所以不选择。

目前的 I/O 多路复用有三种:Select、Poll、Epoll,Epoll 是在 Linux 内核 2.4 中提出的,对于 I/O 轮询可以做到效率最大化。至于这三者的具体关系就不详细介绍了,只需要知道 Epoll "最好" 就行了。说了这么多,那么 I/O 模型和 kafka 又有什么关系呢?实际上 Kafka 客户端底层依赖多路复用器 Selector,Selector 会自动从 Select、Poll、Epoll 中选择一个,而 Windows 只支持 Select。因此在这一点上 Linux 是有优势的,因为能够获得更高效的 I/O 性能。


数据网络传输效率

Kafka 生产和消费的消息都是通过网络传输的,而消息保存在磁盘上,故 kafka 需要在磁盘和网络之间进行大量的数据传输。为了减少性能的损耗,Kafka 使用了 Linux 提供的零拷贝(Zero Copy)技术,当数据在磁盘和网络之间进行传输时避免昂贵的内核态数据拷贝,从而实现快速的数据传输。一句话总结,在 Linux 部署 Kafka 能够享受零拷贝技术带来的数据快速传输特性。


社区支持度

最后是社区支持度,这一点虽然不是什么明显的差别,但如果不了解的话,所造成的影响可能会比前两个因素更大。简单来说,就是社区目前对 Windows 平台上发现的 bug 不做任何承诺。因此 Windows 平台上部署 Kafka 只适合于个人测试或用于功能验证,千万不要用于生产环境。

磁盘

如果要问哪种资源对 Kafka 的性能影响最大,磁盘无疑是排名靠前的。在对 Kafka 集群进行磁盘规划时经常要面对一个问题:应该选择普通的机械磁盘还是固态硬盘?前者成本低且容量大,但易损坏;后者性能优势大,不过单价高。个人建议:使用普通的机械硬盘即可。

Kafka 大量使用磁盘不假,可它使用的方式是多顺序读写操作,一定程度上规避了机械磁盘最大的劣势,即随机读写操作慢。从这一点上,使用 SSD 似乎没有太大的性能优势,毕竟从性价比来看,机械磁盘物美价廉,而它因易损坏而造成的可靠性差等缺陷,又有 Kafka 在软件层面提供机制来保证,故使用普通机械磁盘是很划算的。

关于磁盘选择另一个常常讨论的话题,到底是否应该使用磁盘阵列(raid)。使用磁盘阵列的两个优势在于:

  • 追求性价比的公司可以不搭建磁盘阵列,使用普通磁盘组成存储空间即可。
  • 使用机械磁盘完全能够胜任 Kafka 线上环境。

磁盘容量

Kafka 集群到底需要多大的存储空间,这是一个非常经典的规划问题。Kafka 需要将消息保存在底层的磁盘上,等一段时间过后自动删除。虽然这段时间是可以配置的,但应该如何结合自身业务场景和存储需求来规划 Kafka 集群的存储容量呢?

我举一个简单的例子来说明如何思考这个问题,假设你所在公司有个业务每天需要向 Kafka 集群发送 1 亿条消息,每条消息保存两份以防止数据丢失,另外消息默认保存两周时间。现在假设消息的平均大小是 1KB,那么你能说出你的 Kafka 集群需要为这个业务预留多少磁盘空间吗?

我们来计算一下,每天 1 亿条 1KB 大小的消息,保存两份且存两周的时间,那么总的空间大小就等于 1亿 * 1KB * 2 / 1024 / 1024 。一般情况下,Kafka 集群除了消息数据还有其它类型的数据,比如索引数据等,因此我们需要再为这些数据预留出 10% 的磁盘空间,因此我们在原来的基础上乘上 1.1。既然要保存两周,并且这两周内每天都会产生 1 亿条 1KB 大小的消息,那么结果还要再乘上 14,所以整体容量大概为 21.5TB 左右。由于 Kafka 支持数据的压缩 ,假设数据的压缩比是 0.75,那么最后你需要规划的存储空间是 21.5 * 0.75=16.14TB 左右。

总之在规划磁盘容量时你需要考虑下面这几个元素:

  • 新增消息数
  • 消息留存时间
  • 平均消息大小
  • 备份数
  • 是否启用压缩

带宽

对于 Kafka 这种通过网络进行大量数据传输的框架而言,带宽特别容易成为瓶颈。事实上,在真实案例当中,带宽资源不足导致 Kafka 出现性能问题的比例至少占 60% 以上。如果你的环境中还要涉及跨机房传输,那么情况会更糟。

如果你所在公司不是超级有钱的话,我会认为你们使用的是普通的以太网,带宽也主要有两种:1Gbps 的千兆网络,和 10Gbps 的万兆网络,特别是千兆网络应该是一般公司网络的标准配置了。下面就以千兆网举一个实际的例子,来说明一下如何进行带宽资源的规划。

注意:上面 Gbps 里面的 b 是小写字母 b(表示比特位),不是大写字母 B(表示字节),因为带宽的单位是 bps。比如你在运营商办理宽带,客服告诉你这是 100 兆的带宽,指的是 100Mbps,表示你在下载的时候速度最高能达到每秒 100Mb。但这个 b 是比特位,换算成字节的话还要除以 8,所以最大速度是 12 MB 到 13MB 之间。

与其说是带宽资源的规划,其实真正要规划的是所需的 Kafka 服务器的数量。假设你公司的机房环境是千兆网络,即 1Gbps,现在你有个业务,其业务目标或 SLA 是在 1 小时内处理 1TB 的业务数据。那么问题来了,你到底需要多少台 Kafka 服务器来完成这个业务呢?

让我们来计算一下,假设每台 kafka 服务器都是安装在专属的机器上,也就是说每台 Kafka 机器上没有其他服务,但是真实环境中不建议这么做。通常情况下你要假设 Kafka 只会用到 70% 的资源,因为总要为其他应用或者进程留一些资源。根据实际使用经验,超过 70% 的阈值就有网络丢包的可能性了,故 70% 的设定是一个比较合理的值,也就是说单台 Kafka 服务器最多也就能使用 700Mb 的带宽资源。

稍等,这只是它能使用的最大带宽资源,你不能让 Kafka 服务器常规性地使用这么多资源,故通常要再额外留出 2/3 的资源,即单台服务器使用带宽为 700/3≈233Mbps。需要提示的是,这里的 2/3 是相当保守的,你可以结合自己机器的使用情况酌情减少此值。

好了,有了 233Mbps,我们就可以计算 1 小时内处理 1TB 数据所需要的服务器数量了。根据这个目标,我们每秒需要处理 1TB / 3600s * 8 ≈ 2336Mb 的数据(乘上 8 计算出的是比特位,因为带宽的单位是每秒多少个比特),除以 233 约等于 10 台服务器。如果消息还需要额外复制两份,那么总的服务器台数还要乘以 3,即 30 台。

CPU 选择

  • num.io.threads = 8,负责写磁盘的 IO 线程数,整个参数值要占总核数的 50%。
  • num.network.threads = 3,转发请求、返回响应的网络线程数,这个参数占总核数的 50% 的 2/3。
  • num.replica.fetchers = 1,副本拉取线程数,这个参数占总核数的 50% 的 1/3。

建议 Kafka 集群不少于 32 个 CPU 核心。

Kafka 调优之生产者配置

先来看一张图:

生产者调优主要是针对它的一些配置参数进行设置,那么相关配置都有哪些呢。


bootstrap.servers

生产者连接集群所需的 Broker 地址清单,例如 satori001:9092,satori002:9092,可以指定 1 个或者多个,中间用逗号隔开。注意这里并非需要所有 Broker 的地址,因为生产者从给定的 Broker 里查找到其它 Broker 的信息。


key.serializer 和 value.serializer

key 和 value 的序列化函数,在发消息前会先调用一遍指定函数。


buffer.memory

RecordAccumulator 缓冲区总大小,默认 32M。


batch.size

缓冲区可容纳的最大数据量,默认 16k。适当增加该值可以提高吞吐量,但如果设置的太大,会导致数据传输的延迟增加。


linger.ms

如果数据迟迟未达到 batch.size,那么 Sender 线程在等待 linger.ms 之后也会将消息数据发出去。默认值是 0ms,表示没有延迟,生产环境建议设置为 5 ~ 100ms 之间。


acks

应答机制,有三种选择:

  • 0:生产者将消息发送出去,不会收到任何应答,此时消息是否写成功,生产者是不知道的;
  • 1:生产者将消息发送出去,Leader 收到之后会给予应答;
  • -1:生产者将消息发送出去,Leader 收到并同步给 ISR 中的其它副本之后会给予应答;

为了最大保证消息能发给 Broker,我们可以设置成 -1,默认值为 1。


max.in.flight.requests.per.connection

允许最多没有返回 ack 的次数,默认为 5,开启幂等性要保证该值是 1 ~ 5 的数字。


retries

当消息发送出现错误的时候,系统会重发消息。retries 表示重试次数,默认是 int 最大值:2147483647。

注意:如果设置了重试,还想保证消息的有序性,需要设置 MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION=1,否则在重试此失败消息的时候,其它的消息可能已经发送成功了。


retry.backoff.ms

两次重试之间的时间间隔,默认是 100ms。


enable.idempotence

是否开启幂等性,默认 true,开启幂等性。


compression.type

生产者发送的所有数据的压缩方式,支持:none、gzip、snappy、lz4 和 zstd。默认为 none,也就是不压缩。

在合理范围内增大 RecordAccumulator、batch.size、linger.ms,以及设置 compression.type,可以提高吞吐量。

Kafka 调优之消费者配置

再来看看消费的配置参数。


bootstrap.servers

生产者连接集群所需的 Broker 地址清单,例如 satori001:9092,satori002:9092,可以指定 1 个或者多个,中间用逗号隔开。注意这里并非需要所有 Broker 的地址,因为生产者从给定的 Broker 里查找到其它 Broker 的信息。


key.deserializer 和 value.deserializer

key 和 value 的反序列化函数,在收到消息后会调用一遍指定函数。


group.id

标记消费者所属的消费者组。


enable.auto.commit

默认值为 true,消费者会自动周期性地向服务器提交偏移量。


auto.commit.interval.ms

消费者偏移量向 Kafka 提交的频率,默认 5s,需要 enable.auto.commit 的值为 true。


auto.offset.reset

消费者从什么地方开始消费,earliest:最近一次提交的偏移量开始;latest:最新消息的分区位移开始。


offsets.topic.num.partitions

__consumer_offsets 的分区数,默认是 50 个分区。该主题是存储消费者偏移量的(之前偏移量数据存在 ZK 中),属于元数据,不建议修改。


heartbeat.interval.ms

Kafka 消费者和 Coordinator 之间的心跳时间,默认 3s。


session.timeout.ms

Kafka 消费者和 Coordinator 之间连接的超时时间,默认 45s。超过该值,消费者会被移除,消费者组执行重平衡。


max.poll.interval.ms

消费者处理消息的最大时长,默认是 5 分钟。超过该值,消费者被移除,消费者组执行再平衡。


fetch.min.bytes

默认 1 个字节,消费者获取服务端一批消息的最小字节数。


fetch.max.wait.ms

默认 500ms,如果时间到,即使没有从服务端获取到一批数据的最小字节数,仍然会返回数据。


fetch.max.bytes

默认 52428800,即 50M,消费者获取服务端一批消息的最大字节数。不过即使服务端一批次的数据大于该值,仍然可以拉回来这批数据,因此这不是一个绝对最大值。

一批次的大小受 message.max.bytes(Broker config)或 max.message.bytes(Topic config)的影响。


max.poll.records

一次 poll 拉取后,返回消息的最大条数,默认是 500 条。


消费者如果想提高吞吐量,可以增大 fetch.max.bytes 和 max.poll.records。

搭建 Kafka 集群

最后我们来搭建一个三节点的 Kafka 集群,在我的本地有三台虚拟机,主机名分别是 satori001、satori002、satori003。然后我们让 satori001 作为 Controller 和 Broker,satori002 和 satori003 只作为 Broker,那么配置文件如下。

satori001 节点的配置文件 config/kraft/server.properties

# 只作为 Controller
process.roles=broker,controller
# 节点 id 为 1
node.id=1
# 指定所有竞选 Controller 的节点
controller.quorum.voters=1@satori001:9093
# 监听的地址,同时配置 Broker 和 Controller
listeners=PLAINTEXT://satori001:9092,CONTROLLER://satori001:9093
# 因为都在同一个内网,不需要公网访问,所以将 advertised.listeners 必须注释掉
# 文件存储路径
log.dirs=/opt/kafka_2.13-3.6.1/data  

satori002 节点的配置文件 config/kraft/server.properties

# 只作为 broker
process.roles=broker
# 节点 id 为 2
node.id=2
# 指定所有竞选 Controller 的节点
controller.quorum.voters=1@satori001:9093
listeners=PLAINTEXT://satori002:9092
# 注释掉 advertised.listeners
log.dirs=/opt/kafka_2.13-3.6.1/data

satori003 节点的配置文件 config/kraft/server.properties

process.roles=broker
node.id=3
controller.quorum.voters=1@satori001:9093
listeners=PLAINTEXT://satori003:9092
# 注释掉 advertised.listeners
log.dirs=/opt/kafka_2.13-3.6.1/data

配置文件修改完之后,我们来格式化存储目录。

# 在任意一个节点上生成一个集群 id
kafka-storage.sh random-uuid
# 拿着上一步生成的集群 id(这里是 8npH2Yk5Q0i62EOzVxdvJA),在每个节点上执行如下命令
kafka-storage.sh format -t 8npH2Yk5Q0i62EOzVxdvJA -c $KAFKA_HOME/config/kraft/server.properties

都格式化成功之后,我们启动 Kafka 集群,在每个节点上分别启动。

kafka-server-start.sh -daemon $KAFKA_HOME/config/kraft/server.properties

启动完毕后,我们查看是否启动成功。

所有节点都返回了相同的集群 id,显然集群启动成功了。我们再创建一个主题试试:

创建一个主题 A,分区数为 2,副本数为 3。然后查看分区,其中分区 0 的 Leader 副本位于 node.id=1 的节点上,分区 1 的 Leader 副本位于 node.id=2 的节点上。因为有 3 个 Broker,所以三副本的话,每个 Broker 分别存储一份。

小结

以上我们就说完了 Apache Kafka,它是一个分布式流处理平台,被广泛用于构建实时的数据管道和流应用程序,支持高效地处理大量数据,并具有高吞吐量、可扩展性、容错性和低延迟等优点。

基本上大部分公司内部(不单单是做大数据的公司),都少不了 Kafka 的身影,因此我们掌握它是非常有必要的。


 

本文参考自:

  • 极客时间胡夕:《Kafka 核心技术与实战》
posted @ 2024-02-24 01:20  万明珠  阅读(775)  评论(0编辑  收藏  举报