ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

SLF4J multiple bindings警告排查与日志依赖治理实战

SLF4J multiple bindings警告排查与日志依赖治理实战 日志这东西平时没人管一出事就是半夜被叫起来看堆栈。而SLF4J: Class path contains multiple SLF4J bindings.这句警告大概是我这几年被同事问得最多的一条启动期提示没有之一。它不像空指针那样直接把服务打挂也不像内存溢出那样有明确堆栈它就安安静静地躺在控制台最上面几行然后你的日志该出还是出只是偶尔会发现某些日志莫名其妙地少了一半或者项目里明明配了 logback 的滚动策略结果日志文件死活不滚动。这就是 Class path 上出现 multiple bindings 之后最典型的软故障表现——服务能跑但日志体系已经不是你以为的那套了。这篇东西就是围绕这一条警告展开的。我会把 SLF4J 的绑定机制从 1.7 到 2.x 两代实现讲清楚把 Maven 和 Gradle 两条线下的依赖树排查命令给全再给出排除、统一版本、桥接三类处理方案的取舍逻辑最后附上我自己踩过的坑和一张现象对照表。目标读者是那些被这条警告困扰过、或者正打算给老项目做日志治理的 Java 后端新手能照着命令一步步抄老手可以只看后面几节的经验部分。1. 先搞清楚这个警告到底在说什么1.1 SLF4J 的门面契约与绑定的真实含义SLF4J 的全称是 Simple Logging Facade for Java注意 Facade 这个词它本质上就是个门面。你在代码里写的LoggerFactory.getLogger(Xxx.class)和logger.info(...)编译期只依赖一个slf4j-api的 jar里面全是接口和抽象类没有任何一行真正往文件、控制台或者 socket 里写字的代码。真正干活的实现比如 logback、log4j2、JUL是通过一个叫绑定的东西在运行时被挂上去的。这个设计的价值在于解耦。你写的业务代码不用关心底层到底是 logback 还是 log4j2哪天公司统一日志规范要换实现理论上只改依赖不动代码——当然实际情况是要改配置文件的这是后话。问题也恰恰出在这里。SLF4J 的 API 层在设计时没法在编译期判断你到底想用哪个实现它只能在 JVM 启动、第一次调用LoggerFactory的时候去 classpath 上扫一遍看看有哪些 jar 提供了绑定。扫到一个皆大欢喜扫到两个甚至更多它没有能力替你决定留哪个于是打印警告然后按 classpath 顺序挑一个用。这就是multiple bindings的字面含义classpath 上存在不止一个日志实现向 SLF4J 注册了自己。注意区分另一种情况——multiple bindings和多重日志框架共存不是一回事后者比如项目里同时用了 JCL 和 SLF4J 两套 API那属于桥接问题的范畴后面第 3 节会讲。1.2 从 StaticLoggerBinder 到 ServiceLoader两代机制差别很大SLF4J 1.7.x 及以前绑定的实现机制非常土每个实现包里必须放一个类全限定名固定为org.slf4j.impl.StaticLoggerBinder。SLF4J 的 API 在初始化时直接尝试用反射加载这个类名加载成功就调用它的getSingleton()拿到实例。因为类名是写死的所以一个 classpath 上只要有两个 jar 都提供了这个类第二次加载的时候就会被 JVM 引到另一个同名类上——严格说不是加载失败而是 classpath 扫描拿到了多个同名资源。所以你在 1.7 环境下看到的警告长这样SLF4J: Class path contains multiple SLF4J bindings. SLF4J: Found binding in [jar:file:/.../logback-classic-1.2.11.jar!/org/slf4j/impl/StaticLoggerBinder.class] SLF4J: Found binding in [jar:file:/.../slf4j-simple-1.7.36.jar!/org/slf4j/impl/StaticLoggerBinder.class] SLF4J: See http://www.slf4j.org/codes.html#multiple_bindings for an explanation. SLF4J: Actual binding is of type [ch.qos.logback.classic.util.ContextSelectorStaticBinder]从 1.8 开始SLF4J 引入了期待已久的模块化改造到 2.0 正式切换成 Java 标准的 ServiceLoader 机制。实现方不再放StaticLoggerBinder而是在自己的 jar 里放一个META-INF/services/org.slf4j.spi.SLF4JServiceProvider文件里面写上自己 provider 的全限定类名。警告文案也换了SLF4J: Class path contains multiple SLF4J providers. SLF4J: Found provider [ch.qos.logback.classic.spi.LogbackServiceProvider...] SLF4J: Found provider [org.slf4j.simple.SimpleServiceProvider...] SLF4J: Actual provider is of type [ch.qos.logback.classic.spi.LogbackServiceProvider...]两代机制有个关键差异值得记住1.7 时代绑定类和 API 版本必须严格配套slf4j-api1.7 配 logback 1.3logback 1.3 已经切到 provider 机制是加载不到绑定的结果是静默退化成 NOP空实现你的日志会一条都不打印而且控制台上只有一行不太起眼的Failed to load class org.slf4j.impl.StaticLoggerBinder很多人会当成普通提示忽略掉。反过来slf4j-api2.0 配 logback 1.2会提示No SLF4J providers were found.同样退化 NOP。这组版本对应关系我用表格整理得更清楚一些SLF4J API 版本绑定机制匹配的实现版本不匹配时的现象1.7.xStaticLoggerBinder反射加载logback 1.2.x、log4j2 的log4j-slf4j-impl、slf4j-log4j12降级 NOP日志静默不输出2.0.x / 2.0.xServiceLoaderSLF4JServiceProviderlogback 1.3.x / 1.4.x、log4j-slf4j2-impl多 provider 警告或No SLF4J providers提示如果你的项目是 Spring Boot 2.7 及以下默认走的是 SLF4J 1.7 logback 1.2Spring Boot 3.x 换成了 SLF4J 2.0 logback 1.4。排查之前先确认这一层能省掉一半的误判。2. 依赖树排查五分钟定位多余的那个绑定2.1 Maven 侧的三条命令从粗到细排查的第一原则是不要靠肉眼翻 pom。传递依赖可能来自三层以外某个你从没听说过的工具包随手引了slf4j-simple光看自己的 pom 是永远看不出来的。Maven 环境下的第一条命令先看全貌mvn dependency:tree -Dincludesorg.slf4j-Dincludes后面跟的是groupId:artifactId:version:scope的四段式过滤只写 groupId 就表示这个组下的所有东西。跑出来的树只保留 SLF4J 相关的节点长度通常能压缩到十几行。第二条加上 verbose可以看到被调解掉的依赖mvn dependency:tree -Dverbose -Dincludesorg.slf4j-Dverbose会额外打印(version managed from 1.7.25; omitted for conflict with 1.7.36)这类注释。这个信息在判断到底谁把谁顶掉了的时候特别有用很多人只看普通 tree看到的版本是调解后的结果看不到原始诉求就会误以为某个依赖没引进来。第三条专门查所有名字里带 slf4j 的东西包括各种桥接包mvn dependency:tree -Dincludes*:slf4j-*桥接包的名字往往叫jcl-over-slf4j、log4j-over-slf4j、jul-to-slf4j它们不在org.slf4j组下其实大部分是但log4j-over-slf4j在 org.slf4j 下用通配符匹配 artifactId 更保险。输出的树长这样一眼就能看出问题[INFO] - org.springframework.boot:spring-boot-starter-web:jar:2.7.18:compile [INFO] | - org.springframework.boot:spring-boot-starter-logging:jar:2.7.18:compile [INFO] | | - ch.qos.logback:logback-classic:jar:1.2.12:compile [INFO] | | | \- (org.slf4j:slf4j-api:jar:1.7.36:compile - omitted for duplicate) [INFO] | | \- org.slf4j:jul-to-slf4j:jar:1.7.36:compile [INFO] \- com.example:legacy-util:jar:1.0.0:compile [INFO] \- org.slf4j:slf4j-simple:jar:1.7.32:compile这里的罪魁祸首是legacy-util顺手带进来的slf4j-simple它和 logback-classic 都是绑定冲突就这么产生了。2.2 Gradle 与 IDE 插件的快捷路径Gradle 的依赖树输出比 Maven 啰嗦得多因为它会按配置维度分开打。最常用的是./gradlew dependencyInsight --dependency slf4j-log4j12 --configuration runtimeClasspathdependencyInsight才是 Gradle 排查冲突的正牌工具它会告诉你这个模块为什么被选进来、被哪个版本顶掉、经过哪条路径。输出里会有一个Selection reasons段落写着Was requested : 1.7.30和By conflict resolution : between versions 1.7.30 and 1.7.36这就是 Gradle 的默认策略——同模块冲突时选最高版本和 Maven 的最短路径优先完全不同这一点在做多模块项目迁移时经常把人绕晕。./gradlew dependencies --configuration runtimeClasspath | grep -i slf4j这条是土办法但快尤其在只想看看有几个绑定时够用。IDE 层面IntelliJ IDEA 装一个 Maven Helper 插件打开 pom.xml 切到 Dependency Analyzer 标签页搜索框里敲 slf4j冲突的依赖会标红显示右键可以直接 Exclude。这个操作生成的 exclusion 代码就是标准片段比手写靠谱。Eclipse 的话有 m2e 自带的 Dependency Hierarchy 视图切到 Hierarchy 标签右键任意节点可以 Exclude Maven Artifact。2.3 学会读日志本身警告里已经写明了凶手有个细节很多人忽略SLF4J 的警告里已经把冲突的 jar 完整路径列出来了。SLF4J: Found binding in [jar:file:/C:/Users/xxx/.m2/repository/ch/qos/logback/logback-classic/1.2.12/logback-classic-1.2.12.jar!/org/slf4j/impl/StaticLoggerBinder.class] SLF4J: Found binding in [jar:file:/D:/work/libs/xxl-job-core-2.3.0.jar!/org/slf4j/impl/StaticLoggerBinder.class]看第二行路径不在本地 Maven 仓库里而是在D:/work/libs下面——这说明有个 jar 是手工放进 classpath 的或者被 Fat Jar 打包时嵌进去了。这种情况在dependency:tree里根本查不到因为它压根不来自 Maven。我遇到过一次是运维在启动脚本里手动-cp加了一个旧工具包查了两小时才发现。还有一类更隐蔽的是maven-shade-plugin或者 Spring Boot 的repackage把两个实现类都塞进了同一个 Fat Jar此时每个依赖的 jar 路径变成了jar:file:/app.jar!/BOOT-INF/lib/logback-classic-1.2.12.jar!/...同样能看出来是哪几个。注意如果你看到的是Actual binding is of type [...]这一行它告诉你的是最终选了谁而不是应该选谁。很多人的第一反应是既然选了 logback 那就没事但被顶掉的那个实现如果同时也承担了桥接职责就可能造成日志重复输出或者静默丢失。3. 动手清理排除、统一版本、桥接三套打法3.1 排除法最小改动解决冲突最直接的方案是把多余的绑定依赖排掉。判断标准很简单一个 classpath 上只允许存在一个真正的 SLF4J 绑定常见的绑定清单是这样ch.qos.logback:logback-classic隐含 slf4j-api 依赖org.apache.logging.log4j:log4j-slf4j-impl或log4j-slf4j2-implorg.slf4j:slf4j-log4j12org.slf4j:slf4j-simpleorg.slf4j:slf4j-jdk14org.slf4j:slf4j-jclMaven 里的排除写法dependency groupIdcom.example/groupId artifactIdlegacy-util/artifactId version1.0.0/version exclusions exclusion groupIdorg.slf4j/groupId artifactIdslf4j-simple/artifactId /exclusion /exclusions /dependency这里有个取舍exclusion 是写在引入方上的也就是说哪个依赖带了脏东西就在哪个依赖上排除。如果同一个脏依赖被多个模块引入就要写多份。这种情况下更好的做法是全局排除dependencyManagement dependencies dependency groupIdorg.slf4j/groupId artifactIdslf4j-simple/artifactId version0/version scopeimport/scope typepom/type /dependency /dependencies /dependencyManagement这个写法的技巧在于把版本写成0配合 import scope 和 pom 类型Maven 会认为该 artifact 无法解析从而全局排除掉。它的优点是集中、一处生效缺点是版本写 0 有点 hack团队里得注释清楚不然新人看不懂。Gradle 里全局排除更简单configurations.all { exclude group: org.slf4j, module: slf4j-simple exclude group: org.slf4j, module: slf4j-log4j12 }或者在单个依赖上排implementation(com.example:legacy-util:1.0.0) { exclude group: org.slf4j, module: slf4j-simple }3.2 统一版本把 slf4j-api 和实现锁在一个 BOM 里排除只能解决多了不该有的但实际项目里更常见的是版本对不齐。比如slf4j-api被传递依赖拉到了 1.7.25而 logback 是 1.2.12 期望配 1.7.36这种小版本错位多数时候不报错但偶尔会出NoSuchMethodError。标准解法是引入 BOM 做统一管控。Spring Boot 项目直接享受spring-boot-dependencies的管控什么都不用写只要保证所有相关依赖别单独指定版本。非 Spring Boot 项目可以显式引入dependencyManagement dependencies dependency groupIdorg.slf4j/groupId artifactIdslf4j-bom/artifactId version1.7.36/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement选版本的时候注意对齐规则SLF4J API 的主版本决定赛道。走 1.7 赛道就用 1.7.361.7 系列最后一版收尾很干净走 2.0 赛道就用 2.0.9 或更高并且实现的版本要同步切——logback 从 1.2 切到 1.3 或 1.4log4j2 的 slf4j 桥接从log4j-slf4j-impl切到log4j-slf4j2-impl。这条线我建议一次切干净别留半新半旧的中间状态。Gradle 的对应写法dependencies { implementation platform(org.slf4j:slf4j-bom:2.0.9) implementation org.slf4j:slf4j-api implementation ch.qos.logback:logback-classic }platform()就是 Gradle 版的 import scope会自动做约束传播。还有一种强制手段适合救火场景configurations.all { resolutionStrategy { force org.slf4j:slf4j-api:1.7.36 force ch.qos.logback:logback-classic:1.2.12 } }force是暴力指定优先级最高能压过所有传递依赖。它的风险在于会掩盖真实的版本冲突用的时候建议配合failOnVersionConflict()一起上先让它跑一遍看有没有意外确认无误再放开。3.3 桥接包搭配的三条红线清理完绑定冲突很多项目还会顺手做日志统一把所有日志 API 都导向 SLF4J。这时候桥接包的搭配就有讲究了配错会造成栈溢出级别的灾难。第一条红线log4j-over-slf4j不能和slf4j-log4j12、log4j:log4j同时存在。前者的作用是把对 Log4j 1.x API 的调用转成 SLF4J 调用后者是把 SLF4J 调用转成 Log4j 调用。两者一碰调用链变成 A 转 B、B 转 A 的无限循环跑起来就是StackOverflowError而且堆栈全是 Logger 相关看着特别迷惑。第二条红线log4j-to-slf4j不能和log4j-slf4j-impl共存。这是 Log4j2 时代的版本原理和上面一样一个是 API 到 SLF4J一个是 SLF4J 到 API互相转圈。第三条红线jcl-over-slf4j和commons-logging只能留一个。这俩提供的类名完全一样都是org.apache.commons.logging.LogFactory放在一起就是同名类冲突加载顺序决定行为结果随机。基于这三条红线一个干净的日志依赖组合大概是这样场景保留排除Spring Boot 默认logback-classic、jul-to-slf4j、log4j-to-slf4jcommons-logging、slf4j-log4j12、log4j:log4j换 Log4j2log4j-slf4j2-impl、log4j-core、log4j-apispring-boot-starter-logging、logback-classic、log4j-to-slf4j老项目统一到 SLF4Jlogback-classic、jcl-over-slf4j、log4j-over-slf4jcommons-logging、log4j:log4j、slf4j-log4j12Spring Boot 换 Log4j2 的经典写法是把 starter-logging 排掉dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId exclusions exclusion groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-logging/artifactId /exclusion /exclusions /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-log4j2/artifactId /dependency如果项目里有多个 starter 依赖每个都得排一遍漏一个 logback 就又回来了。我一般的做法是直接看dependency:tree里 logback-classic 的路径把所有带它的父节点都列出来一次性排干净。4. 常见问题与排查技巧实录4.1 一张现象对照表从症状反推原因排查日志问题最忌讳盲目试错先看现象再定位能省很多时间。下表是我这些年攒下来的经验对应关系覆盖了绝大多数场景现象大概率原因处理方向启动打印 multiple bindings 警告日志正常classpath 有两个绑定被选中的那个恰好是想要的排掉多余的消除警告防患于未然启动打印警告日志格式和 logback.xml 里配的不一样被选中的是slf4j-simple等其他实现排除非目标绑定确认生效的实现日志一条都不出控制台只有Failed to load class StaticLoggerBinderAPI 与绑定版本跨了大版本对齐到同一赛道1.7 配 1.2.x logback日志一条都不出提示No SLF4J providers were found只引了 api没引实现补上 logback-classic 或对应实现日志重复输出两遍桥接包和原实现共存事件被转发两次检查 jcl-over-slf4j 与 commons-logging启动直接 StackOverflowError桥接方向互转形成死循环拆掉红线组合中的一方单元测试里日志正常打包后不出打包插件把多个绑定塞进 Fat Jar在 shade 或 repackage 中过滤只在某个特定机器上警告classpath 里有手工放入的旧 jar看警告里的绝对路径找非仓库路径看这张表的时候注意一个反直觉的点multiple bindings 警告本身并不总是意味着有问题。如果被选中的实现恰好是你想要的那个功能是正常的只是警告碍眼。但它的隐患在于选择顺序取决于 classpath 顺序而 classpath 顺序在不同环境、不同构建工具、不同容器下可能不一样——今天在你机器上选 logback明天在 CI 上可能就选了 slf4j-simple然后就出事故了。所以我的建议是见到就修别留。4.2 几个最容易踩的坑第一个坑测试 scope 的绑定也能触发警告。有些人觉得slf4j-simple是 test scope 的不影响生产就懒得管。但实际上如果你用的是spring-boot-maven-plugin的 repackagetest scope 确实不会打进去可如果是maven-assembly-plugin配了jar-with-dependencies或者手写了一个把所有依赖都打进去的脚本test scope 也可能被裹进去。更麻烦的是单元测试的 classpath如果测试期间用的是 simple 实现那测试断言里关于日志输出的部分全是不可靠的。第二个坑Maven 插件的 classpath 和项目 classpath 是两回事。有次我遇到一个项目dependency:tree干干净净但执行某个插件的时候还是报multiple bindings。原因是那个插件自身的依赖里带了绑定插件的 classpath 和项目的是隔离的排查得用mvn dependency:resolve-plugins -DincludeGroupIdsorg.slf4j或者直接在插件声明里加 exclusions。第三个坑容器共享类加载器。如果是把 jar 部署到 Tomcat 的lib目录下不是WEB-INF/lib那些 jar 会被所有应用共享别的应用带进来的绑定会影响到你。这种情况排查要跳出项目本身看容器的 lib 目录。第四个坑ServiceLoader在 2.x 下有个坑多个 provider 时会选第一个找到的但顺序不确定。2.0 提供了显式指定的后门java -Dslf4j.providerch.qos.logback.classic.spi.LogbackServiceProvider -jar app.jar这个参数适合应急——生产环境不方便重新打包时改一行启动参数就能把实现锁死。但长期方案还是把多余的依赖清干净。第五个坑IDE 里不报命令行报。IDEA 运行时会自动把模块依赖做一个 classpath有时候和 Maven 命令行算出来的不一致尤其是用了providedscope 或者有 profile 激活的时候。遇到这种IDE 正常、打包异常的情况第一件事是用mvn dependency:build-classpath -Dmdep.outputFilecp.txt把真实 classpath 导出来对比而不是在 IDE 里反复点。4.3 把检查固化进构建流程人工排查一次两次可以但团队大了、模块多了靠人盯是不现实的。我这里有两个低成本的做法。做法一用 maven-enforcer-plugin 在构建期直接失败。配置一条 bannedDependencies 规则plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-enforcer-plugin/artifactId version3.4.1/version executions execution idban-duplicate-slf4j-bindings/id goals goalenforce/goal /goals configuration rules bannedDependencies excludes excludeorg.slf4j:slf4j-log4j12/exclude excludeorg.slf4j:slf4j-simple/exclude excludeorg.slf4j:slf4j-jdk14/exclude excludecommons-logging:commons-logging/exclude excludelog4j:log4j/exclude /excludes message检测到禁用的日志依赖请检查是否引入了多余的 SLF4J 绑定/message /bannedDependencies /rules /configuration /execution /executions /plugin这样一旦有人不小心引入了slf4j-simpleCI 直接红比等到生产环境发现日志不对要早得多。注意 excludes 是禁止出现的意思所以只列你确定不要的那些别把 logback 也列进去。做法二单独写一个依赖检查的测试用例。思路是在测试代码里读 classpath 上的绑定资源数量大于 1 就 failTest void shouldHaveExactlyOneSlf4jBinding() throws Exception { EnumerationURL bindings getClass().getClassLoader().getResources(org/slf4j/impl/StaticLoggerBinder.class); ListURL list Collections.list(bindings); assertEquals(classpath 上存在多个 SLF4J 绑定: list, 1, list.size()); }2.x 的版本把资源名换成META-INF/services/org.slf4j.spi.SLF4JServiceProvider即可。这种做法的好处是把检查放在最贴近代码的地方谁改坏了谁自己修比在构建脚本里兜底更直观。提示如果你的项目同时存在 1.7 和 2.x 两套检查需求比如多模块里老模块还没升级可以按模块配置两份别在根项目里混着写。5. 我在实际处理这类问题时的一些体会第一件事是别急着动手删依赖。我见过太多人看到警告就直接把slf4j-simple从 pom 里手写删掉结果发现根本没引它删除当然无效白白浪费时间。先跑dependency:tree先看警告里的路径把凶手确认下来再动手这一步花不了两分钟能省掉半小时的瞎试。第二件事是给老项目做日志治理的时候尽量一次只改一个变量。同一次提交里既换 API 版本又换实现又加桥接出问题的时候根本不知道是哪一步导致的。我的习惯是先统一版本对齐跑一轮完整回归再排多余的绑定再跑一轮最后才动桥接。每一步都能单独回滚这样万一线上出事回退的范围很清晰。第三件事也是我觉得最值得分享的一条警告里的那两行 jar 路径一定要仔细看它们是不是来自同一个 classpath 层级。有一种情况是父项目里配了 exclusion但子模块自己又引了一遍Maven 的 dependencyManagement 只管版本不管有没有所以父项目的排除对子模块的传递依赖不一定生效。这种场景下要在子模块的 pom 里再排一次或者干脆用dependencyManagement里那种版本写0的全局排除写法一次搞定所有层级。这个坑我在三个不同的项目里踩过几乎每次都要重新想起来一遍。最后留一句给正在做技术选型的人如果你的项目还在用slf4j-log4j12这套组合趁着做日志治理考虑顺势切到 logback 或者 log4j2 的新版本。Log4j 1.x 早就停止维护了继续留着它除了 multiple bindings 之外还会有别的麻烦在后面等着。
返回列表