最近在做一个文件传输平台核心场景是把几十GB的教学视频传上服务器。一开始我直接用普通HTTP上传结果被线上环境教做人改用Java分块上传顺手把加密传输也设计进去了。这篇把方案、代码和踩坑过程都写透适合正在做文件上传模块或者对数据安全有要求的Java后端同学参考。你会看到分块大小怎么算、AES-GCM怎么用、初始化/上传/合并三个接口怎么设计、合并时怎么校验以及一堆生产环境才会暴露的问题。1. 为什么分块上传要搭配加密传输1.1 大文件上传的痛点大文件上传第一个问题就是网络不稳定。几百MB甚至几GB的文件一次性POST发过去很可能在中途断掉断掉以后整包重传对客户端和服务端都是灾难。服务端如果直接把MultipartFile转成byte[]几GB文件全塞进JVM堆内存GC停顿就能让人怀疑人生甚至直接OOM。所以分块是刚需把文件切成固定大小的块每块独立上传失败只重传那一块还能用并发上传提高吞吐。第二个痛点是断点续传。一个文件上传到一半用户切了网络或者手机关了整个文件要重传这在产品上很难接受。切分块之后客户端可以本地记录已上传的分块列表下次只传缺失的块体验完全不一样。有些场景还需要边录边传比如直播录制、监控视频回传分块更是唯一解。除此之外分块对服务端的存储也有好处。每一块可以先落到临时目录或对象存储合并时才生成完整文件全程不需要把大文件读进内存。这也是我在设计文件传输模块时坚持把分块作为基础能力的原因。1.2 加密传输解决的不只是“防窃听”很多同事问过我“都上HTTPS了为什么还要在应用层加密”这个问题很典型。HTTPS解决的是传输链路的保密性和完整性它保护的是“数据在网络上跑的时候”不被中间人截获和篡改。但文件传上去之后总要在服务器上落盘要经过网关、代理、日志系统甚至中间件的临时文件。这一路上的泄露点非常多HTTPS管不到。如果业务对数据安全有硬要求比如隐私数据、加密备份、企业文件外发正确做法是“端到端加密”客户端把文件内容加密成密文再把密文传上去服务端只保存密文。就算数据库被拖库、磁盘镜像被拿走对方拿到的也是一堆看不出明文的数据。分块上传的场景尤其明显因为分块在传输过程中会形成多个临时文件如果每个分块都加密临时文件被拖走也只是密文碎片。所以“加密传输”在我这里其实是两层叠加第一层是TLS保证链路安全第二层是应用层文件加密保证数据落盘安全。两者解决的不是同一个问题不能互相替代。这轮设计里我默认严格要求客户端把每个分块先加密再上传服务端不接触明文。2. 分块与加密的方案选型先搞清楚这些核心参数2.1 分块大小拍脑袋不如算一笔账分块大小直接决定请求数量和失败重传的代价。以500MB文件为例1MB一块是500个请求5MB一块是100个请求10MB一块是50个请求。请求越多网络往返、鉴权、日志开销越大块越大单次传输时长越久遇到网络抖动的概率也越高。我一般给公网环境定1MB到10MB最常用5MB局域网或者云内网可以大胆点到16MB甚至32MB。还要看服务端限制比如对象存储的分片上传通常会规定分片最小值一般不低于100KB。分块数也可以直接算出来totalParts (fileSize partSize - 1) / partSize。注意最后一块不一定是完整partSize服务端和客户端都要对“最后一块大小”做兼容不能假设所有分块一样长。并行上传时线程数不要盲目拉高5到10个比较合理因为每个分块都要经过加密和校验CPU和带宽都会成为瓶颈。2.2 应用层加密算法为什么我推荐AES-GCM文件加密不能直接用RSA去逐块加密非对称加密性能实在太差。合理做法是“信封加密”用一把对称密钥去加密文件分块再用非对称密钥或KMS去保护这把对称密钥。对称加密算法里我首选AES-GCM。AES-GCM是一种AEAD算法能同时完成加密和完整性认证。它加密之后会附带一个16字节的认证标签tag解密时一旦发现密文被篡改会直接抛AEADBadTagException。这比AES-CBC强很多CBC只负责保密不防篡改往往还要自己再配一套HMAC工程上很容易配错。GCM的密文长度和明文长度几乎相等不存在CBC填充导致体积膨胀的问题。使用GCM时有一个铁律在同一个密钥下IV永远不能重复。IV可以简单理解成随机的一次性“盐”推荐用96位12字节。每次加密分块都要生成一个新的随机IV并把IV随请求带给服务端。IV本身不需要保密但绝对不能重用否则GCM的安全性会急剧下降。具体到Java生态JDK自带的AES/GCM/NoPadding足够用了不需要额外引入加密库。如果项目有国产化或者合规要求可以考虑SM4-GCM但JDK默认不支持需要依赖BouncyCastle生态会麻烦一些。不是重点合规场景我建议先用AES-128-GCM起步密钥长度不够再加到AES-256-GCM。2.3 完整性校验Hash不是可选项分块上传合并后文件损坏是我见过最多的线上事故之一。所以从第一版设计起就把Hash校验写进了接口协议里。每个分块上传时客户端计算出该分块密文的SHA-256服务端收到后重新计算并比对不一致直接拒绝该分块要求客户端重传。等所有分块都传完合并成完整密文文件后还需要对完整文件再计算一次SHA-256和初始化接口提交的整体指纹做对比。这一步能兜住“分块漏传、错传、合并时顺序错乱”等问题。这里要分清两个目标如果用SHA-256做防篡改攻击者完全可以把文件改掉后重新计算哈希意义有限。真正防篡改靠AES-GCM的认证标签只要密钥没泄露密文被改动就解不出来。因此我的实践是GCM负责防篡改SHA-256负责传输一致性和合并确认两者一起用谁也不能少。3. 核心接口与代码落地初始化、上传、合并怎么设计3.1 接口设计的基本原则分块上传最少要拆成三个接口init、uploadPart、complete。init负责申请上传任务上下文返回全局唯一的uploadIduploadPart负责上传单个密文分块complete负责合并分块并返回最终文件信息。再加一个cancel接口用于清理未完成的任务。接口建议直接用HTTP天然跨语言客户端无论是Android、iOS还是浏览器都能对接。传输二进制分块时不要为了图方便把密文Base64编码塞进JSON那样体积会膨胀33%几千GB的文件多传几十GB流量浪费很大。正确做法是直接用multipart/form-data或application/octet-stream。关于同步异步init和uploadPart可以同步返回complete因为要合并大文件建议做成异步任务客户端轮询结果避免HTTP连接长时间挂起导致超时。3.2 初始化上传接口返回uploadId是关键init接口的核心是创建一个上传会话让服务端知道接下来会有多少个分块上传过来。客户端把fileName、fileSize、partSize、keyId、fileSha256这些信息传上来服务端生成uploadId并保存任务状态。uploadId一定要全局唯一不能只靠数据库自增ID在分布式环境里去拼我直接用UUID替换掉横杠。生成之后要立刻落库不能放在内存里否则多实例部署时别的节点根本查不到这个uploadId。PostMapping(/init) public UploadInitResp init(RequestBody UploadInitReq req) { // 校验文件大小、分块大小是否合法 String uploadId UUID.randomUUID().toString().replace(-, ); int totalParts (int) Math.ceil((double) req.getFileSize() / req.getPartSize()); // 保存upload_session表状态INIT // 返回uploadId、partSize、totalParts给客户端 return new UploadInitResp(uploadId, req.getPartSize(), totalParts); }注意一点totalParts是必须返回的参数客户端之后的上传进度条、重试逻辑都要靠它。服务端在init阶段保存整体文件的SHA-256后面的complete阶段才能做最终校验。3.3 分块上传接口加密后传输与校验客户端拿到uploadId后按partSize读取文件分块对每一块明文执行AES-GCM加密。加密后生成三个东西密文、tag、随机IV。将IV、keyId、分块哈希一起传到服务端。为了简化协议tag通常和密文拼在一起解密时再从密文尾部取出。服务端uploadPart的核心逻辑是校验uploadId有效、partNumber不越界、分块哈希一致然后把密文分块写入临时存储。这里不推荐用file.getBytes()一把梭几GB文件不要转成byte[]操作直接用InputStream流式读取。PostMapping(value /part, consumes MediaType.MULTIPART_FORM_DATA_VALUE) public UploadPartResp uploadPart( RequestParam(uploadId) String uploadId, RequestParam(partNumber) int partNumber, RequestParam(keyId) String keyId, RequestParam(iv) String ivBase64, RequestParam(sha256) String sha256, RequestPart(file) MultipartFile file) throws IOException { byte[] ciphertext file.getBytes(); String actualHash DigestUtils.sha256Hex(ciphertext); if (!actualHash.equalsIgnoreCase(sha256)) { throw new BizException(分块哈希校验失败); } // 保存分块密文到临时目录同时将upload_part状态置为COMPLETED return new UploadPartResp(uploadId, partNumber, ciphertext.length); }这段代码里有几个容易踩的点DigestUtils.sha256Hex输出是小写十六进制客户端如果传大写比对时要注意忽略大小写。IV是随机生成的一般建议单独传因为后续合并后整段解密时需要每个分块的IV和分块一一对应。3.4 合并分块接口顺序、校验、落盘合并接口接收uploadId服务端先校验所有分块是否都上传完成。只检查总数还不够要按partNumber逐个检查状态防止中间漏了一块。然后按顺序拼接密文分块生成完整密文文件再计算整体SHA-256和init阶段保存的摘要对比。PostMapping(/complete) public UploadCompleteResp complete(RequestBody UploadCompleteReq req) { // 1. 根据uploadId查询所有分块校验状态 // 2. 按partNumber排序用FileOutputStream顺序写出密文分块 // 3. 计算合并后文件的SHA-256与init时的fileSha256对比 // 4. 校验通过把正式密文移动到最终存储目录 // 5. 清理临时分块更新upload_session状态 // 6. 返回文件key、大小、sha256 }如果是“端到端加密”模式服务端合并出来的文件依然是密文这时一定要把元数据保存好。至少需要记录algorithm、keyId、iv列表或者整体解密方式不然以后想下载解密都无从下手。4. 完整工程化落地密钥管理、存储与状态管理4.1 密钥管理千万别把密钥写死在类里加密方案里最容易翻车的就是密钥管理。有人图省事直接在代码里写private static final String AES_KEY xxxx;这在公司项目里迟早是个安全漏洞。正确做法是密钥独立存放用配置中心或者KMS服务管理。至少做到代码里只有keyId没有明文密钥密钥通过安全接口分发不硬编码到配置文件提交到Git仓库。分块上传是一个跨长时间的任务可能持续几十分钟。密钥轮换之后进行中的上传任务必须还能用历史密钥解密因此keyId要作为分块上传元数据的一部分记录下来不能丢。客户端拿到AES密钥时也需要加密传输通常做法是用RSA公钥包裹AES密钥后下发避免密钥在网络上裸奔。4.2 分块与元数据存储别只放内存很多教程里的Demo把分块信息放在ConcurrentHashMap单机演示没问题。可一旦生产环境多实例部署用户在A节点初始化上传请求被负载均衡打到B节点B节点查不到uploadId直接一脸懵。生产环境要持久化。我用的核心表包括upload_session和upload_part。前者保存任务基本信息包括uploadId、文件名、文件大小、partSize、keyId、整体SHA-256、状态后者保存每个分块的partNumber、状态、存储路径、哈希值。更新分块状态时要注意并发重复提交一个partNumber不能同时被两个请求写入成功可以加唯一索引或者用数据库乐观锁。4.3 服务端落盘密文合并与临时文件回收分块上传过程中临时分块文件会占用大量磁盘。每块上传后先落在临时目录合并后删除临时文件。如果任务一直不完成要有定时任务清理过期任务例如超过30分钟没有更新状态的上传会话直接标记失败并删除对应分块。对象存储场景下可以把每个分块作为单独的临时Object上传最后用对象存储的ListParts和CompleteMultipartUpload能力合并。这样服务端不需要自己处理文件流也省去磁盘占用问题。如果只是自建文件系统则需要自己实现合并注意用缓冲流别用一条条Byte数组逐个写速度慢且容易OOM。5. 常见问题与排查技巧实录5.1 上传过程反复失败先排查分块超时与重试线上第一个高频问题是某个分块总是失败或者大文件上传到一半整体卡住。常见的直接原因是HTTP连接超时配置太短默认30秒可能在弱网环境下不够传一个5MB分块。建议连接超时设5秒读超时设60秒以上并设计指数退避重试策略。第一次失败等1秒第二次2秒第三次4秒最多重试3到5次。并发上传时还要注意连接池大小。如果把HTTP连接池的最大连接数设置成200再同时起50个线程上传容易直接打满系统的文件描述符。我这边最终压到5个线程并发单线程5MB分块整个上传流程已经比原来一次POST快很多。5.2 合并后的文件打不开多半是校验与顺序问题合并后文件损坏排查顺序要固定下来。先看分块排序是不是严格按照partNumber升序拼接而不是按上传完成时间排序。再看每个分块的实际大小最后一块小于partSize是正常情况不能当成损坏。然后看哈希客户端和服务端要统一是“明文分块的哈希”还是“密文分块的哈希”。我在设计时用的是“密文分块哈希”因为服务端不接触明文无法计算明文哈希。如果客户端传了明文哈希服务端永远比对不通过这种Bug特别隐蔽。5.3 加解密报错、内存溢出性能与异常处理AES-GCM加密时Cipher实例不要设计成全局共享。同一个Cipher对象在并发加密下会出各种奇怪问题要么每个线程new一个要么用ThreadLocal包装。大文件处理时file.getBytes()是非常危险的操作几GB文件会把内存直接打爆一定要用流式读取每次只读一个partSize块。GCM解密时最常见的异常是javax.crypto.AEADBadTagException这个异常基本等于告诉你密钥对不上、IV对不上、密文被改过。排查时先确认keyId对应的密钥是否正确再确认解密时用的IV是不是加密时生成的那个IV千万别顺手写死了一个固定IV。5.4 常见问题速查表问题可能原因处理方式上传接口一直超时读超时太短、分块过大调整超时时间适当减小分块大小增加重试合并后文件损坏分块顺序错误、缺失分块、哈希算法不一致按partNumber排序检查分块状态统一哈希规则解密报AEADBadTagException密文被篡改、IV重复使用、密钥不对核对keyId和IV重新下载原文件磁盘被临时文件塞满未清理未完成任务定时清理过期uploadId删除临时分块多实例部署后上传失败分块元数据存内存迁移到数据库或Redis并发上传CPU飙高线程数和加密操作叠加控制并发数评估改用更轻量的AES-GCM实现6. 最后分享几个我踩过的坑6.1 先把明文流程跑通再加加密这是我做这个模块最大的体会不要一上来就把加密嵌套进分块逻辑。先实现一个普通的分块上传确认合并和校验都没问题再在客户端插入AES-GCM加密。这样任何一次失败都能快速定位是分块流程的问题还是加密的问题。我之前就是因为图省事加密和分块一起写一上来报Hash校验失败排查了大半天才发现是客户端把明文和密文哈希搞混了。6.2 日志里千万别打印文件内容加密传输容易让人忽略一个细节调试日志。为了排查问题很多人喜欢把HTTP请求体打出来如果直接打印MultipartFile的字节密文甚至明文就全暴露在日志平台里了。分块上传的日志只记录uploadId、partNumber、文件大小、哈希值不要打印文件内容。可以在网关或统一日志过滤器里把file字段过滤掉。6.3 元数据一定要预留扩展位上传接口的元数据设计要留一个扩展字段。后面想增加加密算法版本、压缩算法、病毒扫描结果或者做文件去重都是加字段的事。现在表里没有扩展位将来改动可能要重新刷数据。密钥版本号也一定要提前设计好每个上传任务记录keyId和algorithm否则密钥一换老文件全部解不开那真是“一夜回到解放前”。