kafka
博客内容东拼西凑,以下全文内容整理自极客时间专栏,非原创
文章目录
大体分类:
一个topic 可以拥有若干个partition(从 0 开始标识partition ),实现系统的高伸缩性(Scalability)。不同的分区能够被放置到不同节点的机器(Broker)上。
名词介绍:
消息
:Record。Kafka 是消息引擎嘛,这里的消息就是指 Kafka 处理的主要对象。主题
:Topic。主题是承载消息的逻辑容器,在实际使用中多用来区分具体的业务。分区
:Partition。一个有序不变的消息序列。每个主题下可以有多个分区。消息位移
:Offset。表示分区中每条消息的位置信息,是一个单调递增且不变的值。副本
:Replica。- Kafka 中同一条消息能够被拷贝到多个地方以提供数据冗余,这些地方就是所谓的副本。
- 副本还分为领导者副本和追随者副本,各自有不同的角色划分。
- 副本是在分区层级下的,即每个分区可配置多个副本实现高可用。
生产者
:Producer。向主题发布新消息的应用程序。消费者
:Consumer。从主题订阅新消息的应用程序。消费者位移
:Consumer Offset。表征消费者消费进度,每个消费者都有自己的消费者位移。消费者组
:Consumer Group。多个消费者实例共同组成的一个组,同时消费多个分区以实现高吞吐。重平衡
:Rebalance。消费者组内某个消费者实例挂掉后,其他消费者实例自动重新分配订阅主题分区的过程。Rebalance 是 Kafka 消费者端实现高可用的重要手段。
我们把生产者
和消费者
统称为客户端(Clients)。
Kafka 的服务器
端由被称为 Broker 的服务进程构成,即一个 Kafka 集群
由多个 Broker
组成, Broker
负责接收和处理客户端发送过来的请求,以及对消息进行持久化。
消费者位移
、消息位移
:
前者是在consumer角度描述,用于阐述清楚当前consumer该从哪一条消息开始继续消费;
后者是在record角度描述,用于阐述该record于partition上的位置,于consumer无太多关系。
一)高可用介绍:
1) broker 分属在不同的机子上:
虽然多个 Broker 进程能够运行在同一台机器上,但更常见的做法是将不同的 Broker 分散
运行在不同的机器上,这样如果集群中某一台机器宕机,即使在它上面运行的所有 Broker 进程都挂掉了,其他机器上的 Broker 也依然能够对外提供服务。这其实就是 Kafka 提供高可用的手段之一
。
2 )备份机制(Replication):
备份的思想很简单,就是把相同的数据拷贝到多台机器上,而这些相同的数据拷贝在 Kafka 中被称为
副本(Replica)
。
副本的数量是可以配置的,这些副本保存着相同的数据,但却有不同的角色和作用。
Kafka 定义了两类副本:领导者副本(Leader Replica)
和追随者副本(Follower Replica)
。
领导者副本(Leader Replica):
对外提供服务,这里的对外指的是与客户端程序进行交互;
追随者副本(Follower Replica):
不能与外界进行交互。
当然了,你可能知道在很多其他系统中追随者副本是可以对外提供服务的,比如 MySQL 的从库是可以处理读操作的,但是在 Kafka 中追随者副本不会对外提供服务。
副本的工作机制
也很简单:
- 生产者总是向领导者副本写消息;
- 而消费者总是从领导者副本读消息。
- 追随者副本,它只做一件事:向领导者副本发送请求,请求领导者把最新生产的消息发给它,这样它能保持与领导者的同步。
[KIP-392] 的提案对读写分离提供支持;
二)Scalability – 伸缩性
虽然有了副本机制可以保证数据的持久化或消息不丢失,但没有解决伸缩性的问题。
伸缩性即所谓的 Scalability,是分布式系统中非常重要且必须要谨慎对待的问题。
什么是伸缩性呢?
我们拿副本来说,虽然现在有了领导者副本和追随者副本,但倘若领导者副本积累了太多的数据以至于单台 Broker 机器都无法容纳了,此时应该怎么办呢?
一个很自然的想法就是,能否把数据分割成多份保存在不同的 Broker 上?如果你就是这么想的,那么恭喜你,Kafka 就是这么设计的。
这种机制就是所谓的分区(Partitioning)
。
如果你了解其他分布式系统,你可能听说过分片、分区域等提法,比如 MongoDB 和 Elasticsearch 中的 Sharding、HBase 中的 Region,其实它们都是相同的原理,只是 Partitioning 是最标准的名称。
Kafka 中的分区机制指的是将每个主题
划分成多个分区(Partition)
,每个分区是一组有序的消息日志。
生产者生产的每条消息只会被发送到一个分区中,也就是说如果向一个双分区的主题发送一条消息,这条消息要么在分区 0 中,要么在分区 1 中。
讲到这里,你可能有这样的疑问:刚才提到的副本
如何与这里的分区
联系在一起呢?
实际上,副本是在分区这个层级定义的。
每个分区下可以配置若干个副本,其中只能有 1 个领导者副本和 N-1 个追随者副本。生产者向分区写入消息,每条消息在分区中的位置信息由一个叫位移(Offset)
的数据来表征。
分区位移总是从 0 开始,假设一个生产者向一个空分区写入了 10 条消息,那么这 10 条消息的位移依次是 0、1、2、…、9。
三)Kafka三层架构总结
至此我们能够完整地串联起 Kafka 的三层消息架构
:
- 第一层是主题层,每个主题可以配置 M 个分区,而每个分区又可以配置 N 个副本。
- 第二层是分区层,每个分区的 N 个副本中只能有一个充当领导者角色,对外提供服务;其他 N-1 个副本是追随者副本,只是提供数据冗余之用。
- 第三层是消息层,分区中包含若干条消息,每条消息的位移从 0 开始,依次递增。
- 最后,客户端程序只能与分区的领导者副本进行交互。
四)Broker
讲完了消息层次,我们来说说 Kafka Broker 是如何持久化数据的。
总的来说,Kafka 使用消息日志(Log)来保存数据,一个日志就是磁盘上一个只能追加写(Append-only)消息的物理文件。
因为只能追加写入,故避免了缓慢的随机 I/O 操作,改为性能较好的顺序 I/O 写操作,这也是实现
Kafka 高吞吐量特性的一个重要手段
。
不过如果你不停地向一个日志写入消息,最终也会耗尽所有的磁盘空间,因此 Kafka 必然要定期地删除消息以回收磁盘。怎么删除呢?
简单来说就是通过日志段(Log Segment)机制。
- 在 Kafka 底层,一个日志又近一步细分成多个日志段,消息被追加写到当前最新的日志段中,当写满了一个日志段后,Kafka 会自动切分出一个新的日志段,并将老的日志段封存起来。
- Kafka 在后台还有定时任务会定期地检查老的日志段是否能够被删除,从而实现回收磁盘空间的目的。
五)MessageGroup
在 Kafka 中实现 P2P 模型的方法是引入了消费者组(Consumer Group)
。
所谓的消费者组,指的是多个消费者实例共同组成一个组来消费一组主题。这组主题中的每个分区都只会被组内的一个消费者实例消费,其他消费者实例不能消费它。
为什么要引入消费者组呢?
主要是为了提升消费者端的吞吐量。多个消费者实例同时消费,加速整个消费端的吞吐量(TPS)。
消费者组里面的所有消费者实例不仅“瓜分”订阅主题的数据,而且更酷的是它们还能彼此协助。假设组内某个实例挂掉了,Kafka 能够自动检测到,然后把这个 Failed 实例之前负责的分区转移给其他活着的消费者。这个过程就是 Kafka 中大名鼎鼎的“重平衡”(Rebalance)。嗯,其实既是大名鼎鼎,也是臭名昭著,因为由重平衡引发的消费者问题比比皆是。事实上,目前很多重平衡的 Bug 社区都无力解决。
每个消费者在消费消息的过程中必然需要有个字段记录它当前消费到了分区的哪个位置上,这个字段就是消费者位移(Consumer Offset)
。
注意,这和上面所说的位移完全不是一个概念。
上面的“位移”表征的是分区内的消息位置,它是不变的,即一旦消息被成功写入到一个分区上,它的位移值就是固定的了。
而消费者位移则不同,它可能是随时变化的,毕竟它是消费者消费进度的指示器嘛。
另外每个消费者有着自己的消费者位移,因此一定要区分这两类位移的区别。
我个人把消息在分区中的位移称为分区位移,而把消费者端的位移称为消费者位移。
5.1 )消费者组的并发性
消费侧的并发性需要考虑两个问题:
- 消息拉取到客户端
- 消息偏移的提交和获取
前者支持并发,但是后者则不然。
从代码上看,同一个消费者组的消费进度是没法并发提交的,有加可重入锁保护消费者组的元数据对象,每次写入的时候都需要先获取到锁
// 针对消费者组元数据的很多操作都是在临界区中完成的
group.inLock {
...
}
更加反直觉的是,消费进度的读取操作也是同样的一把锁保护,无法并发获取,具体原因不详,但是此锁的作用可能是:
- 保护正在使用中的消费者组不被删除
- 消费进度出现变动(偏移过期被删除、分区扩容有新分区进度加入等),等待其他操作完成再执行
总的来讲针对一个消费者组的几乎所有操作都不支持并发(读写都是),主要目的可能就是为了保护正在使用的资源不被意外删除。
5.2 )生产者组的并发性
消息生产的大概流程是:
- 连接到任意 Broker,获取集群元数据
- 发送消息到指定的分区 Leader 副本所在的 Broker
- 其他 Broker 上的副本向 Leader 副本同步
在这个流程中消息是通过集群元数据的提示,发往对应分区 Leader 副本所在的 Broker 上的,注意这里不允许消息在 Broker 之间进行转发
并发性
一句话总结:同一个 Topic 不同分区之间是支持并发写入消息的,同一个分区不支持并发写入消息
这很好理解,单个分区是临界资源,需要用锁来进行冲突检测保证同一时间只有一批消息在写入避免出现消息乱序或者写入被覆盖的情况。
六)Push vs Pull
在 Kafka 定义的消息模型中,消费端是通过主动拉取消息的方式来消费的,但是与之对应的还有消息推送模型,Broker 对生产者推送过来的消息进行主动分发和推送到消费端。
直觉上我们会觉得这种方式很自然,或者认为这是消息引擎的唯一范式,但是实际上关于为什么选择 Pull 的方式来进行消费,Kafka 的官方文档中关于这部分设计有专门列出来,主要讨论的点是消息消费的流控策略应该放在 Broker 端还是 Consumer 端
,感兴趣的可以去阅读一下 Apache Kafka Documentation。
七)都有哪些分区策略?
下面我们说说 Kafka 生产者的分区策略。所谓分区策略是决定生产者将消息发送到哪个分区的算法。Kafka 为我们提供了默认的分区策略,同时它也支持你自定义分区策略。
int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster);
这里的topic、key、keyBytes、value和valueBytes都属于消息数据,cluster则是集群信息(比如当前 Kafka 集群共有多少主题、多少 Broker 等)。
Kafka 给你这么多信息,就是希望让你能够充分地利用这些信息对消息进行分区,计算出它要被发送到哪个分区中。只要你自己的实现类定义好了 partition 方法,同时设置partitioner.class参数为你自己实现类的 Full Qualified Name,那么生产者程序就会按照你的代码逻辑对消息进行分区。
虽说可以有无数种分区的可能,但比较常见的分区策略也就那么几种,下面我来详细介绍一下。
1) 轮询策略也称 Round-robin 策略,即顺序分配。
比如一个主题下有 3 个分区,那么第一条消息被发送到分区 0,第二条被发送到分区 1,第三条被发送到分区 2,以此类推。当生产第 4 条消息时又会重新开始,即将其分配到分区 0,就像下面这张图展示的那样。
这就是所谓的轮询策略。轮询策略是 Kafka Java 生产者 API 默认提供的分区策略。如果你未指定partitioner.class参数,那么你的生产者程序会按照轮询的方式在主题的所有分区间均匀地“码放”消息。
轮询策略有非常优秀的负载均衡表现,它总是能保证消息最大限度地被平均分配到所有分区上,故默认情况下它是最合理的分区策略,也是我们最常用的分区策略之一。
2) 随机策略也称 Randomness 策略。所谓随机就是我们随意地将消息放置到任意一个分区上,如下面这张图所示。
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
return ThreadLocalRandom.current().nextInt(partitions.size());
本质上看随机策略也是力求将数据均匀地打散到各个分区,但从实际表现来看,它要逊于轮询策略,所以如果追求数据的均匀分布,还是使用轮询策略比较好。事实上,随机策略是老版本生产者使用的分区策略,在新版本中已经改为轮询了。
3) 按消息键保序策略
Kafka 允许为每条消息定义消息键,简称为 Key。
这个 Key 的作用非常大,它可以是一个有着明确业务含义的字符串,比如客户代码、部门编号或是业务 ID 等;也可以用来表征消息元数据。特别是在 Kafka 不支持时间戳的年代,在一些场景中,工程师们都是直接将消息创建时间封装进 Key 里面的。一旦消息被定义了 Key,那么你就可以保证同一个 Key 的所有消息都进入到相同的分区里面,由于每个分区下的消息处理都是有顺序的,故这个策略被称为按消息键保序策略,如下图所示。
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
return Math.abs(key.hashCode()) % partitions.size();
前面提到的 Kafka 默认分区策略实际上同时实现了两种策略:如果指定了 Key,那么默认实现按消息键保序策略;如果没有指定 Key,则使用轮询策略。
消息顺序影响case1
:
之前做车辆实时定位(汽车每10s上传一次报文)显示的时候,发现地图显示车辆会突然退回去,开始排查怀疑是后端处理的逻辑问题导致的,但是后台保证了一台车只被一个线程处理,理论上不会出现这种情况;于是猜测是不是程序接收到消息的时候时间序就已经乱了,查阅了kafka相关资料,发现kafka同一个topic是无法保证数据的顺序性的,但是同一个partition中的数据是有顺序的;根据这个查看了接入端的代码(也就是kafka的生产者),发现是按照kafka的默认分区策略(topic有10个分区,3个副本)发送的;于是将此处发送策略改为按照key(车辆VIN码)进行分区,后面车辆的定位显示就正常了。
说明
消息乱序问题
我们通常认为生产者发送的消息总是能够保证分区有序,这是一种误解,因为这里有一个陷阱,就是
max.in.flight.requests.per.connection 这个客户端网络配置
查阅 Kafka 的 官方文档 此配置的默认值是 5,表示一个连接中可以同时有 5 个消息批次在途,文档中也明确指出了由于错误重试的关系,这种场景下消息会乱序
所以,当我们业务上对消息顺序有硬性需求的时候,这个点必须引起重视。
八)消息压缩
说起压缩(compression),我相信你一定不会感到陌生。它秉承了用时间去换空间的经典 trade-off 思想,具体来说就是用 CPU 时间去换磁盘空间或网络 I/O 传输量,希望以较小的 CPU 开销带来更少的磁盘占用或更少的网络 I/O 传输。
在 Kafka 中,压缩也是用来做这件事的。
何时压缩?
在 Kafka 中,压缩可能发生在两个地方:
生产者端
和Broker 端
。
设置生产者压缩方式的相关代码:
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("acks", "all");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
// 开启GZIP压缩
props.put("compression.type", "gzip");
Producer<String, String> producer = new KafkaProducer<>(props);
在生产者端启用压缩是很自然的想法,那为什么我说在 Broker 端也可能进行压缩呢?
其实大部分情况下 Broker 从 Producer 端接收到消息后仅仅是原封不动地保存而不会对其进行任何修改,但这里的“大部分情况”也是要满足一定条件的。
有两种例外情况就可能让 Broker 重新压缩消息。
- 情况一:Broker 端指定了和 Producer 端不同的压缩算法。
- 情况二:Broker 端发生了消息格式转换。
在一个生产环境中,Kafka 集群中同时保存多种版本的消息格式非常常见。
为了兼容老版本的格式,Broker 端会对新版本消息执行向老版本格式的转换。这个过程中会涉及消息的解压缩和重新压缩。一般情况下这种消息格式转换对性能是有很大影响的,除了这里的压缩之外,它还让Kafka 丧失了引以为豪的 Zero Copy 特性
。
何时解压缩?
有压缩必有解压缩!
通常来说解压缩发生在消费者程序中,也就是说 Producer 发送压缩消息到 Broker 后,Broker 照单全收并原样保存起来。
当 Consumer 程序请求这部分消息时,Broker 依然原样发送出去,当消息到达 Consumer 端后,由 Consumer 自行解压缩还原成之前的消息。
Producer 端压缩、Broker 端保持、Consumer 端解压缩。
九)如何配置 Kafka 无消息丢失
不丢失消息的情况:
Kafka 只对“
已提交
”的消息(committed message)做有限度的持久化保证。
-
第一个核心要素:是“
已提交的消息
”。什么是已提交的消息?当 Kafka 的
若干个 Broker
成功地接收到一条消息并写入到日志文件后
,它们会告诉生产者程序这条消息已成功提交。此时,这条消息在 Kafka 看来就正式变为“已提交”消息了。
那为什么是
若干个 Broker 呢
?这取决于你对“已提交”的定义。你可以选择只要有一个 Broker 成功保存该消息就算是已提交,也可以是令所有 Broker 都成功保存该消息才算是已提交。
不论哪种情况,Kafka 只对已提交的消息做持久化保证这件事情是不变的。
-
第二个核心要素:是“
有限度的持久化保证
”,也就是说 Kafka 不可能保证在任何情况下都做到不丢失消息。假如你的消息保存在 N 个 Kafka Broker 上,那么这个前提条件就是这 N 个 Broker 中至少有 1 个存活。只要这个条件成立,Kafka 就能保证你的这条消息永远不会丢失。
丢失消息的case 1
:
目前 Kafka Producer 是异步发送消息的,也就是说如果你调用的producer.send(msg)
这个 API,那么它通常会立即返回,但此时不能认为消息发送已成功完成
。
- 网络抖动,导致消息压根就没有发送到 Broker 端;
- 消息本身不合格导致 Broker 拒绝接收(比如消息太大了,超过了 Broker 的承受能力)等。
Producer 如果想知道详细是否发送成功,不要使用 producer.send(msg),而要考虑使用
producer.send(msg, callback)
。
以上这种场景造成的消息发送失败,责任是在producer端。
丢失消息的case 2
:
Consumer 端丢失数据主要体现在 Consumer 端要消费的消息不见了。Consumer 程序有个“位移”的概念,表示的是这个 Consumer 当前消费到的 Topic 分区的位置。下面这张图来自于官网,它清晰地展示了 Consumer 端的位移数据。
这里的“位移”类似于我们看书时使用的书签,它会标记我们当前阅读了多少页,下次翻书的时候我们能直接跳到书签页继续阅读。
正确使用书签有两个步骤:第一步是读书,第二步是更新书签页。
如果这两步的顺序颠倒了,就可能出现这样的场景:当前的书签页是第 90 页,我先将书签放到第 100 页上,之后开始读书。当阅读到第 95 页时,我临时有事中止了阅读。那么问题来了,当我下次直接跳到书签页阅读时,我就丢失了第 96~99 页的内容,即这些消息就丢失了。
同理,Kafka 中 Consumer 端的消息丢失就是这么一回事。要对抗这种消息丢失,办法很简单:维持先消费消息(阅读),再更新位移(书签)的顺序即可
。
这样就能最大限度地保证消息不丢失。
丢失消息的case 3
:
我们依然以看书为例。假设你花钱从网上租借了一本共有 10 章内容的电子书,该电子书的有效阅读时间是 1 天,过期后该电子书就无法打开,但如果在 1 天之内你完成阅读就退还租金。为了加快阅读速度,你把书中的 10 个章节分别委托给你的 10 个朋友,请他们帮你阅读,并拜托他们告诉你主旨大意。当电子书临近过期时,这 10 个人告诉你说他们读完了自己所负责的那个章节的内容,于是你放心地把该书还了回去。不料,在这 10 个人向你描述主旨大意时,你突然发现有一个人对你撒了谎,他并没有看完他负责的那个章节。那么很显然,你无法知道那一章的内容了。
对于 Kafka 而言,这就好比 Consumer 程序从 Kafka 获取到消息后开启了多个线程异步处理消息,而 Consumer 程序自动地向前更新位移。假如其中某个线程运行失败了,它负责的消息没有被成功处理,但位移已经被更新了,因此这条消息对于 Consumer 而言实际上是丢失了。
这里的关键在于 Consumer 自动提交位移,与你没有确认书籍内容被全部读完就将书归还类似,你没有真正地确认消息是否真的被消费就“盲目”地更新了位移。
这个问题的解决方案也很简单:如果是多线程异步处理消费消息,Consumer 程序不要开启自动提交位移,而是要应用程序手动提交位移
。
在这里我要提醒你一下,单个 Consumer 程序使用多线程来消费消息说起来容易,写成代码却异常困难,因为你很难正确地处理位移的更新,也就是说避免无消费消息丢失很简单,但极易出现消息被消费了多次的情况。
总结,相关实践:
- 不要使用 producer.send(msg),而要使用
producer.send(msg, callback)
。记住,一定要使用带有回调通知的 send 方法。 - 设置
acks = all
。acks 是 Producer 的一个参数,代表了你对“已提交”消息的定义。如果设置成 all,则表明所有副本 Broker 都要接收到消息,该消息才算是“已提交”。这是最高等级的“已提交”定义。 - 设置
retries
为一个较大的值。这里的 retries 同样是 Producer 的参数,对应前面提到的 Producer 自动重试。当出现网络的瞬时抖动时,消息发送可能会失败,此时配置了 retries > 0 的 Producer 能够自动重试消息发送,避免消息丢失。 - 设置 unclean.leader.election.enable = false。这是 Broker 端的参数,它控制的是哪些 Broker 有资格竞选分区的 Leader。如果一个 Broker 落后原先的 Leader 太多,那么它一旦成为新的 Leader,必然会造成消息的丢失。故一般都要将该参数设置成 false,即不允许这种情况的发生。
- 设置 replication.factor >= 3。这也是 Broker 端的参数。其实这里想表述的是,最好将消息多保存几份,毕竟目前防止消息丢失的主要机制就是冗余。
- 设置 min.insync.replicas > 1。这依然是 Broker 端参数,控制的是消息至少要被写入到多少个副本才算是“已提交”。设置成大于 1 可以提升消息持久性。在实际环境中千万不要使用默认值 1。
- 确保 replication.factor > min.insync.replicas。如果两者相等,那么只要有一个副本挂机,整个分区就无法正常工作了。我们不仅要改善消息的持久性,防止数据丢失,还要在不降低可用性的基础上完成。推荐设置成 replication.factor = min.insync.replicas + 1。确保消息消费完成再提交。
- Consumer 端有个参数
enable.auto.commit
,最好把它设置成 false,并采用手动提交位移的方式。就像前面说的,这对于单 Consumer 多线程处理的场景而言是至关重要的。
十) 为何采用 TCP?
为什么 Kafka 不使用 HTTP 作为底层的通信协议呢?其实这里面的原因有很多,但最主要的原因在于 TCP 和 HTTP 之间的区别。
何时创建 TCP 连接?
Kafka 的 Java 生产者 API 主要的对象就是 KafkaProducer。通常我们开发一个生产者的步骤有 4 步。
- 第 1 步:构造生产者对象所需的参数对象。
- 第 2 步:利用第 1 步的参数对象,创建 KafkaProducer 对象实例。
- 第 3 步:使用 KafkaProducer 的 send 方法发送消息。
- 第 4 步:调用 KafkaProducer 的 close 方法关闭生产者并释放各种系统资源。
要回答上面这个问题,我们首先要弄明白生产者代码是什么时候创建 TCP 连接的。就上面的那段代码而言,可能创建 TCP 连接的地方有两处:
- Producer producer = new KafkaProducer(props)
- producer.send(msg, callback)。
你觉得连向 Broker 端的 TCP 连接会是哪里创建的呢?
首先,生产者应用在创建 KafkaProducer 实例时是会建立与 Broker 的 TCP 连接的。其实这种表述也不是很准确,应该这样说:
在创建 KafkaProducer 实例时,生产者应用会在后台创建并启动一个名为 Sender 的线程,该 Sender 线程开始运行时首先会创建与 Broker 的连接。
你也许会问:怎么可能是这样?如果不调用 send 方法,这个 Producer 都不知道给哪个主题发消息,它又怎么能知道连接哪个 Broker 呢?
Q: 难不成它会连接 bootstrap.servers 参数指定的所有 Broker 吗?
A: 嗯,是的,Java Producer 目前还真是这样设计的。
TCP 连接还可能在两个地方被创建:
- 一个是在更新元数据后,
- 另一个是在消息发送时。
为什么说是可能?因为这两个地方并非总是创建 TCP 连接。
当 Producer 更新了集群的元数据信息之后,如果发现与某些 Broker 当前没有连接,那么它就会创建一个 TCP 连接。同样地,当要发送消息时,Producer 发现尚不存在与目标 Broker 的连接,也会创建一个。
场景一:当 Producer 尝试给一个不存在的主题发送消息时,Broker 会告诉 Producer 说这个主题不存在。此时 Producer 会发送 METADATA 请求给 Kafka 集群,去尝试获取最新的元数据信息。
场景二:Producer 通过 metadata.max.age.ms 参数定期地去更新元数据信息。该参数的默认值是 300000,即 5 分钟,也就是说不管集群那边是否有变化,Producer 每 5 分钟都会强制刷新一次元数据以保证它是最及时的数据。
讲到这里,我们可以“挑战”一下社区对 Producer 的这种设计的合理性。目前来看,一个 Producer 默认会向集群的所有 Broker 都创建 TCP 连接,不管是否真的需要传输请求。这显然是没有必要的。再加上 Kafka 还支持强制将空闲的 TCP 连接资源关闭,这就更显得多此一举了。试想一下,在一个有着 1000 台 Broker 的集群中,你的 Producer 可能只会与其中的 3~5 台 Broker 长期通信,但是 Producer 启动后依次创建与这 1000 台 Broker 的 TCP 连接。一段时间之后,大约有 995 个 TCP 连接又被强制关闭。这难道不是一种资源浪费吗?很显然,这里是有改善和优化的空间的。
何时关闭 TCP 连接?
说完了 TCP 连接的创建,我们来说说它们何时被关闭。
Producer 端关闭 TCP 连接的方式有
两种
:
- 一种是用户主动关闭;
- 一种是 Kafka 自动关闭。
我们先说第一种。这里的主动关闭实际上是广义的主动关闭,甚至包括用户调用 kill -9 主动“杀掉”Producer 应用。当然最推荐的方式还是调用 producer.close() 方法来关闭。
第二种是 Kafka 帮你关闭,这与 Producer 端参数 connections.max.idle.ms 的值有关。默认情况下该参数值是 9 分钟,即如果在 9 分钟内没有任何请求“流过”某个 TCP 连接,那么 Kafka 会主动帮你把该 TCP 连接关闭。用户可以在 Producer 端设置 connections.max.idle.ms=-1 禁掉这种机制。一旦被设置成 -1,TCP 连接将成为永久长连接。当然这只是软件层面的“长连接”机制,由于 Kafka 创建的这些 Socket 连接都开启了 keepalive,因此 keepalive 探活机制还是会遵守的。值得注意的是,在第二种方式中,TCP 连接是在 Broker 端被关闭的,但其实这个 TCP 连接的发起方是客户端,因此在 TCP 看来,这属于被动关闭的场景,即 passive close。被动关闭的后果就是会产生大量的 CLOSE_WAIT 连接,因此 Producer 端或 Client 端没有机会显式地观测到此连接已被中断。
十一)最多一次、至少一次、精确一次
- 最多一次(at most once):消息可能会丢失,但绝不会被重复发送。
- 至少一次(at least once):消息不会丢失,但有可能被重复发送。
- 精确一次(exactly once):消息不会丢失,也不会被重复发送。
目前,Kafka 默认提供的交付可靠性保障是第二种,即至少一次
。
我们说过消息“已提交”的含义,即只有 Broker 成功“提交”消息且 Producer 接到 Broker 的应答才会认为该消息成功发送。不过倘若消息成功“提交”,但 Broker 的应答没有成功发送回 Producer 端(比如网络出现瞬时抖动),那么 Producer 就无法确定消息是否真的提交成功了。
因此,它只能选择重试,也就是再次发送相同的消息。这就是 Kafka 默认提供至少一次可靠性保障的原因,不过这会导致消息重复发送。
Kafka 也可以提供最多一次交付保障,只需要让 Producer 禁止重试即可。
这样一来,消息要么写入成功,要么写入失败,但绝不会重复发送。
我们通常不会希望出现消息丢失的情况,但一些场景里偶发的消息丢失其实是被允许的,相反,消息重复是绝对要避免的。此时,使用最多一次交付保障就是最恰当的。
无论是至少一次还是最多一次,都不如精确一次来得有吸引力。
大部分用户还是希望消息只会被交付一次,这样的话,消息既不会丢失,也不会被重复处理。或者说,即使 Producer 端重复发送了相同的消息,Broker 端也能做到自动去重。在下游 Consumer 看来,消息依然只有一条。
那么问题来了,Kafka 是怎么做到精确一次的呢?
简单来说,这是通过两种机制:幂等性(Idempotence)
和事务(Transaction)
。
它们分别是什么机制?两者是一回事吗?要回答这些问题,我们首先来说说什么是幂等性。
幂等性 Producer 保证消息不重复发送
需要设置一个参数即可,即 props.put(“enable.idempotence”, ture),或 props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true)。enable.idempotence 被设置成 true 后,Producer 自动升级成幂等性 Producer,其他所有的代码逻辑都不需要改变。Kafka 自动帮你做消息的重复去重。
底层具体的原理很简单,就是经典的用空间去换时间的优化思路,即在 Broker 端多保存一些字段。当 Producer 发送了具有相同字段值的消息后,Broker 能够自动知晓这些消息已经重复了,于是可以在后台默默地把它们“丢弃”掉。当然,实际的实现原理并没有这么简单,但你大致可以这么理解。
幂等性 Producer 的限制 :
- 1.
某个主题的一个分区
上不出现重复消息,它无法实现多个分区的幂等性
- 2.只能实现单会话上的幂等性,不能实现跨会话的幂等性。
如果我想实现多分区以及多会话上的消息无重复,应该怎么做呢?答案就是事务(transaction)或者依赖事务型 Producer。
事务型 Producer
事务型 Producer 能够保证将消息原子性地写入到多个分区中。这批消息要么全部写入成功,要么全部失败。另外,事务型 Producer 也不惧进程的重启。Producer 重启回来后,Kafka 依然保证它们发送消息的精确一次处理。设置事务型 Producer 的方法也很简单,满足两个要求即可:和幂等性 Producer 一样,开启 enable.idempotence = true。设置 Producer 端参数 transactional. id。最好为其设置一个有意义的名字。
producer.initTransactions();
try {
producer.beginTransaction();
producer.send(record1);
producer.send(record2);
producer.commitTransaction();
} catch (KafkaException e) {
producer.abortTransaction();
}
和普通 Producer 代码相比,事务型 Producer 的显著特点是调用了一些事务 API,如 initTransaction、beginTransaction、commitTransaction 和 abortTransaction,它们分别对应事务的初始化、事务开始、事务提交以及事务终止。这段代码能够保证 Record1 和 Record2 被当作一个事务统一提交到 Kafka,要么它们全部提交成功,要么全部写入失败。实际上即使写入失败,Kafka 也会把它们写入到底层的日志中,也就是说 Consumer 还是会看到这些消息。因此在 Consumer 端,读取事务型 Producer 发送的消息也是需要一些变更的。修改起来也很简单,设置 isolation.level 参数的值即可。当前这个参数有两个取值
- read_uncommitted:这是默认值,表明 Consumer 能够读取到 Kafka 写入的任何消息,不论事务型 Producer 提交事务还是终止事务,其写入的消息都可以读取。很显然,如果你用了事务型 Producer,那么对应的 Consumer 就不要使用这个值。
- read_committed:表明 Consumer 只会读取事务型 Producer 成功提交事务写入的消息。当然了,它也能看到非事务型 Producer 写入的所有消息。
十二) Consumer Group
消费者组,即 Consumer Group,应该算是 Kafka 比较有亮点的设计了。那么何谓 Consumer Group 呢?用一句话概括就是:Consumer Group 是 Kafka 提供的可扩展且具有容错性的消费者机制
。既然是一个组,那么组内必然可以有多个消费者或消费者实例(Consumer Instance),它们共享一个公共的 ID,这个 ID 被称为 Group ID。
组内的所有消费者协调在一起来消费订阅主题(Subscribed Topics)的所有分区(Partition)。当然,每个分区只能由同一个消费者组内的一个 Consumer 实例来消费。
- Consumer Group 下可以有一个或多个 Consumer 实例。这里的实例可以是一个单独的进程,也可以是同一进程下的线程。在实际场景中,使用进程更为常见一些。
- Group ID 是一个字符串,在一个 Kafka 集群中,它标识唯一的一个 Consumer Group。
- Consumer Group 下所有实例订阅的主题的单个分区,只能分配给组内的某个 Consumer 实例消费。这个分区当然也可以被其他的 Group 消费。
传统的消息队列模型的缺陷在于消息一旦被消费,就会从队列中被删除,而且只能被下游的一个 Consumer 消费。严格来说,这一点不算是缺陷,只能算是它的一个特性。但很显然,这种模型的伸缩性(scalability)很差,因为下游的多个 Consumer 都要“抢”这个共享消息队列的消息。发布 / 订阅模型倒是允许消息被多个 Consumer 消费,但它的问题也是伸缩性不高,因为每个订阅者都必须要订阅主题的所有分区。这种全量订阅的方式既不灵活,也会影响消息的真实投递效果。
如果有这么一种机制,既可以避开这两种模型的缺陷,又兼具它们的优点,那就太好了。
幸运的是,Kafka 的 Consumer Group 就是这样的机制。当 Consumer Group 订阅了多个主题后,组内的每个实例不要求一定要订阅主题的所有分区,它只会消费部分分区中的消息。Consumer Group 之间彼此独立,互不影响,它们能够订阅相同的一组主题而互不干涉。再加上 Broker 端的消息留存机制,Kafka 的 Consumer Group 完美地规避了上面提到的伸缩性差的问题。可以这么说,Kafka 仅仅使用 Consumer Group 这一种机制,却同时实现了传统消息引擎系统的两大模型:
- 如果所有实例都属于同一个 Group,那么它实现的就是消息队列模型;
- 如果所有实例分别属于不同的 Group,那么它实现的就是发布 / 订阅模型。