Kafka生产者压缩

1,902 阅读10分钟

1.为什么压缩:

压缩秉承了用时间换空间的经典trade-off思想,即用CPU的时间去换取磁盘空间或网络I/O传输量,Kafka的压缩算法也是出于这种目的。

2.怎么压缩:

了解Kafka如何压缩消息,首先要清楚Kafka的消息格式,目前kafka有两大类消息格式,社区称之为V1版本和V2版本。

v1版本

image.png image.png

字段含义

1.CRC(4B):CRC校验码,占用4个字节,校验magic至value之间字节是否被篡改

2.magic(1B):消息格式版本号,占用1个字节。V0版本是0,V1版本是1,V2版本是2

3.attributes(1B):属性字段,占用1个字节,只使用低3位表示消息的压缩类型, 0表示NONE,表示不启用压缩。1表示GZIP。2表示SNAPPY。3表示LZ4。 且第4个bit被利用起来:0表示timestamp类型为CreateTime,而1表示tImestamp类型为LogAppendTime,其他位保留。

4.timestamp(8B):时间戳

5.key length(4B):表示消息的key的长度。如果为-1,则表示没有设置key,即key=null

6.key:消息key,长度由key length值指定。如果key length值是-1,则无key(没有此字段)

7.value length(4B):表示消息的长度,占用4字节。

8.value:消息value,长度由value length值指定。如果value length值是-1,则无value,消息没有该字段。

上图中的RECORD就是消息v1版本的消息格式,大多数人将offset和message size字段都都看成是消息,因为每个Record(v1版)必定对应一个offset和message size。每条消息都一个offset用来标志它在partition中的偏移量,这个offset是逻辑值,而非实际物理偏移值,message size表示消息的大小,这两者的一起被称之为日志头部(LOG_OVERHEAD),固定为12B。LOG_OVERHEAD和RECORD一起用来描述一条消息。与消息对应的还有消息集的概念,消息集中包含一条或者多条消息,消息集不仅是存储于磁盘以及在网络上传输(Produce & Fetch)的基本形式,而且是kafka中压缩的基本单元,详细结构参考上右图。

v1版本的最小消息长度(RECORD_OVERHEAD_V1)为crc32+magic+timestamp+attributes + key length + value length = 4+1+8+1+4+4=22B,也就是说v1版本中一条消息的最小长度为22B,如果小于这个值,那么这就是一条破损的消息而不被接受。

例子 发送一条key="key",value="value"的消息,那么此条消息会占用 LOG_OVERHEAD + RECORD_OVERHEAD_V1 + 3B的key + 5B的value = 12 + 22 + 3 + 5 = 42B。

V2版本

v2版本中消息集谓之为Record Batch,而不是先前的Message Set了,其内部也包含了一条或者多条消息,消息的格式参见下图中部和右部。在消息压缩的情形下,Record Batch Header部分(参见下图左部,从first offset到records count字段)是不被压缩的,而被压缩的是records字段中的所有内容。 image.png

先说一下,Varint是一种使用一个或多个字节序列化整数的方法,会把整数编码为变长字节。对于32位整型数据经过Varint编码后需要15个字节,小的数字使用1个byte,大的数字使用5个bytes。64位整型数据编码后占用110个字节。在实际场景中小数字的使用率远远多于大数字,因此通过Varint编码对于大部分场景都可以起到很好的压缩效果。

Record的关键字段,内部字段大量采用了Varints,这样Kafka可以根据具体的值来确定需要几个字节来保存。v2版本的消息格式去掉了crc字段,另外增加了length(消息总长度)、timestamp delta(时间戳增量)、offset delta(位移增量)和headers信息,并且attributes被弃用了。

字段分析:

1.length:消息总长度。

2.attributes:弃用,但是还是在消息格式中占据1B的大小,以备未来的格式扩展。

3.timestamp delta:时间戳增量。通常一个timestamp需要占用8个字节,如果像这里保存与RecordBatch的起始时间戳的差值的话可以进一步的节省占用的字节数。

4.offset delta:位移增量。保存与RecordBatch起始位移的差值,可以节省占用的字节数。

5.headers:这个字段用来支持应用级别的扩展,而不需要像v0和v1版本一样不得不将一些应用级别的属性值嵌入在消息体里面。Header的格式如上图最有,包含key和value,一个Record里面可以包含0至多个Header。具体可以参考以下KIP-82。

crc移动到了RrcordBatch中

原来在 V1 版本中,每条消息都需要执行 CRC 校验,但有些情况下消息的 CRC 值是会发生变化的。比如在 Broker 端可能会对消息时间戳字段进行更新(例如用户指定的timestamp类型是LogAppendTime而不是CreateTime),那么重新计算之后的 CRC 值也会相应更新;再比如 Broker 端在执行消息格式转换时(主要是为了兼容老版本客户端程序),也会带来 CRC 值的变化。鉴于这些情况,再对每条消息都执行 CRC 校验就有点没必要了,不仅浪费空间还耽误 CPU 时间,因此在 V2 版本中,消息的 CRC 校验工作就被移到了消息集合这一层(RecordBatch)。

v2消息集的修改(彻底)

1.first offset:表示当前RecordBatch的起始位移。

2.length:计算partition leader epoch到headers之间的长度。

3.partition leader epoch:用来确保数据可靠性,详细可以参考KIP-101

4.magic:消息格式的版本号,对于v2版本而言,magic等于2。

5.attributes:消息属性,注意这里占用了两个字节。低3位表示压缩格式,可以参考v0和v1;第4位表示时间戳类型;第5位表示此RecordBatch是否处于事务中,0表示非事务,1表示事务。第6位表示是否是Control消息,0表示非Control消息,而1表示是Control消息,Control消息用来支持事务功能。

6.last offset delta:RecordBatch中最后一个Record的offset与first offset的差值。主要被broker用来确认RecordBatch中Records的组装正确性。

7.first timestamp:RecordBatch中第一条Record的时间戳。

8.max timestamp:RecordBatch中最大的时间戳,一般情况下是指最后一个Record的时间戳,和last offset delta的作用一样,用来确保消息组装的正确性。

9.producer id:用来支持幂等性,详细可以参考KIP-98。

10.producer epoch:和producer id一样,用来支持幂等性。

11.first sequence:和producer id、producer epoch一样,用来支持幂等性。

12.records count:RecordBatch中Record的个数。

例子:v2相比于v1节省空间了吗?

插入一条key="key",value="value"的消息,按照格式验证一下,Record Batch Header部分共61B,Record部分attributes占1B;timestamp delta值为0,占1B;offset delta值为0,占1B;key length值为3,占1B,key占3B;value length值为5,占1B,value占5B;headers count值为0,占1B, 无headers。record部分长度为(1+1+1+1+3+1+5+1=14B),所以Record的length字段值为14,编码为变长整型占1B。最后总消息占用字节数为=61+14+1=76

这么看上去好像v2版本的消息比之前版本的消息占用空间要大很多,的确对于单条消息而言是这样的,如果我们连续往msg_format_v2中再发送10条value长度为6,key为null的消息,可以得到:

baseOffset: 2 lastOffset: 11 baseSequence: -1 lastSequence: -1

producerId: -1 producerEpoch: -1 partitionLeaderEpoch: 0

isTransactional: false position: 149 CreateTime: 1524712213771

isvalid: true size: 191 magic: 2 compresscodec: NONE crc: 820363253

本来应该占用740B大小的空间,实际上只占用了191B,如果在v0版本中这10条消息则需要占用320B的空间,v1版本则需要占用400B的空间,这样看来v2版本又节省了很多的空间,因为其将多个消息(Record)打包存放到单个RecordBatch中,又通过Varints编码极大的节省了空间。

消息压缩

image.png 消息压缩是将整个消息集进行压缩而作为内层消息(inner message),内层消息整体作为外层(wrapper message)的value。结构如上图所示。压缩后的外层消息(wrapper message)中的key为null,所以图中没有画出那一部分。

压缩对于offset的操作: 当生产者创建压缩消息的时候,对内部压缩消息设置的offset是从0开始为每个内部消息分配offset,详细可以参考下图右部:

image.png

其实每个从生产者发出的消息集中的消息offset都是从0开始的,当然这个offset不能直接存储在日志文件中,对offset进行转换时在服务端进行的,客户端不需要做这个工作。外层消息保存了内层消息中最后一条消息的绝对位移(absolute offset),绝对位移是指相对于整个partition而言的。参考上图,对于未压缩的情形,图右内层消息最后一条的offset理应是1030,但是被压缩之后就变成了5,而这个1030被赋予给了外层的offset。当消费者消费这个消息集的时候,首先解压缩整个消息集,然后找到内层消息中最后一条消息的inner offset,然后根据如下公式找到内层消息中最后一条消息前面的消息的absolute offset(RO表示Relative Offset,IO表示Inner Offset,而AO表示Absolute Offset):

例子:

如上图:如果不压缩的话,那么内层消息应该为(1025,1026,1027,1028,1029,1030),压缩后变为(0,1,2,3,4,5),将1030赋给外层。

那么解压如何如何确定呢,利用(RO表示Relative Offset,IO表示Inner Offset,而AO表示Absolute Offset):公式为

RO = IO_of_a_message - IO_of_the_last_message

AO = AO_Of_Last_Inner_Message + RO

举例:找1030的起始偏移量:先解压整个消息集,找到最后一条消息的偏移量1030,找到内层的偏移量,0-5, so RO = 0-5 = -5 AO = 1030 + (-5) = 1025.

v1和v2版本异同

A:相同点:

(1):Kafka的消息层次分为:消息集合(message set,Record Batch)和消息(message,Record);一个消息集合中包含若干条日志项(record item),而日志项才是真正封装消息的地方。

(2):Kafka底层的消息日志由一系列消息集合日志项组成。Kafka通常不会直接操作具体的一条条消息,他总是在消息集合这个层面上进行写入操作。

B:不同点:引入V2的目的主要是针对V1版本的一些弊端做了修正

(1)把消息的公共部分抽取出来放到外层集合里。 如:在V1中每条消息都要执行CRC校验(循环冗余校验),有些情况下消息的CRC值会变,对每条消息都执行CRC校验,不仅浪费空间还耽误CPU时间。 v2中,消息的crc校验工作被移到了消息集合这一层。

(2)保存压缩消息的方法发生了变化: v1把多条消息进行压缩后在保存到外层消息的消息体字段中。 v2 对整个消息集合进行压缩,压缩效果好与前者。

何时压缩?

在 Kafka 中,压缩可能发生在两个地方:生产者端和 Broker 端。生产者程序中配置 compression.type 参数即表示启用指定类型的压缩算法。

 Properties props = new Properties();
 props.put("bootstrap.servers", "localhost:9092");
 props.put("acks", "all");
 props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
 props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
 // 开启GZIP压缩
 props.put("compression.type", "gzip");
 
 Producer<String, String> producer = new KafkaProducer<>(props);

这样 Producer 启动后生产的每个消息集合都是经 GZIP 压缩过的,故而能很好地节省网络传输带宽以及 Kafka Broker 端的磁盘占用。

Broker端采用压缩算法的情况

1.Broker 端指定了和 Producer 端不同的压缩算法。这会导致Broker端接收到生产者发来的压缩消息,Broker端重新解压、在压缩。

2.Broker 端发生了消息格式转换。这种转换主要是为了兼容老版本的消费者程序,(v1和v2的差别)。这个过程会涉及消息的解压和重新压缩。这不仅对性能影响很大,还会让Kafka丧失引以为豪的Zero Copy特性。

何时解压

通常情况下解压发生在消费者端。

A:这个流程是Producer发送的压缩消息到Broker,Broker原封不动的保存起来,当Consumer程序请求这部分消息时,Broker原样发出去,当下消息到的Consumer端后,由Consumer自行解压。

B:Consume之所以知道这些消息是用何种压缩算法的,是因为Kafka会将启用了哪种压缩算法封装到消息集合中,当Consumer读取到消息集合时,就知道了。

压缩算法对比

在Kafka2.1.0版本之前,仅支持GZIP,Snappy和LZ4。2.1.0后还支持Zstandard算法(Facebook开源,能够提供超高压缩比)。

A:一个压缩算法的优劣,有两个重要指标:压缩比和压缩/解压缩吞吐量,两者都是越高越好。

B:吞吐量:LZ4>Snappy>zstd和GZIP,压缩比:zstd>LZ4>GZIP>Snappy

启用压缩情况

A:启用压缩的一个条件是Producer端所在机器CPU资源充裕

B:生产环境网络带宽资源有限

C:尽量不要出现消息格式转换的情况。