尧图精选

SpringBoot大文件分片上传:国密芯片加密与断点续传实践

🕒 发布时间:2026/10/1 16:10:30 📁 来源:尧图网络
前两个月我接了一个芯片制造产线数据回传平台的改造最让我头疼的就是大文件上传这块。产线上每天产生大量固件包、老化日志和工艺参数文件单个文件从几十MB到1GB以上而且按公司的数据红线要求这些文件落盘之前必须经过国产加密芯片完成国密算法加密不允许明文直接进存储。产线到中心机房的网络也不省心既有Wi-Fi又有有线时不时闪断偶尔还限速。最初图省事直接用form-data整文件上传结果连续几次传一半断流整个文件重来的事故后来老老实实把方案改成了SpringBoot HTTP分片上传 国产加密芯片适配层问题才算真正落地解决。这个方案拆开看其实就三件事第一把大文件切成多个分片通过HTTP逐片上传实现断点续传和并发加速第二在服务端接入国密加密硬件用SM4加密每一个分片的数据SM3做完整性摘要第三用SpringBoot把这套流程串起来对外提供初始化、分片上传、合并校验几个REST接口。如果你正在做大文件上传或者需要在Java后端里集成国密硬件做数据加密这篇文章应该能给你一套可以直接抄作业的思路。只是想了解原理的话看完设计思路部分也会有收获。1. 先讲清楚这个场景到底要解决什么问题1.1 产线数据回传的三个硬约束芯片制造产线的数据回传和普通办公网传文件完全是两码事。第一个硬约束是数据规模测试工装在跑老化测试的时候日志是持续累加的一个批次跑下来能产生几百MB的日志镜像文件再加上固件包、良率统计、工艺参数快照高峰期一台测试机一天要往中心回传几十GB的数据。这种量级如果走单连接整包上传一条TCP连接长时间打满中间任何一次闪断都意味着全部重来。第二个硬约束是网络环境。产线不像写字楼有干净的办公网络设备分布在车间不同位置有的走工业Wi-Fi有的走有线中间还隔了好几层交换机和防火墙。现场实测下来Wi-Fi链路的抖动非常明显丢包率在高峰期能到百分之几整包上传在这种链路上基本就是碰运气。第三个硬约束是数据安全。半导体产线的工艺参数、良率数据和固件属于核心资产公司数据安全团队的要求很明确任何敏感文件在进入中心存储之前必须经过国产加密芯片完成SM系列算法加密密钥不能离开硬件模块。这就意味着加密动作不能放在文件到齐之后再做否则明文在服务器临时目录里停留的时间窗口是不可接受的。1.2 为什么必须选分片上传 芯片加密的组合很多人第一反应是既然有安全问题那把文件先传上来服务器再调用加密芯片加密不就行了这里有个关键漏洞文件在合并完成之前分片数据会以明文形式落在磁盘临时目录里。即便你事后加密并删除了临时文件谁也不敢保证在删除之前这些明文没有被其他进程读到这个风险在安全审计里是过不去的。分片上传正好解决了这个矛盾。数据从客户端到达服务端的那一刻就立刻交给加密芯片做密文化处理然后再落盘。临时目录里永远只有密文分片没有明文窗口。这是方案选择上的一个分水岭加密时机选在分片级而不是文件级。分片本身的价值就更不用多说。断点续传是核心收益客户端记录已经上传成功的分片序号下次启动时跳过这些分片网络再烂也不用从头再来。其次是并发加速几十个分片可以用多个HTTP连接并发上传特别是在网络延迟较高的链路上并发能明显提高吞吐。最后是进度反馈整包上传只能干等分片上传可以精确到百分比客户反馈体验好很多。选型上我对比过其他方案比如FTP断点续传、HTTP流式上传但最终还是选了HTTP分片上传。原因很简单这套上传接口要暴露给产线上不同语言的测试客户端调用C#、Python、Qt都有HTTP协议是兼容性最好的选择任何语言都有成熟的HTTP库而且SpringBoot提供MultipartFile支持后端实现成本很低。FTP虽然也支持断点续传但多一套端口、多一套账密管理在安全策略收紧的产线环境里很难过审批。2. 整体方案设计接口怎么拆数据怎么流2.1 分片上传的核心参数设计分片大小是第一个要定的参数。我最终选的是8MB这个值不是拍脑袋定的是从几个维度权衡出来的。分片越小断点续传的粒度越细但HTTP请求数量就越多每片的TCP握手和请求头开销就很可观分片越大请求数量少但单次失败的重传代价变大进度反馈也不够平滑。8MB在百兆到千兆的产线链路上单分片上传时间大概是几秒到几十秒配合加密芯片的处理速度整批任务节奏是比较舒服的。如果你所在网络环境延迟特别高或者带宽特别窄可以适当降到2MB或者4MB。接口上的核心参数我总结了一套约定前端和后端都按这个来对齐identifier上传任务的唯一标识客户端生成建议用文件MD5值的前16位加上随机数既能关联到同一个文件又能避免不同设备之间的任务号冲突chunkNumber当前分片的序号从1开始totalChunks总分片数客户端在初始化时告诉服务端chunkSize分片大小用于服务端校验fileSize和fileName文件大小和原始文件名合并和落库要用。服务端的状态管理我用了Redis的Hash结构key就是identifierfield存chunkNumber对应的处理状态Redis还能顺便做过期控制防止任务无限堆积。如果你不想引入Redis用ConcurrentHashMap加上定时清理也能凑合但多实例部署时会出问题产线系统后续大概率要扩容建议一步到位用Redis。分片数据的落盘结构也要提前设计好。我采用的目录结构是{存储根目录}/{identifier}/{chunkNumber}.tmp其中存储根目录按业务线划分便于实施不同的磁盘配额策略。每个分片经过加密后写入对应序号的文件文件名保持不变但内容已经是密文了这个目录天然就是安全的不需要再设置额外权限。2.2 加密芯片适配层的设计国产加密芯片的接入是整个方案里最容易踩坑的地方。芯片厂商提供的SDK千奇百怪有基于JNI的有提供C接口让你自己封装JNA的还有走网络socket通信的。我建议在SpringBoot工程里单独抽一层NationalCryptoService接口把芯片交互细节全部封装在实现类里业务代码只跟接口打交道。这个接口至少要有三个方法public interface NationalCryptoService { // SM4对称加密返回密文 byte[] sm4Encrypt(byte[] plaintext, String keyId); // SM3摘要计算 byte[] sm3Digest(byte[] data); // SM2签名 byte[] sm2Sign(byte[] data, String keyId); }为什么一定要抽接口我吃过亏第一版直接在网上找了段芯片调用的代码硬编码到上传逻辑里。后来换了芯片固件版本SDK的初始化方式变了结果要改动的地方遍布整个UploadController重构了大半天。抽成接口之后换芯片型号、升级SDK、加缓存层都只需要改一个实现类。芯片适配层内部有一个非常重要的子逻辑大块数据拆分。SM4算法本身按16字节一个分组工作但是芯片驱动对单次调用输入的长度是有限制的我用的这张卡单次最大支持64KB超过就直接返回参数错误。所以服务端拿到一个8MB的分片后不能一次性丢给芯片要先在适配层内部切成64KB的小块逐块送给芯片加密再拼接起来。这个过程对上层业务透明业务只需要调sm4Encrypt(byte[], keyId)不用关心底层切了几刀。并发控制是另一个关键设计。加密芯片本质上是个串行设备或者最多支持有限的并行通道。如果上传接口的并发度过高多线程同时往芯片丢任务轻则性能急剧下降重则芯片驱动直接报设备忙。我用了Semaphore来做限流核心代码如下private final Semaphore chipSemaphore new Semaphore(4); public byte[] sm4Encrypt(byte[] data, String keyId) { chipSemaphore.acquire(); try { // 调用芯片SDK return doSm4Encrypt(data, keyId); } finally { chipSemaphore.release(); } }许可数量取决于芯片型号我这边压测下来4个并发是最优值超过这个数芯片的响应时间就开始陡增。如果你的芯片支持多通道可以适当调大这个值。3. 核心实现SpringBoot接口与芯片调用落地3.1 接口定义与状态管理整个上传流程我拆成了三个接口加一个查询接口。初始化接口负责创建任务上下文分片上传接口负责接收和加密单个分片合并接口负责最后拼装和校验查询接口用来支持断点续传。先看初始化接口PostMapping(/api/upload/init) public UploadInitResponse init(RequestBody UploadInitRequest request) { String identifier request.getIdentifier(); if (strRedis.hasKey(uploadKey(identifier))) { // 已存在相同任务直接返回原信息实现幂等 return buildInitResponse(identifier); } UploadTask task new UploadTask(); task.setIdentifier(identifier); task.setTotalChunks(request.getTotalChunks()); task.setChunkSize(request.getChunkSize()); task.setFileName(request.getFileName()); task.setFileSize(request.getFileSize()); task.setChunkDigests(request.getChunkDigests()); // 每个分片的SM3摘要客户端预先计算 task.setKeyId(generateDataKeyId()); // 为本次任务生成一个数据密钥标识 saveTaskToRedis(task); return buildInitResponse(identifier); }这里有个经验点初始化时一定要客户端把每个分片的SM3摘要一并传上来。这样服务端每收到一个分片完成加密前可以先算一次摘要做比对能提前拦住数据损坏的错误不用等到合并阶段才发现问题。分片上传接口是整个流程的核心PostMapping(/api/upload/chunk) public Result uploadChunk(RequestParam(file) MultipartFile chunk, RequestParam(identifier) String identifier, RequestParam(chunkNumber) Integer chunkNumber) { // 1. 校验任务是否存在 UploadTask task getTaskFromRedis(identifier); if (task null) { return Result.error(任务不存在请先初始化); } // 2. 幂等判断如果该分片已上传完成直接返回成功 if (isChunkCompleted(identifier, chunkNumber)) { return Result.success(分片已存在); } // 3. 读取分片字节并做SM3校验 byte[] plainBytes chunk.getBytes(); byte[] digest nationalCryptoService.sm3Digest(plainBytes); if (!Arrays.equals(digest, task.getChunkDigests()[chunkNumber - 1])) { return Result.error(分片摘要校验失败); } // 4. 调用加密芯片做SM4加密得到密文 byte[] cipherBytes nationalCryptoService.sm4Encrypt(plainBytes, task.getKeyId()); // 5. 密文直接落盘 saveCipherChunk(identifier, chunkNumber, cipherBytes); // 6. 更新Redis状态 markChunkCompleted(identifier, chunkNumber); return Result.success(); }这6步是串行关系缺一不可。尤其第4步加密完成之后原来的plainBytes我立刻设为null让GC尽快回收避免大对象堆积触发Full GC。8MB的byte数组在并发上传时是很吃内存的我一度因为没注意这个细节导致服务在高峰期频繁Full GC接口响应从几百毫秒飙到十几秒。3.2 分片加密的核心逻辑分片加密这里有一个容易被忽略的细节SM4加密需要做数据块对齐。SM4分组长度是16字节芯片驱动通常要求输入数据长度必须按16字节对齐所以在适配层内部要补上PKCS7填充。这个逻辑网上有现成例子但很容易写错边界情况。我贴一下我封装好的核心写法public static byte[] pkcs7Pad(byte[] data) { int blockSize 16; int padding blockSize - (data.length % blockSize); byte[] padded Arrays.copyOf(data, data.length padding); for (int i data.length; i padded.length; i) { padded[i] (byte) padding; } return padded; }注意一个特殊情况如果数据长度恰好是16的整数倍填充长度就是16字节而不是0。我第一次实现的时候在这里偷了懒判断如果余数为0就不填充结果芯片直接报错数据长度非法。后来查了PKCS7的规范才明白填充是必须的永远要补至少一个字节这是为了让解密时能明确识别出填充区。在芯片调用层面加密一个大分片的流程是把8MB分片按64KB切成128个小块最后一个小块不足64KB也没关系送入芯片前先做PKCS7填充以及补齐处理逐个调用芯片驱动的加密方法得到128段密文按顺序拼接这128段密文形成一个完整分片的密文数据把这个密文数据写入对应的.tmp文件。整个过程听起来不复杂但务必注意芯片调用的连接复用。有些SDK每次调用都要重新打开句柄性能损耗很大。我的做法是在适配层持有一个芯片连接在初始化阶段就建立会话后续所有加密调用都复用这个会话只在应用关闭时释放。如果你用的芯片协议不支持长连接至少要加一个连接缓存池避免频繁创建销毁。3.3 合并与完整性校验所有分片上传完成后客户端调用complete接口触发合并。合并不是简单地把文件拼接起来而是有一个严格顺序和校验的过程。PostMapping(/api/upload/complete) public Result complete(RequestBody CompleteRequest request) { UploadTask task getTaskFromRedis(request.getIdentifier()); // 1. 校验所有分片是否都已上传完成 if (!isAllChunksCompleted(task)) { return Result.error(仍有分片未上传完成); } // 2. 按chunkNumber顺序读取密文分片写入最终文件 try (FileOutputStream fos new FileOutputStream(finalFile(task))) { for (int i 1; i task.getTotalChunks(); i) { byte[] cipherChunk readCipherChunk(task.getIdentifier(), i); fos.write(cipherChunk); // 逐分片写入避免一次性加载整个文件到内存 } } // 3. 对最终密文文件计算SM3摘要 byte[] fileDigest calculateFileSm3(finalFile(task)); // 4. 与客户端在init时提交的最终密文摘要比对 if (!Arrays.equals(fileDigest, request.getFileDigest())) { return Result.error(文件完整性校验失败); } // 5. 清理临时分片文件和任务状态 cleanupTask(task); return Result.success(); }这里我要特别提醒合并时不要试图把整个文件一次性读进内存。我见过有同事用Files.readAllBytes()合并几个GB的文件服务直接OOM。正确做法是逐分片读入、写出去虽然多几次磁盘IO但内存占用是恒定的稳定压倒一切。还有一个经验是关于校验时机的。我在合并完成之后再做一次SM3整体摘要是对分片摘要校验的兜底。分片摘要校验只能证明每个分片在传输过程中没有问题但证明不了合并顺序是正确的。整体摘要能发现合并逻辑里序号错乱的问题这是实际发生过的事有个客户端传分片时把序号顺序搞错了服务端按序号逐个拼接结果是乱序的文件人眼很难看出来但SM3一比对就露馅了。4. 实操中踩过的坑与排查方法4.1 加密芯片的块大小和并发限制这个坑我在前面已经提到了一部分但在实操中它的表现形态很迷惑。第一次压测的时候我拿一个10MB的文件测试偶尔出现SM4加密返回错误码87这样的报错。开始以为是数据问题排查了半天最后发现是并发量一高芯片驱动的内部缓冲区被多线程踩踏了。解决办法就是信号量限流把并发压到芯片能承受的范围。芯片对单次处理数据大小的限制也值得再强调一次。不同厂商的芯片性能差异很大有的单次调用上限是4KB有的是64KB还有高端加密机支持MB级别的单次调用。务必在集成阶段就看清楚SDK文档里的这个参数否则你的适配层切块逻辑就是错的。建议在适配层写一个单元测试人为构造一个比芯片上限大几倍的数据验证切块和拼接的正确性。密钥管理也是一个容易忽视的问题。国密芯片加解密需要密钥标识通常用一个keyId来索引硬件中存储的密钥同一个keyId加密的数据必须用同一个keyId才能解密。我在设计里为每个上传任务生成一个独立的keyId这样不同文件使用不同的数据密钥即便某一个密钥泄露影响范围也仅限单次上传任务。这个设计在安全评审时被加分不少。4.2 代理层超时与请求体限制SpringBoot本身对请求体大小是有限制的spring.servlet.multipart.max-file-size和max-request-size这两个配置项默认只有1MB和10MB分片方案下必须调大。我配置的是单文件16MB、请求体18MB给8MB分片留足余量也防止客户端实际发送的分片大小超过预期。但真正坑人的是前面还有一层Nginx。Nginx默认的client_max_body_size只有1MB如果你忘了调前端会一直收到413 Request Entity Too Large。这里要在Nginx的server配置里加上client_max_body_size 20m; client_body_timeout 120s;client_body_timeout这块也建议一并调大。分片上传走的是生产环境慢网络如果请求体在传输过程中长时间没有进展代理层超时会把连接切断。我遇到过客户端收到connection reset的报错服务端日志里却没有任何异常查了半天才定位到是Nginx的body超时设置太短。还有一点是关于HTTP连接复用。分片上传会频繁发起HTTP请求如果客户端每次新建连接TCP握手开销非常可观。我建议客户端使用连接池比如Java的OkHttp或者Apache HttpClient的连接池配置产线客户端用Python的话也可以用requests.Session保持连接复用。实测在同样网速下启用连接复用后的分片上传时间能缩短20%左右。4.3 断点续传的幂等处理断点续传的坑主要在幂等逻辑上。客户端重传一个已经上传过的分片服务端要能识别出来并直接返回成功。我最开始的实现里没有幂等判断重传的分片会再次触发加密和落盘导致同一分片被写了两遍后面的合并就直接乱了。加幂等判断需要注意先后顺序。必须先把分片状态检查放在分片数据保存之前也就是我前面代码里展示的那样先查Redis里的完成状态已完成的直接返回。这里有一个边界场景客户端在上传分片时网络超时了它不确定服务端到底收没收到于是重传了同一个分片但服务端第一次已经写入了密文只是响应超时。这种情况下重传请求会命中幂等判断直接返回成功也不会产生重复数据正好符合预期。还有一个并发隐患多个分片并发上传时最后一个分片可能抢跑先于其他分片到达。如果complete接口没有做好校验就会触达仍有分片未上传完成的分支返回错误。这个分支逻辑必须要放在合并操作之前不能依赖客户端在最后一个分片上传后再等一秒这种约定否则很容易在慢网络下翻车。最后是临时目录的清理策略。产线设备有时候会意外断电导致已经初始化但没完成的任务残留在Redis和磁盘里。我在Redis里给上传任务设置了24小时过期时间并写了个定时任务每30分钟扫描一次临时目录删除过期任务的残留分片文件。这类清理任务看着不起眼但上线后帮我省了不少磁盘空间尤其是有一次客户错误地发起了上百个初始化任务每个任务都写了一部分分片如果不清理磁盘很快就满了。我在实际推进这个项目的过程中最大的体会是分片上传本身并不难真正的复杂度在于分片上传和硬件加密这两个环节的衔接。加密芯片有自己的一堆边界条件——数据块大小、并发上限、密钥索引、填充要求任何一个没处理好整个链路都会变得脆弱。前期花时间把适配层做得干净一点把并发控制想清楚后面联调阶段能省掉大量不必要的来回撕扯。最后再分享一个小技巧上线前一定要准备一个网损模拟的测试环境最简单的办法是在服务端用tc命令加上随机丢包和延时模拟产线的真实网络质量。我用这个方式在测试环境把上传、断线重传、并发上传、乱序到达这些场景全部过了一遍找出了好几个只在恶劣网络下才会出现的边界问题。这些Bug在千兆内网里根本藏不住但放到产线就是事故。做这类系统的提前把网络最坏情况想明白比多写一百行业务代码都重要。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →