文件哈希
1. 简介
作为后端,在一些场景中需要提供 文件上传 的功能,如上传视频、上传用户头像。
这里有一个 典型 的技术方案:
- 前端将大文件进行 分片。
- 前端 发送分片 给后端。
- 前端给后端发送 合并分片 请求。
一般来说,前端传递分片时需要携带 文件哈希值,作用是 区分文件,这里有一个前提条件——不同文件的哈希值不同。
注:本文我将使用 md5 哈希算法,但这个哈希算法的效率和安全性都不是很好,可以用其他更高效且安全的哈希算法替换 md5。
作为后端,不能绝对信任前端的校验,我们应该再次计算分片的哈希并对比前端传过来的哈希值,尽管前端已经正确地计算过了,本文将介绍 如何高效地计算文件哈希值。
2. 普通哈希算法
2.1 思想
普通哈希算法很好理解,就是把文件读到内存中,然后使用库里的函数计算哈希值。
2.2 具体实现
具体实现时需要注意 不能将文件一次性读入到内存,这样会 浪费很多内存:
- 将文件部分内容读取到内存中。
- 调用 md5 对象的
Write方法。 - 继续读取部分内容,并调用
Write方法,直到将整个文件都读取完毕。 - 调用 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
这里简要介绍一下每列测试结果的含义:
- 测试函数名、测试用例名称、参与测试的 CPU 核心数量。例如
BenchmarkMerkleHash/size=1Mi-16表示:对于BenchmarkMerkleHash这个函数,在名称为size=1Mi-16的测试用例中,参与测试的 CPU 核心数量为 16 核。 - 测试次数。例如
778表示这个测试被运行了 778 次,这个值其实就是 for 循环条件中的b.N值,测试框架会根据函数的耗时自动调整这个值的大小。 - 单次操作的平均用时,单位:ns()。例如
1401939 ns/op表示测试一次平均花费 1401939 纳秒,也就是 1.4 毫秒。 - 单次操作的平均分配字节数,单位:B(字节)。例如
1049143 B/op表示测试一次平均需要分配 1049143 字节,约为 1Gi。 - 单次操作平均发生的内存分配次数。例如
12 allocs/op表示测试一次平均需要 12 次内存分配。
从结果中可以进行分析得到如下结果:
- 执行速度:
- 对于小于等于 2Mi 的小文件,两者速度接近,默克尔树略快。
- 对于 5Mi~10Mi 的文件,默克尔树优势明显,10Mi 时耗时约 3.57ms,约为普通哈希的四分之一。
- 对于大于等于 100Mi 的大文件,默克尔树优势进一步扩大,1Gi 时耗时约 136ms,约为普通哈希的十分之一。
- 内存占用:
- 默克尔树:内存占用随着文件大小线性增长,和整个文件的体积相当。
- 普通哈希:内存占用固定为 216 字节,不随文件大小变化。
- 内存分配:
- 默克尔树:分配次数随文件增大而增多。
- 普通哈希:分配次数固定为 6 次。
5. 优化
- 使用通道替代
leafHashes数组,避免存储完整叶子节点哈希数组。 - 分片读取时使用
sync.Pool复用缓冲区,避免重复分配。 - 合并哈希时避免临时切片分配(注意
append函数底层做的事情)。 - 控制并发线程数量,避免内存峰值过高。
6. 总结
默克尔树是一种高效的计算文件哈希的算法,在中、大文件的场景下,耗时相比普通哈希算法拉开了一定差距,不过需要付出使用更多内存的代价,在文件上传的场景,前后端最好约定同时使用默克尔树来计算整个文件的哈希,否则会出现不一致的情况。此外,我还介绍了几种默克尔树的优化手段,这些优化将会在下一篇文章中实现。