
前段时间接了一个信创环境下的项目交付对方拿一套SpringBoot 3.x的大文件上传服务往国产化服务器上部署结果项目直接起不来——现场JDK是1.8中间件走的是宝兰德传输链路里还夹着一层“数据必须加密”的硬要求。整个排查过程把上传、加密、部署三块全部牵扯出来最后折腾了好几天才把链路跑通。这篇文章就是那次交付之后的完整复盘聊聊在信创环境下用SpringBoot做大文件上传和加密传输时那些文档里不会写、但实际一定会踩的东西。先说结论这类项目能不能顺利落地往往不取决于你的上传代码写得有多花哨而是取决于你花多大力气去适配底层环境。JDK版本、Servlet容器、类加载器、请求体大小限制任何一个环节没对齐功能在本地再正常上了生产也会出幺蛾子。1. 先摸清底座信创环境到底有哪些隐藏差异在动手改代码之前先回答一个问题现场到底是怎么部署的我接触过好几个叫“信创环境”的项目实际形态完全不同。有的就是普通Linux OpenJDK8跑一个SpringBoot jar包有的强制要求应用部署到宝兰德等国产中间件上还有的数据库要从MySQL换成国产数据库。大文件上传功能本身对数据库依赖不大但JDK、Servlet容器、JCE这几个点直接影响整个方案怎么设计。1.1 JDK和SpringBoot版本的匹配问题最容易踩的坑就是版本。信创服务器上默认装的往往是OpenJDK 8SpringBoot 3.x最低要求JDK17现场如果没有更高版本的JDK项目连启动都过不去。我们当时拿到一个SpringBoot 3.2的工程在本地一切正常到现场直接报UnsupportedClassVersionError。处理思路有两种。第一种是让现场安装JDK17但部分环境管控严格装软件要走审批流程一等就是几天。第二种更省事把SpringBoot从3.x降到2.7.x。2.7还处于维护期对JDK8支持非常稳定而且上传、加密、Servlet这套API和3.x差别不大改造成本很低。我个人的建议是如果没有必须用SpringBoot 3的新特性比如AOT编译、GraalVM原生镜像信创项目优先选2.7.x JDK8这个组合兼容面最宽各个国产中间件提供的适配starter也基本都是围绕这个版本段做的。1.2 容器、数据库和底层库这些“隐形组件”部署形态决定了后面的适配量。如果直接用jar包跑内嵌Tomcat处理上传没问题配置好spring.servlet.multipart就行如果要求部署到宝兰德这类中间件那你至少要把项目打成war包或者引入中间件厂商提供的SpringBoot starter来替换内嵌Tomcat。一旦切换到中间件容器上传配置可能被容器接管HttpClient、Jackson这些基础库也可能和容器自带的版本冲突表现就是各种稀奇古怪的NoSuchMethodError。数据库方面也需要提前确认。大文件上传如果要做断点续传和秒传会涉及任务表、分片记录表这些建表SQL尽量用标准语法不要在字段类型、自增主键、分页写法上依赖MySQL私有特性否则后面迁到达梦、金仓这类数据库时会非常痛苦。我把这类项目里最需要提前确认的环境项列一下环境项常见现场状态对上传功能的影响JDKOpenJDK 8 或 11SpringBoot 3.x无法启动需要降到2.7.xServlet容器宝兰德/东方通/内嵌Tomcat请求体大小限制、超时配置位置不同数据库达梦/金仓/PostgreSQL建表SQL和分页语法需要通用化加密依赖BouncyCastle版本差异部署到中间件时可能出现类加载器冲突反向代理Nginx/自研网关client_max_body_size不放大传大文件必挂这些环境信息不是看一遍代码就能知道的建议一到现场就拉一个环境清单逐项确认。别急着改代码环境参数没对齐后面所有改动都可能是白工。2. 上传链路设计前端Worker分片、后端接口与任务状态管理环境问题理清楚之后再聊大文件上传本身。一个几百MB甚至几个GB的文件如果整体走一个POST请求后端内存会直接被打爆网络稍微波动一下整个文件就得重传。所以分片上传是标配方案。分片本身不难真正的难点在“怎么不卡页面”和“怎么把分片状态管理好”。2.1 为什么必须用Worker做分片与Hash计算前端要完成分片上传至少要干三件事把文件切成若干片、计算整个文件的Hash、逐片并发上传。切分和上传都不太消耗CPU但计算文件Hash是重活。如果不开Worker主线程在做SparkMD5逐块读取时浏览器会卡到像是崩溃了用户肯定受不了。用Web Worker把这些重活全部丢到后台线程主线程只负责渲染进度条。现在前端框架里把File对象传给Worker非常方便Worker里拿到的仍然是同一个Blob不会因为结构化克隆而丢数据切出来的分片可以直接用File.slice()生成。要注意的是Worker环境里不能操作DOM也不能访问window下的大部分API纯计算逻辑在Worker里跑就对了。计算Hash建议用增量方式不要一次性把整个文件读进内存。SparkMD5支持增量计算用FileReader分块读取文件流边读边更新Hash状态既省内存又能准确算出整个文件的指纹。2.2 分片接口、上传任务与临时文件组织后端接口设计成三个就足够清晰POST /upload/init创建上传任务返回uploadIdPOST /upload/chunk接收单个分片落盘到临时目录POST /upload/merge客户端通知服务端所有分片传完触发合并/upload/init接收文件名、文件大小、分片大小、总分片数、文件Hash这些元数据在服务端生成一个上传任务记录返回一个全局唯一的uploadId。这里用UUID生成uploadId而不是直接用文件名原因是文件名可能重名、可能带特殊字符直接拿它当目录名容易被路径遍历攻击用服务端生成的随机串既隔离任务又安全。/upload/chunk的代码骨架大概是这样的RestController RequestMapping(/upload) public class UploadController { private final Path tmpRoot Paths.get(/data/upload-tmp); PostMapping(/chunk) public Result uploadChunk(RequestParam(uploadId) String uploadId, RequestParam(index) int index, RequestParam(total) int total, RequestPart(chunk) MultipartFile chunk) throws IOException { // 校验uploadId是否存在 File dir tmpRoot.resolve(uploadId).toFile(); if (!dir.exists() !dir.mkdirs()) { throw new BizException(临时目录创建失败); } // 分片统一命名为 index.part File partFile new File(dir, index .part); chunk.transferTo(partFile); // 这里可以记录分片上传状态用于断点续传 return Result.ok(); } }临时目录的组织方式建议是/data/upload-tmp/{uploadId}/{index}.part。为什么每个任务单独一个目录因为合并时只需要遍历这个目录下的分片按index排序就行清理任务时直接删整个目录也方便。临时目录的磁盘空间和写权限必须提前确认分片落盘时如果目录没有写权限报错信息会很隐蔽甚至只告诉你“文件上传失败”。2.3 一个可落地的分片参数建议分片大小和并发数是需要结合网络环境调的。分片太小每个分片都要带一遍HTTP头、走一遍加解密额外开销占比太高分片太大单个分片丢了重传的成本就上去了。常见的经验值我整理成了一个表格网络场景建议分片大小建议并发数普通办公网上行2Mbps-10Mbps2MB - 5MB2-3内网/专线百兆以上10MB - 20MB3-5弱网/跨地域传输1MB - 2MB3并发数不要开太高控制在3-5比较稳。并发太高会把服务器的连接数和线程池直接打满影响其他业务接口的响应。另外如果一个100MB的加密文件加密本身就有CPU开销分片太小会让加解密的固定损耗占比过大得不偿失。3. 数据加密怎么做SM2交换密钥、SM4加密分片的落地细节“加密传输”这四个字听起来简单实际拆解起来有两个层面通道加密和数据加密。HTTPS管的是通道层数据到了后端之后如果直接落盘文件还是明文的。信创环境里通常还会对算法有明确要求所以完整方案往往需要落到国密算法上。3.1 为什么加密传输不等于单纯上HTTPSHTTPS解决的是“传输过程中会不会被窃听”的问题但大文件上传场景里还有一层需求数据在后端处理过程中尽量不要出现完整明文。比如分片先落到临时目录临时文件如果被其他人读到就是明文再比如应用日志不小心把上传内容打出来了也是泄露。所以更合理的做法是前端把分片加密后再发送后端解到内存后使用完整文件落库时再用服务端密钥加密一次。这样整条链路里除了业务代码主动解密的那一瞬间其他环节都不存在完整明文安全性高一个量级。3.2 混合加密的标准套路与国密选型加密大文件时直接用非对称加密是行不通的。SM2这类非对称算法计算开销大加密几千字节的数据就很慢更别说几个GB的文件了。所以标准做法是混合加密前端进入上传页面前先调用服务端接口获取SM2公钥前端随机生成一个16字节的SM4会话密钥用SM2公钥加密这个SM4密钥得到encryptedKey之后每个分片都用SM4密钥加密encryptedKey放在请求头里一起传过去服务端用SM2私钥解出SM4密钥再用它逐片解密这样做的本质是用非对称算法解决密钥分发问题用对称算法解决大文件加密的性能问题。SM4密钥是一次性的会话结束或者上传完成就作废即使被截获也只能影响到当前这一次上传任务。另外要说明一点如果项目没有强制的国密校验要求直接用AES-GCM从技术上完全可行性能不错还自带完整性认证。但如果验收或合规要求明确指定国密算法就得老老实实用SM2SM4这套方案。3.3 每次加密的IV和认证标签怎么管理国密方案里最容易被忽略的细节是IV的管理。如果使用SM4-GCM这类带认证标签的模式同一把密钥下的IV绝对不能重复。IV一旦重复两个分片的密文会被异或出密钥流加密直接失效。必须保证每个分片加密时都生成一个新的随机IV把IV随分片一起传给后端。这里有一个实际兼容性问题前端常用的sm-crypto库对SM4的支持主要是CBC和ECB模式GCM模式不一定有现成实现。我在项目里采用的折中方案是分片用SM4-CBC加密额外用SM3计算密文摘要后端先校验摘要再解密。虽然不如GCM这种标准AEAD优雅但结合HTTPS通道实际安全性已经够用而且前端库成熟、后端BouncyCastle直接支持落地风险小很多。如果必须用GCM可能需要前端自行封装WASM实现成本会高不少。3.4 前端加密代码与服务端解密逻辑示例前端在Worker里的加密逻辑大概是这样import { sm4, sm2, sm3 } from sm-crypto; // 假设 sm4Key 是会话密钥iv 是每次分片随机生成 function encryptChunk(arrayBuffer, iv) { // sm-crypto 的 sm4.encrypt 接受字符串或 ArrayBuffer按实际API调整 const encrypted sm4.encrypt(arrayBuffer, sm4Key, { mode: cbc, iv, output: array }); // 对密文计算摘要防止篡改 const digest sm3(encrypted); return { data: encrypted, iv, digest }; }后端的解密逻辑用Hutool的国密工具封装会快很多// encryptedKey 是前端用SM2公钥加密后的SM4密钥 SM2 sm2 SmUtil.sm2(privateKey, null); byte[] sm4Key sm2.decryptStr(encryptedKey, KeyType.PrivateKey); // 用SM4密钥解密分片 SM4 sm4 SmUtil.sm4(sm4Key); byte[] plainBytes sm4.decrypt(encryptedBytes, iv);这里有个特别重要的实践经验接口文档里必须把密钥和IV的编码格式写死。前端用Hex还是Base64传参后端就必须用对应方式解析。我见过太多次“本地联调好好的到现场解密失败”的情况最后发现是前端把IV转成了Hex字符串后端默认按UTF-8字符串解析密钥对不上全链路崩掉。4. 合并与校验分片攒齐之后真正决定成败的几个点分片全部传完并不等于文件就传成功了。合并阶段才是大文件上传里出错最多的地方。网络并发会导致分片到达顺序和提交顺序不一致前端重试也可能让同一个分片被提交多次这些情况如果没处理好合并出的文件就是损坏的。4.1 分片乱序、缺片与重复分片的处理合并时绝对不能按接收顺序做文件追加写入。正确做法是每个分片落盘为{uploadId}/{index}.part文件index从0开始编号合并时读取所有分片按index排序后按偏移量写入目标文件。接收分片这个接口还要考虑幂等性。前端并发上传时同一个分片可能会重复提交服务端应该判断一下分片是否已经存在如果存在直接返回成功而不是重复写一遍。否则分片内容被覆盖写入极端情况下会把正常的分片写坏。4.2 合并方法选择和哈希校验合并操作推荐用FileChannel按偏移量写入而不是用Files.write循环追加。原因很简单按偏移写能严格执行index * chunkSize这个位置即使某个分片晚到只要分片文件完整合并结果就是正确的。示例代码如下Path dest Paths.get(/data/files, fileName); FileChannel out FileChannel.open(dest, StandardOpenOption.CREATE, StandardOpenOption.WRITE); for (int i 0; i totalChunks; i) { Path part tmpDir.resolve(i .part); try (FileChannel in FileChannel.open(part, StandardOpenOption.READ)) { // 关键点按偏移量写入而不是追加 out.position((long) i * chunkSize); in.transferTo(0, in.size(), out); } } out.close();合并完成后必须对整个文件重新计算摘要和前端在/upload/init阶段传的fileHash比对。如果一致说明文件完整如果不一致直接删除合并产物让前端重新上传缺失的分片。前端计算Hash建议用SparkMD5的增量计算实现一次性读取整个文件会让浏览器内存爆掉。文件大小完全一致但合并后文件打不开这种问题十有八九是偏移量算错了特别是最后一片的大小如果没按实际剩余字节算偏移就会错位。所以chunkSize和totalChunks必须在任务创建时固定下来合并全程使用同一套参数。4.3 秒传和断点续传的工程化思路有了任务表和分片状态秒传和断点续传就是顺带的事。/upload/init阶段先按fileHash查一下服务端是不是已经存在相同文件存在就直接返回“秒传”前端不用再传任何分片。如果是一个没传完的任务接口可以返回已上传的分片编号列表前端只上传缺失的分片即可。任务表字段可以设计成字段说明upload_id任务IDUUIDfile_hash文件完整Hashfile_name原始文件名file_size文件总大小chunk_size分片大小total_chunks总分片数uploaded_chunks已上传的分片编号可以用逗号分隔存储status状态0上传中、1已完成、2已过期create_time / update_time时间字段断点续传的关键在于uploadedChunks要实时更新每收一个分片就更新一次。否则前端刷新页面后查询已传分片拿到的数据是旧的会把已经传过的分片重复传一遍。5. 部署到中间件宝兰德替换内嵌Tomcat时的实测适配记录开发环境下内嵌Tomcat跑得好好的上传接口一旦部署到中间件上就可能抽风。这一部分是那次交付里耗时最长的环节我把关键问题完整记录下来。5.1 war包改造还是嵌入式容器替换部署到宝兰德这样的中间件有两条路可以走。第一条是打成war包部署对现有代码改动最小。SpringBoot应用只需要让启动类继承SpringBootServletInitializer并重写configure方法SpringBootApplication public class UploadApplication extends SpringBootServletInitializer { Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(UploadApplication.class); } }这样应用会以war包形式部署到中间件的Servlet容器里内嵌Tomcat不会启动。项目里如果有基于Servlet的Filter、WebSocket、临时文件绝对路径这类代码war包方式通常更稳一些因为中间件对Servlet规范的实现比较成熟。第二条路是保留SpringBoot启动方式引入宝兰德提供的嵌入式容器starter把内嵌Tomcat替换掉。这个方案需要用到SpringBootApplication(exclude TomcatServletWebServerFactoryAutoConfiguration.class)之类的排除配置。思路是对的但对SpringBoot版本和中间件版本的匹配要求非常高版本对不上大概率启动失败。我的建议是优先war包方式能不做容器替换就别做。5.2 请求体大小限制是三层叠加的问题大文件上传在中间件环境中最隐蔽的坑就是“413要到处找”SpringBoot的配置明明已经放开了前端还是提示上传失败。这是因为大文件请求体要经过三层限制任何一层没放开都会失败限制位置关键配置常见默认值SpringBootspring.servlet.multipart.max-file-size / max-request-size1MB / 10MB宝兰德中间件请求体大小限制不同版本名称不同几十MB不等Nginx反向代理client_max_body_size1m网上搜这个问题大概率只提到SpringBoot那一层但实际生产环境中间件和Nginx的存在让问题变复杂。排查思路很直接一层一层关掉限制试先调SpringBoot再调Nginx最后调中间件。这个顺序比较符合“离应用越近越容易改”的原则。还有一个环境层面的因素容易被忽略上传临时目录的磁盘空间。分片落盘时如果/data/upload-tmp所在分区满了报错可能不是典型的“磁盘空间不足”而是各种奇怪的IO异常。上线前务必检查临时目录的挂载路径、容量和写权限。5.3 中间件下的类加载器冲突排查这是宝兰德这类中间件特有的坑。中间件自带了一套类库应用里如果也打了BouncyCastle或者某个版本的Jackson部署时可能被容器的父类加载器优先加载导致应用实际用到的类和开发环境完全不一样。典型报错是NoSuchMethodError、NoClassDefFoundError而且通常只在部署到中间件之后才出现本地用内嵌Tomcat怎么跑都没事。排查方法不复杂启动日志里加上-verbose:class或者打开中间件的类加载日志看BouncyCastle到底是从哪个jar加载的。解决办法有两个方向把中间件自带的旧版本库替换成应用需要的版本放到容器共享lib目录下在中间件部署配置里调整类加载顺序开启“优先应用类加载”的选项这块一定要在联调阶段提前验证不要等到上线了才发现加密模块在开发环境正常、部署到中间件就报错。如果要我用一句话总结这次交付的体会信创环境下的项目代码层面的工作只占一半另一半是在和版本、容器、部署配置缠斗。分片上传和加密传输的技术方案本身是通用的但真正的门槛在于你要能看清现场环境的每一个细节。最后还想起一个细节。做国密改造时前后端联调最容易翻车的不是算法本身而是编码格式的口径不统一。建议在项目一开始就把所有密钥、IV、密文的编码方式写进接口文档Hex还是Base64统一了能省掉后面一大半的排查时间。这个坑我踩了不只一次也希望看到这篇文章的朋友别再踩一遍。