做上传功能最烦的不是写不出代码,而是文件一大,各种隐藏问题全冒出来了。2GB 的压缩包传到一半断了,用户那边直接喊崩溃;传给某个存储服务,网关超时卡死;换台手机拍照传原图,内存直接顶满。我去年处理过一个模拟项目,客户要传单文件好几个 GB 的影像数据,前端不能装任何插件,还要断点续传、要加密,后端最好能证明数据没被改过。折腾一圈后我把完整方案沉淀了下来,核心就一句话:大文件上传必须分片,分片之后才有资格谈加密和续传。
这篇文章不打算写什么理论纲领,直接把我用的源码结构、加密选型、续传思路和踩坑记录都摆出来。适合正在做文件上传相关功能的前端同学,也适合被后端拉着联调“分片接口”的运维或全栈朋友。你看完能直接落地,甚至可以直接复制里面的核心代码去改。
1. 大文件分片上传这件事,先看清全貌
1.1 分片解决的三个实际问题
很多人以为分片只是为了“断点续传”,这其实是个误区。分片解决的是三个完全独立的问题,断点续传只是分片之后顺手带来的一个红利。
第一个问题是浏览器的请求体积限制。虽然 HTTP 协议本身不限制 body 大小,但网关、负载均衡、Web 容器都有默认限制。我见过某个环境里上传超过 200MB 就返回 413,调大以后又触发服务端内存溢出的,因为服务端代码用了readFile把整个文件读进去了。分片以后,单片 5MB,无论网关还是服务端都能轻松应付。
第二个问题是失败重试的成本。一个 1GB 文件整体上传失败,重试就得从头再来。但拆成 200 片之后,哪一片失败就传哪一片,其他 199 片不用动。这个道理很简单,但在实际项目里,很多人优先把接口调通,却忘了在接口外面做“重试单元”的划分。
第三个问题最容易忽略,是并行度。单文件上传无论怎么优化,都只有一条连接在跑,遇到网络抖动时吞吐量上不去。切分以后可以控制并发数,比如同时传三片,相当于把一条窄路变成三条并行通道,尤其在高带宽、低延迟的局域网场景下,速度提升非常明显。但注意,并发数不是越大越好,浏览器对同域名的连接数有限制,HTTP/1.1 下一般是 6 个,超过以后排队反而更慢。
分片的大小我一般选 5MB 到 10MB 之间。太小的片会让请求数爆炸,比如 1GB 文件切成 1MB,那就是 1024 个请求,服务端压力大,前端遍历也有额外开销。太大的片又失去了分片的意义,续传粒度太粗,单片传输时间过长,失败后重试成本高。最终我在这套方案里用了 5MB,兼容性和性能比较均衡。
1.2 一套完整的流程长什么样
在写代码之前,先要把整个流程走一遍,脑中有图,代码才不会乱。我习惯把上传拆成四个阶段:准备、传输、校验、合并。
准备阶段做的事情是计算文件唯一标识,也就是文件指纹。这一步非常关键,因为它同时服务于秒传和断点续传。文件指纹算完以后,前端会调用后端的“查询接口”,把指纹传过去,后端根据指纹返回文件是否已存在、已上传了哪些分片。
如果文件已存在,直接提示“秒传成功”,整个上传流程结束。如果不存在,前端就开始按序切分文件,每一片都独立编号,从 0 开始。切分不是一次性把所有分片读进内存,而是借助 File 对象的 Blob 切片能力,按偏移量动态读取。也就是说,切片是“逻辑切片”,并不是物理上把大文件拆成了多个临时文件。
传输阶段就是逐个上传分片。每个分片请求携带的信息至少包括:文件指纹、分片总数、当前分片序号、分片原始大小。如果有加密,还要带上加密后的实际大小和加密参数。传输过程中前端会在本地记录哪些分片已经成功,以便中断后恢复。
校验阶段分两层。第一层是传输完整性,每个分片上传时后端可以校验分片的哈希值,防止数据在传输过程中损坏。第二层是文件完整性,所有分片传完后,后端合并成一个完整文件,再对合并后的整体文件计算一次哈希,和前端的文件指纹比对。如果不一致,说明分片组合逻辑或加密处理有问题。
合并阶段就是把临时存放的分片按序号拼接成完整文件。合并可以由后端主动触发,也可以等前端调用“合并接口”来触发。推荐后者,因为合并是重操作,如果前面还有分片没传完就触发合并,只会得到错误结果。合并完成后,后端清理临时分片,返回最终文件的访问路径。
这套流程看着简单,实际落地时加密加进来,流程就会多几个岔路口。第 3 节我会专门说加密选型,这里先把指纹和续传的细节讲透。
2. 断点续传的核心逻辑:指纹、状态与接口
2.1 文件指纹:秒传与续传的开关
断点续传的实现方式可以有很多种,最简单粗暴的是前端本地保存一个已上传列表,刷新页面后检查这个列表,跳过已经成功的分片。这种办法只适合自己玩的小项目,换个浏览器、清一下缓存,续传能力就没了。真正的续传必须让后端参与,后端才是分片状态的最终权威。
要让后端识别“同一个文件”,就必须给文件一个唯一标识。我常用的方式是计算整个文件的 MD5 哈希,或者更稳妥一点,用 SHA-256。但是这里有一个现实问题:大文件的整体哈希计算非常耗时。一个 2GB 的文件,即使用 SparkMD5 这种流式算法,在普通机器上跑完也要好几秒,甚至超过十秒。用户上传时看着“计算哈希中”的进度条,体验很差。
所以实际项目里我更喜欢用抽样哈希,也叫快速指纹。取文件头部 2MB、中间 2MB、尾部 2MB,再加上文件大小,拼成一段数据后计算 MD5。抽样哈希的碰撞概率比完整哈希高,但对大多数业务场景完全够用。你可以把“完整哈希”和“抽样哈希”理解为两种不同精度的“身份证”:完整哈希是 DNA 鉴定,抽样哈希是看脸部特征。判断同一个人,脸部特征大概率就够了,没必要每次都抽血。
但要记住一个前提:如果业务对文件一致性有法律级别或审计级别的要求,比如医疗影像、合同文件,那抽样指纹不够,必须在最后合并完成后做一次整体哈希校验。也就是不要把“快速指纹”当成“完整性校验”,这是两个维度的东西。
另外,算指纹要放在加密之前还是之后?我强烈建议对原始文件算指纹。因为加密之后的数据如果作为指纹基准,同一个原始文件每次用不同密钥加密,指纹就不同了,秒传和续传都会失效。原始文件算出来的指纹稳定、可复现,适合做去重和续传依据。加密数据的完整性校验,单独用分片哈希去解决。
2.2 状态记录与接口约定
断点续传需要回答一个问题:已经传了哪些分片?前端知道不够,后端必须记住。后端的实现方式有几种:用数据库表记录,用 Redis 记录分片信息,或者直接在磁盘上按“文件指纹/分片序号”命名临时文件。传完一片,磁盘上就存在一个文件,扫描目录就能知道进度。
我常用的接口是三个:
第一个是查询接口,前端传入文件指纹,后端返回一个对象,里面包含三个字段:exist表示文件是否已经完整上传,chunks表示已收到的分片序号数组,size表示文件总大小。如果exist为 true,前端直接走秒传逻辑;如果chunks里有部分数据,前端只上传缺失的片。
第二个是分片上传接口,前端上传一个分片,同时提交文件指纹、分片序号、分片总数等元数据。后端先检查这个文件指纹对应的临时目录是否存在,不存在就创建,然后接收分片数据,写成临时文件。这个接口要注意分片序号必须由前端显式传入,不能依赖文件名排序,否则遇到超过 10 片时,像 1、10、11、2 这样的顺序会把合并逻辑坑惨。
第三个是合并接口,前端在所有分片上传完成后调用,后端把临时目录里的分片按序号排序、逐个拼接,生成最终文件。合并完成后再看临时目录是否要清理。
这里有个容易踩的坑:分片的默认序号是从 0 开始还是从 1 开始?我的建议是固定的,从 0 开始,跟数组下标对齐,前后端约定永远不改变。这样前端在判断“缺失哪些分片”时可以直接用数组索引,后端排序时也只用数字比较,不会混入字符串排序问题。
状态记录还有一个细节:后端在接收分片时,不能只依赖前端传的“文件大小”来校验,万一前端数据被截断,后端会拿到不完整的片。比较稳妥的做法是前端每个分片附带一个该分片的原始哈希,后端校验一致后再落盘。如果校验失败,直接返回错误,前端重传这一片。这样服务端就不会存下一堆损坏的临时文件,合并阶段也能少很多莫名其妙的 bug。
3. 加密到底加在哪一层:先想清楚再动手
3.1 三个加密场景别混为一谈
加密这个词在文件上传里特别容易混淆。我在和别人讨论方案时,经常发现双方说的“加密”根本不是一回事。至少有三个完全不同的场景:
第一个是传输加密,也就是 HTTPS。这是所有方案的地基。只要数据还在网络上传输,就必须有 TLS 加密,否则前面做的所有工作在抓包工具面前都是透明的。实际上,如果只是防止数据在网络上被窃听,把站点切到 HTTPS 就已经解决了大部分问题,根本不需要额外在前端做加密。
第二个是端到端加密,也就是数据从浏览器端开始就是密文,服务端、存储端都接触不到明文。这种场景常见于网盘中的私密文件、企业内部的合规数据。前端用密钥加密分片,密钥本身不经过服务器,或者经过服务器但以密文形式保存。端到端加密的代价是,服务端无法对数据内容做病毒扫描、图片缩略图等处理,因为看到的全是乱码。
第三个是存储加密,也就是数据落盘以后是密文。这个一般在服务端完成,只要对象存储服务开了 SSE 加密,或者数据库层面做了透明加密,就能满足很多合规需求。存储加密对前端完全透明,业务代码无感。
我在这套方案里默认采用“传输加密 + 前端分片加密”的组合,主要解决的是两个核心诉求:一是防止网络层泄露,二是让服务端拿到的分片即使被拖走,也无法解析出原始内容。但必须说清楚,这种不算严格的端到端加密,因为密钥最后还是会被服务端获取,服务端如果被完全攻破,密文还是能被解开。真要端到端,密钥管理要单独设计。
3.2 分片加密的正确姿势:每片独立IV
确定了前端要加密,接下来要考虑用什么算法、怎么处理密钥、IV 怎么管理。
算法选择上,我用的是 AES-GCM。AES 是国际通用的对称加密算法,GCM 模式自带认证标签,也就是说加密出来的数据不仅能保密,还能校验有没有被篡改。AES-GCM 已经被各大浏览器的 Web Crypto API 原生支持,调用crypto.subtle.encrypt就能完成,不需要引入额外依赖。
密钥管理是重点。严格来说,前端保存的密钥也不能硬编码在 JS 里,因为 JS 代码对用户是可见的,硬编码等于没有密钥。我常用的做法是让后端提供一个临时密钥下发接口,前端在开始上传前调用接口获取一次性密钥。这个密钥绑定当前上传任务,任务结束后失效。这个方案仍然是服务端可控的,不是端到端,但实现简单、管理方便。
IV 的处理是很多人忽略的地方。AES-GCM 的 IV 不需要保密,但必须保证每个分片使用不同的 IV,而且加密时用了哪个 IV,解密时也必须用同一个。我会为每个分片生成一个随机的 12 字节 IV,然后把 IV 连同密文一起上传给后端,后端合并时从分片元数据里取回 IV 再逐个解密。
这里有个非常关键的细节:分片序号必须参与加密认证。AES-GCM 额外支持附加认证数据 AAD,可以把分片序号、文件指纹这些信息加进去。这样即使攻击者把分片改成了另一个文件的合法分片,合并时也会因为 AAD 不匹配而校验失败。简单理解,IV 管“这一片用了什么随机数”,AAD 管“这一片必须属于谁”。
加密后的分片大小会变化。AES-GCM 密文比明文多 16 字节的认证标签,这意味着不能直接拿分片原始大小做校验。后端的临时分片长度是加密后的长度,合并时先解密,再按解密后的明文做拼接。
3.3 加密和断点续传怎么配合
加密不是额外塞进去的一个步骤,它必须跟分片上传流程融合。我试过两种方案,第一种是“先进内存读整块文件,整体加密,再切分”,第二种是“先分片,再对每个分片单独加密”。实际操作后我坚定选择第二种。
第一种方案的问题在于内存。一个 200MB 的文件整体加密后,生成的密文还在内存里,切分也要在大数组上操作,移动端浏览器很容易崩溃。而且整体加密会产生一个“加密后的完整文件”,这个文件没有原始文件的指纹特征,续传判断非常麻烦。
第二种方案每片独立加密,天然适配分片上传流程。每个分片的切片操作、指纹操作、加密操作、上传操作可以流水线处理,内存占用低,某一个分片加密失败也不影响其他分片。加密后的每个分片自带 IV 和认证标签,后端可以边收边校验。
加密对续传恢复的影响也很直观。在断点恢复时,前端拿到后端返回的已上传分片列表,剩下的工作就是:重新从原文件切片、给缺失的分片重新生成 IV、重新加密、重新上传。因为每个分片独立加密,完全不需要关心之前加密过的分片是什么内容。
提示:密钥不能在续传时更换。如果上传进行到一半用户刷新了页面,密钥过期了,那前端拿到新密钥后,已上传的分片用的是旧密钥,合并时后端就无法解密。最简单的处理是:密钥有效期设得足够长,比如 24 小时;或者在断点恢复时,后端把“当前任务正在使用的密钥 ID”连同已上传分片列表一起返回。
4. 源码实现:分片上传、加密、断点续传怎么捏合到一起
4.1 前端核心代码:切片、指纹、加密、上传、恢复
下面这份代码是我在模拟项目里用过的精简版,核心逻辑完整,你可以直接抄去改。环境是浏览器,使用 Web Crypto API 做 AES-GCM 加密,上传用 XMLHttpRequest,这样能拿上传进度。
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB const CONCURRENCY = 3; function createChunks(file) { const count = Math.ceil(file.size / CHUNK_SIZE); const chunks = []; for (let i = 0; i < count; i++) { const start = i * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, file.size); chunks.push({ index: i, blob: file.slice(start, end), size: end - start, }); } return chunks; } async function computeFastHash(file) { // 抽样哈希:取头、中、尾各2MB,加文件大小 const size = file.size; const head = file.slice(0, 2 * 1024 * 1024); const middle = file.slice( Math.floor(size / 2) - 1024 * 1024, Math.floor(size / 2) + 1024 * 1024 ); const tail = file.slice(size - 2 * 1024 * 1024, size); const buffer = await new Response( new Blob([head, middle, tail, String(size)]) ).arrayBuffer(); const digest = await crypto.subtle.digest('SHA-256', buffer); return Array.from(new Uint8Array(digest)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } function generateIV() { return crypto.getRandomValues(new Uint8Array(12)); } async function getUploadKey(fileFingerprint) { // 从后端换取本次上传的一次性密钥 const resp = await fetch('/api/upload/key', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fingerprint: fileFingerprint }), }); const data = await resp.json(); return base64ToBytes(data.key); } async function encryptChunk(chunkBlob, key, aad) { const iv = generateIV(); const chunkData = await chunkBlob.arrayBuffer(); const cryptoKey = await crypto.subtle.importKey( 'raw', key, { name: 'AES-GCM' }, false, ['encrypt'] ); const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv, additionalData: aad }, cryptoKey, chunkData ); return { iv, encrypted }; } class ChunkUploader { constructor(file, fingerprint) { this.file = file; this.fingerprint = fingerprint; this.chunks = createChunks(file); this.uploadedIndexes = new Set(); this.activeCount = 0; this.queue = []; this.totalChunks = this.chunks.length; } async prepare() { // 查询后端哪些分片已上传 const resp = await fetch('/api/upload/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fingerprint: this.fingerprint, size: this.file.size }), }); const data = await resp.json(); if (data.exist) { return { exist: true, status: 'done' }; } this.uploadedIndexes = new Set(data.chunks); this.key = await getUploadKey(this.fingerprint); return { exist: false, status: 'resume' }; } uploadSingle(index) { return new Promise(async (resolve, reject) => { const chunk = this.chunks[index]; const aad = new TextEncoder().encode(`${this.fingerprint}:${index}`); const { iv, encrypted } = await encryptChunk(chunk.blob, this.key, aad); const formData = new FormData(); formData.append('fingerprint', this.fingerprint); formData.append('index', index); formData.append('total', this.totalChunks); formData.append('size', chunk.size); formData.append('file', new Blob([encrypted]), `chunk-${index}`); const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload/chunk'); xhr.upload.onprogress = (e) => { if (e.lengthComputable) { // 这里可以回调进度 } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { this.uploadedIndexes.add(index); resolve(); } else { reject(new Error(xhr.responseText)); } }; xhr.onerror = () => reject(new Error('network error')); xhr.send(formData); }); } async uploadAll(onProgress) { const needUpload = this.chunks.filter( (c) => !this.uploadedIndexes.has(c.index) ); let finished = this.chunks.length - needUpload.length; for (let i = 0; i < Math.min(CONCURRENCY, needUpload.length); i++) { this.runWorker(onProgress); } return new Promise((resolve, reject) => { this.resolveFn = resolve; this.rejectFn = reject; this.onProgress = onProgress; this.needUpload = needUpload; this.finishedCount = finished; this.injectWorker(); }); } }这段代码的思路是:先用createChunks逻辑切分,computeFastHash算指纹,prepare阶段连完成后端查询和密钥换取,encryptChunk做 AES-GCM 加密,最后用 XMLHttpRequest 上传。并发控制我用了一个简单的 worker 机制,限制同时只有 3 个分片在传。
完整的并发队列代码我在这里补一个更清晰的小片段,避免你直接抄上面的半成品踩坑:
class UploadQueue { constructor(uploader, concurrency = 3) { this.uploader = uploader; this.concurrency = concurrency; this.running = 0; this.pending = []; this.done = 0; this.success = []; this.failed = []; } push(task) { this.pending.push(task); this.next(); } next() { while (this.running < this.concurrency && this.pending.length > 0) { const task = this.pending.shift(); this.running++; task() .then((res) => { this.success.push(res); }) .catch((err) => { this.failed.push(err); }) .finally(() => { this.running--; this.done++; this.next(); }); } } }实际使用时,把所有缺失分片的上传任务塞进UploadQueue,队列内部自动维护并发数。等队列跑完,检查failed数组,把失败的分片重试,最多重试两次。重试仍失败的,记录断点位置,提示用户刷新后再次恢复。
4.2 后端核心代码:接收、校验、合并
后端我用 Node.js 写,框架假设是 Express,目录结构很简单:临时分片放/tmp/upload/${fingerprint},合并后的最终文件放/data/files/。你可以换成任意语言,逻辑是通用的。
先看分片上传接口:
const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const UPLOAD_TMP = '/tmp/upload'; const FINAL_DIR = '/data/files'; app.post('/api/upload/chunk', async (req, res) => { // 假设 multer 已经处理了 multipart/form-data const { fingerprint, index, total, size } = req.body; const file = req.file; if (!file) { return res.status(400).json({ error: 'no file part' }); } const chunkDir = path.join(UPLOAD_TMP, fingerprint); fs.mkdirSync(chunkDir, { recursive: true }); const chunkIndex = parseInt(index, 10); const chunkPath = path.join(chunkDir, String(chunkIndex)); const buffer = fs.readFileSync(file.path); const receivedHash = crypto.createHash('sha256').update(buffer).digest('hex'); // 这里可以校验分片的认证标签解密后再算原始分片哈希, // 简化起见先记录加密分片哈希 fs.writeFileSync(chunkPath, buffer); fs.unlinkSync(file.path); res.json({ received: true, index: chunkIndex }); });这个接口的重点是:分片以“文件指纹/分片序号”落在临时目录,序号就是文件名。虽然逻辑简单,但并发上传时存在一个隐患:同指纹目录下多个分片同时写入,文件系统层面不会有问题,因为文件名不同。
再看查询接口,返回已存在的分片序号列表:
app.post('/api/upload/check', (req, res) => { const { fingerprint, size } = req.body; const chunkDir = path.join(UPLOAD_TMP, fingerprint); let chunks = []; if (fs.existsSync(chunkDir)) { chunks = fs.readdirSync(chunkDir) .map((name) => parseInt(name, 10)) .filter((n) => !isNaN(n)) .sort((a, b) => a - b); } const filePath = path.join(FINAL_DIR, `${fingerprint}.bin`); const exist = fs.existsSync(filePath) && fs.statSync(filePath).size === Number(size); res.json({ exist, chunks }); });合并接口是最后一步,必须等所有分片都到达才能执行。合并时除了按序号拼接文件,还要做两步校验:一是分片数量是否完整,二是合并后的总长度是否等于前端声明的原始文件大小。
app.post('/api/upload/merge', async (req, res) => { const { fingerprint, total, size, keyId } = req.body; const chunkDir = path.join(UPLOAD_TMP, fingerprint); if (!fs.existsSync(chunkDir)) { return res.status(404).json({ error: 'no chunks found' }); } const chunkNames = fs.readdirSync(chunkDir); if (chunkNames.length !== Number(total)) { return res.status(400).json({ error: 'chunk count mismatch' }); } // 从 keyId 对应的地方拿到密钥材料 const key = getKeyById(keyId); const finalPath = path.join(FINAL_DIR, `${fingerprint}.bin`); const writeStream = fs.createWriteStream(finalPath); for (let i = 0; i < Number(total); i++) { const chunkPath = path.join(chunkDir, String(i)); const ivAndCipher = fs.readFileSync(chunkPath); const iv = ivAndCipher.slice(0, 12); const cipher = ivAndCipher.slice(12); const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); // 这里要补上加密时的 AAD,AAD 是 fingerprint:index 的字节形式 decipher.setAAD(Buffer.from(`${fingerprint}:${i}`)); const decrypted = Buffer.concat([ decipher.update(cipher), decipher.final(), ]); writeStream.write(decrypted); } writeStream.end(); writeStream.on('finish', () => { fs.rmSync(chunkDir, { recursive: true, force: true }); res.json({ url: `/files/${fingerprint}.bin`, size }); }); });看到这里你应该能明白,核心逻辑并不复杂,复杂的是前后端对分片元数据的约定要保持一致。尤其是 AAD,如果前端加密时用了fingerprint:index,后端解密时必须一模一样,连中间那个冒号都不能换。
4.3 怎么把加密续传塞进现有上传组件
很多人不会从零写上传组件,项目里可能已经用了现成的上传库。这种情况下怎么集成加密和断点续传?我的答案是:不要动上传库的分片逻辑,用自己的代码包一层,把加密后的数据喂给上传库,或者反过来在上传库的回调里加加密处理。
比如现有的上传库提供了onFileSelected、onProgress、onSuccess这些回调,你就可以在文件选择后先算指纹,然后把“加密后的分片 Blob”作为实际上传的数据源。上传库只管“把 Blob 传到指定接口”,它并不知道传的是密文。续传时,你在上传前调用查询接口,过滤掉已上传的分片,只把缺失部分塞给上传库。
如果上传库本身自带断点续传,问题就稍微复杂一点。它可能用自己的本地缓存记录分片状态,状态里存的是分片序号和 Blob。你需要在加密后重新生成一份 Blob,并让上传库的缓存记录“以加密后的 Blob 为准”。否则断点恢复时它可能会尝试上传旧的明文分片,后端一查哈希不对就会拒绝。
提示:如果上传库的缓存机制不可控,最省事的方案是禁用上传库自带的断点续传,完全用我们自己这套指纹+后端查询方案来控制。上传库只承担“单次分片上传”的能力。这样可以避免两套续传逻辑互相打架。
5. 实战中的坑与排查思路
5.1 高频问题速查表
我在实际测试过程中收集了一批踩过的坑,做成表格放在这里,按问题、原因、解决方式三列整理,方便你对照排查。
| 问题 | 原因 | 解决方式 |
|---|---|---|
| 合并后的文件打不开,文件头是乱码 | 加密后没解密直接拼接,或者解密时 AAD 不一致 | 确认前后端 AAD 完全一致,加密/解密使用相同密钥和算法参数 |
| 断点续传后文件损坏,部分内容重复 | 后端合并时按字母序排序文件,1 和 10 排错位 | 改为按数字序号排序,parseInt 后再比较 |
| 刷新后秒传成功但文件只有 0 字节 | 查询接口只看指纹,没看文件大小 | 查询接口必须校验文件大小,指纹相同且大小一致才能秒传 |
| 加密后上传速度明显变慢 | 每个分片都用arrayBuffer()把 Blob 读进内存,再加密 | 小分片可以接受,超大文件考虑 Web Worker 异步处理,避免阻塞 UI |
| 并发 3 个分片时出现 503 | 后端临时目录无写入权限,或磁盘空间不足 | 调整临时目录权限,监控磁盘占用 |
前端报crypto.subtleundefined | 当前页面不是 HTTPS,或浏览器不支持 Web Crypto API | 确保 HTTPS 环境;如果是本地调试,用 localhost 环境 |
| 合并时内存溢出了 | 把整个文件读进内存再解密拼接 | 改用流式写入,逐分片解密后写入,见前面 merge 代码 |
| 续传时提示分片数超过预期 | 后端临时目录残留了上一次失败任务的脏数据 | 在查询接口增加清理逻辑,比如超过 2 小时未完成就二次确认后清掉 |
这个表格覆盖了我遇到过的大部分问题。还有一个不常见但很坑的情况:文件大小刚好是分片大小的整数倍时,前端createChunks不会多切最后一片,但后端判断分片完整时容易把最后一个分片漏掉。建议切分时用Math.ceil,并且合并前检查分片序号是否为0 到 total-1全覆盖。
5.2 几条实操建议与习惯
先说断点续传的本地缓存。除了后端记录已上传分片,我还会在前端把分片状态写进 localStorage。这样做有两个好处:一是后端查询接口压力小一点,二是用户可以立刻看到哪些分片还没传,不用等接口返回。但注意 localStorage 有容量限制,不能存大数组,只存序号列表和文件指纹,足够了。如果你要存的信息多一点,我建议切换到 IndexedDB,容量大得多,且不会在浏览器退出时丢失。
再说分片上传的进度计算。单片传输进度可以靠 XMLHttpRequest 的upload.onprogress,但是多片并发时,总进度不能简单把单片进度相加。我常用的算法是:已分片数 / 总分片数 × 0.9 + 当前活跃分片平均进度 × 0.1。这样保证整体进度在 90% 以前主要反映分片完成情况,最后 10% 留给合并接口的等待时间,用户看到进度条走到 99% 后不再前进,也不会太焦虑。
加密方面还有一个建议:如果不需要端到端加密,我其实更推荐“HTTPS + 服务端存储加密”的组合。前端加密并不是免费的,它增加了 CPU 计算量,移动端尤其明显,而且会带来密钥管理复杂度。只有当业务有明确的数据私密性要求,或者安全合规强制要求“数据不得以明文形式经过应用层”,才值得在前端做这一层。
最后提醒一句:密钥不要写死在 JS 里。如果你在代码里看到const KEY = '1234567890abcdef',那就是在裸奔。换用后端下发,每次上传任务独立密钥,到期自动失效,这应该是底线。
这些经验是反复踩坑之后才沉淀下来的。我处理的模拟项目里,刚开始加加密的时候,就栽在“整体加密还是分片加密”的选择上,导致续传功能一度无法实现。后来改成每片独立加密,配合 AAD,整个逻辑一下子清晰了,前后端联调也顺利很多。你如果正在做类似的东西,建议先按这个流程走通一个最小版本,再用真实大文件压测,比一上来就堆代码高效得多。