Leader Epoch的介入
上一节的内容所陈述的都是在正常情况下的 leader 副本与 follower 副本之间的同步过程,如果 leader 副本发生切换,那么同步过程又该如何处理呢?在 0.11.0.0 版本之前,Kafka 使用的是基于 HW 的同步机制,但这样有可能出现数据丢失或 leader 副本和 follower 副本数据不一致的问题。
首先我们来看一下数据丢失的问题,如上图所示,Replica B 是当前的 leader 副本(用 L 标记),Replica A 是 follower 副本。参照上一节中的过程来进行分析:在某一时刻,B 中有2条消息 m1 和 m2,A 从 B 中同步了这两条消息,此时 A 和 B 的 LEO 都为2,同时 HW 都为1;之后 A 再向 B 中发送请求以拉取消息,FetchRequest 请求中带上了 A 的 LEO 信息,B 在收到请求之后更新了自己的 HW 为2;B 中虽然没有更多的消息,但还是在延时一段时间之后返回 FetchResponse,并在其中包含了 HW 信息;最后 A 根据 FetchResponse 中的 HW 信息更新自己的 HW 为2。
可以看到整个过程中两者之间的 HW 同步有一个间隙,在 A 写入消息 m2 之后(LEO 更新为2)需要再一轮的 FetchRequest/FetchResponse 才能更新自身的 HW 为2。如上图所示,如果在这个时候 A 宕机了,那么在 A 重启之后会根据之前HW位置(这个值会存入本地的复制点文件 replication-offset-checkpoint)进行日志截断,这样便会将 m2 这条消息删除,此时 A 只剩下 m1 这一条消息,之后 A 再向 B 发送 FetchRequest 请求拉取消息。
此时若 B 再宕机,那么 A 就会被选举为新的 leader,如上图所示。B 恢复之后会成为 follower,由于 follower 副本 HW 不能比 leader 副本的 HW 高,所以还会做一次日志截断,以此将 HW 调整为1。这样一来 m2 这条消息就丢失了(就算B不能恢复,这条消息也同样丢失)。
对于这种情况,也有一些解决方法,比如等待所有 follower 副本都更新完自身的 HW 之后再更新 leader 副本的 HW,这样会增加多一轮的 FetchRequest/FetchResponse 延迟,自然不够妥当。还有一种方法就是 follower 副本恢复之后,在收到 leader 副本的 FetchResponse 前不要截断 follower 副本(follower 副本恢复之后会做两件事情:截断自身和向 leader 发送 FetchRequest 请求),不过这样也避免不了数据不一致的问题。
如上图所示,当前 leader 副本为 A,follower 副本为 B,A 中有2条消息 m1 和 m2,并且 HW 和 LEO 都为2,B 中有1条消息 m1,并且 HW 和 LEO 都为1。假设 A 和 B 同时“挂掉”,然后 B 第一个恢复过来并成为 leader,如下图所示。
之后 B 写入消息 m3,并将 LEO 和 HW 更新至2(假设所有场景中的 min.insync.replicas 参数配置为1)。此时 A 也恢复过来了,根据前面数据丢失场景中的介绍可知它会被赋予 follower 的角色,并且需要根据 HW 截断日志及发送 FetchRequest 至 B,不过此时 A 的 HW 正好也为2,那么就可以不做任何调整了,如下图所示。
如此一来 A 中保留了 m2 而 B 中没有,B 中新增了 m3 而 A 也同步不到,这样 A 和 B 就出现了数据不一致的情形。
为了解决上述两种问题,Kafka 从 0.11.0.0 开始引入了 leader epoch 的概念,在需要截断数据的时候使用 leader epoch 作为参考依据而不是原本的 HW。leader epoch 代表 leader 的纪元信息(epoch),初始值为0。每当 leader 变更一次,leader epoch 的值就会加1,相当于为 leader 增设了一个版本号。
与此同时,每个副本中还会增设一个矢量 <LeaderEpoch => StartOffset>,其中 StartOffset 表示当前 LeaderEpoch 下写入的第一条消息的偏移量。每个副本的 Log 下都有一个 leader-epoch-checkpoint 文件,在发生 leader epoch 变更时,会将对应的矢量对追加到这个文件中。在讲述 v2 版本的消息格式时就提到了消息集中的 partition leader epoch 字段,而这个字段正对应这里讲述的 leader epoch。
下面我们再来看一下引入 leader epoch 之后如何应付前面所说的数据丢失和数据不一致的场景。首先讲述应对数据丢失的问题,如下图所示,这里只是多了 LE(LeaderEpoch 的缩写,当前 A 和 B 中的 LE 都为0)。
同样A发生重启,之后A不是先忙着截断日志而是先发送OffsetsForLeaderEpochRequest请求给B(OffsetsForLeaderEpochRequest请求体结构如上图所示,其中包含A当前的LeaderEpoch值),B作为目前的leader在收到请求之后会返回当前的LEO(LogEndOffset,注意图中LE0和LEO的不同),与请求对应的响应为OffsetsForLeaderEpochResponse,对应的响应体结构可以参考下面第一张图,整个过程可以参考下面第二张图。
如果 A 中的 LeaderEpoch(假设为 LE_A)和 B 中的不相同,那么 B 此时会查找 LeaderEpoch 为 LE_A+1 对应的 StartOffset 并返回给 A,也就是 LE_A 对应的 LEO,所以我们可以将 OffsetsForLeaderEpochRequest 的请求看作用来查找 follower 副本当前 LeaderEpoch 的 LEO。
如上图所示,A 在收到2之后发现和目前的 LEO 相同,也就不需要截断日志了。之后 B 发生了宕机,A 成为新的 leader,那么对应的 LE=0 也变成了 LE=1,对应的消息 m2 此时就得到了保留,这是原本所不能的,如下图所示。之后不管 B 有没有恢复,后续的消息都可以以 LE1 为 LeaderEpoch 陆续追加到 A 中。
下面我们再来看一下 leader epoch 如何应对数据不一致的场景。如下图所示,当前 A 为 leader,B 为 follower,A 中有2条消息 m1 和 m2,而 B 中有1条消息 m1。假设 A 和 B 同时“挂掉”,然后 B 第一个恢复过来并成为新的 leader。
之后 B 写入消息 m3,并将 LEO 和 HW 更新至2,如下图所示。注意此时的 LeaderEpoch 已经从 LE0 增至 LE1 了。
紧接着 A 也恢复过来成为 follower 并向 B 发送 OffsetsForLeaderEpochRequest 请求,此时 A 的 LeaderEpoch 为 LE0。B 根据 LE0 查询到对应的 offset 为1并返回给 A,A 就截断日志并删除了消息 m2,如下图所示。之后 A 发送 FetchRequest 至 B 请求来同步数据,最终A和B中都有两条消息 m1 和 m3,HW 和 LEO都为2,并且 LeaderEpoch 都为 LE1,如此便解决了数据不一致的问题。
为什么不支持读写分离
在 Kafka 中,生产者写入消息、消费者读取消息的操作都是与 leader 副本进行交互的,从而实现的是一种主写主读的生产消费模型。数据库、Redis 等都具备主写主读的功能,与此同时还支持主写从读的功能,主写从读也就是读写分离,为了与主写主读对应,这里就以主写从读来称呼。Kafka 并不支持主写从读,这是为什么呢?
从代码层面上来说,虽然增加了代码复杂度,但在 Kafka 中这种功能完全可以支持。对于这个问题,我们可以从“收益点”这个角度来做具体分析。主写从读可以让从节点去分担主节点的负载压力,预防主节点负载过重而从节点却空闲的情况发生。但是主写从读也有2个很明显的缺点:
- 数据一致性问题。数据从主节点转到从节点必然会有一个延时的时间窗口,这个时间窗口会导致主从节点之间的数据不一致。某一时刻,在主节点和从节点中 A 数据的值都为 X,之后将主节点中 A 的值修改为 Y,那么在这个变更通知到从节点之前,应用读取从节点中的 A 数据的值并不为最新的 Y,由此便产生了数据不一致的问题。
- 延时问题。类似 Redis 这种组件,数据从写入主节点到同步至从节点中的过程需要经历网络→主节点内存→网络→从节点内存这几个阶段,整个过程会耗费一定的时间。而在 Kafka 中,主从同步会比 Redis 更加耗时,它需要经历网络→主节点内存→主节点磁盘→网络→从节点内存→从节点磁盘这几个阶段。对延时敏感的应用而言,主写从读的功能并不太适用。
现实情况下,很多应用既可以忍受一定程度上的延时,也可以忍受一段时间内的数据不一致的情况,那么对于这种情况,Kafka 是否有必要支持主写从读的功能呢?
主写从读可以均摊一定的负载却不能做到完全的负载均衡,比如对于数据写压力很大而读压力很小的情况,从节点只能分摊很少的负载压力,而绝大多数压力还是在主节点上。而在 Kafka 中却可以达到很大程度上的负载均衡,而且这种均衡是在主写主读的架构上实现的。我们来看一下 Kafka 的生产消费模型,如下图所示。
如上图所示,在 Kafka 集群中有3个分区,每个分区有3个副本,正好均匀地分布在3个 broker 上,灰色阴影的代表 leader 副本,非灰色阴影的代表 follower 副本,虚线表示 follower 副本从 leader 副本上拉取消息。当生产者写入消息的时候都写入 leader 副本,对于上图中的情形,每个 broker 都有消息从生产者流入;当消费者读取消息的时候也是从 leader 副本中读取的,对于上图中的情形,每个 broker 都有消息流出到消费者。
我们很明显地可以看出,每个 broker 上的读写负载都是一样的,这就说明 Kafka 可以通过主写主读实现主写从读实现不了的负载均衡。有以下几种情况(包含但不仅限于)会造成一定程度上的负载不均衡:
- broker 端的分区分配不均。当创建主题的时候可能会出现某些 broker 分配到的分区数多而其他 broker 分配到的分区数少,那么自然而然地分配到的 leader 副本也就不均。
- 生产者写入消息不均。生产者可能只对某些 broker 中的 leader 副本进行大量的写入操作,而对其他 broker 中的 leader 副本不闻不问。
- 消费者消费消息不均。消费者可能只对某些 broker 中的 leader 副本进行大量的拉取操作,而对其他 broker 中的 leader 副本不闻不问。
- leader 副本的切换不均。在实际应用中可能会由于broker宕机而造成主从副本的切换,或者分区副本的重分配等,这些动作都有可能造成各个 broker 中 leader 副本的分配不均。
对此,我们可以做一些防范措施。针对第一种情况,在主题创建的时候尽可能使分区分配得均衡,好在Kafka中相应的分配算法也是在极力地追求这一目标,如果是开发人员自定义的分配,则需要注意这方面的内容。对于第二和第三种情况,主写从读也无法解决。对于第四种情况,Kafka提供了优先副本的选举来达到leader副本的均衡,与此同时,也可以配合相应的监控、告警和运维平台来实现均衡的优化。
在实际应用中,配合监控、告警、运维相结合的生态平台,在绝大多数情况下 Kafka 都能做到很大程度上的负载均衡。总的来说,Kafka 只支持主写主读有几个优点:可以简化代码的实现逻辑,减少出错的可能;将负载粒度细化均摊,与主写从读相比,不仅负载效能更好,而且对用户可控;没有延时的影响;在副本稳定的情况下,不会出现数据不一致的情况。为此,Kafka 又何必再去实现对它而言毫无收益的主写从读的功能呢?这一切都得益于 Kafka 优秀的架构设计,从某种意义上来说,主写从读是由于设计上的缺陷而形成的权宜之计。