日志异步落盘阻塞优化:Logback AsyncAppender 队列满丢弃日志?CallerRuns 策略保关键日志!

去年双十一,隔壁组出了个事故。交易系统在高并发的时候偶发超时,运维翻日志找原因,结果发现那个时间段的 ERROR 日志全是空的。排查了一圈才搞明白——Logback 设了 AsyncAppender,队列满了之后,新来的日志直接被丢弃了。问题日志没留下来,复盘都无从下手。

异步日志本来是提升性能的好东西,但如果没搞懂它"队列满了怎么办",关键时刻它会把你最需要的那条日志扔掉。

今天聊聊怎么用好 Logback 的异步日志——既不让日志拖慢业务线程,也不让关键日志在队列溢出时被丢弃。


同步日志慢在哪

先说清楚为什么需要异步。一次常规的日志写入,背后是好几步操作:

业务线程调用 log.info()
  │
  ├─ 格式化:把参数拼进日志模板 → "订单 12345 支付成功"
  ├─ 编码:转成字节数组(UTF-8)
  ├─ 写磁盘:fsync 刷到文件
  └─ 返回,业务线程继续

其中"写磁盘"这一步最慢。固态硬盘一次随机写入大约 0.1ms,机械硬盘可能到几毫秒。如果业务代码里日志打得很密,每次 log.info() 业务线程都要等磁盘写完才能继续——高并发下这个等待会非常可观。

异步日志的思路很简单:业务线程把日志丢进队列就返回,后台线程慢慢消费队列、写磁盘。 这样业务线程不需要等待磁盘 IO,日志打印几乎是零开销。

但这带来了一个新问题:队列满了怎么办?


队列满了,Logback 默认怎么做

Logback 的 AsyncAppender 底层是一个 ArrayBlockingQueue。默认大小是 256。

队列容量 = 256 条日志

高并发场景:
  每秒 1000 条日志 → 队列每秒溢出 700+ 条

队列满了之后,默认行为是——丢弃新日志。

对,Logback 默认不会阻塞业务线程,也不会报错。它只是静默地把新来的日志扔掉。日志文件里看不出来,监控也看不出来,但你的 ERROR 日志可能已经丢了。

丢了就丢了,但更可怕的是你不知道它丢了。


三个队列溢出策略

Logback 提供了三种处理队列满的策略:

策略一:丢弃(默认)

队列满 → 新日志 → 直接丢弃

一句话:队列满了就不记了。性能最好,但也最危险——你永远不知道自己丢了什么。

策略二:阻塞等待

队列满 → 新日志 → 业务线程阻塞,等队列有空位

业务线程会卡在 log.info() 这一行,直到队列里的日志被消费掉腾出位置。这等于把异步日志的好处全抵消了——你本来就是为了不让业务线程等磁盘 IO,结果现在变成等队列有空间。

高并发下这是灾难。如果你的日志写入速度赶不上产生速度,队列永远不会空,所有业务线程都会被堵住。不要用这个。

策略三:CallerRuns(推荐)

队列满 → 新日志 → 业务线程亲自执行日志写入

当队列满了,业务线程不排队、不丢弃,而是自己动手把这条日志写完。也就是说,这条日志变成了同步写入。

这个策略的精妙之处在于:正常流量下,队列够用,日志全是异步的,性能零影响。只有在突发高流量把队列打满的瞬间,才会有一部分日志回退到同步模式。而这些回退的日志,恰好就是你最需要保留的——因为高负载时的日志往往是排查问题的关键。

而且同步写入会自然地给系统产生背压——写磁盘变慢反过来拖慢业务线程,相当于一个天然的限流效果。系统不会无限制地产生日志直到 OOM。


完整配置:两个 Appender + 不同丢弃策略

日志不应该一刀切。INFO 日志丢几条没关系,ERROR 日志一条都不能丢。

所以实际的做法是:配两个 AsyncAppender,分别处理不同级别的日志,用不同的溢出策略。

<!-- ==================== 异步 ERROR 日志:CallerRuns,绝对不丢 ==================== -->
<appender name="ASYNC_ERROR" class="ch.qos.logback.classic.AsyncAppender">
    <!-- 不过滤级别,交给真正的 Appender 处理 -->
    <appender-ref ref="FILE_ERROR"/>

    <!-- 队列大小:1024(默认 256 太小) -->
    <queueSize>1024</queueSize>

    <!-- 丢弃阈值:0 表示队列满时不丢弃,走 CallerRuns -->
    <discardingThreshold>0</discardingThreshold>

    <!-- 阻塞模式:false 表示不阻塞,队列卸由 CallerRuns 处理 -->
    <neverBlock>true</neverBlock>

    <!-- 关键:不丢弃任何日志 -->
    <includeCallerData>false</includeCallerData>
</appender>


<!-- ==================== 异步 INFO 日志:允许丢弃旧日志 ==================== -->
<appender name="ASYNC_INFO" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="FILE_INFO"/>

    <queueSize>2048</queueSize>

    <!-- 丢弃阈值:队列超过 80% 时开始丢弃低级别日志(INFO/DEBUG)-->
    <discardingThreshold>80</discardingThreshold>

    <neverBlock>true</neverBlock>
</appender>

三个核心参数的配合关系:

  • queueSize:队列大小,默认 256。生产环境至少设 1024。但别设太大,队列每多一条就多占一份内存,日志对象本身还包含堆栈信息,一条可能几 KB。
  • discardingThreshold:队列水位线。设为 0 表示"永远不丢弃日志",队列满了就走 CallerRuns。设为 80 表示"队列占用超过 80% 时开始丢弃 INFO 及更低级别的日志"。注意,WARN 和 ERROR 永远不会被这个参数丢弃。
  • neverBlock:队列满时是否阻塞业务线程。设为 true 就是走 CallerRuns 而不阻塞,设为 false 业务线程会卡住等待。

综合下来,推荐的组合拳是:

ERROR 日志:discardingThreshold=0, neverBlock=true  → 永不丢弃,满了自己写
INFO  日志:discardingThreshold=80, neverBlock=true → 满了可以丢旧的,但不能阻塞业务

为什么不堆大一点就没事了

有人会想:把队列设成 10000 不就行了?

队列只是一个缓冲。如果你的日志产生速度持续大于磁盘写入速度,队列多大都会满,只是迟早问题。而且队列越大,内存占用越大——一万条日志对象在堆内存里,每条平均 2KB 的话就是 20MB,这只是日志队列的开销。

正确做法不是堆大队列,而是:

控制日志量——循环里不要打日志,大对象不要 toString() 打印,没必要打的 DEBUG 别开。

控制日志大小——一行日志别超过几百个字符。有人喜欢把整个 JSON 请求体打出来,几 KB 一行,10 行就 30KB,队列瞬间爆。

正确的丢弃策略——允许 INFO 被丢弃,死保 ERROR。


一个容易忽略的问题:includeCallerData

AsyncAppender 有个参数叫 includeCallerData,默认是 false

如果设为 true,每条日志会额外记录调用者的类名、方法名、行号。看起来很有用对吧?但这需要额外的堆栈遍历,在日志产生线程上做,拖慢速度。生产环境建议关掉,除非你真的很需要知道每条日志是哪个类的哪一行打的。

关掉之后日志里仍然有时间戳、线程名、日志级别和消息内容,排查问题够用了。


总结

异步日志的坑不在于技术有多深,而在于默认配置太容易让人忽略。

核心就三件事:

队列满了就丢日志是不可接受的——至少 ERROR 绝对不能丢。discardingThreshold=0 + neverBlock=true 走 CallerRuns,让业务线程在极端情况下亲自写日志,保证不丢失。

不同级别不同策略——INFO 可以设 discardingThreshold=80 允许丢弃,ERROR 死守 discardingThreshold=0。好钢用在刀刃上。

异步不是魔法——队列只是缓冲,不能解决日志产生速度持续大于写入速度的问题。控制日志量才是根本。

下次出事故的时候,希望你的 ERROR 日志还在。


有用的话转给还在用默认 AsyncAppender 配置的同事。


标题:日志异步落盘阻塞优化:Logback AsyncAppender 队列满丢弃日志?CallerRuns 策略保关键日志!
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/06/05/1780412494764.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消