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,periodretentionCheckMs)scheduler.schedule(kafka-log-start-offset-checkpoint,...)scheduler.schedule(kafka-recovery-point-checkpoint,checkpointLogs)scheduler.schedule(kafka-log-flusher,flushDirtyLogs,periodflushCheckMs)scheduler.schedule(kafka-delete-logs,deleteLogs,perioddeleteDelayMs)// ③ 启动日志压缩线程池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 执行 flushfsync把页缓存数据落盘并推进 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.policycompact 的 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。把仍有效的消息复制到新的段文件丢弃旧版本和过期的 tombstonenull 值墓碑。注意是复制重写不是原地修改压缩期间原文件依然可读。第五步更新检查点、释放锁写 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、写进活跃段。