Maven依赖冲突终极解法:maven-shade-plugin重定位实战 📅 发布时间:2026/9/16 1:16:14 👁 浏览次数: 接手过一个老项目点开pom.xml一看同一个 jar 包赫然躺着三四个版本。编译不报错一启动就NoSuchMethodError、ClassNotFoundException满天飞查了半天找不到是谁把旧版依赖偷偷塞进来的。这种问题做 Java 后端的朋友大概率都遇到过本质上就是 Maven 在依赖管理时面对同一依赖多版本共存场景缺少最终的裁决手段。Maven 作为 Java 项目最常用的构建工具本身有一套依赖仲裁机制但在真实项目里传递依赖层层嵌套仲裁结果经常不是你想要的。这时候maven-shade-plugin就成了兜底方案。它能直接对 jar 包做缝合和重定位把一个版本改头换面塞进去和另一个版本和平共处。这篇文章我从问题成因、插件原理到完整配置、踩坑记录一次性讲清楚。1. 为什么同一个 jar 会出现多个版本——先搞清楚 Maven 依赖管理的潜规则1.1 Maven 到底是干嘛的很多人用了几年 Maven还是只停留在它能帮我下 jar 包的层面。Maven 核心做两件事一是依赖管理二是构建管理。依赖管理指的是你不需要自己把 jar 包下载好丢进lib目录只需要在pom.xml里声明坐标groupId、artifactId、versionMaven 会从中央仓库或私有仓库把包拉下来。构建管理则是把源码编译、打包、测试、发布这些流程标准化实现一条命令构建项目。但 Maven 的依赖管理不是简单的你要什么就给你什么它有一套传递依赖机制。比如你的项目引入了 A 包A 包内部又引用了 B 包那么 B 也会被自动带进来。这就是为什么你明明只声明了几个依赖最后mvn dependency:tree一执行能列出一大串。1.2 多版本依赖是怎么被传染的传递依赖是好东西省去了自己手动找底层依赖的麻烦但副作用也在这里爆发。举个最常见的场景你的项目直接依赖了common-lib:1.0同时你又引入了 SDK-ASDK-A 内部依赖了common-lib:0.9另一个模块 SDK-B 内部却依赖了common-lib:1.2三个版本就这么同时出现了。Maven 在解析时不会把所有版本都装进 ClassPath它只会选出一个优胜者。选择逻辑有两条核心规则路径最短者优先谁在依赖树里离根项目最近谁就获胜。声明顺序优先如果路径深度一样就按dependencies里的声明顺序先声明的获胜。所以即使pom.xml里直接声明了common-lib:1.0如果某个 SDK 把common-lib:1.2通过更深路径传递进来依然有可能被仲裁掉。问题来了编译期用的可能是 1.2 的类因为 IDE 索引到了当前本地的某个版本但运行时 ClassPath 里实际加载的是另一个版本接口变化、方法签名变更就直接表现为NoSuchMethodError。这类问题最恶心的点在于mvn compile是成功的启动时才炸让人一脸懵。1.3 常规解决手段为什么有时候不够用面对多版本冲突很多人第一反应是手动排除依赖或者用dependencyManagement强制锁定版本。这几个招数确实能解决大部分问题但也存在局限。依赖排除exclusion可以在某个dependency里排除掉该死的传递依赖。麻烦在于依赖多了之后你根本不知道应该在哪一层去 exclude有时候排除一个另一个 SDK 又带进来一个新版本按下葫芦浮起瓢。dependencyManagement 统一版本可以在父 POM 里锁定所有依赖版本号让传递依赖都被压到指定版本。但如果两个 SDK 硬编码依赖了互不兼容的版本比如一个必须要旧版的commons-beanutils另一个操纵新版的 API你没法同时满足两边。新版 API 无法向下兼容比如某个框架强行要求使用新版本而老代码在类路径上被新版本破坏。这种场景下无论怎么调仲裁规则冲突都会存在。说到底exclusion和dependencyManagement是桥归桥路归路的思路——强行让整个 ClassPath 只保留一个版本。但如果多个版本在业务上必须共存就得换个思路让其中一个版本改名换姓后住在另一个路径下。2. maven-shade-plugin 的核心原理——打包缝合之外最厉害的是relocation2.1 插件的底层逻辑maven-shade-plugin是 Maven 官方维护的一个打包插件它最强的能力有两个把多个 jar 合并成一个 fat jar以及对类做 relocation重定位。合并 jar 的概念很直观。默认的maven-jar-plugin打出来的包只包含当前项目的类依赖还是散在外面的 jar。但maven-shade-plugin会把所有声明的依赖解压开把它们的.class文件全部塞进同一个 jar 里。所以它也被很多人叫作uber-jar或fat-jar插件。relocation 才是解决多版本共存的灵魂。它可以把你指定的包路径彻底改写比如把com.alibaba.fastjson下所有的类都改写到com.internal.fastjson下。这个过程会同步修改.class文件里的字节码引用不是简单改个文件名那么简单。打完包之后类加载器看到的就是一个全新的包名和原来的那套实现完全可以共存。2.2 为什么说它是同一个 jar 包多个版本的终极解法场景复现一下你的项目里因为历史原因大量老代码直接调用httpclient:4.5的 API而公司内部新引入的一个 SDK 却必须跑在httpclient:4.3上。老代码参数调用的新版方法在 4.3 里根本不存在硬统一版本则新 SDK 跑不起来。这种情况下用 shade 插件做隔离是最直接的保留老代码对httpclient:4.5的正常依赖ClassPath 上还是 4.5。把httpclient:4.3打包时重定位到一个内部路径比如com.internal.httpclient。新 SDK 在字节码层面的所有org.apache.http.\*引用都被改写成了com.internal.httpclient.\*。结果就是ClassPath 上物理存在两个版本的 httpclient一个真名一个换了马甲各用各的互不干扰。2.3 画个重点relocation 的配置语法先上一个最容易理解的最小配置plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.5.1/version executions execution phasepackage/phase goals goalshade/goal /goals configuration relocations relocation patternorg.apache.http/pattern shadedPatterncom.internal.httpclient/shadedPattern /relocation /relocations /configuration /execution /executions /pluginpattern是要搬家的包路径前缀shadedPattern是搬过去之后的新路径。执行mvn package后所有org.apache.http开头的类都会被打包进com/internal/httpclient/目录同时引用这些类的地方也会被同步改写。有两点容易栽坑提前说pattern和shadedPattern匹配的是包名前缀不是精确类名。如果你想精细控制可以用includes配合exclude做白名单否则会把匹配前缀下所有包都挪走。如果项目自身代码里也出现了pattern前缀的类引用shade 插件默认会把这些引用一并改写。这是符合预期的但如果其中某些类是你故意留在原路径的就需要特别小心。2.4 小心别把所有依赖都打进 fat jar很多人第一次用 shade 插件图省事直接执行打包结果所有依赖全被并进了一个 jar 里。项目里本身就有一堆第三方库全量合并之后包体积爆炸从几 MB 变成几百 MB。多个 jar 里存在的同名资源文件互相覆盖出现各种稀奇古怪的运行时问题。无法被其他项目正常依赖因为createDependencyReducedPom默认行为会把你已经打进 fat jar 的依赖从 POM 里移除。所以常规建议是用artifactSet限定只对特定依赖做 shade而不是全量收编artifactSet includes includeorg.apache.httpcomponents:httpclient/include includeorg.apache.httpcomponents:httpcore/include /includes /artifactSet这样只把这两个目标 jar 塞进当前产物其他依赖还是走正常机制最大程度降低对整个工程的侵入。3. 从冲突检测到 jar 包缝合——一次完整的实操记录3.1 第一步用 mvn dependency:tree 找到元凶在动手配置之前先得确认到底哪些 jar 存在多版本冲突否则全是盲猜。我用得最多的命令是mvn dependency:tree -Dverbose-Dverbose会把被仲裁掉的版本也打出来这样能清楚看到哪个版本最终胜出哪些版本被丢弃了。如果项目模块很多树会非常长可以配合-Dincludes参数做过滤只查特定包mvn dependency:tree -Dincludesorg.apache.httpcomponents输出大概是下面这种感觉[INFO] - org.apache.httpcomponents:httpclient:jar:4.5.13:compile [INFO] | - org.apache.httpcomponents:httpcore:jar:4.4.13:compile [INFO] - com.example:internal-sdk:jar:1.2.3:compile [INFO] \- org.apache.httpcomponents:httpclient:jar:4.3.6:compile (version omitted for conflict)看到(version omitted for conflict)这行基本可以断定项目实际用的是 4.5.13而internal-sdk依赖的 4.3.6 被仲裁掉了。如果这个 SDK 只兼容 4.3.6冲突就实锤了。3.2 第二步决定谁改身份确定冲突之后核心决策是谁改身份我的建议是让被仲裁掉的那个版本改身份也就是把它 relocation 到新路径。因为当前 ClassPath 里正常生效的版本通常被项目主体逻辑大量引用改它等于全项目流血而 SDK 里依赖的版本只要做一次 relocation就能把 SDK 的字节码引用一起改掉影响面小得多。以刚才的场景举例SDK 内部引用org.apache.http的地方会在打包时被统一改写为com.internal.httpclient。即使 ClassPath 上存在原版httpclient:4.5.13也碰不到 SDK 内部被改写的逻辑。3.3 第三步完整 POM 配置实战下面是一份比较完整的配置包含了 relocation、artifactSet、过滤签名文件和资源合并 Transformer。这是我在实际项目中打磨出来的一个比较稳的模板build plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.5.1/version executions execution phasepackage/phase goals goalshade/goal /goals configuration createDependencyReducedPomfalse/createDependencyReducedPom artifactSet includes includeorg.apache.httpcomponents:httpclient/include includeorg.apache.httpcomponents:httpcore/include /includes /artifactSet relocations relocation patternorg.apache.http/pattern shadedPatterncom.internal.httpclient/shadedPattern /relocation /relocations filters filter artifact*:*/artifact excludes excludeMETA-INF/*.SF/exclude excludeMETA-INF/*.DSA/exclude excludeMETA-INF/*.RSA/exclude /excludes /filter /filters transformers transformer implementationorg.apache.maven.plugins.shade.resource.ServicesResourceTransformer/ transformer implementationorg.apache.maven.plugins.shade.resource.AppendingTransformer resourceMETA-INF/spring.factories/resource /transformer /transformers /configuration /execution /executions /plugin /plugins /build逐项解释一下为什么这么写createDependencyReducedPom 设为 false默认值是 true意思是把已经被 shade 的依赖从生成的 POM 里删掉。如果你这个模块最终还要发布给其他服务依赖必须设成 false否则别人拉你的 jar 时会发现httpclient没了导致运行时ClassNotFoundException。artifactSet 窄化范围只收编 httpclient 和 httpcore避免把所有依赖全打进去。filters 去掉签名文件很多 jar 打包时带META-INF/*.SF、*.DSA、*.RSA这类数字签名文件。多个 jar 合并成同一个文件时旧 jar 的签名留在里面类加载时可能触发SecurityException。所以干脆全部排除。transformers 做资源合并Java 的 SPI 机制靠META-INF/services/下的文件加载实现类直接合并 jar 会把这个文件覆盖掉ServicesResourceTransformer能把多个 jar 的同名 services 文件内容追加到一起。AppendingTransformer同理专门处理spring.factories这类需要拼接的资源文件。3.4 第四步构建并验证成果执行构建mvn clean package -DskipTests之后到target目录检查产物。我常用的验证命令是jar tf target/your-app.jar | grep com/internal/httpclient能看到一堆com/internal/httpclient/...的类说明 relocation 生效了。然后再验证 SDK 内部的引用确实被改写javap -verbose target/your-app.jar | grep com/internal/httpclient | head -20或者用更直观的方式直接反编译一个 SDK 里的类看常量池。正常情况下原本引用org/apache/http/...的地方已经被改成了com/internal/httpclient/...。最后启动项目跑一下新 SDK 相关的功能观察日志和接口调用是否正常。这一步别省因为有些坑是运行时才暴露的。4. 避坑实录使用 shade 插件常见的 6 个问题4.1 反射和字符串路径找不到类Relocation 改写的是字节码里的类引用但它不会改写字符串常量。如果项目代码里有这样的写法Class.forName(org.apache.http.conn.ssl.SSLConnectionSocketFactory)或者 Spring 的配置文件里写了org.apache.http...的全限定类名shade 插件不会帮你改字符串内容。运行时类加载器会拿着这个字符串去原路径找类结果自然是找不到。排查思路是搜索代码和配置里有没有硬编码的类全路径。遇到这种情况要么手动把字符串改成 relocation 后的新路径要么用transformers里的ResourceTransformer对特定资源文件做二次处理。这个坑隐蔽性很高它经常只在某些冷门分支或者特殊配置下被触发测试阶段发现不了上线后炸。4.2 签名文件导致 SecurityException多个 jar 合并且不过滤签名文件时旧 jar 里自带的META-INF/*.SF、*.DSA、*.RSA文件会被保留下来。JVM 的类加载器一旦发现某个 jar 包含签名就会尝试验证所有类的签名完整性。合并后的包类路径和签名记录对不上验证失败直接抛SecurityException。解决的方案就是上面配置里加的那段 filter把签名文件全部过滤掉excludeMETA-INF/*.SF/exclude excludeMETA-INF/*.DSA/exclude excludeMETA-INF/*.RSA/exclude这段 filter 写法上用的*:*意思是所有被 shade 的 artifact 都执行同样的过滤规则。也可以针对某个特定的包做精确过滤。4.3 打包后项目启动报错找不到主类如果项目是一个可执行 jar比如 Spring Boot 应用直接用默认 shade 配置打包后运行java -jar app.jar经常报no main manifest attribute。原因是合并 jar 之后没有生成包含Main-Class信息的MANIFEST.MF。解决办法是加上ManifestResourceTransformertransformer implementationorg.apache.maven.plugins.shade.resource.ManifestResourceTransformer mainClasscom.example.Application/mainClass /transformer如果你的项目用的是 Spring Boot 自己的打包插件其实不太建议再用 shade 做全量 fat jar。Spring Boot 打出来的 jar 是三层结构BOOT-INF/classes、BOOT-INF/lib、org/springframework/boot/loadershade 的合并逻辑和它不兼容容易把结构破坏掉。所以我在 Spring Boot 项目里一般只在某些特定依赖需要 relocation的场景下用 shade并且只针对少量依赖做隔离不整个打成 fat jar。4.4 createDependencyReducedPom 带来的假依赖缺失shade 插件默认会在打包后重写 POM把已经打进 fat jar 的依赖从dependencies里删除。如果这个模块同时作为依赖被其他模块引用别人通过 Maven 引入你的 jar 时就拿不到这些依赖了。所以如果 jar 最终是要发布给别的服务用的一定记得把createDependencyReducedPom设为false。遇到这个问题时很多人会一头雾水本地跑得好好的为什么别的服务一引用就报类找不到其实不是发布错了包而是 POM 里少了依赖声明。4.5 多个 jar 的同名资源文件互相覆盖Java 生态里大量使用 SPI比如META-INF/services和 Spring 的spring.factories。直接合并 jar 时这些文件默认是后写的覆盖先写的结果是某些功能模块静默失效。比如两个 jar 各自提供了不同的META-INF/services/java.sql.Driver内容合并后只剩一个驱动数据库连接直接失败还没有任何明显报错。排查这种问题很痛苦所以配置里那些 Transformer 不只是锦上添花是必须加的。4.6 relocation 之后包名改了老的 exclude 配置失效这是个反向坑。如果项目里一直用exclusions排除某个传递依赖而别人把对应版本的依赖 relocation 成了新路径你原来的 exclude 规则就够不着它了。新路径的依赖会以一个新坐标的身份出现在依赖树里需要重新加排除规则或者改dependencyManagement锁定。所以每次对依赖做 relocation都要重新跑一遍mvn dependency:tree观察有没有漏网之鱼。整理成一张速查表排查问题的时候方便对照症状可能原因解决方案NoSuchMethodError运行时才出现Maven 仲裁到旧版本编译期用新版本先查依赖树确认冲突版本再决定统一版本还是 shade relocationSecurityException启动即报jar 合并后残留签名文件过滤META-INF/*.SF、*.DSA、*.RSA服务实现类不存在SPI 文件被覆盖加ServicesResourceTransformerSpring 配置里的类路径找不到字符串常量没有随字节码改写手动替换字符串或单独处理资源文件其他服务收到 jar 后依赖缺失createDependencyReducedPom未关设为false后再发布新依赖包路径和 exclude 规则对不上relocation 改变了坐标路径重新检查依赖树更新排除规则5. 我在实操中的最终体会maven-shade-plugin用好了可以解决典型的同一个 jar 包多个版本难题但它不是银弹。我更愿意把它定位成一个隔离兜底方案——当你把dependencyManagement、exclusion、依赖收敛这种治理手段都试过仍然无法让两个版本的依赖兼容时才需要请它出场。日常开发中优先还是应该做好依赖版本的统一管理别一上来就开大招。最后再分享一个小经验无论用哪种方案动手前一定先用mvn dependency:tree把依赖树摸清楚。很多人觉得这一步浪费时间但排查依赖冲突最烧时间的阶段恰恰是没有依赖树做指引的猜谜阶段。相对而言配置插件的半小时反而不是大头。你在实际操作中遇到什么奇葩的依赖冲突也欢迎按这个配置模板去试基本上能覆盖九成以上的场景。