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 读写为什么快。

面试官 Kafka 为什么 这么快? Broker 磁盘 · order-created-0/ 12 天前 正在写 超过 7 天了,还没删 一段一个文件,按时间从老到新 订单服务 一直在写 报表组 report 从 7 天前开始重放 库存组 inventory 实时读,一重放就变慢 1 2 3
三件事落在同一个地方:消息在 Broker 磁盘上怎么存、怎么删、怎么被读走。
1讲清 Kafka 为什么快

面试必问。标准答案是一串词:顺序写、页缓存、零拷贝、批量、分区。本页把每个词背后的机制画出来。

④ 原理
2保留时间和磁盘对得上

retention 设了 7 天,磁盘上却有 12 天前的段;磁盘告警了,等它自己删又删不掉。

⑥ 坑 1
3回放历史,不拖慢实时

报表组从 7 天前读起,库存组的延迟跟着涨。要知道为什么,以及怎么避开。

⑥ 页缓存

②老办法:自己设计会怎样

存储是 Kafka 里的一个机制,没有现成的“老技术”可替换。那就假设没有 Kafka,用手上会的东西存消息、发消息。最先想到的四种做法都能跑,但各疼一处,Kafka 的设计正好是冲着这几处去的。

存进 MySQL 表:一条消息一行 1 2 3 4 5 1→5 是写入顺序:写到哪一页由索引决定,磁盘到处跳 虚线格是 delete 留下的空洞;每次写还要记 redo log、binlog 只追加的文件:永远写在末尾 过期整段删 新消息只追加到这里 磁盘只在一个地方写,机械盘也很快 删除不碰数据,直接删掉最老的段文件
左边是 Java 后端最熟的存法,右边是 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:每个分区一串文件 P0 P1 P2 P3 ▶ = 写入点:4 个分区就有 4 个 分区到了几千个,磁盘要在几千个文件之间来回写 找一条:只看本分区的文件和索引 RocketMQ:所有消息写一个 CommitLog 唯一 不同颜色 = 不同 topic 的消息,混在一起顺序写 ConsumeQueue(每条 20 字节) 队列 A:只存“在 CommitLog 哪、多长” 队列 B 写入点只有一个,队列再多也是顺序写 找一条:先查 ConsumeQueue,再回 CommitLog 取
两边都是只追加。区别在分不分文件:Kafka 每个分区一串文件,RocketMQ 把所有消息混写进一个大日志,再给每个队列建一份很小的索引。
KafkaRocketMQRabbitMQ 经典队列Redis Stream
写入每个分区一串段文件,顺序追加所有 topic 顺序写进一个 CommitLog按队列存,消息确认后就删内存里的基数树,靠 RDB / AOF 落盘
找一条选段 → 二分稀疏索引 → 扫几 KB查 ConsumeQueue 拿到位置,再去 CommitLog 读只从队头取,没有按位置查按消息 ID 在树里查
删除按段删:超过保留时间或大小按文件过期删消费者确认(ack)后删XTRIM / MAXLEN 裁剪
发给消费者sendfile 零拷贝(明文连接时)mmap 映射文件后读写没有零拷贝从内存直接回复
分区 / 队列很多时写入点变多,几千个以上要小心写入点只有一个队列多时内存压力大单个 Stream 只在一个节点上
选择规则
  1. 要回放历史、多个组各读一份、量大:选只追加日志(Kafka、RocketMQ,或 RabbitMQ 3.9 起的 Stream 类型)。
  2. 单个 Broker 上分区或队列特别多(几千到上万):RocketMQ 的单 CommitLog 更稳;用 Kafka 就要控制分区数。
  3. 量不大、已经有 Redis、数据能全放内存:Redis Stream 就够。

本页学 Kafka 的存储:段文件、稀疏索引、页缓存、sendfile。把这四样连起来,就能回答“Kafka 为什么快”。

④原理:一串段文件、一本稀疏目录、交给内核搬

打个比方:一本只往后写的流水账,写满一本换新的(段);每本前面有一张“每隔几页记一次页码”的目录(稀疏索引);账本摊在操作系统的桌上(页缓存),有人来借,管理员直接把那几页递出去,不经过你的手(零拷贝)。比喻就到这里,下面看真东西。

一个分区 = 一个目录 = 一串段(segment) 00000000000000000000 第 1 段 · 写满了 00000000000000004672 第 2 段 · 写满了 00000000000000018688 第 5 段 · 活跃段,正在写 … 新的一批追加到这 .log:消息本体,一批(batch)一批追加 23→4485 · 43→8970 · … .index:offset → 文件位置,8 字节一条,每 4 KB 一条 .timeindex:时间戳 → offset,12 字节一条 4695→4485 · 4715→8970 · … 写满后截短:1864 字节 = 233 条 18711→4485 · 18731→8970 … 一创建就预分配 10 MiB 滚到下一段时再截短
文件名和数字都来自第⑤步的实测:段最大 1 MiB 时,每段装 4672 条。.log 上的小三角是被索引记下的位置,大约每 5 批(4 KB 多一点)一个。
用户态 内核 生产者 produce 请求 Broker(JVM) 写:校验、分配 offset 读:只下命令,数据不进来 消费者 fetch 请求 页缓存(内核内存) 刚写的、刚读过的都在这 网卡 发给消费者 磁盘:段文件 ① 发 ② write(),进了页缓存就返回 ③ 操作系统后台刷盘 没命中才读盘 ④ fetch ⑤ 调 sendfile ⑥ 直接送
蓝线是写,棕线是读。写入只到页缓存为止,读取的数据从页缓存直接到网卡,两条路都不在 JVM 里存消息。

① 分段

一个分区是一串段文件,文件名是这段第一条消息的 offset(20 位补零)。写满 1 GiB(segment.bytes)或满 7 天(segment.ms)就换新段,只有最后一段在写。

按段删除 → ⑥ 坑 1

② 稀疏索引

.index 每攒够 4 KB 数据(index.interval.bytes)记一条“offset → 文件位置”。找一条消息:选段 → 二分索引 → 从记下的位置往后扫。按时间找先查 .timeindex。

索引格式、mmap → ⑥ 第三圈

③ 页缓存

写入只是 write() 进页缓存就返回,默认不 fsync,刷盘交给操作系统。消费者读刚写的数据,基本都直接从页缓存拿。

回放拖慢实时 → ⑥ 坑 2

④ 零拷贝

明文连接下,Broker 对 .log 里的一段调用 sendfile,内核把页缓存里的数据直接交给网卡,不拷进 JVM。

开 TLS 就没了 → ⑥ 坑 4

动手:按 offset 找一条消息

索引间隔 index.interval.bytes

为了画得下,这个分区每段只有 32 KB。查找规则和 Kafka 一样:攒够 index.interval.bytes 才记一条索引,索引记的是“某一批的最后一个 offset → 这一批在文件里的起始位置”(第⑤步实测的索引就是这样)。

命令行建 topic 时定存储参数,翻开段文件看
# 建 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
Java按 offset 跳、按时间跳
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建一个段很小的 topic,灌 2 万条命令都在 Kafka 解压目录下执行;Docker 版在前面加 docker exec -it kafka /opt/kafka/
# 段大小设成允许的最小值 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 字节。

2找到分区目录,看看里面有什么
# 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。

3翻开第 2 段的索引和日志,手算 offset 4730 在哪
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 在这一批
  1. 选段:4672 ≤ 4730 < 9344,在 00000000000000004672 这一段。
  2. 查索引:不超过 4730 的最大一条是 4715 → 8970。注意它记的是 4712–4715 这一批的最后一个 offset 和这一批的起始位置。
  3. 扫日志:从第 8970 字节往后,一批一批看 lastOffset,跳过 4 批,第 5 批 4728–4731 包含 4730,它在第 12558 字节。
  4. 验证: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], …),正好从这一批开始发。
4看页缓存和 sendfilefincore 在 util-linux 里,strace 要 root 权限;Docker 镜像里一般没有这两个命令,这一步用解压版做
# 段文件有多少在页缓存里(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 显示文件名。

5Java 里摸一下零拷贝:同一个 1 GiB 文件发给 socket,两种写法比 CPUZeroCopyDemo.java,JDK 11 以上直接 java ZeroCopyDemo.java
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 明文连接发数据走的就是这个方法。

到这里已经能交差了:能画出一个分区的段文件和索引,能说清“找一条消息 = 选段 + 二分索引 + 往后扫几 KB”,能用 kafka-dump-log.sh、fincore、strace 现场验证,还能用 Java 演示零拷贝省了什么。面试和日常排查够用了。
已知还没处理的细节(都在第⑥步)

⑥细节 · 规范 · 优化

按圈往里走:第一圈是不知道就出事的坑,第二圈是团队里该怎么配,第三圈是零拷贝和页缓存的细账、索引的源码细节和版本差异。带 实测 的条目都在本机跑过。

第一圈

注意事项:不知道就出事

坑 1retention 设了 7 天,磁盘上有 12 天前的数据 实测
现象
流量小的 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。
坑 2报表组一回放,实时消费跟着变慢 实测
现象
回放开始后磁盘读 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)或上分层存储。实时组的落后时间要远小于下面算出的“热窗口”。
坑 3Broker 的堆开得越大越好?
现象
32 GB 的机器给 Broker 开了 24 GB 堆,结果磁盘读变多,消费反而慢了。
原因
消息不缓存在堆里。堆占走的内存,页缓存就用不上;索引的 mmap、发送的 sendfile 也都靠页缓存。
做法
堆按官方文档的参考配置给 6 GB 左右(-Xmx6g -Xms6g,G1),剩下的内存留给页缓存。用下面的页缓存实验台估一下够不够。
坑 4开了 TLS,Broker CPU 涨了好几倍 实测
现象
读流量不变,开 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 指令的。
坑 5为了不丢消息,设成每条都刷盘 实测
现象
吞吐掉一大截,延迟抖动。
原因
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(上一页),官方文档也推荐默认刷盘设置。
坑 6分区太多,Broker 起不来或重启很慢
现象
报 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)。
坑 7带着旧时间戳写进来,转眼就被删了 实测
现象
迁移、回放数据时保留了原始时间戳,写进去没多久就没了;或者时间戳在未来,直接写不进去。
原因
按时间删看的是消息里的时间戳,默认是生产者填的 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 写入的时间。
坑 8segment.bytes 调得太小或太大 实测
现象
太小:文件数爆炸,句柄和映射跟着涨。太大:删除粒度粗,磁盘回收慢。
实测
4.x 设 100 KB 直接报错,最小 1 MiB;3.x 最小只要 14 字节,所以老集群上能看到很小的段。
做法
默认 1 GiB 大多合适。想让小 topic 删得及时,调 segment.ms,别把 segment.bytes 调得很小。
第二圈

使用规范:团队里怎么配

topic存储相关配置模板
# 普通业务 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、重建索引。
    • 敢不刷盘的前提是副本:一台机器掉电丢的页缓存数据,别的副本上还有。

    版本差异

    2.x / 3.x老集群上常见
    • segment.bytes 最小 14 字节,能看到很小的段
    • num.recovery.threads.per.data.dir 默认 1,恢复慢
    • 不限未来时间戳(message.timestamp.after.max.ms 默认 Long 最大值)
    • 还要给很老的客户端转换消息格式,转换的那部分在 JVM 里做,没有零拷贝
    4.0+Broker 要 JDK 17
    • 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 调小。)

    用到:⑥ 坑 1

    练习 2 · 做一次冷读

    用 fincore 看 ⑤ 的 .log 在不在页缓存。用 Python 的 os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED) 把它们踢出去,再从头消费,对比 Broker /proc/<pid>/io 里 read_bytes 涨了多少。

    用到:⑥ 坑 2、页缓存实验台

    练习 3 · 按时间回放

    写一个 Java 消费者,用 offsetsForTimes 从“1 分钟前”开始读 order-demo。再故意用 8 天前的时间戳发一条,看它多久被删。

    用到:④ 最小语法、⑥ 坑 7

    自测

    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 秒。