经由同个文件多次压缩的文件MD5都不一样问题排查,感慨AI的强大!

开心一刻

今天点了个外卖:牛肉炒饭

外卖到了后,发现并没有牛肉,我找商家理论

我:老板,这个牛肉炒饭的配菜是哪些?

商家:青菜 豆芽 火腿 鸡蛋 葱花

我:没有牛肉?

商家:亲,没有的哦

我:我点的牛肉炒饭没有牛肉,你这不是虚假宣传?

商家:亲,你误会了,牛肉是我们的厨师名字!

问题描述

先跟大家统一一个概念:文件的MD5,它是一种用于验证文件完整性的哈希值,一个文件的MD5值是固定的 。文件的MD5值获取方式有多种,Linux 下可以通过 md5sum 命令获取

shell 复制代码
[root@k8s-master opt]# md5sum run.sh

Win 下则通过 certutil 命令获取

bat 复制代码
D:\>certutil -hashfile run.sh MD5

各个开发语言也有对应的获取方式,例如 Java

java 复制代码
/**
 * 通过 JDK 获取文件的MD5
 * @author 青石路
 */
public static String getFileMd5ByJdk(Path path) throws Exception {
    MessageDigest digest = MessageDigest.getInstance("MD5");
    try (InputStream fis = Files.newInputStream(path)) {
        byte[] byteArray = new byte[1024];
        int bytesCount = 0;
        while ((bytesCount = fis.read(byteArray)) != -1) {
            digest.update(byteArray, 0, bytesCount);
        }
    }
    byte[] bytes = digest.digest();
    StringBuilder sb = new StringBuilder();
    for (byte b : bytes) {
        sb.append(String.format("%02x", b));
    }
    return sb.toString();
}

但是我们通常会用第三方组件或框架来实现,例如

  1. Apache Commons Codec

    java 复制代码
    /**
     * 通过 Apache Commons Codec 获取文件的MD5
     * @author 青石路
     */
    public static String getFileMd5ByCodec(Path path) throws IOException {
        try(InputStream is = Files.newInputStream(path)) {
            return DigestUtils.md5Hex(is);
        }
    }
  2. Guava

    java 复制代码
    /**
     * 通过 Guava 获取文件的MD5
     * @author 青石路
     */
    public static String getFileMd5ByGuava(Path path) throws IOException {
        return com.google.common.io.Files.hash(path.toFile(), Hashing.md5()).toString();
    }
  3. Hutool

    java 复制代码
    /**
     * 通过 Spring 获取文件的MD5
     * @author 青石路
     */
    public static String getFileMd5BySpring(Path path) throws IOException {
        try(InputStream is = Files.newInputStream(path)) {
            return org.springframework.util.DigestUtils.md5DigestAsHex(is);
        }
    }
  4. Spring

    java 复制代码
    /**
     * 通过 Hutool 获取文件的MD5
     * @author 青石路
     */
    public static String getFileMd5ByHutool(Path path) throws IOException {
        try(InputStream is = Files.newInputStream(path)) {
            return DigestUtil.md5Hex(is);
        }
    }

这些方式获取的 MD5 值都是一致的,都是 cf51e1e40cd1964827bf02916231be85

至此,相信你们对 文件的MD5 都理解了;接下来回到正题,我先复现下问题,既然是压缩,那就把压缩代码整起来,基于 zip4j 实现 zip 压缩

  1. 引入依赖

    xml 复制代码
    <dependency>
        <groupId>net.lingala.zip4j</groupId>
        <artifactId>zip4j</artifactId>
        <version>2.11.3</version>
    </dependency>
  2. 实现 zip 压缩

    java 复制代码
    /**
     * zip 压缩
     * @author 青石路
     * @param destFilePath 压缩文件路径
     * @param sources 源文件列表
     * @throws IOException 压缩异常
     */
    public static void compressZip(String destFilePath, List<File> sources) throws IOException {
        try(ZipFile zipFile = new ZipFile(destFilePath)) {
            for (File sourceFile : sources) {
                ZipParameters param = new ZipParameters();
                param.setCompressionMethod(CompressionMethod.DEFLATE);
                param.setCompressionLevel(CompressionLevel.NORMAL);
                param.setFileNameInZip(sourceFile.getName());
                try (FileInputStream is = new FileInputStream(sourceFile)) {
                    zipFile.addStream(is, param);
                }
            }
        }
    }

代码很简单,相信你们都能看懂;照理来说,只要源文件列表(sources)的顺序是固定的,那么压缩之后得到的zip包文件的MD5就应该是一致的,对不对?我们来看一个案例

java 复制代码
public static void main(String[] args) throws Exception {
    List<File> sources = new ArrayList<>();
    sources.add(new File("D:\\run.sh"));
    sources.add(new File("D:\\hello.txt"));
    String zip1 = "D:\\qsl1.zip";
    String zip2 = "D:\\qsl2.zip";
    compressZip(zip1, sources);
    TimeUnit.SECONDS.sleep(1);
    compressZip(zip2, sources);
    System.out.println("zip1 MD5:" + getFileMd5ByCodec(Paths.get(zip1)));
    System.out.println("zip2 MD5:" + getFileMd5ByCodec(Paths.get(zip2)));
}    

这个代码你们肯定都能看懂,但我还是要强调一下

两次压缩间隔了 1 秒,是模拟实际项目中的两次压缩的时间间隔

实际项目中间隔肯定不止 1 秒,设置成 1 秒是为了达到同样效果的同时快速出结果

执行如上代码,结果如下

两个压缩包的 MD5 不一致

这是为什么?

问题排查

源文件列表 sources 是同一个(文件一致、顺序也一致),打包方法也是同一个(compressZip),为什么得到的压缩包的MD5会不一致?会不会是 Codec 组件(因为用的 getFileMd5ByCodec 方法获取的压缩包的MD5)的问题,后面切成 getFileMd5ByJdk

结果与 getFileMd5ByCodec 一致,这说明获取文件的MD5是没问题的;莫非是压缩包名的问题?这个我们可以反向验证下,同个文件复制一份,验证下复制文件的MD5与源文件的MD5是不是一致

可以看到,复制文件的MD5与源文件的MD5一致,所以问题应该出在 compressZip 上,具体出在哪,我也没有可排查的方向了;此时,换做是你们,你们会怎么排查?现在 AI 这么火热,不得问问它?

讯飞星火 给出了 4 个方向,我们逐一分析下

  1. 时间戳

    是指 ZIP 包的创建时间和修改时间

    还是指 ZIP 包中文件的创建时间和修改时间

    有待进一步分析

  2. 压缩算法版本

    这个可以排除,因为用的是同个压缩打包方法:compressZip,并且从上图可以看出,压缩算法都是:Deflate,版本都是 20

  3. 文件系统差异

    这个也可以排除,都是基于 Win10 的 FAT 文件系统

  4. 随机数或唯一标识符

    这个也可以排除,没有随机数和唯一标识

所以我们需要重点分析下时间戳,时间戳又分两个方向

  1. 压缩包的时间戳

    还记得前面压缩包名的验证吗,复制文件和源文件的MD5一致,也变相验证了文件MD5不受压缩包的 创建时间 的影响

    源文件和复制文件的 创建时间访问时间 不一致,但文件MD5一致,说明文件MD5与 创建时间访问时间 无关

    所以我们只需要验证下文件MD5是不是与压缩包的 修改时间 有关即可;很好验证,只需要修改复制文件的修改时间,然后再比较两个文件的MD5,实例代码如下

    java 复制代码
    public static void main(String[] args) throws Exception {
        /*List<File> sources = new ArrayList<>();
        sources.add(new File("D:\\run.sh"));
        sources.add(new File("D:\\hello.txt"));*/
        String zip1 = "D:\\qsl1.zip";
        String zip2 = "D:\\qsl1 - 副本.zip";
        /*compressZip(zip1, sources);
        TimeUnit.SECONDS.sleep(1);
        compressZip(zip2, sources);*/
        System.out.println("zip1 MD5:" + getFileMd5ByJdk(Paths.get(zip1)));
        File file2 = new File(zip2);
        Path path2 = file2.toPath();
        // 将副本文件的修改时间增加1分钟
        Files.setLastModifiedTime(path2, FileTime.fromMillis(file2.lastModified() + (60 * 1000)));
        System.out.println("zip2 MD5:" + getFileMd5ByJdk(path2));
    }

    执行结果如下

    所以我们可以得出结论

    压缩文件的MD5与压缩包的 修改时间 无关

    那么再结合前面的 创建时间访问时间,说明压缩包的MD5与压缩包的时间戳无关!

    引申一个问题:非压缩文件MD5是否与其时间戳有关?

  2. 压缩包中文件的时间戳

    这个很好验证,源文件打包进压缩包的时候,保留其修改时间即可,代码如下

    java 复制代码
    /**
     * zip 压缩
     * @author 青石路
     * @param destFilePath 压缩文件路径
     * @param sources 源文件列表
     * @throws IOException 压缩异常
     */
    public static void compressZip(String destFilePath, List<File> sources) throws IOException {
        try(ZipFile zipFile = new ZipFile(destFilePath)) {
            for (File sourceFile : sources) {
                ZipParameters param = new ZipParameters();
                param.setCompressionMethod(CompressionMethod.DEFLATE);
                param.setCompressionLevel(CompressionLevel.NORMAL);
                param.setFileNameInZip(sourceFile.getName());
                // 保留源文件的修改时间
                param.setLastModifiedFileTime(sourceFile.lastModified());
                try (FileInputStream is = new FileInputStream(sourceFile)) {
                    zipFile.addStream(is, param);
                }
            }
        }
    }

    删除旧压缩包后重新进行打包测试

    java 复制代码
    public static void main(String[] args) throws Exception {
        List<File> sources = new ArrayList<>();
        sources.add(new File("D:\\run.sh"));
        sources.add(new File("D:\\hello.txt"));
        String zip1 = "D:\\qsl1.zip";
        String zip2 = "D:\\qsl2.zip";
        compressZip(zip1, sources);
        // 压缩间隔1分钟
        TimeUnit.MINUTES.sleep(1);
        compressZip(zip2, sources);
        System.out.println("zip1 MD5:" + getFileMd5ByJdk(Paths.get(zip1)));
        File file2 = new File(zip2);
        Path path2 = file2.toPath();
        System.out.println("zip2 MD5:" + getFileMd5ByJdk(path2));
    }

    执行结果如下

    所以我们可以得出结论

    压缩包的MD5与压缩包中文件的 修改时间 有关

    压缩包的MD5否与压缩包中文件的 创建时间访问时间 有关,这个交由你们去验证了!

问题修复

如何修复,前面已经讲过了,就是增加一个压缩参数

param.setLastModifiedFileTime(sourceFile.lastModified());

保留源文件的 修改时间 即可;既然前面已经讲过了,为什么还要单独拿个章节来讲?仅仅只是强调下,你们要是不服,来打我呀!

小插曲

Win10 文件夹和文件的 修改日期 只显示到分钟,不显示秒

这也导致解压工具打开压缩包的界面也只显示到分钟

这很容易让我们产生错觉

两次压缩的修改时间(压缩包以及压缩包中的文件)为什么是一样的?

修改时间一致,怎么压缩包的MD5还不一致?

然后就开始自我质疑了,到底哪个环节出了问题?

如果你们看的比较细致的话,会发现我将压缩间隔时间从之前的 1 秒调整成了 1 分钟,因为我就产生了错觉,不怕你们笑话,我在这个错觉上还折腾了挺长时间!!!

另外,7z工具可以查看压缩包中文件的修改时间到秒级别

至于Win10,我始终没有折腾出文件夹和文件的 修改日期 显示到秒

总结

  1. 非压缩文件的MD5与文件的时间戳无关
  2. 压缩文件的MD5与压缩文件的时间戳无关,但与压缩包中文件的 修改时间 有关
  3. Win10 文件夹和文件的 修改日期 只显示到分钟,不显示秒,不显示秒,不显示秒!!!
  4. AI 的愈发成熟,带来了便利的同时也带来了挑战,工作经验的优势会越来越弱,35 这个坎会持续提前!!!
相关推荐
优秀的颜1 小时前
计算机基础知识(第五篇)
java·开发语言·分布式
BillKu1 小时前
Java严格模式withResolverStyle解析日期错误及解决方案
java
网安INF1 小时前
ElGamal加密算法:离散对数难题的安全基石
java·网络安全·密码学
AWS官方合作商2 小时前
在CSDN发布AWS Proton解决方案:实现云原生应用的标准化部署
java·云原生·aws
gadiaola3 小时前
【JVM】Java虚拟机(二)——垃圾回收
java·jvm
coderSong25686 小时前
Java高级 |【实验八】springboot 使用Websocket
java·spring boot·后端·websocket
Mr_Air_Boy6 小时前
SpringBoot使用dynamic配置多数据源时使用@Transactional事务在非primary的数据源上遇到的问题
java·spring boot·后端
豆沙沙包?7 小时前
2025年- H77-Lc185--45.跳跃游戏II(贪心)--Java版
java·开发语言·游戏
年老体衰按不动键盘7 小时前
快速部署和启动Vue3项目
java·javascript·vue
咖啡啡不加糖7 小时前
Redis大key产生、排查与优化实践
java·数据库·redis·后端·缓存