Kafka是一个分布式流处理平台,主要用于大规模消息传递和数据处理。Kafka通过精心设计的消息格式和存储结构实现了节约磁盘空间占用的目标。接下来我将从以下几个方面进行解释:
消息格式
Kafka将每条消息存储为一个称为Record的结构。Record包含如下几个关键字段:
记录的长度(Length):该字段存储了消息记录的总长度,包括消息头和消息体。在读取或写入消息时,这个字段可以帮助快速定位消息的边界。
CRC32 校验和(Checksum):该字段用于检测消息是否被修改或破坏。在写入消息时,Kafka使用CRC32算法计算消息内容的校验和,并将该值存储在Checksum字段中。在读取消息时,Kafka会重新计算校验和,如果与Checksum字段中的值不匹配,则说明消息已被修改或破坏。
魔数(Magic Byte):该字段指定了消息的格式版本。Kafka支持多个消息格式版本,并且可以向后兼容。在写入消息时,Kafka会将消息的魔数字段设置为当前使用的消息格式版本。在读取消息时,Kafka可以根据魔数字段确定消息的格式版本,并使用正确的解码器对消息进行解析。Kafka中的魔数字段值通常是1或2,这取决于消息的格式版本。下面是Kafka中魔数字段值为1和2的情况:
魔数字段值为1:
当使用Kafka的0.10版本或更早的版本时,消息格式的魔数字段值通常为1。此时消息格式比较简单,只包含了长度、校验和、属性、时间戳、键和值等几个字段。魔数字段值为2:
当使用Kafka的0.11版本或更高的版本时,消息格式的魔数字段值通常为2。此时消息格式更为复杂,除了上述字段之外,还包含了批次头和多个记录等内容,支持将多个消息记录打包成一个批次进行传输,从而提高消息传输的效率。魔数的值决定了消息的格式版本,Kafka在读取消息时会根据魔数字段的值来确定消息的格式版本,并使用相应的解码器对消息进行解析。如果消息格式版本过低或过高,可能会导致消息解析失败或丢失关键信息。
因此,在Kafka中,魔数字段的值是非常重要的,它指定了消息的格式版本,影响了消息的格式和解析方式。在选择Kafka版本时,需要根据实际情况选择适合的魔数版本
属性(Attributes):该字段存储了消息的属性,例如是否启用压缩、是否启用时间戳等。在写入消息时,Kafka可以根据属性字段确定是否需要对消息进行压缩或附加时间戳等操作。在读取消息时,Kafka可以根据属性字段确定如何解压缩消息或者如何处理时间戳。
时间戳(Timestamp):该字段存储了消息的时间戳。Kafka支持两种类型的时间戳:创建时间和日志追加时间。在写入消息时,Kafka可以选择是否附加时间戳,并指定时间戳的类型。在读取消息时,Kafka可以根据时间戳类型获取正确的时间戳,并在需要的时候将其转换为本地时间。
键(Key):该字段存储了消息的键。键是一个可选字段,可以用于将多个消息分组或在消息存储和检索时进行优化。在写入消息时,Kafka可以选择是否添加键,并将键存储在Record中。在读取消息时,Kafka可以根据键的值对消息进行分组或进行其他操作。
值(Value):该字段存储了消息的实际内容。在写入消息时,Kafka将消息的实际内容存储在Record中。在读取消息时,Kafka可以获取消息的实际内容,并对其进行解码或其他操作。
Kafka在设计消息格式时,尽量减少了额外的元数据,以减小每条消息的存储开销。此外,Kafka使用压缩算法(如Snappy、LZ4或Gzip)对消息值进行压缩,从而进一步减少存储空间需求。
以下是Kafka Record的部分源码,用于创建Record:
public class Record {
// 定义Record对象的最大大小,使用位运算实现,最大值为2^30
public static final int MAX_SIZE = 1 << 30;
// 定义Record对象中key的偏移量为0
public static final int KEY_OFFSET = 0;
// 定义Record对象中value的偏移量为1
public static final int VALUE_OFFSET = 1;
// 用于存储Record对象的数据
private final ByteBuffer buffer;
/**
* 构造函数,创建一个Record对象
* @param buffer 用于存储Record对象的数据
*/
public Record(ByteBuffer buffer) {
this.buffer = buffer;
}
/**
* 计算Record对象的大小
* @param key 存储在Record对象中的key
* @param value 存储在Record对象中的value
* @return 计算得到的Record对象的大小
*/
public static int recordSize(SerializedKey key, SerializedValue value) {
// 计算Record对象的大小,等于HEADER_SIZE + key的大小 + value的大小
return HEADER_SIZE + key.size() + value.size();
}
/**
* 创建一个新的Record对象的缓冲区
* @param key 存储在Record对象中的key
* @param value 存储在Record对象中的value
* @param compressionType 压缩类型
* @param timestamp 记录时间戳
* @return 创建的新的Record对象的缓冲区
*/
public static ByteBuffer newRecordBuffer(SerializedKey key, SerializedValue value, CompressionType compressionType, long timestamp) {
// 计算Record对象的大小
int size = recordSize(key, value);
// 创建一个指定大小的ByteBuffer对象
ByteBuffer buffer = ByteBuffer.allocate(size);
// 将时间戳写入缓冲区
buffer.putLong(timestamp);
// 将压缩类型的id写入缓冲区
buffer.put((byte) compressionType.id);
// 将key的大小写入缓冲区
Utils.writeVarint(key.size(), buffer);
// 将value的大小写入缓冲区
Utils.writeVarint(value.size(), buffer);
// 将key写入缓冲区
buffer.put(key.buffer());
// 将value写入缓冲区
buffer.put(value.buffer());
// 将缓冲区的位置设置为0
buffer.rewind();
// 返回创建的新的Record对象的缓冲区
return buffer;
}
}
以上代码展示了Record类的实现,可以看到它包含了一个ByteBuffer作为底层数据结构。通过newRecordBuffer方法,可以创建一个新的Record缓冲区,包含所需的字段。这种紧凑的设计有助于减少磁盘空间的占用。
日志分段
Kafka将每个主题分区的消息存储在一个名为Log的日志文件中。为了避免单个文件过大,Kafka将Log分成多个分段(Segment),每个分段都包含一系列顺序存储的消息。分段文件有两个主要组件:消息文件(.log)和索引文件(.index)。通过分段和索引,Kafka可以在保持高性能的同时,有效地管理磁盘空间。
关于日志分段,这里是创建新的LogSegment的部分源码:
public class LogSegment {
// 用于存储日志的文件
private final File logFile;
// 用于存储索引的文件
private final File indexFile;
// 存储了offset与其对应的位置的偏移量的索引
private final OffsetIndex offsetIndex;
// 存储了offset与其对应的时间戳的偏移量的索引
private final TimeIndex timeIndex;
/**
* 构造函数,创建一个LogSegment对象
* @param logFile 用于存储日志的文件
* @param indexFile 用于存储索引的文件
* @param timeIndexFile 用于存储时间戳与偏移量的对应关系的文件
* @param baseOffset 日志段的基本偏移量
* @param indexIntervalBytes 索引之间的间隔字节数
* @param maxIndexSize 索引的最大大小
* @param time 时间对象
*/
public LogSegment(File logFile,
File indexFile,
File timeIndexFile,
long baseOffset,
int indexIntervalBytes,
long maxIndexSize,
Time time) {
// 创建一个FileRecords对象,用于存储日志
this.log = new FileRecords(logFile);
// 创建一个OffsetIndex对象,用于存储offset与其对应的位置的偏移量的索引
this.offsetIndex = new OffsetIndex(indexFile, baseOffset, maxIndexSize);
// 创建一个TimeIndex对象,用于存储offset与其对应的时间戳的偏移量的索引
this.timeIndex = new TimeIndex(timeIndexFile, baseOffset, maxIndexSize);
}
/**
* 将一条记录追加到日志段中
* @param offset 记录的offset
* @param timestamp 记录的时间戳
* @param size 记录的大小
* @param buffer 存储记录的缓冲区
*/
public void append(long offset, long timestamp, int size, ByteBuffer buffer) {
// 追加记录到日志文件
log.append(offset, buffer);
// 追加offset与其对应的位置的偏移量到偏移量索引中
offsetIndex.append(offset, log.sizeInBytes());
// 追加offset与其对应的时间戳到时间戳索引中
timeIndex.append(offset, timestamp);
}
}
在上述代码中,LogSegment包含了日志文件、索引文件、偏移索引(OffsetIndex)和时间索引(TimeIndex)。append方法可以将一条记录追加到这些文件和索引中。这种分段和索引的设计有助于在保持高性能的同时有效地管理磁盘空间。
空间回收
通过Log Compaction和Log Cleanup进行空间回收:Kafka提供了日志压缩和日志清理功能来实现更高效的磁盘空间管理。日志压缩通过保留消息键的最新值来消除重复的消息,从而减少磁盘空间占用。日志清理则基于用户配置的保留策略(如时间或大小)定期删除旧的日志分段。
关于日志压缩和日志清理,这里是启动日志压缩的部分源码:
public class LogManager {
// 日志清理器
private final LogCleaner cleaner;
/**
* 构造函数,创建一个LogManager对象
* @param config 日志的配置
* @param time 时间对象
*/
public LogManager(LogConfig config, Time time) {
// 创建一个LogCleaner对象,用于清理日志
this.cleaner = new LogCleaner(config, time);
// 启动日志清理器
cleaner.start();
}
/**
* 判断是否需要安排日志清理操作
* @param log 要进行清理的日志
*/
public void maybeScheduleCleanup(Log log) {
// 如果需要清理,则安排日志清理操作
if (log.shouldBeCleaned()) {
cleaner.scheduleCleaning(log);
}
}
}
LogManager类中的LogCleaner负责执行日志压缩和日志清理任务。当满足压缩或清理条件时,maybeScheduleCleanup方法将调度清理任务。这有助于Kafka实现更高效的磁盘空间管理。
综上所述,Kafka通过精心设计的消息格式、日志分段、索引和日志压缩、日志清理功能实现了有效地节省磁盘空间占用