携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第11天
你好, 我是华仔,又和大家见面了。
从这篇文章开始,我将对 Kafka 专项知识进行深度剖析, 今天我就来聊聊 kafka 的存储系统架构设计下篇。
04 kafka 日志系统架构设计
了解了 Kafka 存储选型和存储架构设计后, 我们接下来再深度剖析下 Kafka 日志系统的架构设计。
根据上面的存储架构剖析,我们知道 kafka 消息是按主题 Topic 为基础单位归类的,各个 Topic 在逻辑上是独立的,每个 Topic 又可以分为一个或者多个 Partition,每条消息在发送的时候会根据分区规则被追加到指定的分区中,如下图所示:
图6:4个分区的主题逻辑结构图
日志目录布局
那么 Kafka 消息写入到磁盘的日志目录布局是怎样的?接触过 Kafka 的老司机一般都知道 Log 对应了一个命名为 topic-partition 的文件夹。举个例子,假设现在有一个名为“topic-order”的 Topic,该 Topic 中有4个 Partition,那么在实际物理存储上表现为“topic-order-0”、“topic-order-1”、“topic-order-2”、“topic-order-3” 这4个文件夹。
看上图我们知道首先向 Log 中写入消息是顺序写入的。但是只有最后一个 LogSegement 才能执行写入操作,之前的所有 LogSegement 都不能执行写入操作。为了更好理解这个概念,我们将最后一个 LogSegement 称为 "activeSegement",即表示当前活跃的日志分段。随着消息的不断写入,当 activeSegement 满足一定的条件时,就需要创建新的 activeSegement,之后再追加的消息会写入新的 activeSegement。
图7:activeSegment示意图
为了更高效的进行消息检索,每个 LogSegment 中的日志文件(以“.log”为文件后缀)都有对应的几个索引文件:偏移量索引文件(以“.index”为文件后缀)、时间戳索引文件(以“.timeindex”为文件后缀)、快照索引文件 (以“.snapshot”为文件后缀) 。其中每个 LogSegment 都有一个 Offset 来作为基准偏移量(baseOffset),用来表示当前 LogSegment 中第一条消息的 Offset。偏移量是一个64位的 Long 长整型数,日志文件和这几个索引文件都是根据基准偏移量(baseOffset)命名的,名称固定为20位数字,没有达到的位数前面用0填充。比如第一个 LogSegment 的基准偏移量为0,对应的日志文件为00000000000000000000.log。
我们来举例说明,向主题topic-order中写入一定量的消息,某一时刻topic-order-0目录中的布局如下所示:
图8:log 目录布局示意图
上面例子中 LogSegment 对应的基准位移是12768089,也说明了当前 LogSegment 中的第一条消息的偏移量为12768089,同时可以说明当前 LogSegment 中共有12768089条消息(偏移量从0至12768089的消息)。
注意每个 LogSegment 中不只包含“.log”、“.index”、“.timeindex”这几种文件,还可能包含“.snapshot”、“.txnindex”、“leader-epoch-checkpoint”等文件, 以及 “.deleted”、“.cleaned”、“.swap”等临时文件。
另外 消费者消费的时候,会将提交的位移保存在 Kafka 内部的主题__consumer_offsets中,对它不了解的可以直接查看之前写的 聊聊 Kafka Consumer 那点事 中的位移提交部分,下面我们来看一个整体的日志目录结构图:
图9:log 整体目录布局示意图
日志格式演变
对于一个成熟的消息中间件来说,日志格式不仅影响功能的扩展,还关乎性能维度的优化。所以随着 Kafka 的迅猛发展,其日志格式也在不断升级改进中,Kafka 的日志格式总共经历了3个大版本:V0,V1和V2版本。
我们知道在 Kafka Partition 分区内部都是由每一条消息进行组成,如果日志格式设计得不够精巧,那么其功能和性能都会大打折扣。
V0 版本
在 Kafka 0.10.0 之前的版本都是采用这个版本的日志格式的。在这个版本中,每条消息对应一个 Offset 和 message size。Offset 用来表示它在 Partition分区中的偏移量。message size 表示消息的大小。两者合起来总共12B,被称为日志头部。日志头部跟 Record 整体被看作为一条消息。如下图所示:
图10:V0 版本日志格式示意图
crc32(4B):crc32校验值。校验范围为magic至value之间。
magic(1B):日志格式版本号,此版本的magic值为0。
attributes(1B):消息的属性。总共占1个字节,低3位表示压缩类型:0 表示NONE、1表示GZIP、2表示SNAPPY、3表示LZ4(LZ4自Kafka 0.9.x 版本引入),其余位保留。
key length(4B):表示消息的key的长度。如果为-1,则没有设置key。
key:可选,如果没有key则无此字段。
value length(4B):实际消息体的长度。如果为-1,则消息为空。
value:消息体。
从上图可以看出,V0 版本的消息最小为 14 字节,小于 14 字节的消息会被 Kafka 认为是非法消息。
下面我来举个例子来计算一条消息的具体大小,消息的各个字段值依次如下:
- CRC:对消息进行 CRC 计算后的值;
- magic:0;
- attribute:0x00(未使用压缩);
- key 长度:5;
- key:hello;
- value 长度:5;
- value:world。
那么该条消息长度为:4 + 1 + 1 + 4 + 5 + 4 + 5 = 24 字节。
V1 版本
随着 Kafka 版本的不断迭代发展, 用户发现 V0 版本的日志格式由于没有保存时间信息导致 Kafka 无法根据消息的具体时间进行判断,在进行清理日志的时候只能使用日志文件的修改时间导致可能会被误删。
从 V0.10.0 开始到 V0.11.0 版本之间所使用的日志格式版本为 V1,比 V0 版本多了一个 timestamp 字段,表示消息的时间戳。如下图所示:
图11:V1 版本日志格式示意图
V1 版本比 V0 版本多一个 8B 的 timestamp 字段;
那么 timestamp 字段作用:
对内:会影响日志保存、切分策略;
对外:影响消息审计、端到端延迟等功能扩展
从上图可以看出,V1 版本的消息最小为 22 字节,小于 22 字节的消息会被 Kafka 认为是非法消息。
总的来说比 V0 版本的消息大了 8 字节,如果还是按照 V0 版本示例那条消息计算,则在 V1 版本中它的总字节数为:24 + 8 = 32 字节。
V0、V1 版本的设计缺陷
通过上面我们分析画出的 V0、V1 版本日志格式,我们会发现它们在设计上的一定的缺陷,比如:
空间使用率低:无论 key 或 value 是否存在,都需要一个固定大小 4 字节去保存它们的长度信息,当消息足够多时,会浪费非常多的存储空间。
消息长度没有保存:需要实时计算得出每条消息的总大小,效率低下。
只保存最新消息位移。
冗余的 CRC 校验:即使是批次发送消息,每条消息也需要单独保存 CRC。
V2 版本
针对 上面我们分析的 关于 V0、V1 版本日志格式的缺陷,Kafka 在 0.11.0.0 版本对日志格式进行了大幅度重构,使用可变长度类型解决了空间使用率低的问题,增加了消息总长度字段,使用增量的形式保存时间戳和位移,并且把一些字段统一抽取到 RecordBatch 中。
图12:V2 版本日志格式示意图
从以上图可以看出,V2 版本的消息批次(RecordBatch),相比 V0、V1 版本主要有以下变动:
将 CRC 值从消息中移除,被抽取到消息批次中。
增加了 procuder id、producer epoch、序列号等信息主要是为了支持幂等性以及事务消息的。
使用增量形式来保存时间戳和位移。
消息批次最小为 61 字节,比 V0、V1 版本要大很多,但是在批量消息发送场景下,会提供发送效率,降低使用空间。
综上可以看出 V2 版本日志格式主要是通过可变长度提高了消息格式的空间使用率,并将某些字段抽取到消息批次(RecordBatch)中,同时消息批次可以存放多条消息,从而在批量发送消息时,可以大幅度地节省了磁盘空间。
日志清理机制
Kafka 将消息存储到磁盘中,随着写入数据不断增加,磁盘占用空间越来越大,为了控制占用空间就需要对消息做一定的清理操作。从上面 Kafka 存储日志结构分析中每一个分区副本(Replica)都对应一个 Log,而 Log 又可以分为多个日志分段(LogSegment),这样就便于 Kafka 对日志的清理操作。
Kafka提供了两种日志清理策略:
日志删除(Log Retention):按照一定的保留策略直接删除不符合条件的日志分段(LogSegment)。
日志压缩(Log Compaction):针对每个消息的key进行整合,对于有相同key的不同value值,只保留最后一个版本。
这里我们可以通过 Kafka Broker 端参数 log.cleanup.policy 来设置日志清理策略,默认值为 “delete”,即采用日志删除的清理策略。如果要采用日志压缩的清理策略,就需要将 log.cleanup.policy 设置为 “compact” ,这样还不够,必须还要将log.cleaner.enable(默认值为 true)设为 true。
如果想要同时支持两种清理策略, 可以直接将 log.cleanup.policy 参数设置为“delete,compact”。
3.1 日志删除
Kafka 的日志管理器(LogManager)中有一个专门的日志清理任务通过周期性检测和删除不符合条件的日志分段文件(LogSegment),这里我们可以通过 Kafka Broker 端的参数 log.retention.check. interval.ms 来配置,默认值为300000,即5分钟。
在 Kafka 中一共有3种保留策略:
基于时间策略
日志删除任务会周期检查当前日志文件中是否有保留时间超过设定的阈值 (retentionMs) 来寻找可删除的日志段文件集合 (deletableSegments) 。
其中retentionMs可以通过 Kafka Broker 端的这几个参数的大小判断的
log.retention.ms > log.retention.minutes > log.retention.hours优先级来设置,默认情况只会配置 log.retention.hours 参数,值为168即为7天。
这里需要注意:删除过期的日志段文件,并不是简单的根据该日志段文件的修改时间计算的,而是要根据该日志段中最大的时间戳 largestTimeStamp 来计算的,首先要查询该日志分段所对应的时间戳索引文件,查找该时间戳索引文件的最后一条索引数据,如果时间戳值大于0,则取值,否则才会使用最近修改时间(lastModifiedTime)。
【删除步骤】:
首先从 Log 对象所维护的日志段的跳跃表中移除要删除的日志段,用来确保已经没有线程来读取这些日志段。
将日志段所对应的所有文件,包括索引文件都添加上“.deleted”的后缀。
最后交给一个以“delete-file”命名的延迟任务来删除这些以“ .deleted ”为后缀的文件。默认1分钟执行一次, 可以通过 file.delete.delay.ms 来配置。
图13:基于时间保留策略示意图
基于日志大小策略
日志删除任务会周期检查当前日志大小是否超过设定的阈值 (retentionSize) 来寻找可删除的日志段文件集合 (deletableSegments) 。
其中 retentionSize 这里我们可以通过 Kafka Broker 端的参数log.retention.bytes来设置, 默认值为-1,即无穷大。
这里需要注意的是 log.retention.bytes 设置的是Log中所有日志文件的大小,而不是单个日志段的大小。单个日志段可以通过参数 log.segment.bytes 来设置,默认大小为1G。
【删除步骤】:
首先计算日志文件的总大小Size和retentionSize的差值,即需要删除的日志总大小。
然后从日志文件中的第一个日志段开始进行查找可删除的日志段的文件集合(deletableSegments)
找到后就可以进行删除操作了。
图14:基于日志大小保留策略示意图
基于日志起始偏移量
该策略判断依据是日志段的下一个日志段的起始偏移量 baseOffset 是否小于等于 logStartOffset,如果是,则可以删除此日志分段。
【如下图所示 删除步骤】:
首先从头开始遍历每个日志段,日志段 1 的下一个日志分段的起始偏移量为20,小于logStartOffset的大小,将日志段1加入deletableSegments。
日志段2的下一个日志偏移量的起始偏移量为35,也小于logStartOffset的大小,将日志分段2页加入deletableSegments。
日志段3的下一个日志偏移量的起始偏移量为50,也小于logStartOffset的大小,将日志分段3页加入deletableSegments。
日志段4的下一个日志偏移量通过对比后,在logStartOffset的右侧,那么从日志段4开始的所有日志段都不会加入deletableSegments。
待收集完所有的可删除的日志集合后就可以直接删除了。
图15:基于日志起始偏移量保留策略示意图
3.2 日志压缩
日志压缩 Log Compaction 对于有相同key的不同value值,只保留最后一个版本。 如果应用只关心 key 对应的最新 value 值,则可以开启 Kafka 相应的日志清理功能,Kafka会定期将相同 key 的消息进行合并,只保留最新的 value 值。
Log Compaction 可以类比 Redis 中的 RDB 的持久化模式。我们可以想象下,如果每次消息变更都存 Kafka,在某一时刻, Kafka 异常崩溃后,如果想快速恢复,可以直接使用日志压缩策略, 这样在恢复的时候只需要恢复最新的数据即可,这样可以加快恢复速度。
图16:日志压缩策略示意图
磁盘数据存储
我们知道 Kafka 是依赖文件系统来存储和缓存消息,以及典型的顺序追加写日志操作,另外它使用操作系统的 PageCache 来减少对磁盘 I/O 操作,即将磁盘的数据缓存到内存中,把对磁盘的访问转变为对内存的访问。
在 Kafka 中,大量使用了 PageCache, 这也是 Kafka 能实现高吞吐的重要因素之一, 当一个进程准备读取磁盘上的文件内容时,操作系统会先查看待读取的数据页是否在 PageCache 中,如果命中则直接返回数据,从而避免了对磁盘的 I/O 操作;如果没有命中,操作系统则会向磁盘发起读取请求并将读取的数据页存入 PageCache 中,之后再将数据返回给进程。同样,如果一个进程需要将数据写入磁盘,那么操作系统也会检查数据页是否在页缓存中,如果不存在,则 PageCache 中添加相应的数据页,最后将数据写入对应的数据页。被修改过后的数据页也就变成了脏页,操作系统会在合适的时间把脏页中的数据写入磁盘,以保持数据的一致性。
除了消息顺序追加写日志、PageCache以外, kafka 还使用了零拷贝(Zero-Copy)技术来进一步提升系统性能, 如下图所示:
图17:kafka 零拷贝示意图
这里也可以查看之前写的 Kafka 三高架构设计剖析 中高性能部分。
消息从生产到写入磁盘的整体过程如下图所示:
图18:日志消息写入磁盘过程示意图
05 总结
本文从 Kafka 存储的场景剖析出发、kafka 存储选型分析对比、再到 Kafka 存储架构设计剖析、以及 Kafka 日志系统架构设计细节深度剖析,一步步带你揭开了 Kafka 存储架构的神秘面纱。
如果我的文章对你有所帮助,还请关注、点赞、在看、转发一下,非常感谢!
坚持总结, 持续输出高质量文章 关注我: 华仔聊技术 回复 kafka 有惊喜哦