深夜收到磁盘告警,登录上去一看,数据分区占用已经92%,log.retention.hours明明配的是24小时,分区目录下却堆着三天前的日志段文件。这种场景我遇到过不止一次,也是很多Kafka维护者在排查“日志删不掉”时最困惑的地方。
Kafka的日志删除从来不是“到期就立刻消失”,而是一整套后台调度、策略判定、文件删除和元数据推进的协同过程。市面上讲Kafka原理的文章很多,但真正把日志删除这条链路从源码层面拆开讲透的并不多。这篇内容我会从LogManager入手,把segment的滚动、保留策略的判定、物理删除的实现,再到我实际排查中踩过的坑,完整梳理一遍。如果你正在处理磁盘告警,或者想弄明白Kafka内部那条“看不见的清理线程”到底在干什么,这篇应该能直接帮到你。
1. 从磁盘告警说起:Kafka日志删除到底删的是什么
1.1 segment是唯一的最小删除单元
先建立一张代码地图。Kafka里日志存储不是“一个Topic一个文件”,而是一个分区一个目录,目录里再按时间、大小切成若干段文件,这就是日志段LogSegment。每个段由几个文件组成,最常见的是.log数据文件、.index偏移量索引文件、.timeindex时间戳索引文件。如果开启事务,还会有.txnindex,此外还可能有.snapshot这类辅助文件。
日志删除的最小单位,就是一个完整的LogSegment。Kafka不会在某个segment里挑出几条过期消息单独删掉,原因很简单:日志文件是顺序追加写的,索引和数据文件彼此对齐,如果做“缝缝补补”式的单条删除,既要重写数据文件又要重排索引文件,代价极大。更关键的是,Kafka的生态依赖offset连续性,在一段文件里挖掉几条消息,会让消费者和副本同步的offset语义直接乱掉。
所以Kafka的做法,用仓库装箱来类比最贴切:货物按箱存放,过期就整箱扔,而不是拆箱挑几件。一个小细节是,一个segment文件的取名规则是“起始offset补零到20位”,比如00000000000000000000.log,这个数字代表该segment的第一条消息的offset,也是它在文件系统里的唯一身份证。删除时,Kafka拿到这个起始offset,就能定位并删掉这一整组文件。
1.2 三个核心类之间的嵌套关系
要在源码里不迷路,需要先分清LogManager、Log、LogSegment这三个角色。
LogManager:整个broker的日志总管,启动时扫描所有日志目录,构建出“TopicPartition -> Log”的映射,同时负责启动后台清理任务。Log:一个分区对应一个Log对象,内部持有该分区目录下所有LogSegment的集合,并维护logStartOffset、logEndOffset、recoveryPoint等关键位移信息。真正判断哪些段该删,主要在这个类的方法里完成。LogSegment:一个具体的segment,负责数据文件、索引文件的实际读写和删除动作。
平时我在线上排查日志删除问题,第一步就是先弄清楚问题落在哪一层。配置没生效、分区被卡住,那是LogManager和Log的问题;文件删了但磁盘空间没掉,那要往LogSegment的文件句柄和延迟删除逻辑上找。这三层如果混在一起看,很容易被各种表象带偏。
还需要补充一个认知:Kafka的日志删除在整个消息中间件生态里算是比较“粗放”的。它不代表“精确清理单条消息”,它只代表“把过期整个段扔进回收站”。理解了这个前提,再看后面的源码路径,就不会纠结“为什么retention时间到了还有部分旧数据没消失”这种伪问题了。
2. 源码入口:从LogManager.startup到周期调度
2.1 谁在定时唤醒删除流程
Kafka的日志删除不是被动触发的,而是由broker内部一个周期任务不断扫描和驱动。入口在LogManager.startup()里,大概逻辑是向调度器注册一个名为kafka-log-retention的任务,执行周期由log.retention.check.interval.ms控制,默认是300000毫秒,也就是5分钟一次。
我贴一段示意性的调度伪代码,方便理解:
// 示意代码:LogManager启动时注册周期清理任务 scheduler.schedule("kafka-log-retention", cleanupLogs _, delay = 0L, period = retentionCheckIntervalMs, unit = TimeUnit.MILLISECONDS)也就是说,你改完log.retention.hours或 topic级的retention.ms,最迟可能要等一个检查周期才看到效果。很多线上“为什么配置改了没生效”的疑惑,第一嫌疑就是这个默认5分钟的调度周期。你在生产环境改完配置,没等5分钟就去看,当然会觉得没反应。
cleanupLogs这个任务在较老的Scala版本里叫这个名字,后续版本逐步迁移到Java实现后方法名可能略有变化,但核心思路没变:遍历当前broker上所有Log对象,逐个检查是否需要清理过期段。
2.2 全分区扫描的成本与陷阱
对整个broker上所有分区全量扫描,这个成本需要心里有数。假设一个broker上有2000个分区,每个分区有若干个segment,cleanupLogs每次执行就是几万次的文件元数据访问和条件判断。
这也是我不建议把log.retention.check.interval.ms调太小的原因。有次测试环境为了快速验证删除,把它调到了1000毫秒,结果broker的CPU明显爬升,磁盘IO上也多了一堆stat系统调用。生产环境里,这个5分钟的默认值其实是经过权衡的:删除晚几分钟执行不会造成灾难,但每1分钟全量扫一遍几千个分区目录,纯属自找麻烦。
顺着调用链往下,LogManager最终会调用Log对象的deleteOldSegments方法。这是整个删除逻辑的枢纽方法,后面讲三条策略的时候,大家会发现策略再怎么变,最后都要汇聚到这个方法上来。
2.3 删除之外还有一张“压缩”网
说到日志清理,很多人会混淆delete和compact。LogManager里其实有两条并行路径:一条是“过期删除”,也就是本文重点分析的kafka-log-retention任务;另一条是LogCleaner线程池处理的“日志压缩”,也就是log.cleanup.policy=compact时干的事。
压缩并不是把旧数据删掉,而是对同一个key的旧版本消息做合并整理,只保留最新有效值,同时留下墓碑消息表示删除标记。它的核心目的是缩减数据体积,但并不会按时间轴推前logStartOffset。所以,如果配置的是compact,delete的组合策略,你会发现日志段既会被压缩,也可能会按保留时间被整体删除,两条链路并行工作。排查问题时,先在配置里确认当前topic到底走的是哪条路,不然很容易拿“压缩延迟”的原因去解释“删除失败”的现象。
3. retention策略的三种判定:时间、大小、logStartOffset
3.1 基于时间:看的是段的最大时间戳,不是单条消息时间
进入Log.deleteOldSegments之后,第一个判定条件是时间保留策略retentionMs。这段逻辑在旧版本里常表现为applyRetentionMs这样的内部方法。判断一个segment是否过期,不是拿“现在”减去每条消息的写入时间,而是拿这个segment里最大的那条记录时间戳,也就是largestTimestamp,减去retentionMs,如果结果大于当前时间,这个段就该删。
这里有一个极其容易踩的坑:largestTimestamp受log.message.timestamp.type影响。如果配置是CreateTime,时间戳来自客户端生产消息时自带的时间;如果客户端机器时钟不准,记录时间可能比真实时间晚很久,导致Kafka认为数据还很“新鲜”,日志段迟迟不删。如果配置是LogAppendTime,时间戳由broker在写入时生成,系统时钟基本上就是准的。生产环境里,如果发现某些topic的日志删除明显滞后,先看一眼客户端时钟同步和这个配置。
另外,活跃段永远不在时间删除的候选列表里。这是因为正在写入最新数据的segment无法滚动,一旦删除它,整个分区的尾部就没了。Kafka的源码里对活跃段有明确保护:至少会保留当前正在写的这个segment。所以,即使你把保留时间设成1分钟,某个segment是最后一个活跃段,它也必须等写满并roll成新段后,才会在下一次清理周期里进入被删除的候选队列。
3.2 基于大小:算总账,优先扔最旧的
大小保留策略retentionSize的逻辑不算复杂:当分区日志目录下所有segment的总大小超过retention.bytes时,从最老的segment开始逐个删,直到总大小降到阈值以下。
注意计算口径里的一个细节:Kafka在统计大小时,会把当前活跃段排除掉,因为活跃段无法删除。如果活跃段本身很大,你会看到总占用超过retention.bytes,但分区目录依旧没有动静,这不是bug,是保护机制在起作用。
配置上,log.retention.bytes默认是-1,代表不限制大小。topic级别可以用retention.bytes单独覆盖。这个策略比较适合那种数据增长速度平稳、但磁盘空间又有限的场景,相当于给分区日志设了一个“软上限”。不过它有时间滞后性:如果写入突然暴涨,可能等清理周期跑起来时,磁盘已经被写到了危险水位。所以生产上通常以时间为准做保留,大小策略更多是兜底。
3.3 基于logStartOffset:数据消费者视角的起点推进
第三种判定和logStartOffset有关。当一个分区被截断、或者因为某些特殊场景需要清理“起始offset之前”的数据时,Kafka会直接以logStartOffset为分界线,只保留从这个offset开始往后的segment。
deleteOldSegments接受的其实就是一个谓词函数(predicate),每种策略都构造一个“判断segment是否可删”的函数,然后统一交给同一个删除过程执行。这是源码里设计得比较干净的地方:判定逻辑各不相同,但执行删除的通道只有一条。logStartOffset往前推进后,那些包含更早数据的segment,就会被这个谓词筛选出来,进入删除队列。
什么是logStartOffset?一句话:消费者能读到的最早offset。比如一个消费者现在offset是1000,但某次清理把logStartOffset推进到2000,那么当消费者尝试拉取1000位置的数据时,broker会直接返回OutOfRange异常,由消费者触发reset。这就是删除行为对外部可见的影响,Kafka文档里叫“日志起始偏移上的数据不可读”。运维中如果遇到消费者突然报offset越界,先确认是不是这个分区刚发生过日志删除或截断。
三条策略不是互斥关系。时间到了会删,大小超了也会删,任何一条路径判定可删,段就进入删除队列。一个segment可能同时命中了时间和大小两个条件,这没关系,反正它最终只会被删除一次。
4. segment从标记到消失:四步拆除过程
4.1 第一步:从集合中摘除并更新logStartOffset
当一个segment被判定为可删除,Log对象会先通过deleteOldSegments把这个段从自己的logSegments集合里摘除。这一步非常关键:摘除之后,新的写入和读取就不会再走到这个段上。
同时,logStartOffset会被推进到保留段里最老的那个baseOffset。这一步通常要持有Log对象的锁,保证并发环境下,一边删除、一边还有消费者拉取或生产者写入时,元数据不会错乱。源码里这个过程有锁保护,但锁的粒度在不同版本里有过调整,老版本偏粗,后来逐渐优化。这不是我们需要背的细节,但理解“删除不是单纯删文件,而是要同步变更内存里的offset元数据”这一点,对排查问题很有帮助。
4.2 第二步:关闭文件通道并删除文件族
摘除元数据之后,进入真正的文件操作层面。LogSegment会逐个关闭自己持有的文件通道,然后调用Files.deleteIfExists之类的方法,把.log、.index、.timeindex、.txnindex以及可能存在的.snapshot文件全部删掉。
这里有个细节:删除顺序是先关闭通道再删文件。如果顺序反了,文件本身还没被完全写入磁盘,或者通道里还有没刷盘的缓冲数据,就可能出现索引和数据不一致的问题。源码里那些琐碎的try-finally和异常捕获,就是为文件系统层面的不确定性兜底。
4.3 第三步:延迟删除给了读写进程缓冲时间
文件不是立刻就在文件系统里消失的。Kafka在删除segment文件时,会受log.segment.delete.delay.ms控制,默认是60000毫秒。也就是说,物理删除动作会被推迟最多60秒。
这个设计我一开始也觉得多余,直到遇到过消费者正在读老数据、副本正在追赶offset的情况才明白。如果你直接删掉正在被读取的文件,持有句柄的进程可能读到半截数据甚至直接报错。延迟删除相当于留出一个“安全缓冲期”,让已经打开文件描述符的消费者、Follower或后台任务有机会优雅退出。
线上表现就是:日志里能看到“Deleted segment xxx”的记录,但du一看文件还在。如果你只盯着文件系统看,就会误以为删除没生效。需要给这60秒一点耐心,或者用lsof确认还有谁在持有句柄。
4.4 第四步:事务痕迹与producer snapshot的连带清理
如果开启了幂等或事务能力,Log里还维护着与producer相关的快照文件(.snapshot),用于记录producer的epoch和sequence信息。当一个segment被删除,如果它落在producer快照的覆盖范围里,Kafka需要同步清理这些快照,避免陈旧的producer状态残留。
这一块看起来不起眼,但实际排查时遇到过:事务开启的topic,日志删除后.snapshot文件残留,导致目录占用比预期多出不少。后来对照源码才发现,快照清理不是跟着segment文件删除自动完成的,而是有自己的一套调度和判定逻辑,甚至在删除异常时快照会保留一段时间等待重试。如果你部署的Kafka版本较老,清理完日志后发现目录大小变化不大,可以顺手查一查快照文件的数量。
从整体来看,Kafka把“删除”设计成了一套分级动作:定时扫描发现候选段、从内存集合里摘除、推进offset、延迟物理删除、连带清理辅助文件。任何一步出问题,表象都是“日志怎么还不删”,但根因可能差出十万八千里。
5. 常见问题与排查:日志删不掉、磁盘不释放怎么定位
5.1 排查顺序:先从server.log里找“Deleted segment”
遇到日志删除异常,我一般不是先去翻配置,而是先看broker的server.log里有没有“Deleted segment”开头的记录。这句话一旦出现,说明Kafka已经认定某段该删,而且已经走到了物理删除环节。如果这句话连影子都没有,那问题在策略判定这一层;如果这句话有,但磁盘空间没变化,那问题往往在文件句柄和延迟删除这一层。
配合这条日志,还可以看看Kafka打印的partition、segment起始offset等上下文信息,再和目录里的实际文件对照。生产上我依赖这个办法,已经快速定位过好几起“删除不生效”的case,效率远高于漫无目的地调参。
5.2 为什么retention配置改了,日志迟迟不删
这个问题最常见的六个原因,按优先级排:
- 检查周期没到:
log.retention.check.interval.ms默认300秒,改完配置后需要一个周期才扫描到。 - 活跃段保护:即使过期判定成立,当前正在写的活跃segment不会被删除,要等它roll成新段。
- segment太大:
log.segment.bytes=1GiB时,一个段可能写了很久,导致“最老的数据”其实还在活跃段里。 - 配置层级搞混:broker级和topic级配置同时存在时,topic级
retention.ms优先,你改了全局配置但topic自己有覆盖。 - 时间戳来源问题:
CreateTime模式下客户端时钟不准,会让largestTimestamp偏新,导致段看起来“没过期”。 - 时间戳索引缺失:早期写入的segment可能没有足够的时间戳索引数据,极端情况下Kafka无法取得
largestTimestamp,会自动跳过并给出日志,表现也是不删除。
如果你遇到的是前两个原因,那什么都不用做,等下一轮清理自然就好。如果是后两个原因,需要从配置和时间戳数据源头去查。
5.3 日志显示删了,但磁盘占用一直没降
这种情况我在NFS和容器化环境里都见过,原因通常是文件句柄没释放。Linux下删除一个被进程打开的文件,文件不会立刻从磁盘消失,要等所有打开它的进程关闭句柄后,空间才会真正释放。
定位命令很实用:
# 查看被标记删除但仍有进程持有的文件 lsof +L1 | grep deleted输出结果里能看到哪个进程还持有deleted状态的文件句柄,以及文件路径和大小。正常情况下Kafka自己会在延迟删除后关闭句柄,但如果你的环境里刚好有消费者持有旧文件句柄,或者监控脚本长时间打开着日志文件,空间就会一直占着。
还有一种情况需要分辨:du看到目录占用变小了,但df显示磁盘占用高。这是因为du按文件名统计,df按块统计,被删除但未释放的文件在df里依然占块。两者不一致时,优先用lsof找占块文件。
5.4 清理周期性“删除风暴”对写入的影响
有时Kafka在磁盘写满后会有一次报复性的删除:因为写入被卡住,老segment一直没被清理,等到磁盘IO恢复或检查周期到来时,一次性删掉几十个segment,造成短暂的IO尖刺。这在高吞吐topic上尤其明显。
从源码角度看,删除本身是一次次deleteIfExists和元数据更新,几十个文件的删除集中在一起,对磁盘就是一次小型的IO风暴。缓解手段:不要让磁盘长期处于90%以上的高水位;适当增大log.segment.bytes减少segment数量;或者把log.segment.delete.delay.ms调大,让物理删除分散一些。不要为了“赶紧释放空间”把延迟删除调成0,那会把读端风险直接拉满。
5.5 日志目录故障时的另类“删除”
还有一类问题是目录级别的:某块磁盘发生IO故障后,Kafka会通过LogDirFailureChannel把该目录标记为Failed,然后将这个目录下的分区副本迁移到其他健康目录。迁移过程中,原来目录里的日志文件会被清理,但它走的是副本重建逻辑,而不是常规的retention删除路径。
如果你在日志清理排查中发现某个分区的日志目录整个消失,别先往“retention删除”方向想,看看是不是这个目录已经被标记offline。这类场景里,检查是否有“offline log directory”之类的错误日志,会比盯着segment文件更有价值。
6. 调试与调优:本地复现、参数权衡和监控建议
6.1 五分钟在本地把删除链路跑一遍
纸上谈兵再多,不如本地跑一个Kafka实例,把删除的全过程亲眼看到。要加速验证,可以把几个关键配置都调到很小的值:
log.retention.check.interval.ms=1000 log.retention.ms=60000 log.segment.bytes=1048576 log.segment.delete.delay.ms=1000这样设置之后,segment大约1MB滚动一次,60秒就会被判定过期,每隔1秒检查一轮。启动一个单节点Kafka,用生产脚本灌入一些数据,然后观察server.log里Deleted segment的输出。处理过一遍之后,你对“活跃段保护”“延迟删除”“logStartOffset推进”这几个概念会形成非常直观的体感。
如果你是想研究源码,我建议在IDE里拉Kafka源码,在Log.deleteOldSegments和LogSegment的删除方法上打断点,逐步看deletable集合是怎么被构建出来的。先看时间策略再切到大小策略,一次就能把三种predicate的差异看清楚。
6.2 源码阅读的一条推荐路径
读Kafka日志删除源码,不建议从头到尾啃大文件。我自己的顺序是先LogManager.startup,找到kafka-log-retention的调度位置,然后跳进cleanupLogs一类的入口方法,接着看Log.deleteOldSegments,之后再去找applyRetentionMs、applyRetentionSize、logStartOffset相关的谓词。最后再追到LogSegment的删除文件和文件关闭逻辑。这条路径把整个链路串下来,大概需要半天时间,收益却极高——你会彻底理解为什么Kafka官方把某些参数设计成默认值,而不是只记住几个调参口诀。
6.3 配置调优的几个权衡点
log.segment.bytes是最值得权衡的配置之一。段越大,segment数量越少,文件管理的开销越小;但删除的最小颗粒度也越大。举个例子,如果把段大小设成4GiB,一个topic每天产生2个段。retention.time设为24小时,那实际留存时间可能宽到36小时左右,因为最老段还能多存一会。反过来,段设得太小比如128MB,segment文件数量爆炸,目录里inode占用飙升,日志删除也会变成高频动作。日常建议保持默认1GiB左右,除非你的业务有明确的精确留存需求。
log.segment.delete.delay.ms不建议乱动,默认60秒是安全的。我之前见过一个同学把这个值调成5000,结果消费者在滚动offset时经常读到“文件不存在”的错误,罪魁祸首就是删除太快,读端还在用旧句柄定位数据。
log.retention.check.interval.ms在5分钟基础上略微调成2-3分钟可以接受,再低就要考虑CPU成本。分区特别多的集群,建议同时关注broker上每秒的日志清理CPU占用。
6.4 可观测性:重点盯什么
运维层面,除了磁盘水位,我建议重点关注三个量化指标:每个分区目录下的segment数量、单个Log的大小趋势、以及“Deleted segment”日志的频率。segment数量如果异常增长,往往意味着log.segment.bytes设置过小或滚动条件异常;删除日志频率突然飙升,则要和写入流量关联起来看是否出现了删除风暴。
用命令行也能快速体检:
# 查看broker各副本日志目录大小分布 ./kafka-log-dirs.sh --bootstrap-server localhost:9092 --describe这个命令可以列出每个partition的日志段数量、大小、leader状态等。经常会有某个分区因为单key热点或重复重建导致日志膨胀,用这个工具扫一遍就能发现偏移点。
调试参数时,还可以临时把kafka.log.LogManager和日志相关包的日志级别调成TRACE,观察每个分区的清理判断。线上操作时记得调试完恢复INFO,因为TRACE级别日志量极大,长时间开启会让broker日志文件本身又变成一个磁盘杀手。
源码里那些看起来不起眼的延时和异常处理,生产上往往就是救命的细节。比如延迟删除的60秒,平时谁都不会注意,但遇到消费者正在读老数据时,这段缓冲就是从容下线的窗口;再比如活跃段永不删除的规则,看似简单,实际上避免了多少次“分区尾巴消失”的事故。Kafka日志删除这套机制,把复杂的数据清理问题拆成了“判断哪些段该扔”和“怎么安全地扔”两件事,每一步都朴素直接,但合在一起就构成了一个应对海量数据存储的稳定方案。