《Java 消息中间件·Kafka 内核与客户端》10.1 顺序写、页缓存与零拷贝

顺序写为什么快、零拷贝到底省掉哪两次拷贝、Kafka 为什么把缓存交给操作系统?本节把约 10 MB 数据写进单分区,用 kafka-dump-log.sh 验证 625 个批次的 position 首尾相接,再对照 /proc/meminfo 的 Dirty 值与内核脏页参数,解释刷盘抖动从哪里来。

存储这一章要回答什么

前面九章讲的是「消息怎么走到 broker」。第 10 章开始讲「到了 broker 之后,数据怎么落地」。

这一节只回答三个问题:

  1. 为什么「一直往后追加」比「回到文件中间改写」快,快在哪一环;
  2. Kafka 的读路径上,零拷贝省掉的到底是哪两次内存拷贝,什么情况下省不掉;
  3. Kafka 几乎不自己做缓存,那它的「缓存」是谁的?代价是什么。

三个问题共用同一个答案:Kafka 把存储这件事尽量交还给操作系统。理解这一点,后面段滚动、恢复、容量规划才有共同的坐标系。

顺序追加快在哪

先说不成立的说法:「顺序写比随机写快」不是因为顺序写不用寻道——这只对机械盘成立;在 SSD 上,随机 4 KB 和顺序 4 KB 的差距远没有机械盘那么大。真正的差距来自三层:

层次随机写的代价顺序追加的代价
设备机械盘每次换磁道要寻道(毫秒级);SSD 每次随机要擦除/映射磁头几乎不动;SSD 内部可合并成大块
内核页缓存里散落的脏页,回写时按块排序的成本脏页连续,回写可以合并成大 IO
文件系统需要维护 extent 映射,碎片化后元数据压力大追加只在末尾扩展 extent,元数据几乎不变

Kafka 的设计把这三点都吃满了:一个分区只追加、不原地改;段文件写满就封口;删除以段为单位整文件删除。所以它对外表现为「一条永远在增长的日志」,对内核表现为「一个大文件末尾的连续写入」。

一个经常被忽略的细节是预读(readahead)。顺序读时内核会提前把后面的页读进缓存,消费端的 fetch 请求命中率因此很高;随机读则把预读全部浪费掉。Kafka 的消费模型是「从某个 offset 顺序往下读」,正好是预读最擅长的形态。

本机实测:position 是不是真的首尾相接

口说无凭。判断「是不是追加写」,最直接的证据是每个批次在 .log 文件里的字节位置 position。

本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 单节点 / 2026-10-29):

# 容器内
awk 'BEGIN{for(i=0;i<10000;i++){d="";for(j=0;j<1000;j++)d=d"x";printf "%05d %s\n", i, d}}' > /tmp/payload.txt
wc -c /tmp/payload.txt
# 10070000 /tmp/payload.txt

/opt/kafka/bin/kafka-console-producer.sh \
  --bootstrap-server 127.0.0.1:9092 --topic b5.seqio < /tmp/payload.txt

写入完成后,用 kafka-dump-log.sh --print-data-log 把整个段导出来:

/opt/kafka/bin/kafka-dump-log.sh \
  --files /tmp/kafka-logs/b5.seqio-0/00000000000000000000.log \
  --print-data-log > /tmp/dumpall.txt

从输出里提取每个批次头的 baseOffset / lastOffset / count / position / size,得到:

batches 625
first (0, 15, 16, 0, 16301)
last  (9984, 9999, 16, 10171824, 16301)
last pos+size = 10188125
gap distribution {0: 624}
zero-copy contiguous: True

四行输出说明四件事:

  1. 10000 条记录被装进 625 个批次,每个批次 16 条,批次大小 16301 字节——这是生产者端 batch.size=16384 与记录大小的自然结果(第 3.1 节会拆批次与 linger)。
  2. 第一个批次的 position 是 0,最后一个批次的 position 是 10171824。
  3. 相邻批次的间隙全部为 0,即 position[i] == position[i-1] + size[i-1] 对 624 对相邻批次全部成立。
  4. 最后一个批次的 position + size 等于 10188125,正好等于 .log 文件的字节数:
ls -l /tmp/kafka-logs/b5.seqio-0/00000000000000000000.log
# -rw-r--r-- 1 appuser appuser 10188125 Oct 29 04:20 00000000000000000000.log

文件里没有任何空洞。这就是「追加写」的证据:数据从 0 开始,一段接一段地写在末尾,没有一次回到中间。

页缓存:Kafka 的缓冲区是操作系统的

现在看第二个问题:数据从生产者到磁盘,中间经过谁?

生产者发来的批次进入 broker 后,被追加到 .log 文件的末尾。这次 write() 系统调用不直接写盘,而是写进内核的页缓存(page cache),由内核决定什么时候回写。Kafka 默认几乎不做主动刷盘:

log.flush.interval.messages = 9223372036854775807
log.flush.interval.ms = null
log.flush.scheduler.interval.ms = 9223372036854775807

这三行是容器内 /opt/kafka/logs/server.log 里打印的生效配置(本机实测,2026-10-29)。三个值都被设成了「永远不会触发」:不按条数刷、不按时间刷、调度器也不跑。也就是说,消息写入成功并不等于已经落到盘片上,它只保证进了页缓存。

这不是疏忽,是设计选择。原因有三条:

  • JVM 堆缓存和页缓存会变成双份。如果 Kafka 自己把最近的数据放一份在堆里,内核页缓存里还有一份,同一份数据占两次内存,操作系统还无法回收 JVM 堆里的那份。
  • 堆内对象带来 GC 压力。几十 GB 的日志用 Java 对象表示,光是存活对象扫描就够呛;而页缓存是纯粹的字节,没有对象头、没有 GC。
  • 页缓存是全局共享的。多个消费者读同一个段、副本同步读同一批数据,都能命中同一份页缓存;broker 自己做的缓存做不到这种跨连接共享。

代价也很明确:进程重启后缓存全丢,冷读要真的走磁盘;而且页缓存是「尽力而为」,内存紧张时会被回收,读取延迟会突然变高。

本机实测:写入前后的 Dirty

页缓存里被修改过、还没回写的页叫脏页(dirty page),在 /proc/meminfo 里是 Dirty。写入 10 MB 前后各采样一次:

# 写入前
grep -E "^(Cached|Dirty|MemFree):" /proc/meminfo
# MemFree:          239272 kB
# Cached:          2825176 kB
# Dirty:               568 kB
# 写完 10,070,000 字节后立刻
# Dirty:             20376 kB
# 约 10 秒后再采样
# Dirty:               764 kB

三组数字放在一起看:

时点Dirty说明
写入前568 kB系统空闲,几乎没有待回写的数据
写入后立刻20376 kB约 20 MB 脏页,包含我这 10 MB 和其它容器的写入
约 10 秒后764 kB内核回写完成,脏页被清空

Dirty 先涨后落,正是「先写页缓存,再由内核异步回写」的形状。这也解释了为什么整条命令(含 JVM 启动)只用了约 2.8 秒(elapsed_ms=2804,单次运行,随调度波动,不作基准):数据写完就返回,没有等磁盘。

必须说明的口径问题

上面所有 /proc/meminfo 的数字都是 colima 虚拟机(4 vCPU / 7.7 GiB)的全局值,不是这个容器的隔离值:

MemTotal:        8112468 kB

容器没有设置内存 cgroup 上限,/proc/meminfo 直接透出宿主 VM 的视图。所以:

  • Cached 在这次实验里从 2825176 kB 变成 2182728 kB 又变成 1642068 kB,这不是我的写入造成的,是同一台 VM 上其它容器与负载在换页。不能拿它当「Kafka 缓存了多少」的证据。
  • Dirty 的上涨有我的贡献,但同样混入了其它写入,只能作为「量级对得上」的旁证,不能当成精确归因。
  • 同理,后面用到的 iostat、/proc/diskstats 也是整块虚拟盘的数字。要精确归因到 Kafka 进程,应该看 /proc/<pid>/io,或者干脆在独占机器上做。

把口径写清楚,比给出一个好看的假数字重要。

dirty_ratio 与写入抖动

既然是内核在管回写,那内核的几个参数就直接决定了 Kafka 的写入延迟有没有毛刺。本机容器的取值:

cat /proc/sys/vm/dirty_ratio              # 20
cat /proc/sys/vm/dirty_background_ratio   # 10
cat /proc/sys/vm/dirty_expire_centisecs   # 3000  (30 秒)
cat /proc/sys/vm/dirty_writeback_centisecs # 500  (5 秒)

这四个参数的作用可以按「水位」来理解:

参数默认含义
dirty_background_ratio10脏页超过内存的 10% 时,内核开始后台回写,但写操作不阻塞
dirty_ratio20脏页超过 20% 时,写操作被同步阻塞,直到脏页降下去
dirty_expire_centisecs3000脏页存在超过 30 秒,必须回写
dirty_writeback_centisecs500后台回写线程每 5 秒醒一次

对 Kafka 来说,危险的是第二条。当写入速率持续高于磁盘回写速率时,脏页会一路涨到 dirty_ratio,此后每一次 write() 都可能被阻塞几百毫秒甚至更久。表现出来就是:吞吐看着还在,但请求处理线程(num.io.threads 那几个)被卡住,p99 延迟突然飙升,甚至触发客户端 request.timeout.ms。

这不是 Kafka 的 bug,是「用页缓存换吞吐」这件事的固有代价:你借了操作系统的内存当写缓冲,就得接受它在内存压力下向你收利息。

常见的调法有两种,取舍不同:

  • 调低回写水位:vm.dirty_background_ratio=5、vm.dirty_ratio=10(或改用绝对值的 dirty_background_bytes / dirty_bytes)。脏页更早开始回写,突发时被阻塞的概率下降,代价是磁盘更忙、顺序合并的机会略少。
  • 给页缓存留足内存:Kafka 节点上不要让其它进程抢内存。页缓存被压缩到很小的时候,回写压力会更频繁地触顶。

还有一条经验:Kafka 的堆不需要很大。本机 -Xmx384m 就撑起了 30 MB 的写入实验;生产上给 6–8 GB 堆已经算宽裕,剩下的内存应该留给页缓存,而不是堆。

零拷贝:省掉的到底是哪两次

第三个问题:消费者 fetch 数据时,broker 怎么把磁盘上的字节送到 socket?

传统路径:四次拷贝、四次上下文切换

不用零拷贝时,一次「读文件发给网络」大致是这样:

磁盘 --DMA--> 页缓存 --CPU 拷贝--> 用户缓冲区 --CPU 拷贝--> socket 缓冲区 --DMA--> 网卡

四次数据搬运里有两次是 CPU 参与的拷贝:页缓存到用户态、用户态到内核的 socket 缓冲区;另外两次是 DMA。上下文切换也是四次(read 进入内核、read 返回、write 进入内核、write 返回)。

sendfile:省掉两次 CPU 拷贝

sendfile 把「读文件 + 写 socket」合成一个系统调用,数据不再经过用户态:

磁盘 --DMA--> 页缓存 --CPU 拷贝--> socket 缓冲区 --DMA--> 网卡

省掉的是「页缓存 → 用户缓冲区」和「用户缓冲区 → socket 缓冲区」这两次 CPU 拷贝,用户态缓冲区整个不参与了。剩下的那次 CPU 拷贝发生在页缓存到 socket 缓冲区之间;在支持 scatter-gather 的网卡上,这一次也可以退化成只传描述符。

Kafka 里对应的源码

Kafka 的读路径最终落到 FileRecords.writeTo。这是 kafka-clients-4.3.0-sources.jar 里 org/apache/kafka/common/record/internal/FileRecords.java 的原文(第 291–302 行):

@Override
public int writeTo(TransferableChannel destChannel, int offset, int length) throws IOException {
    long newSize = Math.min(channel.size(), end) - start;
    int oldSize = sizeInBytes();
    if (newSize < oldSize)
        throw new KafkaException(String.format(
                "Size of FileRecords %s has been truncated during write: old size %d, new size %d",
                file.getAbsolutePath(), oldSize, newSize));

    long position = start + offset;
    int count = Math.min(length, oldSize - offset);
    // safe to cast to int since `count` is an int
    return (int) destChannel.transferFrom(channel, position, count);
}

destChannel 的类型是 TransferableChannel,它存在的理由就写在接口注释里(org/apache/kafka/common/network/TransferableChannel.java 第 39–48 行):

/**
 * Transfers bytes from `fileChannel` to this `TransferableChannel`.
 *
 * This method will delegate to {@link FileChannel#transferTo(long, long, java.nio.channels.WritableByteChannel)},
 * but it will unwrap the destination channel, if possible, in order to benefit from zero copy. This is required
 * because the fast path of `transferTo` is only executed if the destination buffer inherits from an internal JDK
 * class.
 * ...
 */
long transferFrom(FileChannel fileChannel, long position, long count) throws IOException;

明文连接上的实现只有一行(PlaintextTransportLayer.java 第 212–214 行):

@Override
public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
    return fileChannel.transferTo(position, count, socketChannel);
}

FileChannel.transferTo 在 Linux 上走的就是 sendfile。所以「Kafka 用零拷贝读消息」这句话,落到代码上就是这三层调用。

一个必须知道的例外:TLS 不走零拷贝

TransferableChannel 有两个主要实现。明文是上面那个;TLS 是 SslTransportLayer,它的 transferFrom 完全是另一套(第 1003 行起):

int transferSize = 32768;
// Allocate a direct buffer to avoid one heap to heap buffer copy. SSLEngine copies the source
// buffer (fileChannelBuffer) to the destination buffer (netWriteBuffer) and then encrypts in-place.
// FileChannel.read() to a heap buffer requires a copy from a direct buffer to a heap buffer, which is not
// useful here.
fileChannelBuffer = ByteBuffer.allocateDirect(transferSize);

它必须先把数据读进一个 32 KB 的堆外缓冲区,再做 TLS 加密,然后才 write() 到 socket。加密这件事必须触碰每一个字节,零拷贝在这里天然不成立。

这条边界的实际含义:开了 TLS 的集群,读路径的 CPU 开销和内存带宽占用都会明显高于明文,而且这部分开销落在网络线程上。第 11.1 节讲网络线程时会回到这个点。

零拷贝不代表「不拷贝」

需要强调:零拷贝没有让数据「一次都不复制」。它消灭的是用户态那一份多余的副本。如果你在 Kafka 前面又加了一层代理(比如某些网关),代理很可能把数据读进用户态再转发,零拷贝就被打断了——这也是「加一层代理吞吐掉一半」的常见原因之一。

读路径的两种形态

把上面几节串成一张表:

环节无零拷贝sendfileTLS 连接
磁盘到页缓存DMADMADMA
页缓存到用户态CPU 拷贝无CPU 拷贝到 32 KB 直接缓冲区
用户态到 socketCPU 拷贝CPU 拷贝(可退化为描述符)CPU 拷贝 + 加密
socket 到网卡DMADMADMA
用户态缓冲区需要不需要需要(堆外)

表格里最值得记的是最后一列:同一套 Kafka,明文的读路径和 TLS 的读路径不是一回事。做容量规划时把两者混着算,会得到偏乐观的结论。

观测手段与口径

本机容器里可用的工具:

iostat 1 2        # BusyBox v1.37.0 的 iostat,输出字段比 sysstat 少
cat /proc/diskstats
vmstat 1 2        # procps-ng 4.0.5

iostat 能看 tps、Blk_read/s、Blk_wrtn/s、%iowait,但它统计的是整块虚拟盘。本机在写入 20 MB 前后读 /proc/diskstats 的 vdb 一行:

写入前: ... 263701288 ...   (第 10 列:累计写入扇区数)
写入后: ... 263764224 ...

差值 62936 个扇区,约 32.2 MB。这个数字大于我写的 20 MB 数据,原因有两个:同一块盘上还有其它容器的写入;以及批处理、索引、快照等额外写。这个差值不能单独归因给本次实验,只能说明「量级相符」。

还有一个更精确的观察点是进程级 IO:

cat /proc/1/io

rchar/wchar 是系统调用层的字节数,read_bytes/write_bytes 是真正落到块设备的字节数。两者差距越大,说明页缓存吸收得越多。本机容器里没有 strace(command -v strace 返回空),所以没有对 sendfile 做系统调用级抓取;上面的零拷贝结论来自源码路径,不是 strace 输出,这一点必须说清楚。

三个常见误解

误解一:顺序写等于「消息已经安全落盘」。 顺序写只描述「写到文件的哪个位置」,不描述「什么时候真正写到盘片」。默认配置下 Kafka 不主动 fsync,落盘时机由内核决定。

误解二:acks=all 等于 fsync。 acks=all 说的是「等 ISR 里的副本都收下」,副本收下同样是写进各自的页缓存。要防的是机器掉电,不是进程崩溃;这两件事需要的机制不同,卷二会详细展开。

误解三:零拷贝对所有连接都生效。 只有明文的 PlaintextTransportLayer 走 transferTo;TLS 连接必然多一次读进用户态缓冲区的拷贝。

参数与建议

把这一节落成可执行的判断:

  1. 不要调大 log.flush.interval.messages 来「提速」——它默认就是 Long.MAX_VALUE,已经是「不刷」。
  2. 担心掉电丢数据,靠副本而不是靠 fsync。acks=all + min.insync.replicas=2 + 3 副本,比任何 fsync 频率都更划算。
  3. 关注 Dirty 与回写水位。如果节点上 Dirty 长期贴着 dirty_ratio,说明磁盘跟不上写入,下一步就是请求线程被阻塞。
  4. TLS 集群单独做容量评估。读路径多一次拷贝与加密,CPU 与内存带宽都要重新算。
  5. 不要让 Kafka 和别的重 IO 进程共享磁盘。页缓存、回写队列、IO 调度器都是共享的,邻居的写入会直接变成你的延迟毛刺。

本节判据

回答四个问题:

  1. 怎么用一条命令证明 Kafka 是追加写? 用 kafka-dump-log.sh --print-data-log 导出段,检查每个批次的 position 是否等于上一个批次的 position + size,且最后一个批次的终点等于文件大小。
  2. 零拷贝省掉的是哪两次拷贝? 页缓存到用户缓冲区、用户缓冲区到 socket 缓冲区这两次 CPU 拷贝。
  3. Kafka 为什么不自己做缓存? 避免与页缓存形成双份内存、避免堆内对象的 GC 压力,并让多个消费者和副本共享同一份缓存。
  4. dirty_ratio 和写入抖动是什么关系? 脏页超过该水位后,写系统调用被同步阻塞,请求处理线程被卡住,表现为延迟毛刺。

下一节进入段级别的机制:段什么时候滚动,.index 与 .timeindex 各自存什么,崩溃之后索引是怎么从 .log 重建出来的,以及重启日志里哪几行能证明恢复走了哪条路径。

阅读导航:上一节 9.3 客户端指标口径 | 下一节 10.2 段滚动、索引重建与恢复

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收