ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LogManager启动后在忙什么:三类后台线程撑起日志全生命周期

LogManager启动后在忙什么:三类后台线程撑起日志全生命周期

摘要:上一篇讲了 LogManager 是什么、Broker 启动时如何加载恢复日志。这篇接着追问:日志恢复完、Broker 对外服务之后,LogManager 还在做什么?答案藏在 startup() 方法里——它会拉起三类后台线程:一个单线程调度器、一个压缩线程池、一个删除线程。理解这几个线程的分工和工作循环,才能看懂段滚动、刷盘、保留删除、日志压缩这些"自动发生"的事到底由谁驱动。

关键词:Kafka LogManager startup、KafkaScheduler、CleanerThread、日志压缩、后台线程、段滚动、日志清理

一个观察线程的小技巧

排查 Kafka 问题时,我习惯先jstack打一份线程栈,看后台线程都在干嘛。Broker 进程里除了前面网络层讲过的 Acceptor、Processor、RequestHandler,还会看到这样一组线程名:

kafka-scheduler- // 周期任务调度(单线程) kafka-log-cleaner-thread-0 // 日志压缩(线程池,默认1个) kafka-delete-logs // 日志/目录异步删除(单线程)

这几个线程全是 LogManager.startup() 拉起的。它们不处理任何客户端请求,却决定了日志文件"什么时候切段、什么时候刷盘、什么时候删除、什么时候压缩"。这篇就逐个拆开看。

01 startup() 的启动序列

日志加载恢复(loadLogs)完成后,LogManager.startup() 按固定顺序拉起后台组件:

defstartup():Unit={// ① 启动单线程调度器scheduler.startup()// ② 注册四个周期任务(只在 Broker 是活跃节点时)scheduler.schedule("kafka-log-retention",cleanupLogs,period=retentionCheckMs)scheduler.schedule("kafka-log-start-offset-checkpoint",...)scheduler.schedule("kafka-recovery-point-checkpoint",checkpointLogs)scheduler.schedule("kafka-log-flusher",flushDirtyLogs,period=flushCheckMs)scheduler.schedule("kafka-delete-logs",deleteLogs,period=deleteDelayMs)// ③ 启动日志压缩线程池if(cleanerConfig.enableCleaner)logCleaner.startup()}

启动后形成三类线程分工:

  • KafkaScheduler(单线程):周期巡检,负责段滚动、保留删除检查、刷盘、检查点写入
  • LogCleaner 线程池(默认 1 个 CleanerThread):专做日志压缩这种重 IO 活
  • kafka-delete-logs(单线程):消费待删队列,真正执行文件删除

注意一个设计原则:scheduler 只"发号施令",重活一律外包。这是理解整个后台体系的钥匙,下面分别看。

02 KafkaScheduler:一个单线程调度四类任务

KafkaScheduler 内部是一个ScheduledThreadPoolExecutor,但核心线程数被设成1。很多人第一反应是:一个线程管全 Broker 所有日志,忙得过来吗?

关键在于这四个任务每次执行都极轻,只做"遍历 + 判断 + 触发":

日志段滚动(roll)。遍历每个 Log,检查当前活跃段是否满足切分条件——大小达到log.segment.bytes(默认 1GB),或时间达到log.roll.hours(默认 168h)。满足就 roll 出新段,旧段封闭。注意滚动检查还带一个log.roll.jitter.ms(抖动),给不同 Log 的切段时刻加随机偏移,避免所有段在同一时刻一起滚动产生 IO 尖刺。

保留清理(retention / cleanupLogs)。周期由log.retention.check.interval.ms控制(默认 5 分钟)。对每个 Log 按保留策略判断:delete 策略下,按时间(log.retention.hours默认 168h)或大小(log.retention.bytes)找出该删的旧段;compact 策略则标记需要压缩。要删的段不是立刻删,而是改名加.deleted后缀,交给删除线程延迟处理。

刷盘(flushDirtyLogs)。只有显式配置了log.flush.interval.ms或按消息数配置时才真正干活:找出超过刷盘阈值的 Log 执行 flush(fsync),把页缓存数据落盘并推进 recovery point。默认这个周期是 Long.MaxValue,也就是不主动刷、完全交给操作系统页缓存——这是 Kafka 高吞吐的重要选择,代价是宕机时依赖副本和恢复机制兜底。

检查点写入(checkpointLogs / recovery-point)。周期由log.flush.offset.checkpoint.interval.ms控制(默认 60 秒),把各 Log 的 recovery point offset 持久化到recovery-point-offset-checkpoint文件。这个文件上一篇讲过,是宕机恢复范围的分界线。

因为都是轻量巡检,单线程串行执行反而有好处:不需要为遍历成百上千个 Log 而引入多线程锁竞争。某次 cleanup 稍微慢一点,后续任务顺延即可,重 IO 又不在这个线程做,所以几乎不会堆积。

03 LogCleaner:压缩线程的工作循环

真正的重活是日志压缩(compaction),它由独立的 LogCleaner 线程池承担,线程数由log.cleaner.threads控制,默认只有 1 个。每个 CleanerThread 跑一个cleanOrSleep()死循环,逻辑非常清晰:

while (running) { val cleanable = logCleaner.grabFilthiestCompactedLog() // 抢最脏的Log if (cleanable == null) { pause(backoffMs) // 没活干,休眠 log.cleaner.backoff.ms(默认15s) } else { clean(cleanable) // 有活,执行压缩 checkpointDrawn() } }

第一步,挑最脏的日志(grabFilthiestCompactedLog)。扫描所有 cleanup.policy=compact 的 Log,计算脏数据比例:

dirtyRatio = 脏段字节数 / 日志总字节数

脏段指 cleaner-offset 之后、还没压缩过的部分。只有 dirtyRatio 超过log.cleaner.min.cleanable.ratio(默认 0.5)才有资格压缩,比例最高的"最脏"日志优先。这解释了一个常见现象:compact 主题刚写入时不会立刻压缩,要等脏数据攒够一半。

第二步,加锁(grabAndLockLog)。对选中的 Log 加锁,防止压缩过程中与段删除、truncation 等操作冲突。

第三步,构建 key→最新offset 映射(buildOffsetMap)。从 cleaner-offset 扫到 LEO,在内存的 OffsetMap(基于去重缓冲log.cleaner.dedupe.buffer.size,默认 128MB)里为每个 key 记录最新 offset。同一个 key 的旧值就此被判定为可删除。

第四步,重写段(cleanSegments)。把仍有效的消息复制到新的段文件,丢弃旧版本和过期的 tombstone(null 值墓碑)。注意是"复制重写"不是原地修改,压缩期间原文件依然可读。

第五步,更新检查点、释放锁,写 cleaner-offset-checkpoint,进入下一轮。

如果去重缓冲装不下,会把日志切成多批(分组段)逐批压缩,这也是大日志压缩时会看到多组新段的原因。

04 后台压缩与前台写入为什么不打架

[图3位置下半部分]

这是设计上最精巧的地方,值得单独说。压缩是重 IO,前台 append 是高频写,两者并发却基本不互相阻塞,靠三条保证:

  • 压缩只动已封闭的旧段:正在 append 的活跃段不参与压缩,前台写入路径完全不受影响。
  • 锁只保护关键切换:替换段文件、更新检查点这类需要一致性的动作才短暂持锁;复制重写这种耗时 IO 在锁外完成。
  • 去重缓冲可双份配置:log.cleaner.io.buffer/ dedupe buffer 让扫描和写新段可以流水化,减少相互等待。

运维上对应的经验是:compact 主题多、写入量大的集群,默认 1 个压缩线程往往跟不上,表现为磁盘占用持续增长、Log clean time指标偏高,这时要调大log.cleaner.threads,并适当增加 dedupe buffer。

05 删除线程:慢 IO 也要隔离

段清理命中删除后,scheduler 并不自己删文件,而是把待删日志放进一个队列,由独立的kafka-delete-logs线程异步处理。删除大量段文件是慢 IO(尤其在机械盘上),单独成线程可以保证:scheduler 的巡检节奏不被文件删除拖慢,压缩线程也不必等待删除完成。删除前先改名.deleted、再延迟(log.segment.delete.delay.ms,默认 1 分钟)真正删除,还给误删/崩溃恢复留了缓冲窗口。

写在最后

LogManager 启动后做的事,可以用一句话概括:用一个轻量单线程做全局巡检和调度,把压缩和删除两类慢活分别外包给专用线程,自己始终保持轻快。这和网络层"IO 线程不做业务、业务线程不碰 Selector"的分工思想如出一辙——Kafka 在不同层面反复使用同一个套路:把不同节奏的工作拆到不同线程,中间用队列解耦。

读后台线程的回报很实际:理解了四个周期任务的触发条件,你能解释日志为什么"过了保留期还没立刻消失";理解了 dirty ratio,你能解释 compact 为什么不实时;理解了 flush 默认关闭,你能正确评估宕机的数据边界。下一篇回到写入热路径,深入 Log.append,看一条消息如何分配 offset、写进活跃段。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进