部分翻译自http://kafka.apache.org/documentation/#majordesignelements
设计动机
- 高并发的实时日志存储
- 周期性的离线处理
- 低延时消息分发
- fault tolerance
正由于这四点原因,kafka被设计成了partition-consumer式的分布式结构。
持久化
kafka使用磁盘作为存储介质。虽然磁盘通常被认为存储速度慢,但是由于现代操作系统的Read-ahead和write-behind机制,对磁盘顺序操作的速度是比较高效的(据doc称5块7200转磁盘组成的RAID5可以达到600MB/s的顺序读写速度)。另外,在现代操作系统的优化机制中,很多操作系统将所有的空闲内存作为磁盘的缓存使用。所有针对磁盘的有缓存顺序读写都会被缓存。因此可以将对磁盘的顺序读写看做系统内核维护的一个内存存储库。
相对而言,虽然直接将数据作为对象存储在内存中也可以达到较快速度,但是有以下几个缺点:
- 在Java的内存管理机制中,对象的实际内存占用比其本身要大。
- 在进程重启之后,数据会丢失。即使使用磁盘持久化,将数据重新载入内存也需要时间。
因此,与大多数类似系统使用内存作为存储介质,在内存满时或者定期持久化的做法不同,kafka选择了磁盘作为存储介质,相比前者kafka的设计也规避了缓存一致性的维护,可以由操作系统来自行解决这个问题。
kafka通过在文件后追加内容的方式来维持数据。这种做法相比于传统的使用B-Tree等随机存取数据结构,具有O(1)的时间复杂度,其耗时不会跟着数据规模的增大而变长,因此效率极高。特别是由于kafka基于文件系统,使用B-Tree由于读取不是顺序的,存取延迟会在10ms左右,非常影响性能,相比之下直接追加文件在带缓存的情况下可以大量减少耗时的I/O操作。这种维护数据的方法还可以实现的一个好处是,不需要定期从磁盘删除消费过的历史消息,因为磁盘足够的大,可以保存较长时间内的历史消息。
数据操作效率
kafka的数据操作效率是其性能指标中最重要的一点。作为一个典型的服务下游设施,kafka很微小的性能降级也会导致整个服务化系统出现比较大的问题。因此,保持较高的数据操作效率是kafka设计中相当关键的一点。
基于前面讲的数据持久化方法,kafka所面临的效率问题主要在于:
- 如何降低I/O操作造成的时延
- 如何规避低效率的byte-copying操作**(什么是byte-copying操作?为何低效?)**
对于第一个问题,kafka通过将多个消息组装成group,再以batch的形式传送的批处理方法,减少了I/O调用的次数,从而规避I/O操作造成的时延。这个优化要基于生产者使用batch的方法批量生产消息而不是仅仅将单条消息逐个发送。
对于第二个问题,kafka采用了将log、producer、consumer的数据保持为相同的内容的方案。通过这种方案,在传送过程中进程并不需要关心数据内的内容,而是直接将其发送/转发即可,这使得我们可以使用较常见的zero-copy策略来规避数据在内核态和用户态之间无用的复制,也就没有了byte-copying操作。这一点基于内存的消息队列系统是无法做到的(因为必须有用户态到内核态的内存数据拷贝)。
同时,kafka采用了压缩的方法减少了网络开销。与传统的将单条消息内容压缩不同,kafka在多条消息的角度,将消息之间相同的部分做压缩,达到了更高的压缩比。将多条被整体压缩的消息batch一起传递,就可以很大的提升网络带宽使用率。kafka支持GZIP, Snappy and LZ4在内的众多压缩算法。
生产者设计
kafka的生产者主要为两个优化方向而设计:负载均衡和异步发送。
负载均衡
在kafka集群中,生产者直接发送消息到某一个broker,中间没有任何的路由过程。这要求每个kafka节点都可以维护并通知生产者应该将消息发送到哪个server以及该server的leader的metadata。
用户可以通过key指定将消息发往哪个partition,同时也可以重载决定partition的hash函数,从而实现一些诸如将一些消息发往局部的partition之类的语义操作。例如,可以将id相同的数据相关的消息发送到同一个partition,这样在消费这些消息的时候就实现了指定id数据相关的消息都被同一个consumer消费的语义。
异步发送
kafka生产者发送消息是异步的。每个生产者有一个buffer用于缓存待发送的消息,允许用户设置超时时间或消息总大小上限作为触发发送消息的条件。这种做法好处见上面描述的批处理生产消息方案的介绍。
消费者设计
kafka的消费者采用pull-based的设计方案,由consumer从broker按照offset获取消息并消费。主要消费过程由消费者而非broker控制。由于offset的存在,允许消费者重复消费已经被消费的数据。
pull vs. push
在消息队列的设计中,一直有push和pull两种消费者设计的模式。push-based是指broker将消息发送给消费者,然后消费者被触发消费的模式。pull-based是指消费者从集群获取消息并自行消费的模式。这两种模式均有利弊。
push-based 主要的缺点有:
- 发送速率等完全由集群控制,经常会出现消费者终端超负荷的情况,而集群又无法灵活地根据消费者的实际情况调整发送速率,影响系统可靠性
- 难以做到同时使所有的消费者持续以自身最大能力消费
pull-based 主要的缺点有:
- 在集群中无消息的时候,消费者会一直空转,不断做无用的轮询
之所以kafka最后选择了pull-based,是因为kafka的设计目标是所有消费者能够以自身最大的能力快速消费。在这种目标下,消费者只需要一直按需尽力的从broker中获取消息消费,然后改变offset就可以了。消费者也可以根据自己的需求调整消费的速率,而不需消费者和集群之间复杂的协议来控制消费速度。至于消费者空转的问题,设计者引入了一些参数,使得消费者在没有待消费数据(或者待消费数据数目不到一个阈值)的情况下,会进入阻塞状态,待需要消费时再苏醒。
消息正确性保证
在kafka中,有三种消息传递保证:
- At most once:消息从不重复,但有可能丢失——一次发送
- at least once:消息从不丢失,但有可能重复——重传直到确认
- exactly once:消息既不重复也不丢失
首先指出,这个问题可以分解为两个子问题:发布消息时的持久性保证和消费消息时的持久性保证。
kafka在发送消息时,会生成一个commited的log,这保证了已经提交的消息除非所有备份机器都crash,就不会丢失。这个机制在下一个章节会详细介绍,这里先假设broker是完美的,即它不会丢失内容,在此基础上来讨论对于producer和consumer的正确性保证问题。如果producer尝试去发布一个消息,然后发生了网络错误,那么它是无法确定在消息被提交前后,是否发生了错误的。这和数据库中向具有自增键的表中插入新的数据具有相同的语义。
在0.11版本之前,如果producer无法收到broker的回复说这个消息被提交,那么它只好重发消息。这就提供了at-least-once的语义,因为如果消息实际上是被提交了,那么就会在log中出现两次。从0.11版本开始,kafka提供了一个幂等性保证选项,可以保证重发消息不会导致log中出现两条记录。为实现这个特性,broker为每个producer分配了一个ID,并且通过对每个producer发送的消息打上一个序列号的方法来去重。与此同时,producer也开始支持在向多个topic发送消息的时候采用类似事务的语义,即要么消息写入了所有的topic,要么一个都没写入。这个特性的一个主要用例就是在topics间的exactly-once消息处理(在下面会被描述)。
但是,并非所有例子都需要如此强的保证。对于那些延迟敏感的用例,我们允许producer来说明它需要什么程度的持久性。如果producer想要等待消息被提交,那么可能需要数十ms的事件。然而producer也可以声明只需要leader得到了消息即可或者完全异步的发送,以降低延迟。
现在,我们从consumer的角度来看上面的语义。所有的replica具有相同的log以及相同的offset。consumer来控制存储在log中的position。如果consumer从不crash,那么position可以存储在内存中。但是如果consumer crash掉了,那么新启动的process需要找到一个合适的position来重新开始处理。我们具体看一下在consumer读一个消息的时候怎么更新position,主要有这么两个选择:
- 先读消息,然后在log中写position,最后处理消息。这样的话如果crash在写position和处理消息之间,那么就会发生消息的漏处理。
- 先读消息,然后处理消息,最后写position。这样的话则有可能导致消息重复被处理。
所以怎么保证exactly-once呢?当从一个topic消费消息然后生产到另一个topic时,我们可以利用上面提到的0.11版本基础上的producer事务机制。consumer的position被当作一个message存储在一个topic中,所以我们可以将写kafka的offset和从topic接收处理后的消息写在同一个transaction里面。这样这两个操作要么都发生了,要么都没发生。