技术-文件哈希

95 阅读7分钟

文件哈希

1. 简介

作为后端,在一些场景中需要提供 文件上传 的功能,如上传视频、上传用户头像。

这里有一个 典型 的技术方案:

  1. 前端将大文件进行 分片
  2. 前端 发送分片 给后端。
  3. 前端给后端发送 合并分片 请求。

一般来说,前端传递分片时需要携带 文件哈希值,作用是 区分文件,这里有一个前提条件——不同文件的哈希值不同

注:本文我将使用 md5 哈希算法,但这个哈希算法的效率和安全性都不是很好,可以用其他更高效且安全的哈希算法替换 md5。

作为后端,不能绝对信任前端的校验,我们应该再次计算分片的哈希并对比前端传过来的哈希值,尽管前端已经正确地计算过了,本文将介绍 如何高效地计算文件哈希值

2. 普通哈希算法

2.1 思想

普通哈希算法很好理解,就是把文件读到内存中,然后使用库里的函数计算哈希值。

2.2 具体实现

具体实现时需要注意 不能将文件一次性读入到内存,这样会 浪费很多内存

  1. 将文件部分内容读取到内存中。
  2. 调用 md5 对象的 Write 方法。
  3. 继续读取部分内容,并调用 Write 方法,直到将整个文件都读取完毕。
  4. 调用 md5 对象的 Sum 方法计算文件哈希。

2.3 代码

import (
    "crypto/md5"
    "encoding/hex"
    "io"
    "os"
)

func Hash(filePath string) (string, error) {
    file, err := os.Open(filePath)
    if err != nil {
        return "", nil
    }
    defer file.Close()

    hash := md5.New()

    buffer := make([]byte, 4096)
    for {
        n, err := file.Read(buffer)
        if err != nil {
            if err == io.EOF {
                break
            }
            return "", nil
        }
        hash.Write(buffer[:n])
    }

    md5Bytes := hash.Sum(nil)
    return hex.EncodeToString(md5Bytes), nil
}

3. 默克尔树

3.1 思想

要想获得更高的性能,就需要了解更多思想默克尔树(Merkle Tree) 是一种高效的哈希算法,整体呈树状:

  • 叶子节点 对应分片的哈希
  • 非叶子节点 是其两个子节点哈希的拼接哈希
  • 根节点 就是整个文件的哈希

为什么说它更加高效?因为计算叶子节点的分片哈希时,可以用多线程来缩短计算时间,从而压榨多核处理器的性能(下文中提到的线程在 go 中可以理解为 goroutine)。

3.2 具体实现

实现默克尔树分为如下两步:

3.2.1 多线程计算分片哈希

将文件分成若干片,每个分片对应一个线程,每个线程需要执行下面的任务:

  • 将文件分片读取到内存中。
  • 计算这个分片的哈希值,将其存入指定数组。
3.2.2 单线程两两合并哈希

从叶子节点那一层开始,两两合并到上一层,每层合并完之后,再合并上一层,直到当前层只剩一个节点(根节点)为止。

假设一共有 5 个节点,那么合并的过程就是这样的:

图示两两合并

3.3 代码

import (
    "crypto/md5"
    "encoding/hex"
    "os"
    "sync"
)

// chunkSize 是分片大小的最大值,我这里设置成 1Mi 了
const chunkSize = 1 * 1024 * 1024

func hashChunk(data []byte) []byte {
    h := md5.New()
    h.Write(data)
    return h.Sum(nil)
}

func mergeHashes(a, b []byte) []byte {
    return hashChunk(append(a, b...))
}

func MerkleHash(filePath string) (string, error) {
    file, err := os.Open(filePath)
    if err != nil {
        return "", err
    }
    defer file.Close()

    fileInfo, _ := file.Stat()
    fileSize := fileInfo.Size()
    numChunks := (int(fileSize) + chunkSize - 1) / chunkSize

    // 多线程计算分片哈希
    leafHashes := make([][]byte, numChunks)
    var wg sync.WaitGroup
    wg.Add(numChunks)
    for i := 0; i < numChunks; i++ {
        go func(idx int) {
            defer wg.Done()

            offset := int64(idx * chunkSize)
            size := int64(chunkSize)
            if offset+size > fileSize {
                size = fileSize - offset
            }

            buffer := make([]byte, size)
            _, err := file.ReadAt(buffer, offset)
            if err != nil {
                panic(err)
            }

            leafHashes[idx] = hashChunk(buffer)
        }(i)
    }
    wg.Wait()

    // 单线程两两合并哈希
    currLevel := leafHashes
    for len(currLevel) > 1 {
        nextLevel := make([][]byte, (len(currLevel)+1)/2)
        for i := 0; i < len(currLevel); i += 2 {
            if i+1 < len(currLevel) {
                // 如果剩余大于一个节点
                nextLevel[i/2] = mergeHashes(currLevel[i], currLevel[i+1])
            } else {
                // 如果只剩余一个节点
                nextLevel[(i+1)/2] = currLevel[i]
            }
        }
        currLevel = nextLevel
    }

    rootHash := currLevel[0]
    return hex.EncodeToString(rootHash), nil
}

3.4 注意事项

默克尔树 和 普通哈希算法 计算得到的文件哈希值是不同的,这是因为 默克尔树在合并节点的哈希时,对两个节点的哈希值又进行了一次哈希操作。假设普通哈希算法是 hash(1, 2, 3, 4, 5),那么默克尔树就是 hash(hash(hash(hash(1), hash(2)), hash(hash(3), hash(4))), hash(5))(其中,1 等数字代表一份字节数组的数据,, 表示将两份哈希值组合到一起)。

所以说,如果后端使用了默克尔树计算整个文件的哈希值,那么前端也应该使用默克尔树计算整个文件的哈希值(可用 Web Worker 来运行多个计算分片哈希的任务)。

4. 性能对比

只是理论上的了解远远不够,性能的对比一定需要经过压测

4.1 基准测试代码

go 语言的 testing 包提供了很便捷的测试功能——testing.B,基准测试代码如下:


import (
    "testing"
)

var testCases = []struct {
    name     string
    filePath string
}{
    {"size=1Mi", "?"}, // 1Mi 大小的文件路径
    {"size=2Mi", "?"}, // 2Mi 大小的文件路径
    {"size=5Mi", "?"}, // 5Mi 大小的文件路径
    {"size=10Mi", "?"}, // 10Mi 大小的文件路径
    {"size=100Mi", "?"}, // 100Mi 大小的文件路径
    {"size=1Gi", "?"}, // 1Gi 大小的文件路径
}

func BenchmarkMerkleHash(b *testing.B) {
    for _, tc := range testCases {
        filePath := tc.filePath
        b.Run(tc.name, func(b *testing.B) {
            b.ResetTimer()

            for i := 0; i < b.N; i++ {
                // TODO 记得导入 MerkleHash 函数所在的包
                _, err := ?.MerkleHash(filePath)
                if err != nil {
                    b.Fatal(err)
                }
            }
        })
    }
}

func BenchmarkHash(b *testing.B) {
    for _, tc := range testCases {
        filePath := tc.filePath
        b.Run(tc.name, func(b *testing.B) {
            b.ResetTimer()

            for i := 0; i < b.N; i++ {
                // TODO 记得导入 Hash 函数所在的包
                _, err := ?.Hash(filePath)
                if err != nil {
                    b.Fatal(err)
                }
            }
        })
    }
}

4.2 基准测试结果

$ go test -bench=. -benchmem
goos: darwin
goarch: arm64
pkg: xxxxxxxxxxxxxxxx/xxxx/xxxx
BenchmarkMerkleHash/size=1Mi-16         	     778	   1401939 ns/op	 1049143 B/op	      12 allocs/op
BenchmarkMerkleHash/size=2Mi-16         	     777	   1540564 ns/op	 2098019 B/op	      19 allocs/op
BenchmarkMerkleHash/size=5Mi-16         	     478	   2264224 ns/op	 5244483 B/op	      39 allocs/op
BenchmarkMerkleHash/size=10Mi-16        	     333	   3573544 ns/op	10488388 B/op	      70 allocs/op
BenchmarkMerkleHash/size=100Mi-16       	      81	  16138530 ns/op	104880525 B/op	     624 allocs/op
BenchmarkMerkleHash/size=1Gi-16         	       8	 136006370 ns/op	1073951728 B/op	    6193 allocs/op
BenchmarkHash/size=1Mi-16               	     847	   1409342 ns/op	     216 B/op	       6 allocs/op
BenchmarkHash/size=2Mi-16               	     426	   2786960 ns/op	     216 B/op	       6 allocs/op
BenchmarkHash/size=5Mi-16               	     174	   6921804 ns/op	     216 B/op	       6 allocs/op
BenchmarkHash/size=10Mi-16              	      81	  13721809 ns/op	     216 B/op	       6 allocs/op
BenchmarkHash/size=100Mi-16             	       7	 143839571 ns/op	     216 B/op	       6 allocs/op
BenchmarkHash/size=1Gi-16               	       1	1450165209 ns/op	     216 B/op	       6 allocs/op
PASS
ok  	xxxxxxxxxxxxxxxx/xxxx/xxxx	18.063s

这里简要介绍一下每列测试结果的含义:

  1. 测试函数名、测试用例名称、参与测试的 CPU 核心数量。例如 BenchmarkMerkleHash/size=1Mi-16 表示:对于 BenchmarkMerkleHash 这个函数,在名称为 size=1Mi-16 的测试用例中,参与测试的 CPU 核心数量为 16 核。
  2. 测试次数。例如 778 表示这个测试被运行了 778 次,这个值其实就是 for 循环条件中的 b.N 值,测试框架会根据函数的耗时自动调整这个值的大小。
  3. 单次操作的平均用时,单位:ns(1ms=106ns1ms = 10^6ns。例如 1401939 ns/op 表示测试一次平均花费 1401939 纳秒,也就是 1.4 毫秒。
  4. 单次操作的平均分配字节数,单位:B(字节)。例如 1049143 B/op 表示测试一次平均需要分配 1049143 字节,约为 1Gi。
  5. 单次操作平均发生的内存分配次数。例如 12 allocs/op 表示测试一次平均需要 12 次内存分配。

从结果中可以进行分析得到如下结果:

  • 执行速度:
    • 对于小于等于 2Mi 的小文件,两者速度接近,默克尔树略快。
    • 对于 5Mi~10Mi 的文件,默克尔树优势明显,10Mi 时耗时约 3.57ms,约为普通哈希的四分之一。
    • 对于大于等于 100Mi 的大文件,默克尔树优势进一步扩大,1Gi 时耗时约 136ms,约为普通哈希的十分之一。
  • 内存占用:
    • 默克尔树:内存占用随着文件大小线性增长,和整个文件的体积相当。
    • 普通哈希:内存占用固定为 216 字节,不随文件大小变化。
  • 内存分配:
    • 默克尔树:分配次数随文件增大而增多。
    • 普通哈希:分配次数固定为 6 次。

5. 优化

  • 使用通道替代 leafHashes 数组,避免存储完整叶子节点哈希数组。
  • 分片读取时使用 sync.Pool 复用缓冲区,避免重复分配。
  • 合并哈希时避免临时切片分配(注意 append 函数底层做的事情)。
  • 控制并发线程数量,避免内存峰值过高。

6. 总结

默克尔树是一种高效的计算文件哈希的算法,在中、大文件的场景下,耗时相比普通哈希算法拉开了一定差距,不过需要付出使用更多内存的代价,在文件上传的场景,前后端最好约定同时使用默克尔树来计算整个文件的哈希,否则会出现不一致的情况。此外,我还介绍了几种默克尔树的优化手段,这些优化将会在下一篇文章中实现。