Spring Boot 项目中,同一个版本的依赖,内容却不一样?一次因依赖污染导致 Redis 启动失败的排查
最近修改了一段代码,引入了 Redisson。本地运行正常,但在 Jenkins 打包并部署到 K8s 环境后,服务启动失败,接口提示 Redis 访问异常。
错误信息显示找不到类:org.springframework.data.redis.connection.RedisStreamCommands。我在 IDEA 中搜索了该类,发现当前使用的 Spring Data Redis 版本中确实不存在这个类,但本地程序却能正常启动。

为了进一步排查,我通过添加 JVM 参数 -Xdebug -Xrunjdwp:transport=dt_socket,address=5005,server=y,suspend=n 进行远程调试。发现线上环境确实尝试加载了 RedisStreamCommands 类,而本地程序并未触发该行为。
由此怀疑:Jenkins 构建出的 jar 包与本地构建的版本存在差异。
于是决定对比两个 jar 包中 BOOT-INF/lib 目录下的依赖。果然发现依赖数量不一致。由于涉及多个依赖项,让AI编写了一个工具类来自动对比。
对比两个 jar 包的依赖
目标是对比两个 jar 包中 BOOT-INF/lib 下的文件,包括:
- 文件数量;
- 仅一方存在的 jar;
- 同名 jar 的内容是否一致(通过 MD5 校验);
之所以校验 MD5,是因为其他部门存在不升级版本号的情况下修改 SNAPSHOT 包的内容的情况。
import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.util.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
public class ZipLibComparator {
    private static final String LIB_PATH = "BOOT-INF/lib/";
    public static void compareZipLibs(String zipPath1, String zipPath2) {
        System.out.println("=== 比较两个 ZIP 文件中 BOOT-INF/lib/ 下的文件 ===\n");
        Map<String, String> libFiles1 = extractLibFiles(zipPath1);
        Map<String, String> libFiles2 = extractLibFiles(zipPath2);
        if (libFiles1 == null || libFiles2 == null) {
            System.err.println("无法读取其中一个或两个 ZIP 文件。");
            return;
        }
        System.out.println("📁 文件1: " + zipPath1 + " → " + libFiles1.size() + " 个文件");
        System.out.println("📁 文件2: " + zipPath2 + " → " + libFiles2.size() + " 个文件\n");
        Set<String> allFileNames = new HashSet<>();
        allFileNames.addAll(libFiles1.keySet());
        allFileNames.addAll(libFiles2.keySet());
        List<String> onlyIn1 = new ArrayList<>();
        List<String> onlyIn2 = new ArrayList<>();
        List<String> differentMd5 = new ArrayList<>();
        for (String fileName : allFileNames) {
            if (!libFiles1.containsKey(fileName)) {
                onlyIn2.add(fileName);
            } else if (!libFiles2.containsKey(fileName)) {
                onlyIn1.add(fileName);
            } else {
                String md5_1 = libFiles1.get(fileName);
                String md5_2 = libFiles2.get(fileName);
                if (!md5_1.equals(md5_2)) {
                    differentMd5.add(fileName + " (MD5不同: " + md5_1 + " vs " + md5_2 + ")");
                }
            }
        }
        // 输出结果
        System.out.println("✅ 文件名相同且内容一致的文件数: " +
                           (allFileNames.size() - onlyIn1.size() - onlyIn2.size() - differentMd5.size()));
        if (!onlyIn1.isEmpty()) {
            System.out.println("\n🟡 仅在【文件1】中存在的文件 (" + onlyIn1.size() + " 个):");
            onlyIn1.forEach(System.out::println);
        }
        if (!onlyIn2.isEmpty()) {
            System.out.println("\n🔵 仅在【文件2】中存在的文件 (" + onlyIn2.size() + " 个):");
            onlyIn2.forEach(System.out::println);
        }
        if (!differentMd5.isEmpty()) {
            System.out.println("\n🔴 文件名相同但内容不同(MD5不同)的文件 (" + differentMd5.size() + " 个):");
            differentMd5.forEach(System.out::println);
        }
        if (onlyIn1.isEmpty() && onlyIn2.isEmpty() && differentMd5.isEmpty()) {
            System.out.println("\n🎉 两个 ZIP 文件中的 BOOT-INF/lib/ 内容完全一致!");
        }
    }
    private static Map<String, String> extractLibFiles(String zipPath) {
        Map<String, String> fileMd5Map = new HashMap<>();
        try (ZipFile zipFile = new ZipFile(zipPath)) {
            Enumeration<? extends ZipEntry> entries = zipFile.entries();
            while (entries.hasMoreElements()) {
                ZipEntry entry = entries.nextElement();
                String entryName = entry.getName();
                // 忽略目录,只处理文件,且路径以 BOOT-INF/lib/ 开头(忽略大小写)
                if (!entry.isDirectory() && entryName.toLowerCase().startsWith(LIB_PATH.toLowerCase())) {
                    String fileName = entryName.substring(LIB_PATH.length());
                    String md5 = computeMd5(zipFile.getInputStream(entry));
                    fileMd5Map.put(fileName, md5);
                }
            }
        } catch (IOException e) {
            System.err.println("读取 ZIP 文件失败: " + zipPath + " → " + e.getMessage());
            return null;
        }
        return fileMd5Map;
    }
    private static String computeMd5(InputStream inputStream) {
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            byte[] buffer = new byte[8192];
            int bytesRead;
            while ((bytesRead = inputStream.read(buffer)) != -1) {
                md.update(buffer, 0, bytesRead);
            }
            byte[] digest = md.digest();
            StringBuilder sb = new StringBuilder();
            for (byte b : digest) {
                sb.append(String.format("%02x", b));
            }
            return sb.toString();
        } catch (Exception e) {
            System.err.println("计算 MD5 失败: " + e.getMessage());
            return "MD5_ERROR";
        }
    }
    // 主方法用于测试
    public static void main(String[] args) {
        // 示例用例:请替换为你本地实际存在的两个 ZIP/JAR 文件路径
        String zipPath1 = "D:\\a.jar";  // ← 替换为你的第一个文件路径
        String zipPath2 = "E:\\b.jar";  // ← 替换为你的第二个文件路径
        System.out.println("🔍 开始对比两个 ZIP 文件中 BOOT-INF/lib/ 下的内容...\n");
        compareZipLibs(zipPath1, zipPath2);
    }
}
使用该工具对比本地打包和 Jenkins 打包生成的 jar 文件,发现两者差异较大。
接着,我清空了本地仓库中所有公司相关的依赖,并重新拉取。

本以为重新拉取后,本地与 Jenkins 的依赖应保持一致(Jenkins 构建时已添加 -U 参数,理论上会强制更新 SNAPSHOT 依赖),但运行结果仍与之前相同。问题仍未解决。
于是,我决定进一步对比两个 jar 包中某个不同依赖的具体文件内容。
对比整个 jar 包的文件内容
我又编写了一个工具,用于对比两个压缩包中所有文件的路径和 MD5 值。
import java.io.*;
import java.nio.file.*;
import java.security.MessageDigest;
import java.util.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
public class ZipFullComparator {
    public static void compareAllFilesInZip(String zipPath1, String zipPath2) {
        System.out.println("🔍 开始全面对比两个 ZIP 压缩包中的所有文件内容...\n");
        // 提取两个 ZIP 中的 {相对路径 -> MD5} 映射
        Map<String, String> files1 = extractAllFilesWithMd5(zipPath1);
        Map<String, String> files2 = extractAllFilesWithMd5(zipPath2);
        if (files1 == null || files2 == null) {
            System.err.println("❌ 读取 ZIP 文件失败,请检查路径是否正确或文件是否损坏。");
            return;
        }
        System.out.println("📦 压缩包1: " + zipPath1 + " → 包含 " + files1.size() + " 个文件");
        System.out.println("📦 压缩包2: " + zipPath2 + " → 包含 " + files2.size() + " 个文件\n");
        Set<String> allPaths = new HashSet<>();
        allPaths.addAll(files1.keySet());
        allPaths.addAll(files2.keySet());
        List<String> onlyIn1 = new ArrayList<>();
        List<String> onlyIn2 = new ArrayList<>();
        List<String> contentDifferent = new ArrayList<>();
        for (String path : allPaths) {
            boolean in1 = files1.containsKey(path);
            boolean in2 = files2.containsKey(path);
            if (in1 && !in2) {
                onlyIn1.add(path);
            } else if (!in1 && in2) {
                onlyIn2.add(path);
            } else if (in1 && in2) {
                String md5_1 = files1.get(path);
                String md5_2 = files2.get(path);
                if (!md5_1.equals(md5_2)) {
                    contentDifferent.add(path + " (MD5: " + md5_1 + " ≠ " + md5_2 + ")");
                }
            }
        }
        // 输出结果
        System.out.println("✅ 内容完全相同的文件数: " +
                (allPaths.size() - onlyIn1.size() - onlyIn2.size() - contentDifferent.size()));
        if (!onlyIn1.isEmpty()) {
            System.out.println("\n🟡 仅在【第一个压缩包】中存在的文件 (" + onlyIn1.size() + " 个):");
            onlyIn1.forEach(System.out::println);
        }
        if (!onlyIn2.isEmpty()) {
            System.out.println("\n🔵 仅在【第二个压缩包】中存在的文件 (" + onlyIn2.size() + " 个):");
            onlyIn2.forEach(System.out::println);
        }
        if (!contentDifferent.isEmpty()) {
            System.out.println("\n🔴 文件路径相同但内容不同(MD5不同)的文件 (" + contentDifferent.size() + " 个):");
            contentDifferent.forEach(System.out::println);
        }
        if (onlyIn1.isEmpty() && onlyIn2.isEmpty() && contentDifferent.isEmpty()) {
            System.out.println("\n🎉 两个压缩包中所有文件内容完全一致!");
        } else {
            System.out.println("\n📌 总结:发现差异文件共 " +
                    (onlyIn1.size() + onlyIn2.size() + contentDifferent.size()) + " 个。");
        }
    }
    /**
     * 遍历 ZIP 文件,提取所有非目录项的 {相对路径 -> MD5} 映射
     */
    private static Map<String, String> extractAllFilesWithMd5(String zipPath) {
        Map<String, String> pathToMd5 = new LinkedHashMap<>(); // 保持顺序便于调试
        try (ZipFile zipFile = new ZipFile(zipPath)) {
            Enumeration<? extends ZipEntry> entries = zipFile.entries();
            while (entries.hasMoreElements()) {
                ZipEntry entry = entries.nextElement();
                // 跳过目录
                if (entry.isDirectory()) {
                    continue;
                }
                String entryPath = entry.getName();
                InputStream inputStream = zipFile.getInputStream(entry);
                String md5 = computeMd5(inputStream);
                pathToMd5.put(entryPath, md5); // 注意:保留原始路径(含大小写),可用于精确对比
            }
        } catch (IOException e) {
            System.err.println("❌ 无法读取 ZIP 文件: " + zipPath + " → " + e.getMessage());
            return null;
        }
        return pathToMd5;
    }
    /**
     * 计算输入流的 MD5 值
     */
    private static String computeMd5(InputStream inputStream) {
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            byte[] buffer = new byte[8192];
            int bytesRead;
            while ((bytesRead = inputStream.read(buffer)) != -1) {
                md.update(buffer, 0, bytesRead);
            }
            byte[] digest = md.digest();
            StringBuilder sb = new StringBuilder();
            for (byte b : digest) {
                sb.append(String.format("%02x", b));
            }
            return sb.toString();
        } catch (Exception e) {
            System.err.println("⚠️  MD5 计算失败: " + e.getMessage());
            return "ERROR_MD5";
        }
    }
    // ==================== 主方法:内置测试用例 ====================
    public static void main(String[] args) {
        // 🧪 === 🔔 替换为你本地实际存在的两个 ZIP/JAR 文件路径 ===
        String zipFile1 = "D:\\a-SNAPSHOT.jar";   // ← 修改为你的第一个压缩包路径
        String zipFile2 = "D:\\l\\a.jar";   // ← 修改为你的第二个压缩包路径
        // 检查文件是否存在
        File f1 = new File(zipFile1);
        File f2 = new File(zipFile2);
        if (!f1.exists()) {
            System.err.println("❌ 文件不存在: " + zipFile1);
            return;
        }
        if (!f2.exists()) {
            System.err.println("❌ 文件不存在: " + zipFile2);
            return;
        }
        compareAllFilesInZip(zipFile1, zipFile2);
    }
}
运行后确认:两个 jar 包的文件内容确实不一致。

最后查看了 MANIFEST.MF 和 pom.properties 文件,发现问题根源:同一个版本的依赖包,由不同人构建,时间相差两个月,且代码内容不同。这正是依赖版本管理混乱的典型后果——不应在不升级版本号的情况下修改 SNAPSHOT 包。
进一步核对私服上的依赖,发现本地拉取的版本与私服一致。因此推测:Jenkins 构建时可能从某个缓存或中间仓库拉取了旧版本依赖,尽管使用了 -U 参数,但仍未能强制更新。
由于 Jenkins 所用 Maven 仓库服务器权限受限,无法直接清理,只能联系运维协助删除旧依赖。
这个问题可能早已存在,只是此次恰好引发了运行时异常。开发过程中规范依赖管理非常重要,否则会耗费大量时间在排查此类问题上。本次经历也算是一次有价值的教训。
 
                
            
         
         浙公网安备 33010602011771号
浙公网安备 33010602011771号