日志异步落盘阻塞优化: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://jiangyi.space/articles/2026/06/05/1780412494764.html
公众号:服务端技术精选
评论