主题
日志存储与读写路径
一句话:每个分区是一条只追加的日志,写入先落 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 是否直接 TLS | TLS 需要用户态参与,transferTo 失效 |
| 压缩格式一致性 | 消息批次压缩格式与 compression.type(Broker 是否需解压再压缩) | 不一致会整批解压重压,CPU 双重放大 |
| 拷贝次数 | 对比改造前后的 ProcessCpuTime 与网络字节比 | 每 GB 流量的 CPU 秒显著上升即确认 |
| 磁盘与 PageCache | iostat、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 demokafka-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.bytes | 1073741824 | 1 GiB 滚动 |
log.retention.hours | 168 | 7 天 |
log.retention.bytes | -1 | 不按大小限制 |
log.index.interval.bytes | 4096 | 稀疏索引间隔 |
log.cleanup.policy | delete | 可选 compact / delete,compact |
min.cleanable.dirty.ratio | 0.5 | 触发压缩的脏数据比例 |
max.compaction.lag.ms | Long.MAX_VALUE | 最长未压缩时间 |
容量与清理的判断:
- 先看
kafka-log-dirs.sh --describe的size,确认是哪个 Topic/分区在涨。 - 对照该 Topic 的
retention.ms/retention.bytes。如果配置很大或为 -1,磁盘会一直涨到上限。 - 只涨一个分区是 key 倾斜;所有分区都涨是流量增长或保留配置过长。
- 降低
retention.ms后不会立刻回收,要等清理线程检查,并且只能整段删除。活跃 Segment 必须等滚动后才可能被删。 - 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 文件。