1. 从一次磁盘告警说起:为什么Kafka的文件布局值得单独拎出来讲
凌晨两点,监控群里弹出一条磁盘使用率超过85%的告警,定位到一台Kafka Broker。第一反应是日志量涨了,扩容就行。但真正登上去看目录结构的时候,我发现事情没那么简单——data目录下密密麻麻全是.log、.index、.timeindex、.snapshot文件,单个分区目录里还躺着好几个不同偏移量命名的日志段。当时我盯着这些文件愣了几秒,才意识到自己对Kafka底层文件到底怎么组织、怎么滚动、怎么清理,其实只有个模糊印象。
这件事之后我花了不少时间把Kafka的文件体系从头到尾捋了一遍。这篇内容就是那次梳理的完整沉淀,围绕Kafka日志存储的物理文件结构展开,讲清楚每个文件是干什么的、命名规则怎么来的、日志段什么时候滚动、索引怎么加速查找、清理和压缩又动了哪些文件。适合已经用过Kafka、能跑通生产消费,但对其磁盘上到底存了什么、为什么这么存还不太有底的开发者。如果你只是会调API,看完这篇至少能对Broker的存储行为有个具象认知,排查磁盘、性能、数据丢失类问题时心里有谱。
需要先说明一点:Kafka的存储设计是围绕“顺序写 + 稀疏索引 + 分段管理”这套组合拳来的,所有文件的存在都不是拍脑袋,而是为了在超高吞吐下还能快速定位消息。理解了这条主线,后面每个文件的作用就都能串起来。
2. 一个分区目录里到底躺着哪些文件
2.1 分区即目录:Topic-Partition的物理映射
Kafka在逻辑上把消息组织成Topic,Topic再切成多个Partition,而每个Partition在Broker的磁盘上就对应一个实实在在的目录。目录命名规则是<topic名称>-<分区编号>,比如一个叫order-events的Topic有3个分区,那么在Broker的数据目录下就会看到order-events-0、order-events-1、order-events-2三个文件夹。
这个映射关系很关键:分区是Kafka并行读写的最小单位,也是副本复制的最小单位,而它落地就是一个目录。所以当你说“某个分区写入压力大”时,本质上是在说“某个目录下的文件在疯狂增长”。这也解释了为什么分区数不能无限加——每个分区都是一组文件句柄、一段内存索引、一份副本同步开销,分区太多会把Broker的文件描述符和内存吃光。
我见过有同学把分区数直接开到几百,结果Broker频繁Full GC,最后发现是每个分区的索引对象和日志段管理对象累积起来太占内存。所以分区目录不只是“存数据的地方”,它本身就是一种资源单位。
2.2 日志段文件:.log才是真正装消息的地方
进入任意一个分区目录,你会看到一堆以数字命名的.log文件,比如00000000000000000000.log、00000000000000012345.log。这些就是日志段(Log Segment),消息真正躺在里面。
为什么不是一个分区一个大文件,而要切成很多段?原因有几个。第一,删除旧数据时,直接删掉整个段文件比在一个大文件里挖洞高效得多,文件系统层面就是一次unlink。第二,查找消息时,可以先根据偏移量快速定位到某个段,再在段内查找,缩小范围。第三,段文件可以整体做索引、整体做压缩,管理粒度更细。
每个.log文件名的那串数字,是这个段里第一条消息的起始偏移量(baseOffset)。比如00000000000000012345.log表示这个段从偏移量12345开始存。文件名用20位数字左补零,是为了保证按文件名排序时顺序和偏移量顺序一致,这样Broker扫描目录就能直接得到有序的段列表,不用额外维护顺序。
2.3 索引文件:.index和.timeindex的分工
光有.log还不够,如果要从一个几百MB的段里找偏移量是某个值的消息,顺序扫太慢。所以每个日志段都配了两个索引文件:
.index:偏移量索引,记录“某个偏移量 → 该消息在.log文件中的物理位置”的映射。.timeindex:时间戳索引,记录“某个时间戳 → 对应偏移量”的映射。
这两个索引都是稀疏索引,不是每条消息都记,而是每隔一定字节数才记一条。默认由log.index.interval.bytes控制,值是4096字节。也就是说,大约每写入4KB数据,才往索引里加一条记录。这样索引文件体积很小,可以常驻内存(通过mmap映射),查找时先在索引里二分找到最近的记录,再回到.log里顺序扫一小段,就能定位到目标消息。
这个“稀疏”设计是Kafka高性能的关键之一。如果索引是稠密的,每条消息都记,那索引文件本身就会和日志一样大,内存根本放不下,写入时还要额外维护索引,吞吐直接崩掉。稀疏索引用一点点顺序扫描的代价,换来了索引体积的极大压缩,这笔账非常划算。
2.4 其他辅助文件:.snapshot、.deleted、leader-epoch-checkpoint
除了上面三类主力文件,分区目录里还可能看到一些辅助文件:
.snapshot:这是幂等生产者和事务相关的快照文件,记录生产者状态,用于故障恢复后避免重复写入。普通非事务场景不一定出现。.deleted:当段文件被标记删除但还没真正物理删除时,会先重命名成.deleted后缀,等待清理。leader-epoch-checkpoint:记录分区的Leader任期(epoch)信息,用于副本同步时判断数据新旧,防止旧Leader重新加入后覆盖新数据。partition.metadata:某些版本里记录分区的一些元信息。
这些文件平时不显眼,但在副本切换、事务恢复、数据清理时都会参与。排查问题时如果只盯着.log,可能会漏掉线索。
3. 日志段滚动:什么时候会切出一个新文件
3.1 滚动触发的两类条件
日志段不会无限增长,达到条件就会“滚动”(roll),也就是关闭当前段,开一个新的段继续写。触发条件分两类:
基于大小的滚动:由log.segment.bytes控制,默认是1GB。当一个段文件写满1GB,就滚动出新段。这是最常用的触发方式。
基于时间的滚动:由log.roll.ms或log.roll.hours控制,默认是7天。即使段没写满,超过这个时间也会强制滚动。这个设计是为了配合数据保留策略——如果某个分区写入量很小,一个段可能很久都写不满1GB,但如果不滚动,基于时间的过期删除就没法生效,因为删除是以段为单位的。
实际生产中,高吞吐分区基本靠大小触发,低吞吐分区靠时间触发。两者取先到者。
3.2 滚动时Broker做了什么
滚动不是简单新建一个文件。完整过程大致是:
- 关闭当前活跃段,把它从“可写”状态转为“只读”。
- 刷新(flush)该段的数据和索引到磁盘,确保落盘。
- 根据当前段的最后偏移量+1,计算出新段的baseOffset。
- 创建新的
.log、.index、.timeindex文件,文件名用新的baseOffset。 - 把新段设为活跃段,后续写入都进这个新段。
这里有个容易忽略的点:索引文件是随日志段一起滚动的。每个段有自己独立的索引,不是全局共享。所以查找时是“先定位段,再用该段的索引定位消息”,两级结构。
3.3 滚动频率对性能的隐性影响
段大小设得太大,比如设成4GB,会带来几个问题。一是删除粒度变粗,一个段里可能混着该删和不该删的数据,只能等整个段都过期才能删,磁盘占用会偏高。二是索引文件变大,虽然稀疏,但段越大索引条目越多,查找时的二分和后续扫描成本略增。三是段滚动时的flush操作会带来一次集中的磁盘IO抖动。
反过来,段设得太小,比如几MB,会导致段文件数量爆炸,每个段都要维护文件句柄和索引对象,内存和文件描述符压力大,而且频繁滚动本身也有开销。
我的经验是:默认1GB对大多数场景是合理的。如果分区写入量特别大(比如每秒几十MB),可以适当调大到2GB减少段数量;如果对数据保留精度要求高、希望过期数据尽快释放,可以调小到256MB或512MB。调整前最好结合磁盘容量和保留时长算一下段数量,别让目录里堆几千个段文件。
4. 索引文件内部长什么样:稀疏索引的物理结构
4.1 .index文件的二进制布局
.index文件不是文本,是二进制。每条索引记录固定8字节,拆成两部分:
- 前4字节:相对偏移量,是相对于该段baseOffset的差值,用int32存。用相对值而不是绝对偏移量,是为了省空间——绝对偏移量是long(8字节),相对值int(4字节)就够了,因为一个段内的消息数不会超过int范围。
- 后4字节:物理位置,是该消息在
.log文件中的字节偏移,也是int32。
所以一条索引记录就是(relativeOffset, position)。查找偏移量X时,先算出相对值X - baseOffset,在索引里二分找到不大于它的最大索引项,拿到position,然后从.log的position处开始顺序扫描,直到找到偏移量X的消息。
4.2 .timeindex文件的结构差异
.timeindex每条记录12字节:
- 前8字节:时间戳(long)。
- 后4字节:相对偏移量(int32)。
注意它存的是“时间戳 → 偏移量”,而不是物理位置。因为通过时间查找时,目标是先找到时间对应的偏移量,再走偏移量索引去定位物理位置。这是两段式查找:时间索引 → 偏移量 → 偏移量索引 → 物理位置。
4.3 索引条目数量的估算
假设段大小1GB,log.index.interval.bytes默认4096字节,那么一个段的索引条目大约是1GB / 4KB = 262144条。每条8字节,.index文件约2MB。.timeindex每条12字节,约3MB。加起来5MB左右,相对于1GB的日志,索引占比约0.5%。这个比例很小,所以索引文件可以放心地mmap到内存,不会造成内存压力。
但如果有人把log.index.interval.bytes调得很小,比如设成100字节,那索引条目会暴增到千万级,索引文件几十MB甚至上百MB,mmap虽然不直接占堆内存,但会占用页缓存,间接影响性能。所以这个参数不建议随意调小,默认4096是经过验证的平衡点。
5. 消息在.log文件里是怎么排布的
5.1 日志文件的物理格式:一批一批写
.log文件里存的不是一条条孤立的消息,而是消息批次(RecordBatch)。生产者发送时会把多条消息打包成一个批次,Broker接收后按批次追加到日志末尾。批次是Kafka存储和复制的基本单位。
一个批次的结构大致包含:起始偏移量、最后偏移量、消息数量、时间戳、CRC校验、压缩编码,以及批次内的实际消息数据。批次内的消息如果是压缩的,整批一起压缩,这样压缩率更高,也减少了单条消息的头部开销。
5.2 偏移量的分配与存储
消息的偏移量是Broker在写入时分配的,单调递增。批次头里记录了这批消息的起始偏移量和最后偏移量,批次内的消息偏移量是连续的,可以推算出来,不需要每条都存。
这也是为什么Kafka能支持高效消费:消费者拿到一个批次的起始偏移量,就能知道这批消息覆盖的范围,按批次拉取,减少网络往返。
5.3 为什么顺序写这么重要
Kafka的高吞吐很大程度上靠顺序写磁盘。机械硬盘顺序写的速度可以接近内存随机写的水平,而随机写会频繁寻道,速度差几个数量级。Kafka的日志追加就是纯顺序写,所有消息都往文件末尾加,不修改已有内容(除了索引和少量元数据)。这种设计让Kafka在普通磁盘上也能跑出很高的吞吐。
配合操作系统的页缓存(Page Cache),写入先到内存,由内核异步刷盘,Broker不用等每次写都落盘,进一步提升了吞吐。当然这也带来一个权衡:如果机器断电,页缓存里没刷盘的数据会丢。Kafka通过副本机制来兜底,而不是靠同步刷盘。
6. 数据清理与压缩:段文件的生命周期终点
6.1 基于时间的保留与基于大小的保留
Kafka不会永久保留数据,清理策略由log.retention.ms(时间)和log.retention.bytes(大小)控制。清理以段为单位:当一个段的所有消息都超过了保留时间,或者分区总大小超过保留字节数,这个段就会被标记删除。
删除过程是:先把段文件重命名为.deleted后缀,然后由后台线程延迟删除。延迟是为了让正在读这个段的消费者有机会完成读取,避免读到一半文件没了。
6.2 日志压缩(Log Compaction)动了哪些文件
除了直接删除,Kafka还支持日志压缩,用于保留每个Key的最新值。开启cleanup.policy=compact后,后台的清理线程会扫描段,把相同Key的旧值清理掉,只留最新值。
压缩不是原地修改,而是把需要保留的消息重写到新的段文件,然后删掉旧段。所以压缩期间会看到新段生成、旧段消失。压缩后的段里,消息的偏移量可能不再连续,因为中间被删掉的消息留下了偏移量空洞。这也是为什么压缩场景下消费者不能假设偏移量连续。
6.3 清理线程的工作节奏
清理由log.cleaner.threads控制线程数,log.cleaner.enable开启。清理线程会挑选“脏”比例高的段优先处理。所谓脏比例,就是可以被清理掉的消息占比。如果一个段里大部分消息的Key都还有更新的值在后面,这个段就值得清理。
清理是IO和CPU密集型操作,生产环境要控制好线程数和触发频率,避免和正常读写抢资源。我一般会把清理安排在业务低峰期,或者限制清理线程的带宽。
7. 排查磁盘和性能问题时,我会先看这几个文件指标
7.1 段文件数量和大小分布
登上一台Broker,我习惯先ls -lh看分区目录,重点看两件事:段文件数量是不是异常多,单个段大小是不是偏离预期。如果发现某个分区有几千个段文件,说明段滚动太频繁或者保留策略没生效,需要检查log.segment.bytes和保留配置。如果段大小远小于设定值,可能是时间滚动在起作用,也可能是写入量太小。
7.2 索引文件与日志文件的比例
正常情况下.index和.timeindex加起来应该是.log的1%以内。如果发现索引文件异常大,比如占了日志的10%,那多半是log.index.interval.bytes被调小了,或者有异常写入模式导致索引条目暴增。索引过大不仅占页缓存,还会拖慢查找。
7.3 .deleted文件堆积说明什么
如果目录里出现大量.deleted文件且长时间不消失,说明删除线程跟不上,可能是磁盘IO饱和,或者有消费者长时间持有文件句柄导致删除延迟。这时候要检查消费者组的消费进度,看是不是有卡住的消费者。
7.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 段文件数量暴增 | 段太小或滚动过频 | 检查log.segment.bytes、写入速率 |
| 索引文件异常大 | 索引间隔调小 | 检查log.index.interval.bytes |
| .deleted文件堆积 | 删除线程受阻 | 检查磁盘IO、消费者句柄 |
| 磁盘占用高于预期 | 保留策略未生效 | 检查retention配置、清理线程状态 |
| 查找消息变慢 | 索引失效或段过大 | 检查索引文件完整性、段大小 |
8. 几个我踩过的坑和对应的处理方式
第一个坑是误删段文件导致数据错乱。有次为了快速释放磁盘,手动删了几个.log文件,结果Broker重启后偏移量对不上,副本同步直接失败。后来才明白,段文件必须通过Kafka自己的清理机制删除,手动删会破坏偏移量连续性。正确做法是调整保留策略,让Broker自己清理。
第二个坑是索引文件损坏。某次机器异常断电后,部分.index文件损坏,导致消费时找不到消息。Kafka有索引重建机制,删除损坏的索引文件后,Broker会根据.log重新生成索引。所以索引文件理论上是可以重建的,但.log不能丢。
第三个坑是段大小设置不当导致内存压力。曾经把段设成4GB,结果分区多的时候,每个段的索引对象和mmap区域累积起来,把页缓存挤占得很厉害,影响了其他服务的IO。后来改回1GB,问题缓解。
第四个坑是忽略timeindex的作用。有次做基于时间的消息回溯,发现按时间查找特别慢,后来发现是.timeindex没有正常生成,原因是时间戳写入有问题。时间索引依赖消息时间戳,如果生产者写入的时间戳异常,时间索引就会失效。
9. 关于文件体系,我个人的几点使用体会
把Kafka的文件体系捋清楚之后,我最大的感受是:它的每一个设计都在为吞吐和可管理性做权衡。分段是为了删除和查找的粒度,稀疏索引是为了内存和速度的平衡,顺序写是为了磁盘性能,副本是为了可靠性兜底。这些设计不是孤立的,而是互相咬合的一整套方案。
实际运维中,我建议把分区目录当成一个需要定期观察的对象,而不是只盯着监控面板上的数字。磁盘告警时,先看段文件分布;消费延迟时,先看索引和段状态;副本异常时,先看leader-epoch-checkpoint。这些文件是Broker行为的直接证据,比任何二手指标都可靠。
另外,调整任何和文件相关的参数(段大小、索引间隔、保留策略、清理线程)之前,最好在测试环境验证一遍,观察段数量、索引大小、磁盘占用的变化。这些参数之间是联动的,改一个可能影响另一个。比如调小段大小会减少单段删除粒度,但会增加段数量;调大索引间隔会减小索引体积,但会增加查找时的扫描量。没有万能配置,只有适合当前业务写入模式和保留需求的配置。
最后分享一个小习惯:我会定期用脚本统计每个Broker上分区目录的段数量、平均段大小、索引占比,生成一份趋势报告。这样在问题爆发前就能看到异常苗头,比如段数量缓慢增长、索引占比悄悄上升。这种主动观察比被动救火省心得多。