消息引擎与分布式流处理平台kafka的概念和使用方式,以及如何对接Python

楔子

本次来聊一聊 kafka,估计很多人都知道它是一个应用于大数据实时领域、基于发布/订阅模式的分布式消息中间件,但是我们一般更愿意称 kafka 为消息引擎系统,只不过消息队列听上去会更加的耳熟一些。

但 kafka 真的只是消息引擎系统吗?要搞清楚这个问题,就要从 kafka 的发展历史说起。纵观 kafka 的发展历史,它确实是消息引擎起家的,但它不仅是一个消息引擎系统,同时也是一个分布式流处理平台(distributed stream processing platform),而 kafka 官方也是这么定义 kafka 的。

总结:kafka 虽然是消息引擎起家,但它不仅是一个消息引擎,还是一个分布式流处理平台。

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

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

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

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

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

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


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

  • 第一点是更容易实现端到端的正确性(correctness)。流处理要最终替代它的兄弟批处理需要具备两个核心优势:1. 实现正确性;2. 提供能够推导时间的工具。实现正确性是流处理能够匹敌批处理的基石。

正确性一直是批处理的强项,而实现正确性的基石则是要求框架能提供 '精确一次语义处理',即处理一条消息有且只有一次机会能够影响系统状态。目前主流的大数据流处理框架都宣称实现了 '精确一次语义处理',但是这是有限定条件的,即它们只能实现框架内的精确一次语义处理,无法实现端到端的。这是为什么呢?因为当这些框架与外部消息引擎系统结合使用时,它们无法影响到外部系统的处理语义,所以如果你搭建了一套环境使得 spark 或 flink 从 kafka 读取消息之后进行有状态的数据计算,最后再写回 kafka,那么你只能保证在 spark 或者 flink 内部,这条消息对于状态的影响只有一次,但是计算结果有可能多次写入的 kafka,因为它们不能控制 kafka 的语义处理。相反地,kafka 则不是这样,因为所有的数据流转和计算都在 kafka 内部完成,故 kafka 可以实现端到端的精确一次处理。

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

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

这里再来解释一下什么是精确一次语义处理。举个例子,如果我们使用 kafka 计算某网页的 pv,我们将每次网页访问都作为一个消息发送给 kafka,pv 的计算就是我们统计 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 connect 组件支持的一部分而已,并且使用 kafka connect 组件的用户越来越多,相信在未来会有越来越多的人开发自己的连接器。

说了这么多,可能会有人好奇这跟这一节的主题有什么关系呢?其实清晰地了解 kafka 的发展脉络和生态圈现状,对于我们选择合适的 kafka 版本大有裨益。下面我们就进入今天的主题:如何选择 kafka 版本。


首先你知道几种 kafka 呢?

咦?kafka 不是一个开源框架吗?什么叫有几种 kafka,实际上 kafka 的确有好几种。当然这里的好几种是指存在多个组织或者公司发布的不同 kafka。就像 Linux 的发行版有 Ubuntu、Centos 等等,虽说 kafka 没有发行版的概念,但姑且可以这样的近似的认为市面上的确存在着多个 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 亿美元,足见资本市场的青睐。这里说点题外话,饶军是我们中国人,清华大学毕业的大神级人物,我们已经看到越来越多的 Apache 顶级项目创始人中出现了中国人的身影。另一个例子就是,Apache pulsar,它是一个以打败 kafka 为目标的新一代消息引擎系统,在开源社区中活跃的中国人数不胜数,这种现象实在令人振奋。说回 Confluent 公司,它主要从事商业化的 kafka 工具开发,并在此基础上发布了 Confluent kafka。Confluent kafka 提供了 Apache kafka 没有的高级特性,比如跨数据中心备份、schema 注册中心以及集群监控工具等等。

  • Cloudera/Hortonworks Kafka

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

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


特点比较

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

  • 1. 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。

  • 2. Confluent 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。

  • 3. CDH/HDP kafka

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

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

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


小结

总结一下,我们今天讨论了不同的 kafka "发行版" 以及它们的优缺点,根据这些优缺点,我们可以有针对性地根据实际需求选择合适的 kafka。最后我们回顾一下内容:

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

kafka线上集群部署方案怎么做?

前面我们分别从 kafka 的定位,版本的变迁以及功能的演进等方面循序渐进地梳理了 Apache kafka 的发展脉络。那么现在我们就来看看生产环境中的 kafka 集群方案该怎么做。既然是集群,那必然就要有多个 kafka 节点机器,因为只有单台机器构成的 kafka 伪集群只能用于日常测试之用,根本无法满足实际的线上生产需求。而真正的线上环境需要仔细地考量各种因素,结合自身的业务需求而制定。下面我们就从操作系统、磁盘、磁盘容量和带宽等方面来讨论一下。

操作系统:

这个不多 BB,果断选择 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 客户端底层使用 java 的 selector,selector 会自动从 select、poll、epoll 中选择一个,而 Windows 只支持 select。因此在这一点上 Linux 是有优势的,因为能够获得更高效的 I/O 性能。

  • 数据网络传输效率

首先 kafka 生产和消费的消息都是通过网络传输的,而消息保存在哪里呢?肯定是磁盘,故 kafka 需要在磁盘和网络之间进行大量数据传输。如果你熟悉 Linux,那么你肯定听说过零拷贝(zero copy)技术,就是当数据在磁盘和网络进行传输时避免昂贵的内核态数据拷贝从而实现快速的数据传输。Linux 平台实现了这样的零拷贝机制,但有些遗憾的是在 Windows 平台上必须等到 java8 的 60 更新版本才能享受这个福利。一句话总结一下,在 Linux 部署 kafka 能够享受到零拷贝技术所带来的快速数据传输特性。

  • 社区支持度

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


磁盘:

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

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

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

  • 提供冗余的磁盘存储空间
  • 提供负载均衡

以上两个优势对于任何一个分布式系统都很有吸引力,不过就 kakfa 而言,一方面 kafka 自己实现了冗余机制来提供高可靠性;另一方面通过分区的概念,kafka 也能在软件层面自行实现负载均衡。如此一来磁盘阵列的优势就没有那么明显了,当然并不是说磁盘阵列不好,实际上依然有很多大厂是把确实是把 kafka 底层的存储交给磁盘阵列的,只是目前 kafka 在存储这方面提供了越来越便捷的高可靠性方案,因此在线上环境使用磁盘阵列似乎变得不那么重要了。综合以上的考量,个人给出的建议是:

  • 追求性价比的公司可以不搭建磁盘阵列,使用普通磁盘组成存储空间即可。
  • 使用机械磁盘完全能够胜任 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 服务器来完成这个业务呢?

让我们来计算一下,由于带宽是 1Gbps,即每秒处理 1GB 的数据,假设每台 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 台。

聊聊kafka的版本号

上面说了那么多,主要是一些前置知识,最好先有一个印象。下面我们就来安装 kafka,不过不好意思,在安装 kafka 之前我们必须先来聊聊 kafka 版本号的问题,这个问题实在是太重要了,甚至是日后能否用好 kafka 的关键。前面我们介绍了 kafka 的几种发行版,其实不论是哪种 kafka,本质上都内嵌了最核心的 Apache kafka,也就是社区版 kafka,那今天我们就说说 Apache kafka 版本号的问题。在开始之前,先强调一下,后面出现的所有 "版本" 这个词都表示 kafka 具体的版本号,而非上面说的 kafka 种类,这一点要切记。

那么现在可能会有这样的疑问,我为什么要关心版本号的问题呢?直接使用最新版本不就好了吗?当然了,这的确是一种有效的版本选择的策略,但我想强调的是这种策略并非在任何场景下都适用。如果你不了解各个版本之间的差异和功能变化,你怎么能准确地评判某kafka版本是不是满足你的业务需求呢?因此在深入学习 kafka 之前,花些时间搞明白版本演进,实际上是非常划算的一件事。


kafka 版本命名

当前 Apache kafka 已经迭代到 2.6 版本,但是稍微有些令人吃惊的是,很多人对于 kafka 的版本命名理解存在歧义。比如我们在官网下载 kafka 时,会看到这样的版本。

注意红色框框里面的部分,于是有些人或许就会纳闷,难道 kafka 的版本号不是 2.12 或者 2.13 吗?其实不然,前面的版本号是编译 kafka 源代码的 Scala 版本。kafka 服务器端的代码完全由 Scala 语言编写,Scala 同时支持面向对象编程和函数式编程,用 Scala 写的源代码编译之后也是普通 ".class" 文件,因此我们说 Scala 是 JVM 系的语言,它的很多设计思想都是为人称道的。

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

回到刚才的版本号讨论,现在你应该知道了对于 kafka_2.12-2.6.0 的提法,真正的 kafka 版本号是 2.6.0,那么这个 2.6.0 又表示什么呢?前面的 2 表示大版本号,即 major version;中间的 6 表示小版本号或者次版本号,即 minor version;最后的 0 表示修订版本号,也就是 patch 号。kafka 社区在发布 1.0.0 版本后特意写过一篇文章,宣布 kafka 版本命名规则正式从 4 位演进到 3 位,比如之前的 0.11.0.0 版本就是 4 位版本号。

安装 kafka

终于到了 kafka 的安装环节了,kafka 的安装非常简单,这里简单说一下。另外 kafka 的源代码虽然是 Scala 语言编写的,但是实际上我们不需要安装 Scala,只需要安装 jdk 即可,版本不建议低于 1.8。

export JAVA_HOME=/opt/jdk1.8.0_221/
export PATH=$JAVA_HOME/bin:$PATH

接下来安装 zookeeper,因为要通过 zookeeper 来管理和协调 kafka 集群,zookeeper 可以去 zookeeper.apache.org 中下载。

export ZOOKEEPER_HOME=/opt/zookeeper-3.4.14
export PATH=$ZOOKEEPER_HOME/bin:$PATH

zookeeper 我们使用的是 3.4.14,配置环境变量之后我们还需要修改一下配置文件,将 /opt/zookeeper-3.4.14/conf 里面的 zoo_sample.cfg 拷贝一份,命名为 zoo.cfg,然后修改 zoo.cfg 即可。

这里修改 dataDir,也就是数据的存储位置,默认存在临时目录中,一旦重启数据就没了。所以这里我们在 zookeeper 的安装目录下创建一个 zkData 目录,让 zookeeper 把数据都存在这里面,当然你可以设置成别的目录。

然后在命令行直接输入 zkServer.sh start 即可启动 zookeeper,启动之后输入 jps 查看相关进程,如果出现 QuorumPeerMain 就代表启动成功了。


最后安装 kafka,这里我们使用社区版的,直接去 kafka.apache.org 下载即可,这里我们直接使用最新版的。

export KAFKA_HOME=/opt/kafka_2.12-2.6.0
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,不过这个 zookeeper 我们不用管,它是 kafka 自带的,我们会使用刚才下载的 zookeeper。而最重要的配置文件则是倒数第四个:server.properties,它和 kafka 集群密切相关,用于配置 kafka 集群的 runtime。

启动 kafka 集群:bin/kafka-server-start.sh config/server.properties,启动的时候需要指定配置文件,就是我们刚才配的 server.properties,因为它负责配置 kafka 集群的运行时。但是需要注意,这种启动方式是以前台方式启动的,如果以后台方式启动的话,那么需要加上 -daemon 参数。

关闭 kafka 集群:bin/kafka-server-stop.sh config/server.properties,无论前台启动还是后台启动,关闭的时候都不需要指定 -daemon 参数。其实 server.properties 也不需要指定,因为是关闭,当然指定了也无所谓。

不过还有一点需要注意,我们说 kafka 依赖于 zookeeper,因为它需要通过 zookeeper 来存储一些配置信息。所以我们要在 zookeeper 启动之后才能启动 kafka,否则就会导致连接 zookeeper 超时,从而导致启动失败。当然我们这里不会失败,因为刚才在 zookeeper 安装完之后就已经启动了。并且 zookeeper 默认监听的端口是 2181,kafka 在连接的时候也会自动去连接本机的 2181 端口,所以默认情况下我们只需要启动 zookeeper 即可。不过我们这里是单机,如果 zookeeper 在别的节点上、或者绑定的端口不是 2181、再或者有多个 zookeeper,那么我们就需要在 server.properties 文件里面进行指定了,配置文件我们后面会详细说。

kafka 核心概念

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

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

向主题发布消息的客户端应用程序被称为生产者(producer),生产者通常持续不断地向一个或多个主题发送消息,而订阅这些主题获取消息进行消费的客户端应用程序就被称为消费者(consumer)。和生产者类似,消费者也能同时订阅多个主题。我们把生产者和消费者统称为客户端(clients)。你可以同时运行多个生产者和消费者实例,这些实例不断地向 kafka 集群中的多个主题生产和消费消息。

在其它消息中间件中,存放消息的地方叫做消息队列,在 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 中追随者副本不会对外提供服务,生产者向主题写的消息总是往领导者那里,消费者向主题获取消息也都是来自于领导者。也就是说,无论是读还是写,针对的都是领导者副本,至于追随者副本,它只做一件事情,那就是向领导者副本发送请求,请求领导者副本把最新生产的消息发送给它,这样便能够保持和领导者的同步,至于为什么做我们后面说。对了,关于领导者和追随者,之前其实是叫做主(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 是如何持久化数据的呢?总的来说,kafka 使用消息日志(log)来保存数据,一个日志就是磁盘上一个只能追加写(append-only)消息的物理文件。因为只能追加写入,故避免了缓慢的随机 I/O 操作,改为性能较好的顺序 I/O 操作,这也是实现 kafka 高吞吐量特性的一个重要手段。不过如果不停地向一个日志写入消息,最终也会耗尽所有的磁盘空间,因此 kafka 必然要定期地删除消息以回收磁盘。怎么删除?简单来说就是通过日志段(log segment)机制。在 kafka 底层,一个日志又进一步细分成多个日志段,消息被追加写到当前最新的日志段中,当写满了一个日志段后,kafka 会自动切分出一个新的日志段,并将老的日志段封存起来。kafka 在后台还有定时任务会定期地检查老的日志段是否能够被删除,从而实现回收磁盘的目的。

这里再重点说一下消费者,之前说过有两种消费模型,即点对点模型(peer to peer,p2p)和发布订阅模型。在点对点模型中我们说可以存在多个消费者,但是同一条消息只能被下游的一个消费者消费,其它消费者不能染指,而这些多个消费者在 kafka 中叫做消费者组(consumer group)。所谓的消费者组,指的是多个消费者实例共同组成一个组来消费一个主题,这个主题中的每个分区都只会被消费者组里面的一个消费者实例消费。至于为什么要引入消费者组,主要是为了提升消费者端的吞吐量,多个消费者实例同时消费,加速了整个消费端的吞吐量(TPS)。关于消费者组的机制,后面会详细介绍,现在只需要知道消费者组就是多个消费者组成一个组来消费主题里面的消息、并且消息只会被组里面的一个消费者消费即可。此外,这里的消费者实例可以是运行消费者应用的进程,也可以是一个线程,它们都称为一个消费者实例(consumer instance)。

消费者组里面的消费者不仅瓜分订阅主题的数据,而且更酷的是它们还能彼此协助。假设组内某个实例挂掉了,kafka 能够自动检测,然后把这个 Failed 实例之前负责的分区转移给其他活着的消费者。这个过程就是大名鼎鼎的 "重平衡(rebalance)"。嗯,其实即是大名鼎鼎,也是臭名昭著,因为由重平衡引发的消费者问题比比皆是。事实上,目前很多重平衡的 bug,整个社区都无力解决。

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

总结:

  • 生产者,producer向主题发布新消息的应用程序。
  • 消费者,consumer从主题订阅新消息的应用程序。
  • 消息,recordkafka 是消息引擎,这里的消息就是指 kafka 处理的主要对象。
  • 主题,topic主题是承载消息的逻辑容器,在实际使用中多用来区分具体的业务,即不同的业务对应不同的主题。
  • 分区,partition一个有序不变的消息序列,每个主题下可以有多个分区。分区编号从 0 开始单调递增,分布在不同的broker上面,实现发布于订阅的负载均衡。生产者将消息发送到主题下的某个分区中,以分区位移(offset)来标识一条消息在一个分区当中的位置(唯一性),分区位移也是一个从 0 开始单调递增的值。
  • 分区位移,offset表示分区中每条消息的位置信息,是一个单调递增且不变的值。
  • 副本,replicakafka 中同一条数据能够被拷贝到多个地方以提供数据冗余,这便是所谓的副本。副本还分为领导者副本和追随者副本,各自有各自的功能职责。读写都是针对领导者副本来的,追随者副本只是用来和领导者副本进行数据同步、保证数据冗余、实现高可用。
  • 消费者位移,consumer offset表示消费者消费进度,即消费到了分区的哪一条消息,每个消费者都有自己的消费者位移。
  • 消费者组,consumer group多个消费者实例共同组成的一个组,同时消费多个分区以实现高吞吐。
  • 重平衡,rebalance消费者组内某个消费者实例挂掉之后,其它消费者实例自动重新分配订阅主题分区的过程,重平衡是 kafka 消费者端实现高可用的重要手段。

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

因为 kafka 的主题已经被分为多个分区,分布在不同的 broker 上,而不同的 broker 又分布在不同的机器上,因此从某种角度来说,kafka 已经实现了负载均衡的效果。不像 MySQL,压力都在主上面,所以才要从读;另外,kafka 保存的数据和数据库的数据有着实质性的差别,kafka 保存的数据是流数据,具有消费的概念,而且需要消费者位移。所以如果支持从读,那么消费端控制 offset 会更复杂,而且领导者副本同步到追随者副本需要时间的,会造成数据不一致的问题;另外对于生产者来说,kafka 是可以通过配置来控制是否等待 follower 对消息确认的,如果支持从读的话,那么也需要所有的 follower 都确认了才可以回复生产者,造成性能下降,而且 follower 出现了问题也不好处理。

配置文件 server.properties 解析

介绍完了 kafka 核心概念之后,我们来看一下配置文件 server.properties,我们说它是和 kafka 集群 runtime 息息相关的。这个配置文件总共一百多行,但是包含了很多的注释,所以实际上配置并不是很多(因为有部分配置没有写在文件里),我们将注释去掉,然后贴出来一个一个介绍。

############################# Server Basics #############################

broker.id=0

############################# Socket Server Settings #############################

#listeners=PLAINTEXT://:9092
#advertised.listeners=PLAINTEXT://your.host.name:9092
#listener.security.protocol.map=PLAINTEXT:PLAINTEXT,SSL:SSL,SASL_PLAINTEXT:SASL_PLAINTEXT,SASL_SSL:SASL_SSL
num.network.threads=3
num.io.threads=8
socket.send.buffer.bytes=102400
socket.receive.buffer.bytes=102400
socket.request.max.bytes=104857600

############################# Log Basics #############################

log.dirs=/tmp/kafka-logs
num.partitions=1
num.recovery.threads.per.data.dir=1

############################# Internal Topic Settings  #############################

offsets.topic.replication.factor=1
transaction.state.log.replication.factor=1
transaction.state.log.min.isr=1

############################# Log Flush Policy #############################

#log.flush.interval.messages=10000
#log.flush.interval.ms=1000

############################# Log Retention Policy #############################

log.retention.hours=168
#log.retention.bytes=1073741824
log.segment.bytes=1073741824
log.retention.check.interval.ms=300000

############################# Zookeeper #############################

zookeeper.connect=localhost:2181
zookeeper.connection.timeout.ms=18000

############################# Group Coordinator Settings #############################

group.initial.rebalance.delay.ms=0

我们看到里面的配置已经按照用途被分成了不同的组,我们从上往下撸。

broker.id:

broker 的唯一 id,默认值为 0,对于每一个 broker 都必须设置为唯一的 "整数",另一台 broker 的话,broker.id=1。

listeners、advertised.listeners、listener.security.protocol.map:

这三个配置放在一起解释会更容易理解,首先看最后一个配置:listener.security.protocol.map,它表示消息在传输时所使用的协议以及协议类型,格式为 "协议:协议类型",其中协议会体现在 listeners 和 advertised.listeners 中。而协议类型有以下几种:

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

当然对于 PLAINTEXT、SSL、SASL_PLAINTEXT、SASL_SSL 而言,它们除了表示协议类型之外,还表示协议本身。比如 PLAINTEXT 协议,该协议的类型是 PLAINTXT(消息明文传输),当然我们还可以自定义协议。

listeners 和 advertised.listeners 都表示监听器,但 listeners 用于内网访问,advertised.listeners 用于外网访问。像我当前使用的机器是阿里云上的 CentOS,有一个内网 ip 和一个外网 ip,内网 ip 是 172.24.60.6,外网 ip 是 47.94.174.89。

假设 kafka 集群只有一个 broker,并且客户端应用也在相同的节点上(这种情况个人觉得只可能发生在学习的过程中),那么只需要配置 listeners 即可,ip 指定为 localhost 或者 127.0.0.1。

# 监听端口 9092,后续就采用明文传输
# 如果配置为 localhost(127.0.0.1),那么客户端只能在 broker 所在的节点上通过 localhost(127.0.0.1)访问
# 该配置是被注释掉的,因为默认监听本机的 9092 端口
listeners=PLAINTEXT://localhost:9092

然后我们就可以在当前节点访问了,并且也只能在当前节点访问,比如使用 Python 充当客户端连接至 broker,但如果在其它机器上则不行。

当客户端不在当前 broker 所在的节点上,但它们都在同一个内网中,那么仍然只需要配置 listeners,只不过此时需要将 ip 指定为内网 ip。

# 如果配置为内网 ip,那么客户端可以在同一内网网段的任意节点上使用内网 ip 进行访问
listeners=PLAINTEXT://172.24.60.6:9092

此时客户端即可通过内网 ip 进行访问,另外,如果指定为内网 ip,那么即使是当前 broker 所在节点的客户端,也要通过内网 ip 访问,不能使用 localhost。这里仍然假设只有一个 broker,后面会说如何搭建由多个 broker 组成的集群。

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

listeners=PLAINTEXT://172.24.60.6:9092
advertised.listeners=PLAINTEXT://47.94.174.89:9092
# 至于 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:

用逗号分隔的一系列目录路径,比如:/home/kafka1,/home/kafka2,/home/kafka3,用于存储日志文件,当然不止日志文件,还有暂存数据也会存在这里面。

不过强烈建议在有条件的情况下将目录挂载到不同的磁盘上,有以下两个好处:

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

默认存储在 /tmp/kafka-logs 目录下,我们知道 /tmp 是临时目录,一旦重启机器,里面的数据就全没了,所以这个参数在生产上是必须要改的。

num.partitions:

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

num.recovery.threads.per.data.dir:

每个 data 目录(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 底层一个日志会被细分成多个日志段,而该配置就表示每一个日志段文件的最大字节,默认值为 1073741824,换算之后是一个 G,当达到这个值时就会创建一个新的日志段,并将老的日志段封存起来。

log.retention.check.interval.ms:

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

zookeeper.connect:

zookeeper 的连接地址,默认值为 localhost:2181,而我们服务器上的 zookeeper 默认也是监听 2181,所以我们当时不设置此参数也能成功启动 kafka。但如果 zookeeper 在其它机器上启动、或者监听的端口不是 2181,那么这里就需要指定了,另外也可以指定多个 zookeeper,之间以逗号分割。

zookeeper.connection.timeout.ms:

连接 zookeeper 的最大超时时间,默认为 18000 毫秒。

group.initial.rebalance.delay.ms:

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

这些配置可以先一个印象,后面遇到了会结合场景详细解释。

topic 相关操作

我们说 kafka 中的消息会保存在主题中,而在介绍 bin 目录的时候我们说里面有一个 kafka-topics.sh 是专门通过命令行来操作主题的,那么下面就来看看它支持哪些操作。

我们之前启动了 kafka,然后又给关闭了,所以这里重新启动,但是启动之前先把配置文件给改一下。

# 当前只有一个节点,把内网 ip 172.24.60.6 改成 localhost 也是可以的
listeners=PLAINTEXT://172.24.60.6:9092  
advertised.listeners=PLAINTEXT://47.94.174.89:9092

别的则不需要动,直接采用默认配置即可,我们目前只是学习用法。

查看所有的 topic:

kafka-topics.sh --list --zookeeper localhost:2181

可以看到,由于我们还没有创建,所以此时还没有主题。这里要指定 --zookeeper,因为主题、分区信息,以及 broker 分布情况都保存在 zookeeper 中。

创建 topic:

kafka-topics.sh --create --zookeeper localhost:2181 --topic 主题名 --partitions 分区数 --replication-factor 副本数

注意:副本数不能超过你 broker 的数量,因为我们只有一台机器,所以副本数是 1,但是分区在一台 broker 上是可以有多个的。

如果创建一个已存在的主题会报错:

提示我们主题 "matsuri" 已存在。

除此之外,我们在创建的时候也可以指定的主题的一些其它属性,比如消息的最大限制。kafka 默认允许的最大消息大小是 1M,但是对于视频或者图片信息来说,1M 显然达不到这个要求,所以我们可以通过 --config 进行指定。

kafka-topics.sh --create --zookeeper localhost:2181 --topic 主题名 --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:

kafka-topics.sh --delete --zookeeper localhost:2181 --topic 主题名

这里我们看到分区 "aqua" 被标记为删除,并且提示我们:如果没有将 delete.topic.enable 设置为 true,那么这个删除操作没有任何作用。而从图中可以看出该分区已经被删除了,当然以防万一我们可以再验证一下,我们再创建一个名为 "auqa" 的分区,如果创建的时候没有报错,那么证明确实被删了,因为创建一个已存在的分区是会报错的。

我们看到创建成功,证明确实被删除了,说明 delete.topic.enable 默认是为 true 的。但是问题来了,这个配置写在哪里呢?答案是写在 server.properties 里面,又是一个新的配置,因为我们说 kafka 提供的配置并不止文件里面的那些。注意:删除的时候不能指定 --config。

除此之外,还有一种方式可以删除 topic,由于 kafka 的主题信息是写在 zookeeper 里面的,我们直接操作 zookeeper 即可。

我们看到通过操作 zookeeper 成功地删除了 kafka 中的主题,不过还是不建议直接操作 zookeeper。

修改 Topic

kafka-topics.sh --alter --zookeeper localhost:2181 --topic 主题名 --partitions 分区数

修改主题的话不能修改副本数,但分区数可以修改,并且修改之后的分区数必须大于原来的分区数,否则报错。

这里我们成功地将分区数从 1 改成了 2,但是给出了一个警告:如果为一个具有 key(后面说)的主题增加了分区,那么分区逻辑或消息顺序将受到影响。如果当前的业务场景对消息顺序有着严格的要求,那么一定要谨慎添加分区,建议将当前主题内的所有消息都消费完毕之后再添加分区。

然后就是我们说的分区数只能增加不能减少,那么为什么会有这个限制呢?

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

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

除了分区数之外,还可以修改 --config 指定的一些配置,比如消息的最大大小。

修改成功,但是提示我们通过 kafka-topics.sh 修改主题的配置(--config)在未来的版本中将被移除,建议我们使用 kafka-configs.sh 替代。

查看某个 topic 的详细信息:

我们通过 --list 可以查看都有哪些主题,但我想查看某个主题的具体信息该怎么办呢?

kafka-topics.sh --describe --zookeeper localhost:2181 --topic 主题名

第一行是对该主题的一个总结:Topic 表示主题名;PartitionCount 表示分区数,这里是我们之前修改的 2;ReplicationFactor 表示副本系数;然后 Configs 不需要解释了,我们之前设置的消息最大大小也体现在上面了,如果没有设置那么就什么也没有。

然后第二行和第三行是一样的,显然分别对应 aqua 的两个分区。其中 Leader 是当前 partitons 中负责读写的节点(broker.id),每个节点都有可能成为 leader;Replicas 是当前副本数据所在的节点(一个列表,存放 broker.id),不管该节点是否是 leader 或者是否存活;Isr 则是当前 kafka 集群中可用的节点的 broker.id 组成的列表。

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

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

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

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

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

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

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

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

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

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

在新版的 kafka 中可以支持批量提交和批量消费,而 producer 中有个参数 batch.size,默认是 16 kb,它会为每个分区缓存消息,一旦满了就打包将消息批量发出。尽管这是个提升性能的设计,但很明显这个参数是分区级别的,而分区数越多,这部分缓存所需的内存占用也会更多;consumer 也是同理,并且分区数越多,消费者的线程数也会增加,而线程切换的开销也是需要考虑的。

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

我们知道 kafka 通过副本(replica)机制来保证高可用,具体做法就是为每个分区保存若干个副本(replica_factor 指定副本数),每个副本保存在不同的 broker 上。其中的一个副本充当 leader 副本,负责处理 producer 和 consumer 请求,而其它副本则充当 follower 角色,由 kafka controller 负责保证与 leader 的同步。但如果某个 broker 挂掉了,那么该 broker 上的领导者副本就无法对外提供服务了,那么 kafka controller 就会借助于 zookeeper 上存储的分区元信息进行 leader 选举,从而在其它的 broker 上选择一个作为领导者副本,并且每个分区都要有这个过程。假设有 10000 个分区,那么就需要为 10000 个分区进行 leader 选举,这显然会花费较长的时间,而在选举完成之前 kafka 是无法对外提供服务的。如果挂掉的 broker 恰好是 kafka controller 所在的节点,那么情况会更糟糕。

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

Python 操作 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='47.94.174.89:9092')

# 查看所有的 topic
print(client.list_topics())  # ['aqua']


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


# 删除主题,一次性可以删除多个
client.delete_topics(topics=["aqua", "mea"],
                     timeout_ms=1000 * 3)
print(client.list_topics())  # ['matsuri']


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

个人觉得使用 python 操作要比使用 kafka-topics.sh 简单一些。

以上就是 kafka 主题相关的操作,还是不难的。

Go 操作 topic

下面再来看看如何使用 Go 操作 kafka 集群上的 topic,Go 操作 kafka 同样需要使用一个第三方包,叫 sarama,直接 go get github.com/Shopify/sarama 即可。

package main

import (
    "encoding/json"
    "fmt"
    "github.com/Shopify/sarama"
)

func main() {
    // 实例化一个 Config 对象,返回其指针,里面是一些连接 kafka 的配置信息
    config := sarama.NewConfig()
    client, err := sarama.NewClusterAdmin([]string{"47.94.174.89:9092"}, config)
    if err != nil {
        panic("连接 kafka 集群失败,失败原因" + err.Error())
    }

    // 查看所有的 topic,返回 map[string]TopicDetail, error
    topics, _ := client.ListTopics()
    // 返回的 map 里面的 key 显然是主题名,TopicDetail 则是一个结构体,里面包含了主题的相关信息
    // type TopicDetail struct {
    //     NumPartitions     int32
    //     ReplicationFactor int16
    //     ReplicaAssignment map[int32][]int32
    //     ConfigEntries     map[string]*string
    // }
    for topic, topicDetail := range topics {
        fmt.Println(fmt.Sprintf("主题名:%s,主题的分区数:%d,副本数:%d",
            topic, topicDetail.NumPartitions, topicDetail.ReplicationFactor))
    }
    /*
       主题名:matsuri,主题的分区数:2,副本数:1
    */

    // 创建 topic,一次只能创建一个
    topicDetail := &sarama.TopicDetail{NumPartitions: 1, ReplicationFactor: 1}
    // 参数一:主题名,参数二:主题属性,参数三:直接写 false 即可,写 true 的话主题不会创建
    _ = client.CreateTopic("nana", topicDetail, false)
    // 再次查看主题
    topics, _ = client.ListTopics()
    for topic, topicDetail := range topics {
        fmt.Println(fmt.Sprintf("主题名:%s,主题的分区数:%d,副本数:%d",
            topic, topicDetail.NumPartitions, topicDetail.ReplicationFactor))
    }
    /*
       主题名:nana,主题的分区数:1,副本数:1
       主题名:matsuri,主题的分区数:2,副本数:1
    */

    // 查看 Topic 的详细信息,可以同时查看多个
    // 会返回 (metadata []*TopicMetadata, err error)
    topicMetaData, _ := client.DescribeTopics([]string{"matsuri", "nana"})
    // 这里为了美观打印,我们转成 json
    info, _ := json.MarshalIndent(topicMetaData, "", "  ")
    // 由于 map 里的每一个元素各占一行,导致行数过多,因此打印的输出略有调整
    fmt.Println(string(info))
    /*
       [
         {
           "Err": 0,
           "Name": "matsuri",
           "IsInternal": false,
           "Partitions": [
             {"Err": 0, "ID": 0, "Leader": 0, "Replicas": [0], 
              "Isr": [0], "OfflineReplicas": null},
             {"Err": 0, "ID": 1, "Leader": 0, "Replicas": [0], 
              "Isr": [0], "OfflineReplicas": null}]
         },
         {
           "Err": 0,
           "Name": "nana",
           "IsInternal": false,
           "Partitions": [
             {"Err": 0, "ID": 0, "Leader": 0, "Replicas": [0], 
              "Isr": [0], "OfflineReplicas": null}]
         }
       ]
    */

    // 删除 topic,一次只能删除一个
    _ = client.DeleteTopic("nana")
    // 再次查看主题
    topics, _ = client.ListTopics()
    for topic, topicDetail := range topics {
        fmt.Println(fmt.Sprintf("主题名:%s,主题的分区数:%d,副本数:%d",
            topic, topicDetail.NumPartitions, topicDetail.ReplicationFactor))
    }
    /*
       主题名:matsuri,主题的分区数:2,副本数:1
    */
}

我们看到 API 的设计、方法名和 kafka-python 非常相似,这确实让人有些意外。

消息的生产与接收

下面我们就来发送和接收消息了,首先在 bin 目录里面有两个 sh 文件:kafka-console-producer.sh 用于生产消息,kafka-console-consumer.sh 用于消费消息,然后看看用法。

启动生产者:

kafka-console-producer.sh --topic 主题 --broker-list ip:9092,注意这里是 --broker-list,也就是 broker 的地址,不是 zookeeper 的地址。

然后我们注意一下 ip,这个 ip 指的是 broker 的 ip,不是 zookeeper 的 ip。由于我们配置的时候指定的是内网 ip,不是 localhost,所以这里也要使用内网 ip,然后我们看到 --broker 后面带上了 -list,所以可以指定 broker,之间以逗号分割。

程序阻塞在这里了,需要我们输入消息。

启动消费者:

kafka-console-consumer.sh --topic 主题 --bootstrap-server ip:9092,这个 ip 同样要指定内网 ip,并且生产者叫 --broker-list,消费者叫 --bootstrap-server。

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

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

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

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

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

这里先关闭消费者,然后生产者又生产了几条消息,再启动消费者。

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

当然消费者组比较简单,我们的重点是消息的顺序性问题。我们看到消费者在启动的时候,生产者发一条,消费者收一条,这没什么问题。但是当指定 --from-beginning 从头开始重新消费的时候,收到的消息的顺序乱掉了,这显然无法容忍,因为消息的顺序啃腚是要保证的。

首先消息乱掉的原因很简单,我们的 matsuri 主题有两个分区,它就是导致消息顺序乱掉的原因,如果只有一个分区,那么消费者在从头开始重新消费的时候,收到的消息百分百是顺序的。那么问题就来了,在有多个分区的情况下,当生产者发送一条消息时,kafka 会将该消息写到哪一个分区中呢?它用的策略是什么呢?以及我们想保证消息的顺序性又该如何做呢?下面我们就来一点一点分析。

kafka 的存储原理

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

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

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

page cache 是由 Linux 内核托管的,假设我们读取文件想只读取 1M,但是 Linux 实际读取的却并不止 1M,因为这样你后续再读取的时候就不需要从磁盘上加载了。因为从磁盘到内存的数据传输速度是很慢的,如果物理内存有空余,那么就可以多缓存一些内容,而这部分用作 "磁盘缓存文件" 的内存就叫做 page cache。

kafka 正是利用顺序 IO,以及 page cache 达到超高的吞吐量。

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

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

现在我们知道 kafka 吞吐量高的原因了,那么下面再来说一说 kafka 的日志文件,因为消息是要被写入到文件中的。假设我们现在 kafka 集群只有一个 broker,我们创建两个主题,名称分别为 topic1 和 topic2,topic1 有一个分区、topic2 有两个分区,那么在我们的文件系统上就会有三个目录。

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

之前说过,在 kafka 的文件存储中,同一个 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 表示数据文件里面的第一条消息是整个分区的第 368770 条消息,或者表示数据文件里面的第一条消息的分区位移是 368769,因为分区位移从 0 开始。所以对于当前数据文件而言,内部的第 N 条消息就是整个分区的第 368769 + N 条消息。

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

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

假设我们要找 offset 为 425314 的消息,那么 kafka 要如何快速定位到这条消息呢?此时索引文件就登场了。我们以索引的文件的第二行为例,里面的内容是 3, 497,它表示如果想读取第三条消息,那么只需要从文件开头向后 seek 497 个字节,再进行读取即可;同理 8, 1686 表示从文件开头向后 seek 1686 个字节,那么会从第八条消息开始读取;而第一行的 1, 0 表示 seek 0 字节即可读取第一条消息,所以 index 文件是从 1 开始记录的。而对于当前的问题,要找偏移量为 425314 的消息,首先要判断它在哪一个文件中,由于 425314 大于 0 并且小于 737337,显然它位于 00000000000000368769.log 中,并且是第 56546 条消息(425314 + 1 - 368769),然后直接去索引文件中查找该消息在数据文件中的偏移量即可。

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

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

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

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

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

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

生产者的消息发送机制

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

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

1. 轮询策略

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

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

2. 随机策略

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

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

3. 按 key 保序策略

kafka 允许为每条消息定义消息键,简称为 key。这个 key 的作用非常大,它可以是一个有着明确业务含义的字符串,比如客户代码、部门编号或是业务 ID 等;也可以用来表征消息元数据。特别是在 kafka 不支持时间戳的年代,在一些场景中,工程师们都是直接将消息创建时间封装进 key 里面的。一旦消息被定义了 key,那么就可以保证拥有相同 key 的所有消息都进入到同一个分区里面,具体做法就是计算 key 的哈希值并对分区数取余,然后根据结果决定消息要进入哪一个分区。并且由于每个分区下的消息处理都是有顺序的,故这个策略被称为按 key 保序策略。

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

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

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

为什么指定 --from-beginning 从头消费时,消息的顺序会乱掉

现在可以解释为什么消息会乱掉了,因为我们没有指定分区,那么消息选择的分区就是随机的。而消费者在从头读取时,会按照分区的顺序将消息依次全部读取,我们举个栗子:

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

需要注意的是,一个分区只会被消费者组里面一个消费者消费,但是反之则不成立。比如我们这里通过控制台启动的消费者,它就消费了两个分区的数据。

做了那么多的铺垫,总算是将这个问题解释清楚了,其实这么做也是刻意而为之。因为铺垫的内容也是我们要重点介绍的,通过一个问题将它们全部引出来。

关于生产者生产消息、消费者消费消息涉及的更多内容,我们后面慢慢说,总之目前我们已经能够通过控制台的方式生产和消费消息了。

Python 充当生产者、消费者

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

kafka-topics.sh --create --zookeeper localhost:2181 --topic lulu --partitions 2 --replication-factor 1

消费者

先来看看消费者:

from kafka import KafkaConsumer
# 实例化一个消费者,或者说启动一个消费者线程
consumer = KafkaConsumer('lulu',  # 消费的主题
                         bootstrap_servers=['47.94.174.89: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)

以上就是消费者的逻辑,非常的简单,一旦获取到消息,就会交给 message,而 message 是一个 ConsumerRecord 对象,在 Python 里面就是一个 namedtuple。

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

消费者编写完毕之后,启动起来。

生产者

然后是生产者:

from kafka import KafkaProducer

# 创建一个生产者
producer = KafkaProducer(bootstrap_servers=['47.94.174.89:9092'])
# 往主题里面发送消息,参数一:主题名,参数二:传递的 value,当然还有其它参数我们后面说
# send 方法会返回一个 FutureRecordMetadata 对象
# 但是注意:此时消息还没有发送,而是写到了缓冲区中
future = producer.send("lulu", "夏色祭".encode("utf-8"))
print(future)  # <kafka.producer.future.FutureRecordMetadata object at 0x000001E02950EC40>
# 我们需要调用 future.get 方法,然后消息才会发送给 broker
# 里面的参数表示超时时间,这里最多等待 5 秒,消息必须在 5 秒内进入主题(分区)然后返回
record = future.get(5)
# 返回的 record 是一个 RecordMetadata 对象
# 我们可以用它来获取相关信息,比如消息写入了哪一个主题、哪一个分区、以及偏移量是多少
print(record.topic)  # lulu
print(record.partition)  # 1
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(
    "lulu", "夏哥你怎么娘们唧唧的".encode("utf-8")
).add_callback(on_send_success).add_errback(on_send_error)
# 返回的 future 仍然是一个
# 注意:此时消息仍然没有发送,我们还是需要调用 future.get 方法,只不过此时就不需要使用 record 变量接收了
# 当消息发送成功时会自动触发回调,函数中 record 参数就是 future.get 的返回值
future.get(5)
# 以下是 on_send_success 函数中的输出
"""
lulu
1
1
"""

# 可能有人发现了,每次都要 future.get 方法有点不方便,因为消息是先写入到缓冲区中
# 如果你不需要查看生产者将消息发送之后,会进入哪一个分区,以及偏移量是多少,只是希望把消息发出去
# 那么可以只调用 send,而无需 future.get
# 只需要在最后再调用一次 producer.flush() 即可将缓冲区中所有的消息都发送出去(后面举例说明)
# 因此可以调用 future.get 写一条发一条,也可以先往缓冲区中写,最后调用 producer.flush 一次性全发出去
# 如果都不调用,那么当缓冲区满了会自动发,显然此时就不可控了
# 因为当缓冲区中有数据,但是又没满的时候,生产者宕机了,那么缓冲区里面的数据就没了

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

我们将生产者再执行一次:

会发现消息分别写入了分区 0 和分区 1 当中,由于分区 1 里面已经有两条消息了,所以第二次写入分区 1 的消息的分区位移(offset)就是 2,当然也是此时的消费者位移。

然后我们还可以将消息发送到指定的分区:

from kafka import KafkaProducer

producer = KafkaProducer(bootstrap_servers=['47.94.174.89:9092'])
# 发送到分区0
producer.send("lulu", "夏色祭参上".encode("utf-8"), partition=0)
producer.send("lulu", "夏色祭参上".encode("utf-8"), partition=0)
producer.send("lulu", "夏色祭参上".encode("utf-8"), partition=0)

# 发送到分区1
producer.send("lulu", "马自立三舅".encode("utf-8"), partition=1)
producer.send("lulu", "马自立三舅".encode("utf-8"), partition=1)
producer.send("lulu", "马自立三舅".encode("utf-8"), partition=1)

# 以上消息还没有发送,这里可以调用 producer.flush() 将缓冲区的消息全发出去
# 同样可以设置超时时间
producer.flush(5)

查看消费者输出:

我们看到消息确实进入了相应分区,只不过顺序有点不对劲,我们上面的生产者明明是先往分区0 里面发的。所以还是之前的问题,producer.send 并没有真正将消息发出去,而是写入了缓冲区中,当我们调用 producer.flush 之后才开始发送,并且是一次性发送,因此我们就无法得知到底是哪一个分区先得到消息。而我们的消费者则是监控主题下的所有分区,哪个分区先有消息,哪个分区里面的消息就先被消费。

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

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

消费者从头开始消费

这里创建一个新的主题,主题名叫 mea:

kafka-topics.sh --create --zookeeper localhost:2181 --topic mea --partitions 2 --replication-factor 1

然后生产者直接发消息:

from kafka import KafkaProducer

producer = KafkaProducer(bootstrap_servers=['47.94.174.89:9092'],
                         # 我们在指定 value 的时候,需要传递字节串,因此可以在创建生产者的时候指定 value_serializer
                         # 该参数接收一个可调用对象,会自动将 value 传递进来进行调用,所以我们后续就不需要手动 encode 了
                         value_serializer=lambda x: bytes(x, encoding="utf-8") if x is not None else x
                         # 同理还有 key_serializer,和 value_serializer 的作用以及用法都相同
                         # 但针对的 key,因为 key 在传递的时候也要求是字节串(或 None)
                         )
# 这里只是介绍这两个参数的用法,但实际工作中不建议用这两个参数
# 如果我们传递的值本身就是一个字节串,那么还需要先 decode,所以建议还是发消息的时候再手动编码
for i in range(1, 20):
    if i % 2 == 0:
        # 发送到分区 0
        producer.send("mea", f"message{i}", partition=0)
    else:
        # 发送到分区 1
        producer.send("mea", f"message{i}", partition=1)

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

启动生产者之后将消息发给 broker、进入主题,但是注意:这里我们还没有启动消费者,如果现在启动消费者的话,那么是什么也收不到的。我们需要指定从头开始消费:

from kafka import KafkaConsumer

consumer = KafkaConsumer(
    'mea',
    bootstrap_servers=['47.94.174.89:9092'],
    # 和生产者的 value_serializer 作用相反,由于得到的是字节串
    # 那么当指定了 value_deserializer 之后,会自动调用进行转化
    # 不过还是建议获取到消息时,再手动转换,个人不怎么用 value_deserializer 这个参数
    # 同理还有 key_deserializer,功能和 value_deserializer 类似,但针对于 key
    value_deserializer=lambda x: str(x, encoding="utf-8") if x is not None 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)

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

顺序和我们预期的一样。消费者可以消费所有的分区,在消费者启动的时候生产者发送消息,那么哪个分区先有消息,消费者就先消费哪个分区;如果是消费者重新启动,从头消费,此时所有分区都有消息,那么会按照分区从小到大顺序依次消费。所以这里是先消费分区 0、再消费分区 1,因此消费消息的顺序就不再是发送消息的顺序了(1、2、3、4、5、6、......)。

然后我们再看看控制台:

消费者组

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

import threading
from kafka import KafkaConsumer


def consume_message():
    consumer = KafkaConsumer(
        'mea',
        # 指定消费者组
        group_id="kagura",
        bootstrap_servers=['47.94.174.89: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()

这里我们创建了一个消费者组 "kagura",里面启动了 4 个消费者线程,但是分区只有两个,所以会有两个线程处于空闲状态。因此同一个消费者组里面的消费者线程数不要超过分区数,因为会有资源浪费,两者相等才是最佳的选择;当然如果分区过多的话,为避免消费者的压力,也可以适当减少线程数,让一个线程消费多个分区,但我们说不会出现一个分区被多个消费者(同一个消费者组中的)消费。

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

我们看到确实是一个线程消费一个分区,并且每个线程都是从头开始消费的,只不过分区数为 2,所以我们只用了两个线程,剩余的两个没用上。而每个分区里面有 10 条消息所以还是打印 20 行。

然后再看一下打印,我们发现是 consumer2 先打印、consumer1 后打印,不过这就不是我们需要关心了,因为默认情况下(不使用锁之类的操作)多个线程之间是随机的,无法得知谁先输出。

如果我们将上面的 group_id 给去掉,那么会发生什么呢?我们说不指定 consumer group 的时候,会默认创建一个不重名的 consumer group,所以我们会创建 4 个消费者组。而我们之前说分区只能同时被多个消费者中的一个消费者消费,但这前提是多个消费者位于同一个消费者组,如果是位于不同的消费者组,那么是可以消费同一个分区的。所以我们将 group_id 去掉之后启动,终端会打印 80 条消息,因为总共 20 条消息,4 个消费者都打印,可以自己尝试一下。

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

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

要搞清楚这一点,我们需要了解和 kafka 消息的提交与确认相关的内容,至于具体细节后需介绍。我们先来说一下如何使用 Go 来对消息进行发送与接收。

Go 充当生产者、消费者

介绍完 Python 收发消息之后,再看 Go 就简单多了,我们还是创建一个新的主题:

kafka-topics.sh --create --zookeeper localhost:2181 --topic fubuki --partitions 2 --replication-factor 1

然后编写消费者:

package main

import (
    "fmt"
    "github.com/Shopify/sarama"
)

func main() {
    config := sarama.NewConfig()
    // 创建消费者
    consumer, err := sarama.NewConsumer([]string{"47.94.174.89:9092"}, config)
    if err != nil {
        panic("创建消费者失败,失败原因" + err.Error())
    }

    // fmt.Println(consumer.Topics()) 查看当前都有哪些主题,返回 ([]string, error)
    // fmt.Println(consumer.Partitions("topic")) 查看某个主题下都有哪些分区,返回 ([]int32, error)

    // 重点是消费消息,Go 里面需要显示地指定分区,参数一:主题、参数二:分区、参数三:分区位移
    // 注意这里的分区位移:-1 相当于 latest、-2 相当于 earliest,其余则是指定的分区位移(大于等于 0)
    // 我们看到相比 Python,Go 的消费者在接收消息的时候更可控一些
    partition0, _ := consumer.ConsumePartition("fubuki", 0, -1)
    defer partition0.AsyncClose()  // 记得关闭
    // partition0.Messages() 会返回一个只读 channel:<-chan *ConsumerMessage
    // 我们只需要遍历这个 channel 即可不断地拿到消息,而消息在这里显然就是一个 ConsumerMessage 结构体实例
    // 至于 ConsumerMessage 内部有哪些属性可以点进源码中查看
    for message := range partition0.Messages() {
        fmt.Printf("value:%s,partition:%d,offset:%d\n",
            string(message.Value), message.Partition, message.Offset)
    }

    // 我们这里只读了一个分区,你也可以先通过 consumer.Partitions("topic") 获取所有的分区
    // 然后循环遍历每一个分区,为每个分区创建一个 ConsumePartition,然后消费消息(要开启 goroutine)
}

消费者编写完毕,启动起来,然后编写生产者:

package main

import (
    "fmt"
    "github.com/Shopify/sarama"
    "strconv"
)

func main() {
    config := sarama.NewConfig()
    // 我们下面会创建一个生产者 producer,调用 producer.Input() 方法会返回一个写 channel
    // 只需要往这个 channel 里面写消息,便可发给 broker
    // 在 Go 里面,生产者发送的消息就是一个 sarama.ProducerMessage 实例(指针)
    // 但问题是我们怎么知道这条消息是否成功发给了 broker 呢?
    // 所以 producer.Successes()、producer.Errors 也分别会返回 channel,假设叫 successChan、errorChan
    // 如果消息发送成功,那么会获取成功写入的消息的所有相关信息
    // 然后根据这些信息创建一个新的 *sarama.ProducerMessage,然后发到 successChan 中
    /* 如果消息发送失败,就会把发送失败的原始消息(*sarama.ProducerMessage)和失败原因(error)打包起来
       得到 *sarama.ProducerError,塞到 errorChan 中。*/
    /* 通过 successChan 和 errorChan 我们即可判断消息是否成功发送,
       但前提是 config.Producer.Return.Successes 和 config.Producer.Return.Errors 必须为 true。*/
    // 如果为 false,那么 successChan、errorChan 里面不会有任何东西,我们就不知道这条消息是否发成功了
    config.Producer.Return.Successes = true
    config.Producer.Return.Errors = true
    // 我们说 kafka 是有分区策略的,在 Go 里面必须显式地指定
    // config.Producer.Partitioner = sarama.NewRandomPartitioner,随机选择分区
    // config.Producer.Partitioner = sarama.NewRoundRobinPartitioner,轮询选择分区
    // config.Producer.Partitioner = sarama.NewHashPartitioner,通过 key 的哈希值选择分区
    // config.Producer.Partitioner = sarama.NewManualPartitioner,手动选择分区
    config.Producer.Partitioner = sarama.NewManualPartitioner
    // 创建生产者
    producer, err := sarama.NewAsyncProducer([]string{"47.94.174.89:9092"}, config)
    if err != nil {
        panic("创建生产者失败,失败原因" + err.Error())
    }

    for i := 0; i < 10; i++ {
        // 生产者发送的消息就是一个 ProducerMessage 实例(指针)
        // 然后在里面我们指定了 Partition,表示手动选择分区,但前提必须将分区策略设置为 sarama.NewManualPartitioner
        // 否则指定 Partition 就没有任何意义,所以在 Go 里面,发消息之前要先显式地指定分区策略
        // 如果想按照 key 的哈希值选择分区,那么就将分区策略设置为 sarama.NewHashPartitioner 并指定 Key 即可
        // 但此时 Partition 会失效,因为分区策略变了
        producer.Input() <- &sarama.ProducerMessage{Topic: "fubuki", Partition: 0,
            Value: sarama.StringEncoder("message" + strconv.Itoa(i))}

        // 因为 successChan、errorChan 的容量是有限的,我们必须在发完消息后进行读取
        // 不然里面的内容会越积越多,直到超过容量上限,进入阻塞
        select {
        // message 也是一个 *sarama.ProducerMessage,可以通过 message.Offset 获取消息在写入之后的分区位移
        // 当然也可以通过 message.Partition、message.Value 查看写入的消息的分区、消息内容等等
        case message := <-producer.Successes():
            fmt.Printf("分区:%d,偏移量:%d,消息内容:%s\n", message.Partition, message.Offset, message.Value)
        // err 是一个 *ProducerError,消息发送失败,可以通过 err.Msg 拿到发送失败的原始消息,也可以通过 err.Error() 查看失败原因
        case err := <-producer.Errors():
            fmt.Println(err.Error())
        }
    }
}

启动生产者发送消息,然后查看输出:

消息发出去了,然后再来看看消费者的输出:

整体流程是没有问题的,当然我们这里只往分区0 里面发了消息,至于往多个分区发也是很简单的。

消费者从头开始消费

在 Go 里面消费者从头开始消费非常简单,我们之前已经说过了。

consumer.ConsumePartition("fubuki", 0, -1)

在调用 ConsumePartition 方法时指定的第三个参数表示分区位移,或者理解为偏移量也行。总之它就是负责指定消费者从哪一个消息开始消费:

  • -1:latest,从最新的位置开始消费,接收不到历史消息
  • -2:earliest,从最老的位置开始消费,可以接收到历史消息
  • 其它大于 0 的整数:从指定的位置开始消费

比较简单,可以自己试一下。

消费者组

下面来看一下在 Go 里面如何创建消费者组:

package main

import (
    "context"
    "fmt"
    "github.com/Shopify/sarama"
    "sync"
    "time"
)

// 消费者组中的每一个消费者都是 ConsumerGroupHandler,而 ConsumerGroupHandler 是一个接口
// 我们需要自定义一个结构体,然后实现内部的几个方法:Setup、Cleanup、ConsumeClaim
type consumerGroupHandler struct{
    ok bool
}

// Setup 会最先调用,做一些初始化工作
func (c *consumerGroupHandler) Setup(_ sarama.ConsumerGroupSession) error {
    fmt.Printf("消费者正在准备当中\n")
    return nil
}

// Cleanup 最后调用,做一些清理工作
func (c *consumerGroupHandler) Cleanup(_ sarama.ConsumerGroupSession) error {
    fmt.Printf("消费者正在打扫战场\n")
    return nil
}

// ConsumeClaim 负责具体的消费逻辑
func (c *consumerGroupHandler) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
consumerLabel:
    for {
        select {
        case message := <-claim.Messages():
            fmt.Printf("分区:%d,内容:%s,分区位移:%d\n",
                message.Partition, sarama.StringEncoder(message.Value), message.Offset)
        case <-time.After(time.Second * 30):
            // 30 秒内收不到消息,直接退出
            c.ok = true
            break consumerLabel
        }
    }
    return nil
}

func main() {
    config := sarama.NewConfig()
    ctx := context.Background()
    // 设置偏移量,消费者也可以通过这种方式设置偏移量,不过没太大用,我们会在调用 ConsumePartition 方法是指定
    // 但消费者组是需要提前指定好的
    config.Consumer.Offsets.Initial = sarama.OffsetNewest // 设置为 -1,从最新的位置开始消费,默认值也是 -1
    // 创建消费者组
    consumerGroup, err := sarama.NewConsumerGroup([]string{"47.94.174.89:9092"}, "my_group", config)
    if err != nil {
        panic("创建消费者组失败,失败原因" + err.Error())
    }
    defer func() {
        err := consumerGroup.Close()
        if err != nil {
            fmt.Printf("关闭消费者组出现异常:%s\n", err.Error())
        }
    }()
    var wg sync.WaitGroup
    wg.Add(1)
    c := &consumerGroupHandler{}
    go func() {
        defer wg.Done()
        for {
            // 启动消费者进行消费,第三个参数是 consumerGroupHandler 实例,会依次调用内部的 Setup、ConsumeClaim、Cleanup
            // 注意:里面会自动计算分区数量,然后开启合适的 goroutine
            err = consumerGroup.Consume(ctx, []string{"fubuki"}, c)
            // 我们外面套了层 for 循环,原因是当发生重平衡时,消息就会终止消费,因此写一个死循环
            // 但如果是 30s 内没有消息,那么 ok 会变成 true
            if c.ok {
                break
            }
        }
    }()
    wg.Wait()
    _ = consumerGroup.Close()
    fmt.Println("=========================")
}

然后编写生产者发消息:

package main

import (
    "fmt"
    "github.com/Shopify/sarama"
    "strconv"
)

func main() {
    config := sarama.NewConfig()
    config.Producer.Return.Successes = true
    config.Producer.Return.Errors = true
    config.Producer.Partitioner = sarama.NewManualPartitioner
    producer, err := sarama.NewAsyncProducer([]string{"47.94.174.89:9092"}, config)
    if err != nil {
        panic("创建生产者失败,失败原因" + err.Error())
    }

    for i := 0; i < 10; i++ {
        if i % 2 == 0 {
            producer.Input() <- &sarama.ProducerMessage{Topic: "fubuki", Partition: 0,
                Value: sarama.StringEncoder("message" + strconv.Itoa(i))}
        } else {
            producer.Input() <- &sarama.ProducerMessage{Topic: "fubuki", Partition: 1,
                Value: sarama.StringEncoder("message" + strconv.Itoa(i))}
        }
        select {
        case message := <-producer.Successes():
            fmt.Printf("分区:%d,偏移量:%d,消息内容:%s\n", message.Partition, message.Offset, message.Value)
        case err := <-producer.Errors():
            fmt.Println(err.Error())
        }
    }
}

然后我们先启动消费者,再启动生产者,看看输出。生产者我们就不看了,因为不看也知道输出是啥,我们重点看看消费者是否正常。由于我们前已经分区 0 里面发了 10 条消息,所以接下来分区位移就应该从 10 开始,而分区 1 则是从 0 开始(之前没往里面发消息)。

和我们的预期是一样的,以上就是 Go 的消费者组,总的来说还是不难的。另外我们发现使用 Go 进行并发编程真的是太方便了,真的有种 goroutine + channel 在手,天下我有的感觉。

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 秒自动提交一次:

而我们之前生产者是向两个分区各自发送 10 条数据,消费者在消费完数据之后,会将两个分区的 offset = 9 提交,并且指向 offset = 10。后续消费者即使想从头消费,也只能从 offset = 10 的位置开始消费,因为我们说已提交的 offset 以及之前的消息就没法再消费了,而 offset = 10 的位置还没有消息,所以重启之后就什么也没有收不到了。

因此我们之前的问题就算解释清除了,但只解释了一半,因为我们换一个消费者组就又能消费了,这又是为啥呢。很明显,提交位移数据是针对消费者组而言的,因为一个分区可以被多个消费者组消费,消费者 A 提交位移数据和消费者组 B 没有任何关系。组 A 里面的消费者提交之后,broker 就会记住该组提交的位移数据(commit_offset),后续再消费的时候,如果消费者是组 A 里面的,那么一律从 commit_offset + 1 的位置开始消费。但这是组 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 之间的消息就有可能被重复消费。因此你对位移提交的管理直接影响了你的消费者所能提供的消息语义保障。

我们来测试一下:

from kafka import KafkaProducer

producer = KafkaProducer(bootstrap_servers=['47.94.174.89:9092'])
# 这里为了测试将 fubuki 主题删掉,然后重新创建
# 分别往分区里面各发 10 条消息
for i in range(10):
    producer.send("fubuki", f"message{i}".encode("utf-8"), partition=0)
    producer.send("fubuki", f"message{i}".encode("utf-8"), partition=1)

producer.flush()

然后消费者进行测试:

from kafka import KafkaConsumer, TopicPartition, OffsetAndMetadata


def consume_message():
    consumer = KafkaConsumer(
        'fubuki',
        # 指定消费者组
        group_id="hololive",
        bootstrap_servers=['47.94.174.89:9092'],
        # 将自动提交改成 False,在不手动提交的情况下,每次都可以从头消费
        enable_auto_commit=False,
        # auto_commit_interval_ms 表示每个多长时间自动提交一次,默认是 5 秒
        # 我们将自动提交设置为 False 即可,这个参数不需要关心
        # 但我想说的是,如果消费者在 5 秒内将消息都消费完毕、并且立刻暂停消费者,那么重启之后还是消费到消息的
        # 因为在消费者提交之前,程序终止了,这种情况也是可能发生的,但前提是数据量比较少的情况下
        # 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,分区 1 的位移提交为 8
        if info["partition"] == 0 and info["offset"] == 9:
            consumer.commit({TopicPartition("fubuki", 0): OffsetAndMetadata(6, "")})
        if info["partition"] == 1 and info["offset"] == 9:
            consumer.commit({TopicPartition("fubuki", 1): OffsetAndMetadata(8, "")})


consume_message()

我们指定了一个新的消费者组,所以此时消息会从头全部消费掉:

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

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

同理我们可以使用 Go 创建消费者再测试一下,但是需要先将 Python 启动的消费者停掉,因为我们指定的是相同的消费者组:

package main

import (
    "context"
    "fmt"
    "github.com/Shopify/sarama"
    "sync"
    "time"
)

type consumerGroupHandler struct{
    ok bool
}
func (c *consumerGroupHandler) Setup(_ sarama.ConsumerGroupSession) error {
    fmt.Printf("消费者正在准备当中\n")
    return nil
}
func (c *consumerGroupHandler) Cleanup(_ sarama.ConsumerGroupSession) error {
    fmt.Printf("消费者正在打扫战场\n")
    return nil
}
func (c *consumerGroupHandler) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
consumerLabel:
    for {
        select {
        case message := <-claim.Messages():
            fmt.Printf("分区:%d,内容:%s,分区位移:%d\n",
                message.Partition, sarama.StringEncoder(message.Value), message.Offset)
            // 重新提交位移数据,两种方式:session.MarkOffset 和 session.ResetOffset
            // 前者需要新提交的位移数据比之前的大;后者则没有要求,可以原来的大、也可以比原来的小
            if message.Offset == 9{
                // 均重置为 5
                session.ResetOffset("fubuki", 0, 5, "")
                session.ResetOffset("fubuki", 1, 5, "")
                session.Commit()  // 提交,在 Go 里面是先重置偏移量,然后再提交
            }
        case <-time.After(time.Second * 30):
            c.ok = true
            break consumerLabel
        }
    }
    return nil
}

func main() {
    config := sarama.NewConfig()
    ctx := context.Background()
    config.Consumer.Offsets.Initial = sarama.OffsetOldest // 设置为 -2,从最老的位置开始消费
    // 注意:Go 的消费者也是自动提交的,config.Consumer.Offsets.AutoCommit.Enable 默认为 true
    // 并且默认每隔 1 秒自动提交一次,可以通过 config.Consumer.Offsets.AutoCommit.Interval 进行设置
    // 如果想手动提交,那么将 config.Consumer.Offsets.AutoCommit 设置为 false 即可
    config.Consumer.Offsets.AutoCommit.Enable = false
    // 创建消费者组,注意 groupId 不能变,否则就没意义了
    consumerGroup, err := sarama.NewConsumerGroup([]string{"47.94.174.89:9092"}, "hololive", config)
    if err != nil {
        panic("创建消费者组失败,失败原因" + err.Error())
    }
    defer func() {
        err := consumerGroup.Close()
        if err != nil {
            fmt.Printf("关闭消费者组出现异常:%s\n", err.Error())
        }
    }()
    var wg sync.WaitGroup
    wg.Add(1)
    c := &consumerGroupHandler{}
    go func() {
        defer wg.Done()
        for {
            err = consumerGroup.Consume(ctx, []string{"fubuki"}, c)
            if c.ok {
                break
            }
        }
    }()
    wg.Wait()
    _ = consumerGroup.Close()
    fmt.Println("=========================")
}

我们看一下输出的内容:

结果是正确的,并且 Go 默认也是自动提交的,并且时间是 1 秒钟提交一次。同理 Go 也可以提交位移数据,可以调大、也可以调小,这里我们均重置为 5,然后再使用 Python 进行消费(代码不变),看看所谓 "从头开始" 的这个 "头" 是不是 5。

我们看到真的都变成 5 了,当然 Python 在消费完数据之后,又会将分区 0 重置为 6,分区 1 重置为 8。

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

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

自动提交与手动提交

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

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

反观手动提交位移,它的好处就在于更加灵活,你完全能够把控位移提交的时机和频率。但也有缺陷,就是当发生网络抖动、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=['47.94.174.89:9092'])
producer.send("fubuki", f"message{i}".encode("utf-8"), partition=0)

producer.flush()

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

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

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

生产者、broker 参数设置

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

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

Python:

producer.send("topic", b"value", partition=0).add_callback(success_cb).add_errback(error_cb)

Go:

config.Producer.Return.Successes = true
config.Producer.Return.Errors = true

producer.Input() <- &sarama.ProducerMessage{Topic: "topic", Value: sarama.StringEncoder("value")}

select {
    case message := <-producer.Successes():
    case err := <-producer.Errors():
}

2. 设置 acks

acks 是 producer 的一个参数,代表了你对 "已提交" 消息的定义。

  • 设置成 0:不返回任何 Response,消息是否发送成功并不知道
  • 设置成 1:消息写入到指定 broker 之后返回 Response
  • 设置成 -1:消息写入到指定 broker、并被同步到其它的所有 broker 之后返回 Response

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

Python:

producer = KafkaProducer(bootstrap_servers=['47.94.174.89:9092'],
                         acks=-1)

Go:

config := sarama.NewConfig()
config.Producer.RequiredAcks = -1

3. 设置 retries

设置 retries 为一个较大的值,这里的 retries 同样是 producer 的参数,对也就是 producer 的自动重试。当出现网络的瞬时抖动时,消息发送可能会失败,此时配置了 retries > 0 的 producer 能够自动重试消息发送,避免消息丢失,默认为 0。

Python:

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

Go:

config := sarama.NewConfig()
// Go 的默认值为 3
config.Producer.Retry.Max = 8

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

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

5. 设置 replication.factor >= 3

这是 broker 端的参数,显然我们为了消息不丢失,最好多保存几份,前提是要有足够数量的 broker。

6. 设置 min.insync.replicas > 1

这是 broker 端的参数,控制的是消息至少要被写入到多少个副本才算是 "已提交",设置成大于 1 可以提升消息持久性,在实际环境中千万不要使用默认值 1。

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

如果两者相等,那么只要有一个副本挂机,整个分区就无法正常工作了。我们不仅要改善消息的持久性,防止数据丢失,还要在不降低可用性的基础上完成。推荐设置成 replication.factor = min.insync.replicas + 1,允许最多有一台副本挂机。

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

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

一个小问题

其实 kafka 还有一个特别隐秘的消息丢失场景,就是增加分区。我们知道生产者、消费者都启动时,消费者是能及时收到生产者发送的消息的,无论生产者往哪个分区里写,消费者都能收到,因为消费者已经监控了所有的分区。但如果在中途临时增加了一个分区(生产者、消费者仍处于运行状态),那么可能会出现 producer 率先感知到新增加的分区,并往里面写入了 N 条消息之后,消费者之后才感知到新分区的存在。如果消费者配置的还是从最新的位置(latest,分区位移为 N)开始消费,那么这样的话生产者往新分区写入的 N 条消息,消费者就收不到了(相当于生产者先发了 N 条消息之后消费者才启动)。不过这不是我们的问题,而是 kafka 设计上的缺陷,但不管怎样这终究是一个问题,你要如何解决呢?

kafka 拦截器

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

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

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

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

生产者拦截器允许你在发送消息前以及消息提交成功后植入你的拦截器逻辑;而消费者拦截器支持在消费消息前以及提交位移后编写特定逻辑。值得一提的是,这两种拦截器都支持链的方式,即你可以将一组拦截器串连成一个大的拦截器,kafka 会按照添加顺序依次执行拦截器逻辑。

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

那么下面我们来看看 Go 里面如何实现拦截器。

package main

import (
    "fmt"
    "github.com/Shopify/sarama"
    "strconv"
)

// 生产者拦截器在 Go 里面是一个接口 ProducerInterceptor
// 我们需要实现里面的 OnSend 方法
type producerInterceptor1 struct{}

type producerInterceptor2 struct{}

func (p *producerInterceptor1) OnSend(message *sarama.ProducerMessage) {
    content, _ := message.Value.Encode()
    fmt.Printf("拦截器 producerInterceptor1 被访问,消息内容:%s\n", string(content))
}
func (p *producerInterceptor2) OnSend(message *sarama.ProducerMessage) {
    content, _ := message.Value.Encode()
    fmt.Printf("拦截器 producerInterceptor2 被访问,消息内容:%s\n", string(content))
}

func main() {
    config := sarama.NewConfig()
    config.Producer.Return.Successes = true
    config.Producer.Return.Errors = true
    config.Producer.Partitioner = sarama.NewManualPartitioner
    // 指定生产者拦截器
    config.Producer.Interceptors = []sarama.ProducerInterceptor{&producerInterceptor1{}, &producerInterceptor2{}}
    producer, err := sarama.NewAsyncProducer([]string{"47.94.174.89:9092"}, config)
    if err != nil {
        panic("创建生产者失败,失败原因" + err.Error())
    }

    for i := 0; i < 10; i++ {
        producer.Input() <- &sarama.ProducerMessage{Topic: "fubuki", Partition: 0,
            Value: sarama.StringEncoder("message" + strconv.Itoa(i))}
        select {
        case <-producer.Successes():
        case <-producer.Errors():
        }
    }
}

这里我们只是简单的打印,就不做复杂的操作了,看看输出:

注意:拦截器只是让你对消息进行一些修饰,比如添加一些附加信息等等,我们不应该修改消息的内容。

同理消费者拦截器也很简单,对应的接口是 ConsumerInterceptor,我们只需要实现内部的 OnConsume 方法即可。该方法在消息返回给 consumer 之前调用,也就是说在开始正式处理消息之前,拦截器会先拦一道,搞一些事情,之后再返回给你。

package main

import (
    "context"
    "fmt"
    "github.com/Shopify/sarama"
    "sync"
    "time"
)

type consumerGroupHandler struct{
    ok bool
}
func (c *consumerGroupHandler) Setup(_ sarama.ConsumerGroupSession) error {
    return nil
}
func (c *consumerGroupHandler) Cleanup(_ sarama.ConsumerGroupSession) error {
    return nil
}
func (c *consumerGroupHandler) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
consumerLabel:
    for {
        select {
        case <-claim.Messages():
        case <-time.After(time.Second * 30):
            c.ok = true
            break consumerLabel
        }
    }
    return nil
}

type consumerInterceptor1 struct {}
type consumerInterceptor2 struct {}

func (c *consumerInterceptor1) OnConsume(message *sarama.ConsumerMessage) {
    fmt.Println("拦截器 consumerInterceptor1 被访问")
}
func (c *consumerInterceptor2) OnConsume(message *sarama.ConsumerMessage) {
    fmt.Println("拦截器 consumerInterceptor2 被访问")
}

func main() {
    config := sarama.NewConfig()
    ctx := context.Background()
    config.Consumer.Offsets.Initial = sarama.OffsetOldest
    // 定义消费者拦截器
    config.Consumer.Interceptors = []sarama.ConsumerInterceptor{&consumerInterceptor1{}, &consumerInterceptor2{}}
    consumerGroup, err := sarama.NewConsumerGroup([]string{"47.94.174.89:9092"}, "test", config)
    if err != nil {
        panic("创建消费者组失败,失败原因" + err.Error())
    }
    defer func() {
        err := consumerGroup.Close()
        if err != nil {
            fmt.Printf("关闭消费者组出现异常:%s\n", err.Error())
        }
    }()
    var wg sync.WaitGroup
    wg.Add(1)
    c := &consumerGroupHandler{}
    go func() {
        defer wg.Done()
        for {
            err = consumerGroup.Consume(ctx, []string{"fubuki"}, c)
            if c.ok {
                break
            }
        }
    }()
    wg.Wait()
    _ = consumerGroup.Close()
    fmt.Println("=========================")
}

当前这个主题里面的消息已经很多了,这里就不截图了,至于程序肯定是会走拦截器的。

拦截器确实比较冷门,但你可以发挥你的聪明才智将其合适地应用在你的项目上。

消息的幂等性以及事务

接下来我们讨论一下 kafka 消息交付可靠性保障以及精确处理一次语义的实现,所谓的消息交付可靠性保障,是指 klafka 对 producer 和 consumer 要处理的消息提供什么样的承诺。常见的承诺有以下三种:

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

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

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

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

那么问题来了,kafka 是怎么做到精确一次的呢?简单来说,这是通过两种机制:幂等性(Idempotence)和事务(Transaction)。它们分别是什么机制?两者是一回事吗?要回答这些问题,我们首先来说说什么是幂等性。

幂等性

什么是幂等性(Idempotence)?

幂等这个词原是数学领域中的概念,指的是某些操作或函数能够被执行多次,但每次得到的结果都是不变的。我来举几个简单的例子说明一下:比如在乘法运算中,让数字乘以 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.0 版本引入的新功能。在此之前,kafka 向分区发送数据时,可能会出现同一条消息被发送了多次,导致消息重复的情况。在 0.11 之后,指定 producer 幂等性的方法很简单,仅需要设置一个参数即可(准确来说是三个,因为会间接导致其它两个参数也必须设置)。

config := sarama.NewConfig()
config.Producer.Idempotent = true
// sarama.NoResponse = 0,不返回任何的 Response
// sarama.WaitForLocal = 1,领导者副本写完之后再返回
// sarama.WaitForAll = -1,领导者副本写完、以及 ISR 中的追随者副本将消息同步之后再返回(ISR 是什么,后面解释)
// 必须设置为 sarama.WaitForAll
config.Producer.RequiredAcks = sarama.WaitForAll
// 一个连接在发送之前允许有多少未完成的请求,必须设置为 1
config.Net.MaxOpenRequests = 1

config.Producer.Idempotent 被设置成 true 后,producer 自动升级成幂等性 producer,其它所有的代码逻辑都不需要改变,kafka 自动帮你做消息的重复去重。底层具体的原理很简单,就是经典的用空间去换时间的优化思路,在 broker 端多保存一些字段。当 producer 发送了具有相同字段值的消息后,broker 能够自动知晓这些消息已经重复了,于是可以在后台默默地把它们丢弃掉。当然,实际的实现原理并没有这么简单,但你大致可以这么理解。

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

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

幂等性 Producer 实现原理

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

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

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

而 kafka 在写入消息时如果发现该消息的 ProducerID、SequenceNumber 和上一条消息都一样的话,那么就不会再写入了。因为发生网络抖动进行重试的时候,发的消息消息还是之前已经发过的消息,ProducerID、SequenceNumber 是不变的,如果是新的消息那么 SequenceNumber 会自增 1,因此通过 ProducerID、SequenceNumber 便可实现幂等。

因为分区是独立的,每个分区都有自己的 SequenceNumber,所以幂等无法跨分区,因为不同的分区的消息的 SequenceNumber 可以相同。另外,一个分区中,除了 SequenceNumber,ProducerID 也要一样,这样消息才能算重复,因此也说明了幂等 producer 不能跨会话,也就是说必须是同一个 producer 进程,否则 ProducerID 就不一样了。

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

事务

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

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

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

kafka 自 0.11 版本开始也提供了对事务的支持,目前主要是在 read committed 隔离级别上做事情。它能保证多条消息原子性地写入到目标分区,同时也能保证 consumer 只能看到事务成功提交的消息。下面我们就来看看 kafka 中的事务型 producer。

事务型 Producer

事务型 producer 能够保证将消息原子性地写入到多个分区中,这批消息要么全部写入成功,要么全部失败。另外事务型 producer 也不惧进程的重启,producer 重启回来后,Kafka 依然保证它们发送消息的精确一次处理。

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

kafka 副本机制详解

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

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

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

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

副本定义

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

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

接下来我们来看一张图,它展示的是一个有 3 台 broker 的 kafka 集群上的副本分布情况。从这张图中,我们可以看到,主题 1 分区 0 的 3 个副本分散在 3 台 broker 上,其他主题分区的副本也都散落在不同的 broker 上,从而实现数据冗余。

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

副本角色

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

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

第一,在 kafka 中,副本分成两类:领导者副本(Leader Replica)和追随者副本(Follower Replica)。每个分区在创建时都要选举一个副本,成为领导者副本,其余的副本自动成为追随者副本。

第二,Kafka 的副本机制比其他分布式系统要更严格一些,在 kafka 中,追随者副本是不对外提供服务的,也就是说任何一个追随者副本都不能响应消费者和生产者的读写请求。所有的请求都必须由领导者副本来处理,或者说所有的读写请求都必须发往领导者副本所在的 broker,由该 broker 负责处理。追随者副本不处理客户端请求,它唯一的任务就是从领导者副本 "异步拉取" 消息,并写入到自己的提交日志中,从而实现与领导者副本的同步。

第三,当领导者副本挂掉了,或者说领导者副本所在的 broker 宕机时,kafka 依托于 zookeeper 提供的监控功能可以实时感知到,并立即开启新一轮的领导者选举,从追随者副本中选一个作为新的领导者。老的 leader 副本重启回来后,只能作为追随者副本加入到集群中。

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

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

1. 方便实现 Read-your-writes

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

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

2. 方便实现单调读(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 这一个副本。

那么,到底什么副本能够进入到 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 为空,那么只能选择不在 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 定义了很多类似的请求格式,截止到目前最新的 2.8 版本,kafka 共定义了多达 45 种请求格式,并且所有的请求都是通过 TCP 网络以 Socket 的方式进行通讯的。下面就来详细讨论一下 kafka broker 端处理请求的全流程。

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

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

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

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

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

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

既然这两种方案都不好,那么,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 个网络线程,专门处理客户端发送的请求

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

好了,现在我们了解了客户端发来的请求会被 broker 端的 Dispatcher 线程分发到任意一个网络线程中,由它们来进行处理。那么,当网络线程接收到请求后,它是怎么处理的呢?你可能会认为,它顺序处理不就好了吗?实际上,Kafka 在这个环节又做了一层异步线程池的处理,我们一起来看一看下面这张图。

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

IO 线程池处中的线程才是执行请求逻辑的线程,broker 端参数 num.io.threads 控制了这个线程池中的线程数。目前该参数默认值是 8,表示每台 broker 启动后自动创建 8 个 IO 线程处理请求,你可以根据实际硬件条件设置此线程池的个数。

比如,如果你的机器上 CPU 资源非常充裕,你完全可以调大该参数,允许更多的并发请求被同时处理。当 IO 线程处理完请求后,会将生成的响应发送到网络线程池的响应队列中,然后由对应的网络线程负责将 Response 返回给客户端。

细心的你一定发现了请求队列和响应队列的差别:请求队列是所有网络线程共享的,而响应队列则是每个网络线程专属的。这么设计的原因就在于,Dispatcher 只是用于请求分发而不负责响应回传,因此只能让每个网络线程自己发送 Response 给客户端,所以这些 Response 也就没必要放在一个公共的地方。

我们再来看看刚刚的那张图,图中有一个叫 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 配置,显式地指定哪套端口用于处理哪类请求。

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

之前我们聊到过消费者组的重平衡流程,它的作用是让组内所有的消费者实例就消费哪些主题分区达成一致。重平衡需要借助 kafka broker 端的 Coordinator 组件(协调者),在 Coordinator 的帮助下完成整个消费者组的分区重分配,下面我们就来详细说说这个流程。

触发与通知

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

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

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

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

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

对了,很多人还搞不清楚消费者端参数 heartbeat.interval.ms 的真实用途,下面来解释一下。从字面上看,它就是设置了心跳的间隔时间,但这个参数的真正作用是控制重平衡通知的频率。如果你想要消费者实例更迅速地得到通知,那么就可以给这个参数设置一个非常小的值,这样消费者就能更快地感知到重平衡已经开启了。

在 Go 里面通过如下方式设置:

// 默认是 3 秒
config.Consumer.Group.Heartbeat.Interval = 1 * time.Second

在 Python 里面通过如下方式设置:

consumer = KafkaConsumer(
    'topic',
    group_id="group_id",
    bootstrap_servers=['47.94.174.89:9092'],
    # 默认 3000 毫秒
    heartbeat_interval_ms=1000
)

消费者组状态机

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

目前,kafka 为消费者组定义了 5 种状态,它们分别是:Empty、Dead、PreparingRebalance、CompletingRebalance 和 Stable。那么,这 5 种状态的含义是什么呢?我们一起来看看下面这张表格。

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

解释一下消费者组启动时的状态流转过程。一个消费者组最开始是 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 请求的处理流程。

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 控制器

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

在开始之前,先简单介绍一下 zookeeper 框架,要知道控制器是重度依赖 zookeeper 的,因此我们有必要花一些时间学习下 zookeeper 是做什么的。

zookeeper 是一个提供高可靠性的分布式协调服务框架,它使用的数据模型类似于文件系统的树形结构,根目录也是以 / 开始。该结构上的每个节点被称为 znode,用来保存一些元数据协调信息。如果以 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 的依赖。

掌握了 zookeeper 的这些基本知识,现在我们就可以开启对 kafka 控制器的讨论了。

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

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

实际上,broker 在启动时,会尝试去 zookeeper 中创建 /controller 节点,而 kafka 当前选举控制器的规则是:第一个成功创建 /controller 节点的 broker 会被指定为控制器。

控制器是做什么的?

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

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

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

2. 分区重分配

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

3. Preferred 领导者选举

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

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

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

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

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

5. 数据服务

控制器的最后一大类工作,就是向其它 broker 提供数据服务。控制器上保存了最全的集群元数据信息,其他所有 broker 会定期接收控制器发来的元数据更新请求,从而更新其内存中的缓存数据。

控制器保存了什么数据?

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

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

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

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

控制器故障转移(Failover)

我们在前面强调过,在 kafka 集群运行过程中,只能有一台 broker 充当控制器的角色,那么这就存在单点失效(Single Point of Failure)的风险,那么 kafka 是如何应对单点失效的呢?答案就是,为控制器提供故障转移功能,也就是说所谓的 Failover。

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

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

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

控制器内部设计原理

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

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

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

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

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

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

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

不过这在 2.2 版本之前是做不到的,但从自 2.2 开始,kafka 正式支持这种不同优先级请求的处理。简单来说,kafka 将控制器发送的请求与普通数据类请求分开,实现了控制器请求单独处理的逻辑。鉴于这个改进还是很新的功能,具体的效果我们就拭目以待吧。

小窍门:

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

关于高水位和 Leader Epoch

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

什么是高水位?

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

在时刻 T,任意创建时间(Event Time)为 T',且 T' ≤ T 的所有事件都已经到达或被观测到,那么 T 就被定义为水位。

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

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

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

follower 副本

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

  • 1. 写入消息到本地磁盘
  • 2. 更新 LEO 值
  • 3. 更新高水位值,分为三步: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 的内部原理,那么这些内容是非常值得你好好琢磨并熟练掌握的。

posted @ 2018-08-26 18:40  古明地盆  阅读(2669)  评论(0编辑  收藏  举报