资讯动态

Java服务端分块上传与AES-GCM加密存储一体化实现

发布时间:2026/10/20 10:36:57 来源:尧图企业网站定制
做文件上传这块的经历我一直觉得最有价值的一点不是用什么框架、调什么参数而是把两条容易分开做的技术链路真正串起来。今天想聊的是“分块上传”和“加密存储”在Java服务端的结合实现。很多团队会把它们当成两件事先做分块上传解决大文件传输失败率的问题再做存储加密解决服务端落盘明文的问题。但实际做下来你会发现如果等文件合并完再加密那前面这些临时文件、合并过程中的明文数据反而成了最容易泄露的环节。这篇文章就围绕这个场景展开分享一套我自己验证过的方案。1. 为什么分块上传与加密存储必须放在一起设计1.1 大文件传输的真正痛点失败重传成本先聊聊大文件。一个2GB的文件如果用普通POST一次性上传后端Spring MVC默认的max-post-size根本扛不住就算手动调高客户端和服务端也会因为网络抖动、超时、连接断开而整包重传。2GB的文件重传一次内网可能还好外网环境下基本就是灾难。分块上传的思路是把文件切成固定大小的分片比如每片8MB或16MB前端按顺序或并发上传服务端逐个接收落盘等所有分片都传完了再做一次合并。这样单个请求上传的数据量很小失败后只需要重传对应分片而不是整个文件。这个思路本身不复杂市面上也很多现成方案。但我当时真正纠结的是另外一件事文件在服务端临时落盘的时候是不是明文“临时文件只是几秒钟的事”这个想法在安全审计面前是站不住脚的。磁盘被脱库、运维误操作、临时目录权限配错任何一个环节出了问题临时文件里的明文数据就可能被直接拿到。1.2 加密等文件合并完再补会埋下明文落盘的隐患有一种做法是先把所有分片以明文形式写到临时目录合并完成后再对整个文件做一次AES加密然后把明文文件删掉。这个流程看起来很简单实际风险点很集中上传过程中临时目录里始终存在明文分片。合并过程中明文文件完整存在磁盘上删除操作不能保证彻底覆盖。如果加密步骤失败或进程被杀明文中转文件可能残留在磁盘上。所以我当时的结论是加密必须前置到分片上传环节。每个分片到达服务端后立刻加密写入临时密文文件客户端永远不知道服务端磁盘上存的是什么。等到合并时操作对象也全部是密文分片最后合并出来的就是一个完整的密文文件。1.3 整体架构上传会话、分片状态与合并链路先说清楚我做的整体轮廓。客户端决定要传一个文件时先调用初始化接口向服务端申请一个uploadId服务端为该上传会话创建独立目录并记录文件元信息。之后客户端把文件按固定大小切分每个分片调用上传接口服务端用AES-GCM加密后落盘并将分片状态、nonce、密文摘要等信息写入数据库。全部上传完成后客户端调用合并接口服务端校验分片完整性按顺序拼接密文分片形成最终密文文件。我把上传会话的状态定义成四个状态含义触发时机INIT会话已创建等待分片上传初始化接口返回后UPLOADING已上传部分分片分片写入成功时更新MERGED所有分片已合并合并接口完成时更新EXPIRED超时未完成上传定时清理任务触发这个状态机看起来简单但对后面的断点续传和并发控制非常有帮助。状态要持久化在数据库里不能只依赖内存Map因为服务端很可能不止一个节点。2. 加密方案的选型AES-GCM与分块加密的组合取舍2.1 CBC、CTR、GCM为什么最终选择GCMJava标准库支持的对称加密算法里AES是最成熟的选择。关键是工作模式怎么选。ECB模式坚决不能用同样明文会得到同样密文图案分明显CBC模式需要IV且加密过程是一个串行链路无法并行处理多个数据块CTR模式支持流式加密和随机访问但它不带认证功能密文在传输或落盘过程中被篡改后解密端无法感知。GCM模式在CTR基础上加了GHASH认证属于AEAD加密算法一次操作同时保证机密性和完整性。解密时如果密文或tag被改动会直接抛出认证异常。这一点对存储场景特别重要文件在磁盘上可能存放很长时间我们需要一个能主动发现数据被篡改的机制而不是默默解出乱码。在Java里对应的算法名是AES/GCM/NoPadding全称是AES-GCM不需要填充。它有一个特点需要记住密文长度比明文长度多16字节这16字节是认证标签tag。我会在后面的分片格式里用到这个参数。2.2 每个分片一个随机nonce分片加密的格式设计AES-GCM加密需要提供一个12字节的IV在GCM语境里通常叫nonce。同一个密钥下nonce绝对不能重复否则GCM的安全性直接崩溃。那大文件分成多个分片后怎么保证nonce不重复最简单的做法是每个分片单独生成一个12字节的随机nonce。这个设计让每个分片都是独立加密、独立认证的密文单元。每个密文分片文件的二进制格式我定义为12字节 nonce | 分片密文 | 16字节 GCM tag整个合并后的最终密文文件其实就是把多个这样的分片块按顺序拼起来。解密时可以按“分片大小28”12字节nonce 16字节tag来切分每个数据块分别解密。但需要注意最后一个分片大小不固定所以要按文件剩余长度处理。这里有个新手容易踩的坑分片重传时如果直接把nonce也重用了而密钥又是同一个那GCM的nonce复用问题就会暴露。正确做法是每次重新生成nonce并把新的nonce和密文一起写盘。2.3 密钥的获取与轮换策略加密方案定了密钥管理也得跟上。我见过有人把AES密钥直接写死在代码里的这属于上线前必改的问题。保守但可行的方案是密钥放在环境变量或配置中心程序启动时读取代码库里不出现任何私钥数据。我实际采用的是“主密钥会话密钥”两层结构系统配置一个主密钥masterKey用于加密和解密会话密钥。每次初始化上传会话时随机生成一个sessionKey用masterKey加密后存到数据库。所有分片加密使用该会话的sessionKey。这样带来的好处是如果某一个文件的密钥泄露不会影响到其他文件。就算数据库被拖走攻击者拿到的也只是被主密钥加密过的会话密钥没有主密钥照样解不开。主密钥本身要定期轮换同时给密钥加上版本号比如v1、v2。每次加密时在元数据里记录当前使用的主密钥版本解密时根据版本选择对应的密钥。这样老文件不会因为轮换密钥而失效。3. 分块上传接口的落地实现从元数据表到文件合并3.1 元数据表设计我先给出核心的两张表结构这是我后来验证过好几轮没有大改的版本。CREATE TABLE upload_session ( id BIGINT AUTO_INCREMENT PRIMARY KEY, upload_id VARCHAR(64) NOT NULL UNIQUE, file_name VARCHAR(255) NOT NULL, total_size BIGINT NOT NULL, chunk_size INT NOT NULL, total_chunks INT NOT NULL, encrypted_key VARBINARY(256), key_version VARCHAR(16) NOT NULL, status VARCHAR(16) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE upload_chunk ( id BIGINT AUTO_INCREMENT PRIMARY KEY, upload_id VARCHAR(64) NOT NULL, chunk_index INT NOT NULL, nonce VARBINARY(16) NOT NULL, enc_size BIGINT NOT NULL, enc_sha256 CHAR(64) NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_upload_chunk (upload_id, chunk_index) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个字段说明一下encrypted_key被主密钥加密后的会话密钥不是明文。key_version主密钥版本号解密时靠它选密钥。chunk_size统一的分片大小比如8MB最后一片可能小于这个值。enc_sha256密文分片的SHA-256值用于完整性校验。nonce这个分片加密时用的随机IV解密要用。3.2 初始化上传会话初始化接口做的事情很简单生成uploadId、生成会话密钥、创建会话目录、插入upload_session记录。PostMapping(/upload/init) public ResultInitResponse init(RequestBody InitRequest request) { // 1. 生成 uploadId例如 UUID 去除横线 String uploadId UUID.randomUUID().toString().replace(-, ); // 2. 生成随机会话密钥 AES-256 byte[] sessionKey new byte[32]; SecureRandom.getInstanceStrong().nextBytes(sessionKey); sessionKey[0] | 0x01; // AES-256 密钥任意 32 字节均可用无需额外操作 // 3. 用主密钥加密会话密钥 byte[] encryptedKey encryptByMasterKey(sessionKey, currentKeyVersion()); // 4. 创建会话目录路径可设计为 /data/upload_temp/{uploadId}/ createSessionDir(uploadId); // 5. 写入 upload_session 表 uploadSessionMapper.insert(...); return Result.ok(new InitResponse(uploadId, chunkSize)); }这里有个小细节SecureRandom.getInstanceStrong()可能会在低熵环境下阻塞生产环境如果遇到启动慢可以评估是否需要退回new SecureRandom()但要注意随机数强度。GCM的nonce随机性直接决定安全性不能用简单的Random。3.3 上传分片并加密落盘这是整个方案最核心的接口。客户端把第N个分片传上来服务端读取流用AES-GCM加密后写入临时密文文件。PostMapping(/upload/chunk) public ResultVoid uploadChunk(RequestParam(uploadId) String uploadId, RequestParam(chunkIndex) int chunkIndex, RequestParam(file) MultipartFile file) { // 1. 校验会话状态必须为 INIT 或 UPLOADING UploadSession session uploadSessionMapper.selectByUploadId(uploadId); // 2. 每次分片重新生成随机 nonce避免重传复用 byte[] nonce new byte[12]; SecureRandom.getInstanceStrong().nextBytes(nonce); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); cipher.init(Cipher.ENCRYPT_MODE, session.getSessionKey(), new GCMParameterSpec(128, nonce)); // 3. 文件路径/data/upload_temp/{uploadId}/chunk_{index}.enc Path encPath sessionDir.resolve(chunk_ chunkIndex .enc); MessageDigest digest MessageDigest.getInstance(SHA-256); try (FileOutputStream fos new FileOutputStream(encPath.toFile()); DigestOutputStream dos new DigestOutputStream(fos, digest); CipherOutputStream cos new CipherOutputStream(dos, cipher)) { // 先写 nonce再写入密文流 fos.write(nonce); file.getInputStream().transferTo(cos); cos.flush(); } // 4. 记录分片元数据 byte[] encSha256 digest.digest(); uploadChunkMapper.insert(uploadId, chunkIndex, nonce, file.getSize() 16, toHex(encSha256)); // 5. 更新会话状态为 UPLOADING uploadSessionMapper.updateStatus(uploadId, UPLOADING); return Result.ok(); }这段代码里有一个细节值得强调DigestOutputStream包裹在外层CipherOutputStream包裹在内层这样计算的是加密后密文的SHA-256而不是明文的。因为我们要验证的是“落盘文件有没有被篡改”算密文摘要才是准确的。为什么不把nonce放在密文流里一起写因为GCM的tag是CipherOutputStream在close()时自动追加的如果我把nonce也塞进CipherOutputStream解密时很难区分nonce、密文和tag的边界。显式先用底层流写入nonce再包裹加密流写入密文格式才清晰。3.4 合并分片与整体校验合并接口的逻辑是把所有加密分片按chunk_index顺序拼接成一个完整的密文文件。拼接完成不代表立刻可以做任何解密操作但它同时必须验证两部分分片数量完整吗有缺失直接拒绝合并。每个分片的密文SHA-256和数据库里记录的一致吗不一致说明分片在传输或落盘过程中被改过直接返回错误。PostMapping(/upload/merge) public ResultMergeResponse merge(RequestBody MergeRequest request) { String uploadId request.getUploadId(); ListUploadChunk chunks uploadChunkMapper.selectByUploadId(uploadId); // 校验分片数 if (chunks.size() ! session.getTotalChunks()) { return Result.error(分片未全部上传); } // 按 chunk_index 排序顺序拼接 chunks.sort(Comparator.comparingInt(UploadChunk::getChunkIndex)); Path finalPath Paths.get(/data/encrypted_files).resolve(uploadId .enc); try (FileOutputStream fos new FileOutputStream(finalPath.toFile())) { for (UploadChunk chunk : chunks) { Path chunkPath sessionDir.resolve(chunk_ chunk.getChunkIndex() .enc); Files.copy(chunkPath, fos); } } // 更新状态为 MERGED uploadSessionMapper.updateStatus(uploadId, MERGED); // 删除会话临时目录可异步 deleteQuietly(sessionDir); return Result.ok(new MergeResponse(finalPath.toString())); }合并之后最终密文文件和各临时分片文件的大小关系是每个分片文件 12字节nonce 分片明文大小 16字节tag最终密文文件就是所有分片文件的顺序拼接。这里不需要额外再做一层加密因为每个分片已经是独立加密状态了。4. 断点续传、并发控制与完整性校验的实战经验4.1 断点续传客户端如何知道从哪里继续断点续传的核心不是“续传”这个动作而是服务端能准确地告诉客户端“你已经传了哪些分片还缺哪些”。我提供了GET /upload/status?uploadIdxxx接口返回已上传分片的索引列表。客户端拿到后把本地文件按同样规则切分跳过已上传的分片只上传剩余部分。有一点需要注意客户端切分文件的规则必须和服务端一致。比如chunk_size8MB最后一个分片可能只有1.2MB那索引怎么编号、大小怎么约定都要在初始化接口返回时定死。我见过前端和后端因为分片大小不一致导致合并失败的情况排查半天才发现两端配置不同。建议初始化接口直接返回chunkSize客户端严格按这个值切分。4.2 并发与顺序问题分片上传天然适合并发但服务端不能假设分片会按顺序到达。我在实现时坚持一条原则分片落盘只依赖chunk_index不依赖到达顺序。每个分片写到chunk_{index}.enc谁先到谁先写互不干扰。合并时统一按索引排序拼接这样乱序问题就在架构层面被消除了。真正容易出问题的是合并操作的并发控制。如果用户在客户端同时点了两个“完成上传”按钮或者服务端接到重试请求就可能出现两个线程同时合并同一个会话的情况。轻则浪费磁盘IO重则合并出损坏文件。我的处理方式是加一个简单的状态流转合并接口里先执行UPDATE upload_session SET statusMERGING WHERE upload_id? AND status IN (INIT,UPLOADING)如果更新影响行数为0说明已经被别的线程合并过直接返回当前结果。这一步利用数据库的行锁避免引入额外中间件。4.3 分片重复提交的幂等性客户端在网络超时后很可能把同一个分片重发一次。服务端如果不去重就会在合并时出现重复文件或者把已写入的同名分片覆盖掉。覆盖本身没有大问题但会导致性能浪费和潜在的不一致。我利用upload_chunk表上的唯一索引uk_upload_chunk(upload_id, chunk_index)来做幂等。插入分片记录时使用INSERT ... ON DUPLICATE KEY UPDATE把nonce、密文大小和SHA-256更新为最新值同时覆盖写密文文件。这样同一个分片无论传多少次最终都只有一个有效版本。这里要特别提醒如果分片重传了务必重新生成nonce并一起更新。老分片的nonce已经用于GCM加密重传时如果沿用同一个nonce而使用新的会话密钥虽然不会立刻出问题但在密钥相同的情况下这就是nonce复用理论上是禁止的。4.4 完整性校验的闭环GCM本身自带认证能力为什么还要算SHA-256两者目的不一样GCM的tag用于解密时验证数据完整性如果密文被篡改解密直接失败。SHA-256用于上传和存储环节的快速校验合并前可以秒级发现问题不需要等解密流程跑完才报错。实际运行中我还会给最终的密文文件算一个整体SHA-256保存在文件记录里提供给后续审计或下载校验使用。这样形成了一个闭环分片级用密文SHA-256校验文件级用整体SHA-256校验解密时用GCM tag兜底。5. 实测踩过的坑与调优建议5.1 内存陷阱一次性读入整个分片导致的OOM第一版实现里我在上传接口写了一个很“省事”的代码byte[] bytes file.getBytes()然后加密、写盘。结果压测时线程一多OutOfMemoryError直接来了。大文件分块上传虽然每个请求的数据量小了但并发请求加起来内存压力依然可观。8MB的分片来20个并发请求就是160MB还没算上加密时产生的临时缓冲。正确做法是用流式处理我已经在上面代码里用了file.getInputStream().transferTo()分片数据以8KB缓冲流经过加密流不把整个分片读进堆内存。5.2 临时文件清理与上传会话超时上传会话可能因为用户断网、客户端退出而永远完不成。如果不清理/data/upload_temp/目录会被塞满磁盘告警迟早会来。我加了一个定时任务每小时扫描一次upload_session表把超过24小时仍处于INIT或UPLOADING状态的记录标记为EXPIRED同时删除对应的临时目录。这个策略在生产环境跑下来很稳定建议阈值根据业务情况调整但一定要有不能省。5.3 加密带来的性能账怎么算很多同事听说每个分片都要做AES-GCM加密第一反应是“会不会很慢”。实测下来现代CPU基本都有AES-NI指令集Java的AES/GCM/NoPadding在8MB分片场景下加密吞吐量能到几百MB每秒远不是瓶颈。真正影响上传速度的环节是网络IO和磁盘IO不要把加密当作替罪羊。我做了两个实际调优点分片大小从5MB调整到8MB。分片太小会导致请求数量过多服务端处理连接和写入元数据的开销上升分片太大则会增加客户端内存压力。8MB是文档、视频、安装包类型文件的比较通用的折中值。GCM标签长度选择128位。这是Java默认推荐值安全性远高于96位又不会明显增加存储成本。5.4 密钥轮换后老文件怎么解主密钥不可能永远不换但直接换掉意味着所有历史文件都解不开。我的做法是在upload_session.encrypted_key旁边存一个key_version字段解密流程读取密文文件后先从元数据表中查到对应的版本号再用该版本的主密钥解开会话密钥。这样轮换密钥时只需要把新版本密钥加入配置之后的新上传文件使用新版本老文件继续用旧版本解密。等确认所有老版本密钥对应的文件已经全部迁移或过期后再把旧密钥从配置中移除。最后说说我对这个方案的个人体会。加密存储这件事单纯从代码量来看可能只是多了一个Cipher和几行IO操作但它真正影响的是整个上传链路的边界临时目录里没有明文、合并过程不落地明文、最终文件本身就是密文。把分块上传和加密放在同一个流程里设计比事后补加密省心得多。如果你正在做一个Java后端文件上传服务我建议在画架构图阶段就把“分片到达即加密”这个原则固化下来后面你会感谢这个决定的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑