跳到正文

日志存储与读写路径 ​

一句话:每个分区是一条只追加的日志,写入先落 PageCache,消费和副本拉取再由 sendfile 绕过用户态直接发到网卡。

适用版本:Kafka 3.9 / 4.0。分层存储 KIP-405 在 3.9 GA,4.0 延续。

为什么需要 ​

如果每条消息都随机写、每条都 fsync,磁盘和 CPU 都扛不住高吞吐。Kafka 的做法是把写入变成「顺序追加 + 交给操作系统页缓存」,把读取变成「优先命中页缓存,未命中再读盘,并用零拷贝发走」。这套设计换来高吞吐,也带来两个边界:PageCache 不是持久化,retention 只能按整段删除。

核心机制 ​

目录与 Segment ​

一个分区在磁盘上是一个目录,里面按 base offset 命名多个 Segment:

text
orders-0/
├── 00000000000000000000.log        # 消息本体
├── 00000000000000000000.index      # offset → 文件位置,稀疏索引
├── 00000000000000000000.timeindex  # timestamp → offset
├── 00000000000000002048.log        # 下一个 Segment,从 offset 2048 开始
└── leader-epoch-checkpoint

只有最后一个 Segment 是活跃的,所有写入都追加到它末尾。滚动条件:达到 log.segment.bytes(默认 1073741824,1 GiB)、达到 log.roll.ms/log.roll.hours(默认 168h),或索引写满。

稀疏索引 ​

Kafka 不会为每条消息建索引。每写入约 log.index.interval.bytes(默认 4096)字节,才在 .index 里记一条「offset → 物理位置」。

查找 offset N 时:先二分 .index,找到不大于 N 的最近一条索引;再从该物理位置顺序扫描 .log,直到命中 N。所以索引很小、能常驻内存,代价是定位后要扫一小段。.timeindex 用于按时间查找,比如按时间重置位点或 kafka-get-offsets.sh --time。

写路径:追加 + PageCache ​

Producer → Leader 追加到活跃 Segment → 写入 PageCache → 由 OS 异步刷盘

Kafka 不在每条消息后 fsync。log.flush.interval.messages 和 log.flush.interval.ms 默认都不强制按条刷(前者 Long.MAX_VALUE,后者 null),持久化主要依赖 OS 脏页回写、副本复制,以及 Segment 滚动/关闭/恢复时的 flush。

这也解释了「Kafka 写入很快」:它把随机写变成了顺序追加,把同步刷盘变成了批量异步。

读路径与零拷贝 ​

Consumer fetch 时,Broker 从 Leader 的 Segment 读取,通过 FileChannel.transferTo()(底层 sendfile)把 PageCache 里的数据直接送到 Socket,不经过用户态缓冲区。

传统 read + write 是 4 次拷贝、4 次上下文切换;sendfile 降为 2 次 DMA 拷贝、2 次上下文切换,CPU 不参与数据搬运。

零拷贝成立有条件:走的是消费 fetch 或 Follower 副本 fetch 路径(Producer 写入不走);没有 TLS/SSL(加密需要用户态参与);Broker 不需要解压再压缩(消费者使用的压缩格式与消息批次一致时可以整批转发)。

retention:按整段删除 ​

默认 log.cleanup.policy=delete。清理线程按 log.retention.check.interval.ms(默认 300000)检查,满足任一条件就删除整个 Segment:

  • 最旧消息超过 log.retention.hours(默认 168,7 天);
  • 分区总大小超过 log.retention.bytes(默认 -1,不限制)。

注意 log.retention.bytes 默认是 -1,不是 1GB;1GB 是 log.segment.bytes。删除以 Segment 为单位,活跃 Segment 永远不删,所以实际保留时间/大小会超过配置,最多多出一个 Segment。

compaction:按 key 保留最新值 ​

log.cleanup.policy=compact 时,Cleaner 线程按 key 合并,只保留每个 key 的最新 value。适合 CDC、配置、状态快照这类「关心当前值」的流。

  • min.cleanable.dirty.ratio=0.5:脏数据占比超过 50% 才触发清理。
  • min.compaction.lag.ms=0:消息至少多久后才可以被压缩。
  • max.compaction.lag.ms=Long.MAX_VALUE:消息最长多久未被压缩就必须进入清理,防止冷 key 永远不清理。
  • delete.retention.ms=86400000:墓碑(value 为 null 的删除标记)保留 24h,保证消费者能看到删除。

compaction 不保证 offset 连续,也不保证立刻回收空间;它需要额外的 Cleaner 线程和磁盘余量。

分层存储 ​

KIP-405 把旧 Segment 上传到远端(S3/GCS 等),本地只留热数据。3.9 GA,4.0 延续。开启后读取旧数据会走远端,延迟和成本模型都变了;具体开关和版本行为以官方文档为准。

实战场景 ​

一、上了 TLS,Broker CPU 翻三倍 ​

现象:为通过安全审计,把 Producer/Consumer 到 Broker 的链路全部改成 SSL。功能一切正常,但 Broker CPU 从 30% 涨到 85%,同样的流量吞吐掉了近一半,RequestMetrics 里 LocalTimeMs 基本没变、TotalTimeMs 涨了一截。

排查:零拷贝的成立条件被破坏了。

定位层取证据判定
是否走 sendfile消费 fetch 路径的 CPU profile 是否出现 SSLEngine.wrap、用户态 ByteBuffer 复制出现即没有零拷贝
TLS 终止位置客户端到 Broker 是否直接 TLSTLS 需要用户态参与,transferTo 失效
压缩格式一致性消息批次压缩格式与 compression.type(Broker 是否需解压再压缩)不一致会整批解压重压,CPU 双重放大
拷贝次数对比改造前后的 ProcessCpuTime 与网络字节比每 GB 流量的 CPU 秒显著上升即确认
磁盘与 PageCacheiostat、vmstat 的 cache 命中率排除 IO 瓶颈

传统 read + write 是 4 次拷贝、4 次上下文切换,sendfile 降到 2 次 DMA、2 次切换;TLS 之后数据必须回到用户态加密,这个收益直接消失。

对症做法:

text
1. 分区处理:把 TLS 放在边缘,集群内部走明文 + 网络隔离。
   - 客户端 → 网关/Sidecar:TLS
   - 网关 → Broker:VPC 内网明文 + 安全组白名单 + 只读账号/ACL
   这既满足传输加密要求,又保住 Broker 的 sendfile 路径。
2. 必须端到端 TLS 时按“CPU 换带宽”重估容量:压测得到每 GB 流量的 CPU 成本,
   按该值扩 Broker 或限制高吞吐消费者数量,而不是沿用旧容量结论。
3. 压缩链路对齐:Producer 用 lz4,Broker 配 compression.type=lz4(或 offline),
   避免“解压再压缩”;消费者侧不解压就整批转发是吞吐关键。
properties
# Broker:保持 Broker 侧不重压
compression.type=lz4
# 监听器分层:INTERNAL 明文(仅内网网卡),EXTERNAL TLS(仅面向客户端)
listeners=INTERNAL://0.0.0.0:9092,EXTERNAL://0.0.0.0:9093
advertised.listeners=INTERNAL://10.0.0.11:9092,EXTERNAL://kafka.example.com:9093
listener.security.protocol.map=INTERNAL:PLAINTEXT,EXTERNAL:SSL
inter.broker.listener.name=INTERNAL

复盘要点:容量结论要按“传输方式 + 压缩 + 平台”三要素标注,换任何一项都要重测。判据是效率指标而不是绝对值:每 GB 出网流量消耗的 CPU 秒,改造前后对比超过 1.5 倍就必须回到容量评审(阈值经验值,需校准)。安全与性能的取舍要写成架构决定,别让“加密”在 Broker 层默默吃掉一半容量。

二、retention 配了 7 天,磁盘 21 天不降 ​

现象:Topic 配 retention.ms=604800000(7 天),但分区目录磁盘占用持续增长,三周后触发磁盘水位告警;同时另一套集群因为把 segment.bytes 调到 64 MiB,出现 Too many open files。运维以为“retention 是按时间精确删除”。

排查:删除以 Segment 为粒度,且 log.retention.bytes 默认是 -1(不限制)。

定位层取证据判定
段是否够老活跃 Segment 永不删除;最旧消息超期只判断整段实际保留 = 配置 + 最多一个 Segment 的量
段大小与写入速率segment.bytes(默认 1 GiB)与该分区每天写入量写入慢时一个段要攒很多天才滚动,保留期被拉长
大小上限是否配了log.retention.bytes(默认 -1)只有时间维度,写得快就不降
检查周期log.retention.check.interval.ms(默认 300000,5 分钟)影响删除延迟,不是主要问题
句柄数分区数 × 段数 × 文件类型;ulimit -n、process_open_fds段变小即文件数暴涨,句柄先耗尽
副本因素每分区 replication.factor 份磁盘容量要乘副本数算

对症做法:

properties
# 1. 双维度限制:时间 + 大小,谁先到删谁(大小按分区算,注意乘副本)
log.retention.hours=168
log.retention.bytes=107374182400      # 单分区 100 GiB 上限,按集群盘量校准

# 2. 段大小按“写入速率”定,而不是越小越好
#    目标:每天滚动 3~7 个段(保留期粒度合适且文件数可控)
segment.bytes=1073741824              # 高吞吐 Topic 保持默认 1 GiB
segment.ms=86400000                   # 低吞吐 Topic 用时间兜底滚动,避免段长期不关
bash
# 3. 句柄预算:每分区约“段数 × 3(log/index/timeindex)× 副本数”
bin/kafka-run-class.sh kafka.server.MetadataQuorumStats   # 或看 process_open_fds
ulimit -n 100000                     # Broker 进程与容器 limit 同步抬高
# 4. 容量告警按“盘量水位”设,而不是等 retention 生效:75% 告警、85% 停止分配新分区

复盘要点:保留策略的判据是“磁盘水位 + 段数量”两条曲线,retention.ms 单独配不足以管住容量。把“分区数 × 副本数 × 保留期 × 日均写入”这条算式写进建 Topic 模板,超出预算就要求改分区数或缩短保留期;低吞吐但要求精确保留窗口的场景,用 segment.ms 主动滚动,别等 segment.bytes 攒满。

三、compact Topic 的历史被合并,CDC 下游取不到中间状态 ​

现象:把用户画像的更新流配成 cleanup.policy=compact,下游做“按 offset 回放重建昨天状态”时发现大量 offset 消失、某些 key 只剩最新值,还有一个已删除的用户又在次日重新出现(墓碑被清理,消费者还没读到)。

排查:compaction 按 key 保留最新值,不保证 offset 连续,也不保证立刻回收空间。

定位层取证据判定
清理触发条件min.cleanable.dirty.ratio=0.5:脏占比超 50% 才清未触发时空间不回收,触发后历史直接合并
墓碑保留delete.retention.ms=86400000(24h)最慢消费者超过 24h 没读到删除标记 → 删除丢失
最小滞后min.compaction.lag.ms=0为 0 时新写入的 key 可能马上被合并
消费者位点lag 与“被清理的 offset”对比消费跟不上清理速度即取不到中间态
用途混用同一 Topic 既存“当前值”又承担“事件流回放”两种需求天然冲突
磁盘余量Cleaner 需要额外空间重写段盘紧时清理失败或被推迟

对症做法:

properties
# 1. 墓碑保留必须覆盖“最慢消费者 + 补数窗口”,24h 只是默认值
delete.retention.ms=604800000              # 7 天(经验值,按最慢消费者校准)

# 2. 给消费者留出读取窗口:写入后至少 N 分钟不参与压缩
min.compaction.lag.ms=3600000              # 1 小时

# 3. 冷 key 也要被清理,避免永远不合并
max.compaction.lag.ms=2592000000           # 30 天

# 4. 回放需求与当前值需求分开建 Topic
#    user-profile-state   : cleanup.policy=compact      (取最新值)
#    user-profile-events  : cleanup.policy=delete,按保留期回放(审计/重建)
text
5. 双写用同一个事务或本地消息表保证两边都有,别用“compact Topic 兼做事件流”。
6. 需要“按 key 精确一次重建”时,把快照单独存对象存储,
   Kafka 只做变更通知,回放从快照 + 增量事件重建。

复盘要点:判据是“消费者位点与首个可用 offset 的差值”——这个差值超过 lag 允许范围就说明清理跑在消费前面,要调 min.compaction.lag.ms 或缩短消费滞后。语义也要在文档里写明:compact 的 Topic 是“可查当前值的变更日志”,不是“事件流”,按 offset 回放历史的需求必须用另一条保留策略承载;CDC 落地链路见 Canal 数据同步。

最小上手 ​

bash
# 建 Topic 时直接指定保留策略(也可事后 --alter)
bin/kafka-topics.sh --bootstrap-server localhost:9092 \
  --create --topic demo --partitions 1 --replication-factor 1 \
  --config segment.bytes=104857600 --config retention.ms=86400000

# 看 Topic 实际生效的配置(--all 才包含 Broker 默认值)
bin/kafka-configs.sh --bootstrap-server localhost:9092 \
  --describe --all --entity-type topics --entity-name demo

# 看每个 Broker/分区占用的磁盘大小和 offsetLag
bin/kafka-log-dirs.sh --bootstrap-server localhost:9092 \
  --describe --topic-list demo

kafka-log-dirs.sh 的输出是 JSON,形如 {"brokers":[{"broker":1,"logDirs":[{"logDir":"/var/lib/kafka/data","partitions":[{"partition":"demo-0","size":1048576,"offsetLag":0,"isFuture":false}]}]}]},重点看 size(分区在当前 Broker 上的字节数)、offsetLag(Follower 相对 Leader 的落后量,0 表示同步)、isFuture(是否是重分配过程中的未来副本)。kafka-configs.sh --describe 则列出 retention.ms、segment.bytes 等实际生效值,Topic 级配置会覆盖 Broker 默认。

直接看 Segment 文件内容:

bash
bin/kafka-dump-log.sh \
  --files /var/lib/kafka/data/demo-0/00000000000000000000.log \
  --print-data-log --deep-iteration

输出里认这几个字段:baseOffset: 0 lastOffset: 2 count: 3 ... isTransactional: false 中,baseOffset/lastOffset 是这一批消息的起止 offset,count 是批次内消息数,isTransactional 表示是否属于事务。--deep-iteration 会把压缩批次里的每条消息都展开;不加时可能只看到批次头。

查分区当前 LEO(末尾 offset)和最早 offset:

bash
bin/kafka-get-offsets.sh --bootstrap-server localhost:9092 --topic demo --time -1  # latest
bin/kafka-get-offsets.sh --bootstrap-server localhost:9092 --topic demo --time -2  # earliest

生产动作 ​

默认值(Broker/Topic 级,3.9/4.0)。Topic 级配置名去掉 log. 前缀(segment.bytes、retention.ms),Broker 级用下表全名:

配置默认值说明
log.segment.bytes10737418241 GiB 滚动
log.retention.hours1687 天
log.retention.bytes-1不按大小限制
log.index.interval.bytes4096稀疏索引间隔
log.cleanup.policydelete可选 compact / delete,compact
min.cleanable.dirty.ratio0.5触发压缩的脏数据比例
max.compaction.lag.msLong.MAX_VALUE最长未压缩时间

容量与清理的判断:

  1. 先看 kafka-log-dirs.sh --describe 的 size,确认是哪个 Topic/分区在涨。
  2. 对照该 Topic 的 retention.ms / retention.bytes。如果配置很大或为 -1,磁盘会一直涨到上限。
  3. 只涨一个分区是 key 倾斜;所有分区都涨是流量增长或保留配置过长。
  4. 降低 retention.ms 后不会立刻回收,要等清理线程检查,并且只能整段删除。活跃 Segment 必须等滚动后才可能被删。
  5. compact Topic 要预留额外空间:Cleaner 重写 Segment 期间新旧文件并存,磁盘接近满时会停止清理。

不要手工 rm 正在使用的 .log 文件。Broker 的索引和内存状态会与实际文件不一致,恢复时可能直接报错或丢数据。要释放空间,用 retention 配置、分区迁移(kafka-reassign-partitions.sh)或扩容磁盘。

边界 ​

  • PageCache 不是持久化。断电时未刷盘的页会丢;Kafka 的持久化靠副本和 acks=all,不是靠 PageCache。
  • 零拷贝只在 fetch 路径。Producer 写入、TLS 连接、需要重新压缩的场景都不走 sendfile。
  • retention 按整段删除。实际保留量会超过配置,最多多一个 Segment;活跃 Segment 不会被删。
  • log.retention.bytes 是每个分区的上限,不是 Broker 总量;Topic 总占用要乘以分区数和副本数。
  • compaction 需要 key。没有 key 的消息无法合并;墓碑只在 delete.retention.ms 内保留,过期后消费者再也看不到删除。
  • compaction 不是立即生效。min.cleanable.dirty.ratio 和 Cleaner 线程共同决定什么时候真正清理。
  • 分层存储有版本前提。KIP-405 在 3.9 GA,旧版本行为不同;远端读延迟、缓存和计费要单独评估,以官方文档为准。

讲清楚 ​

Kafka 的存储可以按「写、读、清理」三条路径讲。写是追加:每个分区切成多个 Segment,新消息只追加到最后一个,写进 PageCache 就返回,不按条 fsync,靠 OS 回刷和副本复制保证持久化。读是先用稀疏索引定位:每 4KB 才记一条索引,二分找到最近位置再顺序扫一小段;消费和副本拉取用 sendfile,把 PageCache 直接送到网卡,省掉用户态拷贝。

清理有两种。delete 按时间或大小删除整个 Segment,所以实际保留会多出一个段的量;log.retention.bytes 默认是 -1,不限制,1GB 是 Segment 大小。compact 按 key 保留最新值,适合 CDC 和状态流,但需要 key、需要额外磁盘,墓碑只保留 delete.retention.ms。边界要记牢:PageCache 不是持久化,零拷贝只在 fetch 路径,retention 不能精确到单条消息。排障先看 kafka-log-dirs.sh 的 size 和 offsetLag,再看实际生效的 retention 配置,不要手工删 Segment 文件。

把一门技术讲透到既懂也会用