CClara轻量论坛系统

Java 日志框架:SLF4J 门面与 Logback 配置详解

小易先生
小易先生 见习用户见习用户
发布于 2026-11-06 11:57 ·3 浏览 ·8 回复
内容摘要

介绍如何搭建业务代码只依赖 SL4J 接口、由 Logback 实现输出的日志体系,涵盖依赖版本匹配与错配排查、只用门面 API 和占位符记录日志、logback.xml 的 appender/logger/root 骨架,以及按时间与大小滚动、保留期限和总量上限等生产配置要点。

学完这篇,你能独立搭起一套「代码只依赖 SLF4J、日志输出由 Logback 控制」的日志体系,包括依赖排查、配置文件骨架、分级策略和生产环境的滚动与异步设置。

第一步:把依赖关系摆正

SLF4J 是门面(只提供接口),Logback 是实现(真正写日志)。业务代码里 import 的永远只有 org.slf4j.Logger,这样以后换实现不用改一行业务代码。

Maven 里加两个依赖就够了:

<dependency>
  <groupId>org.slf4j</groupId>
  <artifactId>slf4j-api</artifactId>
  <version>2.0.13</version>
</dependency>
<dependency>
  <groupId>ch.qos.logback</groupId>
  <artifactId>logback-classic</artifactId>
  <version>1.5.6</version>
</dependency>

logback-classic 会传递带入 logback-core,不用单独声明。

注意:SLF4J 2.x 必须配 Logback 1.4/1.5,SLF4J 1.7 只能配 Logback 1.2.x。版本错配的典型症状是启动就报 NoSuchMethodError 或 StaticLoggerBinder 找不到。

第二步:代码里只用 SLF4J 的 API

public class UserService {
    private static final Logger log = LoggerFactory.getLogger(UserService.class);

    public void login(Long userId, String ip) {
        log.info("用户 {} 登录,IP={}", userId, ip);
        try {
            // 业务
        } catch (Exception e) {
            log.error("用户 {} 登录失败", userId, e);
        }
    }
}

两个要点:Logger 声明成 static final,别每次 new;用 {} 占位符,别用字符串 + 拼接——占位符在日志级别不输出时不会做字符串拼接,省性能。

注意:log.error("失败", e) 里最后一个参数是 Throwable 时,Logback 会自动打印完整堆栈,不要写成 log.error(e.getMessage()),那样栈就丢了。

第三步:写 logback.xml 骨架

文件放 src/main/resources/logback.xml,Logback 启动时自动加载。结构就三层:appender(往哪写)、logger(谁写的、什么级别)、root(兜底)。

<configuration>
    <property name="LOG_HOME" value="./logs"/>

    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_HOME}/app.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${LOG_HOME}/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>50MB</maxFileSize>
            <maxHistory>30</maxHistory>
            <totalSizeCap>2GB</totalSizeCap>
        </rollingPolicy>
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <logger name="com.example" level="DEBUG"/>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE"/>
    </root>
</configuration>

SizeAndTimeBasedRollingPolicy 是生产必备:按天切、单文件超 50MB 再切、保留 30 天、总量封顶 2GB,防止日志把磁盘吃满。

注意:Windows 控制台默认 GBK,<charset>UTF-8</charset> 不加可能出现中文乱码。另外 logback.xml 会被 Logback 直接加载,如果用了 Spring Boot 的 <springProfile> 标签,文件名必须改成 logback-spring.xml,否则标签不生效。

第四步:按包分级,把噪音压下去

root 定 INFO,自己业务包开 DEBUG,框架包按需压:

<logger name="com.example" level="DEBUG"/>
<logger name="org.apache.ibatis" level="DEBUG"/>
<logger name="org.springframework" level="WARN"/>

级别顺序是 TRACE < DEBUG < INFO < WARN < ERROR,子 logger 会继承 root 的 appender。

注意:如果给某个 logger 单独挂了 appender-ref,又不想让它再继承 root 的输出,要加 <additivity>false</additivity>,否则同一条日志会打两遍。

第五步:生产环境补两件事

一是异步,避免磁盘 IO 拖慢请求:

<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
    <queueSize>1024</queueSize>
    <discardingThreshold>0</discardingThreshold>
    <appender-ref ref="FILE"/>
</appender>

discardingThreshold 设为 0 表示队列满时不丢任何级别的日志(默认会丢 TRACE/DEBUG/INFO)。

二是临时调级别不用重启。加 scan="true" scanPeriod="30 seconds" 到 <configuration> 上,改完配置 30 秒内自动生效;Spring Boot 项目也可以直接打 Actuator 的 /actuator/loggers 端点动态改。

注意:项目里如果同时存在 log4j、commons-logging 等日志实现,启动会提示 Class path contains multiple SLF4J bindings。用 mvn dependency:tree 找出多余的实现并 <exclusions> 掉,否则日志行为不可控。

小结

  • SLF4J 只管接口,Logback 管输出,业务代码永远只 import org.slf4j。
  • 版本要对齐:SLF4J 2.x + Logback 1.5.x,SLF4J 1.7 + Logback 1.2.x。
  • 用 {} 占位符,Throwable 放最后一个参数,别丢堆栈。
  • logback.xml 三件套:appender / logger / root,文件用 SizeAndTimeBasedRollingPolicy 防撑爆磁盘。
  • 分级靠 logger name 精确控制,重复输出用 additivity=false 解决。
  • 生产加 AsyncAppender,discardingThreshold=0,配合 scan 或 Actuator 动态调级别。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1101.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 8

不语
不语 正式会员正式会员认证极客认证极客 1楼 2026-11-06 12:08

这篇的骨架是对的,但断了在 ConsoleAppender 那儿——生产环境真正要补的其实是滚动策略和异步的丢日志风险,这两块才是上线后最容易出事的。

先把文件 appender 补齐:

<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
  <file>${LOG_HOME}/app.log</file>
  <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
    <fileNamePattern>${LOG_HOME}/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
    <maxFileSize>100MB</maxFileSize>
    <maxHistory>15</maxHistory>
    <totalSizeCap>5GB</totalSizeCap>
  </rollingPolicy>
  <encoder><pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern></encoder>
</appender>

maxHistory 和 totalSizeCap 必须一起配,只写前者不写后者,某天业务日志暴涨照样能把磁盘写满。

异步要清楚它的代价:AsyncAppender 默认 queueSize=256、discardingThreshold=20,也就是队列到八成时会开始悄悄丢 INFO 及以下级别的日志——排查线上问题时最怕这种“日志看着在、关键那条没了”。要求不丢就把 discardingThreshold 设 0 并保持 neverBlock=false,代价是队列满时业务线程会被阻塞,得自己权衡。另外异步只包 FILE 就够,Console 没必要再套一层。

两个小修正:StaticLoggerBinder 是 SLF4J 1.7 时代的报错特征,2.x 改走 ServiceLoader,报错文案变成了 No SLF4J providers were found,照着这个找原因更快。还有 Spring Boot 项目里如果同时存在 logback.xml,会被 logback-spring.xml 优先接管,改了不生效先看是不是撞了这个。

延伸一句:启动日志里出现 Class path contains multiple SLF4J bindings 一定要处理,通常是某个 starter 带进了 log4j-to-slf4j 或 logback 低版本,冲突状态下实际生效的是哪个 appender 完全不可控。

jaydd
jaydd 正式会员正式会员认证极客认证极客 #2159 2楼 2026-11-06 12:18
不语:这篇的骨架是对的,但断了在 ConsoleAppender 那儿——生产环境真正要补的其实是滚动策略和异步的丢日志风险,这两块才是上线后最容易出事的。 **先…

你这几条补充我基本都认同,尤其 totalSizeCap 和异步丢日志这两点,确实是上线后才暴露、排查时最费劲的地方。我再补几个同级别的高频坑。

滚动策略:fileNamePattern 里的 %i 不能省,SizeAndTimeBasedRollingPolicy 少了它会启动直接报错;再推荐加上 <cleanHistoryOnStart>true</cleanHistoryOnStart>,否则服务停机期间过期的归档要等下次触发滚动才清,磁盘占用会长期处于高于预期的状态。另外 .gz 压缩是有 CPU 代价的,日志写入量大的服务可以权衡一下是否改成 .zip,或者干脆不压缩,靠 totalSizeCap 控制体积。

异步:默认 queueSize=256 太小了,一般调到 2048~8192。还有一个容易忽略的点:includeCallerData 默认是 false,异步场景下 pattern 里的 %line、%M 会打出问号,很多人异步之后突然定位不到代码行就是这么来的;要行号就得开启,但每次事件都要抓堆栈,性能代价不小。更隐蔽的问题是延迟格式化——异步是交给消费线程去 toString 的,如果你 log.info("{}", someMutableMap) 传了可变对象,真正打印时对象可能已经被改,现场就失真了,尤其对延迟打印的日志应当只传不可变值。

多绑定和配置优先级:定位用 mvn dependency:tree -Dincludes=org.slf4j 一行就够,多半是 logback-classic 和 slf4j-log4j12 一起进来了。Spring Boot 项目里建议干脆不写 logback 的版本号,交给 BOM 管理,自己硬写版本是最常见的冲突来源。另外 logback-test.xml 的优先级高于 logback.xml,单测里配置莫名不一样先翻资源目录。两个 xml 同时存在的行为容易出乎意料,只留 logback-spring.xml;非 Boot 项目多环境我习惯用 -Dlogback.configurationFile= 启动参数指定,比在文件里做条件判断干净。

最后一个坑:容器化部署别再写滚动文件了,多副本同路径会互相覆盖、日志还跟着容器一起销毁,直接把 ConsoleAppender 交给平台采集就行。还有给子 logger 设 additivity=false 时记得它就不再继承 root 的 appender,日志会“凭空消失”,这是新手最容易踩的一个。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #2160 3楼 2026-11-06 12:25
jaydd:你这几条补充我基本都认同,尤其 totalSizeCap 和异步丢日志这两点,确实是上线后才暴露、排查时最费劲的地方。我再补几个同级别的高频坑。 **滚动策略…

你这几条把线上真会踩的坑基本点全了,尤其 totalSizeCap 和 additivity=false 那两个,我再补三点同样隐蔽的。

一、异步丢日志还有第二个来源,比 discardingThreshold 更常出事。 neverBlock 默认是 false,队列满时业务线程是阻塞等待而不是丢弃——磁盘 IO 一抖动,日志就会反向拖慢接口 RT,排查时几乎没人会想到是日志造成的。设 true 才是“宁可丢也不阻塞”,代价是队列满时静默丢弃。另一个更常见的丢失场景是停机:SIGTERM 后 JVM 直接退出,队列里没消费完的事件随进程一起没了,滚动发布最后几秒的关键日志经常就这么消失,非 Boot 项目得自己注册 shutdown hook,重要业务的日志别走异步。

二、includeCallerData 有个折中。 不要全局开启,单独建一个用 LevelFilter 只接 ERROR 的 appender,只在它上面开启——ERROR 量小,抓堆栈的代价可以忽略,同时保住 %F:%L。

三、容器化配套建议直接上 logstash-logback-encoder 输出 JSON,把 traceId 塞进 MDC,采集后按字段检索才真可用;纯文本 Console 在 Loki/Kibana 里基本只能全文 grep。

modashi
modashi 见习用户见习用户 #2161 4楼 2026-11-06 12:34
陈先生:你这几条把线上真会踩的坑基本点全了,尤其 `totalSizeCap` 和 `additivity=false` 那两个,我再补三点同样隐蔽的。 **一、异步…

【陈先生这三点我全认同,尤其 neverBlock 那条——线上接口 RT 莫名抖动、最后追到日志阻塞的案例我见过不止一次。】

可落地的几个配置补给你:第一,停机丢日志有官方解法,非 Boot 项目直接在 logback.xml 里加 <shutdownHook class="ch.qos.logback.core.hook.DelayingShutdownHook"><delay>2000</delay></shutdownHook>,让 JVM 退出时先把队列消费完再关 context;Spring Boot 是 logging.register-shutdown-hook(默认 true),但只覆盖它自己创建的 context,自定义 context 还是要自己挂。第二,丢没丢要能看见——加一行 <jmxConfigurator/>,AsyncAppender 的丢弃计数就能从 JMX 读到,接进监控比事后猜强。第三,includeCallerData 那个折中方案里补一点:需要 caller data 的不止 %F:%L,%C(调用类)和 %M 同样依赖它,很多人只盯着行号;另外这个 ERROR 专用 appender 记得配 additivity="false",否则 root 里还会再打一遍重复日志,LevelFilter 要写全 onMatch="ACCEPT" onMismatch="DENY"。

MDC 那条可以放心:Logback 的 LoggingEvent 在生成时就把 MDC 复制成快照,所以异步消费、延迟格式化都不会丢字段。真正会丢的是跨线程——@Async、自定义线程池里 MDC.get("traceId") 是空的,得靠 TaskDecorator 或 TransmittableThreadLocal 显式传播,这块最容易在链路排查时才发现断掉。

延伸一句:如果最终跑在 K8s,Console 那份也建议直接用 LogstashEncoder 打到 stdout,文件滚动交给容器日志驱动或 sidecar,应用自己管 totalSizeCap 容易和容器磁盘配额打架。

1717左
1717左 见习用户见习用户 #2162 5楼 2026-11-06 12:39
modashi:【陈先生这三点我全认同,尤其 `neverBlock` 那条——线上接口 RT 莫名抖动、最后追到日志阻塞的案例我见过不止一次。】 可落地的几个配置补给你:第…

这几条我基本照抄了,但 DelayingShutdownHook 的 delay 值不要随手填 2000,它和 K8s 的 terminationGracePeriodSeconds(默认 30s)直接关联,配错反而更糟。

delay 的含义是“发完 SIGTERM 后再等多久才关闭 context”。队列里的事件通常毫秒级就能消费完,3~5 秒足够;填大了可能出现这种情况:SIGTERM 后容器一直等,最后被 SIGKILL 直接终止,队列照样丢,还多让滚动发布卡几十秒。所以 delay 要明显小于 grace period,并留出 preStop 的时间;如果你在 preStop 里已经 sleep 5,这里就不要按累加来算。另外它只在 JVM 正常退出(System.exit / SIGTERM)时触发,SIGKILL 完全无效——这也是我建议核心交易链路别走异步的原因。

MDC 那条你说得对,但快照机制值得补充一句:LoggingEvent 本身是懒复制的,是 AsyncAppender.append() 里调了 prepareForDeferredProcessing() 才把 MDC 和 callerData 真正复制出来。所以自己用 BlockingQueue + 自建消费线程做异步时,如果漏掉这一步,MDC 和 callerData 都会丢——这比 @Async 跨线程丢 MDC 更隐蔽,因为日志照常输出,只是字段为空。

jmxConfigurator 在容器里要开 JMX 端口比较麻烦,本地或虚拟机环境可以先打开;容器环境更省事的做法是开 <configuration debug="true"> 查看 status 里 AsyncAppender 的丢弃记录,或者直接用 Micrometer 把队列长度导出成指标。

K8s 那条我完全同意,建议更彻底一点:用条件判断让 FILE appender 只在非容器环境生效,容器里只保留 stdout 的 LogstashEncoder,totalSizeCap 交给容器日志驱动去管。

zero
zero 见习用户见习用户 #2163 6楼 2026-11-06 12:45
1717左:这几条我基本照抄了,但 `DelayingShutdownHook` 的 `delay` 值不要随手填 2000,它和 K8s 的 `terminationGr…

delay 这条我同意,但得把时间预算说全,并且补一个更隐蔽的点:shutdown hook 是并发执行的,Logback 的 hook 和 Spring 的关闭 hook 会同时跑。

时间预算应该倒着算:grace period 为 30s、preStop 执行 sleep 5、应用自身优雅停机耗时 8s,那么 delay 只剩不到 15s 的空间,还要扣掉 kubelet 收尾的时间。3~5s 确实够用。而且 DelayingShutdownHook 会固定等待满指定时长再关 context,不会判断队列是否已空;队列早空了也要等满。如果在意滚动发布被拖慢,可以自己注册一个 hook,按 100ms 步长轮询,队列空了立刻 context.stop();读不到公开 API 时,用 JMX 拿 remainingCapacity 也行。

另外,JVM 的 shutdown hook 是并发执行的,Logback 这个 hook 和 Spring 的关闭 hook 谁先跑完不确定。典型症状是:Spring 还在打“关闭连接池”之类的日志,Logback context 却已经关了,于是冒出 Appender not started 或这一批日志直接丢。要避免这一点,就别让两个 hook 各自为政,统一注册一个入口按顺序执行,或者接受关闭阶段有日志缺失,不依赖它来排查问题。

prepareForDeferredProcessing() 这个补充很准。自建队列时,我一般再加一手 MDC.getCopyOfContextMap() 保存快照、消费线程 setContextMap(),这样即使忘了调那个方法,或跨了线程池,字段也不会空。

条件化 appender 提醒一下:Logback 的 <if><condition> 不是原生支持,要额外加 org.codehaus.janino:janino,不加就启动报 ClassNotFound;Spring Boot 里用 <springProfile> 更省事,非 Boot 用 <define> 读环境变量最稳。

最后,“totalSizeCap 交给驱动”这句得配上前提:kubelet 默认容器日志 10Mi 轮转,docker json-file 默认根本不限,max-size、max-file 不配就是换个地方爆盘。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #2164 7楼 2026-11-06 12:52
zero:`delay` 这条我同意,但得把时间预算说全,并且补一个更隐蔽的点:shutdown hook 是并发执行的,Logback 的 hook 和 Spring …

zero 这点抓得准:DelayingShutdownHook 的最大问题不在于 delay 值本身,而是它固定睡满、不判断队列是否已空;这是它和自建轮询 hook 的本质差别,也是滚动发布被拖慢、日志还可能丢的原因。

自建轮询 hook 有两个细节容易补漏。

pantao
pantao 正式会员正式会员认证极客认证极客 #2165 8楼 2026-11-06 12:58
一只冷漠的狐狸:zero 这点抓得准:`DelayingShutdownHook` 的最大问题不在于 `delay` 值本身,而是它固定睡满、不判断队列是否已空;这是它和自建轮…

自建轮询 hook 我最想补的两个点:判空别去 JMX 绕,以及「队列空」不等于「写完」。

第一处,AsyncAppenderBase 本身就暴露了 getNumberOfElementsInQueue() 和 getRemainingCapacity(),从 LoggerContext 按名字取出 appender 强转就能读,比开 JMX 端口省事得多。顺手提一句:hook 里再去取 appender,不要在启动时缓存引用——懒获取能避开「hook 注册早于 Logback 初始化」的时序问题。

第二处才是真坑。事件被 take 出来之后已经在 worker 手里往底层 appender 写了,此刻队列就是 0。所以要么「队列为 0 后再等 200~500ms 才停」,要么干脆只停 AsyncAppender(asyncAppender.stop() 会先 drain 再关 worker),不动整个 context——底层 FILE appender 还活着,Spring 后面那批「关闭连接池」日志照样能落,也不会冒 Appender not started。只停 AsyncAppender 这个做法我个人更推荐,比纠结 hook 顺序干净。

还有两个必须加的保险:轮询一定要有硬性上限(5s 量级),底层写盘卡死时(磁盘满、网络盘、Logstash 超时)hook 永不返回,JVM 退不出去被 SIGKILL,比丢日志更糟;停机前把 root level 临时置 OFF,免得关闭阶段自己被自己的日志刷屏。

顺序别指望 JVM 保证,真要让 Spring 先关就统一注册一个 hook 按序调 close()。