资讯动态

Kafka存储原理深度解析:顺序写日志与稀疏索引如何支撑百万TPS

发布时间:2026/10/14 4:50:46 来源:尧图企业网站定制
如果有人突然问你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、.timeindex2.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 字节字段字节数说明relativeOffset4相对偏移量相对 Segment 基准偏移position4物理位置相对 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 版本之后一批消息共用一个批次头大幅减少了元数据开销。批次头的关键字段如下字段字节数说明baseOffset8本批次第一条消息的绝对偏移量batchLength4整个批次占用的总字节数partitionLeaderEpoch4分区首领任期用于隔离性校验magic1消息格式版本crc4批次校验和attributes2压缩格式、事务标记等属性lastOffsetDelta4最后一条消息相对 baseOffset 的差值baseTimestamp8批次内第一条消息的时间戳maxTimestamp8批次内最大时间戳producerId / producerEpoch8 2生产者幂等 ID 与纪元baseSequence4生产者序号起点recordsCount4批次内消息条数这一大串头信息对存储层的意义很大扫描时只需要读 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.policycompact,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 个 fdlog、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——整个排查链路就顺了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑