Kafka是一种发布订阅消息系统,主要用于处理活跃的流式数据。
Kafka的特点:
同时为发布和订阅提供高吞吐量
。Kafka每秒可以生产约25万消息(50MB),每秒处理55万消息(110MB)。可进行持久化操作
。将消息持久化到磁盘,因此可用于批量消费,例如ETL,以及实时应用程序。通过将数据持久化到硬盘以及replication防止数据丢失。分布式系统,易于向外扩展
。所有的producer、broker和consumer都会有多个,均为分布式的。无需停机即可扩展机器。它以集群的方式运行,可以灵活伸缩,在内部通过复制数据提升容错能力和高可用性。消息被处理的状态是在consumer端维护,而不是由server端维护
。当失败时能自动平衡。- 支持online和offline的场景。
- Kafka支持实时的流式处理。
相比传统的消息系统,Kafka可以很好的保证有序性,也可以在多个消费者组并发的情况下提供较好的负载均衡。
Kafka是分布式架构,Producer、Broker和Consumer都可以有多个。
Kafka将消息以Topic为单位进行归纳,向Topic发布消息的程序就是Producer,预订Topic并消费消息的程序是Consumer。
Kafka以集群的方式运行,可以由一个或多个服务组成,每个服务叫做一个 Broker。Producer通过网络将消息发送到Kafka集群,集群向消费者提供消息。
Broker:负责消息存储和转发,Kafka集群中的一台或多台服务器统称为Broker。
Topic:消息类别,Kafka按照Topic来分类消息。
Partition:Topic物理上的分组,一个Topic可以分为多个Partition,每个Partition是一个有序的队列。Partition中的每条消息都会被分配一个有序的id(offset)。每个分区都由一系列有序的、不可变的消息组成,这些消息被连续的追加到分区中。分区中的每个消息都有一个连续的序列号叫做offset,用来在分区中唯一的标识这个消息。
offset:消息在日志中的位置,可以理解是消息在Partition上的偏移量,也是代表该消息的唯一序号。
Segment:一个partition当中存在多个segment文件段,每个segment分为两部分,.log文件和.index文件,其中.index文件是索引文件,主要用于快速查询,.log文件当中数据的偏移量位置。
Producer:消息生产者,向Kafka的Topic发布消息。Producer将消息发布到它指定的Topic中,并负责决定发布到哪个分区。通常简单的由负载均衡机制随机选择分区,但也可以通过特定的分区函数选择分区。
Consumer:消息消费者,订阅Topic并处理发布的消息。实际上每个Consumer唯一需要维护的数据是消息在日志中的位置,也就是offset。这个offset有Consumer来维护:一般情况下随着Consumer不断的读取消息,这offset的值不断增加,但其实Consumer可以以任意的顺序读取消息,比如它可以将offset设置成为一个旧的值来重读之前的消息。
Consumer Group:消费者组,每一个 consumer 属于一个特定的 consumer group(可以为每个consumer指定 groupName).。
Zookeeper:保存着集群Broker、Topic、Partition等meta数据;另外,还负责Broker故障发现,partition leader选举,负载均衡等功能。
如:某一个主题有4个分区,那么消费组中的消费者应该小于等于4,而且最好与分区数成整数倍1/2/4这样。同一个分区下的数据,在同一时刻,不能同一个消费组的不同消费者消费。
分区数越多,同一时间可以有越多的消费者来进行消费,消费数据的速度就会越快,提高消费的性能。
假设现在Kafka集群只有一个 Broker,创建2个Topic名称分别为:topic1、topic2,Partition数量分别为1、2,根目录下就会创建如下三个文件夹:
| --topic1-0
| --topic2-0
| --topic2-1
在Kafka的文件存储中,同一个Topic下有多个不同的Partition,每个Partition都为一个目录,而每一个目录又被平均分配成多个大小相等的Segment File中,Segment File又由index file和data file组成,他们总是成对出现,后缀 “.index” 和 “.log” 分表表示Segment索引文件和数据文件。
假设设置每个Segment大小为500MB,并启动生产者向topic1中写入大量数据,topic1-0文件夹中就会产生类似如下的一些文件:
| --topic1-0
| --00000000000000000000.index
| --00000000000000000000.log
| --00000000000000368769.index
| --00000000000000368769.log
| --00000000000000737337.index
| --00000000000000737337.log
| --00000000000001105814.index | --00000000000001105814.log
| --topic2-0
| --topic2-1
Segment是Kafka文件存储的最小单位。Segment文件命名规则:Partition全局的第一个Segment从0开始,后续每个Segment文件名为上一个Segment文件最后一条消息的offset值。数值最大为64位long大小,19 位数字字符长度,没有数字用0填充。如 00000000000000368769.index和00000000000000368769.log。
segment index file 采取稀疏索引存储方式,减少索引文件大小,通过mmap(内存映射)可以直接内存操作,稀疏索引为数据文件的每个对应message设置一个元数据指针,它比稠密索引节省了更多的存储空间,但查找起来需要消耗更多的时间。
一个Partition当中由多个Segment文件组成,每个Segment文件,包含两部分,一个是.log文件,另外一个是.index文件,其中 .log 文件包含了我们发送的数据存储,.index 文件,记录的是我们.log文件的数据索引值,以便于我们加快数据的查询速度。
索引文件中元数据指向对应数据文件中message的物理偏移地址。
其中以索引文件中元数据 ❤️, 497> 为例,依次在数据文件中表示第 3 个 message(在全局 Partition表示第 368769 + 3 = 368772 个 message)以及该消息的物理偏移地址为 497。
上图左半部分是索引文件,里面存储的是一对一对的key-value,其中key是消息在数据文件(对应的log文件)中的编号,比如“1,3,6,8……”, 分别表示在log文件中的第1条消息、第3条消息、第6条消息、第8条消息……
为什么在index文件中这些编号不是连续的呢? 这是因为index文件中并没有为数据文件中的每条消息都建立索引,而是采用了稀疏存储的方式,每隔一定字节的数据建立一条索引。 这样避免了索引文件占用过多的空间,从而可以将索引文件保留在内存中。 但缺点是没有建立索引的Message也不能一次定位到其在数据文件的位置,从而需要做一次顺序扫描,但是这次顺序扫描的范围就很小了。
value代表的是在全局partiton中的第几个消息。以索引文件中元数据 3,497 为例,其中3代表在右边log数据文件中从上到下第3个消息, 497表示该消息的物理偏移地址(位置)为497(也表示在全局partiton表示第497个消息-顺序写入特性)。
注意该 index 文件并不是从0开始,也不是每次递增1的,这是因为 Kafka 采取稀疏索引存储的方式,每隔一定字节的数据建立一条索引,它减少了索引文件大小,使得能够把 index 映射到内存,降低了查询时的磁盘 IO 开销,同时也并没有给查询带来太多的时间消耗。
因为其文件名为上一个 Segment 最后一条消息的 offset ,所以当需要查找一个指定 offset 的message 时,通过在所有 segment 的文件名中进行二分查找就能找到它归属的 segment ,再在其index 文件中找到其对应到文件上的物理位置,就能拿出该 message 。
由于消息在 Partition 的 Segment 数据文件中是顺序读写的,且消息消费后不会删除(删除策略是针对过期的 Segment 文件),这种顺序磁盘 IO 存储设计师 Kafka 高性能很重要的原因。
Kafka 是如何准确的知道 message 的偏移的呢?这是因为在 Kafka 定义了标准的数据存储结构,在 Partition 中的每一条 message 都包含了以下三个属性:
offset:表示 message 在当前 Partition 中的偏移量,是一个逻辑上的值,唯一确定了Partition 中的一条 message,可以简单的认为是一个 id;
MessageSize:表示 message 内容 data 的大小;
data:message 的具体内容。
#索引文件
00000000000000000000.index
#日志内容
00000000000000000000.log
在目录下的文件,会根据log日志的大小进行切分,.log文件的大小为1G的时候,就会进行切分文件。示例:
-rw-r--r--. 1 root root 389k 1月 17 18:03 00000000000000000000.index
-rw-r--r--. 1 root root 1.0G 1月 17 18:03 00000000000000000000.log
-rw-r--r--. 1 root root 10M 1月 17 18:03 00000000000000077894.index
-rw-r--r--. 1 root root 127M 1月 17 18:03 00000000000000077894.log
在kafka的设计中,将offset值作为了文件名的一部分。
segment文件命名规则:partion全局的第一个segment从0开始,后续每个segment文件名为上一个全局 partion的最大offset(偏移message数)。数值最大为64位long大小,20位数字字符长度,没有数字就用 0 填充。
通过索引信息可以快速定位到message。通过index元数据全部映射到内存,可以避免segment File的IO磁盘操作;
通过索引文件稀疏存储,可以大幅降低index文件元数据占用空间大小。
稀疏索引:为了数据创建索引,但范围并不是为每一条创建,而是为某一个区间创建; 好处:就是可以减少索引值的数量。 不好的地方:找到索引区间之后,要得进行第二次处理。
生产者发送给Kafka数据,可以采用同步方式或异步方式。
生产者等待10s,如果broker没有给出ack响应,就认为失败。
生产者重试3次,如果还没有响应,就报错。
0:生产者只负责发送数据,不关心数据是否丢失,丢失的数据,需要再次发送。
1:partition的leader收到数据,不管follow是否同步完数据,响应的状态码为1。
-1:所有的从节点都收到数据,响应的状态码为-1。
如果broker端一直不返回ack状态,producer永远不知道是否成功;producer可以设置一个超时时间10s,超过时间认为失败。
Kafka对于数据的读写是以分区为粒度的,分区可以分布在多个主机(Broker)中,这样每个节点能够实现独立的数据写入和读取,并且能够通过增加新的节点来增加Kafka集群的吞吐量,通过分区部署在多个Broker来实现负载均衡的效果。
Kafka的分区策略指的就是将生产者锁产生的数据发送到哪个分区的算法。Kafka 为我们提供了默认的分区策略,同时它也支持自定义分区策略。分区策略有这几种:
Producer端可以通过GZIP或Snappy格式对消息集合进行压缩。Producer端进行压缩之后,在Consumer端需进行解压。压缩的好处就是减少传输的数据量,减轻对网络传输的压力,在对大数据处理上,瓶颈往往体现在网络上而不是CPU(压缩和解压会耗掉部分CPU资源)。
Kafka的消息分为两层:消息集合 和 消息。一个消息集合中包含若干条日志项,而日志项才是真正封装消息的地方。Kafka底层的消息日志由一系列消息集合日志项组成。Kafka通常不会直接操作具体的一条条消息,它总是在消息集合这个层面上进行写入操作。Kafka Producer中使用compression.type来开启压缩。示例:
private Properties properties = new Properties();
properties.put("bootstrap.servers","192.168.1.9:9092");
properties.put("key.serializer","org.apache.kafka.common.serialization.StringSerializer");
properties.put("value.serializer","org.apache.kafka.common.serialization.StringSerializer");
//使用GZIP压缩算法
properties.put("compression.type", "gzip");
Producer<String,String> producer = new KafkaProducer<String, String>(properties);
ProducerRecord<String,String> record = new ProducerRecord<String, String>("CustomerCountry","Precision Products","France");
在消费消息时,有两种模式:队列模式和发布-订阅模式。队列模式中,消费者可以同时从服务端读取消息,每个消息只被其中一个消费者读到。发布-订阅模式中消息被广播到所有的消费者中。
消费者可以加入一个消费者组,共同竞争一个Topic,Topic中的消息将被分发到组中的一个成员中。
如果所有的消费者都在一个组中,这就成为了传统的队列模式,在各消费者中实现负载均衡。
如果所有的消费者都不在不同的组中,这就成为了发布-订阅模式,所有的消息都被分发到所有的消费者中。
消费者演变过程大致:最初是一个消费者订阅一个主题并消费其全部分区的消息,后来有一个消费者加入群组,随后又有更多的消费者加入群组,而新加入的消费者实例分摊了最初消费者的部分消息,这种把分区的所有权通过一个消费者转到其他消费者的行为称为重平衡(Rebalance)。
重平衡非常重要,它为消费者群组带来了高可用性 和 伸缩性,我们可以放心的添加消费者或移除消费者,不过在正常情况下我们并不希望发生这样的行为。在重平衡期间,消费者无法读取消息,造成整个消费者组在重平衡的期间都不可用。另外,当分区被重新分配给另一个消费者时,消息当前的读取状态会丢失,它有可能还需要去刷新缓存,在它重新恢复状态之前会拖慢应用程序。
消费者通过向组织协调者(Kafka Broker)发送心跳来维护自己是消费者组的一员并确认其拥有的分区。对于不同不的消费群体来说,其组织协调者可以是不同的。只要消费者定期发送心跳,就会认为消费者是存活的并处理其分区中的消息。当消费者检索记录或者提交它所消费的记录时就会发送心跳。
如果过了一段时间Kafka停止发送心跳了,会话(Session)就会过期,组织协调者就会认为这个Consumer已经死亡,就会触发一次重平衡。如果消费者宕机并且停止发送消息,组织协调者会等待几秒钟,确认它死亡了才会触发重平衡。在这段时间里,死亡的消费者将不处理任何消息。在清理消费者时,消费者将通知协调者它要离开群组,组织协调者会触发一次重平衡,尽量降低处理停顿。
在重平衡期间,消费者组中的消费者实例都会停止消费,等待重平衡的完成。而且重平衡这个过程很慢。
重平衡过程可以从两个方面去看:消费者端和协调者端。
Kafka遵循了一种大部分消息系统共同的传统的设计:Producer将消息推送到Broker,Consumer从Broker拉取消息。
Pull模式的一个好处:Consumer可以自主决定是否批量的从Broker拉取数据。Push模式必须在不知道下游Consumer消费能力和消费策略的情况下决定是立即推送每条消息还是缓存之后批量推送。如果为了避免Consumer崩溃而采用较低的推送速率,将可能导致一次只推送较少的消息而造成浪费。Pull模式下,Consumer可以根据自己的消费能力去决定这些策略。
Pull有个缺点:如果Broker没有可供消费的消息,将导致Consumer不断在循环中轮询,直到新消息到达。
1、Kafka持久化日志,这些日志可以被重复读取和无限期保留。
2、Kafka是一个分布式系统:它以集群的方式运行,可以灵活伸缩,在内部通过复制数据提升容错能力和高可用性。
3、Kafka支持实时的流式处理。
复制功能是 Kafka 架构的核心功能,在 Kafka 文档里面 Kafka 把自己描述为 一个分布式的、可分区的、可复制的提交日志服务。复制之所以这么关键,是因为消息的持久存储非常重要,这能够保证在主节点宕机后依旧能够保证 Kafka 高可用。副本机制也可以称为备份机制(Replication),通常指分布式系统在多台网络交互的机器上保存有相同的数据备份/拷贝。
Kafka 使用Topic来组织数据,每个Topic又被分为若干个分区,分区会部署在一到多个 broker 上,每个分区都会有多个副本,所以副本也会被保存在 broker 上,每个 broker 可能会保存成千上万个副本。
上图所示,有两个 broker ,每个 broker 指保存了一个 Topic 的消息,在 broker1 中分区 0 是 Leader,它负责进行分区的复制工作,把 broker1 中的分区 0 复制一个副本到 broker2 的主题 A 的分区 0。同理,主题 A 的分区 1 也是一样的道理。
副本类型分为两种:一种是 Leader(领导者) 副本,一种是Follower(跟随者)副本。
Leader 副本:Kafka 在创建分区的时候都要选举一个副本,这个选举出来的副本就是 Leader 领导者副本。
Follower 副本:除了 Leader 副本以外的副本统称为 Follower 副本,Follower 不对外提供服务。
Kafka 中,Follower 副本也就是追随者副本是不对外提供服务的。这就是说,任何一个追随者副本都不能响应消费者和生产者的请求。所有的请求都是由领导者副本来处理。或者说,所有的请求都必须发送到 Leader 副本所在的 broker 中,Follower 副本只是用做数据拉取,采用异步拉取的方式,并写入到自己的提交日志中,从而实现与 Leader 的同步。
当 Leader 副本所在的 broker 宕机后,Kafka 依托于 ZooKeeper 提供的监控功能能够实时感知到,并开启新一轮的选举,从追随者副本中选一个作为 Leader。如果宕机的 broker 重启完成后,该分区的副本会作为 Follower 重新加入。
首领的另一个任务是搞清楚哪个跟随者的状态与自己是一致的。跟随者为了保证与领导者的状态一致,在有新消息到达之前先尝试从领导者那里复制消息。
跟随者副本在收到响应消息前,是不会继续发送消息,这一点很重要。通过查看每个跟随者请求的最新偏移量,首领就会知道每个跟随者复制的进度。如果跟随者在 10s 内没有请求任何消息,或者虽然跟随者已经发送请求,但是在 10s 内没有收到消息,就会被认为是不同步的。如果一个副本没有与领导者同步,那么在领导者掉线后,这个副本将不会称为领导者,因为这个副本的消息不是全部的。
如果跟随者同步的消息和领导者副本的消息一致,那么这个跟随者副本又被称为同步的副本。也就是说,如果领导者掉线,那么只有同步的副本能够称为领导者。
副本机制的好处:
- 能够立刻看到写入的消息,就是你使用生产者 API 成功向分区写入消息后,马上使用消费者就能读取刚才写入的消息。
- 能够实现消息的幂等性。就是对于生产者产生的消息,在消费者进行消费的时候,它每次都会看到消息存在,并不会存在消息不存在的情况。
producer 通知 ZooKeeper 识别领导者。
producer 向领导者写入消息。
领导者收到消息后会把消息写入到本地 log。
跟随者会从领导者那里拉取消息。
跟随者向本地写入 log。
跟随者向领导者发送写入成功的消息。
领导者会收到所有的跟随者发送的消息。
领导者向 producer 发送写入成功的消息。
异步复制:和同步复制的区别在于,领导者在写入本地 log 之后,直接向客户端发送写入成功消息,不需要等待所有跟随者复制完成。
replica.lag.time.max.ms
参数默认的时间是 10 秒,如果跟随者副本落后领导者副本的时间不超过 10 秒,那么 Kafka 就认为领导者和跟随者是同步的。即使此时跟随者副本中存储的消息要小于领导者副本。如果跟随者副本要落后于领导者副本 10 秒以上的话,跟随者副本就会从 ISR 被剔除。倘若该副本后面慢慢地追上了领导者的进度,那么它是能够重新被加回 ISR 的。这也表明,ISR 是一个动态调整的集合,而非静态不变的。broker 之间有一个控制器组件(Controller),它是 Kafka 的核心组件。它的主要作用是在 ZooKeeper 的帮助下管理和协调整个 Kafka 集群,集群中的每个 broker 都可以称为 controller。
Kafka是一个的消息系统,无论是发布还是订阅,都要指定Topic。Topic只是一个逻辑的概念。每个Topic都包含一个或多个Partition,不同Partition可位于不同节点。
一方面,由于不同 Partition 可位于不同机器,因此可以充分利用集群优势,实现机器间的并行处理。另一方面,由于 Partition 在物理上对应一个文件夹,即使多个Partition位于同一个节点,也可通过配置让同一节点上的不同 Partition 置于不同的磁盘上,从而实现磁盘间的并行处理,充分发挥多磁盘的优势。
Kafka 中每个分区是一个有序的,不可变的消息序列,新的消息不断追加到 partition 的末尾,这个就是顺序写。
由于磁盘有限,不可能保存所有数据,实际上作为消息系统 Kafka 也没必要保存所有数据,需要删除旧的数据。又由于顺序写入的原因,所以 Kafka 采用各种删除策略删除数据的时候,并非通过使用“读 - 写”模式去修改文件,而是将 Partition 分为多个 Segment,每个 Segment 对应一个物理文件,通过删除整个文件的方式去删除 Partition 内的数据。这种方式清除旧数据的方式,也避免了对文件的随机写操作。
引入 Cache 层的目的是为了提高 Linux 操作系统对磁盘访问的性能。Cache 层在内存中缓存了磁盘上的部分数据。当数据的请求到达时,如果在 Cache 中存在该数据且是最新的,则直接将数据传递给用户程序,免除了对底层磁盘的操作,提高了性能。Cache 层也正是磁盘 IOPS 为什么能突破 200 的主要原因之一。
在 Linux 的实现中,文件 Cache 分为两个层面,一是 Page Cache,另一个 Buffer Cache,每一个 Page Cache 包含若干 Buffer Cache。Page Cache 主要用来作为文件系统上的文件数据的缓存来用,尤其是针对当进程对文件有 read/write 操作的时候。Buffer Cache 则主要是设计用来在系统对块设备进行读写的时候,对块进行数据缓存的系统来使用。
使用 Page Cache 的好处:
I/O Scheduler 会将连续的小块写组装成大块的物理写从而提高性能。
I/O Scheduler 会尝试将一些写操作重新按顺序排好,从而减少磁盘头的移动时间。
充分利用所有空闲内存(非 JVM 内存)。如果使用应用层 Cache(即 JVM 堆内存),会增加 GC 负担。
读操作可直接在 Page Cache 内进行。如果消费和生产速度相当,甚至不需要通过物理磁盘(直接通过 Page Cache)交换数据。
如果进程重启,JVM 内的 Cache 会失效,但 Page Cache 仍然可用。
Broker 收到数据后,写磁盘时只是将数据写入 Page Cache,并不保证数据一定完全写入磁盘。从这一点看,可能会造成机器宕机时,Page Cache 内的数据未写入磁盘从而造成数据丢失。但是这种丢失只发生在机器断电等造成操作系统不工作的场景,而这种场景完全可以由 Kafka 层面的 Replication 机制去解决。如果为了保证这种情况下数据不丢失而强制将 Page Cache 中的数据 Flush 到磁盘,反而会降低性能。也正因如此,Kafka 虽然提供了 flush.messages 和 flush.ms 两个参数将 Page Cache 中的数据强制 Flush 到磁盘,但是 Kafka 并不建议使用。
Kafka 中存在大量的网络数据持久化到磁盘(Producer 到 Broker)和磁盘文件通过网络发送(Broker 到 Consumer)的过程。这一过程的性能直接影响 Kafka 的整体吞吐量。
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的权限。
为了避免用户进程直接操作内核,保证内核安全,操作系统将虚拟内存划分为两部分,一部分是内核空间(Kernel-space),一部分是用户空间(User-space)。
传统的 Linux 系统中,标准的 I/O 接口(例如 read,write)都是基于数据拷贝操作的,即 I/O 操作会导致数据在内核地址空间的缓冲区和用户地址空间的缓冲区之间进行拷贝,所以标准 I/O 也被称作缓存 I/O。这样做的好处是,如果所请求的数据已经存放在内核的高速缓冲存储器中,那么就可以减少实际的 I/O 操作,但坏处就是数据拷贝的过程,会导致 CPU 开销。
Producer 可将数据压缩后发送给 broker,从而减少网络传输代价,目前支持的压缩算法有:Snappy、Gzip、LZ4。数据压缩一般都是和批处理配套使用来作为优化手段的。
序列化器
:生产者需要用序列化器(Serializer)把对象转换成字节数组才能通过网络发送给Kafka。而在对侧,消费者需要用反序列化器(Deserializer)把从Kafka中收到的字节数组转换成相应的对象。
分区器
:分区器的作用就是为消息分配分区。如果消息ProducerRecord中没有指定partition字段,那么就需要依赖分区器,根据key这个字段来计算partition的值。
Kafka一共有两种拦截器:生产者拦截器和消费者拦截器。
生产者拦截器
既可以用来在消息发送前做一些准备工作,比如按照某个规则过滤不符合要求的消息、修改消息的内容等,也可以用来在发送回调逻辑前做一些定制化的需求,比如统计类工作。
消费者拦截器
主要在消费到消息或在提交消费位移时进行一些定制化的操作。
消息在通过send方法发往broker的过程中,有可能需要经过拦截器、序列化器和分区器的一系列作用之后才能被真正地发往broker。拦截器一般不是必需的,而序列化器是必需的。消息经过序列化之后就需要确定它发往的分区,如果消息ProducerRecord中指定了partition字段,那么就不需要分区器的作用,因为partition代表的就是所要发往的分区号。
处理顺序 :拦截器->序列化器->分区器。
KafkaProducer在将消息序列化和计算分区之前会调用生产者拦截器的onSend方法来对消息进行相应的定制化操作。然后生产者需要用序列化器(Serializer)把对象转换成字节数组才能通过网络发送给Kafka。最后可能会被发往分区器为消息分配分区。
Rebalance
:一个consumer正在消费一个分区的一条消息,还没有消费完,发生了rebalance(加入了一个consumer),从而导致这条消息没有消费成功。rebalance后,另一个consumer又把这条消息消费一遍。
消费者端手动提交
:如果先消费消息,再更新offset位置,导致消息重复消费。
消费者端自动提交
:设置offset为自动提交,关闭kafka时,如果在close之前,调用consumer.unsubscribe(),则有可能部分offset没提交,下次重启会重复消费。
生产者端
:生产者因为业务问题导致的宕机,在重启之后可能数据会重发。
自动提交
:设置offset为自动定时提交,当offset被自动定时提交时,数据还在内存中未处理,此时刚好把线程kill掉,那么offset已经提交,但是数据未处理,导致这部分内存中的数据丢失。
生产者发送消息
:发送消息设置的是fire-and-forget(发后即忘),它只管往Kafka中发送消息而并不关心消息是否正确到达。不过在某些时候(比如发生不可重试异常时)会造成消息的丢失。这种发送方式的性能最高,可靠性也最差。
消费者端
:先提交位移,但是消息还没消费完就宕机了,造成了消息没有被消费。
acks没有设置为all
:如果在broker还没把消息同步到其他broker的时候宕机了,那么消息将会丢失。
Kafka最初考虑的问题是,customer应该从brokes拉取消息还是brokers将消息推送到consumer,也就是pull还push。在这方面,Kafka遵循了一种大部分消息系统共同的传统的设计:producer将消息推送到broker,consumer从broker拉取消息
。
一些消息系统比如Scribe和Apache Flume采用了push模式,将消息推送到下游的consumer。这样做有好处也有坏处:由broker决定消息推送的速率,对于不同消费速率的consumer就不太好处理了。消息系统都致力于让consumer以最大的速率最快速的消费消息,但不幸的是,push模式下,当broker推送的速率远大于 consumer 消费的速率时,consumer恐怕就要崩溃了。最终Kafka还是选取了传统的pull模式。
Pull模式的另外一个好处是 consumer 可以自主决定是否批量的从 broker 拉取数据。Push模式必须在不知道下游 consumer 消费能力和消费策略的情况下决定是立即推送每条消息还是缓存之后批量推送。如果为了避免 consumer 崩溃而采用较低的推送速率,将可能导致一次只推送较少的消息而造成浪费。Pull 模式下,consumer 就可以根据自己的消费能力去决定这些策略。
批量发送
:在发送消息的时候,kafka 不会直接将少量数据发送出去,否则每次发送少量的数据会增加网络传输频率,降低网络传输效率。kafka 会先将消息缓存在内存中,当超过一个的大小或者超过一定的时间,那么会将这些消息进行批量发送。端到端压缩
:当然网络传输时数据量小也可以减小网络负载,kafaka 会将这些批量的数据进行压缩,将一批消息打包后进行压缩,发送 broker 服务器后,最终这些数据还是提供给消费者用,所以数据在服务器上还是保持压缩状态,不会进行解压,而且频繁的压缩和解压也会降低性能,最终还是以压缩的方式传递到消费者的手上。Kafka 中的可靠性保证有如下四点:
- 对于一个分区来说,它的消息是有序的。如果一个生产者向一个分区先写入消息A,然后写入消息B,那么消费者会先读取消息A再读取消息B。
- 当消息写入所有in-sync状态的副本后,消息才会认为已提交(committed)。这里的写入有可能只是写入到文件系统的缓存,不一定刷新到磁盘。生产者可以等待不同时机的确认,比如等待分区主副本写入即返回,后者等待所有in-sync状态副本写入才返回。
- 一旦消息已提交,那么只要有一个副本存活,数据不会丢失。
- 消费者只能读取到已提交的消息。
在发送延时消息的时候并不是先投递到要发送的真实主题(real_topic)中,而是先投递到一些Kafka内部的主题(delay_topic)中,这些内部主题对用户不可见,然后通过一个自定义的服务拉取这些内部主题中的消息,并将满足条件的消息再投递到要发送的真实的主题中,消费者所订阅的还是真实的主题。
如果采用这种方案,那么一般是按照不同的延时等级来划分的,比如设定 5s、10s、30s、1min、2min、5min、10min、20min、30min、45min、1hour、2hour 这些按延时时间递增的延时等级,延时的消息按照延时时间投递到不同等级的主题中,投递到同一主题中的消息的延时时间会被强转为与此主题延时等级一致的延时时间,这样延时误差控制在两个延时等级的时间差范围之内(比如延时时间为17s的消息投递到30s的延时主题中,之后按照延时时间为30s进行计算,延时误差为13s)。虽然有一定的延时误差,但是误差可控,并且这样只需增加少许的主题就能实现延时队列的功能。
发送到内部主题(delay_topic_*)中的消息会被一个独立的DelayService进程消费,这个DelayService进程和Kafka broker进程以一对一的配比进行同机部署(参考下图),以保证服务的可用性。
针对不同延时级别的主题,在 DelayService 的内部都会有单独的线程来进行消息的拉取,以及单独的 DelayQueue(这里用的是 JUC 中 DelayQueue)进行消息的暂存。与此同时,在DelayService内部还会有专门的消息发送线程来获取 DelayQueue 的消息并转发到真实的主题中。从消费、暂存再到转发,线程之间都是一一对应的关系。如下图所示,DelayService的设计应当尽量保持简单,避免锁机制产生的隐患。
为了保障内部 DelayQueue 不会因为未处理的消息过多而导致内存的占用过大,DelayService 会对主题中的每个分区进行计数,当达到一定的阈值之后,就会暂停拉取该分区中的消息。因为一个主题中一般不止一个分区,分区之间的消息并不会按照投递时间进行排序,DelayQueue的作用是将消息按照再次投递时间进行有序排序,这样下游的消息发送线程就能够按照先后顺序获取最先满足投递条件的消息。
Kafka中的消息是以主题为基本单位进行归类的,各个主题在逻辑上相互独立。每个主题又可以分为一个或多个分区。不考虑多副本的情况,一个分区对应一个日志(Log)。为了防止Log过大,Kafka又引入了日志分段(LogSegment)的概念,将Log切分为多个LogSegment,相当于一个巨型文件被平均分配为多个相对较小的文件。
Log和LogSegment也不是纯粹物理意义上的概念,Log在物理上只以文件夹的形式存储,而每个LogSegment对应于磁盘上的一个日志文件和两个索引文件,以及可能的其他文件(比如以“.txnindex”为后缀的事务索引文件)。
每个日志分段文件对应了两个索引文件,主要用来提高查找消息的效率。
偏移量索引文件
:用来建立消息偏移量(offset)到物理地址之间的映射关系,方便快速定位消息所在的物理文件位置。
时间戳索引文件
:则根据指定的时间戳(timestamp)来查找对应的偏移量信息。