如果有人突然问你:"Kafka 不是把消息写到了磁盘上吗?磁盘那么慢,它凭什么还能扛住几十万甚至上百万的 TPS?"这个问题几乎是每个刚接触 Kafka 的人都会卡住的第一道坎。答案其实藏在两件事里:一是 Kafka 压根没打算做"随机读写",它的存储模型就是为顺序追加设计的;二是它在磁盘之上叠了一套索引机制,让定位消息时不需要把整个日志文件翻个底朝天。这篇文章我就从日志文件结构、索引文件格式、消息定位流程一直讲到日志清理和生产环境里的排障经验,把 Kafka 的存储原理从头到尾拆一遍,适合正在用 Kafka 但没细看过存储层的后端开发,也适合准备系统设计面试的同学。
1. 为什么Kafka敢把消息全落磁盘——先搞懂存储模型
1.1 顺序写才是主角
大家对"磁盘慢"的印象大多来自随机读写。一块普通的机械盘,随机 IOPS 也就一两百,每次寻道加旋转延迟轻松超过 10 毫秒;SSD 虽然快很多,但随机写入仍然要面对垃圾回收和写放大问题。可一旦把读写模式改成顺序的,情况就完全不同:机械盘顺序写能跑到 150MB/s 以上,SSD 上 500MB/s 甚至更高都不稀奇。
Kafka 的整个存储模型就是建立在这个反差上的。每个分区的数据都以追加方式写入,新消息永远落在日志尾部,旧消息不会被修改、不会被移动。这个设计和数据库的 B+ 树存储引擎形成鲜明对比:数据库为了支持按主键随机更新,必须在页面上反复做分裂、合并、重写;Kafka 则在写路径上彻底放弃了"原地更新"这件事,只往文件末尾追加字节。
这套思路在工程上叫"提交日志"(commit log)。它不只 Kafka 在用,很多分布式存储系统都靠它做数据复制和崩溃恢复。对 Kafka 来说,顺序写带来的直接收益就是:写入路径上没有随机 IO,消息攒成一批(batch)后一次 flush,吞吐自然就上去了。理解了这一点,"磁盘慢"的担心就卸掉了一半。
1.2 页缓存与零拷贝:读路径上的加速器
写是顺序的了,读呢?消费者来拉消息,总不能每次都穿透磁盘吧。这里 Kafka 用了两个非常聪明的招。
第一招是页缓存(page cache)。Kafka 没有像很多消息队列那样在 JVM 堆里维护一大块消息缓存,而是直接依赖操作系统。生产者刚写入的消息大概率还在页缓存里热着,消费者立刻来拉取时,数据根本不用落盘再读盘,直接从内核页缓存里返回。好处是双重的:少了 JVM 堆内缓存和管理开销,GC 压力小;同时页缓存的淘汰策略、预读策略都由操作系统这个"老手"来管,比我们自己写缓存靠谱。
第二招是零拷贝。消费者 fetch 数据时,如果消息已经在页缓存里,Kafka 可以用transferTo这类 sendfile 系统调用,让数据从页缓存直接通过 DMA 拷贝到网卡发送队列,中间少了一次从内核态到用户态的复制,也少了一次 JVM 堆里的周转。这两招叠加,读路径上的 CPU 和内存复制开销被压到了很低。
所以 Kafa (这里应该是 Kafka 的系统名,我保持原文) 其实是"写顺序、读缓存、传输零拷贝"的组合拳。存储层设计得好不好,看的就是这三件事能不能配合起来。
1.3 Topic → Partition → Segment 的层级关系
要读懂日志文件,脑子里得有这张层级图:
- Topic是逻辑上的消息分类。
- 每个Topic分成多个Partition(分区)。分区是 Kafka 并行和复制的基本单位,每个分区在磁盘上对应一个独立目录,比如
test-0、test-1。 - 每个Partition内部是一个只追加的日志,但这个日志不是一张无限大的文件,而是被切分成一段一段的Segment(日志段)。
为什么要把日志切成 Segment?最直接的原因是管理。一个分区写一年不切分,就是一个几十 GB 的巨型文件,清理过期数据时难道要从头重写整个文件?切分成段之后,删除过期数据只需要删掉最老的那几个文件;崩溃恢复时也只需要从最近的一个检查点往后重放。每个 Segment 在物理上对应一组文件,这就是下一节要展开的核心内容。
Segment 的划分在逻辑上也很干净:每个 Segment 有一个基准偏移量(base offset),也就是这个段里第一条消息的 offset。段内的其他偏移量都是相对这个基准值来算的。生产环境里一个分区可能有几十上百个 Segment,它们按基准偏移量从小到大排列,这就为后面的二分查找提供了天然条件。
2. 日志文件的三件套:.log、.index、.timeindex
2.1 一个Segment目录里躺着哪些文件
随手打开一个 partition 目录,你会看到成组的文件,同一组共享同一个基准偏移量。最常见的一组是:
-rw-r--r-- 1 kafka kafka 1073741824 09:30 00000000000000000000.log -rw-r--r-- 1 kafka kafka 10485760 09:30 00000000000000000000.index -rw-r--r-- 1 kafka kafka 10485760 09:30 00000000000000000000.timeindex.log:真正存放消息数据的文件,所有消息以 RecordBatch(记录批次)的形式顺序排列。.index:偏移量索引,记录"相对偏移量 → 物理位置"的映射,解决"根据 offset 找到消息在文件里哪个位置"的问题。.timeindex:时间戳索引,记录"时间戳 → 相对偏移量"的映射,解决"根据时间戳找到消息"的问题。
除了这三个,目录里还可能出现.snapshot(生产者状态快照文件)、.txnindex(开启事务时会有的事务索引)、以及清理过程中产生的.deleted临时文件。但日常分析存储结构,盯着三件套就够了。
注意一下文件命名:Segment 文件名是基准偏移量,用 20 位十进制数字左边补零。比如第一个 Segment 通常是00000000000000000000.log,下一个可能是00000000000010000000.log。文件名本身就告诉我们这个段从哪条消息开始。理解了这一点,你ls一下目录就能看出这个分区的消息水位大概在什么位置。
2.2 Segment 什么时候滚动
Segment 不是凭空产生的,它由"滚动"机制生成。触发滚动的主流条件有三个:
- 大小达到上限:
log.segment.bytes默认 1GB,写完 1GB 就滚动出一个新 Segment。 - 时间达到上限:
log.roll.hours默认 168 小时(7 天),即使没写满,到了时间也强制滚动。 - 索引写满:索引文件有大小上限
log.index.size.max.bytes(默认 10MB),如果索引先写满了,也会触发滚动。
这里有个容易忽略的细节:.index和.timeindex文件是预分配的。你ls -l看到 index 文件的大小可能已经是 10MB,但它内部只是稀疏写入,实际的逻辑条目可能只占了一小部分。这一点在排查磁盘占用时特别容易造成虚惊,等下讲工具的时候再细说。
还有一个规律:滚动之后,老 Segment 就变成只读的了,只有当前正在写的"活跃 Segment"允许追加。只读的好处是,清理由此可以安全地按 Segment 粒度做物理删除,不用考虑并发写入的冲突。
2.3 稀疏索引:为什么不是每条消息都建索引
看到索引文件只有 10MB 大小上限,你可能会想:消息这么多,一个 Segment 里成千上万条消息,索引装得下吗?
答案是:Kafka 用的是稀疏索引,不是每条消息都建一条索引记录。它默认是每隔log.index.interval.bytes(默认 4096 字节)的日志数据,才写入一条索引条目。也就是说,索引只记录"大约每 4KB 日志一个锚点",而不是每条消息一个锚点。
为什么敢这么干?因为定位消息分成两步:先通过索引跳到大概位置,再在日志文件里做小范围顺序扫描。4KB 的间隔意味着索引命中的位置和真实的消息位置之间,最多只差 4KB 的日志内容,而对 kafka 来说,在一个 4KB 区间内顺序扫描几批消息,成本极低,尤其这些数据大概率还留在页缓存里。
稀疏索引换来的是内存和磁盘的双重节省。算一笔账:默认 1GB 的 Segment,按 4KB 间隔,理论上有 1GB ÷ 4KB ≈ 26 万条索引条目,每条 8 字节,逻辑上也就 2MB 左右。如果改成每条消息都建索引,假设平均单批 1KB,条目数量直接翻四倍,索引文件和映射内存的开销都上去了,而查询性能的提升却几乎感觉不到——因为二分查找本来就只有十几次比较。这个"用少量扫描换索引体积"的取舍,是理解 Kafka 索引机制的关键。
3. 稀疏索引下的消息定位:二分查找的全过程
3.1 从offset到position的两段跳转
假设要消费 offset 为 1234567 的消息,broker 端大致做这样几步:
第一步,在分区内找到目标 Segment。分区维护着一个按基准偏移量排序的 Segment 列表,用二分查找找到"基准偏移量 ≤ 1234567 且最接近"的那个 Segment。比如存在三个段,基准分别是 0、1000000、2000000,那目标一定在基准 1000000 的段里。计算相对偏移量:1234567 - 1000000 = 234567。
第二步,打开这个 Segment 的.index文件做二分查找。索引里的 key 是相对偏移量,目标值是 234567。因为索引是稀疏的,大概率找不到一个正好等于 234567 的条目,所以取的是"不大于 234567 的最大条目"。假设找到的条目是(234560, 17823456),意思是:相对偏移 234560 这条消息在日志文件里从相对位置 17823456 字节开始。
第三步,拿到这个相对位置后,在.log文件中执行 seek 到 17823456,然后从那里开始往后顺序扫描消息批次。扫描时读取每个 RecordBatch 头部的 baseOffset 和 lastOffsetDelta,如果能确认目标 offset 还不在当前批次,就直接跳过整个批次,直到落在目标所在的那一批。
这三步合起来就是"先二分定位段,再二分定位索引项,最后小范围扫描"。整个过程最多十几次比较加微小的顺序读,这就是 Kafka 即使数据全在磁盘上,也能支持随机 offset 访问的原因。
3.2 二分查找的边界细节
看着简单的二分,边界条件其实很容易踩坑。Kafka 的 OffsetIndex 在查找时语义是"最大的不大于目标的索引项"(largest lower bound)。这个语义有几种情况要特别注意:
- 如果目标偏移比索引里第一条记录的偏移还小,直接返回空,调用方会从 Segment 起始位置开始扫。
- 如果索引为空,同样从 Segment 开头找。
- 如果目标偏移比最后一条还大,返回最后一条记录,然后从它对应的位置往后扫。
还有一个工程细节:索引文件的查找是在内存映射(mmap)文件上做的,索引本身就预加载在 JVM 的地址空间里。因此二分查找其实发生在内存中,基本不产生磁盘 IO。对有 26 万条条目的索引做二分,log2(262144) 也就是 18 次比较,速度是纳秒级的。
生产环境里偶尔会看到"索引和日志不一致"的情况,比如异常断电导致索引文件比日志文件多写了几条。遇到这种情况,broker 在启动恢复时如果发现索引尾部超出日志实际长度,会截断索引;如果索引文件本身结构损坏,则可能删掉重建。这也是为什么数据目录里偶尔能看到"重建索引"的相关日志,不必大惊小怪。
3.3 时间索引:按时间"捅"进去怎么查
按 offset 查消息是消费的主路径,但还有一类场景是按时间查:比如消费者想从"昨天 14:00 之后"开始消费,或者offsetsForTimes这类 API。这就需要时间索引出马了。
时间索引的定位逻辑也是分层的。Segment 的.timeindex文件记录了每个时间锚点对应的相对偏移量;同时每个 Segment 在内存里会维护一个最大时间戳(通常是最后一批消息的 maxTimestamp)。先在所有 Segment 里二分查找,找出"最大时间戳 ≥ 目标时间"的最老 Segment;进了 Segment 后,在.timeindex里做二分,"最大的不大于目标时间"的条目对应的相对 offset 就是切入点;拿到 offset 之后,再回到.index和.log走上一节的标准定位流程。
这套两级二分设计得非常对称:offset 和 timestamp 是两种不同的"查找键",但最终都归一到同一个落点——日志文件里的某个字节位置。日常使用中,时间索引的精度受稀疏度影响,最多偏差一个索引间隔的日志量,对消费起点来说完全可接受。
4. 从二进制视角看索引文件:到底存了什么
4.1 .index 文件的记录格式
.index文件的每条记录只有两个字段,总共8 字节:
| 字段 | 字节数 | 说明 |
|---|---|---|
| relativeOffset | 4 | 相对偏移量(相对 Segment 基准偏移) |
| position | 4 | 物理位置(相对 Segment 文件起始位置的字节偏移) |
注意两点:第一,这里存的是相对偏移量,不是绝对 offset。之所以能这么存,是因为 Segment 自带基准偏移量,查询时把基准值加回去即可。而且相对偏移量用 4 字节可以表达 2G 条消息,对默认 1GB 的 Segment 绰绰有余。第二,position 也是相对 Segment 起始位置,而不是绝对文件路径偏移。这样设计的好处是 Segment 的物理位置可以在磁盘上灵活放置,甚至迁移目录,索引内部不用做任何修改。
用工具 dump 出来的索引内容大致长这样:
offset: 0 position: 0 offset: 257 position: 4096 offset: 513 position: 8192 offset: 769 position: 12288 ...每 4096 字节的日志对应一条索引记录,position也正好以 4096 为步长的样子,但这只是理想情况。如果消息批次大小不均匀,position 的间距就会变化,索引同样按实际写入位置记录。
4.2 .timeindex 文件的记录格式
.timeindex每条记录也是 8 字节:4 字节时间戳 + 4 字节相对偏移量。这里有个容易让人困惑的点:Kafka 的时间戳是毫秒级的,当前时间已经是 1.7 万亿毫秒,远超 4 字节整数(最大约 21 亿)的表达范围,那 4 字节怎么存?
实际实现里,写入时取 64 位时间戳的高 32 位存入文件,读取时再配合低 32 位还原。这样既保证了索引条目足够小,又能在比较时维持时间戳的单调顺序。普通使用中我们不需要手工解析这个字段,但 dump 索引时看到的时间戳可能和你预期的 wall-clock 时间有偏差,就是这个原因造成的。
4.3 .log 里的一条消息到底长什么样
日志文件不是消息直接首尾相连,而是以RecordBatch为单位。自消息格式升级到 v2(即 0.11 版本)之后,一批消息共用一个批次头,大幅减少了元数据开销。批次头的关键字段如下:
| 字段 | 字节数 | 说明 |
|---|---|---|
| baseOffset | 8 | 本批次第一条消息的绝对偏移量 |
| batchLength | 4 | 整个批次占用的总字节数 |
| partitionLeaderEpoch | 4 | 分区首领任期,用于隔离性校验 |
| magic | 1 | 消息格式版本 |
| crc | 4 | 批次校验和 |
| attributes | 2 | 压缩格式、事务标记等属性 |
| lastOffsetDelta | 4 | 最后一条消息相对 baseOffset 的差值 |
| baseTimestamp | 8 | 批次内第一条消息的时间戳 |
| maxTimestamp | 8 | 批次内最大时间戳 |
| producerId / producerEpoch | 8 + 2 | 生产者幂等 ID 与纪元 |
| baseSequence | 4 | 生产者序号起点 |
| recordsCount | 4 | 批次内消息条数 |
这一大串头信息对存储层的意义很大:扫描时只需要读 baseOffset、lastOffsetDelta、batchLength 三个字段,就能判断目标 offset 是否在当前批次内;如果不在,直接往后跳batchLength字节即可,不需要逐个消息解析。这也是为什么稀疏索引下"范围内顺序扫描"依然高效——扫描单位是整批消息,不是单条。
5. 日志清理与压缩:磁盘空间是怎么被回收的
5.1 按时间与按大小删除
Kafka 默认的清理策略是delete,也就是物理删除老 Segment。删除由两个维度触发:
按时间:log.retention.hours默认 168 小时(7 天)。broker 周期性检查(log.retention.check.interval.ms默认 5 分钟),如果某个 Segment 的最大记录时间戳距今超过了保留时长,就把它标记删除。这里有个关键细节:时间是按 Segment 粒度判断的,不是按单条消息。一个 Segment 里只要还有一条"年轻"消息,整个 Segment 就能继续存活,所以个别老消息的实际存活时间可能比配置的保留期长得多。
按大小:log.retention.bytes默认 -1 表示不限。如果配置了上限,broker 会统计分区所有 Segment 的总大小,超过上限就从头删除最老的 Segment,直到总大小降到上限以内。活跃 Segment 永远不会被删除,所以即使配置很小,当前正在写的文件也会保留。
删除动作不是瞬时完成的,还会受log.segment.delete.delay.ms这类延迟参数影响,避免删除过程中还有消费者正在读取。掌握这两个维度的侧重很重要:时间维度适合控制"数据新鲜度",大小维度适合控制"存储成本",生产上常常两者结合用。
5.2 Log Compaction:保留每个Key的最新值
delete 策略处理的是"过期数据",但有一类数据不是按时间过期,而是按 Key 收敛的。典型场景是保存用户最新资料的 Topic、配置快照、或者__consumer_offsets这类内部偏移量提交 Topic——它们的共同特点是:对同一个 Key,只有最新的一条消息有意义,历史版本全都可以丢弃。
这时候就要开启compact清理策略。Log Compaction 的基本思路是:在一个分区内,对每个 Key 只保留包含该 Key 最新值的消息,之前所有同 Key 的旧消息都会被丢弃;Key 为 null 的消息不会被压缩(这类消息没有可见性语义)。
压缩不是每时每刻都在跑。清洁线程(cleaner thread)会先算一个"脏比例"(dirty ratio),也就是"尚未压缩的日志区间占比",只有当脏比例超过log.cleaner.min.cleanable.ratio(默认 0.5)时才对分区执行一次压缩。压缩的过程大致是:从当前已压缩的位置开始,扫描脏区间内所有消息,用内存里的一个 Key → 最新 offset 的映射做去重,然后把保留消息写进新的"干净" Segment,最后切换文件并删除旧的重复数据。
这里有两个使用上常见的坑。一个是 Key 数量极大的场景,压缩时在内存里维护的映射会非常占内存,需要根据实际情况调整 cleaner 线程数和堆大小。另一个是压缩不一定会立刻让文件变小——如果脏比例刚过阈值就触发,而重复的 Key 不多,压缩后的文件大小可能变化不大。理解了这个机制,就不会对"为什么配了 compact 磁盘占用还没降"感到困惑了。
5.3 清理线程的代价与配置
清理不是免费的。执行删除或压缩时,broker 要做文件 IO、fsync、checkpoint 更新,这些都会占用磁盘 IO 带宽和 CPU。生产环境里如果同时跑大量启用 compact 的 Topic,默认的 1 个 cleaner 线程可能不够,需要调大log.cleaner.threads。
还有一点容易被忽略:delete和compact可以同时配置,策略用delete,compact组合。这时过期消息按时间/大小删除,同时同 Key 的旧消息还会被压缩。像用户状态表这种既希望保留一定历史窗口、又希望 Key 级别的数据尽可能精简的场景,就适合这种组合策略。
# 修改指定 topic 的清理策略示例 bin/kafka-configs.sh --bootstrap-server localhost:9092 \ --alter --entity-type topics --entity-name user-state \ --add-config cleanup.policy=compact,delete6. 经验向:动手拆日志文件和一些调参建议
6.1 用官方工具查看日志与索引
纸上谈兵再多,不如实际拆一次文件。Kafka 自带的kafka-dump-log.sh(底层类是kafka.tools.DumpLogSegments)就是干这个的。
命令格式大概是这样:
# dump 日志文件里的消息内容 bin/kafka-dump-log.sh \ --files /data/kafka/test-0/00000000000000000000.log \ --print-data-log --deep-iteration # dump 偏移量索引 bin/kafka-dump-log.sh \ --files /data/kafka/test-0/00000000000000000000.index \ --index-file /data/kafka/test-0/00000000000000000000.log # dump 时间索引 bin/kafka-dump-log.sh \ --files /data/kafka/test-0/00000000000000000000.timeindex \ --index-file /data/kafka/test-0/00000000000000000000.logdump 日志文件时,加--print-data-log可以看到每条消息的 offset、时间戳、key 和 value;加--deep-iteration会深入到批次内部打印每一条记录。索引文件 dump 出来的格式就是我们前面说的offset: xxx position: xxx或者timestamp: xxx offset: xxx。我第一次看到这两份输出时,前面讲的二进制结构瞬间就立体了——纸上写的 8 字节一条记录,和实际文件里的内容完全对得上。
6.2 常见存储问题的排障思路
结合我自己在实际环境里踩过的坑,整理几个高频问题的排查路径:
磁盘占用暴涨。先别急着加机器,去数据目录里看每个分区有多少 Segment、每个 Segment 多大。如果 Segment 数量很多但单个都不大,说明消息积压了,消费速度跟不上。如果 Segment 数量不多但个个体积巨大,看 retention 是否真的生效——尤其是按时间保留时,可能会出现"某 Segment 里最大时间戳还有效所以整个 Segment 不删"的情况,这时候考虑配合log.retention.bytes限制总大小。
消费者拉取慢、IO 高。先确认是不是索引密度出了问题。如果消息批次特别小(比如每条消息才几十字节),4KB 间隔对应了几百条消息,索引命中后顺序扫描的批次很多,IO 自然就上去了。可以把log.index.interval.bytes调小一些(比如 2048),或者从生产者侧做消息攒批(batch.size 调大),减少小批次的数量。
启动时出现 corrupt index 相关日志。多数情况下 broker 会截断或重建出问题的索引,不用立刻重启整个集群。但如果频繁出现,要排查是不是磁盘损坏或写入路径不稳定,另外确认下文件系统有没有做掉电保护配置。
文件描述符打满。每个 Segment 至少占 3 个 fd(log、index、timeindex),还有打开的数据目录句柄。Topic 分区多、Segment 多的节点,fd 数量很惊人。记得把ulimit -n调大,这是 Kafka 运维的经典一课。
数据目录文件系统。建议用 xfs 或 ext4 这类成熟文件系统,分区数多的 Topic 会产生大量小文件,垃圾回收机制不友好的文件系统会很难受。kafka-log-dirs.sh可以快速查看每个 broker 上各分区的 Segment 分布、大小和异常离线目录,排查时优先用起来。
6.3 一些我常用的配置组合与个人体会
关于 Segment 大小,我建议默认 1GB 就好,不要盲目调大。调大 Segment 虽然能减少文件数量、降低管理开销,但有两个隐患:一是索引的 position 字段只有 4 字节,最多表达 2GB 的段内偏移,配得太大迟早踩到边界;二是单个 Segment 过大时,如果有消费者要从很老的 offset 开始追上进度,读路径上需要扫描的大文件会拖慢恢复速度。
log.index.interval.bytes默认的 4096 在绝大多数场景下是合理的。只有当你明确知道单条消息很大(比如几百 KB 以上)时,可以适当调大到 8192 或 16384,因为这种情况下 4KB 区间的日志可能连一条消息都装不下,索引密度显然过剩了。
还有个小经验:排查存储问题时,先du -sh *看看目录,再ls看 Segment 数量和命名,基本就能判断出是积压、清理策略没生效还是文件系统小文件爆炸。索引配置这类偏门参数,除非 dump 索引发现分布确实异常,否则不要随意动——默认参数能平衡绝大多数工作负载。
最后说一个我自己的使用体会。很多人第一次看到.index文件预分配 10MB 时,都会误以为索引本身占用了大量磁盘,其实它只是稀疏文件,逻辑内容远小于文件大小。理解这点之后,你再看磁盘利用率的监控,心态会稳很多。存储原理这种东西,光看文档容易飘,真正动手 dump 一个 Segment、对照二进制格式看一遍,印象会深刻得多。下次遇到 Kafka 存储问题,你也能像我一样,先看目录、再查索引、最后定位到具体的 Segment——整个排查链路就顺了。