一、Kafka 是什么
1.1 Kafka 定义
首先,我们根据 Hadoop 生态系统的架构图,知道了 Kafka 是位于数据传输层数据存储的。
Kafka 是一个分布式的基于发布/订阅模式的消息队列(Message Queue),主要应用于大数据的实时计算领域。
上述提到了发布/订阅模式的消息队列,所以很容易推想出还有其它模式的消息队列。
1.2 两类消息队列模式
1.2.1 发布/订阅模式
生产者(发布)将消息发布到 topic 中,同时可以有多个消费者可以订阅该消息。和点对点方式不同,发布到 topic 的消息会被所有订阅者订阅。Kafka 使用的是这种模式的消息队列,因为 Kafka 作为数据传输层(还可以作为存储层)的一个框架,它的上层可能会有多个消费者,如 Spark、MapReduce。
1.2.2 点对点模式(一对一)
消息生产者生产消息发送到 Queue 中,然后消费者从 Queue 中取出并且消费消息,消息被消费后, Queue 中不再有存储,所以消费者不能再得到已经消费的消息,Queue 支持多个消费者,但对于一个消息而言,只能由一个消费者消费,因此,如果想要有多个消费者消费同样的数据,那么必须让生产者把数据放到两个队列中,这就类似于 Flume 中的一个 Source,多个 Channel。
1.2.3 消息队列的好处
1、解耦:
使用消息队列,我们可以独立的修改两边的处理过程,只要它们遵循同样的接口约束。(其实,解耦的 这种思想在渗透在了计算机领域的方方面面,典型的就是计网,还有我们的 Hadoop 生态系统也是分层了,具 体可见我的另一篇文章,如果想要解耦,就在它两之间加一个层)
2、可恢复性:
系统的一部分组件失效时,不会影响到整个系统。因为消息队列降低了进程间的耦合度,所以即使一个 处理消息的进程挂掉,加入队列中的消息仍然可以在系统恢复后被处理(Kafka 的消息队列中的数据默认可以存 储 7 天)。
3、缓冲:
像这种类似于管道的组件,我们很容易就想到了缓冲。在没有消息队列的情况下,如果两边的速度不一 致,特别是当生产者生产速度大于消费者消费的速度时,消费者就会丢失数据。
4、灵活性 & 峰值处理能力:
消息队列可以动态地接入消费者,这可以提高系统应对突发情况的能力。如:像双十一这种时候,数据 量非常大,所以我们就必须加大服务器的数量来应对这种情况,而如果没有消息队列,那么这些服务器就必须 全年 365 天待命,这显然不是我们想要的功能,而有了消息队列,我们便可以动态的加入或者删除节点。
5、异步通信:
有时候可能消费者不想立马使用生产者产生的数据,消息队列就提供了异步处理机制,消费者可以在需 要的时候再去取。
1.2.4 Kafka 使用的消息队列
在 1.2.1 中,我们提到了 Kafka 使用的消息队列是发布/订阅模式的消息队列,但如果仔细看的话,会发 现在消息队列到消费者的那条线上写的是 “推/拉”,这是两种不一样的方式。
推是指从消息队列把消息主动发送到消费者,消费者是被动地接收;而拉是指消费者主动到消息队列去取数据,两种方式各有优缺点。
推缺点是不同的消费者接收数据的速度可能不一样,而消息队列写出的速度却是统一的,所以可能存在的一种情况就是消费者 a 的写入速度是 100MB / s,而消费者 b 的写入速度是 50 MB / s,消息队列的写初速度是 100 Mb / s,这会导致消费者 b 被丢失数据。
拉就避免了上述说的那种缺点,但它又引进了新的问题:因为消费者是主动去消息队列提取数据,所以很有可能的一个现象就是消息队列里已经没有数据,但消费者依然循环去查看消息队列,这就造成了资源浪费。
Kafka 在经过权衡之后选用了拉的方式,同时使用了一种机制来减轻拉带来的问题:如果消费者发现消息队列里没有数据,那么它将过一段时间后再去查看,而不是以之前的恒定速率。
二、Kafka 架构
图中涉及到的元素的解释:
Topic: 在 kafka 中,topic 可以理解成一个队列,生产者和消费者面向的都是一个 topic。
Partiton:为了实现扩展性,一个大的 topic 可以分区,然后分布在多个服务器上,每个 partition 是一个有序的队列。
Replica:副本,因为 kafka 是高可靠的,为了防止某台机器宕机而引起数据丢失,所以要有备份。
Leader:每个分区的副本只对应一个主,生产者和消费者都是针对于 Leader 进行数据读写。
Follower: 每个分区的副本对应多个 follower,follower 实时从 leader 中获取数据,当 Leader 挂了的时候,会从多个 Follower 中重新选出一个 Leader。
消费者组:消费者组内的消费者只能读同一个 topic 对应的同一个 partition,而不能读多个。
三、Kafka 工作流程
3.1 概述
在 Kafka 中,tipic 是一个逻辑上的概念,而 partition 是物理上实际存在的,每个 partition 对应于一个 .log 文件(注意,这里的 log 文件不是日志文件,而是存储 producer 生产的数据的文件。Producer 生产的数据会不断地加载到该文件的末尾,并且每条数据都有自己对应的 offset(偏移量, 每个 offset 对应的数据大小可能不一样),每个消费者都会记录自己消费到了哪个 offset,以便在出错恢复时,可以从上次的位置继续消费。
3.2 实现细节——segment 和 index
想象一下,如果我们在一个分区的 .log 文件中不断地添加数据,那么这个文件将会越来越大,假如扩展到了 20G,而 kafka 使用的文件读取方式是顺序读取,该方式在文件末尾追加内容的时间复杂度是 O(1),但读的时间复杂度是 O(n),如果我们想从这 20G 的文件中根据偏移量来定位到某一条数据,将会花费大量的时间,为了解决这一问题,kafka把一个分区的数据进行了分段 ( segment ) ,而定位到分段中的某一条数据又使用了索引 ( index ) 。
segment 中 index 与 .log 的对应:
首先,可以看到两个 segment 中有各有一个 .index 文件和一个 .log 文件,这两个文件都是以该段的最小的 offset 命名的,以这样的文件名命名遍解决了文件太大不能快速找到某一个位置的数据的问题:给定一个 offset ,首先就可以通过二分法确定它在哪一个段中,然后在该段中依据索引来找到对应位置的数据。
索引机制:这里的索引是一个相对索引,相对是指对未分段的 .log 文件的相对。 索引中存储的 offset 是一个逻辑量,而 offset 对应的值是对应 message 的物理偏移地址。
例:找到 offset 为 10 的数据
首先,通过二分查找,锁定 offset 为 10 的数据位于第二个分段中,然后因为第二个分段的第一个 offset 其实相对于未分段的 .log 来说是 6,然后,在 6 的基础上加 4 就是 10,所以在我们的索引表里就是第二个分段的第一个 offset + 4,即 0 + 4 = 4,所以第二个分段的第四个 offset 就是整个 log 的第 10 个 offset。
四、Kafka 生产者
4.1 分区
4.1.1 为什么要分区
(1)如果不分区,直接把一个 topic 中的内容放到一个 broker 中,那么这个 broker 很有可能装不下这么多数据,而 在分区以后,便可以在不同的 broker 中放置不同大小的数据,因此整个集群便可以适应任意大小的数据了;
(2)提高了并发性,把数据分成了多份,那么便可以同时向 broker 中写入数据,可以充分利用资源。
4.1.2 分区策略(写入哪个分区)
(1)指定到哪个 partition;
(2)不 partition,指定 key,把 key 的 hash 值与 topic 的 partition 数进行取余得到 partition 值。
(3)不指定分区个数和 key,第一次调用时生成一个随机数(后面每次调用将这个数自增),将这个值与 topic 总数 取余得到 partition 值,也就是 round-robin(轮询)算法。
4.2 数据可靠性保证
在生产者向集群中写入数据的时候,可能会因为网络原因或其它原因导致数据丢失,因此,为了保证数据的完整性,当 broker 收到数据,它应该向生产者返回一个 ack ( acknowledge) ,表示自己已经收到数据,否则消费者就得重传数据。那么在 broker 向消费者发送 ack 的时候,就引出了一个问题,在上面提到了 kafka 为了防止数据丢失,每个数据都应该有备份,即有一个 leader 和多个 follower,follower 中的数据应与 leader 中的数据保持一致,且这几份数据都不在同一个 broker 上,那么为了保持数据的一致性,就引出了一个问题,是当 leader 收到消息就发送 ack,还是当所有的 follower 都收到消息就发送 ack 呢,还是半数的 follower 收到消息就发送呢?
4.2.1 返回 ack 的两种时机
为了防止数据丢失,自然排除了第一种假设,我们肯定不能在 leader 收到消息就发送 ack,否则一旦 leader 挂掉,就会把这部分数据丢失,下面就对后两种情况进行分析:
方案 | 优点 | 缺点 |
---|---|---|
1、半数以上的 follower 同步数据发送 ack | 延迟低 | 选举新的 leader 时,容忍 n 台节点故障,需要 2n + 1 个节点 |
2、所有 follower 同步数据发送 ack | 选举新的 leader时,容忍 n 台节点故障,需要 n + 1 个副本 | 延迟高 |
两个数学关系式的简单说明:
假设我们有 5 台节点故障,那么要保证半数以上的节点收到数据,就意味着至少得有 6 台节点收到数据,总和就是需要 11 台节点。而要保证所有的 follower 收到数据,如果有 5 台挂了,那么现在就只有 1 个 follower 存活,它就是所有的 folower 了。
4.2.2 Kafka 选用的时机
Kafka 选用了第二种方式,即当所有的 follower 同步数据才发送 ack,因为第一种需要 2n + 1 个节点来存储数据,对于 kafka 这种存储海量数据的框架来说,这种代价太大了,所以不能使用。
但第二种方式,还有一个致命问题,假设一个集群中共有 10 个副本,1 个 leader,9 个follower,如果在一次同步数据的过程中,已经有 8 个 follower 成功同步了,但只有一个 follower 因为某种故障,迟迟不能同步数据,那么 leader 就必须等这个 follower 同步完成 才能发送 ack。为了解决这个问题,kafka 又引入了一个重要的概念——ISR(同步副本集合)。
ISR:
leader 维护了一个动态的 in-sync replica set(ISR),意味与 leader 保持同步的 follower 集合,当 ISR 中的数据全部完成同步时,leader 向生产者发送 ack,如果 ISR 中的 follower 过长时间未响应,就将它踢出 ISR。
当 leader 挂掉的时候,就从 ISR 中选取一个 follower 作为新的 leader。
选取新的 leader 的策略:
在旧版本中的策略是结合 follower 向 leader 的响应时间及 follower 中存储数据的条目来选取,选取响应时间短的、与 leader 中数据条目相差在一定范围之内的。
在新版本中,把存储条目的这一个策略砍掉了,假设一个情形,leader 能容忍的是 ISR 中的 follower 与它相差条目不超过10条,如果当前的 follower 中最多有 10 条数据,某一时刻,生产者向 leader 中写入了 12 条数据,那么此刻,所有的 follower 都不满足条件,就会被全部踢出 ISR,然后再有新的 follower 进入 ISR,这种频繁的进出 ISR 过于耗费资源,所以就不再使用。
4.2.3 ack 的应答机制
ack 的应答机制可以有三个可能的取值:0,1,-1
0 代表不检查,生产者只管发送数据,不管 leader 或者 follower 接收到数据。
1 代表只检查 leader,生产者发送数据,等待 broker 发送 ack,只要当 leader 落盘就返回 ack,这种机制如果在 follower 同步成功之前,leader 挂掉,则会丢失数据。
-1 代表全部检查,生产者发送数据,等待 broker 发送 ack,只有当 leader 和 follower 全部落盘才发送 ack,这种机制如果在 follower 同步完成之后,broker 发送 ack 之前,leader 挂掉,那么将会引起数据重复。
4.2.4 消除重复数据
在传输重要的数据,如银行的交易数据时,既要保证数据不能丢失,又不能容忍重复,在 0.11 版本以前的 kafka 没有解决重复问题,因此只能数据流的下游即消费者来判断数据是否重复, 但在 0.11 版本 kafka 有了消除重复数据的机制——幂等性。
幂等性的实现:为了不让数据重复,那么必须在 leader 处检查数据是否已经接受过,那么依据什么来唯一确定一条数据呢?当然还是依据数据的来源了,Producer 在被初始化的时候会被分配一个 PID,发往同一 Partition 的消息会附带一个 Sequece Number(序列号),这三个值组成一个 key:<PID, Partition,SeqNumber>,在 broker 端每接收一条消息,就缓存一个这样的 key,如果有相同的 key 发过来,则会拒绝接收。但是,这种机制还有一个问题,如果 Producer 挂掉重启,那么它的 PID 就发生了变化,这样还是会发重复数据,kafka 目前还没有解决这个问题。
保证数据不丢失叫作 At Least Once, At Least Once + 幂等性 = Exactly Once
4.2.5 故障处理
现在假设这样一种情况:Leader 存储了 20 条数据,ISR 中有两个 follower,follower A 同步了 15 条数据,follower B 同步了 10 条数据,此时,leader 挂掉了,如果选取 follower B 当作新的 leader,此时它有 10 条数据没有接收,所以生产者要重新发送这 10 条数据,而 follower B 要同步 follower A 接收到的数据,那么它就会收到 5 条重复的数据;或者选取 follower A 当作新的 leader,那么 follower B 可以收到它没收到的那五条数据,这看起来没有问题,但是,如果此时之前的 leader ,那么它作为新的 follwer 就会同步新的 leader 接收到的数据,这又会产生重复,而且如果这种故障发生的次数多了,所有的 leader 和 follower 中存储的数据都是乱的。
因此 kafka 又研制了解决方案:
先解释图中元素:
LEO:每个副本的最后一个 offset
HW:消费者能看到的最大的 offset,ISR 队列中最小的 LEO
(1)leader 故障
当 leader 发生故障后,会从 ISR 中选出一个新的 leader,然后,为保证多个副本之间数据的一致性,其余的 follower 把自己的 log 文件高于 HW 的部分全部切掉,然后再一起从新的 leader 同步数据。
(2)follower 故障
当一个 follower 发生故障,它就会被踢出 ISR,重启之后,先读磁盘,找到自己上次读到的 HW,然后把高于 HW 的部分切掉,然后从 HW 开始同步 leader 中的数据,等到该 follower 的 LEO 大于等于 ISR 队列中的 HW,即 follower 的数据追上了 leader,它就可以加入 ISR 了。
五、Kafka 消费者
5.1 消费策略
所谓消费策略,就是指消费者如何获得消息队列中的数据,在 1.2.4 已经提到,一种是消息队列把数据推给消费者,一种是消费者主动从队列里拉。
推是指从消息队列把消息主动发送到消费者,消费者是被动地接收;而拉是指消费者主动到消息队列去取数据,两种方式各有优缺点。
推缺点是不同的消费者接收数据的速度可能不一样,而消息队列写出的速度却是统一的,所以可能存在的一种情况就是消费者 a 的写入速度是 100MB / s,而消费者 b 的写入速度是 50 MB / s,消息队列的写初速度是 100 Mb / s,这会导致消费者 b 被丢失数据。
拉就避免了上述说的那种缺点,但它又引进了新的问题:因为消费者是主动去消息队列提取数据,所以很有可能的一个现象就是消息队列里已经没有数据,但消费者依然循环去查看消息队列,这就造成了资源浪费。
Kafka 在经过权衡之后选用了拉的方式,同时使用了一种机制来减轻拉带来的问题:如果消费者发现消息队列里没有数据,那么它将过一段时间后再去查看,而不是以之前的恒定速率。
5.2 分区的分配策略
上面提到过,同一个消费者组中的消费者不能分配到同一个 partition,这自然而然就引出来了一个问题——partition 应该怎么分配給消费者,kafka 为我们提供了两种机制:RoundRobin 和 Range
5.2.1 轮询
轮询是把所有主题中的 partition 当作一个整体,然后根据 partition 的 hash 值把它们排一个序,然后一个一个依次分配到消费者。
优点:每一个消费者得到的 partition 最大相差 1,做到了负载均衡。
缺点:当消费者组中的消费者订阅的是不同的 topic 时,它们会得到不是自己订阅的 topic 的分区,因此,这种方式只适用于消费者组中的消费者订阅的是相同的主题。
5.2.2 Range
Range 解决了轮询的缺点,它是按照主题来分的,把主题中的 partition 依次分配到消费者。
优点:可以按照组里面消费者订阅的主题的不同来划分分区。
缺点:当组内的消费者订阅了相同的主题时,可能引起分配不均。例如:Customer1 和 Customer2 都订阅了主题 A 和主题 B,A、B 各有三个分区,因为是按主题划分的,A 把 partition1,partition2 给了 Customer1,partition3 给了 Customer2,同样的,B 又按照同样的策略把 partition1,partition2 给了 Customer1,partition3 给了 Customer2,此时 两个消费者中的分区相差的是 2。
5.2.3 什么时候用到分区策略
当消费者组内消费者个数发生变化的时候,无论是增加或者减少,都会把重新分配分区。
有一种可能性就是当消费者组内的消费者个数增加到大于分区总数的时候,首先重新划分分区,然后就有组里的消费者就必定有一部分没有分区了。
六、事务
Kafka 事务可以保证 Kafka 在 Exactly Once 语义的基础上,生产者和消费者可以跨分区会话,要么全部成功,要么全部失败。
6.1 Producer 事务
上面在说 ExactlyOnce 的时候提到了幂等性,它可以保证数据避免传输重复数据,但这是在 Producer Id 不变的情况下,如果 Producer 重启,则幂等性失效,所以 kafka 又通过事务来避免重复数据的传输。
每一个会话都有一个全局唯一的 Trasaction ID,并将 PID 与 Traction ID 进行绑定,这样每次生产者重启的时候,都会通过 Trasaction ID 来找到它之前的 PID,就可以完全保证幂等性了。