ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

大文件上传分块+SM4国密加密完整实践

大文件上传分块+SM4国密加密完整实践 这两年做大文件上传我踩过不少坑。一开始用传统表单提交文件一超过几百兆服务端直接报内存溢出前端页面也卡得没法看。后来切换成分块上传问题解决了一大半但做的是企业内部系统客户对数据安全要求很严格要求传输过程必须加密。对比了一圈最终选定SM4国密算法来做加密传输配合分块上传把整个链路理顺了。这篇文章就完整记录这套方案的实现思路和核心代码适合正在做JSP项目大文件上传、或者需要在Web传输链路里接入国密加密的开发朋友参考。1. 为什么大文件上传必须分块而且还要叠加SM4加密1.1 传统一次提交的方式先搞清楚它到底崩在哪JSP传统上传依赖input typefile表单提交浏览器在请求体里一次性携带整个文件服务器端通过Part或FileItem读取底层本质上是把整个文件读入内存或者临时文件。这个方案在小文件时代没问题但文件到几个GB的时候问题就很具体了内存占用与服务端吞吐量直接相关并发一高JVM老年代一涨FullGC频繁接口响应越来越慢最后直接OutOfMemory。前端等待时间过长中途断网、服务端重启整个文件就得重传没有断点续传的能力。传输过程是明文企业内部数据在链路上被截获的话等于裸奔。即使服务端配置了超时时间大文件传输耗时远超默认超时阈值请求被容器强制断开这是最常见的“传一半失败”原因。分块上传解决的问题本质上就三件事降低单次请求的资源压力、支持失败重试、支持并发传输。我第一次把2GB文件切成4MB一块传输时整条链路的稳定性立刻不一样了前端不再卡死服务端内存也平稳。1.2 SM4加密在这个方案里扮演什么角色SM4是国密分组密码算法分组长度128位密钥长度128位算法结构类似AES但在国内合规场景下被广泛使用。它在本方案里不是用来做端到端加密的最终答案而是解决传输链路上的明文风险文件分块之后从前端发出去的数据依然是明文抓包就能看到内容在HTTPS不可用或者需要双重加密的涉密场景下对切片做SM4加密能保证即使传输链路被监听对方拿到的也只是无法识别的密文块。我在实际项目里把SM4加密放在前端完成后端负责解密和合并这样从前端浏览器发出那一刻起数据就已经是密文状态。密钥管理用前后端约定加动态协商的方式前端的加密密钥来自服务端下发的临时会话凭证避免把固定密钥直接写死在JS文件里。1.3 适用范围和边界这套方案不是所有项目都适合。如果只是个人博客或者内部小工具HTTPS加普通分块上传就足够了没必要引入SM4带来的性能开销和密钥管理复杂度。但如果做的是政务系统、企业OA、金融类系统或者对数据传输有合规要求SM4基本是绕不开的选择。另外要注意SM4加密只保护传输过程落盘之后的存储加密是另一套体系不要混为一谈。2. 分块上传的整体链路设计2.1 一次完整上传都经历了哪些环节我把整个流程拆成了七个环节从用户选择文件到最后合并完成每一步都有明确的职责前端读取文件元信息计算文件MD5或SHA256用于后续的秒传校验和完整性验证。前端向后端发起初始化请求获取本次上传任务的唯一标识uploadId和SM4加密密钥或密钥提示。前端将文件按固定大小切片对每一片进行SM4加密。前端将密文块按顺序上传附带uploadId、当前块序号、原始块大小、密文块大小等元数据。后端接收密文块先落临时文件或存储到磁盘记录块状态。全部块上传完成后前端通知后端执行合并。后端校验块完整性按顺序解密合并生成最终文件并做整体校验。这里每一步依赖上一部的上下文信息比如uploadId关联了文件元信息和密钥信息块序号决定了合并的先后顺序。整个设计很像数据库里的事务日志每一块都是一个小记录只有全部就绪才能提交合并。2.2 分块大小怎么定不是拍脑袋就能决定的分块大小的选择直接关系到上传的效率和稳定性。块太大单次请求耗时太长失败重试的成本高块太小请求次数暴增服务端压力大网络握手开销也会吃掉大量带宽。我测试过不同块大小在同一网络环境下的表现数据如下分块大小1GB文件块数上传总耗时秒失败重试块数服务端峰值内存1MB1024约2038较低2MB512约1764较低4MB256约1423中等8MB128约1292中等偏上16MB64约1355较高这里4MB到8MB是一个相对合理的区间兼顾了请求数量和单次请求耗时。我最终选了4MB作为默认值因为SM4加密CBC模式下是按16字节分组的4MB正好是16字节的整数倍加密后密文长度等于明文长度加一个填充块切分边界依然可控合并逻辑简单很多。如果文件本身很小小于一个分块大小就不需要走加密分块流程直接整块加密上传即可。3. 前端实现细节3.1 用File API切片注意流的边界问题浏览器端切片用的是File.prototype.slice这个API在现代浏览器里已经非常成熟。切片的核心逻辑很简单但有几个细节容易被忽略。先看核心切片代码function createFileChunks(file, chunkSize) { const chunks []; let start 0; const totalSize file.size; while (start totalSize) { const end Math.min(start chunkSize, totalSize); const blob file.slice(start, end); chunks.push({ blob: blob, start: start, end: end, index: chunks.length, originalSize: end - start }); start end; } return chunks; }这段代码看着简单但end的计算必须用Math.min因为最后一块几乎永远小于chunkSize。另外有一些细节要提醒file.size是字节数不要和其他单位混在一起否则最后一块会越界。Blob对象创建后如果文件在页面上被删除或替换Blob的数据可能变成不可读状态。建议在用户选定文件后立即生成所有切片引用而不是等上传时才slice。如果文件很大超过2GB浏览器对Blob的处理会有一些性能差异我遇到过在部分老版本浏览器上对超大文件切片导致页面卡顿的情况后来统一升级浏览器内核解决这个属于环境兼容性问题建议测试阶段就覆盖。3.2 前端SM4加密库选型与实现前端做SM4加密我用的是sm-crypto这个库它实现了SM2、SM3、SM4算法使用简单体积适中。SM4有ECB和CBC两种基本模式我选择了CBC模式因为ECB模式下相同的明文块会生成相同的密文块容易泄露文件的结构信息CBC模式通过IV初始化向量让相同明文块在不同位置产生不同密文安全性更好。加密分块的代码逻辑const sm4 require(sm-crypto).sm4; /** * 对分块进行SM4加密 * param {Blob} blob 原始分块数据 * param {ArrayBuffer} key SM4密钥16字节 * param {ArrayBuffer} iv IV16字节 */ async function encryptChunk(blob, key, iv) { const arrayBuf await blob.arrayBuffer(); const uint8Array new Uint8Array(arrayBuf); // sm-crypto的加密方法默认接收字符串或16进制数据做一次转换 const keyHex bytesToHex(key); const ivHex bytesToHex(iv); const dataHex bytesToHex(uint8Array); // 以hex模式进行CBC加密输出hex密文 const cipherHex sm4.encrypt(dataHex, keyHex, { iv: ivHex, mode: cbc }); // 把hex密文转回ArrayBuffer便于上传 return hexToArrayBuffer(cipherHex); } function bytesToHex(bytes) { let hex ; for (let i 0; i bytes.length; i) { hex (bytes[i] 4).toString(16); hex (bytes[i] 0xf).toString(16); } return hex; } function hexToArrayBuffer(hexStr) { const view new Uint8Array(hexStr.length / 2); for (let i 0; i view.length; i) { view[i] parseInt(hexStr.substr(i * 2, 2), 16); } return view.buffer; }这里有个关键点必须说明整个文件使用同一个密钥这是合理的但每个分块是否重复使用同一个IV这决定安全性。如果每个分块都用同一个IV去加密那么相同位置的相同明文块仍然会产生相同的密文块文件特征会残留。正确做法是每个分块使用不同的IVIV序号随块号变化块号和IV之间的映射关系在后端解密时通过约定推导出来。最简单的方式是把IV的计算规则设计为IV baseIV XOR 块序号前端加密时动态计算后端解密时按同一规则恢复。我在项目里的实际做法是初始化接口下发baseIV16字节分块加密时用deriveIV(baseIV, index)生成当前分块的IV同时把当前块使用的IV偏移量即块序号放在请求头里传给后端。这样每块之间密文互不关联安全性提升一个台阶。3.3 上传队列管理与失败重试分块上传天然支持并发和重试。我用前端Promise队列控制并发数避免一次性发出几百个请求把服务端打爆。核心逻辑如下async function uploadAllChunks(chunks, uploadId, key, baseIV, concurrentLimit 5) { const results new Array(chunks.length); let nextIndex 0; async function worker() { while (nextIndex chunks.length) { const index nextIndex; const chunk chunks[index]; try { const iv deriveIV(baseIV, index); const encrypted await encryptChunk(chunk.blob, key, iv); await uploadSingleChunk(uploadId, index, chunk.originalSize, encrypted, iv, index); results[index] { success: true }; // 更新进度 updateProgress((index 1) / chunks.length); } catch (e) { // 单块失败重试3次 let retry 0; while (retry 3) { try { await uploadSingleChunk(uploadId, index, chunk.originalSize, encrypted, iv, index); results[index] { success: true }; break; } catch (retryErr) { retry; if (retry 3) { results[index] { success: false, error: retryErr }; throw retryErr; } await sleep(500 * retry); } } } } } const workers []; for (let i 0; i concurrentLimit; i) { workers.push(worker()); } await Promise.all(workers); return results; }这里的重试逻辑有个细节encrypt必须在重试循环外部因为块加密的结果是确定的同一个IV和明文肯定产生同一份密文不需要重新加密。但要注意加密过程中耗时的arrayBuffer()读取如果在循环内重复执行会白白消耗内存和CPU。进度条的更新也不能简单根据已完成的块数来算因为并发上传时块大小虽然一致但网络耗时不同。如果是进度百分比可以用已上传字节数除以总字节数如果是更精准的进度要记录每个块的原始大小按已上传块原始字节数 / 文件总字节数计算。4. 后端接收与解密合并实现4.1 Servlet接收分块请求的接口设计后端我用Servlet 3.0的MultipartConfig注解来接收上传请求。由于每一块都是独立的HTTP请求后端接口的职责就是接收一块密文数据、校验元数据、落磁盘。接口设计需要一个额外的字段来标识块序号。核心Servlet处理逻辑如下WebServlet(/upload/chunk) MultipartConfig(maxFileSize 1024 * 1024 * 64, maxRequestSize 1024 * 1024 * 128) public class ChunkUploadServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { req.setCharacterEncoding(UTF-8); String uploadId req.getParameter(uploadId); int chunkIndex Integer.parseInt(req.getParameter(chunkIndex)); long originalSize Long.parseLong(req.getParameter(originalSize)); String ivHex req.getParameter(iv); Part filePart req.getPart(file); if (filePart null) { writeResult(resp, false, 文件块未找到); return; } // 保存密文块到临时目录 String chunkDir getChunkDir(uploadId); File dir new File(chunkDir); if (!dir.exists()) { dir.mkdirs(); } File chunkFile new File(dir, chunk_ chunkIndex); try (InputStream in filePart.getInputStream(); FileOutputStream out new FileOutputStream(chunkFile)) { byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } } // 记录块元数据到本地数据库或Map ChunkMeta meta new ChunkMeta(); meta.setUploadId(uploadId); meta.setChunkIndex(chunkIndex); meta.setOriginalSize(originalSize); meta.setIv(ivHex); meta.setEncryptedSize(chunkFile.length()); metaStore.save(meta); writeResult(resp, true, 分块上传成功); } }这里的临时目录建议按uploadId隔离避免不同任务文件块混淆。另外每个块都是独立写入天然支持并发但metaStore.save(meta)要注意并发写库的线程安全问题我实际用的是ConcurrentHashMap缓存块元数据全部块完成后统一落库。4.2 解密时的IV复用规则和PKCS7Padding处理后端解密用的SM4和前端加密必须严格匹配包括模式、填充方式、IV推导规则。我前端和后端统一用CBC模式 PKCS7Padding。Java端使用BouncyCastle或者Hutool的SM4工具类我项目里用的是Hutool代码更简洁。解密和合并的代码import cn.hutool.crypto.symmetric.SM4; import cn.hutool.crypto.Mode; import cn.hutool.crypto.Padding; public void decryptAndMerge(String uploadId) { ListChunkMeta metas metaStore.getSortedMetas(uploadId); SM4 sm4 SmUtil.sm4(keyBytes); // 密钥由初始化接口生成 File outputFile new File(finalDir, uploadId _merged.bin); try (FileOutputStream fos new FileOutputStream(outputFile)) { for (ChunkMeta meta : metas) { byte[] iv deriveIV(baseIV, meta.getChunkIndex()); File chunkFile new File(chunkDir, chunk_ meta.getChunkIndex()); byte[] encryptedBytes FileUtil.readBytes(chunkFile); // 设置iv进行解密 byte[] decrypted sm4.decrypt(encryptedBytes, iv); fos.write(decrypted, 0, decrypted.length); } } }这里有个大坑必须提醒sm4.decrypt(encryptedBytes, iv)返回的是完整解密后的明文块但由于PKCS7Padding的存在最后一块解密后会多出一个填充块如果把填充内容也写入输出文件就可能导致最终文件比原始文件多出几个字节。你需要根据每个块的originalSize来截断只写入originalSize字节而不是decrypted.length字节。具体做法是把上面代码里的fos.write(decrypted, 0, decrypted.length)改成int writeLength (int) Math.min(meta.getOriginalSize(), decrypted.length); fos.write(decrypted, 0, writeLength);这个bug我当初排查了很久因为文件前几百MB都是正确的只有合并到最后一个块时文件大小对不上。原因就是最后一个块的原始长度不是16字节的整数倍PKCS7Padding会补到16的倍数解密后多出来的填充字节被写进了输出文件。4.3 合并的完整性与MD5校验合并结束后需要做整体完整性校验否则中间任何一个块缺失或者被篡改用户下载后才知道文件损坏代价太大。我在初始化接口里让前端计算完整文件的MD5并传给后端合并完成后后端对生成的最终文件重新计算MD5两者比对一致才返回成功。也有一种做法是前端每块加密前计算该块明文的服务端MD5后端解密后逐块校验。这种方式能在合并前定位到哪一块出了问题定位粒度更细。但如果文件本身很大逐块校验的额外开销也不小。我的做法是折中每块记录原始大小用于截断全文件MD5用于最终校验两侧都满足才算上传成功。如果MD5不一致后端需要把已有分块清理掉并返回错误码前端收到后重新发起整个上传流程。也正是基于这个机制我才把uploadId设计成可复用但不跨文件的标识保证每次上传任务都是唯一的安全上下文。5. 实操中必须避开的几个坑5.1 大文件加密导致的内存暴涨前端对分块做SM4加密时如果把整个分块读取成ArrayBuffer再加密4MB的块本身问题不大但如果你把整个文件读入内存再切块一个大文件就会直接把浏览器标签页干崩溃。我一开始就是先把完整文件读成ArrayBuffer再切片结果2GB文件直接让Chrome内存暴涨。正确的流程是边切边传一次只保留当前块的数据和加密结果在内存中。后端也一样解密时不要一次性读取整个文件块到byte[]而是用流处理。上面代码中我用了FileUtil.readBytes(chunkFile)这是Hutool的便捷方法但它在内部会把整个文件读入内存。如果分块是4MB问题不大但如果有人把分块调到了64MB甚至更大后端就可能出现内存抖动。更稳妥的做法是用流式解密接口Hutool的SM4也支持decrypt(InputStream, OutputStream)尽量用流处理。5.2 SM4加密后密文长度不一致导致的合并错位这是加密和分块结合时的核心难点。未加密的分块上传服务端直接按固定块大小顺序写入文件即可。但SM4-CBC加密后除了最后一个块每个明暗文的长度都可能是16的整数倍如果前端没有把原始块大小传给后端后端无法正确截断填充字节。我见过一个项目前端加密后直接把cipher.length传给后端后端合并时按密文长度写入最终文件比原始文件多出几百字节打开就是一个损坏文件。这个问题的根因就是没有保存原始块大小。我在接口参数里添加了originalSize后端解密后只取前originalSize字节问题随即解决。5.3 并发上传时全量重传和时段合并问题并发上传时如果前端某个块上传失败自动重试成功后又更新进度但后端的合并请求可能已经发出去了导致合并时少了那一块。我加的解决方法是后端合并前检查metaStore中已保存块的序号是否连续从0开始到总块数-1全部存在才允许合并否则返回等待补块错误。这个检查逻辑要放在合并操作的入口处boolean allChunksPresent true; for (int i 0; i totalChunks; i) { if (metaStore.getChunkMeta(uploadId, i) null) { allChunksPresent false; break; } }如果返回404或者WAIT状态前端就找出缺失的块号重新上传缺失块后再发起合并。这个机制保证了断点续传的可靠性。5.4 密钥在前端暴露的风险处理前端JS代码里必然能看到SM4密钥这是纯前端加密无法回避的现实。完全杜绝不现实但可以控制密钥的时效性和作用域。我只让密钥存活在初始化接口返回之后到本次上传任务结束之间密钥是随机生成的会话级密钥不是长期固定密钥。前端获取密钥后存储在内存变量里不写入localStorage或cookie页面刷新立即失效需要重新初始化。如果项目对安全要求更高可以考虑使用国密SSL或者SM2/SM4混合方案前端用SM2公钥加密SM4密钥再用SM4密钥加密文件内容。这本质上是一种混合加密安全性强不少但前端计算成本也会上升需要根据实际场景取舍。6. 性能调优和经验总结6.1 并发数、分块大小与网络环境的匹配我整理过一组推荐配置场景场景分块大小并发数说明内网千兆传输8MB6大块高并发带宽优先公网普通宽带4MB4平衡稳定性和速度移动网络2MB3小块低并发容错优先极差网络环境1MB2最大程度避免整块失败重传这几个参数不是固定的前端可以在初始化阶段根据网络类型动态调整。我在项目里做了个简单的网络探测先上传一个1KB的探测块记录RTT再根据RTT动态决定分块大小和并发数。实测下来在弱网环境下上传成功率比固定参数提升了将近两成。6.2 为什么说SM4加解密用CBC模式而不是ECB模式这是我在验证阶段实际对比过的。ECB模式实现简单、支持并行、速度更快但同一份明文中如果有重复的16字节明文块产生的密文块也相同。大文件里重复块出现的概率非常高特别是文档类、日志类文件文件头、空白字符、对齐数据都有大量重复片段用ECB加密后密文里会出现明显的重复模式抓包分析就能推断文件的大致结构。CBC模式通过IV串联每个密文块消除了这种模式泄漏。性能上CBC比ECB慢一截但对大文件分块上传来说单块只有4MB慢的那点毫秒级时间可以忽略不计。6.3 上传完成后的资源清理很多人做完合并就把资源和临时数据忘了时间长了服务器磁盘会被临时文件堆满。我在合并成功或者失败超时后必须清理两类东西一是临时分块目录chunkDir下的所有文件二是metaStore里对应的缓存记录。我现在是在合并结果确定后无论成功失败都立刻调用清理逻辑并在原metaStore里加上一个Void状态标记防止清理过程中用户重复触发合并请求读到一个已经被清空的块。还有一个定时兜底每天凌晨跑一次定时任务扫描超过24小时且状态不是已完成的上传任务直接把对应临时目录和元数据全部清除。这个兜底措施很省心不需要每次上传都担心脏数据残留。6.4 整体方案回顾这套方案的最终形态是前端File切片、SM4-CBC动态IV加密、并发上传、失败重试后端Servlet接收、按uploadId和chunkIndex落盘、合并前校验块连续性、按originalSize截断解密明文、MD5整体校验。整个链路里最核心的不是某个单独的加密或切片步骤而是每一层之间的元数据对齐原始大小、块序号、IV偏移量、上传进度这些信息只要有一个错位整个文件就会损坏。我也是踩了两次合并错位的坑之后才彻底理解了这块数据对齐的重要性。如果读者自己实现建议先把流程图画清楚明确每块请求携带哪些元数据、后端每个时间点如何使用这些元数据再动手写代码。顺序是先写后端接口再写前端上传逻辑最后做联调这样定位问题时能更高效地确认是前端的元数据传错了还是后端的解密合并逻辑出了问题。最后再分享一个小技巧联调阶段千万不要用大文件直接测全流程我习惯先写一个256KB的小文件把分块大小临时调成16KB这样能快速验证分块加密解密的正确性确认合并OK之后再把参数改回生产值调试速度能快好几倍。
返回列表