目前生产环境中,使用较多的消息队列有ActiveMQ、RabbitMQ、ZeroMQ、Kafka、MetaMQ、RocketMQ等。

消息系统的作用:异步处理、应用解耦、流量削峰和消息通讯

  1. 异步处理 用户注册后,异步发送邮件和注册短信。 缩短响应时间,提高吞吐量。
  2. 应用解耦 消息队列把数据进行持久化直到它们已经被完全处理,通过这一方式规避了数据丢失风险,两端互不影响。
  3. 流量削峰 通过队列暂存或者队列限流来削峰。 如秒杀系统,流量暴增,此时会使用队列进行排队暂存,从而保护下单服务。 限流:队列长度超过最大数量,则直接抛弃用户请求或跳转到错误页面。
  4. 消息通讯 利用异步通信机制,实现点对点消息队列或者发布-订阅模式的消息通讯。

benchmark

http://www.jasongj.com/2015/12/31/KafkaColumn5_kafka_benchmark/ 开启Kafka JMX Reporter并使用19797端口,利用Kafka-Manager的JMX polling功能监控性能测试过程中的吞吐率。

Producer数
单个Producer每秒可成功发送约128万条Payload为100字节的消息,并且随着Producer个数的提升,每秒总共发送的消息量线性提升。

消息大小
消息越长,每秒所能发送的消息数越少,而每秒所能发送的消息的量(MB)越大。
Payload越大,这些Metadata占比越小,同时发送时的批量发送的消息体积越大,越容易得到更高的吞吐量(MB/s)。

Partition个数
当Partition数量小于Broker个数时,Partition数量越大,吞吐率越高,且呈线性提升。
当Partition数量多于Broker个数时,总吞吐量并未有所提升,甚至还有所下降。Partition数量为Broker数量整数倍时吞吐量明显比其它情况略高。可能的原因是:不同Broker上的Partition数量不同,所以每个broker的负载不同,不能最大化集群吞吐量。

Replica数
随着Replica数量的增加,吞吐率随之下降。但吞吐率的下降并非线性下降,因为多个Follower的数据复制是并行进行的,而非串行进行。

Consumer数
随着Consumer数量的增加,集群总吞吐量线性增加。单个Consumer每秒可消费306万条消息,该数量远大于单个Producer每秒可消费的消息数量,保证了在合理的配置下,消息可被及时处理。

1. 基本概念

kafka有四个核心API:

  • 应用程序使用 Producer API 发布消息到1个或多个topic(主题)。
  • 应用程序使用 Consumer API 来订阅一个或多个topic,并处理产生的消息。
  • 应用程序使用 Streams API 充当一个流处理器,从1个或多个topic消费输入流,并生产一个输出流到1个或多个输出topic,有效地将输入流转换到输出流。
  • Connector API允许构建或运行可重复使用的生产者或消费者,将topic连接到现有的应用程序或数据系统。例如,一个关系数据库的连接器可捕获每一个变化。

分布式partition

Kafka通过Zookeeper管理集群配置,选举leader,以及在Consumer Group发生变化时进行rebalance。

Log的分区被分布到集群中的多个服务器上。每个服务器处理它分到的分区。 根据配置每个分区还可以复制到其它服务器作为备份容错。 每个分区有一个leader,零或多个follower。Leader处理此分区的所有的读写请求,而follower被动的复制数据。如果leader宕机,其它的一个follower会被推举为新的leader。 一台服务器可能同时是一个分区的leader,另一个分区的follower。

生产者也负责选择发布到Topic上的哪一个分区。最简单的方式从分区列表中轮流选择。也可以根据某种算法依照权重选择分区。

Consumer Group

kafka的每个topic都有两种模式:队列和发布订阅。
队列模式:允许同名的group成员瓜分处理。 发布订阅:允许广播消息给多个不同名的consumer group.

相同的消费者组中不能有比分区更多的消费者,否则多出的消费者一直处于空等待,不会收到消息。

每个Topic分区只能由同一个消费者组内唯一的一个consumer来消费。

尽管服务器按顺序发送,消息异步传递到消费者,因此消息可能乱序到达消费者。这意味着消息存在并行消费的情况,顺序就无法保证。 kafka某个topic的partition在同一个group中有唯一的消费者,保证了各个分区的顺序消费。

|–>kafka各种语言客户端


2. Effective持久化

JBOD配置的6个7200rpm SATA RAID-5 的磁盘阵列上线性写的速度大概是600M/秒,但是随机写的速度只有100K/秒,两者相差将近6000倍。线性读写在大多数应用场景下是可以预测的,因此,现代的操作系统提供了预读和写技术,将多个大块预取数据,并将较小的写入组合成一个大的物理写。更多的讨论可以在ACM Queue Artical中找到,他们发现,对磁盘的线性读在有些情况下可以比内存的随机访问要更快。

由于这些因素,使用文件系统并依赖pagecache(页缓存)将优于缓存在内存中或其他的结构 - 我们通过自动访问所有可用的内存将使得可用的内存至少提高一倍。并可能通过存储紧凑型字节结构再次提高一倍。这将使得32G机器上高达28-32GB的缓存,并无需GC。

此外,即使服务重新启动,该缓存保持可用,而进程内的缓存则需要在内存中重建(10GB缓存需要10分钟),否则将需要启动完全冷却的缓存(这意味着可怕的初始化性能)。这也大大简化了代码,因为在缓存和文件系统之间维持的一致性的所有逻辑现在都在OS中,这比一次性进程更加有效和更正确。如果你的磁盘支持线性的读取,那么预读取将有效地将每个磁盘中有用的数据预填充此缓存。

这说明了一个非常简单的设计:当空间耗尽时,将它全部刷出文件系统。我们反过来看,不要在内存中尽可能多地维护, 所有数据立即写入文件系统上的持久性日志上,而不必刷新到磁盘上。 实际上这只是意味着它被转移到内核的页缓存中。

这种以页缓存为中心的设计风格在这里描述:

Varnish 先分配一些虚拟内存,并告知操作系统将这段内存备份到磁盘上的一个文件的存储空间中。当需要使用这个对象时,它只要提交那块虚拟内存空间,操作系统会分配一个空闲RAM页面,并从后备文件中读入其内容。
Varnish 并不会去控制哪些内容缓存在 RAM 中或哪些不是,不会自己把暂时用不到的对象转移到磁盘中去,内核代码和硬件维护程序会处理好这些事情。 Varnish只需使用一个后备文件,而不用不把每个(暂时不用的)对象都放在不同的文件中去。
虚拟内存就是为了在实际数据量大于物理内存容量的时候让编程变得更容易而出现的。

2.1 顺序写磁盘

根据一些场景下顺序写磁盘快于随机写内存所述,将写磁盘的过程变为顺序写,可极大提高对磁盘的利用率。

Kafka的整个设计中,Partition相当于一个非常长的数组,而Broker接收到的所有消息顺序写入这个大数组中。同时Consumer通过Offset顺序消费这些数据,并且不删除已经消费的数据,从而避免了随机写磁盘的过程。

由于磁盘有限,不可能保存所有数据,实际上作为消息系统Kafka也没必要保存所有数据,需要删除旧的数据。而这个删除过程,并非通过使用“读-写”模式去修改文件,而是将Partition分为多个Segment,每个Segment对应一批物理文件,通过删除整个文件(Segment对应的整个log文件和index文件)的方式去删除Partition内的数据。这种方式清除旧数据的方式,也避免了对文件的随机写操作。

2.2 利用PageCache

在linux系统中,为了加快文件的读写,内核中提供了page cache作为缓存,称为页面缓存(page cache)。为了加快对块设备的读写,内核中还提供了buffer cache作为缓存。在2.4内核中,这两者是分开的。这样就造成了双缓冲,因为文件读写最后还是转化为对块设备的读写。在2.6中,buffer cache合并到page cache中,对应的页面叫作buffer page。当进行文件读写时,如果文件在磁盘上的存储块是连续的,那么文件在page cache中对应的页是普通的page,如果文件在磁盘上的数据块是不连续的,或者是设备文件,那么文件在page cache中对应的页是buffer page。buffer page与普通的page相比,每个页多了几个buffer_head结构体(个数视块的大小而定)。此外,如果对单独的块(如超级块)直接进行读写,对应的page cache中的页也是buffer page。这两种页面虽然形式略有不同,但是最终他们的数据都会被封装成bio结构体,提交到通用块设备驱动层,统一进行I/O调度。

预备知识, 为什么kafka那么快

缓冲IO

写操作:将数据从用户空间复制到内核空间的缓存中。这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘中由操作系统决定,除非显示地调用了sync同步命令:sync、fsync与fdatasync。 这里的内核缓冲区也就是页缓存-PageCache,是虚拟内存空间。

读操作:先从内核缓冲区读取,没有缓存再从磁盘读取。减少了读盘次数。

缓存I/O机制 需要在应用程序地址空间(用户空间)和缓存(内核空间)之间进行多次数据拷贝操作

直接IO

应用程序直接访问磁盘数据,而不经过内核缓冲区。这样做的目的是减少一次从内核缓冲区到用户程序缓存的数据复制。比如说数据库管理系统这类应用,它们更倾向于选择它们自己的缓存机制。

O_SYNC,O_DSYNC标志表示每次write面比等到flush完成才返回,效果等同于write后紧接一个fsync()或fdatasync(),不过按APUE里的测试,OS做了优化,性能会比自己每次调fdatasync()好一点,但与只是write相比就慢太多了。
O_DIRECT标志表示完全跳过Page Cache,写的时候从ext3层直接发送给IO调度层,不过这样子,读的时候也就不能从Page Cache里读取必须去访问磁盘文件了,而且要求所有IO请求长度,缓冲区对齐及偏移都必须是底层上去大小的。所以开O_DIRECT的时候一定要在应用层做好Cache

mmap内存映射文件

数据会先通过DMA拷贝到操作系统内核的缓冲区。 接着,应用程序跟操作系统共享这个缓冲区。 内存映射文件MMAP只有一次页缓存的复制,读时从磁盘文件复制到页缓存,写时从页缓存flush到磁盘文件看。

sendfile

将文件中的数据拷贝到操作系统内核缓冲区中,然后数据被拷贝到与socket相关的内核缓冲区。
sendfile()系统调用不需要将数据拷贝或映射到应用程序地址空间,所以sendfile()只适用于应用程序地址空间不需要对所访问数据进行处理的情况。比如apache、nginx等web服务器使用sendfile传输静态文件

Kafka是用mmap作为文件读写方式的,它就是一个文件句柄,所以直接把它传给sendfile;偏移也好解决,用户会自己保持这个offset,每次请求都会发送这个offset。

2.2.1 mmap

现代操作系统可以将内存中的所有剩余空间用作磁盘缓存,而当内存回收的时候几乎没有性能损失。
kafka写操作时,依赖底层文件系统的pagecache功能,pagecache会将尽量多的将空闲内存,当做磁盘缓存,写操作先写到pageCache,并将该page标记为dirty;发生读操作时,会先从pageCache中查找,当发生缺页时,才会去磁盘调整;当有其他应用申请内存时,回收pageCache的代价是很低的。

使用pageCache的缓存功能,会减少我们队JVM的内存依赖,JVM为我们提供了GC功能,依赖JVM内存在kafka高吞吐,大数据的场景下有很多问题。如果heap管理缓存,那么JVM的gc会频繁扫描heap空间,带来的开销很大,如果heap过大,full gc带来的成本也很高;

所有的进程内缓存在OS中都有一份同样的PageCache。不使用进程内缓存,就腾出了内存空间,可以用来存放页面缓存的空间几乎可以翻倍。如果KAFKA重启,进程内缓存就会丢失,但是使用操作系统的page cache依然可以继续使用。

通过mmap,直接利用操作系统的Page来实现文件到物理内存的直接映射。完成映射之后你对物理内存的操作会被同步到硬盘上(操作系统在适当的时候)。
使用这种方式可以获取很大的I/O提升,省去了用户空间到内核空间复制的开销
(调用文件的read会把数据先放到内核空间的内存中,然后再复制到用户空间的内存中。)也有一个很明显的缺陷——不可靠,写到mmap中的数据并没有被真正的写到硬盘,操作系统会在程序主动调用flush的时候才把数据真正的写到硬盘。

Kafka提供了一个参数——producer.type来控制是不是主动flush,如果Kafka写入到mmap之后就立即flush然后再返回Producer叫同步(sync);写入mmap之后立即返回Producer不调用flush叫异步(async)。

mmap其实是Linux中的一个函数就是用来实现内存映射的,谢谢Java NIO,它给我提供了一个mappedbytebuffer类可以用来实现内存映射

2.2.2 sendfile零拷贝传输

现代操作系统将数据从页缓存传输到socket;在Linux中,是通过sendfile系统调用来完成的。Java提供了访问这个系统调用的方法:FileChannel.transferTo

传统网络IO模型中,数据从文件传输到socket的公共数据路径:

操作系统将数据从磁盘读入到内核空间的页缓存 -->
应用程序将数据从内核空间读入到用户空间缓存中 -->
应用程序将数据写回到内核空间到socket缓存中 -->
操作系统将数据从socket缓冲区复制到网卡缓冲区,以便将数据经网络发出

这样做明显是低效的,这里有四次拷贝,两次系统调用。 如果使用sendfile,再次拷贝可以被避免:允许操作系统将数据直接从页缓存发送到网络缓冲区上。所以在这个优化的路径中,只有最后一步将数据拷贝到网卡缓存中是需要的。

2.3 端到端的批量压缩

有效的压缩需要压缩多个消息,而不是单独压缩每个消息。
Kafka通过递归消息集来支持这一点。 一批消息可以一起压缩并以此形式发送到服务器。 这批消息将以压缩形式写入,并将在日志中保持压缩,并且只能由消费者解压缩。

Kafka支持GZIP和Snappy压缩协议,更多的细节可以在这里找到:https://cwiki.apache.org/confluence/display/KAFKA/Compression

3. 生产者消费者

生产者将数据直接发送到分区leader的broker上(没有任何干预的路由层)。为了帮助producer做到这一点,Kafka所有节点都可应答给producer哪些服务器是正常的,哪些topic分区的leader允许producer在给定的时间内可以直接请求。

kafka消费者通过向broker的leader分区发起“提取”请求。

允许用户通过key去指定分区和使用使用hash来指向分区。例如:如果选择的key是用户ID,那么对给定的用户ID的所有数据将被发送到相同分区。反过来,消费者有能指定消费那个分区,这种设计风格,让消费者可以对敏感性的消息进行局部处理。

批处理是效率的一大驱动力,kafka生产者使用批处理试图在内存中积累数据,在单个请求发送累积的大批量数据,可以配置批处理积累的不大于一定的消息数,并等待时间不超过配置的延迟(64k 或 10毫秒)。

Pull or Push ?

push模式和pull模式各有优劣。push模式很难适应消费速率不同的消费者,因为消息发送速率是由broker决定的。push模式的目标是尽可能以最快速度传递消息,但是这样很容易造成消费者来不及处理消息,典型的表现就是拒绝服务以及网络拥塞。而pull模式则可以根据consumer的消费能力以适当的速率消费消息

基于pull模式的另一个优点是,它有助于积极的批处理的数据发送到消费者。基于push模式必须选择要么立即发送请求或者积累更多的数据,然后在不知道下游消费者是否能够立即处理它的情况下发送,如果是低延迟,这将导致一次只发送一条消息,以便传输缓存,这是实在是一种浪费,基于pull的设计解决这个问题,消费者总是pull在日志的当前位置之后pull所有可用的消息(或配置一些大size),所以消费者可设置消费多大的量,也不会引起不必要的等待时间。

基于pull模式不足之处在于,如果broker没有数据,消费者会轮询,忙等待数据直到数据到达,为了避免这种情况,我们允许消费者在pull请求时候使用“long poll”进行阻塞,直到数据到达(并且设置等待时间的好处是可以积累消息,组成大数据块一并发送)。

消费者跟踪

大多数消息系统保留在broker上消费消息的元数据。 也就是说,当消息发送给消费者时,broker本地立即记录哪些已经消费,或者可以等待消费者的应答确认。

如果broker每次通过网络发出消息立即记录的话,那么如果消费者无法处理该消息(比如崩溃或请求超时),则该消息将丢失。为了解决这个问题,许多消息系统添加了一个“应答”功能,这意味着当消息发送时,消息仅仅标记为“发送”而不是“已消费”。broker等待消费者应答该消息,消息才被标记为“已消费”。这确认解决了丢失消息的问题,但是产生了一个新的问题。首先,如果消费者处理了消息,但是在发送应答时失败了,那么该消息将会被处理两次。第二个问题是关于性能,现在broker必须保持关于每个单个消息的多个状态(首先锁定它,所以它不会被发送两次,然后将其标记为永久已消耗,以便可以被删除)。

kafka的topic被分为一组完全有序的分区,每个分区在任何给定的时间都由每个订阅消费者组中的一个消费者消费。 这意味着消费者在每个分区中的位置只是一个整数,下一个消息消费的偏移量。 这使得关于已消费到哪里的状态变得非常的小,每个分区只有一个数字。 可以定期检查此状态。 这使得等同于消息应答并更轻量。

/consumers/[groupId]/offsets/[topic]/[partitionId] -> long (offset)

4. kafka的消息保证机制

kafka提供了生产者和消费者之间的担保语义。有多种可能的消息传递保证可以提供:

  • At most once — Messages may be lost but are never redelivered.
    最多一次 --消息可能丢失,但绝不会重发。
  • At least once — Messages are never lost but may be redelivered.
    至少一次 --消息绝不会丢失,但有可能重新发送。
  • Exactly once — this is what people actually want, each message is delivered once and only once.
    正好一次 --这是人们真正想要的,每个消息传递一次且仅一次。

很多消息系统声称提供“正好一次”的传递语义,但是在阅读相关文章时,更多是误导(例如,它们没有解释消费者或生产者可能失败的情况,有多个消费者进程的情况,或写入磁盘的数据可能丢失的情况)

向Kafka发布一条消息到时,该消息 “committed” 到了日志,一旦发布的消息是 committed 的,只要副本分区写入了此消息的一个broker仍然"活着”,它就不会丢失。

现在假设一个完美的不会丢消息的broker,并去了解如何保障生产者和消费者的。

生产者角度:

如果一个生产者发布消息并且正好遇到网络错误,就不能确定已提交的消息是在这个错误发生之前或之后。

在0.11.0.0之前,如果一个生产者没有收到消息提交的响应,那么只能重新发送消息。 这提供了At least once语义,因为如果原始请求实际上已成功,则在重新发送期间再次将消息写入到日志中。自0.11.0.0起,Kafka生产者支持幂等传递选项,保证重新发送不会导致日志中重复。 broker为每个生产者分配一个ID,并通过生产者发送的序列号为每个消息进行去重。Producer每次发消息带一个sequenceId,接收端进行校验,重复的丢弃掉。

从0.11.0.0开始,生产者支持使用类似事务的语义将消息发送到多个topic分区的能力:即所有消息都被成功写入,或者没有。这个主要用于Kafka topic之间“Exactly once”处理(如下所述)。

并不是所有的情况都需要这么强力的保障,对于延迟敏感的,我们允许生产者指定它想要的耐久性水平。如生产者可以指定它获取需等待10毫秒量级上的响应。生产者也可以指定异步发送,或只等待leader(不需要副本的响应)有响应。

消费者角度:

现在让我们从消费者的角度描述语义。所有的副本都有相同的日志with相同的偏移量。消费者控制offset在日志中的位置。如果消费者永不宕机它可能只是在内存中存储这个位置,但是如果消费者故障,我们希望这个topic分区被另一个进程接管,新进程需要选择一个合适的位置开始处理。我们假设消费者读取了一些消息,几种选项用于处理消息和更新它的位置。

  • 最多一次
    消费者读取消息–然后在日志中保存它的位置–最后处理消息。在这种情况下,有可能消费者保存了位置之后,但是处理消息输出之前崩溃了。在这种情况下,接管处理的进程会在已保存的位置开始,即使该位置之前有几个消息尚未处理。这对应于“最多一次” ,在消费者处理失败消息的情况下,不进行处理。
  • 至少一次
    读取消息–处理消息–最后保存消息的位置。
    在这种情况下,可能消费进程处理消息之后、保存它的位置之前崩溃了。在这种情况下,当新的进程接管了它,这将接收已经被处理的前几个消息。这就符合了“至少一次”的语义。在多数情况下消息有一个主键,以便更新幂等(其任意多次执行所产生的影响均与一次执行的影响相同)。
  • 正好一次
    那么什么是“正好一次”语义(也就是你真正想要的东西)? 我们可以利用之前提到过的0.11.0.0中的生产者新事务功能:消费者的位置作为消息存储到topic中,因此我们可以与接收处理后的数据的输出topic使用相同的事务写入offset到Kafka。如果事务中断,则消费者的位置将恢复到老的值,根据其”隔离级别“,其他消费者将不会看到输出topic的生成数据,在默认的”读取未提交“隔离级别中,所有消息对消费者都是可见的,即使是被中断的事务的消息。但是在”读取提交“级别中,消费者将只从已提交的事务中返回消息。

kafka默认是保证“至少一次”传递,并允许用户通过禁止生产者重试和处理一批消息前提交它的偏移量来实现 “最多一次”传递。而“正好一次”传递需要与目标存储系统合作,实现这一目标的典型方法是在消费者位置的存储和消费者输出的存储之间引入两阶段的”提交“。但kafka提供了偏移量,所以实现这个很简单:通过让消费者将其offset存储在与其输出相同的位置。这样最好,因为大多数的输出系统不支持两阶段”提交“。

5. kafka副本和leader选举

kafka topic的默认复制因子是1–就是不需要副本。
副本以topic的Partition为单位。 kafka每个分区都有一个单独的leader,0个或多个follower。副本的总数包括leader。所有的读取和写入到该分区的leader。 任何时间点上,leader比follower多几条消息尚未同步到follower。

Followers作为普通的消费者从leader中消费消息并应用到自己的日志中。 并允许follower从leader拉取批量日志应用到自己的日志,这样具有良好的性能。

定义alive

和大多数分布式系统一样,自动处理失败的节点。需要精确的定义什么样的节点是“活着”(alive)的,对于kafka的节点活着有2个条件:

  1. 一个节点必须能维持与zookeeper的会话(通过zookeeper的心跳机制)
  2. 如果它是一个slave,它必须复制leader并且不能落后"太多".

leader跟踪“同步中”(in sync)节点。如果一个follower死掉,卡住,或落后,leader将从同步副本列表中移除它。落后是通过replica.lag.max.messages配置控制,卡住是通过replica.lag.time.max.ms配置控制的。

kafka不处理节点产生任意或恶意的响应(也许是因为bug或犯规)–所谓的拜占庭故障。

定义commited

我们现在可以更精确的定义commited:
当该分区的所有同步副本已经写入到其日志中时,该消息视为“已提交”。

只有“已提交”的消息才会给到消费者。所有消费者无需担心如果leader故障,会消费到丢失的消息。另一方面,生产者可以选择等待消费提交,这取决于你更偏向延迟或耐用性(通过acks控制)。生产者请求确保消息已经写入到全部的同步副本中的acknowlegement时,有一个设置topic同步副本的“最小数”。如果生产者要求不严格,则即使同步副本的数量低于最小值,也可以提交和消费该消息。

kafka提供担保,在任何时候,只要至少有一个同步副本活着,已提交的消息就不会丢失。
kafka短暂的故障转移期间,失败的节点仍可用。但可能无法在网络分区仍然可用。

日志复制、Quorums或ISR一致性保证

Quorum:原指为了处理事务、拥有做出决定的权力而必须出席的众议员或参议员的数量(一般指半数以上)。

A replicated log models the process of(模拟了…过程) coming into consensus on the order of a series of values。

有很多方法可以实现这一点,但最简单和最快的是leader提供选择的排序值,只要leader活着,所有的followers只需要复制和排序。当然,如果leader没有故障,我们就不需要follower!当leader确实故障了,我们需要从follower中选出新的leader,但是follower自己可能落后或崩溃,所以我们必须选择一个最新的follower。
日志复制算法必须提供保证,如果我们告诉客户端消息已提交时,leader故障了,我们选举的新的leader必须要有这条消息,这就产生一个权衡:如果leader声明一条消息已提交之前,等待更多的follwer确认应答的话,将会有更多有资格的leader。

如果你选择必须比较【当前要应答的序号和follower已有的日志序号】来选出一个leader的话(这里保证有重叠),那么这就是所谓的Quorum(法定人数)。

先看下多数投票法:

一种常见的方法,用多数投票决定leader选举。kafka不是这样做的,但先让我们了解这个权衡,假如,我们有2f+1副本,如果f+1副本必须在leader声明一个提交之前收到消息,并且如果我们选举新的leader,至少从f+1副本选出最完整日志的follwer,并且不大于f的失败,担保了leader拥有所有已提交的信息。这是因为任何f+1副本中,必须至少有一个副本,其中包含所有已提交的消息。该副本的日志是最完整的,因此选定为新的leader。有许多其余细节,每个算法必须处理(如 精确的定义是什么让一个日志更加完整,确保日志一致性,leader故障期间或更改服务器的副本集),但我们现在不讲这些。

这种投票表决的方式有一个非常好的特性:仅依赖速度最快的服务器,也就是说,如果复制因子为三个,由最快的一个来确定。有各种丰富的算法,包括zookeeper的Zab、 Raft和 Viewstamped Replication。kafka实现的最相似的学术理论是微软的PacificA。

多数投票的缺点是,故障数还不太多的情况下会让你没有候选人可选,要容忍1个故障需要3个数据副本,容忍2个故障需要5个数据副本。实际的系统以我们的经验只能容忍单个故障的冗余是不够的,但是如果5个数据副本,每个写5次,5倍的磁盘空间要求,1/5的吞吐量,这对于大数据量系统是不实用的,这可能是quorum算法更通常在共享集群配置。如zookeeper,主要用于数据存储的系统是不太常见的。例如,在HDFS namenode的高可用性特性是建立在majority-vote-based journal,但这更昂贵的方法不能用于数据本身。

kafka的复制机制

有两种方案可以保证强一致的数据复制: primary-backup replication 和 quorum-based replication。两种方案都要求选举出一个leader,其它的副本作为follower。所有的写都发给leader, 然后leader将消息发给follower。

基于quorum的复制可以采用raft、paxos等算法, 比如Zookeeper、 Google Spanner、etcd等。在有 2n + 1个节点的情况下,最多可以容忍n个节点失败。

基于primary-backup的复制等primary和backup都写入成功才算消息接收成功, 在有n个节点的情况下,最多可以容忍n-1节点失败,比如微软的PacifiaA。

这两种方式各有优缺点。
1、基于quorum的方式延迟(latency)可能会好于primary-backup,因为基于quorum的方式只需要部分节点写入成功就可以返回。
2、在同样多的节点下基于primary-backup的复制可以容忍更多的节点失败,只要有一个节点活着就可以工作。
3、primary-backup在两个节点的情况下就可以提供容错,而基于quorum的方式至少需要三个节点。

Kafka采用了primary-backup方式,也就是主从模式, 主要是基于容错的考虑,并且在两个节点的情况下也可以提供高可用。

ISR之法

kafka采用了一种稍微不同的方法选择quorum,而不是多数投票,kafka动态维护一组同步leader数据的副本(ISR),只有这个组的成员才有资格当选leader,kafka副本写入不被认为是已提交,直到所有的同步副本已经接收才认为是。这组ISR保存在zookeeper,正因为如此,在ISR中的任何副本都有资格当选leader,这是kafka的使用模型,有多个分区和确保leader平衡是很重要的一个重要因素。有了这个模型,ISR和f+1副本,kafka的主题可以容忍f失败而不会丢失已提交的消息

在实践中,容忍f故障,多数投票法和ISR方法将等待相同数量的副本提交消息之前进行确认(例如:活着1个,多数的quorum故障的情况,需要3个副本和1个应答,而ISR方法只需要2个副本和1个应答)。排除最慢的服务器是多数投票的优点,但是,我们认为允许客户选择是否阻塞消息的提交可以改善这个问题,并通过降低复制因子获得额外的吞吐量和磁盘空间也是值得的。

Kafka引入了 ISR的概念。ISR是in-sync replicas的简写。ISR的副本保持和leader的同步,当然leader本身也在ISR中。初始状态所有的副本都处于ISR中,当一个消息发送给leader的时候,leader会等待ISR中所有的副本告诉它已经接收了这个消息,如果一个副本失败了,那么它会被移除ISR。下一条消息来的时候,leader就会将消息发送给当前的ISR中节点了。

同时,leader还维护着HW(high watermark),这是一个分区的最后一条消息的offset。HW会持续的将HW发送给slave,broker可以将它写入到磁盘中以便将来恢复。
http://colobu.com/2017/11/02/kafka-replication/

以下摘自www.jasongj.com

Producer在发布消息到某个Partition时,先通过Zookeeper找到该Partition的Leader,然后无论该Topic的Replication Factor为多少(也即该Partition有多少个Replica),Producer只将该消息发送到该Partition的Leader。Leader会将该消息写入其本地Log。每个Follower都从Leader pull数据。这种方式上,Follower存储的数据顺序与Leader保持一致。Follower在收到该消息并写入其Log后,向Leader发送ACK。一旦Leader收到了ISR中的所有Replica的ACK,该消息就被认为已经commit了,Leader将增加HW(high watermark)并且向Producer发送ACK。

为了提高性能,每个Follower在接收到数据后就立马向Leader发送ACK,而非等到数据写入Log中。因此,对于已经commit的消息,Kafka只能保证它被存于多个Replica的内存中,而不能保证它们被持久化到磁盘中,也就不能完全保证异常发生后该条消息一定能被Consumer消费。但考虑到这种场景非常少见,可以认为这种方式在性能和数据持久化上做了一个比较好的平衡。在将来的版本中,Kafka会考虑提供更高的持久性。

Consumer读消息也是从Leader读取,只有被commit过的消息(offset低于HW的消息)才会暴露给Consumer。

partition leader选举:

另一个重要的区别是,kafka不要求节点崩溃后所有的数据保持原样恢复。不违反一致性,在任何故障恢复场景不丢失的“稳定存储”复制算法是极少的。这种假设有两个主要的问题,首先,根据我们的观察,磁盘错误在持久化数据系统是最常见的问题,通常数据不会完好无损。其次,即使这不是一个问题,我们不希望在每次写入都用fsync做一致性的保障。因为这导致2个至3个数量级的性能下降,我们允许一个副本重新加入ISR协议确保在加入之前,必须再次完全重新同步,即使丢失崩溃未刷新的数据。

Kafka的replica机制,还有一个缺点。当一个Broker挂掉时,其未flush到硬盘的数据是无法找回的。也就是说,Kafka的设计理念不保证Down机时内存数据的及时写回。这一点Kafka官方做了两点解释:

(1) 如果物理硬盘故障,很可能也不能保证数据完整性;
(2) 即使物理硬盘在故障时能保证完整性,每次写都做fsync将会对性能产生很大影响。

因而Kafka允许Replica重新加入ISR的条件是:这个Replica必须和相应的leader保持一致(完成resync)才能重新加入ISR,尽管他丢掉了故障时未写入硬盘的数据。

如果都挂了怎么办

这种情况被称为“ unclean leader election ”。

请注意,kafka对数据丢失的保障是基于至少有一个副本在保持同步。如果分区的所有复制节点都死了,这保证就不再成立。

如果你人品超差,遇到所有的副本都死了,这时候,你要考虑将会发生问题,并做重要的2件事:

  1. 等待在ISR中的副本起死回生并选择该副本作为leader(希望它仍有所有数据)。
  2. 选择第一个副本 (不一定在 ISR),作为leader。

这是在可用性和一致性的简单权衡。如果我们等待ISR中的副本,那么只要副本不可用,我们将保持不可用,如果这些副本摧毁或数据已经丢失,那么就是永久的不可用。另一方面,如果non-in-sync(非同步)的副本,我们让它成为leader,让它的日志成为"源",即使它不能保证承诺的消息不丢失。在我们当前的版本中我们选择第2种方式,支持选择在ISR中所有副本死了时候可选择不能保证一致的副本。可以通过配置unclean.leader.election.enable禁用此行为,以支持停机优先于不一致。

最坏的情况下,如果一个partition所有的replica都发生故障(相关的Broker均掉线),目前Kafka的策略是第一个重新恢复的replica默认为leader, 尽管有可能不属于原来的ISR.

未来Kafka希望能通过配置满足使用场景对于down机和dataloss的不同关切程度。也就是说,如果使用方需要保证数据不丢失,可以选择等待原有ISR中的replica复活作为Leader。代价是down机时间可能更长。

这个难题不是只有kafka有,任何基于quorum的都有。例如在多数投票中,如果多数服务器都遭受永久性的故障,那么你必须选择丢失100%的数据,或违反一致性,用剩下现有服务器作为新的源。

preferred replicas 首选副本

当一个broker停止或崩溃时,这个broker中所有分区的leader将转移给其他副本。这意味着在默认情况下,当这个broker重启之后,它的所有分区都将仅作为follower,不再用于客户端的读写操作。

为了避免这种不平衡,Kafka有一个首选副本的概念。如果一个分区的副本列表是1,5,9,节点1将优先作为其他两个副本5和9的leader,因为它较早存在于副本中。你可以通过运行以下命令让Kafka集群尝试恢复已恢复正常的副本的leader地位:

> bin/kafka-preferred-replica-election.sh --zookeeper zk_host:port/chroot

可以通过这个配置设置为自动执行:

auto.leader.rebalance.enable=true

默认情况下,Kafka的auto.leader.rebalance.enable被设为 true,它会检查preferred replica是不是当前首领,如果不是,并且该副本是In-Sync的,那么就会触发首领选举,让首选首领成为当前首领。