
摘要很多人以为Kafka写消息是直接写磁盘其实FileChannel.write只写OS Page Cache。本文从LogSegment.append六步流程切入拆解MemoryRecords构造、FileChannel写入路径、Page Cache回写机制、三层持久化保障讲透Kafka为什么用Buffered IO而非Direct IO以及一条消息从Producer到磁盘的完整旅程。从一个常见误解切入面试时常被问Kafka为什么写那么快“很多人答顺序写磁盘”。这个答案只对了一半。顺序写确实是关键但Kafka的write()系统调用根本不直接写磁盘。FileChannel.write(ByteBuffer)把数据从用户态拷贝到内核态的Page Cache后就立即返回真正的磁盘写入由操作系统的pdflush线程异步完成。换句话说Kafka的高吞吐不是写磁盘快而是写内存快——磁盘只是最终归宿不是写入路径上的瓶颈。这篇沿着上一篇稀疏索引的线索把Log.append流程中写.log数据文件这一步彻底拆开从LogSegment.append的六步链路到MemoryRecords的二进制构造到FileChannel.write进入内核后的Page Cache机制再到三层持久化保障。读完你会理解Kafka为什么选择Buffered IO而非Direct IO以及acksall到底在等什么。01 写消息的完整路径用户态→Page Cache→磁盘一条消息从Producer发出到最终落盘经过三层空间用户态Kafka进程内Producer端序列化、分区路由、RecordAccumulator攒批Sender线程按Broker分组发送Broker端RequestHandler线程从requestQueue取出请求KafkaApis.handleProduceRequest → ReplicaManager.appendRecords → Partition.append → Log.append → LogSegment.append。整个调用链在JVM内完成不涉及磁盘IO。内核态OS Page CacheLogSegment.append最终调用FileRecords.append → channel.write(buffer)。write()系统调用把DirectByteBuffer的数据从用户态拷贝到内核态的Page Cache4KB为单位的内存页标记为dirty后立即返回。此时数据在内存中不在磁盘上。磁盘异步回写内核的pdflush/kworker线程定期扫描dirty页满足条件dirty_expire_centisecs默认30秒或dirty_ratio达20%时批量回写到磁盘。ext4文件系统的journal先写元数据日志再写数据崩溃后可恢复。这个设计的核心判断是Page Cache是免费的内存数据库。Kafka不自己实现缓存层直接复用OS已经做好的页缓存写时攒批、读时命中、淘汰由OS LRU自动管理。02 LogSegment.append六步流程LogSegment.append是写数据文件的核心方法六步完成一次批次写入第一步锁与校验。获取partitionLockReentrantLock检查当前副本是否为leader、消息格式版本是否兼容、分区是否只读。锁粒度是partition级别同一partition的写入串行化不同partition并行。第二步分配offset。baseOffset logEndOffsetLEO每批消息分配连续的offset区间。如果一批有N条消息占用[baseOffset, baseOffsetN-1]。分配后立即更新LEO后续写入基于新LEO。第三步构造MemoryRecords。MemoryRecordsBuilder按RecordBatch格式构造二进制数据先写Batch Header61字节以上再逐条写Record变长编码。如果配置了压缩多条Record打包压缩后写入Batch的value字段。第四步FileChannel.write。FileRecords.append调用channel.write(buffer)把MemoryRecords的ByteBuffer写入Page Cache。写入前记录channel.position()作为physicalPosition消息在.log文件中的起始字节位置这个值后续用于稀疏索引和LEO元数据。第五步更新offset元数据。构造LogOffsetMetadata包含三个字段messageOffset绝对offset、segmentBaseOffset所在段的baseOffset、relativePositionInSegment段内物理位置。这个三元组是查找消息的关键——消费者fetch时按offset定位段再按physicalPosition seek。第六步更新稀疏索引。bytesSinceLastIndexEntry累加本批消息字节数达到index.interval.bytes默认4096时调用offsetIndex.append和timeIndex.append通过mmap写入.index和.timeindex文件上一篇详细讲过。03 MemoryRecords的二进制构造MemoryRecords不是简单的字节数组它有严格的二进制格式。理解这个格式对理解写入性能和磁盘占用至关重要。RecordBatch Header最小61字节firstOffset(8B) length(4B) partitionLeaderEpoch(4B) magic(1B) crc(4B) attributes(2B) lastOffsetDelta(4B) firstTimestamp(8B) maxTimestamp(8B) producerId(8B) producerEpoch(2B) baseSequence(4B) recordCount(4B)magic字段标识消息格式版本当前v22attributes低3位编码压缩类型0none,1gzip,2snappy,3lz4,4zstd第3位标识时间戳类型CreateTime/LogAppendTime。Record变长编码length(varint) attributes(1B) timestampDelta(varlong) offsetDelta(varint) keyLength(varint) key valueLength(varint) value headersCount(varint) headers变长编码varint/varlong是Kafka节省空间的关键小数字用1-2字节大数字才用更多字节。offsetDelta和timestampDelta都是相对Batch首条的增量通常很小1字节即可。压缩时的特殊处理配置压缩后一个RecordBatch内的多条Record先序列化再整体压缩gzip/snappy/lz4/zstd压缩结果作为唯一一条Record的value写入。Batch Header的recordCount记录原始消息数解压后可恢复。这就是为什么压缩能显著降低磁盘占用和网络传输——重复的字段如相同的key前缀、相似的value在压缩算法中被高效编码。04 为什么用Buffered IO而非Direct IOKafka写数据文件用FileChannelBuffered IO写索引用mmap两种都经过Page Cache。为什么不用O_DIRECT绕过Page Cache直接写磁盘第一写吞吐差一个数量级。Direct IO每次write都要等磁盘IO完成机械盘随机写100 IOPS、SSD随机写1万IOPS而Page Cache写是内存操作百万级IOPS。消息队列的核心诉求是高吞吐写入Buffered IO天然适配。第二失去零拷贝读取。Kafka消费者fetch数据时如果数据在Page Cache中用sendfile系统调用直接从Page Cache → 网卡不经过用户态。如果用Direct IO数据不在Page Cache每次读都要从磁盘加载到用户态再发给消费者多两次拷贝。Kafka的零拷贝完全依赖Page Cache命中。第三批量写效率。Page Cache把多次小write合并成一次大的顺序磁盘写pdflush回写时按磁盘扇区排序减少磁盘寻道。Direct IO每次写都是独立的磁盘操作小批量写入效率极低。第四无需自行管理缓存。如果绕过Page CacheKafka需要自己实现一个内存缓存层类似HBase的MemStore管理写入、淘汰、持久化复杂度大幅增加。复用OS Page Cache等于免费获得一个经过几十年优化的缓存系统。当然Buffered IO有代价掉电时Page Cache中未回写的数据丢失。Kafka的解法不是频繁fsync而是多副本冗余——下一节详细讲。05 三层持久化保障Kafka的持久化不靠单机fsync而是三层机制叠加第一层OS异步回写默认路径。write()后数据在Page Cache中标记dirtypdflush线程定期回写。关键参数dirty_expire_centisecs默认300030秒dirty页超过30秒强制回写、dirty_background_ratio默认10%脏页占内存10%时后台启动回写不阻塞write、dirty_ratio默认20%达20%时write同步阻塞等待回写。正常流量下30秒内数据落盘异常流量写入远超磁盘带宽下dirty_ratio触发背压。第二层Kafka主动flush可选默认关闭。两个配置log.flush.interval.messages每写N条force一次默认Long.MAX_VALUE不触发、log.flush.interval.ms每N毫秒force一次默认null不触发。LogManager的ScheduledTask周期性检查各partition满足条件调用log.flush() → segment.flush() → channel.force(true)触发fsync系统调用。生产环境几乎不配置——fsync是同步阻塞频繁调用会把吞吐从百万条/秒降到几千条/秒。第三层副本机制真正的持久化保障。Producer配置acksall时Leader写入Page Cache后不立即返回等待ISRIn-Sync Replicas中所有Follower拉取并写入各自的Page Cache后才确认。HWHigh Watermark是ISR中最小的LEO只有HW之前的消息对消费者可见。Leader宕机时从ISR选新Leader已复制到多数副本的消息不丢。配合min.insync.replicas≥2即使一个副本宕机写入仍被拒绝而非静默丢消息。关键认知acksall不等同于数据落盘。它等的是所有ISR副本的Page Cache写入完成不是fsync。真正的磁盘落盘仍由OS异步完成。但因为数据存在于多个Broker的Page Cache中同时掉电的概率极低且Kafka支持unclean.leader.election.enablefalse防止落后副本当选Leader持久性已经足够强。06 真实源码解读LogSegment.append的核心实现基于Kafka 2.8源码简化注释defappend(largestOffset:Long,largestTimestamp:Long,shallowOffsetOfMaxTimestamp:Long,records:MemoryRecords):LogOffsetMetadata{// 写入前记录.log文件当前大小 本批消息的物理起始位置valphysicalPositionlog.sizeInBytes()if(records.sizeInBytes0){// FileRecords.append → channel.write写入Page Cachelog.append(largestOffset,largestTimestamp,shallowOffsetOfMaxTimestamp,records)// 累加自上次索引写入以来的字节数bytesSinceLastIndexEntryrecords.sizeInBytes// 达到间隔默认4KB才写一条稀疏索引if(bytesSinceLastIndexEntryindexIntervalBytes){valrelativeOffset(largestOffset-baseOffset).toInt offsetIndex.append(relativeOffset,physicalPosition)timeIndex.append(largestTimestamp,relativeOffset)bytesSinceLastIndexEntry0}}// 返回新的LEO元数据绝对offset 段baseOffset 段内物理位置LogOffsetMetadata(firstOffset,baseOffset,physicalPosition)}FileRecords.append的实现更短overridedefappend(records:MemoryRecords):Unit{// 写入前记录position用于校验写入完整性valstartPoschannel.position()if(records.sizeInBytes0){// channel.write可能部分写入需循环直到写完varwritten0while(writtenrecords.sizeInBytes){writtenchannel.write(records.buffer.duplicate())}}}注意channel.write的返回值可能小于buffer.remaining()——非阻塞模式下内核缓冲区满时会部分写入Kafka用while循环保证全部写入。实际生产中几乎不会触发部分写入因为Page Cache通常有足够空间。写入后Log.updateLogEndOffset更新LEOReplicaManager检查acks策略如果acksall把请求加入delayedProduce等待ISR副本同步如果acks1立即返回响应给Producer。07 与mmap索引写入的对比上一篇讲了稀疏索引用mmap写入这篇讲数据文件用FileChannelPage Cache写入。两种方式的选择不是随意的维度.log数据文件.index/.timeindex索引写入方式FileChannel.writeBuffered IOMappedByteBuffer.putIntmmap写入特征大文件顺序写每次几KB到几MB小文件随机写每次8/12字节系统调用每次write一次系统调用无系统调用直接写内存地址读取方式FileChannel.read或sendfile零拷贝mmap内存上直接二分查找刷盘OS异步回写默认不forceOS异步回写默认不force适合场景大批量顺序写入小条目随机写入随机读取核心逻辑数据文件批量大、顺序写FileChannel一次系统调用写一批摊销开销小索引文件条目小、按位置随机写mmap.putInt直接写内存地址省去每次8字节都要系统调用的开销。两者都经过Page Cache都靠OS异步回写持久化策略一致。写在最后Kafka基于OS Cache写消息本质是一个朴素的工程判断内存比磁盘快三个数量级能写内存就不写磁盘。FileChannel.write把数据丢进Page Cache就返回pdflush异步回写消费者读取时命中Page Cache走sendfile零拷贝——写入和读取都受益于同一份内存缓存。持久化不靠单机fsync那会毁掉吞吐而靠多副本冗余acksall确保数据写入多个Broker的Page Cache同时掉电概率极低配合ISR机制和HW截断保证一致性。理解了这一层再看Kafka的配置就有了判断依据为什么默认不配置flush间隔、为什么生产环境推荐acksallmin.insync.replicas2、为什么Broker机器要留足够内存给Page Cache而不是全给JVM堆。