在处理大文件上传时,生成文件的MD5哈希是一项重要的工作,可以用于文件校验、重复文件检查和数据完整性验证。然而,传统的MD5计算方法在处理大文件(如1GB以上的文件)时,往往效率较低。如何通过分片(chunking)技术和Web Worker实现MD5计算的显著性能提升。
我们通过这种优化方式,将1.67GB文件的MD5生成时间从18秒缩短到了仅3秒!
旧版实现:串行分片MD5计算
在原始的实现中,我们使用 FileReader
对文件进行分片处理,每次将分片转为ArrayBuffer,再通过SparkMD5库的增量计算功能逐片计算文件的MD5。虽然这种方法能够实现文件的逐片处理,但它仅使用单线程顺序计算,因此在大文件上会花费大量时间。以下是旧版实现的代码结构:
typescript
function createHash(chunks: Blob[]) {
return new Promise<string>(resolve => {
const spark = new SparkMD5.ArrayBuffer()
function _hash(index: number) {
if (index >= chunks.length) {
resolve(spark.end())
return
}
const reader = new FileReader()
reader.readAsArrayBuffer(chunks[index])
reader.onload = e => {
spark.append(e.target?.result as ArrayBuffer)
_hash(index + 1)
}
}
_hash(0)
})
}
新版优化:利用Web Worker进行并行计算
新版实现的核心思路是通过Web Worker将分片处理和MD5计算过程并行化。具体来说,将文件划分为多个块,每个块通过Worker分发到独立的线程中进行处理,这样能够有效提高处理速度。
新版实现的关键步骤如下:
- 文件分块与分片:将文件划分为更大的块(例如50MB),并进一步拆分为小的分片(例如10MB),以适应Web Worker的处理。
- Worker管理与并发控制:通过线程池机制限制活跃Worker数量,避免性能瓶颈。
- 并行计算进度与总哈希合并:每个Worker计算完一个块的MD5后,将结果传回主线程,主线程合并所有块的哈希,得出最终的MD5值。
以下是新版优化中的关键代码:
typescript
const worker = new Worker('./hashWorker.js')
worker.postMessage({ chunks: chunkArray })
worker.onmessage = event => {
if (event.data.hash) {
workerResolve(event.data.hash)
} else if (event.data.progress) {
progress.value += (event.data.progress / totalSize) * 100
}
}
性能对比:3秒 vs 18秒
使用新版的优化方法,我们将1.67GB文件的MD5计算时间从18秒降低到了3秒,提升了6倍以上的速度。这种性能提升在处理大文件时尤为显著,为用户提供了更加流畅的文件上传体验。
整个流程如下
处理大文件的上传和哈希计算可能会面临性能瓶颈。以下是通过分块上传和 Web Worker 并发计算优化大文件 MD5 哈希值的完整步骤:
- 创建文件块和分片
- 将大文件分割为若干个较大的块(如每块 50MB)。
- 将每个块进一步拆分为更小的分片(如每片 10MB)。
- 在
createChunks
函数中创建块结构,在createChunksFromBlock
函数中完成从块到分片的转换。
- 初始化 Worker
- 使用线程池管理 Web Worker 并发数量,限制同时处理的 Worker 数量,避免性能压力。
- 通过
postMessage
方法向 Worker 发送分片和块的总字节数。
- 在 Worker 中处理分片
- 在 Worker 中接收分片数据,利用
FileReader
逐片读取内容并通过SparkMD5
进行增量哈希计算。 - 每处理完一个分片后,向主线程发送进度(已处理字节数)和中间哈希值。
- 计算总进度
- 主线程根据每个块的处理情况更新整体进度。
- 每当一个块的哈希计算完成,增加已处理块计数,并相应更新总进度。
- 合并哈希值
- 所有块的哈希计算完成后,主线程收集每个块的哈希值。
- 使用
SparkMD5
合并各块的哈希,最终生成整个文件的 MD5 值。
- 返回结果
- 所有块的处理完成后,返回最终文件的 MD5 哈希值和总进度(通常为 100%)。
通过Web Worker与分片技术的结合,我们显著提升了大文件的MD5计算性能,这种方法不仅适用于MD5计算,也可用于其他大文件的处理场景。未来,我们可以进一步优化多线程逻辑,如增加断点续传、内存优化等,让文件处理更加高效!
完整代码
hashWorker.js
js
// hashWorker.js
// importScripts('https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js')
importScripts('/node_modules/spark-md5/spark-md5.min.js')
self.onmessage = function (event) {
const chunks = event.data.chunks
const spark = new SparkMD5.ArrayBuffer()
let loaded = 0
const readChunk = index => {
if (index >= chunks.length) {
self.postMessage({ hash: spark.end() })
return
}
const fileReader = new FileReader()
fileReader.onload = e => {
spark.append(e.target.result)
loaded++
const chunkSize = chunks[index].size
// 发送当前块已读字节数
// 当前已读字节数
const progress = loaded * chunkSize
// 发送当前块的进度
self.postMessage({ progress, size: chunkSize })
readChunk(index + 1)
}
fileReader.onerror = () => {
self.postMessage({ error: 'File reading error' })
}
fileReader.readAsArrayBuffer(chunks[index])
}
readChunk(0)
}
useFileChunks
ts
// useFileChunks.ts
import { ref } from 'vue'
import SparkMD5 from 'spark-md5'
/**
*
* 大文件分块上传和哈希
* 1. 创建文件块和分片:
* 1.1 将文件分成若干个较大的块(例如 50MB),每个块内部再分成更小的分片(例如 10MB)。
* 1.2 在 createChunks 函数中实现块的创建,并在 createChunksFromBlock 函数中实现从块到分片的转换。
*
* 2. 初始化 Worker:
* 2.1 在处理每个块时,使用线程池管理并发的 Web Worker,限制同时处理的 Worker 数量以避免性能问题。
* 2.2 通过 postMessage 方法将分片和当前块的总字节数发送给 Worker。
*
* 3. 在 Worker 中处理分片:
* 3.1 在 Worker 中接收分片数据,通过 FileReader 读取每个分片的内容,并使用 SparkMD5 计算每个分片的哈希值。
* 3.2 每读取完一个分片,向主线程发送当前进度(已读取字节数)和最终的哈希值。
*
* 4. 计算总进度:
* 4.1 在主线程中,根据每个块的处理进度,计算和更新总进度。
* 4.2 每当一个块的哈希计算完成时,增加已处理的块计数,并根据已处理块数更新总进度。
*
* 5. 合并哈希值:
* 在所有块的哈希计算完成后,收集每个块的哈希值,并使用 SparkMD5 合并这些哈希值,得到最终的文件哈希。
*
* 6. 返回结果:
* 在所有处理完成后,返回文件的 MD5 哈希值和当前进度(可能达到 100%)。
* @param blockSize 块大小 (默认 50MB)
* @param chunkSize 分片大小 (默认 10MB)
* @returns {md5: string, progress: number}
*/
export const useFileChunk = (
blockSize = 50 * 1024 * 1024,
chunkSize = 10 * 1024 * 1024,
) => {
const md5 = ref<string>()
const progress = ref<number>(0)
const activeWorkers = ref<number>(0)
// 设置最大 Worker 数量
const MAX_WORKERS = 4
// 创建块和分片
function createChunks(file: File) {
const blocks = []
let cur = 0
// 分块
while (cur < file.size) {
const block = file.slice(cur, cur + blockSize)
blocks.push(block) // 保存块本身以计算字节数
cur += blockSize
}
return blocks
}
// 从块中创建分片
function createChunksFromBlock(block: Blob) {
const result = []
let cur = 0
while (cur < block.size) {
const chunk = block.slice(cur, cur + chunkSize)
result.push(chunk)
cur += chunkSize
}
return result
}
async function get(file: File) {
if (!file) throw new Error('File is required')
progress.value = 0
md5.value = ''
const blocks = createChunks(file) // 创建块和分片
const totalBlocks = blocks.length // 计算块总数
const workerPromises = blocks.map(block => {
return new Promise((workerResolve, workerReject) => {
const processBlock = () => {
if (activeWorkers.value < MAX_WORKERS) {
activeWorkers.value++
const worker = new Worker(
new URL('./hashWorker.js', import.meta.url),
)
const chunkArray = createChunksFromBlock(block)
// 将块中的所有片发送给 Worker
worker.postMessage({ chunks: chunkArray })
worker.onmessage = event => {
if (event.data.hash) {
workerResolve(event.data.hash) // 返回当前块的哈希
activeWorkers.value-- // 完成后减少活跃 Worker 数量
} else if (event.data.size !== undefined) {
// 更新块进度
progress.value += (event.data.size / file.size) * 100
} else if (event.data.error) {
workerReject(event.data.error)
activeWorkers.value-- // 出错时也减少活跃 Worker 数量
}
}
worker.onerror = error => {
workerReject('Worker error: ' + error.message)
activeWorkers.value-- // 出错时减少活跃 Worker 数量
}
} else {
// 如果当前活跃 Worker 已满,稍后重试
setTimeout(processBlock, 100)
}
}
processBlock() // 启动块处理
})
})
// 等待所有块的哈希返回
const hashes: string[] = (await Promise.all(
workerPromises,
)) as unknown as string[]
// 合并哈希值
const finalSpark = new SparkMD5()
hashes.forEach(hash => finalSpark.append(hash))
md5.value = finalSpark.end() // 计算最终的 MD5 值
return { md5: md5.value, progress: progress.value, chunks: [] }
}
return { getFileInfo: get, progress }
}