日志爆炸防护机制:异常打印刷爆磁盘?动态限频+异步落盘救急
公司有一次线上故障——某个下游服务挂了,调用方在 catch 块里打了
log.error("调用失败", e)。这行代码每分钟被执行了 5 万次,5 万条堆栈日志,每条 3KB,一分钟就写了 150MB 的日志文件。运维发现的时候磁盘已经满了,其他服务也跟着挂。灾难的起点不是下游挂了,而是日志把磁盘写爆了。
这种日志爆炸场景的典型特征是突发性——平时打日志没问题,一旦某个循环里遇到了异常,日志量瞬间暴涨。今天聊聊怎么给日志加上限频和异步落盘,让它在异常场景下也不会失控。
问题出在哪:日志写入是同步阻塞的
Logback 默认的 FileAppender 是同步写盘的。log.error() 调用时,线程会等日志写完了才继续执行。平时没感觉,但一旦日志量暴增,磁盘 IO 就成了瓶颈——所有打日志的线程都堵在等磁盘写入。
而且同步模式下,每行日志都是一次 write() 系统调用。一分钟 5 万条就是 5 万次系统调用,再加上堆栈的格式化,CPU 也被打满。
方案一:异步落盘,业务线程不等 IO
Logback 的 AsyncAppender 就是干这个的:
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>1024</queueSize>
<discardingThreshold>0</discardingThreshold>
<neverBlock>false</neverBlock>
<appender-ref ref="FILE"/>
</appender>
工作方式:业务线程把日志扔进一个内存队列(queueSize),后台线程从队列里取日志写盘。业务线程不用等 IO,log.error() 几乎零延迟返回。
discardingThreshold 和 neverBlock 控制队列满了以后的行为:
discardingThreshold=0→ 队列满了也不丢日志neverBlock=true→ 队列满了阻塞业务线程;false则不阻塞(但可能丢日志)
建议 INFO 和 WARN 用 neverBlock=true(满了丢一点没关系),ERROR 用单独的 Appender 配 neverBlock=false(宁可堵也不能丢关键日志)。
方案二:动态限频,同一错误别反复打
异步落盘解决的是"IO 不堵业务线程",但没解决"日志量本身就很大"的问题。如果某个异常在循环里被反复打印,即使异步了,队列也会满。
限频的思路:同一行日志在单位时间内最多打 N 次。
@Component
public class RateLimitedLogger {
// 限频计数器:日志 key → 上次打印时间戳 + 计数
private final Map<String, RateLimit> counters = new ConcurrentHashMap<>();
// 清理过期计数器的定时任务(每 30 秒)
@Scheduled(fixedDelay = 30_000)
public void cleanup() {
long now = System.currentTimeMillis();
counters.entrySet().removeIf(e ->
now - e.getValue().lastTimestamp > 60_000);
}
/**
* 限频打印
* @param key 日志标识(如方法名)
* @param maxPerMinute 每分钟最多几条
* @return true=允许打印,false=被限频
*/
public boolean tryLog(String key, int maxPerMinute) {
long now = System.currentTimeMillis();
RateLimit limit = counters.computeIfAbsent(key, k -> new RateLimit());
synchronized (limit) {
// 如果已经过了一个周期,重置
if (now - limit.windowStart > 60_000) {
limit.windowStart = now;
limit.count = 0;
}
if (limit.count < maxPerMinute) {
limit.count++;
limit.lastTimestamp = now;
return true;
}
return false; // 被限频
}
}
static class RateLimit {
long windowStart = System.currentTimeMillis();
long lastTimestamp = windowStart;
int count = 0;
}
}
使用时:
if (rateLimitedLogger.tryLog("payService.call", 10)) {
log.error("支付服务调用失败", e);
}
同样的异常,一分钟最多打 10 条。第 11 条开始静默丢弃。可以在第 10 条时多打一条"后续限频"的提示——这样你不会误以为错误消失了。
组合打法
异步 + 限频不是二选一,而是串起来用:
请求打日志
├─ 限频检查 → 超限直接丢弃
└─ 通过 → 入队列 → 异步写盘
限频在前面拦截,减少入队量。异步在后面承接,隔离磁盘 IO。
再进一步:可以和降级策略联动。限频计数器达到阈值的时候,触发告警:"某个日志被限频了,去看看是不是出了故障"。这样即使用户没发现,运维也有感知。
总结
日志的定位是辅助排查,不是拖死服务的负担。
两招:
- 异步落盘——
AsyncAppender+ 队列,业务线程不等 IO,磁盘瓶颈不传导到业务 - 动态限频——同一条日志一分钟只打 N 条,循环异常不会打爆磁盘
加上清理机制(定时清过期计数器),日志系统从"磁盘杀器"变成了真正的排查工具。
有用的话转给还在用同步 FileAppender 并且 queueSize 设 256 的同事。
标题:日志爆炸防护机制:异常打印刷爆磁盘?动态限频+异步落盘救急
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/06/20/1781935092628.html
公众号:服务端技术精选
评论