Kafka复习计划 - 客户端实践及原理(分区/压缩/消息丢失)
一. 生产者消息分区机制原理
背景:一般Kafka
用来收集应用服务器的日志数据,但是这个数据量又是非常庞大的,基本上每分钟产生的日志大小都能以GB为单位。
问题:如何将庞大的数据量均匀地分配到Kafka的各个Broker上?
回答:靠的是Kafka
的分区机制。
上一章Kafka基础知识我们提到了Kafka
的三级消息结构:主题-分区-消息。
官网图:
使用分区的目的:提供负载均衡的能力。实现系统的高伸缩性。
- 因为不同的分区可以放到不同的机器上,因此数据的读写操作也是针对分区来进行的。
- 自然而然的每个节点的机器都能独立地执行读写请求。
- 同时我们也可以通过添加新节点的方式来增加系统的吞吐量。
首先来说下如何自定义分区,在代码编写层面,实现自定义的Partitioner
接口,实现partition()
方法即可:
package org.apache.kafka.clients.producer;
public interface Partitioner extends Configurable, Closeable {
int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster);
void close();
@Deprecated
default void onNewBatch(String topic, Cluster cluster, int prevPartition) {
}
}
自定义完自己的分区实体类后,通过设置partitioner.class
参数即可实现自定义的分区。
接下来来说下几种常见的分区策略。
1.1 轮询策略
轮询策略(Round-robin
):顺序分配,也是Kafka
默认的分区策略的其中一种(为什么说一种,在下文会有所提及)。我们假设某个主题下有3个分区,然后消息分配的过程如图:
轮询策略拥有最好的负载均衡表现,因为它总能保证消息最大限度地被平均分配到所有分区上。 因此默认情况下它是最合理也是最常用的分区策略。
1.2 随机策略
从字面上理解也就是随机的将消息放置到任意一个分区上,如图:
实质上操作并不复杂:
- 计算出该主题的分区数
M
。 - 随机返回一个小于
M
的正整数。
1.3 按消息保序策略
Kafka
允许为每条消息定义消息建Key
,这样就可以保证同一个Key
下的所有消息都进入到相同的分区。并且每个分区下的消息处理又是有序的。因此叫保序策略。如图:
上面提到,轮询策略作为Kafka
默认分区策略的其中一种,这里做出补充说明,Kafka
的默认分区策略同时实现了两种策略:
- 当指定了
Key
的时候,默认实现为按消息保序策略。 - 否则默认实现为轮询策略。
分区的使用是非常重要的,它是实现负载均衡以及高吞吐量的一个关键,倘若使用不当,就会造成数据倾斜,使某一些分区有性能瓶颈。
二. 关于Kafka压缩那些事
首先有个背景需要了解,Kafka
目前有两大类消息格式,分别为版本V1
和版本V2
。我们只需要记住两个版本之间的消息格式是不一样的!后文会提到。
不过,无论那个版本的消息格式,Kafka
的消息都分为两层:
- 消息。
- 消息集合:包含若干条日志项(真正封装消息的地方)。
Kafka
底层的消息日志就由一系列的消息集合构成,Kafka
对于信息的操作也都是在消息集合这一个层面上进行的。
2.2 消息格式V1和V2版本的区别
区别一:概念名称有所区分。
- 消息:
V1
叫message
,V2
叫record
。 - 消息都是分批次去读写的。就好像
V1
以集合message set
为单位。V2
就对应的record batch
。 V1
中message set
包含多条message
。V2
中record batch
可包含多条record
。
区别二:在原本的V1
版本中,每条消息都需要执行CRC
校验,但是在有些情况下这种机制就会造成CPU
的浪费。 例如:
- 当
Broker
端可能会对消息的时间戳字段进行更新的时候,那么重新计算之后的CRC
值也会进行更新。 Broker
端执行消息格式转换时(V2
和V1
版本之间的兼容),也会造成CRC
值的变化。
那么针对上述情况,再针对每条消息都执行CRC
校验就没有必要了,而V2
版本中,消息的CRC工作就不再是针对每条消息了,而是针对的某一个消息集合了。即粒度变大了。
CRC(Cyclic Redundancy Check)
:
是一种根据网络数据包或计算机文件等数据产生简短固定位数校验码的一种信道编码技术,主要用来检测或校验数据传输或者保存后可能出现的错误。
区别三:保存压缩消息的方法有所改变。
V1
:将多条消息进行压缩,再保存到外层消息的消息体字段中,其实也就是一条条单个压缩。V2
:对整个消息集合进行压缩。
区别四:V2
版本无论是否启用压缩功能,都比V1
版本要节省磁盘空间,如图:
2.2 Kafka压缩功能需要注意的点
首先:Kafka
中的压缩可能发生在两个地方。
- 生产者端:生产者端只需要指定
compression.type = xxx
即可开启压缩功能,例如gzip
压缩。 Broker
端。
Broker
端需要单独拿出来细说,首先明确一点:Broker
端在大部分情况下会对生产者端接收到的消息进行原封不动地保存,即不存在解压缩,也就不存在重新压缩的操作了。 但是有以下两种例外情况:
- 情况一:默认情况下,
Broker
端也有一个参数配置compression.type=producer
,意思是和生产者端的压缩算法保持一致。 但倘若Broker
端重新配置了该参数,并且和生产者端不一致,此时就会发生解压缩和压缩操作。而这样通常会导致Broker端的CPU使用率飙升。 - 情况二:
Broker
端发生了消息格式转换。 也就是V2
版本的消息会向上兼容,向V1
版本进行格式转换,而这个过程中也会涉及到消息的解压缩和重新压缩。
其次:Kafka
中的解压缩可能发生在两个地方。
- 消费者端:毫无疑问的,生产者端如果指定了压缩算法,那么消费的地方自然而然要跟着走。
Broker
端:这里就和压缩不太一样了,Broker
端的解压缩操作在生产者端开启了压缩功能的情况下
是百分百发生的。
这里依旧是需要将Broker
端拿出来细说。每个压缩过的消息集合在Broker
端写入的时候,都要发生解压缩操作,目的是为了对消息执行验证。 那么问题来了。
问题一:为什么要对消息执行验证?
回答:
- 验证分为很多种,其中就有
CRC
校验,目的是防止在网络传输的过程中出现数据丢失的情况。 即消息的完整性校验。 - 其次还有消息级别的校验,即消息的合规性校验: 主要是检验
offset
是否是单调增长的,以及MessageSize
是超过了最大值。
问题二:能否把消息校验移到生产者端来做?这样Broker
端就可以避免进行解压缩操作。Broker
直接读取校验结果即可。
回答:不可以,理由如下:
- 在生产者端做校验的意义不大,以消息的完整性方面来考虑,倘若先校验,而在后续的网络传输过程中发生了数据的丢失,造成了消息的不完整性,那么校验的意义也就自然而然的没有了。
注意:Broker
端虽然发生了解压缩操作,但是他不会对保存的数据格式进行修改,解压缩只是为了校验里面的数据,对于数据的保存,生产者端传过来是咋样的,Broker
端保存也就是咋样的。
一般情况下,记住这一点:生产者端压缩,Broker
端保持不动,消费者端解压缩。
三. 如何配置Kafka无消息丢失
首先,Kafka
只会对 已提交 的消息做 有限度的持久化保证。
- 已提交:就好比
Mysql
中事务的一次commit
操作。当Kafka
中的若干个Broker
成功接收到一条消息并写入到日志文件中后,会返回一个Ack
,代表此消息已经提交。 - 有限度的持久化:
Kafka
不丢失消息(已经保存下来的)的前提是,N
个Broker
上至少有一个是存活的。
3.1 消息丢失的案例
案例一:生产者程序丢失数据。
这种情况的发生一般在于生产者程序中,对于消息的发送API
,使用的是:
producer.send(message);
实际上,生产者端发送消息API
还有一个重载方法:
producer.send(message,callback);
可见后者只是比前者多了一个回调函数,但是它的重要性就很大了。
- 首先,
Kafka
发送消息的操作是异步的,通常调用producer.send(message);
方法会立刻返回,但是它并不代表此时消息已经发送完成。 - 也就是说,该
API
只负责消息的发送,并不关心消息是否发送成功。因此倘若出现消息丢失,例如:消息超过了Broker
规定的阈值,网络抖动等因素。我们也是无法知晓的。
相反,带有回调函数的API
,它能够准确地告诉你消息是否真的提交成功了。 因此面对这种情况,我们不要去使用带一个参数的send()
方法,应该去使用producer.send(message,callback);
这样,即使消息失败,我们也可以做出对应的程序处理了。
案例二:消费者程序丢失数据。
消费者端丢失数据主要体现于其需要消费的消息不见了。而这个消费的过程,离不开位移(offset
)的概念。它代表着某个Consumer
当前消费到的某个主题的某个分区的具体位置。 如图:
Kafka
消费者端消费消息一般有两个动作,而且有先后顺序:
- 读取位移值。
- 消费完后,更新自己的一个位移值。
那么试想一下,倘若这个过程颠倒过来,先更新,再读取,会发生什么事情:
- 倘若原本的
offset
是10,本次Consumer
可能处理的消息需要到offset
为20的位置。 - 倘若我先更新
offset
为20,然后进行消息的消费,当程序读到offset
为15位置的时候,发生了宕机, - 那么下一次程序读取的时候,将会从
offset
为20的位置开始消费。 - 也就是说期间
offset
为15-20之间的消息就发生了丢失。
而Kafka
中Consumer
端的消息丢失同理。倘若要对抗这种问题,就需要让程序维持先读取再更新的一个操作顺序。 能最大限度地保证消息不丢失。
案例三:开启多线程异步消费消息。
过程如下:
Consumer
程序接收消息后,开启多个线程同时处理消息。- 倘若此时的机制是自动更新位移,那么当某个线程运行失败了,那么自然而然的,它所负责的消息就没有被成功地处理。但此时此刻,位移已经被更新了。
- 那么这条消息对于
Consumer
而言就是丢失的。
因此对于多线程处理消息的这种情况:我们应该禁止自动提交位移的机制,而是让应用程序手动提交位移。
3.2 配置建议
- 不要使用
producer.send(message);
使用producer.send(message,callback);
避免消息发送失败的时候却不知道结果。 - 设置
ack = all
,代表当所有副本Broker
都接收到消息后,该消息才算是已提交,它是最高级别的 ”已提交“定义。但与此同时,该级别的吞吐量也是最低的。详细可以参考深入理解Kafka系列(五)–Kafka可靠的数据传递。 Producer
端的参数,retries
设置的值可以稍微大一点。目的是:发生网络波动的时候消息可能会发送失败,此时Producer
能够自动重试消息的发送,避免消息的丢失。- 设置
unclean.leader.election.enable = false
。目的:避免那些落后了大量数据的副本
竞选为新的Leader
副本,倘若允许这种情况的发生,也会造成数据的丢失。毕竟和外界交互都是交给Leader
副本来执行的。 Broker
端的参数,设置replication.factor >= 3
,将消息多保存几份(具体看你的硬件设施的条件),因为副本机制,即冗余,是防止消息丢失的一个重要机制。Broker
端的参数,设置min.insync.replicas > 1
,这个参数的作用是:控制消息至少要被写入到多少个副本才算是“已提交”。- 确保
replication.factor > min.insync.replicas
。原因:倘若两者相等,那么只要有一个副本挂掉了,那么整个分区就无法正常工作了,因为此时的消息都是被认定为提交失败的。 - 确保消息消费完成再提交,相关参数:
enable.auto.commit = false
,即禁止自动提交位移。对于单Consumer
下的多线程处理场景是非常重要的一个参数。