1. 概念分析
broker: 消息处理结点, 1个broker就是一个kafka服务进程或者服务器,多个broker组成kafka集群。
topic: 消息主题,所有的消息按照主题归类存储,每个topic会在broker上生成一个文件夹。
partition: topic的物理分组,一个topic可以设置多个partition。partition数量会被各个consumer瓜分掉,consumer只能获取到跟它关联的partition中的消息,所以如果consumer数量超过partition数量,多出的consumer无法读到任何数据。例如,创建一个topic "haha",会在logs下面多出3个文件夹:haha-0, haha-1, haha-2。很明显,文件夹的名称格式是:topic-particion序号。但有一个问题大家都会问,producer往生产一个topic的消息,这条消息应该放到哪个partition中呢?key,消息key的作用就在此。根据key做哈希,最后根据哈希结果决定这条消息放哪个partition。假如说没有设置key,那就轮询分区决定。
replication:分区可以有多个replication,这是broker级别的对partition做的replication。假如replication为3,那么每个partition都应该分别存在与3个broker中。但不一定每个broker都拥有所有的partition。
segment: 为了快速查找到消息,系统将一个partition的数据分成多个segment存储。每个segment有大小限制,超过限制,系统会新建一个segment。系统接收到provider传
传来的消息时,会先将消息保存在内存的一个数据结构中,有另外一个线程,每隔一段时间(1秒,可配置)将内存中的消息flush到磁盘。只有磁盘中的数据,才能被consumer访问。
offset: 一个连续的用于定位被追加到分区的每一个消息的序列号,最大值为64位的long大小,19位数字字符长度。这个offset说白了是某个consumer读取某个partition的最新位置,这个consumer必须是与那个partition关联的。consumer不可能一直在线,有可能挂掉,那么重启的时候consumer难道要从头开始读取消息?所以kafka干脆把consumer的读取进度,也就是offset干脆保存在server端。至于保存方式,可以保存在zookeeper中,我们可以通过在zk命令行中执行:get /consumers/jd-group/offsets/haha/0 来查看group ID为jd-group,topic为haha,partition为0的segment。为什么是group?因为kafka将consumer按group管理,一个group访问一个partition。至于同一个group的各个consumer,只能相互竞争了。但是,用zookeeper来记录offset,性能很容易到达瓶颈,因为zk并不适合大批量频繁的写操作。新版Kafka已推荐将consumer的位移信息保存在Kafka内部的topic中,即__consumer_offsets topic,并且默认提供了kafka_consumer_groups.sh脚本供用户查看consumer信息。
其实,这几个概念(尤其是segment)关乎到kafka的核心——存储。kafka是基于磁盘存储的,而不是内存。我一开始也很纳闷,磁盘读写效率不是很低吗,kafka如何做到高吞吐量的?其实磁盘的顺序读写效率,是和内存的随机读写效率是同一个级别的。而且,用磁盘存储数据,可以持久化。
segment是如何让kafka有高吞吐量的呢?
首先是文件命名,它关系到如何定位到哪个segment的问题,segment利用了消息的offset,每一个segment的文件名是前一个segment中最后一个消息的offset。所以第1个segment的文件名是00000000000000000000,如果这个segment中存放了856个消息,那么下一个segment的文件名就是00000000000000000856,依次类推。所以,要读取某个offset时,只要对segment的文件列表做二分查找,很快就能找到消息所在的对应segment中。
其次,定位了segment之后,如何又在segment中快速找到消息呢?setment的文件不是一个,其实是4个:log, index, timeindex, snapshot。主要是log 和 index。log存放消息的数据,index存放这些数据的索引,索引格式是:<offset, position>,即每个offset对应的消息,在log文件中的具体位置。所以,要在segment中获取到某个offset的消息数据,首先需要遍历index文件,查找到对应的offset的那条索引,然后找到对应的position,然后再读取log文件position位置的数据,就可以找到了。因为index文件很小,所以遍历index文件不会有太大的性能影响。
下面是一个broker下的log文件目录:
${log.dir}
|__ __consumer_offsets-0
| |__ 00000000000000000000.index
| |__ 00000000000000000000.log
| |__ 00000000000000000000.timeindex
| |__ leader_epoch_checkpoint
|__ __consumer_offsets-1
......
|__ __consumer_offsets-49
|__ haha-0
|__ 00000000000000000000.index
|__ 00000000000000000000.log
|__ 00000000000000000000.timeindex
|__ 00000000000000034567.index
|__ 00000000000000034567.log
|__ 00000000000000034567.timeindex
|__ 00000000000000078901.index
|__ 00000000000000078901.log
|__ 00000000000000078901.timeindex
...
|__ haha-1
|__ 00000000000000000000.index
|__ 00000000000000000000.log
|__ 00000000000000000000.timeindex
|__ haha-2
|__ 00000000000000000000.index
|__ 00000000000000000000.log
|__ 00000000000000000000.timeindex
__consumer_offsets是kafka默认的topic,默认有50个partition,但都在同一个kafka服务器上,它用来替代使用zookeeper记录offset。
一个简单的概念分析到此结束,通过将这几个概念彻底理解透,对于深入探索kafka至关重要。
2. Consumer 的 enable.auto.commit 属性问题
首先要理解consumer的offset,它代表了每个consumer group当前读取到的数据位置。原因是kafka的只支持顺序读取数据,所以每个group读取到哪里了,需要offset记录这个位置。
因此,kafka服务器会为每个topic-group维护一个offset。如果enable.auto.commit=true, consumer会起一个线程,自动向kafka报告最新的offset。反之,需要使用者手动commit。
看到这里,包括我,绝大多数人都懂得下面这几行代码的意义:
ConsumerRecords<?, ?> records = consumer.poll(1000);
do something ...
consumer.commitAsync();
很多人包括我一开始都会这样想,假如没有调用commitAsync()函数,那么每次poll都能从头开发拿到数据,但事实上不是这样的。
consumer poll时并不会使用kafka的offset,而是使用自身的offset。记住了,consumer客户端本身就有一个offset,每次poll都会自动向前增加,除非没有数据。而consumer每次poll时使用的是自身的offset。所以,调用了commitAsync()函数,只是更新了kafka服务器的offset,并没有更新consumer本地的offset。
那么,kafka服务器的offset有什么作用???很明显,是供consumer启动的时候用,因为consumer的offset是内存数据,重启会丢失。因此consumer启动时会读取这个服务器的offset来初始化本地offset。反过来看commitAsync()函数的源代码,它也是读取本地的offset,然后更新到kafka服务器。
但是问题又来来,既然每个consumer都保存并使用offset,那么如何做到组管理:一条数据只能让一个group的其中某一个consumer读到。暂时还不清楚,这点需要继续研究。
回到最开始的问题,假如没有commit,又想重复消费,应该怎么办?答案是seek。seek函数是consumer维护本地offset使用的。
我们有时候会遇到这样的场景:拿到50条数据,但处理了20条就出错了,于是想commit()20条,下次从第21条再获取数据。按照上面的逻辑,就可以这样操作:
int offset = consumer.position(partition); //获取当前的offset
consumer.seek(partition, offset - (50 - 20)); //回退30条
consumer.commitAsync();
以上逻辑大部分是通过实验和看源码得出的结论,未必完全准确,但是基本上与实验结果对得上。
3. auto.offset.reset的作用
earliest:当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,从头开始消费
latest:当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,消费新产生的该分区下的数据
none:topic各分区都存在已提交的offset时,从offset后开始消费;只要有一个分区不存在已提交的offset,则抛出异常
这里有一个疑问,何时分区下会有没有提交过offset?大多数人都会想到:刚创建topic并且有数据但没有被消费过。
我们常常漏了这种情况,topic已经被反复消费过,但是某天启动了一个新的group.id的consumer,这个时候对这个consumer来说,它就没有提交过offset。这个时候,对它来说,它应该从哪个位置开始读取数据?auto.offset.reset的作用这时就可以发挥出来了。earliest时offset会被置为0,latest时offset会被置为consumer启动后的第一条数据的offset。