前两个月我接了一个芯片制造产线数据回传平台的改造,最让我头疼的就是大文件上传这块。产线上每天产生大量固件包、老化日志和工艺参数文件,单个文件从几十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就是identifier,field存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在千兆内网里根本藏不住,但放到产线就是事故。做这类系统的,提前把网络最坏情况想明白,比多写一百行业务代码都重要。