在银行系统里折腾大文件上传,真不是写一个 multipart 接口把文件存起来就完事。客户批量导入的 Excel 可能带着几万条身份证号,贷款审批附件里是含住址和收入信息的合同扫描页,这些数据一旦以明文形式经过任何一个环节,后面审计就能让你连续几周都睡不踏实。我最近在做某银行客户资料导入模块的加密改造,核心需求就一句话:大文件上传全链路中,敏感客户数据不允许出现明文落盘、明文日志、明文缓存。这篇文章把这条从浏览器到数据库的加密链路拆开讲,适合正在做 JavaWeb 文件上传、又对数据安全有硬要求的开发者参考。如果你以为“给上传接口加个 Cipher”就够了,那我必须直说:完全不够。
1. 这个需求真正在防什么:拆解敏感客户数据的暴露面
1.1 大文件上传里最容易被人忽略的三类敏感数据
第一类是结构化批量数据,典型就是 CSV、Excel。客户经理在网点导出一份名单,里面几十个字段,姓名、身份证号、手机号、家庭地址、银行卡号全都有,单条记录不大,但整个文件可能几百 MB。这类数据的问题在于开发人员处理时习惯把整份文件当成普通文档,只对数据库字段做加密,文件本体却到处乱放。
第二类是非结构化影像件。身份证正反面照片、银行卡照片、合同扫描件,它们本身不占用太多结构化字段,但图片里客户姓名、证件号码、住址都是明晃晃的。更麻烦的是银行风控或贷后审核经常需要人工调阅,所以你不能“只提取数据,不保留原文件”,文件必须整体留档。一旦这些影像件被明文存储,权限或备份配置稍有疏漏,泄露的就是一整批客户身份信息。
第三类是混合型文件,比如带了宏和批注的 Excel、带修订记录的 Word、含作者和打印机路径的 PDF。表面看文件名只是一个业务附件,但文件内部隐藏的元数据可能残留上一位客户的信息。我在实际项目里甚至遇到过上传模板里宏代码能读取本机用户名的情况,虽然业务系统不一定会执行宏,但这类文件如果直接暴露在下载环节,也会带来额外风险。
所以判断一个文件是否属于“敏感客户数据”,不能只看后缀名和业务字段,还要看文件内容、文件元数据以及使用场景。只有把这三类都纳入加密范围,才算把口子堵住。
1.2 从浏览器到数据库,一条文件要经过多少个明文节点
把一次大文件上传完整拉通来看,明文出现的位置远比你想的多。
浏览器先把文件发送到负载均衡或网关,这一段默认是 HTTPS,但 HTTPS 只保护传输过程中不被第三方窃听,服务端接收后数据在应用内存里就是明文。很多网关和反向代理为了排障,会把请求 URL、上传文件名、客户端 IP 记到访问日志里,如果文件名本身是“张三_身份证.pdf”,日志这一层就已经泄了。
到了应用服务器,更常见的问题是 Spring Boot 默认的 MultipartFile 处理逻辑。如果你直接调用 transferTo(),框架会把上传内容先写进操作系统的临时目录,这个临时文件通常是明文,而且不会立刻自动删除。期间应用如果被导出线程 dump、发生内存溢出后留下堆转储文件,JVM 堆里正在处理的明文内容也可能被带出去。
再往后是转存和落库。对象存储如果没开服务端加密,文件对象本身就是明文;数据库里的 BLOB 字段如果依赖表空间加密,而备份系统只是复制物理文件,那备份出来的数据同样容易读。还有一个经常被忽略的地方是调用链日志:调用远程服务时如果框架自动打印请求体 JSON,某个字段里夹着客户手机号或身份证号,也会变成日志明文。
我总结了一个原则:不要把“加密”理解为某一次密码学操作,而是把明文窗口尽量收窄到“业务真正需要处理的那一瞬间”。比如解析 Excel 提取客户信息时,页面内存里出现明文是允许的,但落盘、入日志、进中间件缓存这些环节,应该尽量让明文不出现。
2. 加密方案选型:为什么“上传后再加密”不是简单加个 Cipher
2.1 传输层加密与应用层加密各管哪一段
很多人一上来就提“用 HTTPS 不就行了”,这是混淆了两个层次。TLS/SSL 解决的是浏览器和服务器之间链路上的窃听与篡改,一旦数据到达服务器,网关和业务进程对数据就是透明的。也就是说,HTTPS 再强,也管不了服务器本地磁盘、运维人员、备份磁带、对象存储共享账号这些后续环节。
银行类系统通常有两道明确要求:传输层必须启用 TLS,这属于默认值;应用层还必须对敏感数据本体做加密,防止数据在存储和内部流转时被盗。应用层加密的位置越早越好,但不必激进到在浏览器端用 JavaScript 那把文件整套加密。浏览器端做加密最大的问题是密钥怎么分发,你总不能让每个客户的浏览器都保存一把业务系统私钥,而且前端加密逻辑一旦被调试改写,保护意义会大打折扣。
实际工程里最稳妥的做法是“HTTPS + 后端流式加密 + 存储加密 + 数据库字段加密”组合。传输层管链路,应用层管静态数据,两层叠加而不是二选一。
2.2 AES-GCM 还是国密 SM4:流式加密的算法选择
做文件加密首先要选对称加密算法,因为大文件动辄几百 MB、几个 GB,用 RSA 这类非对称算法硬扛性能不可接受。非对称加密只适合做密钥信封,比如后面讲的 KEK 包装 DEK,不适合直接加密文件内容。
在对称算法里,主流选择是 AES-256-GCM,或者合规要求明确指定国密时用 SM4。AES-256-GCM 在 Java 标准库原生支持,不需要额外引入第三方包,而且现代 CPU 基本都有 AES-NI 指令集,流式加解密吞吐量很高。它自带认证标签,加密的同时能校验数据是否被篡改,这正是文件上传场景最需要的——你总不想把被中间人换过的“密文”当成客户资料存下来。
SM4 是国密标准,如果业务方明确要求信创或国密合规,那就要替换成 SM4-GCM 或者 SM4-CTR 加 HMAC。替换并不难,难的是元数据设计一开始就要留好算法标识字段,否则老文件用 AES、新文件用 SM4,解密端会乱套。
我用一张表概括几种模式的差别:
| 算法模式 | 完整性校验 | 流式支持 | 需要额外维护 MAC | 适用场景 |
|---|---|---|---|---|
| AES-256-GCM | 自带认证标签 | 支持 | 否 | 通用,推荐默认选择 |
| AES-CTR + HMAC-SHA256 | 需要额外 HMAC | 支持 | 是 | 需要分段认证、自行控制时 |
| SM4-GCM | 自带认证标签 | 支持 | 否 | 国密合规的通用选择 |
| SM4-CTR + HMAC | 需要额外 HMAC | 支持 | 是 | 国密合规且需要分片认证时 |
2.3 整文件单流加密与分片独立加密的取舍
加密方式上一般有两种路线:整文件单流加密和分片独立加密。
整文件单流加密最简单。文件开头写入 IV 和算法标识,然后用一个 CipherOutputStream 从头写到尾,整个文件只有一个认证标签。解密时也顺畅,读文件头、初始化 Cipher、流式解密成一个明文文件。缺点是上传中断后,已经写进去的密文没法单独校验,重传基本只能整体重来。如果采用“前端顺序上传分片、后端追加到同一个 CipherOutputStream”的方式,还能兼顾分片传输,但要求前端必须严格按序号串行上传,不能并发。
分片独立加密则是把文件切成若干 chunk,每个 chunk 用自己的 IV 和认证标签分别加密。每个分片可以乱序传输、独立校验、失败后只重传缺失分片,特别适合银行这种网络环境复杂、文件体积动辄几个 GB 的场景。缺点也明显:最终得到的不是一个标准 GCM 密文文件,而是一组密文块外加一份索引元数据;将来如果有人直接把这个文件拷走,不知道元数据格式的人根本无法解密。而且分片越多,元数据管理、完整性校验的复杂度越高。
我的建议是:如果文件通常在 1 GB 以内且上传链路很稳定,选单流加密加顺序分片;如果要做断点续传和并发上传,尤其是几百 MB 到几个 GB 的批处理文件,直接上分片独立加密。后面实战部分我两种方案都会给出代码骨架,大家按场景取用。
3. 可落地的 JavaWeb 大文件分片加密上传实战
3.1 前端分片与哈希校验:别把整文件一次性交给 Servlet
先说前端。大文件上传最忌讳 File 对象一次 POST 给后端,Servlet 容器默认对请求体大小有限制,而且一个几十 MB 的 byte[] 会瞬间压满内存。所以第一步就是分片。
前端按固定大小切片,不用在意文件格式,直接用 File.prototype.slice()。每片作为一个独立的二进制块上传,同时带上 fileId 和 chunkIndex。fileId 由前端生成一个 UUID,后端把它作为幂等键和元数据主键。
比较关键的是哈希校验。每个分片在发送前计算一个 SHA-256 值放在请求头里,后端收到后先校验,再进入加密流程。为什么要在加密前校验?因为网络传输可能丢位或损坏,如果先把损坏数据加密成密文,后面审计时很难定位是网络问题还是加密问题。校验在加密之前做,能把这个不确定性提前排除。
对整份文件,我建议前端在开始上传前先读一遍文件,计算完整的 SHA-256,把这个值随第一个分片请求带给后端。这个操作会让用户多等几秒,但对于银行客户资料类文件,完整性是硬要求,值得花这个代价。
3.2 后端接收分片时如何直接写密文,绕开明文临时文件
后端处理分片时最容易被坑的是:为了校验或组装,先把收到的 chunk 写进一个临时文件,等全部收齐再统一加密。这个临时文件就是明文,一旦服务器磁盘被拖走,数据全裸。
正确姿势是:分片校验通过后,立刻把明文块用 Cipher 加密成密文块,然后直接写入对象存储或持久化存储,不落任何明文临时文件。如果业务必须要暂存,那也是写入“.enc”后缀的密文临时文件,而不是明文文件。
另外要注意,多个后端实例并行时,不能依赖本地磁盘作为分片暂存区。因为同一个 fileId 的不同 chunk 可能被负载均衡分发到不同节点,本地磁盘会互相看不到。必须用共享存储,比如对象存储本身,或者分布式文件系统。元数据库记录每个 chunk 的存储位置、IV、长度,这样任意节点都可以继续接收后续分片。
上传完成不代表可以直接拼接解密。如果采用分片独立加密,后端把所有 chunk 的密文块按顺序在解密时重组即可,不需要把密文物理拼成一个大文件。如果业务方要求下载时拿到一个标准密文文件,那就在元数据里维护一个“密文块索引”,解密服务按索引遍历。
3.3 完整代码骨架:AES-GCM 流式加密/解密 + 元数据记录
先看分片独立加密的核心代码。我这里假设密钥已经从 KMS 获取,DEK 只存在于内存里,不会写进磁盘。
public byte[] encryptChunk(byte[] plainChunk, UUID fileId, int chunkIndex, SecretKey key) throws Exception { byte[] iv = new byte[12]; SecureRandom.getInstanceStrong().nextBytes(iv); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv)); // AAD 绑定文件 ID 和分片序号,防止密文分片被拼接到别的文件 ByteBuffer aad = ByteBuffer.allocate(20) .putLong(fileId.getMostSignificantBits()) .putLong(fileId.getLeastSignificantBits()) .putInt(chunkIndex); cipher.updateAAD(aad.array()); // doFinal 的输出是“密文 + 16 字节认证标签” byte[] ciphertext = cipher.doFinal(plainChunk); ByteBuffer out = ByteBuffer.allocate(12 + ciphertext.length); out.put(iv); out.put(ciphertext); return out.array(); }这里的重点是 IV。GCM 模式对 IV 唯一性要求极高,同一把密钥下绝对不允许出现两次相同的 IV,否则认证标签直接失效。用 SecureRandom 生成 12 字节随机 IV 是常用做法,工程上还要加一层检测,写入元数据前判断数据库中是否已经存在完全相同的 IV,一旦发现就重新生成。
再看单流加密方式,适合顺序分片或非分片上传:
try (InputStream in = multipartFile.getInputStream(); FileOutputStream fileOut = new FileOutputStream(encFile); CipherOutputStream cos = new CipherOutputStream(fileOut, cipher)) { byte[] buffer = new byte[64 * 1024]; int len; while ((len = in.read(buffer)) != -1) { cos.write(buffer, 0, len); } }这个小代码里有三个容易被忽略的点。第一,必须先声 cam 后关 cos,因为 GCM 的认证标签是在 CipherOutputStream 执行 close() 时才写入底层流的,如果先关 fileOut,标签就丢了。第二,缓冲区不是越大越好,后面调优部分细说。第三,不要在这一层再套一层 BufferedOutputStream,CipherOutputStream 内部已经做了必要的缓冲,重复包装只会增加内存拷贝。
解密时如果是单流,用 CipherInputStream 读取整个文件;如果是分片独立加密,就按索引逐块 doFinal,再把明文块写到同一个输出流。分片方式每个 chunk 都是独立认证的,解到任意一块时如果认证不过,可以快速定位是哪一片出了问题,这就是分片方案在运维上的最大优势。
元数据表建议这样设计:
CREATE TABLE file_encrypt_meta ( file_id UUID PRIMARY KEY, algorithm VARCHAR(20) NOT NULL, key_version INTEGER NOT NULL, plain_sha256 CHAR(64) NOT NULL, chunk_count INTEGER NOT NULL, status VARCHAR(20) NOT NULL, created_at TIMESTAMP NOT NULL ); CREATE TABLE file_chunk_meta ( file_id UUID NOT NULL, chunk_index INTEGER NOT NULL, iv BYTEA NOT NULL, encrypted_size INTEGER NOT NULL, storage_path VARCHAR(512) NOT NULL, PRIMARY KEY (file_id, chunk_index) );注意单流方式不需要 chunk 表,IV 直接放在 file_encrypt_meta 里即可。分片方式则必须把每个分片的位置、IV、长度都记录下来,否则解密索引不完整。
4. 密钥从哪里来、存到哪里去:银行场景下的密钥管理
4.1 DEK/KEK 两层结构和 HSM/KMS 接入
文件内容加密用的对称密钥如果写死在 application.yml 里,那不管加密算法多好,都等于把家门钥匙放在门口的脚垫下面。银行场景下的标准做法是信封加密,也就是密钥分两层:数据加密密钥 DEK 和密钥加密密钥 KEK。
KEK 保存在 HSM 或集中 KMS 中,应用无法直接读取 KEK 明文。实际加密文件之前,先随机生成一个 DEK,用 DEK 加密文件内容;然后用 KEK 加密 DEK,得到加密后的 DEK 密文,这个 DEK 密文可以安全存放在业务数据库里。解密文件时,先从元数据里取出加密的 DEK,调用 KMS 用 KEK 解密得到 DEK 明文,再用 DEK 去解文件内容。
这样做最大的好处是 KEK 轮换时不需要重新加密任何历史文件。比如某天 KMS 要求根密钥更新,我们只需要把数据库里所有被旧 KEK 包住的 DEK 密文,用新 KEK 再重新加密一遍,文件内容完全不用动。DEK 本身可以绑定单个文件,某个文件泄露了就只换那一个文件的 DEK,影响面小得多。
代码层面,获取密钥大概是这样的流程:
String encryptedDekBase64 = metaStore.getEncryptedDek(fileId); byte[] dek = kmsClient.decrypt(encryptedDekBase64, KEY_USAGE_FILE_ENCRYPT); SecretKeySpec key = new SecretKeySpec(dek, "AES");DEK 用完之后,要把内存里的 byte[] 清空,不能留着等 GC。真实 Java 环境里可以用 try-finally 里的 Arrays.fill(dek, (byte) 0) 做一次主动清理,虽然不完美,但能降低堆转储泄露的概率。
4.2 密钥轮换时,老文件怎么平滑过渡
很多人以为密钥轮换就等于把所有历史文件重新加密一遍,这是最吓人的运维噩梦。其实有了信封加密,日常轮换根本不需要碰文件本体。
轮换 KEK 时,只需要启动一个后台任务,遍历所有文件元数据,用旧 KEK 解出 DEK 密文,再用新 KEK 加密一次,更新元数据里的 key_version 和加密后的 DEK 密文。这个操作只涉及数据库里的小数据,几分钟就能完成。
轮换 DEK 才是真正的大工程,因为意味着文件内容要重新加密。一般只在发现某文件 DEK 泄露、或者文件级别安全要求强制时才做。做法是后台分批读取密文文件、解密再重新加密,过程中要保证文件状态是“可读旧版本”或“迁移中”,不要让用户看到一半新一半旧。实际银行项目里我倾向于把 DEK 有效期设计得很长,并依靠 key_version 字段做版本切换,除非有明确事件,否则不主动批量重加密文件。
解密端必须把 key_version 作为解密参数的一部分。因为历史文件里的 DEK 是用旧 KEK 包的,解密时必须根据 key_version 找到对应 KEK,不能默认用当前 KEK 去解,否则老文件全部解不开。
4.3 加密文件的元数据如何安全落库
即使文件内容是密文,元数据本身也可能泄露敏感信息。比如对象存储里的文件名如果叫“张三_身份证.pdf.enc”,那就算文件内容加密了,别人扫一眼文件列表照样知道张三传了身份证。所以对象存储的 key 必须用无业务含义的随机标识,我通常采用{tenantId}/{yyyyMM}/{fileId}.enc这样的结构,客户 ID 只存在于业务数据库中,不直接暴露在存储层。
元数据表里的 customer_id、业务类型这些字段,能脱敏的先脱敏,不能脱敏的考虑字段级加密。字段级加密和文件级加密最好使用不同的 DEK,避免同一个密钥被用于多个场景后,一处泄露波及所有数据。
我见过一个真实踩坑场景:开发人员图省事,把客户手机号直接作为对象存储 key 的一部分,结果对象存储的访问日志里全是手机号,这等于在物理层之外又增加了一个明文出口。所以加密方案里一定要有一条硬性要求:所有外部系统能看到的数据,都必须是无法直接还原出客户信息的随机标识。
5. 实测表现和调优:坏味道往往藏在吞吐量数字背后
5.1 缓冲区和流式处理对大文件吞吐量的影响
大文件加解密最容易出现的性能问题是缓冲区太小导致系统调用频繁。在模拟项目 X 里我们做过一个简单对比:8 KB 缓冲区时加解密 200 MB 文件耗时明显偏高,改成 64 KB 后吞吐量几乎翻倍,再往上加到 1 MB 提升就很小了。原因是 AES-GCM 本身有 AES-NI 硬件加速,CPU 计算不是瓶颈,瓶颈大多在内存拷贝和操作系统读写次数上。
所以 64 KB 是一个比较通用的起点。如果你用的是 JDK 11 以上,可以在测试环境跑一次 jmh,分别测 16 KB、64 KB、256 KB,找到自己机器上的拐点。但注意不要把缓冲区设成十几 MB,大缓冲区会让年轻代 GC 压力上升,尤其在高并发时反而影响整体吞吐。
另一个细节是 CipherOutputStream 的 flush。加密流里的 flush 不见得会立即把数据写到底层,因为 GCM 为了保证认证标签,内部是有缓冲的。不要为了“实时落盘”频繁调用 flush,正确的策略是写满一个缓冲块后再由写操作自然触发,最后靠 close 把数据和 tag 一起写出去。
5.2 防止上传超时与失败重传的工程化设计
银行的文件上传服务经常会挂在网关层,长时间占用连接容易被裁掉。所以必须把“一个大文件上传”拆成“很多个小分片上传”,网关超时时间只需覆盖单个分片即可。
分片大小建议在 10 MB 到 50 MB 之间。太小编程开销太大,太大则失去断点续传意义。如果网络质量差,可以把分片调小到 5 MB,让失败重传的单位更小。
服务端需要记录每个分片的接收状态,可以用 Redis 的 bitmap 存upload:{fileId}:chunks,每接收一片就把对应位置置 1。前端重试时先问一下后端“哪些分片还没收到”,只传缺失部分,不要重新传整个文件。
后端接口必须是幂等的。同一个 fileId、同一个 chunkIndex 重复提交时,如果该分片已经存在且哈希一致,直接返回成功,不要重复执行加密和写入。这样网络重试造成的重复请求才不会把存储写乱。
5.3 典型压测结果与 JVM 参数调整
在模拟项目 X 里,我们用 4 核 8G 的物理机模拟过并发场景:同时开 10 个 200 MB 文件上传,采用分片独立加密,每片 10 MB,后端加密后写入对象存储。最终瓶颈不是 CPU 也不是内存,而是对象存储的写入吞吐量。加解密本身大约只占 CPU 的 20% 左右,剩余开销大多在 TLS 握手和对象存储客户端序列化上。
因此压测时不要只盯着加解密代码,要多看网络连接池、TLS 会话复用、对象存储客户端的最大连接数。JVM 堆设置反而简单,因为全程流式处理,堆里不会同时积压整个文件,Xmx 给 2 GB 足够应付常规并发。真正要留意的是元数据写库的并发和文件状态位图的一致性,这才是性能拐点。
6. 从开发到生产:我在银行环境里踩过的那些坑
6.1 日志和异常信息无意间把明文带上来了
开发环境里大家习惯直接打印“上传成功:filename=测试.xlsx,customer_name=张三”,看起来没什么。但生产环境日志是集中采集的,会进 ELK,会被多个团队检索,权限管理往往比业务系统松得多。客户姓名、手机号一旦进入日志,后面想删都删不干净。
后来我们统一做了日志脱敏过滤器,对身份证号、手机号、银行卡号做正则替换。更严格的是,业务代码里禁止把整个客户对象直接 toString() 打印,异常信息里也只用 fileId 和异常类型,不携带业务字段。这是银行环境审计的基本要求,也是我每次 code review 一定会盯的点。
6.2 IV 复用的“隐形地雷”:GCM 模式最忌讳的重复随机数
有一类问题是平时测不出来、出事就要命的:GCM 模式下如果两次加密用了同一个 IV 和同一把密钥,攻击者把两块密文做异或就能直接拿到明文。所以代码里绝不能写死 IV,也不能用时间戳的前 12 字节当 IV,更不能用 UUID 截断当前缀。
正确做法是用 SecureRandom 生成 12 字节随机 IV,同时在写入元数据库前检查唯一性。还有一个改进:用 fileId 和 chunkIndex 派生 IV,这样同一个文件的不同分片天然不会重复,不同文件因 fileId 不同也不会重复。但派生方式要稳定,别在代码升级后改变规则,否则老数据全部解不开。
6.3 加解密完整性校验与“解密成功但数据被改过”的边界
AES-GCM 自带认证,理论上密文被篡改时解密会直接报错。但分片独立加密有一个整体性问题:每一片都能独立认证,不代表整个文件没被换过分片序。比如攻击者把文件 A 的第 2 片和文件 B 的第 2 片调换,单片认证都能通过,但拼接出来的明文文件已经不是原始内容。
解决办法就是 AAD 绑定 fileId 和 chunkIndex,我前面代码里已经示范了。这样每一片的认证标签都绑定文件身份和分片位置,任意调换都会导致认证失败。另外还要在最后做一次明文 SHA-256 比对,前端上传前算的原始哈希、后端解密后重新算的哈希必须一致,才能最终确认文件没被掉包。
6.4 上线前的两类必测场景:超大文件与并发上传
我每次改这类功能,都会强制要求测试环境跑两个场景。第一个是超过 2 GB 的超大文件,专门检查分片编号会不会超过 int,内存有没有被某个 byte[] 撑爆,网关和对象存储对超大单流的限制是否开启。第二个是 20 个并发上传同时进行,检查元数据表有没有写锁竞争、Redis 分片状态位图有没有交集、KMS 限流会不会把整个服务打挂。
这两类场景过了,基本能覆盖生产环境九成的问题。我的个人习惯是在上线前放一个巡检脚本,跑一遍临时目录、访问日志、对象存储列表,用正则扫“身份证号、手机号、.xlsx、.pdf”这些关键词,一旦出现明文残留立刻报警。这个习惯让我在好几个项目里都提前发现了别人没看到的明文泄露点,也算是我最想分享给同行的一条实战经验。