我到现在还记得那次事故,一个日请求量几百万的订单查询服务,突然在一分钟内接口 P99 从 80ms 飙到 4000ms,错误率直接破了 8%。技术团队的第一反应是查数据库、查网络、查中间件,折腾了快一个小时才发现真凶居然是自己项目里的 Log4j 1.x 锁竞争。那次之后我把日志从 Log4j 1.x 迁移到了 Log4j 2.x,用上了异步写入,整个系统的吞吐和延迟表现完全不一样。这篇文章就记录一下完整的排查过程、锁竞争的根本原因,以及迁移到 Log4j 2.x 异步写入的实战细节,包括配置、压测数据和那些不跑一遍根本发现不了的坑。
1. 事故现场与定位过程:Thread Dump 里 40 个线程全部 Blocked
1.1 接口变慢后的第一波排查
先说时间线。下午 14:02,监控大屏弹出告警:订单查询接口平均响应时间从 80ms 涨到 4s,错误率从 0.1% 升到 8%。因为之前遇到过几次数据库抖动导致的慢查询,团队第一反应就是查 DB。慢查询日志拉出来看了一遍,没有新增的明显慢 SQL;数据库连接池监控也正常,活跃连接数没有打满;再看了网络层,TCP 重传率没有异常,负载均衡和后端节点的带宽也没到瓶颈。
这时候 CPU 倒是有点奇怪,整体使用率在 72% 左右,不算低,但也没有把机器跑满。GC 日志看了一眼,Full GC 次数略多但不至于引起这么大的响应时间抖动。内存使用正常,没有频繁的 Young GC 或者堆溢出征兆。
到这里基础资源层面基本排除了。接下来把目光放到应用本身的线程池上。订单查询服务用的是 Dubbo 和 Spring MVC 两层入口,我连上去看了线程池活跃数,发现 Dubbo 业务线程池 200 个线程几乎全部处于活跃状态,但 CPU 又不是 100%,说明这些线程大概率不是在拼命运算,而是在等什么。
等了什么?最直接的办法就是抓 Thread Dump。我连续抓了两份,间隔 10 秒,对比后锁定了问题。
1.2 Thread Dump 抓到锁竞争证据
两份 Thread Dump 里都有一个极其统一的特征:大量业务线程卡在了 org.apache.log4j.Category.callAppenders 上,线程状态是 BLOCKED (on object monitor)。典型栈如下:
java复制"dubbo-thread-12" #67 daemon prio=5 os_prio=0 tid=0x00007f1b0c015800 nid=0x1f runnable [0x00007f1a9ffd9000]
java.lang.Thread.State: BLOCKED (on object monitor)
at org.apache.log4j.Category.callAppenders(Category.java:98)
- waiting to lock <0x00000000f2a7d9f8> (a org.apache.log4j.spi.RootLogger)
at org.apache.log4j.Category.log(Category.java:856)
at org.apache.log4j.Category.info(Category.java:968)
at com.xxx.order.service.OrderQueryService.queryOrder(OrderQueryService.java:126)
注意那一行 waiting to lock <0x00000000f2a7d9f8> (a org.apache.log4j.spi.RootLogger),这就是锁竞争的直接证据。也就是说,几十上百个业务线程都在等同一把锁——RootLogger 上的对象监视器锁。谁持有这把锁呢?从栈里能看到另一些线程,它们已经拿到了锁,正在执行写文件相关的操作,同样是在 callAppenders 链路里,不过走到了 FileAppender.append 或 WriterAppender.write 这样的方法,正卡在文件 IO 上。
这就能解释为什么 CPU 不到 100% 但线程全部活跃了:大家不是在计算,而是在排队。
我再顺手做了一次采样统计,两份 Thread Dump 里 BLOCKED 状态的业务线程数量分别是 42 个和 38 个,占比超过 60%。这个比例已经足以造成业务线程池喂满。后端的排队请求越来越多,调用方等不到响应就开始超时重试,重试流量又继续进来,形成恶性循环。
1.3 还原"血案"完整的因果链条
锁竞争只是直接原因,真正引爆问题的其实是日志量激增。我翻了一下最近一次发布记录,上线前三天一个同事为了排查一个偶发空指针,在订单查询链路里临时加了一段 DEBUG 日志,打印了完整的订单对象序列化结果,一共几十个字段。这个日志在测试环境没暴露问题,但到了线上,订单查询服务本身 QPS 就有几千,每个请求进来自动补全一次订单详情,高峰期每分钟产生的日志行数从原本的几万行暴涨到几百万行。
日志量一上来,Log4j 1.x 的全局锁就成了瓶颈。所有线程写日志都要抢同一把锁,抢到锁之后还要执行格式化、写文件、刷盘等一系列操作,持锁时间被拉长,排队线程越积越多。最终线程池被日志堵死,业务逻辑根本没机会执行。
完整链条是:
text复制日志量激增 -> 全局锁竞争加剧 -> 每个日志写入都被串行化 -> 业务线程大规模阻塞 -> 线程池打满 -> 调用方超时重试 -> 流量继续涌入 -> 服务雪崩
后来我们临时把那段 DEBUG 日志注释掉,服务在几分钟内就恢复了。但这件事暴露出的问题没这么简单——底层的 Log4j 1.x 同步日志模型本身就扛不住高并发场景,这次是日志量触发的,下次可能换个入口还会炸。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Log4j 1.x 锁竞争的本质:一次日志写入里发生了什么
2.1 Category 和 Appender 的双重同步
如果把 Log4j 1.x 的日志写入链路拆开看,你会发现它几乎是处处加锁。首先是 Category.callAppenders() 这个方法,它本身就是一个 synchronized 方法:
java复制public void callAppenders(LoggingEvent event) {
if (appenderList == null) {
synchronized (this) {
// ...
}
}
// 触发当前 logger 以及所有父 logger 的 appender
}
这里 synchronized 锁的对象是当前的 Logger 实例。但在一个典型配置里,业务代码拿到的一般是 Logger.getLogger("com.xxx.order") 得到的 logger,它并没有自己的 Appender,真正挂 Appender 的是 RootLogger。于是每一次日志调用都要沿着 logger 继承链往上找,最终走到 RootLogger,再调用 RootLogger 上的所有 Appender。
问题就出在这里:如果所有业务 logger 都没有独立的 Appender,而是把日志统一交给 RootLogger 处理,那实际上所有日志都在争抢 RootLogger 这一把全局锁。在我们的案例里,waiting to lock <0x00000000f2a7d9f8> (a org.apache.log4j.spi.RootLogger) 就是最直观的体现。
除了 logger 层级的锁,Appender 自己还有第二把锁。比如 FileAppender 内部用 synchronized 保护 doAppend 和文件写入操作,RollingFileAppender 在做文件滚动时也要同步。所以即使你躲过了 logger 层的锁,到 Appender 写文件时还是会被锁住。
这算是 Log4j 1.x 在远古单机时代的设计——单线程写日志,安全优先。但在多线程高并发场景下,全局锁就是天然的瓶颈。
2.2 一次日志写入的完整开销拆解
锁竞争只是表面现象,真正让持锁时间拉长的是日志写入本身的操作链。一次 logger.info(...) 调用背后,至少要经历以下步骤:
- 检查日志级别是否满足配置,比如配置的是 DEBUG,那么 INFO 和 DEBUG 都会进入下一步。
- 构造
LoggingEvent对象,这个过程中如果配置了 Location 信息(比如%class、%method、%line这些占位符),要调用new Throwable().getStackTrace()来获取调用栈,这一步开销极大,正常情况下就要几十微秒甚至上百微秒。 - 传入各个 Appender,在 Appender 内部执行
Layout.format(),把LoggingEvent渲染成带时间戳、线程名、logger 名的字符串。 - 调用底层
Writer把字符串写到文件,如果是ImmediateFlush=true(Log4j 1.x 的默认配置),每次写完后还要强制 flush 到磁盘,这意味着一次write至少伴随一次系统调用。 - 如果日志量达到滚动条件,还要执行文件重命名、删除过期文件等操作,这些操作在持有文件锁的情况下会更慢。
同样是一次日志调用,在有 Location 信息和 ImmediateFlush 的情况下,单次耗时可能从几十微秒涨到几百微秒。这个数值单独看毫不起眼,但乘以几百万次调用,再叠加全局锁,整个服务的响应时间就被彻底拖垮。
关键点在于:日志写入的成本不只是"写一行字符串"这么简单。它可能触发堆栈采样、文件 IO、磁盘 flush,每一项都是重量级操作。而 Log4j 1.x 的设计是让业务线程在关键路径上同步执行这些操作,完全没有把它们卸载到后台线程的意思。
2.3 从请求积压到雪崩的放大效应
为什么锁竞争最终演变成整个服务雪崩,而不是仅仅让日志变慢?一个重要原因是锁等待产生了"排队效应"。
拿高速收费站举例:所有车都从入口进来,但只有一个收费窗口。就算路面再宽,车辆也只能一辆一辆地过。线程池里的线程也是这样,它们并不是全部同时去抢锁,而是抢到锁的线程在写日志,抢不到锁的线程在排队等待,整个线程池的执行速率被压到"写一行日志"的串行速率上。
当队列积压到一定程度,新的请求根本分配不到线程,Dubbo 线程池会被 RejectedExecutionException 打满,Spring MVC 的 Servlet 线程也会被卡住。上游服务调用这个订单查询接口时等不到响应,开始抛超时异常,同时触发重试机制。重试请求再次进入服务,虽然有一部分会直接失败,但失败的请求也会打错误日志,反而进一步加剧日志压力。
更隐蔽的是,雪崩过程中产生的异常日志往往比正常日志更重。打印堆栈信息、异常 cause 链会大幅拉长单条日志的格式化时间。我们事后统计那段时间的日志文件,异常堆栈类日志数量占比不到 5%,但累积耗时占了整个日志链路的三成以上。
这也是为什么我后来常跟团队说:日志系统表面上只是个配套设施,但它一旦出了锁竞争问题,毁掉的不是日志本身,而是整个业务。
3. 迁移 Log4j 2.x 的落地步骤:从依赖替换到异步写入改造
3.1 依赖替换与兼容性判断
事故恢复之后,我们定下的方案不是去 Log4j 1.x 上做局部优化,而是直接迁移到 Log4j 2.x,启用异步写入。理由很简单:Log4j 1.x 在架构层就存在全局锁问题,再怎么调参数也只是缓解,不能根除;而 Log4j 2.x 从设计上就走了一条完全不同的路。
迁移第一步是处理依赖。如果你也准备迁移,先扫一遍项目,看看除了直接依赖 log4j 1.x 之外,还有没有间接依赖。一个很常见的坑是:你的 pom 里没写 log4j,但某个第三方 SDK 内部带上了 log4j:log4j:1.2.17,你不主动排除的话,迁移就是空谈。
我当时做的是以下几件事:
- 全局搜
org.apache.log4j相关的引用,包括 pom 里的<dependency>和代码里的import org.apache.log4j.Logger。 - 在 pom 里先全局排除掉 log4j 1.x 构件,确认最终依赖树里没有
log4j:log4j。 - 引入
org.apache.logging.log4j:log4j-api和org.apache.logging.log4j:log4j-core,版本直接用当时最新的稳定版 2.17.2 以上,顺带把多个已知的安全问题也避免掉。 - 如果项目还用了 SLF4J,把原来的
slf4j-log4j12替换成log4j-slf4j-impl,这是 Log4j 2.x 官方提供的 SLF4J 绑定。
xml复制<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.17.2</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.17.2</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j-impl</artifactId>
<version>2.17.2</version>
</dependency>
如果你项目里还有大量代码直接用 org.apache.log4j.Logger 的 API,不想一次性改完,可以临时引入 log4j-1.2-api 兼容包。但我要提醒一句:这个兼容包只是让老代码能编译运行,它内部会转调 Log4j 2.x API,但无法享受部分异步优化带来的全部收益,而且会引入一层转换开销。我们最后是硬着头皮把代码全部改成了 org.slf4j.Logger,一次性改完最干净。
3.2 异步方案的选型对比
Log4j 2.x 里所谓"异步日志"其实有几种不同的说法,刚接触的人很容易搞混,我把它们放在一起对比:
| 方案 | 核心机制 | 优点 | 缺点/适用场景 |
|---|---|---|---|
| 同步 Logger | 普通调用,立即写盘 | 日志不丢失,配置简单 | 高并发下仍有性能瓶颈 |
| AsyncAppender | 用 ArrayBlockingQueue 做缓冲,后台线程消费 | 写日志路径变成入队,速度快 | 事件在进队列前仍要经过同步 Appender,队列消费速度跟不上时仍会阻塞 |
| AsyncLogger | 基于 LMAX Disruptor 的 RingBuffer,无锁化提交,后台线程消费 | 真正的异步化,整个日志调用路径几乎无锁,吞吐极高 | 需要额外线程消费,日志在宕机时可能丢失 |
我们最终选的是 AsyncLogger,而不是 AsyncAppender。原因是 AsyncAppender 的"异步"只发生在 Appender 这一层,它内部虽然有一个 ArrayBlockingQueue 做缓冲,但日志事件从 Logger 到 Appender 的整个过程依然是同步的;一旦队列满了,调用线程还是会卡在入队操作上。而 AsyncLogger 直接把日志事件提交到 Disruptor 的 RingBuffer 上,基于 CAS 和内存屏障实现无锁并发写入,业务线程只需要把事件丢进 RingBuffer 就能立刻返回,写盘完全交给后台线程。
如果拿现实生活类比:AsyncAppender 相当于你写文件前先放到一个待办箱,但待办箱满了你就得等着;AsyncLogger 相当于在入口处有几个高速取号窗口,业务线程把号一取就走,后面的人慢慢处理。
3.3 最终配置方案与关键参数
启用 AsyncLogger 需要两步:一是设置 ContextSelector 为异步模式,二是把配置文件放在 classpath 根目录,文件名必须是 log4j2.component.properties 或通过系统属性指定。
最简单的做法是在 JVM 启动参数里加上:
bash复制-Dlog4j2.contextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector
这样所有 Logger 都会自动变为 AsyncLogger,不需要在配置文件里逐个指定。
下面是我们在生产环境使用的 log4j2.xml 核心配置,你可以直接参考:
xml复制<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Properties>
<Property name="logPath">/data/logs/order</Property>
<Property name="logPattern">%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n</Property>
</Properties>
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="${logPattern}"/>
</Console>
<RollingFile name="RollingFile"
fileName="${logPath}/order.log"
filePattern="${logPath}/order.%d{yyyy-MM-dd}.%i.log.gz">
<PatternLayout pattern="${logPattern}"/>
<Policies>
<TimeBasedTriggeringPolicy interval="1" modulate="true"/>
<SizeBasedTriggeringPolicy size="512MB"/>
</Policies>
<DefaultRolloverStrategy max="30"/>
</RollingFile>
</Appenders>
<Loggers>
<AsyncLogger name="com.xxx.order" level="INFO" includeLocation="false" additivity="false">
<AppenderRef ref="RollingFile"/>
</AsyncLogger>
<Root level="INFO">
<AppenderRef ref="Console"/>
<AppenderRef ref="RollingFile"/>
</Root>
</Loggers>
</Configuration>
几个关键参数值得单独说一下:
includeLocation="false":关闭位置信息。如果不需要在日志里输出%class、%method、%line,关闭这个参数能大幅减少开销,因为构造堆栈信息非常昂贵。我们线上日志把%logger保留下来,但去掉了类名、方法名、行号。RingBufferSize:AsyncLogger 使用 Disruptor 的 RingBuffer 大小,默认是256 * 1024,也就是 262144 个事件。这个值不是越大越好,越大占用的堆内存越多,但能缓冲更高的日志峰值。我们把它调到了1024 * 1024,用-DAsyncLoggerConfig.RingBufferSize=1048576指定。- 不要轻易开
immediateFlush。Log4j 2.x 的RollingFile默认immediateFlush=true,但一旦启用了 AsyncLogger,写入 Appender 的动作由后台线程执行,刷盘策略应该交给后台线程控制,一般保持默认即可,不需要额外调优。 - 等待策略:Disruptor 的
WaitStrategy默认是TimeoutBlockingWaitStrategy,消费线程没有事件时会阻塞等待。日志量大的场景这个设置没问题;如果你对延迟更敏感,可以换成YieldWaitStrategy,但会增加 CPU 消耗。
如果你的应用里同时存在需要严格保证不丢的关键审计日志,建议不要全部走异步。我们单独给审计日志建了一个同步 Logger,专门写独立文件,避免异步 RingBuffer 在极端情况下丢事件。
3.4 改造中的几个注意点
第一个注意点:引入兼容包和直接改 API 的选择。我见过不少团队图省事,引入 log4j-1.2-api 后确实跑起来了,但后期排查问题时会很痛苦。老的 org.apache.log4j.Logger 桥接到 org.apache.logging.log4j.Logger,中间的 level 转换、logger 名称截断等细节都有可能埋坑。如果有条件,建议一步到位把代码层的日志 API 全部改成 SLF4J 或 Log4j2 API。
第二个注意点:配置文件的命名和放置。Log4j 2.x 默认自动加载 classpath 下的 log4j2.xml,如果你之前用的是 log4j.properties,记得删掉或改名,否则旧配置文件还在 classpath 里,容易引起混淆。
第三个注意点:additivity=false 的含义。如果你的业务 logger 配置了 additivity="false",日志不会继续向上传递给 RootLogger,因此 RootLogger 上挂的 Appender 不会执行。我在配置里给 com.xxx.order 挂了独立文件,又让 Root 也挂了一份文件,如果 additivity 写成默认值 true,业务日志会在两个文件里各写一份,既浪费磁盘又影响性能。这个细节很容易被忽略。
4. 压测验证与灰度上线:数据对比和实际踩坑
4.1 压测方案设计
迁移完成之后,我没有直接推到全量生产,而是先在测试环境做了一轮压测对比。压测目标很简单:用相同的请求模型,分别打向 Log4j 1.x 同步模式和 Log4j 2.x AsyncLogger 模式,观察响应时间、吞吐量、线程阻塞占比和 GC 表现。
压测模型我模拟了线上特征:一个订单查询接口,QPS 3000,每次请求内部会打 3~5 条 INFO 日志,其中一条会打一条 DEBUG 日志,模拟线上偶发的详细日志路径。这个模型不算极端,但能体现出高并发日志访问下的锁竞争问题。
压测工具用的 wrk,每个场景跑 30 分钟,前 5 分钟热身后记录稳定数据。同时用 jstack 周期性采集线程状态,统计 BLOCKED 线程占比;用 GC 日志采集器记录 Full GC 次数。
测试机的配置保持同一批物理机,避免不同机器硬件差异带来干扰。切换日志实现时只改依赖和配置文件,不改业务代码。
4.2 核心数据对比
压测结果非常直观,我把关键指标整理成了表格:
| 指标 | Log4j 1.x 同步 | Log4j 2.x AsyncLogger |
|---|---|---|
| 平均响应时间 | 620ms | 145ms |
| P99 | 2840ms | 260ms |
| P999 | 4012ms | 348ms |
| TPS | 836 | 2143 |
| 线程 BLOCKED 占比 | 58% | 1.2% |
| CPU 使用率 | 72% | 81% |
| Full GC 次数 | 8 | 2 |
第一眼看上去,Log4j 2.x 的 CPU 使用率反而比 1.x 高了 9 个百分点,这一点让很多人意外。原因不复杂:异步模式下,后台消费线程要做格式化、写文件、刷盘这些原来由业务线程承担的工作,自然多消耗一部分 CPU。但多出的这点 CPU 换来的是响应时间的大幅下降和吞吐量接近 2.5 倍的增长,绝对值非常划算。
另一个值得注意的点是 Full GC 次数从 8 次降到了 2 次。前半段 Log4j 1.x 的场景里,日志对象被大量 synchronized 锁竞争时会产生严重的对象头膨胀和 monitor 竞争,加上大量的 LoggingEvent 对象在业务线程不断创建、堆积、流转,GC 压力明显更大;AsyncLogger 用 RingBuffer 复用事件槽位,对象创建频率下降,GC 负担自然减轻。
线程 BLOCKED 占比从 58% 降到 1.2%,这才是本质变化。锁竞争消失后,业务线程不再在日志上排队,线程池能真正地执行业务逻辑。
4.3 异步日志在真实场景中遇到的坑
压测数据漂亮,不意味着上线后一帆风顺。灰度期间我们踩了几个坑,第一个就是异步日志"看起来变少了"。
上线当天晚上我随手查了一下日志量,发现某个接口的日志行数和老版本相比明显偏少。排查之后发现,这不是 AsyncLogger 丢了日志,而是我们配置的 RingBuffer 在日志峰值期间发生过错弃事件。当时 RingBufferSize 还是默认的 262144,而压测场景里日志峰值每秒超过 30 万条,后台消费线程如果写盘慢一拍,队列满了之后新的事件就会按配置策略被丢弃或阻塞。
Log4j 2.x 对 RingBuffer 满时的处理策略有几种,其中 WaitStrategy 和丢弃策略可以配置。如果要保证不丢,可以把 AsyncLoggerConfig.RingBufferSize 调大,同时让后台消费线程有更充足的写盘时间。但无论如何,RingBuffer 容量都有上限,如果日志量瞬间暴涨超过消费能力,丢弃事件就是不可避免的。后来我们针对这种场景做了两个调整:把 RingBufferSize 调到 1048576,同时把高频 DEBUG 日志通过过滤器直接过滤掉,减少无效日志进入 RingBuffer。
第二个坑是链路追踪参数丢失。我们用 MDC 存放 traceId,原来在 Log4j 1.x 同步模式下,MDC 的线程上下文和业务线程天然绑定,日志打印时能正确读取。但 AsyncLogger 模式下行不行取决于实现细节:我们所有业务线程把日志事件提交到 RingBuffer 后,后台消费线程去渲染日志,必须能从事件对象里拿到当时的 MDC 上下文。Log4j 2.x 对这一点做了处理,它会把 MDC 数据快照到 LoggingEvent 中,所以正常情况下异步打印时 traceId 不会丢。
但关键是:如果你在代码里是通过 ThreadLocal 自研的上下文,而不是 Log4j2 的 ThreadContext,那么异步消费线程里根本拿不到这些值。我们当时有一个老工具类用 ThreadLocal 存了业务单号,打印日志时直接取,同步模式下没问题,切到 AsyncLogger 后取出来全是 null。这个排查过程花了不少时间,最后统一把上下文切到了 ThreadContext 和 MDC 才彻底解决。
第三个坑是关闭容器时的日志截断。应用正常关闭时,如果 RingBuffer 里还有未消费的日志事件,进程退出时这些事件可能会丢。Log4j 2.x 默认在 JVM 关闭时会尝试等 RingBuffer 消费完,但如果你用了 System.exit() 或者应用被 kill -9,那就没得商量。线上规范是我们对优雅停机流程做了配置,确保发布时先摘流量再停机,给后台消费线程留足时间把缓冲区的日志写完。
5. 日志治理的长尾问题与个人体会
5.1 日志量依然要控制:异步不是无敌的
异步日志解决了锁竞争问题,但它本质上只是把日志写入延迟到了后台,并没有减少日志总量。磁盘写入压力、日志存储成本、采集链路带宽这些问题依然存在。如果业务同学为了排查问题随便打 DEBUG,日志量该暴涨还是暴涨,只是从"搞垮接口"变成了"悄悄吃满磁盘"。
我见过一个项目在接入 AsyncLogger 之后,因为不再担心日志拖慢接口,各种调试日志打得肆无忌惮,结果一个月内磁盘被日志文件占满,直接导致服务写不了日志,连带着文件系统出现异常。所以迁移到 Logitech 2.x 异步模式后,更要把日志分级治理提上日程:INFO 记录业务状态,DEBUG 默认关闭,高危异常用独立 Logger 文件保存。
可以给每类日志定一个上限预案。比如我们线上对单个应用每天的日志量做了统计,超过阈值就自动告警,而不是等到磁盘满了才处理。日志治理里"量"的控制,优先级其实比"异步与否"更高。
5.2 监控和告警要覆盖日志链路
很多人把日志监控等同于"日志文件有没有在写",但真正把日志当成一件正经事后,你会发现这些指标都值得关注:
- RingBuffer 队列积压水位:如果 AsyncLogger 的 RingBuffer 积压持续上升,说明后台消费速度跟不上生产速度,需要扩容或者减少日志量。
- 日志文件落盘延迟:后台线程写盘耗时变长往往是磁盘 IO 出现问题的信号。
- 日志写入失败的异常数:FileAppender 写盘失败时,如果业务线程还在同步写,会直接影响业务;异步模式下可能只是后台线程报错,不容易被前台感知。
- 每日日志总量和增长速率:这个指标要和业务量联动,如果业务量没涨、日志量却明显涨了,大概率是有人加了不合理的调试日志。
我们在迁移后给日志链路专门补了一组监控指标,和业务告警放在同一个大屏里。这样日志再出问题,不会像上次一样排查一小时才知道是日志搞的鬼。
5.3 最想分享的一点经验
那次事故之后,我最大的改变是:再也不会把日志系统当成"反正不影响业务"的配角了。日志能不能异步写、怎么异步写、异步之后怎么监控,这些是和高并发业务同等重要的架构决策。
如果你现在正被 Log4j 1.x 的锁竞争困扰,建议不要在一棵老树上缝缝补补,直接往 Log4j 2.x 迁移,用 AsyncLogger 把日志写入和业务线程彻底解耦。迁移过程没那么复杂,但一定要压测、要灰度、要盯监控,尤其注意 RingBuffer 大小、MDC 上下文和关闭流程这三个隐蔽坑。
项目上线快一年了,中间加了监控、优化了日志分级、踩过了异步丢日志的坑。现在每次看到监控大屏上 P99 稳定在 200ms 左右,我都会想起那个下午——真正动手去解决一个系统级问题之前,先弄清楚它背后是怎么工作的,比什么技巧都重要。
