1. 为什么大文件上传一定要走“分块 + 加密”的组合拳
先聊一个很现实的需求背景。很多人一开始接触文件上传,第一反应是“这不就是拿 MultipartFile 接一下就完事了吗”。确实,几 MB 的小文件这么做没什么问题,但一旦文件体量上升到几百 MB、几个 GB,场景马上变味了。比如一个教学视频平台要上传讲师录屏,一个金融系统要上传带有客户信息的对账单,一个医疗系统要上传影像胶片,这些场景里文件即大又敏感,如果还是沿用“直接 Post 整个文件”的老路,超时、内存溢出、失败重传这些坑会一个不落全踩一遍。
那为什么要把“分块上传”和“加密存储”放到一起聊?原因也很直白:分块解决的是“传输可用性”问题,加密存储解决的是“落盘安全性”问题。前者管你能不能把文件顺利送达到服务器,后者管文件到了服务器之后万一被拖库、被脱库、被运维误操作拷贝走了,别人拿到的是不是一堆无意义的密文。两个需求单独拆开都有成熟解法,但放在一起做,会牵扯出一堆交叉细节——块怎么切、密文怎么拼、校验怎么做、断点续传怎么兼容加密、合并时怎么保证密文顺序不乱。这些才是真正值得花时间研究的重点。
这篇文章面向的是有一定 Java 后端基础、想在自己项目里落地“大文件安全上传”这套方案的开发者。整篇文章会沿着“为什么这么设计 -> 核心流程怎么走 -> Java 代码怎么落地 -> 常见坑怎么躲”这条线展开,涉及的方案和代码我都在本地和测试环境实际跑过,可以直接抄作业,但建议你至少把原理部分看明白再动手,不然出了问题很难定位。
在设计阶段,我先给这个项目定了几条硬性约束:
- 文件上传接口必须支持分块,单块大小可配置,推荐 4MB 到 8MB;
- 文件内容必须以密文形式落盘,服务端不能出现任何明文副本;
- 每块密文独立可校验,哪块坏了重传哪块,不用整文件重来;
- 支持断点续传,客户端能查询哪些块已上传;
- 不依赖具体云厂商,纯 Java 原生实现,方便横向迁移。
这套约束定下来之后,方案的整体轮廓就清晰了。下面分几个部分把完整的实现思路和技术细节展开讲。
2. 核心流程设计:从客户端切片到服务端落盘的完整链路
2.1 整体时序与模块划分
分块加密上传的完整链路,我习惯分成客户端、接入层、业务层、存储层四段来看。
客户端负责做两件事:把大文件按固定大小切成若干块;对每一块数据在本地完成一次“预加密”。这里有个容易被新手绕晕的点——是“先加密再分块”还是“先分块再加密”。我的答案是:先分块、再逐块加密。原因有两条:
第一,如果先对整个文件加密再分块,那每个分块的数据边界就依赖加密算法的输出长度,而很多加密算法(尤其带认证标签的 GCM 模式)会额外产生 tag 字节,导致块长度不稳定,服务端处理起来非常别扭。
第二,分块上传最大的优势是“失败重传只传坏块”,如果先整体加密再分块,你很难定位某一块解密失败的具体范围,等于丢掉了块级容错的能力。
所以正确做法是:客户端按固定大小切片,每一片独立做 AES-GCM 加密,产出密文块和时间戳、随机数等附加信息,再逐块上传。
接入层在这个项目里就是 Spring Boot 的 Controller,职责比较纯粹——接收分块、校验参数、落临时目录、更新元数据记录。不建议在 Controller 里堆业务逻辑,所有加解密、合并、校验统一丢给 Service 层处理。
业务层是核心,承担四件事:分块元数据管理、密文块的顺序管理、块完整性校验、最终合并与清理。存储层则比较简单:密文块放临时目录,合并后的密文文件放正式存储区,元数据放数据库,密钥单独交给配置中心或 KMS 管理。
在这个架构下,整个上传流程可以拆成下面五个阶段:
- 客户端发起初始化请求,携带文件名、文件大小、块大小等信息,服务端创建上传任务并返回 uploadId;
- 客户端并发逐块上传,每块携带 uploadId、块序号、本块密文长度和消息认证码;
- 服务端每收到一个块,先校验长度与元数据是否匹配,再写入临时目录,并更新该块的上传状态;
- 全部块上传完成后,客户端发起合并请求;
- 服务端按块序号顺序读取密文块,拼接成完整密文文件,删除临时分块,更新任务状态。
这套时序看起来不复杂,但里面每个环节的细节都比表面复杂,尤其是元数据设计和加解密参数的组织方式,下面单独拿出来说。
2.2 元数据设计——分块上传的大脑
分块上传的元数据设计是整个系统的“大脑”,这一步设计烂了,后面写多少代码都难受。我最终落地的表结构(以 MySQL 为例)大概是这样的:
上传任务表 upload_task:
- id:主键,业务侧生成
- upload_id:全局唯一上传任务编号,客户端后续所有请求都要带上
- file_name:原始文件名
- file_size:文件总大小(字节)
- chunk_size:分块大小(字节)
- chunk_total:总分块数
- upload_status:任务状态(0 初始化 / 1 上传中 / 2 已完成 / 3 已取消)
- key_id:加密密钥的版本标识,对应密钥管理系统中的一个密钥条目
- created_at、updated_at:时间字段
分块信息表 upload_chunk:
- id:主键
- upload_id:关联 upload_task
- chunk_index:块序号,从 0 开始
- chunk_size:本块实际长度
- chunk_md5:本块“明文块”的 MD5(校验用)
- chunk_auth_tag:GCM 模式产生的认证标签
- nonce:本块加密时使用的随机数
- upload_status:本块上传状态(0 未上传 / 1 已上传)
- upload_time:上传完成时间
可能有人会问:chunk_md5 存的是明文块的 MD5,那岂不是暴露了内容特征?这里要解释一下——分块上传过程中,MD5 的用途是校验“传输过程有没有损坏”,本块落盘之后服务端持有的是密文,MD5 并不会和密文存在同一个文件里,数据库和密文文件是分离存储的。而且 MD5 暴露的只是某一个 4MB 分块的摘要,不是文件全文的摘要,风险很小。如果你对安全性要求极高,可以不存 MD5,改用 AES-GCM 自带的认证标签做完整性校验,因为 GCM 的 tag 本身就具备“既认证加密结果、又防篡改”的能力。
这里还涉及一个关键排序问题:分块并发上传时,请求到达服务端的顺序是乱的,所以不能依赖“按到达顺序追加写入”,必须靠 chunk_index 来定位每一块。合并阶段按 chunk_index 升序读取密文块,拼出来的密文文件和客户端逐块加密的顺序完全一致。
2.3 断点续传与秒传的底层逻辑
断点续传依赖的是元数据里记录的状态。客户端重新发起上传时,先调一个 query 接口,服务端返回该 uploadId 下所有已上传分块的序号列表。客户端拿到列表后,只上传缺失的块,已存在的块直接跳过。
秒传的逻辑就更简单了——在初始化阶段计算整个文件的 SHA-256,拿到服务端查一下这个文件摘要是否已存在。如果存在,直接返回“秒传成功”,并把原文件的存储路径映射给当前用户。注意秒传一定要建立在“文件内容明文一致”的前提下,而不是文件大小和文件名一致,否则容易串数据。
有人会担心“先算整个文件的哈希再上传”是不是太慢。实际上,秒传只适用于文件已经在服务端存在的场景,这时候多花一次全文件哈希计算是值得的。而且现在很多语言做 SHA-256 的速度已经很快,一个 1GB 的文件大概一秒多就能算完,成本完全可接受。
2.4 块大小为什么选 4MB 而不是 1MB 或 16MB
块大小的选择是个工程权衡。选小了,请求数量多、数据库压力大、网络握手开销高;选大了,单块传输时间变长,失败重传的粒度变大,断点续传的优势被稀释。
- 1MB 块:请求频繁,100MB 文件就要 100 次 HTTP 请求,数据库写入 100 条记录,整体吞吐未必高;
- 4MB 块:100MB 文件 25 个块,网络波动时单块重传成本可控,是阿里云 OSS、腾讯云 COS 的默认分块区间,实践验证最稳妥;
- 16MB 块:适合内网高速传输,但公网环境下单块超时的概率明显增加。
所以我的最终选择是默认 4MB,同时在前端保留配置项,方便根据不同网络环境动态调整。如果你做的是局域网内的内部系统,切成 16MB 也没问题;如果是公网用户上传,还是老老实实 4MB 起步。
3. Java 后端核心实现:三步走落地加密分块上传
3.1 环境准备与技术栈
在动手写代码之前,先把环境列出来。我用的是 Spring Boot 3.x + JDK 17,加密库直接用 JDK 自带的 javax.crypto,不引入额外的第三方加密库。原因是很现实的:JCE(Java Cryptography Extension)自带的 AES-GCM 实现已经足够成熟,而且引入 Bouncy Castle 这类库会增加依赖体积和审计面,对于大多数业务场景属于过度设计。
工程依赖方面,除了 spring-boot-starter-web 之外,我加了一个 hutool 工具库方便处理文件操作和哈希计算,数据库用的 MyBatis-Plus。如果你项目里没有这些,直接用原生 JDBC 或 Spring JdbcTemplate 也一样能写,只是代码量会多一点。
一个重要的前置知识点是 JDK 版本和 AES-GCM 的关系。JDK 8 的早期版本对 AES-GCM 支持有性能问题,建议至少使用 JDK 8u161 之后的版本。我现在用的 JDK 17 在 GCM 模式的性能上已经很理想了,一个 4MB 分块的加解密耗时基本在几十毫秒量级,完全感知不到。
3.2 第一步:AES-GCM 加解密工具类
这一步是整条链路的基石。AES-GCM 是 AES 加密算法的一种工作模式,它同时提供机密性和完整性校验。GCM 模式输出的内容由三部分组成:密文主体 + 认证标签(tag),加密过程中还需要一个一次性随机数(nonce)。解密的时候,必须用同一个密钥和同一个 nonce,并且认证标签校验通过,才会输出明文,否则直接抛异常。
这里把最关键的工具类代码贴出来,每一段都有必要解释一下:
public class AesGcmUtil { // 密钥长度 256 位,nonce 长度 12 字节,tag 长度 128 位 public static final int AES_KEY_SIZE = 256; public static final int GCM_NONCE_LENGTH = 12; public static final int GCM_TAG_BITS = 128; // 加密:输入明文和密钥,输出 nonce + 密文 + tag 的合并字节数组 public static byte[] encrypt(byte[] plainText, SecretKey key) throws Exception { byte[] nonce = new byte[GCM_NONCE_LENGTH]; SecureRandom secureRandom = new SecureRandom(); secureRandom.nextBytes(nonce); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_BITS, nonce); cipher.init(Cipher.ENCRYPT_MODE, key, spec); byte[] cipherText = cipher.doFinal(plainText); // 输出结构:nonce(12字节) + cipherText(含认证标签) ByteBuffer buffer = ByteBuffer.allocate(nonce.length + cipherText.length); buffer.put(nonce); buffer.put(cipherText); return buffer.array(); } // 解密:入参是 encrypt 方法的输出,需要拆出 nonce 和其余部分 public static byte[] decrypt(byte[] encryptedData, SecretKey key) throws Exception { ByteBuffer buffer = ByteBuffer.wrap(encryptedData); byte[] nonce = new byte[GCM_NONCE_LENGTH]; buffer.get(nonce); byte[] cipherText = new byte[buffer.remaining()]; buffer.get(cipherText); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_BITS, nonce); cipher.init(Cipher.DECRYPT_MODE, key, spec); return cipher.doFinal(cipherText); } }这里有两个细节值得特别注意。
第一是 nonce 的随机性。GCM 模式最大的安全红线就是 nonce 不能重复使用。同一个密钥下,nonce 一旦重复,攻击者就可以通过 XOR 操作破解出明文内容之间的关系,整个加密体系直接失效。所以我用 SecureRandom 而不是 Random 来生成 nonce,前者是密码学安全的伪随机数生成器(CSPRNG),后者不是。在分块场景下,每个分块都必须生成独立的 nonce,绝对不能共用一个。
第二是输出结构的设计。encrypt 方法把 nonce 和密文拼在一起返回,decrypt 的时候先拆出 nonce 再解。这样设计的好处是,每个分块文件都是“自包含”的——你再也不需要额外去数据库查这个块的 nonce 是什么,避免了一次额外 IO。nonce 本身不是秘密,明文传输没问题,安全模型也不依赖 nonce 保密。
工具类写完之后,再补一个测试用例验证一下,确认加解密能正确还原原文,并且篡改密文后解密会直接抛异常。这一步看似多余,但实际开发中我见不少人把参数顺序写反、把 nonce 截断,导致上线后数据解不开,非常头疼。
3.3 第二步:分块接收与加密落盘接口
分块接收接口是客户端上传时直接命中的入口。这个接口要做的事情比表面看起来多:校验任务是否存在、校验块序号是否合法、把密文块写入临时目录、更新分块状态。核心代码如下:
@RestController @RequestMapping("/api/upload") public class ChunkUploadController { @Resource private UploadTaskService uploadTaskService; @PostMapping("/chunk") public Result<Void> uploadChunk(@RequestParam String uploadId, @RequestParam Integer chunkIndex, @RequestParam Long chunkSize, @RequestParam String chunkMd5, @RequestPart MultipartFile file) throws IOException { // 1. 任务校验 UploadTask task = uploadTaskService.getByUploadId(uploadId); if (task == null || task.getUploadStatus() != 0) { return Result.fail("任务不存在或已结束"); } // 2. 块序号合法性校验 if (chunkIndex < 0 || chunkIndex >= task.getChunkTotal()) { return Result.fail("非法的分块序号"); } // 3. 内容长度校验 if (file.getSize() != chunkSize) { return Result.fail("分块长度与声明不一致"); } // 4. 写入临时目录 String chunkPath = buildChunkPath(uploadId, chunkIndex); file.transferTo(new File(chunkPath)); // 5. 记录块元数据 uploadTaskService.markChunkUploaded(uploadId, chunkIndex, chunkMd5); return Result.success(); } }这里有几个隐含的坑想提一下。
第一个是transferTo方法有个隐蔽问题:如果目标路径和临时文件不在同一个文件系统(比如一个是容器内存盘、一个是普通磁盘),这个方法会先复制到内存再写,大文件容易 OOM。更稳妥的方式是用file.getInputStream()配合Files.copy()手动写入,或者用 hutool 的FileUtil.writeFromStream()。
第二个是密文块的临时存储路径设计。我的方案是upload/临时目录/{uploadId}/{chunk_index}.bin,好处是同一个任务的块天然放在一个目录里,合并时按序读取非常方便,清理时直接删目录也干脆。如果你把块路径设计成散落各处,后面合并和清理都会头疼。
第三个是分块写入后的元数据更新。这里要注意幂等性。客户端由于网络超时可能会重试同一个块,而重试请求到达服务端时,服务端并不知道这个块之前是否已经写成功。所以 markChunkUploaded 必须是幂等操作,写入前先查一下状态,如果已存在就直接返回成功,避免重复写入造成数据错乱。
数据库的设计上,chunk 表的记录最好在任务初始化时一次性批量插入,而不是每上传一个块插一条。这样有几个好处:一是上传过程中只需要更新状态,减少插入开销;二是在查询“哪些块还没传”的时候可以直接对状态字段做统计,不用关注记录是否存在;三是更容易发现重复块和缺块。
3.4 第三步:合并分块与解密校验
所有分块上传完成后,客户端调用合并接口。服务端要做的是:按块序号顺序读密文块 -> 拼接成完整密文文件 -> 校验总长度 -> 更新任务状态。附上核心实现:
public void mergeChunks(String uploadId) throws Exception { UploadTask task = uploadTaskService.getByUploadId(uploadId); if (task == null || task.getUploadStatus() != 1) { throw new BusinessException("任务状态异常,无法合并"); } // 校验所有块是否都上传完成 long uploadedCount = uploadTaskService.countUploadedChunks(uploadId); if (uploadedCount != task.getChunkTotal()) { throw new BusinessException("尚有分块未上传,无法合并"); } Path finalCipherPath = Paths.get(task.getFinalCipherPath()); try (OutputStream out = Files.newOutputStream(finalCipherPath)) { for (int i = 0; i < task.getChunkTotal(); i++) { Path chunkPath = Paths.get(buildChunkPath(uploadId, i)); byte[] cipherChunk = Files.readAllBytes(chunkPath); out.write(cipherChunk); } } // 清理临时分块目录 FileUtil.del(Paths.get(buildTaskDir(uploadId)).toString()); // 更新任务状态 uploadTaskService.finishTask(uploadId); }这段代码有几个细节。
第一个是合并前必须二次校验“分块完整性”。为什么不直接信任客户端传的“全部传完了”?因为客户端可能在传输过程中出现假死、内存溢出、线程中断等情况,认为自己传完了,实际上最后一个块并没有落盘。服务端必须自己数一遍已上传块数量,和预期数量比一下,不满足就直接拒绝合并。
第二个是合并的粒度。我这里的示例是逐个读小块再写入输出流,用 Files.readAllBytes 没问题,因为每个块才 4MB,内存完全吃得下。如果你把块大小配置到 64MB 这种级别,readAllBytes 就会造成内存抖动,这时候要用缓冲流分段读写,避免一次性把整个块载入内存。
第三个是合并过程的原子性。合并写到最终路径时要先写到一个临时文件,比如在同一个目录下写xxx.merge.tmp,全部写完再Files.move原子替换。否则一旦合并过程中发生异常,正式路径下可能残留一个不完整的密文文件,后续其他任务再查询时会以为这个文件的密文是完整的。
第四个是最终密文文件的校验。合并完之后,最好再做一次总长度校验。理论上:最终密文文件长度 = Σ(每个分块密文长度),而每个分块密文长度 = 明文分块长度 + GCM_TAG_LENGTH(16字节) + nonce长度(12字节)。如果不匹配,说明某个块在传输或存储过程中被破坏了。这一步能提前拦截一部分“能合并但内容已损坏”的情况。
解密验证时流程就反过来了:把完整密文文件按“nonce + 密文块”的结构逐块切分,每一块用 AesGcmUtil.decrypt 解密,最后拼出明文。GCM 的认证机制会在这一步做最终把关,任何一帧密文被篡改,解密直接抛 AEADBadTagException,不可能得到部分明文。这也是我选择 GCM 而不是 CBC 的核心原因——CBC 模式只加密不认证,密文被篡改后解密虽然可能失败,但失败原因不可控;GCM 把完整性和机密性绑定在一起,安全模型更干净。
4. 安全加固与密钥管理的几个关键决策
4.1 密钥从哪来、放在哪
加密算法再好,密钥管理一团糟,整体安全性照样是零。密钥管理是加密存储系统的重中之重,但也是很多项目最容易忽视的地方。
最忌讳的做法是把密钥硬编码在 Java 代码里,或者写在一个 properties 文件里扔进代码仓库。密钥一旦泄露,整个加密体系就形同虚设。这个项目里我的密钥管理策略分三个层面:
第一层,应用启动时从环境变量读取“主密钥”(Master Key),环境变量在部署平台上单独配置,不进入代码仓库。对于中小项目这是性价比最高的方案,比硬编码强百倍。
第二层,如果团队有运维能力,建议把主密钥放到配置中心(Nacos、Apollo)或密钥管理服务(KMS)里。KMS 的好处是密钥不会以明文形式暴露给应用进程,应用每次调用加解密接口时,KMS 返回的是密文运算结果,而不是密钥本身。不过 KMS 会引入网络开销和额外成本,适合安全合规要求比较高的场景。
第三层,引入“信封加密”思路,把主密钥和数据密钥分离。具体做法是:每个上传任务生成一个随机的“数据密钥”(DEK),用主密钥(KEK)加密这个 DEK,得到“加密后的 DEK”,存储在 upload_task 表里。真正加密分块内容时用的数据密钥,解密时先解出 DEK 再解数据。这样即使数据库泄露,攻击者拿到的也只是一个被 KEK 加密过的 DEK,没有 KEK 依然解密不了数据。
4.2 一次一密,块与块之间要独立 Nonce
回到 nonce 的问题上。很多人忽略的一点是——在分块场景下,“nonce 不能重复”的约束比单文件加密更紧迫。
因为分块数量多,几百个块就有几百个 nonce,如果生成 nonce 时用了不安全的随机源,或者因为代码 bug 导致每个块复用了同一个 nonce,攻击者只要拿到任意两块密文,就能通过 XOR 推导明文之间的关系,安全防线直接崩塌。
之前就有过真实的案例,某知名公司在使用 AES-GCM 时因为 nonce 管理不当,导致大量会话密钥泄露。这个教训做加密存储的人必须刻在脑子里。
我的建议是,除了使用 SecureRandom 之外,还要在代码层面加一道检查:同一个 uploadId 下,已经写入的 nonce 如果和当前要写入的 nonce 相同,直接拒绝该块。虽然 SecureRandom 理论上不会重复,但防御性编程的价值就在于“理论上不会”出问题的场景,一旦出了就是灾难性的事故。
4.3 服务端不落明文的设计红线
这个项目的安全红线非常明确:明文数据在服务端生命周期里只存在于“客户端加密之前”和“服务端解密之后”这两个节点,而且这两端都只是内存态,不允许写临时明文文件。
客户端上传达标的原始文件,做完分块加密后,那些临时明文文件要立即清理。服务端接收到的所有请求,从 MultipartFile 开始就是密文块,密文块直接落临时目录,整个过程不产生明文副本。
这里有一个来自实际项目的教训。早期版本里,我为了方便排查问题,在服务端的日志里打了一段“收到分块:uploadId=xxx, chunkIndex=xxx, dataLength=xxx”之类的信息,看似没问题。后来安全评审发现,如果 data 本身被打印出来,那明文内容就通过日志泄露了——事实上很多数据泄露事件都是通过日志系统出去的。所以这个项目的日志里绝不能出现任何文件内容片段,只记录元数据信息。
另外还有一个细节:处理好 swap 文件。Linux 系统内存紧张时会把内存页换出到 swap 分区,如果进程内存中存在过明文数据,理论上可能被换到磁盘上。对于极高安全等级的场景,可以考虑用堆外内存或 mlock 锁定内存页,但这已经超出大多数业务的现实需求,知道这个原理即可,不用过度设计。
5. 常见问题排查与排坑实录
5.1 分块上传并发时的请求乱序问题
这是分块上传最经典的坑。前端用并发上传时,块 3 可能比块 1 先到。我的服务端设计刻意避免了“按到达顺序追加”的错误写法,统一按 chunk_index 落盘,但这还不够——如果两个相同 chunk_index 的请求同时到达(超时重试导致),服务端可能同时写同一个块文件,后写的覆盖先写的,两边互相干扰。
解决办法有两个层面:
代码层面,在 markChunkUploaded 方法上增加事务和唯一索引约束。chunk_index + uploadId 建唯一索引,当重复请求尝试插入已存在的记录时,数据库会拒绝;更新操作则先查状态再更新,用乐观锁版本号控制并发。
部署层面,如果并发量很大,可以在 Nginx 层对同一个 uploadId 的请求做一致性哈希,让同一个上传任务的请求固定落到同一台后端实例,避免分布式场景下的文件系统竞争。
5.2 GCM 模式报错:javax.crypto.AEADBadTagException
这个异常基本可以断定是“解密时 nonce 或密文被篡改”。常见触发场景有三个:
一是合并密文时块顺序弄错,导致解密时密文和 nonce 对应不上。排查方法是给每个分块文件增加一个“头部长度标记”,或者干脆在块文件命名里带上序号,合并时严格按序号读取。
二是 nonce 在传输或存储过程中被截断。比如数据库字段长度设计成了 32 字节,而 nonce 是 12 字节,存的时候没问题,但如果代码里做了字符串截断或编码转换,解密时 nonce 就读不对了。排查时检查每个环节 nonce 的长度是否始终等于 12。
三是密钥不一致。比如任务初始化时用的密钥版本是 v1,但合并时因为重发代码或配置改动,解密时用的是 v2,密钥不一致自然解不开。这也是我在 upload_task 表里加 key_id 字段的原因,每次解密前先按 key_id 找到对应的密钥,而不是无脑用当前默认密钥。
5.3 合并阶段 OOM
合并大文件时 OOM 的典型原因有两个:一次性把所有块读入内存(比如 for 循环里把所有 Files.readAllBytes 的结果放到一个 List 里),或者把完整密文文件一次性读入内存后再去解密。
正确做法是流式处理。合并分块时,逐块读取、逐块写输出流,保持内存占用恒定。解密完整文件时同样用 CipherInputStream,边读边解,不要把整个文件吞进内存。这块代码我在优化之前,一个 2GB 的文件合并时会直接撑爆测试环境的 JVM,改成流式之后内存占用降到了不到 20MB,完全是两个量级。
5.4 断点续传时报“任务不存在”
客户端发起续传请求时,服务端按 uploadId 查不到任务。常见原因是 upload_task 表的记录被定时清理任务误删了,或者任务初始化接口和上传接口走的是不同的环境(测试环境 vs 生产环境),两个环境数据库不一样。
我的建议是,在初始化接口里把 uploadId 设计成“业务可追踪”的格式,比如包含用户 ID 和时间戳前缀,方便定位问题。同时清理任务只清理“完成且超过 N 天”的记录,不要清理“上传中”状态的任务,给传输过程留足容错时间。
5.5 多实例部署时共享临时目录冲突
如果你的后端是多实例部署(比如 Kubernetes 多副本),每个 Pod 有自己独立的临时目录,那任务初始化落在 Pod A,分块上传的请求被负载均衡到 Pod B,Pod B 就找不到 Pod A 写过的临时块文件。
这个问题的标准解法是使用共享存储,比如 NFS、CephFS、MinIO,把临时目录和最终存储目录都放到共享文件系统上。如果不想引入共享存储,另一个思路是让同一个 uploadId 的所有请求都路由到同一个 Pod,但这会牺牲负载均衡的均匀性。
要注意的是,使用共享文件系统时要关注两个问题:一是网络磁盘的 IO 延迟会比本地盘高,高并发下可能要调整分块大小;二是如果 miniO 或 NFS 服务本身故障,整个上传链路会全部不可用,这时候要做降级处理——比如临时改回本地盘模式,而不是让用户直接传失败。
6. 最后分享几个我反复踩过的设计教训
这个项目做完之后,有几个设计决策回头看尤其值得复盘。
第一个是“能内存做的事不要走文件”。早期版本里,服务端为了追求“每个块自包含可追溯”,在分块落盘时把 nonce、tag、密文和元数据全部分开存储,结果解密时要拼装多个来源的数据,代码极其难维护。后来改成非对称结构——密文块文件里自带 nonce 和 tag,元数据表里只存必要的状态和索引信息,整体代码量少了三分之一,排查问题的难度也低了很多。
第二个是“加解密一定要放在 Service 层,别塞进 Controller”。Controller 的问题在于它天然适合做参数校验和协议转换,不适合做重量级运算。如果你在 Controller 里做加密,迟早会遇到事务边界混乱、异常处理不统一、单元测试难写这些问题。加解密放 Service 层,controller 只负责把 MultipartFile 转成字节数组和元数据对象,职责清晰,后续做限流、横向扩展也更容易。
第三个是“密钥轮换必须提前设计好”。刚开始我以为密钥轮换就是把配置中心的密钥值改一下完事,后来发现老数据怎么解密成了大问题。后来我在 upload_task 表里加 key_id 字段,每次解密时先按 key_id 查密钥版本,密钥轮换时才做得很顺。建议你在设计初期就把“密钥版本化”纳入 schema,不要等到数据产生之后再补,那个成本非常高。
第四个是“前端和后端的块大小必须一致”。有一版我改了后端的默认块大小,但前端还在按旧值切片,结果服务端每次校验块长度都和元数据不匹配,排查了很久才定位。后面我把“初始化接口返回有效的块大小”作为硬性约定,前端以服务端返回的配置为准,不再各自存一份静态配置。
以上就是这套“加密存储 + Java 分块上传”方案的完整落地过程。从整体设计到核心代码,从密钥管理到排坑实录,每一块都是实际项目踩出来的经验。如果只是照着代码抄,你可能也能跑通,但只有理解了每个决策背后的逻辑,遇到问题才不会慌。最后想再说一句,加密存储这个领域,代码能力是基础,安全意识和工程习惯才是决定方案能否经得起推敲的关键。