存储这一章要回答什么
前面九章讲的是「消息怎么走到 broker」。第 10 章开始讲「到了 broker 之后,数据怎么落地」。
这一节只回答三个问题:
- 为什么「一直往后追加」比「回到文件中间改写」快,快在哪一环;
- Kafka 的读路径上,零拷贝省掉的到底是哪两次内存拷贝,什么情况下省不掉;
- 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
四行输出说明四件事:
- 10000 条记录被装进 625 个批次,每个批次 16 条,批次大小 16301 字节——这是生产者端
batch.size=16384与记录大小的自然结果(第 3.1 节会拆批次与 linger)。 - 第一个批次的 position 是 0,最后一个批次的 position 是 10171824。
- 相邻批次的间隙全部为 0,即
position[i] == position[i-1] + size[i-1]对 624 对相邻批次全部成立。 - 最后一个批次的
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_ratio | 10 | 脏页超过内存的 10% 时,内核开始后台回写,但写操作不阻塞 |
dirty_ratio | 20 | 脏页超过 20% 时,写操作被同步阻塞,直到脏页降下去 |
dirty_expire_centisecs | 3000 | 脏页存在超过 30 秒,必须回写 |
dirty_writeback_centisecs | 500 | 后台回写线程每 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 前面又加了一层代理(比如某些网关),代理很可能把数据读进用户态再转发,零拷贝就被打断了——这也是「加一层代理吞吐掉一半」的常见原因之一。
读路径的两种形态
把上面几节串成一张表:
| 环节 | 无零拷贝 | sendfile | TLS 连接 |
|---|---|---|---|
| 磁盘到页缓存 | DMA | DMA | DMA |
| 页缓存到用户态 | CPU 拷贝 | 无 | CPU 拷贝到 32 KB 直接缓冲区 |
| 用户态到 socket | CPU 拷贝 | CPU 拷贝(可退化为描述符) | CPU 拷贝 + 加密 |
| socket 到网卡 | DMA | DMA | DMA |
| 用户态缓冲区 | 需要 | 不需要 | 需要(堆外) |
表格里最值得记的是最后一列:同一套 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 连接必然多一次读进用户态缓冲区的拷贝。
参数与建议
把这一节落成可执行的判断:
- 不要调大
log.flush.interval.messages来「提速」——它默认就是Long.MAX_VALUE,已经是「不刷」。 - 担心掉电丢数据,靠副本而不是靠 fsync。
acks=all+min.insync.replicas=2+ 3 副本,比任何 fsync 频率都更划算。 - 关注
Dirty与回写水位。如果节点上Dirty长期贴着dirty_ratio,说明磁盘跟不上写入,下一步就是请求线程被阻塞。 - TLS 集群单独做容量评估。读路径多一次拷贝与加密,CPU 与内存带宽都要重新算。
- 不要让 Kafka 和别的重 IO 进程共享磁盘。页缓存、回写队列、IO 调度器都是共享的,邻居的写入会直接变成你的延迟毛刺。
本节判据
回答四个问题:
- 怎么用一条命令证明 Kafka 是追加写? 用
kafka-dump-log.sh --print-data-log导出段,检查每个批次的position是否等于上一个批次的position + size,且最后一个批次的终点等于文件大小。 - 零拷贝省掉的是哪两次拷贝? 页缓存到用户缓冲区、用户缓冲区到 socket 缓冲区这两次 CPU 拷贝。
- Kafka 为什么不自己做缓存? 避免与页缓存形成双份内存、避免堆内对象的 GC 压力,并让多个消费者和副本共享同一份缓存。
dirty_ratio和写入抖动是什么关系? 脏页超过该水位后,写系统调用被同步阻塞,请求处理线程被卡住,表现为延迟毛刺。
下一节进入段级别的机制:段什么时候滚动,.index 与 .timeindex 各自存什么,崩溃之后索引是怎么从 .log 重建出来的,以及重启日志里哪几行能证明恢复走了哪条路径。
阅读导航:上一节 9.3 客户端指标口径 | 下一节 10.2 段滚动、索引重建与恢复
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。