KAFKA · SEGMENT · .INDEX · .TIMEINDEX · PAGE CACHE · SENDFILE · RETENTION
只往文件末尾追加,缓存和搬运都交给内核
Kafka 把每个分区存成一串只追加的段文件(segment),每段配一本稀疏索引:按 offset 找消息,先二分索引,再往后扫几 KB。它自己几乎不缓存数据。写进来的数据先留在操作系统的页缓存里;发给消费者时调用 sendfile,内核直接把页缓存里的数据交给网卡,数据不进 JVM,这就是零拷贝。“Kafka 为什么快”的答案,就是这几件事配合起来。
假设场景接着上一页的订单 topic。上线一段时间后,找上门三件事:面试官问“Kafka 为什么快”;运维说 retention 设了 7 天,磁盘上却有 12 天前的数据;报表组一回放历史,实时消费就变慢。所以本页偏原理和排障,第⑥步末尾附面试题。标“实测”的地方,都在本机 Kafka 4.1.2 单节点(KRaft、2 核虚拟机)上跑过。
①为什么要学:三件事都卡在“消息怎么存”
上一页解决了“不丢、不乱”。这一页看 Broker 的磁盘里到底放了什么,Kafka 读写为什么快。
面试必问。标准答案是一串词:顺序写、页缓存、零拷贝、批量、分区。本页把每个词背后的机制画出来。
④ 原理retention 设了 7 天,磁盘上却有 12 天前的段;磁盘告警了,等它自己删又删不掉。
⑥ 坑 1报表组从 7 天前读起,库存组的延迟跟着涨。要知道为什么,以及怎么避开。
⑥ 页缓存②老办法:自己设计会怎样
存储是 Kafka 里的一个机制,没有现成的“老技术”可替换。那就假设没有 Kafka,用手上会的东西存消息、发消息。最先想到的四种做法都能跑,但各疼一处,Kafka 的设计正好是冲着这几处去的。
试 1 · 存进 MySQL一条消息一行
- 每条消息一次随机写,还要写 redo log 和 binlog
- 消费完 delete,表里留下空洞,还得定期整理
- 多一个下游,就要多记一份“谁读到哪了”
Kafka:不建表,只往文件末尾追加;删除就是删掉最老的整段文件。
试 2 · 一个大文件只追加写是快了
- 旧数据删不掉,除非把整个文件重写一遍
- 想读第 1 亿条,只能从文件头一条条数过去
Kafka:切成一段一段(segment),文件名就是这段第一条消息的 offset。
试 3 · 每条消息记一条索引找得快了
- 索引条数和消息一样多:10 亿条消息,每条 8 字节,索引就是 8 GB
- 索引放不进内存,查索引本身又要读盘
Kafka:稀疏索引,每攒够 4 KB 数据才记一条。查找时二分到附近,再往后扫几 KB。
试 4 · JVM 里缓存,读出来再发热数据快了
- 几十 GB 数据放在堆里,GC 停顿受不了
- 进程一重启,缓存全空
- 同一份数据,堆里一份,操作系统的页缓存里还有一份
- 发给消费者:内核拷到 JVM,JVM 再拷回内核
Kafka:不在堆里缓存消息,全靠页缓存;发送用 sendfile,数据不进 JVM。
稀疏查
交给内核
精华:Kafka 的存储很“笨”。写只往段文件末尾追加,查靠稀疏索引定位到几 KB 以内,缓存交给页缓存,发送交给 sendfile。JVM 只管协议、校验和元数据,消息本身很少经过它。
所以调 Kafka 的性能,很多时候是在调操作系统:内存留给页缓存、磁盘别和别人抢、文件句柄够用。
- 给 Broker 开 32 GB 堆,想“多缓存点消息”。Kafka 不在堆里缓存消息,堆越大,留给页缓存的越少。官方文档给的参考配置是
-Xmx6g。 - 为了“更安全”把
flush.messages设成 1,每次写都 fsync。本机实测吞吐从 16.6 MB/s 掉到 4.5 MB/s。不丢消息靠的是副本,见上一页。 - 一个集群建几万个分区。每个分区一串文件,写入点被打散,文件句柄和内存映射也跟着成倍涨。
- 把
retention.ms当成“到点就删”。删除按段来,看的是段里最新一条消息的时间。
③选谁:几种消息系统的存法
存消息不止 Kafka 一种做法。比四件事:写入是不是顺序的,怎么找到一条,怎么删,怎么发出去。最值得对照的是 RocketMQ,两边都是只追加日志,分法不同。
| Kafka | RocketMQ | RabbitMQ 经典队列 | Redis Stream | |
|---|---|---|---|---|
| 写入 | 每个分区一串段文件,顺序追加 | 所有 topic 顺序写进一个 CommitLog | 按队列存,消息确认后就删 | 内存里的基数树,靠 RDB / AOF 落盘 |
| 找一条 | 选段 → 二分稀疏索引 → 扫几 KB | 查 ConsumeQueue 拿到位置,再去 CommitLog 读 | 只从队头取,没有按位置查 | 按消息 ID 在树里查 |
| 删除 | 按段删:超过保留时间或大小 | 按文件过期删 | 消费者确认(ack)后删 | XTRIM / MAXLEN 裁剪 |
| 发给消费者 | sendfile 零拷贝(明文连接时) | mmap 映射文件后读写 | 没有零拷贝 | 从内存直接回复 |
| 分区 / 队列很多时 | 写入点变多,几千个以上要小心 | 写入点只有一个 | 队列多时内存压力大 | 单个 Stream 只在一个节点上 |
- 要回放历史、多个组各读一份、量大:选只追加日志(Kafka、RocketMQ,或 RabbitMQ 3.9 起的 Stream 类型)。
- 单个 Broker 上分区或队列特别多(几千到上万):RocketMQ 的单 CommitLog 更稳;用 Kafka 就要控制分区数。
- 量不大、已经有 Redis、数据能全放内存:Redis Stream 就够。
本页学 Kafka 的存储:段文件、稀疏索引、页缓存、sendfile。把这四样连起来,就能回答“Kafka 为什么快”。
④原理:一串段文件、一本稀疏目录、交给内核搬
打个比方:一本只往后写的流水账,写满一本换新的(段);每本前面有一张“每隔几页记一次页码”的目录(稀疏索引);账本摊在操作系统的桌上(页缓存),有人来借,管理员直接把那几页递出去,不经过你的手(零拷贝)。比喻就到这里,下面看真东西。
① 分段
一个分区是一串段文件,文件名是这段第一条消息的 offset(20 位补零)。写满 1 GiB(segment.bytes)或满 7 天(segment.ms)就换新段,只有最后一段在写。
② 稀疏索引
.index 每攒够 4 KB 数据(index.interval.bytes)记一条“offset → 文件位置”。找一条消息:选段 → 二分索引 → 从记下的位置往后扫。按时间找先查 .timeindex。
动手:按 offset 找一条消息
index.interval.bytes
为了画得下,这个分区每段只有 32 KB。查找规则和 Kafka 一样:攒够 index.interval.bytes 才记一条索引,索引记的是“某一批的最后一个 offset → 这一批在文件里的起始位置”(第⑤步实测的索引就是这样)。
# 建 topic 时定存储参数(下面两个就是默认值)
bin/kafka-topics.sh --bootstrap-server localhost:9092 --create \
--topic order-created --partitions 3 \
--config segment.bytes=1073741824 \
--config retention.ms=604800000
# 翻开段文件和索引(目录在 log.dirs 下)
bin/kafka-dump-log.sh --files \
<分区目录>/00000000000000000000.index
TopicPartition tp = new TopicPartition("order-created", 0);
consumer.assign(List.of(tp));
// 按 offset 跳:Broker 用 .index 定位
consumer.seek(tp, 4730L);
// 按时间跳:先查 .timeindex 拿到 offset,再走 .index
long ts = Instant.now().minus(Duration.ofHours(1)).toEpochMilli();
OffsetAndTimestamp hit = consumer.offsetsForTimes(Map.of(tp, ts)).get(tp);
if (hit != null) consumer.seek(tp, hit.offset());
放到第⑥步索引间隔调大调小的代价、索引为什么预分配 10 MiB、开 TLS 后零拷贝去哪了、retention 为什么不准、页缓存要留多大。这一步先记住四件事:分段、稀疏索引、页缓存、sendfile。
⑤跑起来:把一个分区翻开看
沿用上一页第⑤步的单节点 Kafka(解压版或 Docker 都行)。这一步不写业务代码,用 Kafka 自带的命令把段文件、索引、页缓存、sendfile 挨个看到,最后用一段 Java 摸一下零拷贝。下面的输出都是本机实测,只删了不相关的列。
# 段大小设成允许的最小值 1 MiB,4 MB 数据就能切出好几段
bin/kafka-topics.sh --bootstrap-server localhost:9092 --create \
--topic order-demo --partitions 1 --config segment.bytes=1048576
# 每条 200 字节;batch.size=1024 让每批只装 4 条,方便看稀疏索引
bin/kafka-producer-perf-test.sh --topic order-demo \
--num-records 20000 --record-size 200 --throughput -1 \
--producer-props bootstrap.servers=localhost:9092 batch.size=1024 linger.ms=0
Created topic order-demo. 20000 records sent, …
实测把 segment.bytes 设成 102400(100 KB)会直接报错:Invalid value 102400 for configuration segment.bytes: Value must be at least 1048576。这是 4.0 加的限制,3.x 最小只要 14 字节。
# logDir 就是分区目录的上一级;解压版默认是 /tmp/kraft-combined-logs
bin/kafka-log-dirs.sh --bootstrap-server localhost:9092 --describe --topic-list order-demo
ls -l <logDir>/order-demo-0/
{"partition":"order-demo-0","size":4485000, … ,"logDir":"…/data"}
1864 00000000000000000000.index
1047696 00000000000000000000.log
708 00000000000000000000.timeindex
1864 00000000000000004672.index
1047696 00000000000000004672.log
56 00000000000000004672.snapshot
456 00000000000000004672.timeindex
…(9344、14016 两段同上)
10485760 00000000000000018688.index ← 活跃段:索引预分配 10 MiB
294216 00000000000000018688.log
56 00000000000000018688.snapshot
10485756 00000000000000018688.timeindex
8 leader-epoch-checkpoint
43 partition.metadata
5 段,文件名是每段第一条的 offset:0、4672、9344……每段 4672 条、1047696 字节,再加一批(897 字节)就超过 1 MiB,所以滚到新段。
写满的段 .index 只有 1864 字节,也就是 233 条;正在写的段一创建就是 10485760 字节,滚段时才截短。.snapshot 是生产者状态(幂等去重用),leader-epoch-checkpoint 就是上一页讲的 Leader Epoch。
cd <logDir>/order-demo-0
<kafka>/bin/kafka-dump-log.sh --files 00000000000000004672.index | head -4
<kafka>/bin/kafka-dump-log.sh --files 00000000000000004672.log | head -16
offset: 4695 position: 4485 offset: 4715 position: 8970 ← 不超过 4730 的最大一条 offset: 4735 position: 13455 baseOffset: 4672 lastOffset: 4675 count: 4 position: 0 size: 897 baseOffset: 4676 lastOffset: 4679 count: 4 position: 897 size: 897 … baseOffset: 4692 lastOffset: 4695 count: 4 position: 4485 size: 897 ← 第 1 条索引指着这批 … baseOffset: 4712 lastOffset: 4715 count: 4 position: 8970 size: 897 ← 从这开始扫 baseOffset: 4716 lastOffset: 4719 count: 4 position: 9867 size: 897 baseOffset: 4720 lastOffset: 4723 count: 4 position: 10764 size: 897 baseOffset: 4724 lastOffset: 4727 count: 4 position: 11661 size: 897 baseOffset: 4728 lastOffset: 4731 count: 4 position: 12558 size: 897 ← 4730 在这一批
- 选段:4672 ≤ 4730 < 9344,在
00000000000000004672这一段。 - 查索引:不超过 4730 的最大一条是
4715 → 8970。注意它记的是 4712–4715 这一批的最后一个 offset 和这一批的起始位置。 - 扫日志:从第 8970 字节往后,一批一批看 lastOffset,跳过 4 批,第 5 批 4728–4731 包含 4730,它在第 12558 字节。
- 验证:
kafka-console-consumer.sh --topic order-demo --partition 0 --offset 4730 --max-messages 1 --property print.offset=true --property print.value=false打印Offset:4730。同一时间 strace 抓到 Broker 调的是sendfile(…, 00000000000000004672.log, [12558], …),正好从这一批开始发。
# 段文件有多少在页缓存里(RES)
fincore <logDir>/order-demo-0/*.log
# 盯着 Broker 的 sendfile;另开一个终端从头消费
sudo strace -f -y -e trace=sendfile -p <Broker 进程号>
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 \
--topic order-demo --from-beginning --max-messages 20000 > /dev/null
RES PAGES SIZE FILE 1M 256 1023.1K order-demo-0/00000000000000000000.log 1M 256 1023.1K order-demo-0/00000000000000004672.log … 288K 72 287.3K order-demo-0/00000000000000018688.log sendfile(243<socket:[10561219]>, 247<…/order-demo-0/00000000000000004672.log>, [12558] => [264460], 1035138) = 251902 sendfile(243<socket:[10561219]>, 247<…/order-demo-0/00000000000000004672.log>, [264460] => [497755], 783236) = 233295 …
RES 等于 SIZE:刚写进去的数据全在页缓存里,这时候消费不用读盘。
sendfile 的参数是:socket、段文件、从文件第几个字节开始、最多发多少。本机从头读完 2 万条(4.4 MB)一共 25 次 sendfile;同样的读法换成 TLS 端口,0 次。本机是启动 Broker 时套上 strace 抓的,加了 -y 显示文件名。
import java.io.*;
import java.lang.management.ManagementFactory;
import java.net.*;
import java.nio.channels.*;
import java.nio.file.*;
public class ZeroCopyDemo {
public static void main(String[] args) throws Exception {
Path file = Path.of("big.bin");
if (Files.notExists(file)) { // 准备一个 1 GiB 的文件
try (OutputStream out = Files.newOutputStream(file)) {
byte[] mb = new byte[1 << 20];
for (int i = 0; i < 1024; i++) out.write(mb);
}
}
for (int round = 1; round <= 3; round++) {
send("read+write", file, false);
send("transferTo", file, true);
}
}
static void send(String name, Path file, boolean zeroCopy) throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
Thread sink = new Thread(() -> { // 接收端:读到就扔
try (Socket s = server.accept(); InputStream in = s.getInputStream()) {
byte[] b = new byte[64 * 1024];
while (in.read(b) != -1) { }
} catch (IOException e) { throw new UncheckedIOException(e); }
});
sink.start();
var mx = ManagementFactory.getThreadMXBean();
long cpu0 = mx.getCurrentThreadCpuTime(), t0 = System.nanoTime();
if (zeroCopy) { // 零拷贝:FileChannel.transferTo → sendfile
try (FileChannel ch = FileChannel.open(file);
SocketChannel sock = SocketChannel.open(new InetSocketAddress("localhost", server.getLocalPort()))) {
long size = ch.size();
for (long pos = 0; pos < size; ) pos += ch.transferTo(pos, size - pos, sock);
}
} else { // 老办法:读进 byte[],再写给 socket
try (InputStream in = Files.newInputStream(file);
Socket sock = new Socket("localhost", server.getLocalPort());
OutputStream out = sock.getOutputStream()) {
byte[] buf = new byte[64 * 1024];
for (int n; (n = in.read(buf)) != -1; ) out.write(buf, 0, n);
}
}
sink.join();
System.out.printf("%s 发送线程 CPU %4d ms 总耗时 %4d ms%n", name,
(mx.getCurrentThreadCpuTime() - cpu0) / 1_000_000, (System.nanoTime() - t0) / 1_000_000);
}
}
}
read+write 发送线程 CPU 552 ms 总耗时 1235 ms ← 第一轮有 JIT 预热 transferTo 发送线程 CPU 57 ms 总耗时 484 ms read+write 发送线程 CPU 415 ms 总耗时 717 ms transferTo 发送线程 CPU 57 ms 总耗时 299 ms read+write 发送线程 CPU 421 ms 总耗时 590 ms transferTo 发送线程 CPU 60 ms 总耗时 310 ms
本机 JDK 21、2 核。发送 1 GiB,发送线程的 CPU 从约 420 ms 降到约 58 ms,少了 7 倍左右。差在拷贝:老办法要把数据从页缓存拷进 byte[],再拷回 socket 缓冲区;transferTo 在 Linux 上就是 sendfile,数据不出内核。Kafka 明文连接发数据走的就是这个方法。
kafka-dump-log.sh、fincore、strace 现场验证,还能用 Java 演示零拷贝省了什么。面试和日常排查够用了。⑥细节 · 规范 · 优化
按圈往里走:第一圈是不知道就出事的坑,第二圈是团队里该怎么配,第三圈是零拷贝和页缓存的细账、索引的源码细节和版本差异。带 实测 的条目都在本机跑过。
注意事项:不知道就出事
- 现象
- 流量小的 topic,老数据迟迟不删,磁盘一直涨。
- 原因
- 删除按段来,要等段里最新一条超过
retention.ms才删整段。正在写的段要满 7 天(segment.ms)或写满 1 GiB 才滚出来。一段攒了 7 天,再等 7 天才删,最老的消息最多活 14 天。另外每 5 分钟才检查一次(log.retention.check.interval.ms),删前先改名,1 分钟后才真删(file.delete.delay.ms)。 - 实测
retention.ms=60000,每 10 秒写一条:4 分多钟后第 1 条还在,停写 1 分多钟后才整段删掉。再加segment.ms=20000,只留最近 70 秒左右。- 做法
- 流量小的 topic 设
segment.ms(比如 1 天);磁盘按“保留时长 + 一个段”预留;合规上“到点必须删”的数据别指望 retention。
- 现象
- 回放开始后磁盘读 IO 飙升,实时组的 lag 上涨,生产延迟也可能跟着变高。
- 原因
- 实时组读刚写的数据,在页缓存里;回放读几天前的段,只能读盘,读进来的旧数据还会占页缓存,把新数据挤出去。
- 实测
- 写入 963 MB 后把页缓存清掉再从头读:Broker 从磁盘读了 963 MB(
/proc/<pid>/io的read_bytes),73 MB/s;马上再读一遍:磁盘读 0 MB,152 MB/s。 - 做法
- 给回放的客户端配限速配额(
consumer_byte_rate),错开高峰;大规模回放从 follower 读(replica.selector.class+client.rack)或上分层存储。实时组的落后时间要远小于下面算出的“热窗口”。
- 现象
- 32 GB 的机器给 Broker 开了 24 GB 堆,结果磁盘读变多,消费反而慢了。
- 原因
- 消息不缓存在堆里。堆占走的内存,页缓存就用不上;索引的 mmap、发送的 sendfile 也都靠页缓存。
- 做法
- 堆按官方文档的参考配置给 6 GB 左右(
-Xmx6g -Xms6g,G1),剩下的内存留给页缓存。用下面的页缓存实验台估一下够不够。
- 现象
- 读流量不变,开 TLS 后 Broker CPU 明显上涨,吞吐下降。
- 原因
- 加密只能在用户态做。Kafka 的
SslTransportLayer先把数据从页缓存读进 JVM 的直接缓冲区,用SSLEngine加密,再写给 socket,用不上 sendfile。官方文档也写明 SSL 时不用 sendfile。 - 实测
- 热读同一份 963 MB:明文 Broker CPU 约 1.0 秒,TLS 约 5.9 秒;消费速度 152 MB/s 降到 86 MB/s。strace 计数:明文 25 次 sendfile,TLS 0 次。
- 做法
- 容量规划把加密的 CPU 算进去;不同 listener 可以配不同协议,内网可信链路走明文、对外的走 TLS;CPU 选带 AES 指令的。
- 现象
- 吞吐掉一大截,延迟抖动。
- 原因
flush.messages=1让每次追加后都 fsync。fsync 会阻塞写,操作系统也没法再合并、排序写入。- 实测
- 10 万条 1 KB:默认 16.6 MB/s,
flush.messages=1只有 4.5 MB/s。默认配置下生产 2 万条,.log只在滚段时 fsync 了 4 次(每段一次),正在写的段一次没刷。 - 做法
- 保持默认,不主动 fsync。不丢消息靠 3 副本 +
acks=all+min.insync.replicas=2(上一页),官方文档也推荐默认刷盘设置。
- 现象
- 报
Too many open files或Map failed;异常关机后要恢复很久。 - 原因
- 每段有
.log、.index、.timeindex三个文件,索引用 mmap。官方文档按每段 2 个内存映射算,会撞上vm.max_map_count(很多发行版默认 65530 左右)。异常关机后,没确认刷过盘的段要逐批校验 CRC、重建索引。 - 做法
- 文件句柄至少 100000(文档建议);调大
vm.max_map_count;控制每个 Broker 的分区数;num.recovery.threads.per.data.dir按磁盘数调大(4.x 默认 2,3.x 默认 1)。
- 现象
- 迁移、回放数据时保留了原始时间戳,写进去没多久就没了;或者时间戳在未来,直接写不进去。
- 原因
- 按时间删看的是消息里的时间戳,默认是生产者填的 CreateTime。8 天前的时间戳,段的最新时间就是 8 天前,下次检查就删。4.x 默认拒收比 Broker 时钟晚 1 小时以上的消息(
message.timestamp.after.max.ms),3.x 默认不限。 - 实测
- 时间戳是 8 天前的一条写成功(offset 0),25 秒后最早 offset 变成 1;时间戳是 2 小时后的一条报
InvalidTimestampException。 - 做法
- 迁移、回放时用当前时间,原始时间放进消息体或 header;或者 topic 设
message.timestamp.type=LogAppendTime,用 Broker 写入的时间。
- 现象
- 太小:文件数爆炸,句柄和映射跟着涨。太大:删除粒度粗,磁盘回收慢。
- 实测
- 4.x 设 100 KB 直接报错,最小 1 MiB;3.x 最小只要 14 字节,所以老集群上能看到很小的段。
- 做法
- 默认 1 GiB 大多合适。想让小 topic 删得及时,调
segment.ms,别把segment.bytes调得很小。
使用规范:团队里怎么配
# 普通业务 topic:段大小用默认,保留时间按业务定
retention.ms=604800000 # 7 天
segment.bytes=1073741824 # 1 GiB(默认)
# 每天写不满 1 GiB 的小 topic,再加:
segment.ms=86400000 # 每天滚一段,删除最多晚 1 天
# 保持默认,不要改:
# flush.messages / flush.ms 不主动 fsync,靠副本
# index.interval.bytes=4096 索引大小和扫描长度的平衡点
# compression.type=producer 原样存生产者压好的批
写入速率 × 保留时长 × 副本数,再加余量。比如订单写入 5 MB/s、保留 7 天、3 副本:
5 MB/s × 604800 s ≈ 2.9 TiB # 一份
2.9 TiB × 3 副本 ≈ 8.7 TiB # 整个集群
再留两块余量:每个分区最多多留一个段(坑 1),磁盘水位别超过 70%~80%。
堆 6 GB 左右;页缓存至少装得下“写入速率 × 30 秒”,这是官方文档给的估算法。实时消费者落后得越多,要的越多。
不和应用日志、系统盘混用。文件系统用 XFS 或 ext4,官方文档的对比测试里 XFS 更好。
ulimit -n 至少 100000;调大 vm.max_map_count;常见做法是关掉 swap 或把 vm.swappiness 调到很低。
回放用单独的 client id 配限速;大回放走 follower 读或分层存储;回放前看一眼页缓存够不够。
分区磁盘占用(kafka-log-dirs.sh --describe);磁盘读 IO(iostat 的 rMB/s,以实时消费为主时应接近 0);消费 lag 换算成时间后和热窗口比。
不改刷盘参数、不调小 index.interval.bytes、不把 segment.bytes 调到几 MB,除非有压测数据撑着。
优化和深处:拷贝细账、页缓存、索引源码
零拷贝实验台:一次发送,数据走了几趟
Java 和 Kafka 源码里对应什么
FileChannel.transferTo在 Linux 上走 sendfile;FileChannel.map就是 mmap,返回MappedByteBuffer。- Kafka 4.1.2 的字节码里:
PlaintextTransportLayer.transferFrom调的是FileChannel.transferTo;SslTransportLayer.transferFrom先ByteBuffer.allocateDirect,把文件读进来再加密。 - 老办法在 Java 里比教科书还多拷一次:堆里的
byte[]不能直接交给系统调用,JDK 先读进一块本地内存,再拷进数组。⑤ 里 read+write 多花的 CPU 有一部分在这。
零拷贝只在“读”这一侧
- 生产者发来的数据要进 JVM:校验 CRC、分配 offset,然后
write()进页缓存。这一侧没有零拷贝,靠的是批量和顺序写。 - 批量:一批(RecordBatch)共用一个批头,⑤ 里 4 条 200 字节的消息一批 897 字节。topic 的
compression.type=producer(默认)时,Broker 原样存生产者压好的批,消费者拿到的也是同一份压缩数据。 - Netty 说的“零拷贝”是另一回事:
CompositeByteBuf、slice是在 JVM 里少拷贝;它的DefaultFileRegion才是走 transferTo。
页缓存实验台:最近多久的数据还在内存里
库存组固定落后 2 秒,代表一直跟得上的实时组。
粗算:页缓存 ≈ 内存 − 堆 − 2 GB;按最简单的“最近用过的留下”来估。只有写入时,热窗口 = 页缓存 ÷ 写入速率;有回放时,回放读进来的旧数据也要占地方,热窗口 = 页缓存 ÷(写入 + 回放)。Linux 实际用两条链表管理页缓存,结果会比这个好一些,方向一样。
索引的源码细节
- 条目格式:
.index每条 8 字节 = 相对 offset 4 字节 + 文件位置 4 字节;.timeindex每条 12 字节 = 时间戳 8 字节 + 相对 offset 4 字节。存的是减掉段起始 offset 的相对值,4 字节就够。 - 预分配 + mmap:活跃段的索引一创建就是 10 MiB(
segment.index.bytes),用MappedByteBuffer映射进内存;滚段时截到实际大小(⑤ 实测 1864 字节)。索引写满(10 MiB ÷ 8 字节,约 131 万条)也会触发滚段。 - 热区二分:多数查找是追尾读,落在索引最后面。
AbstractIndex先在最后 8 KB(offset 索引就是 1024 条)里二分,这一块总被访问,一直在内存里,不会缺页。4.1.2 字节码里能看到常量 8192。 - 旧段的索引用到时才打开映射(源码里的
LazyIndex),所以平时占的映射比“每段 2 个”少,回放老数据时才会打开。
段、文件和刷盘
- 所有段按起始 offset 放在一个
ConcurrentNavigableMap(跳表)里,选段就是一次 floor 查找。 - 其他文件:
.snapshot是生产者状态,滚段时写;.txnindex有被中止的事务才出现;.deleted是删除前的改名。 - 刷盘:默认
log.flush.interval.messages是 Long 最大值,不主动 fsync;滚段时把旧段 fsync 一次(⑤ 生产 2 万条,4 次滚段 4 次 fsync)。 - 异常关机:
recovery-point-offset-checkpoint记着刷到哪了,之后的段要逐批校验 CRC、重建索引。 - 敢不刷盘的前提是副本:一台机器掉电丢的页缓存数据,别的副本上还有。
版本差异
segment.bytes最小 14 字节,能看到很小的段num.recovery.threads.per.data.dir默认 1,恢复慢- 不限未来时间戳(
message.timestamp.after.max.ms默认 Long 最大值) - 还要给很老的客户端转换消息格式,转换的那部分在 JVM 里做,没有零拷贝
segment.bytes最小 1 MiB(实测)- 恢复线程默认 2
- 默认拒收晚 1 小时以上的时间戳(实测)
- Fetch 请求最低 v4,老消息格式的转换没有了
- 只有 KRaft,元数据也是一份日志(
__cluster_metadata)
- 段 1 GiB、索引每 4 KB 一条、索引预分配 10 MiB
- 默认不主动 fsync
- 明文连接用 sendfile,TLS 不用
- 按段删除,看段里最新的时间戳
面试官会怎么问
Kafka 为什么这么快?
- 写:只往段文件末尾顺序追加;按批写,一批共用一个批头,压缩后原样存;只
write()进页缓存,不等 fsync。 - 读:稀疏索引二分 + 往后扫几 KB 就能定位;追尾读命中页缓存;明文连接用 sendfile,数据不进 JVM。
- 并行:一个 topic 分成多个分区,分散到多块盘、多台机器。
稀疏索引怎么查一条消息?为什么不每条都建索引?
跳表里 floor 找到段 → 在 .index 里二分,找不超过目标的最大一条 → 从它记的位置往后扫批头,直到某批的 lastOffset ≥ 目标 → 整批返回。稀疏是为了让索引小到能常驻内存:1 GiB 的段只有约 26 万条、2 MiB。代价是每次最多多扫约 4 KB。
零拷贝具体少了什么?
read + write:2 次 DMA、2 次 CPU 拷贝、4 次用户态和内核切换。sendfile(网卡支持 gather):2 次 DMA、0 次 CPU 拷贝、2 次切换。数据已在页缓存时,读盘那次 DMA 也省了。开 TLS 就退回到用户态加密,CPU 拷贝和加密都回来了。
Kafka 默认不刷盘,不怕丢消息吗?
单机掉电会丢页缓存里还没刷的数据。Kafka 靠副本:acks=all + min.insync.replicas=2 时,一条消息至少在两台机器的页缓存里,同时掉电才会丢。官方文档也认为副本比本机 fsync 更可靠,推荐默认刷盘设置。
为什么不把消息缓存在 JVM 堆里?
GC 停顿;进程重启缓存清空;和页缓存重复存两份;Java 对象还有额外开销。页缓存自动用掉所有空闲内存,Broker 重启后还在。
Kafka 和 RocketMQ 的存储有什么不同?
Kafka 每个分区一串文件,分区多了写入点多,磁盘上接近随机写。RocketMQ 所有消息写一个 CommitLog,再给每个队列建 20 字节一条的 ConsumeQueue,写入点只有一个,但读一条要多查一次。发送上,Kafka 用 sendfile,RocketMQ 用 mmap。
retention 为什么不准?
按段删,要段里最新一条过期;活跃段要先滚出来(满 1 GiB 或 7 天);每 5 分钟检查一次;删除前改名再等 1 分钟。还有时间戳:按消息里的时间戳算,生产者填错了也会删早或删晚。
下一圈
- 日志压缩(compact):按 key 只留最新一条,
__consumer_offsets就靠它。 - 分层存储(KIP-405):老段搬到对象存储,Broker 只留热数据,回放不再挤页缓存。
- 事务和 read_committed:
.txnindex、LSO,被中止的事务怎么被跳过。 - 副本同步和截断:follower 怎么按 Leader Epoch 截断日志,接上一页的 HW。
- Linux IO 调优:XFS 挂载参数、
dirty_ratio、预读、IO 调度器。 - RocketMQ 的 CommitLog:mmap、预热和刷盘策略,和 Kafka 对着看。
↺回到第⑤步,再做一遍
每个练习都在 ⑤ 的单节点和 order-demo 上做,用一条第⑥步的内容。
练习 1 · 让 retention 准一点
建一个 retention.ms=60000 的 topic,每 10 秒写一条,用 kafka-get-offsets.sh --time -2 看最早 offset 动没动。再建一个加了 segment.ms=20000 的对比。(检查间隔默认 5 分钟,可以在 Broker 上把 log.retention.check.interval.ms 调小。)
练习 2 · 做一次冷读
用 fincore 看 ⑤ 的 .log 在不在页缓存。用 Python 的 os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED) 把它们踢出去,再从头消费,对比 Broker /proc/<pid>/io 里 read_bytes 涨了多少。
练习 3 · 按时间回放
写一个 Java 消费者,用 offsetsForTimes 从“1 分钟前”开始读 order-demo。再故意用 8 天前的时间戳发一条,看它多久被删。
自测
1. 用一句话说出 Kafka 存储的精华。
只往段文件末尾追加,用稀疏索引定位,缓存交给页缓存,发送用 sendfile,JVM 基本不碰消息本身。
2. 自己在 JVM 里缓存消息,疼在哪?
GC 停顿、重启后缓存全空、和页缓存重复存两份、发给消费者要在内核和 JVM 之间多拷两次。
3. 要从 1 小时前开始重新消费,Java 里怎么写?
consumer.offsetsForTimes(Map.of(tp, 一小时前的毫秒时间戳)) 拿到 OffsetAndTimestamp,不为 null 就 consumer.seek(tp, hit.offset())。Broker 先查 .timeindex 再查 .index。
4. 把 index.interval.bytes 从 4 KB 调到 64 KB,会怎样?
索引条数变成 1/16,更省内存;但每次查找最多要往后多扫约 64 KB。④ 的查找实验台切到 16 KB 就能看到扫的批数变多。
5. retention.ms 是 7 天,为什么磁盘上有 12 天前的数据?(头号坑)
删除按段来,要等段里最新一条超过 7 天。流量小的 topic,一段要满 7 天才滚出来,再过 7 天才删,最老的数据最多留约 14 天。设 segment.ms 可以缩短。
6. 开 TLS 以后 Broker CPU 为什么会涨?
加密只能在用户态做,数据要先读进 JVM 的缓冲区加密再写出去,用不上 sendfile。本机实测同样读 963 MB,Broker CPU 从约 1 秒涨到约 5.9 秒。