1. 异常现象与问题定位
先说说这个异常长什么样。你正在启动一个 Spring Boot 工程,控制台或者日志文件里突然抛出一行嵌套异常:
nested exception is java.lang.NoClassDefFoundError: redis/clients/jedis/util/SafeEncoder
后面通常还会跟着一长串 Caused by 堆栈,常见的是java.lang.ClassNotFoundException: redis.clients.jedis.util.SafeEncoder,或者干脆就是NoClassDefFoundError直接拍在你脸上。
这里有个很关键的点,很多新手第一次看到这类报错会懵:NoClassDefFoundError和ClassNotFoundException到底是不是一回事?严格来说,ClassNotFoundException是在类加载阶段,JVM 压根找不到这个类的字节码;而NoClassDefFoundError的意思是类加载器在运行时原本能加载某个类,但真正去加载它的时候却发现类定义丢失了,或者说它依赖的某个类不见了。放到这个场景里,本质是你项目里有一个组件在运行时调用了redis.clients.jedis.util.SafeEncoder这个类,但当前的 classpath 里没有这个类,或者存在多个版本的 Jedis 互相打架,加载器拿到的那个版本里根本没有这个类。
先给不熟悉的人科普一下背景。redis.clients.jedis.util.SafeEncoder是 Jedis 这个 Java 版 Redis 客户端里的一个工具类,它主要干的事情是把字节数组和字符串之间做安全的编码转换。老的 Jedis 版本里它在redis.clients.jedis.util包下面,是 Jedis 内部实现的一部分。后来 Jedis 经过重构,这个类被移到了别的位置,甚至是直接删掉了。所以当你项目里的 Spring Data Redis 或 Spring Boot Redis 依赖和实际引入的 Jedis 版本对不上时,就会出现这种诡异的问题。
这篇博文适合谁看?如果你是刚接触 Spring Boot 集成 Redis 的开发者,遇到这个报错不知道怎么处理;或者你已经在项目里排查半天了,试过换版本、加依赖但还是没解决,那这篇文章应该能给你一个相对完整的排查思路。我会从异常产生的根本原因讲起,结合实际踩坑经验,把解决步骤和注意事项一次说清楚。
2. 为什么 SafeEncoder 会消失:版本演进与依赖冲突
2.1 Jedis 版本演进中的类路径变化
要彻底理解这个异常,得先看看 Jedis 这个客户端的历史版本情况。早期使用频率很高的 Jedis 2.x 版本,内部结构相对稳定,redis.clients.jedis.util.SafeEncoder拥有明确的包名和类路径,并且大量被 Jedis 自身的序列化组件、集群模式代码引用。Spring Data Redis 在对接 Jedis 时,底层会通过一个连接工厂来创建连接,同时也会用到 Jedis 提供的一些内置工具类。
到了 Jedis 3.x 时代,官方对代码做了一次较大幅度的重构。部分内部工具类被重新组织,包结构发生了变化。SafeEncoder在 3.x 的某些版本里仍然存在,但它所依赖的方法签名、内部实现都有了调整。再到更晚的版本,这些内部工具类被进一步整合或替换。如果你的代码或者某个中间件是在 2.x 时代编译的,而运行环境里放入的是新版 Jedis,那 JVM 按照编译时的类元信息去查找redis/clients/jedis/util/SafeEncoder,自然找不到,于是直接抛NoClassDefFoundError。
用一个生活化的类比来说:你手里拿着一张旧地图,地图上标注了一家餐馆在“A大厦3层”,但这家餐馆已经搬走了,或者那栋大楼已经拆了。你按地图走过去,自然扑了个空。编译时期你的代码依赖的 Jedis 版本决定了“地图”,运行时期 classpath 里的实际 Jar 决定了“大楼现状”,两者一旦不一致,异常就来了。
2.2 实战中最常见的三类触发场景
我在实际排查中,发现这个异常高发于下面这三种典型场景。
第一种场景:Spring Boot 版本与 Jedis 版本不匹配。Spring Boot 2.x 对 Redis 的默认客户端支持中,spring-boot-starter-data-redis默认使用的是 Lettuce,但很多人会手动排除 Lettuce 再引入 Jedis,或者因为历史项目原因直接依赖 Jedis。这时候如果spring-boot-starter-data-redis的版本对应的是 Jedis 3.x 的 API 要求,而你引入的 Jedis 还是 2.9.x,就很容易触发这个问题。更常见的反向情况是,你引入了新版 Jedis 4.x,但项目里某个旧版公共模块是在 Jedis 2.x 下编译打包的,运行时两个 Jar 同时存在于 classpath,版本仲裁结果又偏向那个旧 Jar 或新 Jar 中的一个,导致类定义不完整。
第二种场景:间接依赖传递导致的 Jar 包版本失控。举一个我实际处理过的案例。某个项目里引入了一个内部封装的 Redis 工具 Jar,这个 Jar 在编译时依赖 Jedis 2.9.0。同时项目主工程又直接引入了 Jedis 3.7.0 用来做缓存操作。Maven 的依赖仲裁机制通常会选择距离项目最近的版本,也就是 3.7.0。表面上看起来版本统一了,但那个内部封装 Jar 里某些直接引用了SafeEncoder的类,在 3.7.0 的 Jar 包中已经不存在对应的类文件,运行时一旦调用就炸。这就像两个部门拿着不同版本的合同模板,签出来的文件格式对不上,最后财务那边根本没法入账。
第三种场景:构建产物没有重新打包,或者依赖没有完全刷新。这种情况在多人协作项目里非常常见。某个同事更新了 pom 文件,改了 Jedis 版本,但没有执行mvn clean,旧的 class 文件和新的依赖混在一起。或者 IDE 里没有触发依赖重新导入,本地仓库里的旧 Jar 没被清理。这种问题在本地环境反复出现,但在 CI 环境用干净的机器构建反而不会报错,因为 CI 通常会重新拉取所有依赖。
3. 排查异常根源的完整操作步骤
3.1 第一步:确认异常发生的具体调用链
拿到异常后,最重要的事情不是马上改依赖,而是先看清完整的堆栈信息。很多情况下报错信息会被 Spring 的事务代理、AOP 切面包裹住,真要定位到源头得往前翻几层。
我建议你先去日志文件里搜Caused by,把完整的嵌套异常链条复制出来。举个例子,一个比较典型的情况可能是这样的:
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'redisTemplate' defined in class path resource ... Caused by: java.lang.NoClassDefFoundError: redis/clients/jedis/util/SafeEncoder at redis.clients.jedis.Jedis.<init>(Jedis.java:...) at org.springframework.data.redis.connection.jedis.JedisConnection.<init>(JedisConnection.java:...)看到Jedis.<init>出现,基本就能确定是 Jedis 连接初始化这一步就挂了,说明问题发生在最底层,不是你的业务代码问题,而是依赖环境问题。如果堆栈里出现的是你项目里的某个类,比如com.xxx.framework.redis.RedisClient,那可能是你直接在某处调用了不兼容的 API。
3.2 第二步:用 Maven 或 Gradle 查看实际依赖树
确认异常后,最直接的办法是把依赖关系摊开来看。Maven 项目在项目根目录下执行:
mvn dependency:tree -Dincludes=redis.clients:jedis这个命令会列出当前项目解析到的 Jedis 版本,以及它是从哪个父依赖或间接依赖引入的。我看到过最离谱的情况是一个工程里同时出现了三个 Jedis 版本,分别是 2.9.0、3.7.1 和 4.3.1,分别由不同的模块引入。这种情况下 Maven 仲裁规则会选一个“最近的”版本,但其他两个版本的类在某些情况下仍然会残留,尤其是多个不同命名的 Jar 包,比如jedis-2.9.0.jar和jedis-3.7.1.jar同时出现在mvn dependency:list输出里。
Gradle 项目对应的命令是:
gradle dependencies --configuration runtimeClasspath然后在上面的输出里搜索jedis,就能看到完整的版本选择和冲突情况。这一步是整个排查过程中最核心的环节,我强烈建议你不要跳过。很多人一看到NoClassDefFoundError就直接去改版本号,改来改去运气好碰对了,运气不好反而引入了更多问题。先看依赖树,再动手改,这才是靠谱的流程。
3.3 第三步:打开 Jar 包验证类是否存在
光看依赖树还不够,因为有可能依赖树显示的版本是对的,但实际运行的 classpath 里还有一个漏网的旧 Jar。这时候就要直接去 Jar 包里找类。
找到本地的 Maven 仓库路径,默认在~/.m2/repository/redis/clients/jedis/,进入对应版本目录,执行:
jar tf jedis-3.7.1.jar | grep SafeEncoder如果输出结果为空,说明这个版本里根本没有这个类;如果有输出,说明类存在,那问题就是 Jar 包冲突,实际加载的不是这个 Jar。另外也可以用unzip -l直接查看 Jar 包内容,效果是一样的。
顺带说一个排查技巧:如果你本机有多个 Java 服务在跑,并且某个服务出现了这个问题,你可以用jinfo或者jcmd来查看进程实际使用的 classpath,例如:
jcmd <pid> VM.system_properties这个命令能打印出java.class.path属性,确认运行中的进程到底加载了哪些 Jar。有时候 IDE 里运行正常,部署到服务器就不行,就是因为在服务器上的启动脚本手动拼了 classpath,里面混入了旧 Jar。
4. 解决方案:版本统一与依赖排除实操
4.1 方案一:统一 Jedis 版本到兼容区间
确认依赖树之后,最稳妥的解法是把项目里所有用到 Jedis 的地方统一到一个兼容版本。这里的兼容版本不是随便选一个“最新版”,而是要结合你的 Spring Boot 版本和 Spring Data Redis 版本综合考虑。
如果你用的是 Spring Boot 2.x,比较稳妥的搭配是 Jedis 3.x 系列,比如 3.7.1 或者 3.8.0。如果你的 Spring Boot 是 3.x,那直接使用 Jedis 4.x 或者 5.x 会更合适。原因是 Spring Data Redis 的 Jedis 连接工厂会调用 Jedis 的公开 API,版本跨度太大时,哪怕编译能通过,运行时也会出现各种奇奇怪怪的异常。
在 Maven 里,最简单的做法是在properties节点直接声明统一版本:
<properties> <jedis.version>3.8.0</jedis.version> </properties>然后在依赖中引入:
<dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>${jedis.version}</version> </dependency>这样做的目的是在项目直接依赖层面锁死版本。但要注意,如果还有间接依赖引入旧版本,仅仅这么声明还不行,因为 Maven 仲裁可能会让间接依赖里的版本获胜,主要原因在于依赖深度不同。所以还需要配合依赖排除或者显式声明。
我个人在实际项目中的经验是:直接在dependencyManagement里统一管理 Jedis 版本是最省心的方式。无论是直接依赖还是间接依赖,只要在dependencyManagement中声明了版本,Maven 就会使用这个版本进行仲裁。
<dependencyManagement> <dependencies> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>3.8.0</version> </dependency> </dependencies> </dependencyManagement>4.2 方案二:排除旧依赖,清理冲突源
如果依赖树里显示问题出在某个间接依赖上,比如某个公共模块或者第三方 SDK 里带着旧版 Jedis,你需要在引入那个模块时把传递依赖排除掉。
举个例子,假设你的项目里有这么一段引用:
<dependency> <groupId>com.example</groupId> <artifactId>common-framework</artifactId> <version>2.1.0</version> <exclusions> <exclusion> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> </exclusion> </exclusions> </dependency>排除之后,那个模块运行时如果确实需要 Jedis,会使用项目主工程里声明的统一版本。不过这里要留个心眼:万一那个公共模块是在老版 Jedis 下编译并且直接用了SafeEncoder这类内部类,就算你排除依赖并且主工程引入了新版 Jedis,运行时还是可能因为内部类缺失而报错。这种时候光靠排除依赖解决不了,还得让公共模块升级。如果公共模块是公司内部维护的,最好推动升级改造;如果是第三方提供的,可以先联系一下,了解是否有对应新版本。
4.3 方案三:换用 Lettuce 作为默认客户端
这里插一个实际建议。如果你排查了一圈发现 Jedis 版本问题很难彻底解决,而且你的项目并没有对 Jedis 做非常深度的定制,那么直接改用 Spring Data Redis 默认的 Lettuce 客户端是最省力的方案。
Spring Boot 的spring-boot-starter-data-redis默认引入的就是 Lettuce,它在大部分场景下功能足够,支持同步、异步、响应式编程模型。而且 Lettuce 底层基于 Netty,连接管理比 Jedis 更现代,性能表现也相当不错。很多项目遇到 Jedis 版本冲突,最终都选择了直接移除 Jedis 依赖,切到 Lettuce,从此一劳永逸。
操作上很简单,如果你在 pom 里显式引入了 Jedis,直接删掉。如果项目里其他模块间接引入了,用前面提到的排除方法进行清理,然后确保spring-boot-starter-data-redis没有把 Lettuce 排除掉。改完之后,把配置里和 Jedis 相关的连接池参数检查一下,最好实际跑一遍连接和读写测试,尤其是 Redis 集群模式和哨兵模式,因为这两种模式下客户端的行为差异会比较大。
4.4 实际操作中的验证步骤
改完依赖之后,一定要做三步验证,缺一不可。
第一步,强制刷新依赖并执行干净构建。Maven 项目执行:
mvn clean compile如果编译阶段就能通过,说明依赖层面的问题基本解决了。但注意,编译通过不代表运行时没问题,因为有的类是在编译期不一定被引用,而是在运行时通过反射加载的。
第二步,启动应用并观察日志。Spring Boot 项目直接启动,重点看 Redis 相关的 Bean 是否成功创建。你可以做一个简单的健康检查,往 Redis 里写入一个 key,再读出来,确认读写链路正常。
第三步,在最终部署环境再做一次冒烟测试。有时候本地没问题,但服务器上的依赖是旧的,或者打包流程里用了maven-jar-plugin把依赖打进了 fat jar,导致实际 classpath 里有隐藏的问题。部署时建议在服务器上看一眼实际加载的 Jedis 版本,可以用java -jar启动后通过jinfo或进程日志来确认。
我踩过的一个真实坑是这样的:本地 IDE 启动一切正常,但用 Docker 打包部署后马上报NoClassDefFoundError。查了半天才发现是 Dockerfile 里通过ADD指令把整个依赖目录拷贝到镜像,而基础镜像里已经预装了旧版 Jedis 的 Jar,结果新老 Jar 同时存在于类路径中,类加载器加载了旧的那个。这种部署环境层面的问题,在本地怎么跑都复现不了,只有到了线上环境才会暴露。
5. 常见问题速查与避坑技巧
5.1 高频问题对照表
我把这个异常排查过程中最常遇到的问题整理成了一个速查表,你可以对照着自己的情况直接找答案。
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
启动时报NoClassDefFoundError且堆栈指向Jedis.<init> | Jedis 版本与 Spring Data Redis 版本不兼容 | 统一 Jedis 到兼容版本区间 |
| 同一工程出现多个 Jedis 版本 | 间接依赖传递导致版本失控 | 使用 dependencyManagement 统一版本,排除冲突依赖 |
| 本地正常,服务器或容器部署后报错 | 部署环境 classpath 残留旧 Jar | 检查启动脚本、Docker 镜像中是否存在重复依赖 |
换用新版本后出现ClassNotFoundException | 代码中引用了旧版特有的类或 API | 检查代码中是否有 Jedis 内部类直接调用,改为使用公开 API |
| 排除依赖后业务功能异常 | 公共模块依赖旧版内部类,新版无法兼顾 | 升级公共模块,或改用 Lettuce 客户端 |
这里要特别说一下表格里的最后一类情况。排除依赖后业务功能异常,通常是公共模块内部直接引用了 Jedis 的内部实现类。这种代码在编译期已经写死了类路径,你不能指望新版 Jar 还保留这些废弃类。遇到这种情况,优先推动公共模块升级,这是最优解。如果公共模块无法按时升级,临时方案是把你项目里的 Jedis 版本固定到公共模块编译时的版本,同时确保你直接使用的 API 在那个版本里也存在。这个方案不算完美,但至少能让项目先跑起来。
5.2 深度排查时的几个关键决策点
在决定具体怎么修之前,有几个决策点值得反复推敲。
先想想这个问题是不是必须用 Jedis。如果你项目的 Redis 操作都是通过 Spring Data Redis 提供的RedisTemplate完成的,代码里不会直接出现 Jedis 相关的类,那就比较推荐切到 Lettuce。原因很简单,spring-boot-starter-data-redis默认就是 Lettuce,你不用额外维护版本兼容,踩坑概率大大降低。如果代码里有大量JedisPool、JedisCluster的直接操作,那切换成本就高了,还是集中精力解决 Jedis 版本问题更划算。
再想一个问题:你的 Redis 部署架构是什么?如果是单机模式,Jedis 和 Lettuce 的差异不明显。但如果是哨兵模式或者集群模式,切换客户端时要注意连接参数的差异。比如 Jedis 的JedisPoolConfig和 Lettuce 的LettucePoolingClientConfiguration在连接池配置方式上完全不同,乱改容易引发连接泄露或者超时问题。
最后,问自己一个问题:这个异常是间歇出现还是每次启动必现?如果是必现,通常就是依赖版本不兼容或者 classpath 有问题。如果是间歇出现,那就需要考虑另一种可能:多个类加载器导致的偶发类加载失败。这种情况常见于应用服务器部署多个应用、共享库的场景。Tomcat 或者一些插件化框架下,不同模块的类加载器互相隔离,A 模块加载的 Jedis 类对 B 模块不可见,运行时就会偶发NoClassDefFoundError。这种问题就不是改 pom 能解决的了,需要排查类加载器配置。
5.3 从源头降低依赖冲突风险的两个习惯
说到底,像NoClassDefFoundError这类问题,根源往往就是依赖管理不够严谨。结合我自己这些年的经验,有两个习惯非常值得养成。
第一个习惯是每次引入第三方依赖前,先想明白它会不会传递其他依赖。尤其在引入公司内部的公共框架时,很多人嫌麻烦不看它的 pom 文件,结果一起引入了旧版的 Jedis、旧版的 Guava,后面出问题了才开始痛苦排查。建议在 pom 里对关键组件统一做版本管控,就像前面说的依赖管理一样。这个东西一开始会麻烦一点,但长期收益非常大。
第二个习惯是定期用依赖分析工具扫描项目。Maven 项目可以用mvn dependency:analyze,Gradle 项目可以装插件来做依赖报告。虽然这些工具不能直接解决版本冲突,但能帮你在问题爆发前看到异常。特别是团队里人多口杂,某个人随手在某层 pom 里加了个依赖,你根本不知道,直到线上出问题才看到真相。这种定期扫描能极大地降低排查成本。
6. 案例复盘:一个实际项目的完整排查过程
为了让你对这套方法有更直观的感受,我完整复盘一个实际处理过的案例。
当时我接手的是一个基于 Spring Boot 2.3.7 的老项目,用的是 Maven 构建,多模块结构。项目运行中每隔几次发布后启动就会报nested exception is java.lang.NoClassDefFoundError: redis/clients/jedis/util/SafeEncoder,而且只有其中一台服务器必现,其他服务器偶尔出现。
我先做的不是改代码,而是把出问题服务器的启动日志翻出来,完整打印了堆栈。随后发现异常堆栈中有业务模块common-cache的调用痕迹,说明是模块 A 里的缓存代码触发了这个问题。接着我在本地执行mvn dependency:tree -Dincludes=redis.clients:jedis,结果输出里出现了两个版本,主工程直接引入的 3.1.0 和common-cache传递进来的 2.9.0。不过 Maven 仲裁认为 3.1.0 离主工程更近,所以理论上最终的运行时依赖应该是 3.1.0。那为什么还会报错?
进一步排查发现,问题出在打包环节。项目使用的是 Spring Boot 的spring-boot-maven-plugin打 fat jar,但这个项目又单独配置了maven-assembly-plugin生成分发包,把每个模块的 Jar 和依赖 Jar 分别拷贝到 lib 目录。common-cache模块的 pom 里明确声明了 Jedis 2.9.0,并且没有设置<optional>true</optional>,导致maven-assembly-plugin在拷贝依赖时,把 2.9.0 的 Jar 和一版 3.1.0 的 Jar 同时打进了 lib。服务器启动时,classpath 里同时出现了两个版本,类加载顺序随机,于是有的机器加载到 3.1.0,有的机器加载到 2.9.0。加载到 2.9.0 的机器,由于 Spring Data Redis 的部分代码是按照 3.1.0 编译的,运行时找不到丢失的类,就触发了NoClassDefFoundError。
找到根因后,我的处理方案分两步。第一步,在根 pom 的dependencyManagement里显式声明 Jedis 版本为 3.1.0,这样所有子模块间接依赖都会解析到同一版本。第二步,修改maven-assembly-plugin的配置,在拷贝依赖时排除redis.clients:jedis,避免 Jar 包重复。改完之后,我把服务器上的 lib 目录清空,重新打包部署,连续观察了几次发布,问题没有再出现。
这个案例给我的体会是:像这种类加载异常,很多时候不是简单的“版本不对”,而是“版本在某种环境下不对”。只盯着 pom 看是不够的,要结合打包方式、部署方式、类加载顺序来做整体分析。
再补充一个排查时经常会用到的命令。如果你怀疑进程实际加载了哪个 Jar,可以这样操作:
jcmd <pid> GC.class_histogram | grep redis.clients.jedis或者在 Linux 服务器上找到进程使用的 Jar 路径:
ls -l /proc/<pid>/cwd cat /proc/<pid>/cmdline | tr '\0' '\n'/proc/<pid>/cmdline会输出该进程启动时的完整命令,其中包含-cp参数,可以确认实际 classpath 的顺序。这个方法在排查线上问题时非常有效,推荐你记住。
7. 写在最后:一次异常处理带来的思考
说实话,像NoClassDefFoundError这种问题,技术难点并不在于“改哪个配置”,而在于如何在信息不完整的情况下快速定位根因。我见过不少同行在这个问题上绕了大半天,最后靠“把 Jedis 从 2.x 升到 3.x”碰巧解决,但完全说不清为什么。这种幸运式排查,一旦换一个项目、换一个环境就行不通了。
我个人的体会是,处理依赖冲突类异常时,一定要把“依赖树”和“实际 classpath”当作两个不同维度的信息来看。依赖树告诉你的是“理论上应该用什么”,实际 classpath 告诉你的才是“运行时真的用了什么”。二者之间的差距,就是问题藏身的地方。用这个思路去做排查,方向基本不会跑偏。
最后再给一个小建议:如果你正在维护一个长期项目,建议在构建流水线里加上依赖一致性检查。具体做法是写一个脚本,在打包前自动执行mvn dependency:tree,检查是否存在同 GroupId 同 ArtifactId 不同 Version 的情况,一旦发现就让构建失败或者输出警告。这个检查成本很低,但却能帮你把大量依赖冲突问题消灭在发布之前。把这个习惯建立起来,以后再遇到这类异常,你就知道怎么一步步拆解它,而不是焦虑地在网上反复搜报错信息了。