经由同个文件多次压缩的文件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 这个坎会持续提前!!!
相关推荐
RainbowSea18 分钟前
130道基础OJ编程题之: 78~88
java
松树戈28 分钟前
IDEA Commit 模态提交界面关闭VS开启对比
java·ide·intellij-idea
谦行32 分钟前
前端视角 Java Web 入门手册 4.4:Web 开发基础—— Listener
java·后端
jk_10143 分钟前
MATLAB中strip函数用法
java·服务器·数据库
一弓虽1 小时前
maven学习
java·学习·github·maven
24k小善1 小时前
Flink Forward Asia 2024 大会 内容整理
java·大数据·flink
xiaozaq1 小时前
在Eclipse中安装Lombok插件
java·python·eclipse
非优秀程序员1 小时前
使用Python给自己网站生成llms.txt
人工智能·后端·架构
尘鹄1 小时前
一文讲懂Go语言如何使用配置文件连接数据库
开发语言·数据库·后端·golang
benben0441 小时前
Django小白级开发入门
后端·python·django