说实话,做了快十年的文件上传,每次听到“大文件上传”这几个字,我心里还是会咯噔一下。早年做内部系统时,用户传一个600MB的教学视频,进度条熬到99%,服务器直接502,页面一刷新又得从头再来。用户当场就炸了,我也很无奈——不是我不想解决,而是当时没把问题拆开看。后来我逐步把这套“分片上传 + 多重Hash判重”的方案落地,用它扛过了好几个月的大文件并发场景,今天把完整的思路、代码设计、踩坑过程都整理出来。如果你正在做或打算做上传模块,特别是面向视频素材、压缩包、软件安装包这类动不动就上GB的文件,这篇内容可以直接帮你避开好几天的弯路。
这套方案要解决的核心问题其实很清晰:怎么让超大文件能稳定上传,不因网络抖动而全盘失败;怎么在重复上传时快速判断文件是否已经存在,省带宽、省时间;以及怎么保证文件在传输和合并之后没有被悄悄改坏。涉及的技术点包括前端文件切片、Web Worker并行计算Hash、服务端临时存储、分片校验、合并后二次校验等。完整看完之后,你可以直接照着这套架构去实现自己的上传模块。
1. 为什么大文件上传一定要分片
1.1 单请求直传,为什么扛不住大文件
大家先回想一下最早期的上传实现,无非就是一个<input type="file">,用户选完文件后,前端把整个文件塞进FormData,后端一次性接收。这种写法对几MB的图片、几MB的文档完全没问题,但一旦换成视频素材、系统镜像、三维设计文件,立刻就会暴露出几个很致命的问题。
首先是超时。几乎所有Web容器、反向代理默认都会有请求超时时间,比如 Nginx 的proxy_read_timeout默认60秒,后端服务也有自己的请求读超时设置。文件一大,网络只要波动一下,中间传输时间超过阈值,服务端就会主动断开连接。断开之后前端拿到的要么是504,要么是连接重置,用户之前的等待全部白费。
其次是内存压力。服务端如果用multer这类库接收上传,默认行为是先把整个文件写入内存或者临时目录。如果是1GB的文件,即使写入临时目录,中间如果实现得不好,内存也会被撑得很难看,多个用户同时上传大文件时,服务器基本就卡死了。
再一个是没法断点续传。单文件上传的进度条看着还在走,但底层其实是一个完整的HTTP请求体。只要连接断了,整个请求就没了,TCP层面重新传输也好、应用层重试也好,最终都是从头再来。用户盯着那根进度条,心态很容易崩。
1.2 分片的本质是:把“一次性大考”拆成“多次小测验”
分片上传的核心思路并不复杂——在文件层面选择合适的大小,把一个大文件用File.slice()切成若干个小块,然后前端并发地、有状态地上传这些小块到服务端。服务端先把每个小块单独存起来,等所有小块都上传完成之后,再按照顺序把所有小块合并成完整的文件。
这个过程可以类比搬家。以前搬大件家具,一辆小车想一次把衣柜、床垫、冰箱全塞进去,结果车胎爆了,东西还得全卸下来重装。现在改成先把家具拆成零部件,分几趟运到新家,到了新家再重新组装成完整的家具。哪怕运输途中某一趟坏了,损失的只是那一趟的零件,重新补运一个零件就行,不需要把整个家搬一遍。
分片之后,每个分片的大小一般可以控制在1MB到10MB之间。我实际项目里常用的是5MB一个分片。为什么选5MB?因为如果分片太小,比如256KB,那么一个1GB文件就要切成4096个分片,服务端的请求数量会暴增,产生大量的小文件IO;如果分片太大,比如50MB,则又回到了“超大请求体”的毛病,网络一波动这一个分片就会传很久,重传成本高。所以经过反复权衡,5MB在大多数普通带宽和服务端环境下都是一个不错的默认值。
1.3 分片之后的三个新麻烦
分片不是拆完就没问题了,反而带来了三个我必须处理干净的新麻烦。
第一个是片段之间的顺序和归属。服务端收到几十上百个分片,怎么知道哪些分片属于同一个文件?分片的顺序又怎么确认?我一般会给每个分片带上一个文件标识(由文件名、文件大小、文件内容的Hash等字段计算出唯一ID)和分片序号chunkIndex,服务端靠这两个信息做落盘和排序。
第二个是并发调度的复杂度。客户端如果一次性创建太多请求,比如一个文件切了200个分片,然后全部同时发出去,浏览器一般默认对同一个域名只有6个左右的TCP连接限制,后面的请求会长时间排队。而且并发过高还会把带宽占满,反而拖慢整体速度。所以前端必须做并发控制,比如同一时间最多5个分片在传。
第三个是完整性校验。分片在传输过程中可能因为网络损坏、被截断,产生“半截分片”。服务端如果不校验,最后合并出来的文件大概率是坏的。这就是我为什么要对“每个分片以及最终合并后的完整文件都做Hash校验”的原因。
在逻辑上,这套流程可以画成一个状态机:初始化(申请上传)→ 分片上传中 → 服务端分片校验与落盘 → 全部分片完成 → 合并 → 最终Hash校验 → 通知成功;任一步失败则进入暂停/重试,但不推倒重来。
2. 多重Hash判重,到底在判什么重
2.1 先回答一个关键问题:单一Hash为什么不够
很多刚接触这个方案的人会问:去重不就是一个MD5的事吗?我做个文件级判断,服务端算一次MD5,如果这个文件之前传过就直接返回“秒传”,听起来好像没问题,那为什么还要“多重Hash”?
这里需要区分两个使用场景:安全校验场景和业务判重场景。安全校验里Hash碰撞确实是一个需要严肃对待的问题,因为攻击者可能故意构造两个内容不同但Hash相同的文件做坏事。但在上传判重场景,我们面对的用户没有恶意构造Hash碰撞的需求。那么真正的痛点在哪?
在误判率和性能的平衡上。MD5算得快,但128位的空间在现代硬件和样本量下,隐患确实存在。文件数量一旦上到百万级、千万级,不同文件之间Hash碰撞的概率就不再是理论上可以忽略的数字。要是两个真正不同的文件算出来同一个MD5,后端就会错误地返回“已存在”,用户上传了一个新版本却被告知文件已经在了,这就是很严重的事故。
SHA-256安全性更高,但计算也会更慢。一个1GB的大文件,即使做流式计算,也需要消耗几十秒甚至更久的CPU时间。前端主线程做这么重的计算,页面直接卡成PPT;后端同一时间做十几次并发计算,服务器CPU也会起飞。
所以我不追求唯一的密码学强度,我要的是多个维度的信息组合起来确认“这就是同一个文件”。实际工程里的做法通常是:先算一个快速Hash作为第一层判断,如果撞上了,再用其他维度的特征去二次确认。不同特征的组合,让碰撞概率降到业务可以完全接受的水平。这就是“多重Hash”的意义——每一层都是独立的验证维度,而不是把同一个Hash换着名字重复算。
2.2 文件级Hash、分片级Hash、文件大小,各自承担什么职责
我把“多重Hash”拆成了三层,每一层角色不同,不能互相替代。
第一层是文件大小。文件大小是成本最低的特征,两个文件如果大小都不一样,那就是100%不同,连Hash都不用算。我在判重时不会单独用大小作为依据,但会用大小做前置过滤,先排除掉大量明显不同的文件。
第二层是文件级Hash。这是整个方案里最重要的判重依据。前端在上传前,先对整个文件做一次内容级Hash计算,得出一个长字符串作为文件的标识。理论上,只要文件内容相同,无论文件名叫什么,计算出来的Hash都应该相同。这可以用来实现“秒传”,也可以用来在服务端做已存在文件的直接匹配。
第三层是分片级Hash。这个不是用来做文件判重,而是用来校验每个分片是否完整。传输过程中一旦出现一个字节的损坏,分片的Hash就会对不上。服务端收到分片后,用SHA-256对落盘的临时分片重新计算一遍,和前端上报的分片Hash比对,不一致就直接丢弃并要求重传——这是整个上传方案的“质检员”。
这三层加在一起,才叫真正的多重Hash判重,而不只是哈希算法多选几个。不同层级的职责不同,缺了一个,方案都不够严谨。
2.3 判重触发时机:上传前、分片完成时、合并之后
判重不是一个动作,而是三个时机,每个时机对应不同的处理策略。
上传前判重解决的是“这文件我是不是已经完整上传过了”。用户第一次选择文件后,前端先在本地算好文件级Hash,带着文件大小一起请求后端接口。如果后端发现同样Hash和大小已经关联到一个完整文件,就直接返回“上传成功+秒传标识”,这一步可以省掉整个上传过程。如果后端发现这个文件存在但只上传了一半分片,则返回已有的分片序号列表,前端就能跳过这些分片,只传剩余部分,本质上是“断点续传”。
分片上传过程中也一直在隐性地判重。每个分片到达服务端后,服务端都会看这个分片的Hash序列号是否重复,如果同一个分片被传了两次,直接忽略后面那一次,保持幂等。分片全传完后,服务端会检查实际收到的分片序号是否形成完整的0到N全量集合。这里有一个容易被忽略的点:分片上传和判重状态之间要保证原子性。如果一个分片传完了,但因为网络原因前端没有收到成功响应,前端会重试上传同一个分片。服务端这时必须能识别出“已经在数据库里存在了这个分片”,并且返回一个成功结果,而不是报错。这也就要求前端为每个分片提供fileHash + chunkIndex这样的业务主键。
合并完成之后还要做最终的Hash校验。服务端把所有分片按顺序合并成完整文件后,再次计算整个文件的Hash,与前端上报的文件级Hash进行比较。这一步可以在部分网络异常或者服务端分片存储出问题时兜底。比如某个分片内容错了但分片Hash侥幸没查出来,或者因为代码bug导致合并时漏掉了一个分片,最终校验就能发现问题,此时我宁愿直接把这个合并结果作废,也不要把一个坏文件交给用户。
2.4 文件级Hash怎么选:不能只靠MD5的单点
前端在计算全文件Hash时,受制于浏览器环境和用户机器的性能,不能走服务端那种上百MB流式计算的重路线。所以在计算Hash时,我往往会用快速但足够分散的算法。一般推荐用MD5或SHA-1先快速计算,但为了降低碰撞概率,核心判断会加入两层特征:文件大小 + 首片段Hash + 尾片段Hash + 完整文件采样Hash。
什么意思?举个例子,对于一个500MB的文件,算多重的完整文件Hash性能太差。我会采用“采样策略”:把文件拆成若干个64KB大小的块,每隔几个块取一个块参与Hash计算。从文件头块开始,再取靠近中间位置的块,再取文件尾部的块,把这些块的内容合并起来,算一次SHA-256。同时再对完整文件做一次快速的CRC32校验。这样的组合可以快速筛掉绝大多数不同的文件。
有些项目在服务端已经存了文件整体MD5,直接用MD5做秒传判断,业务量小的时候确实能跑。但你的平台如果将来要支撑百万以上量级的文件存储,我强烈建议至少把文件级标识从“单独MD5”升级为“SHA-256 + 文件大小”。最好有时间再验证一个文件的名词:完整性校验不止可以用Hash,还可以用文件的“分块模式”来辅助。比如对同一个文件,如果两端的分片边界不一样,即使内容完全一致,Hash完全相同,也不影响合并结果。边界不影响文件内容本身。但既然我已经在前端计算了完整的分片序列,服务端可以顺带校验“文件大小 + 分片总数 + 分片序号列表”这些元信息是否一致,进一步防止“文件内容相同但分片策略错误”导致漏合并。这种多重交叉验证,就是标题里“多重Hash判重”的实际含义。
3. 前端用File API切片 + Web Worker计算Hash
3.1 File.slice():切片的正确打开方式
前端拿到用户选择的File对象之后,第一步不是急着上传,而是先把文件切成一个个二进制块。File对象继承自Blob,所以可以直接调用Blob.prototype.slice()方法。
我使用5MB作为标准分片大小,并保留一个可变策略:如果文件不足5MB,那么只有一个分片,这种情况和普通小文件上传没有区别;如果文件超过5MB,则分片数量为Math.ceil(fileSize / chunkSize)。前端这时会用一个chunkList数组来记录每个分片的元信息,包括分片序号、起始字节、结束字节、大小。不要用文件名作为存储标识,因为用户可能把两个不同目录下的同名文件同时拖进来。
实际操作中还需要注意一个细节:某些浏览器对超大文件(比如超过2GB)的File.slice()支持有坑。通常表现为切出来的最后一个分片大小为0,或者start和end出现精度问题。我在代码里会额外加一个防呆判断,如果当前分片start >= fileSize就终止循环。
3.2 Web Worker:别把1GB文件的Hash计算放在主线程
早期版本我直接在页面主线程里用FileReader把整个文件读出来,再算MD5,用户选完文件之后,页面直接卡死几秒甚至十几秒,看起来就像浏览器崩溃了。后来我才把计算逻辑移到了 Web Worker 里面。
Worker的思想很简单:主线程把文件对象通过postMessage传给Worker线程,Worker内部负责异步地切片读取、累加Hash计算,再把最终结果传回主线程。由于Worker跑在独立线程里,不会阻塞用户的界面渲染,用户仍然可以正常填表、点按钮。
需要注意,传给Worker时,传递的是File或Blob对象本身,而不是文件内容。Worker接收后可以用FileReaderSync分块读取,也可以用File.arrayBuffer()来读取每个分片的内容。这里有一个性能优化点:在计算全文件Hash时,不需要同时把所有分片数据都读入内存,逐个分片读取后立即更新Hash上下文,再释放内存即可。
以下是核心的Worker内部分片Hash计算框架:
// hashWorker.js self.onmessage = async function (e) { const file = e.data.file; const chunkSize = e.data.chunkSize || 5 * 1024 * 1024; let offset = 0; let md5 = null; // 按分片读取并计算增量Hash while (offset < file.size) { const blob = file.slice(offset, offset + chunkSize); const buffer = await blob.arrayBuffer(); md5 = appendBufferToHash(md5, buffer); offset += buffer.byteLength; // 向主线程回报进度 self.postMessage({ type: 'hash-progress', percent: Math.min(100, Math.round(offset / file.size * 100)) }); } const fileHash = finalizeHash(md5); self.postMessage({ type: 'hash-complete', fileHash: fileHash }); };如果项目里不想自己维护Hash算法,可以直接引入spark-md5这个库,它支持增量式Hash计算。在我实测过的项目里,spark-md5在100MB左右的文件上计算速度是能接受的,但如果文件上了GB,性能依然有些吃力,这时候可以把算法换成基于Web Crypto API的SHA-256叠加采样块的策略,速度会快不少。
3.3 前端上传时的并发控制与状态管理
拿到每个分片的Hash之后,前端就要真正发起上传请求了。我的做法是:每个分片对应一次独立的HTTP请求,请求体中除了分片的二进制内容,还要带着fileHash、chunkIndex、totalChunks等元数据。后端返回的内容里,最好包含这个分片是否已存在、是否需要重传等信息。
并发控制的核心是限制同一时刻的请求数量。我一般使用一个简单的线程池模型,最大并发数设置5,因为5个请求既不会打满用户带宽,也不会触发浏览器的TCP连接限制。如果网速好、用户设备强,可以适当上调到8~10,但不要太高,否则浏览器和服务器都会吃不消。
上传过程中需要为每一个分片维护一个状态:等待上传、上传中、上传成功、等待重试、上传失败。这些状态可以存在一个Map里,用fileHash + ':' + chunkIndex作为key。前端每次发起请求前,先查看这个分片的状态;如果已经是成功状态,就直接跳过。
暂停和恢复是另一个在真实场景里非常需要的功能。实现思路并不复杂:全局维护一个“是否暂停”的布尔值。每个分片请求发出前检查这个值,如果已经暂停就停止发起新的请求;已经在传输中的请求则不强制取消,等它自然结束或超时后再进入暂停状态。恢复时只需要继续从等待队列里取下一个分片即可。
3.4 为什么现在不推荐用ActiveX控件或老式上传组件
我知道很多旧系统里,大家用的是各种安装到浏览器里的上传控件,有些甚至要求在IE浏览器里关闭安全选项。这类控件在上传大文件时同样有自己的分片逻辑,但最大的问题是兼容性和维护成本。
比如用户换了浏览器内核,控件就失效;系统升级成64位后,32位控件装不上;在企业内网里,如果安全策略禁止下载安装控件,整个上传功能就彻底瘫痪。这也是很多老项目从“IE+控件上传”迁移到“原生File API+HTML5上传”的原因。现代浏览器原生支持File、Blob、FileReader、Web Worker、XMLHttpRequest,已经完全可以做到高度可控的大文件上传,不需要任何第三方控件。
如果你刚好在维护一个历史遗留系统,我建议新功能优先走这套原生方案,旧控件只作为兜底。用Web Worker来做大文件Hash计算、用分片做传输的基本单位,用户的体验和系统的可维护性会明显不一样。
4. 服务端分片接收、合并与落库判重
4.1 服务端临时目录与分片命名规范
分片上传的挑战不只在服务端把文件接收下来,还要设计一套稳定、有序的临时存储结构。我见过不少失败的设计,比如把所有分片堆在一个目录里,再把分片序号拼在临时文件名的后面。如果用户一多,几十个不同文件的分片全都混在一起,合并时根本没法区分归属,一旦重名就相互覆盖。
我的方案是两段式目录结构。首先将所有用户上传临时分片统一放在/data/upload_tmp/{fileHash}下。fileHash是前端全部计算出的文件唯一标识,它在整个上传生命周期内不变。然后在fileHash目录下,每个分片存放为chunk_{index}.part。最终合并的完整文件,先放到另一个路径/data/upload_final/{fileHash}.bin,等确认无误后再移动到正式存储区,并且记录到数据库。
这种设计的好处是,清理任务可以按目录为单位直接删除临时目录。比如一个上传操作失败了三天还没有重新开始,后台定时任务扫描upload_tmp下超过24小时未更新的fileHash目录,直接删掉即可,不会残留垃圾。
4.2 分片校验:校验Hash、忽略重复、拒绝越界
服务端在接收一个分片时,核心流程必须包含三个步骤,顺序不能乱。
第一步是查重。表结构里至少包含这些字段:fileHash、chunkIndex、chunkHash、size、uploadTime、status。利用fileHash和chunkIndex作为联合唯一索引,当同一个分片重复上传时,数据库会产生唯一性冲突,服务端捕获冲突后直接返回“分片已存在”的成功状态。这个设计可以做到分片上传的幂等处理,避免前端因为网络超时重试时产生脏数据。
第二步是校验大小。前端上报的chunkIndex不能超出totalChunks的合理范围。比如上报chunkIndex=999但totalChunks只有5,说明这不是错误就是恶意请求。这种请求我们在接口层直接拒绝,不存盘。
第三步是计算并比对chunkHash。分片内容落盘后,我用服务端的工具库重新计算落盘文件的SHA-256值,再与请求参数里的chunkHash比较,不一致则返回错误并删除已落盘的临时文件。这个步骤必须放在落盘之后而不是之前,因为我们要校验的正是磁盘上实际保存的那个文件,而不是刚刚从网络中收到的二进制流。
以下是一个Node.js服务端的核心分片接收示例:
const fs = require('fs-extra'); const path = require('path'); const crypto = require('crypto'); async function uploadChunk(fileHash, chunkIndex, totalChunks, chunkHash, chunkStream) { const tmpDir = path.join('/data/upload_tmp', fileHash); await fs.ensureDir(tmpDir); const chunkPath = path.join(tmpDir, `chunk_${chunkIndex}.part`); // 1. 幂等判断:分片已存在且校验一致则直接返回成功 if (await fs.pathExists(chunkPath)) { const fileBuffer = await fs.readFile(chunkPath); const existingHash = crypto.createHash('sha256').update(fileBuffer).digest('hex'); if (existingHash === chunkHash) { return { status: 'duplicate', chunkIndex }; } } // 2. 写入临时分片 await fs.ensureFile(chunkPath); const writer = fs.createWriteStream(chunkPath); chunkStream.pipe(writer); await new Promise((resolve, reject) => { writer.on('finish', resolve); writer.on('error', reject); }); // 3. 校验落盘后的Hash const diskBuffer = await fs.readFile(chunkPath); const diskHash = crypto.createHash('sha256').update(diskBuffer).digest('hex'); if (diskHash !== chunkHash) { await fs.remove(chunkPath); throw new Error('chunk hash mismatch'); } return { status: 'ok', chunkIndex }; }4.3 合并策略与原子性:先合并到临时文件,再重命名
当服务端发现同一个fileHash的分片数已经等于totalChunks时,就可以触发合并操作。合并并不是直接把所有chunk_0.part到chunk_{n}.part读进来拼接。因为如果合并过程中程序崩溃,可能出现只合并了一半的坏文件,而且这个坏文件还可能被后续流程使用。
稳妥的做法是先新建一个独立的临时文件,例如/data/upload_tmp/{fileHash}/merged.tmp,然后按照序号从小到大一个一个分片读取并追加进去。合并完成后,对这个完整的临时文件计算一次文件级Hash。如果计算结果和前端上传前上报的文件Hash一致,就可以把这个临时文件原子性地重命名到/data/upload_final/{fileHash}.bin。
这里要特别提醒:合并后的Hash对比必须强一致。如果你的服务端用Node.js读分片内容,默认会把二进制内容当Buffer读,因此不会改变文件编码。但如果你用了文本模式读写,或者不小心在写入时加了个换行符,那最终Hash一定会和前端算的Hash不一致,导致明明传输正常却合并失败。我自己遇到过一个奇怪的问题,在Windows的Node.js环境上用fs.createWriteStream合并某些分片时,写入的零字节分割了二进制数据,导致文件内容和原先不一致,最后排查到是分片合并时没有用二进制模式。跨平台项目一定要留意这种细节。
合并流程做完之后,数据库里的状态要从“UPLOADING”更新为“UPLOADED”。更新和重命名操作虽然不一定需要引入完整的事务性消息队列,但至少要做到:如果更新数据库失败,不删除已合并的文件,并且记录一条待处理日志,方便后台任务重试。
4.4 三种状态的判重结果处理
站在用户视角,一次大文件上传可能对应三种后端反馈:秒传成功、断点续传、全新上传。这三者靠文件级Hash和文件状态区分。
当用户发起初始化上传请求时,服务端先在数据库里查询这个fileHash是否存在。如果存在且状态为“UPLOADED”,就返回“文件已存在+秒传成功”,前端直接跳过所有上传步骤。如果存在但状态为“UPLOADING”,就返回“已有的分片序号列表”,前端只上传缺失分片,实现断点续传。如果不存在,则创建一条状态为“UPLOADING”的记录,并返回全部分片列表。
需要注意的是,秒传后文件所有权问题。如果用户是同一个租户/用户,秒传直接关联引用即可;如果是多租户隔离系统,不能因为文件内容相同就让不同租户共享物理文件,涉及权限隔离的话,需要在业务表里做二次映射,或者直接复制物理文件到租户自己的目录。这个逻辑属于业务层策略,但架构设计时应该预留字段,否则后续安全审计会很难做。
5. 生产环境最常见的坑与排查实录
5.1 高频问题速查表
这套方案上线后,问题大多不是出现在正常路径,而是在各种边界情况上。我把常见的现象、可能原因、解决方案整理成一张表,排查时可以直接对着查。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 所有分片都能传,但合并后Hash对不上 | 合并时漏掉某个分片,或分片顺序错乱 | 按序号严格递增合并,并在合并前检查文件数量和分片索引的连续性 |
| 前端上传到一半页面刷新,重新开始 | 没有保存初始化的fileHash,或fileHash由随机数生成 | fileHash基于文件内容计算,必须在初始化阶段固定下来;前端本地缓存上传任务状态 |
| 个别分片请求偶发超时,整体上传进度卡住 | 单分片网络传输时间过长 | 对每个分片设置合理超时时间,超时后自动进入重试队列,最多重试3~5次 |
| 服务端存放大量找不到归属的零散part文件 | 用户上传到一半就放弃,没有清理机制 | 定时任务定期扫描临时目录,删除超过24小时未完成的任务目录 |
| 浏览器在计算Hash或上传大文件时崩溃 | 主线程阻塞或内存占用过高 | 迁移到Web Worker计算Hash;上传过程不要一次性读取全文件到内存 |
| 多用户并发上传导致服务器带宽打满、接口变慢 | 缺少全局并发限速 | 在网关层或Nginx层做带宽限制;服务端为不同用户分配不同的上传令牌桶,限制总并发数 |
5.2 调试实录:前端Hash和服务端Hash为何不一致
有一次测试组给我反馈,一个400MB左右的压缩包,上传进度显示100%,界面也提示“上传成功”,但是用户下载后打开压缩包却提示“文件已损坏”。我第一反应是合并出了问题,但服务端的日志显示最终校验Hash和前端上报的Hash是一致的,所以文件理论上不可能坏。
后来我让测试同学把现场保留下来,拉取前端发起上传之前算出的fileHash、服务端合并完成后的fileHash,手动比对,发现两个字符串完全相同。这就意味着问题不是出在Hash校验逻辑上,而是出在文件本身:前端在算Hash之前就已经拿到一个不完整的file对象。
再仔细看代码,发现测试文件是从一个网盘下载的,下载的源文件本来就不完整,但测试环境里的同一个文件其实是“占位文件”或“虚拟文件”。在浏览器里通过input选择文件时,File对象的大小可能是真实的,但其中某些字节是填充的。当我用采样Hash生成fileHash时,如果采样区间恰好没有覆盖到损坏的字节段,Hash就会正常,但合并后的文件仍然有问题。
这个案例给我的教训很深刻:在使用“采样Hash+多重Hash”做判重时,一定要考虑Hash计算的覆盖范围。如果业务对文件完整性要求非常高,就不要只算首尾块的采样Hash,而是必须计算全文件内容Hash。少算一个块,就可能把一个隐性坏文件漏过去。这一点直接影响最终的数据安全,宁可慢一点,也不能省。
5.3 生产配置建议:ChunkSize、并发数与安全加固
根据我不同项目的实测,如果用户主要在国内办公网络环境,平均上行带宽为5~30Mbps左右,那么5MB分片是比较均衡的体验。带宽较窄或用户使用的设备是老旧的移动端,我会把分片改小到2MB,这样失败重传的成本更低;如果用户明确在内网,上下行带宽能跑到几百Mbps,分片可以提高到10MB甚至20MB。
并发数方面:默认设为5。对于带宽高的用户,可以设置动态并发,比如根据最近若干次请求的速度估算带宽,当平均速度超过阈值时自动把并发数提到8,反之降回3。这种“自适应并发”可以让大文件上传在千兆内网和弱网下都有不错的表现。
服务端接口需要做安全加固。一是上传接口必须鉴权,不能让任何人随意往临时目录里写文件,否则会变成任意文件写入漏洞。二是限制fileHash、chunkIndex的字符格式,避免路径穿越。理论上chunkIndex必须是纯数字或范围受限的数字,fileHash必须匹配/^[a-zA-Z0-9]{16,128}$/。三是上传接口要检查分片大小,不能无限大,必须在服务端设置一个合法的上限。比如前端申报的每个分片是5MB,如果请求体实际是100MB,就直接拒绝,防止有人绕过前端恶意灌入超大块。
6. 这套方案的能力边界与后续扩展
6.1 什么场景不需要分片,什么场景必须分片
分片上传并不是银弹,有时候引入分片反而是过度设计。文件只有几MB,用户量也不大,一次性上传更快更简单,这时候没必要引入分片和Hash判重带来的复杂度。分片上传真正适用的场景,是文件足够大、网络环境不够稳定、文件重复率比较高、或者系统要支撑多人同时上传大型素材。判断要不要分片,主要看用户是否会因为一次失败而付出过高重试成本。如果一个几十GB的文件,一次失败用户就要崩溃,那即使是小带宽也要上分片。
分片方案也有能力边界。如果前端在低配手机上处理超过4GB的文件,浏览器内存和CPU压力会非常大。即使做成Worker,底层读取大文件本身也依然吃IO。对这种场景,考虑服务端直传、对象存储分段上传或专门的桌面客户端方案会更合适。判断一个方案好不好,不能只看技术完整性,还要看硬件的实际承受能力。
6.2 后续可以让这套框架承载的更多功能
做好了分片文件和Hash仓库之后,很多上层功能都会变得容易扩展。
秒传功能可以直接实现。文件级Hash已经入库,用户上传同一个文件时不需要再走完整上传流程,几乎零成本返回成功。断点续传也很自然,因为服务端已经按fileHash + chunkIndex存储了当前进度,前端只需要主动查询一次缺失分片列表。
另一个有趣的方向是“服务端转码/处理任务的去重触发”。比如一个用户上传了视频,服务端可以根据文件Hash直接跳过已经做过的转码任务,而不会因为文件重复上传多次触发多份冗余转码任务。这个点在企业网盘、协作平台里能省下大量计算资源。还有,如果你在做多端同步工具,同一份文件在不同设备上传到云端时也可以基于同样的Hash体系快速对齐,节省带宽。
如果你把临时目录的功能做完善,还可以支持“异步预审”。上传分片落盘的同时,后台可以边接收边扫描分片内容,比如查毒、敏感信息识别,不必等全文件合并完成后才启动检测,能极大缩短安全检测的等待时间。
6.3 别把Hash和状态管理写死在业务里
做了几轮大文件上传之后,我的体会是:分片只是手段,真正的核心其实是“状态管理”。文件上传要经过“初始化、分片上传、校验、合并、完成”等多个状态,每一个状态都必须可查询、可重试、可恢复。Hash是贯穿整个状态流转的主键,它不是一次性的计算值,而是整条链路的凭证。
在编码上,我会把Hash计算、分片上传、服务端存储都做成独立的模块,而不是散落在业务代码里。比如如果将来要把底层存储从本地磁盘换成云上的对象存储,那就只需要替换“分片临时存储”和“合并上传”这两个模块的实现,Hash算法和判重逻辑完全不需要改动。如果前端要支持更多端(如iOS、Android、小程序),不同端也可以复用同一套接口协议和服务端模块,核心逻辑不会重复写。
项目上线后,我还习惯保留一套独立的回归测试用例,里面包含0KB空文件、10MB小文件、1GB大文件、特殊字符文件名、断网重连、暂停恢复、并发上传同一文件等一整套测试场景。上传这种模块,如果不做自动化回归,每次发版都像在走钢丝。有了这套用例,后续其他同事也敢放心改代码。
最后分享一个小经验:实现上传时不要一开始就追求看起来很“高级”的全链路功能。先把“前端切分 → 服务端接收 → 合并 → 校验 → 成功”这条主链路跑通,然后再做秒传、断点续传、并发控制、自适应调整。每一步的效果都看得见摸得着,出了问题也容易定位。你只要把底层这套分片加Hash的骨架打扎实,后面所有的优化都是在往骨架上添砖加瓦。