在处理大文件上传时,生成文件的MD5哈希是一项重要的工作,可以用于文件校验、重复文件检查和数据完整性验证。然而,传统的MD5计算方法在处理大文件(如1GB以上的文件)时,往往效率较低。如何通过分片(chunking)技术和Web Worker实现MD5计算的显著性能提升。
我们通过这种优化方式,将1.67GB文件的MD5生成时间从18秒缩短到了仅3秒!
旧版实现:串行分片MD5计算
在原始的实现中,我们使用 FileReader
对文件进行分片处理,每次将分片转为ArrayBuffer,再通过SparkMD5库的增量计算功能逐片计算文件的MD5。虽然这种方法能够实现文件的逐片处理,但它仅使用单线程顺序计算,因此在大文件上会花费大量时间。以下是旧版实现的代码结构:
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值。
以下是新版优化中的关键代码:
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
// 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
// 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 } }
作者:ErvinHowell
链接:https://juejin.cn/post/7434460220845539367