☰
Java大文件上传方案:切片、秒传与断点续传实战
2026/10/8 2:45:31 网站建设 项目流程

在金融保险系统里摸爬滚打过的朋友应该都有体会:保单扫描件、理赔影像资料、银行对账单Excel、带电子签章的Word合同,这些文件的体积动不动就是几十MB甚至上GB,还一堆文件放在同一个文件夹里批量上传。传统上传方式在这种场景下简直是一场灾难——传一半断掉、超时重传、服务器内存被打满,业务人员盯着进度条干着急,运维那边CPU和带宽也跟着告急。我在项目里反复折腾过好几轮,最终的解法就是标题里说的这套东西:用切片算法把大附件拆碎,配合秒传和断点恢复,把上传体验从“提心吊胆”变成“无感完成”。这篇文章就把我在金融保险系统中的完整落地思路、关键代码和踩过的坑一次讲清楚,适合正在做Java文件上传模块、尤其是被大文件和弱网环境折磨的同学参考。

1. 先看清场景:金融保险的大附件上传到底难在哪

1.1 保单材料不是普通文件,是“又大又多又急”

金融保险业务里的附件上传,和一般论坛发个图片、社区传个头像完全不是一个量级。用户投保时要传身份证扫描件、收入证明、体检报告,理赔时要传诊断书、费用清单、发票影像,这些材料通常是PDF扫描件或者高清图片打包后的文件夹,单文件几十MB属于常态,文件夹整体上GB也不稀奇。再加上Word版合同、Excel对账单这类办公文档,格式标准不统一,里面可能还嵌着图片和宏,读起来更费劲。

难点还不只在大。金融业务对文件完整性和时效性要求极高,理赔材料传一半丢了,用户投诉事小,影响核赔时效、引发合规问题事大。而且这类系统往往部署在保险公司的内网环境里,有的还经过多层安全设备和网关,网速并不稳定,遇到高峰期或者跨地域传输,大文件上传失败率能到三四成。我接过一个实际案例,某分公司批量上传理赔影像,每天都有人传失败,后台日志里全是“连接被重置”“请求超时”,业务部门怨声载道。

这种场景下,传统的单次HTTP上传方案基本是撑不住的。核心问题在于:请求一次发出,数据量越大,中间链路出问题的概率就越高,而一旦出错就要从头再来。这不是网络的问题,是方案设计的问题。

1.2 秒传和断点,本质上是两个不同的问题

很多人把秒传和断点混在一起说,其实它们要解决的事情完全不同,只不过在切片方案里天然地被整合到了一起。

秒传的核心是“去重”。假设同一个用户今天传了某个Word合同,明天又要传一次,或者不同机构上传了内容完全相同的对账单,那么服务端其实已经有这份文件了,就没必要让数据再走一遍网络。秒传要回答的问题是:如何快速判断“这个文件和服务器上的某个文件是同一个”?答案通常是做指纹比对,最常用的就是MD5。只要文件内容一致,MD5就一致,服务器直接告诉客户端“你传过了,我帮你标记成功”,从用户角度看就是瞬间完成。

断点的核心是“恢复”。如果传输过程中网络断了、服务重启了、甚至用户手动关了页面,下一次再传的时候不应该从头开始,而是跳过已经传完的部分,只补发缺失的部分。这跟视频App里的断点续播是一个道理——看到35分钟退出,下次直接从35分钟接着看,而不是让你重新看一遍。断点要回答的问题是:如何精准记录“传到了哪一块”?这就天然对应上了切片:既然文件被拆成了分片,那就记录每个分片的状态,哪个传成了、哪个没传成,一目了然。

把这两个东西想清楚了,方案设计就有了主线:切片是为了断点恢复,指纹是为了秒传去重,两者共享一套分片状态管理机制。

1.3 为什么选切片而不是流式上传或断点重传

早期我见过不少项目走的是“断点重传”路线:不拆分文件,只记录HTTP请求的字节偏移量,下次从偏移处接着传。这种方式在局域网里能跑,拿到金融保险这种复杂网络环境下就问题不断。原因是很多网关、代理服务器对请求体有大小限制,超过一定阈值就直接断掉,而且流式上传的进度很难校准,中间任何一次写盘失败,偏移量就对不上了。

切片方案则从根本上规避了这些问题。它把一个大文件切分成若干个独立的小分片,每个分片用一次单独的HTTP请求上传,服务端收到后先落盘,最后再按顺序合并。这样一来,单次请求的数据量被控制在一个很小的范围,网络波动、网关限制的影响被降到最低;分片之间的状态相互独立,某个分片失败只需要重传那一片;而且分片可以并行上传,充分利用带宽,整体速度反而比单流上传更快。

我在金融保险系统里最终采用的是固定大小切片,默认4MB一片,关键路径上的设计要素包括:分片序号管理、服务端分片存储结构、状态记录机制、合并与校验流程。下面依次拆开讲。

2. 整体方案设计:切片的粒度、状态与存储怎么定

2.1 切片大小不是拍脑袋,先算网络和内存的账

切片大小是方案里第一个要决策的参数。切小了,分片数量多,请求次数成倍上涨,服务端要频繁写小文件,数据库状态记录也跟着膨胀;切大了,又失去了切片的意义,单次请求的压力重新变大。我一般按两个维度来考量。

第一是网络维度:金融保险系统的内网环境通常能跑5到20MB/s的带宽,外网接入层可能会更低。如果希望单个分片在5秒内传完,切片大小就该控制在带宽乘以5秒的量级。按内网10MB/s算,4MB到8MB比较合适;如果外网带宽只有1到2MB/s,切成1MB到2MB更稳。

第二是内存维度:分片上传往往要做MD5和临时写盘,如果客户端一次性把整个分片读进内存,4MB的分片对应的缓冲数组也就是4MB,这个量级在JVM里完全无压力;但如果切成32MB一片,并发传5个分片,内存占用就上去了。在保险公司的老服务器上,堆内存经常只给了512MB到1GB,这种场景下我宁可多切几片,也不冒险让内存打满。

我最终的保守选择是:内网环境用4MB,外网弱网环境动态下探到2MB。代码里可以通过配置中心动态调整,不需要重新发版。

2.2 分片序号与请求标识的设计

分片上传最怕的是乱序和串档。我先定了一套命名和标识规范,然后在代码层强制约束。

每个上传任务生成一个全局唯一的 uploadId,用UUID就行。每个分片的请求里必须带上四个核心参数:文件唯一指纹(MD5)、上传任务ID、分片序号、总分片数。服务端收到的分片文件统一按 “工作目录/uploadId/序号” 存储,比如:

/data/upload_temp/8f3c2a11-xxxx-xxxx-xxxx-xxxxxxxxxxxx/ ├── 0.part ├── 1.part ├── 2.part └── 3.part

分片序号从0开始递增,和文件切分的字节区间严格对应。比如4MB一个分片,第0片对应 [0, 4194304),第1片对应 [4194304, 8388608),最后一片不足4MB就按实际长度来。

这里有个关键点:分片序号不能由客户端随意指定顺序,必须按偏移量计算。我见过有人按文件名排序来编号,结果某些系统里文件名排序是字典序,“10.part”会排在“2.part”前面,合并的时候就乱套了。所以我坚持只传纯数字序号,合并时按序号大小排序,绝不依赖字符串排序。

2.3 秒传指纹与分片状态分开存储

状态存储我用了一张表加Redis缓存的组合。数据库表用来做最终一致性保证,Redis用来做高频状态查询。

数据库表的数据结构大致是:

字段类型说明
upload_idvarchar(64)上传任务全局唯一ID
file_md5varchar(64)整个文件的MD5,用于秒传判断
file_namevarchar(255)原始文件名
total_sizebigint文件总字节数
chunk_sizeint每个分片的字节数
total_chunksint总分片数
received_chunkstext已收到的分片序号集合,用逗号分隔
statusint0初始化,1上传中,2合并中,3完成,4失败
create_timedatetime创建时间
update_timedatetime更新时间

Redis里的设计更轻量:用uploadId作为key,用二进制位图标记哪些分片已经到达。比如总共100个分片,位图第i位是1就表示第i片已收到。合并前扫描位图,一眼就能看出缺了哪些分片。

分片状态的设计有一个容易漏掉的细节:已上传的分片在合并之前不能直接当成功。因为分片可能传了一半,服务端写盘失败,或者文件校验和不对。所以我在接收分片的逻辑里除了记录“收到”,还会校验分片自身的MD5,校验不过就把状态置为失败并要求重传。分片校验和整文件校验是两道独立的关卡。

2.4 服务端目录结构与临时文件管理

临时文件的目录管理,我吃过几次亏之后总结出一套比较稳的规范。工作根目录按月建目录,比如 /data/upload_temp/202506/,每个上传任务再建一个以uploadId命名的子目录。分片文件直接写进子目录,合并完成后,整个子目录移到归档区或者直接删除。

要注意的点是:切分目录的清理策略。金融保险系统里的文件不允许随意删除,但临时分片文件属于中间产物,过了保留期就该清理。我一般加了一个定时任务,扫描超过24小时未完成的上传任务,先把状态置为“过期”,再删除临时目录。过期时间设在业务低峰期执行,避免影响正在上传的请求。

这里最大的教训是:千万别把分片文件直接写到业务文件目录里。我见过有人图方便,把分片直接落在网盘目录下,结果用户还没上传完,网盘同步客户端就把半拉子分片同步走了,最后合并时出现“文件被占用”的诡异报错。

3. 秒传是怎么实现的:文件指纹与查重链路

3.1 MD5做秒传依据,够用但不万能

秒传的核心逻辑是拿“整个文件”的MD5去服务端查一次,如果命中,就直接返回成功。这个策略在最常见的场景下是可靠的:同一个文件内容一致,MD5就一致;内容不同,MD5冲突的概率在工程实践中可以忽略。

但是金融保险系统得考虑一个隐患:MD5本身是摘要算法,理论上存在碰撞可能,而且在信息安全的语境里,MD5已经被认为不够安全。现实中我们不会拿MD5当加密用,只是当文件唯一性标识,碰撞概率非常低,用在秒传场景是够的。为了进一步提高可靠性,我在指纹里叠加了文件大小:MD5一致且大小一致的才判定为已存在,双因子排除掉绝大多数极端情况。

有些要求更严的金融机构,会要求用SHA-256替代MD5,代价是计算性能略低,大文件初始化扫描更慢。这个可以做成可选配置,我只在核心节点的秒传判断上同时计算两种摘要,生产上绝大多数服务还是走MD5。

另外要注意计算MD5的时机。对于几十MB的文件,全量读一遍计算MD5大概要几百毫秒到1秒,这个延迟用户能接受,但如果是几个GB的文件,就不太合适了。我的做法是:客户端在启动上传前异步计算整个文件的MD5,计算完成后再正式发起秒传请求。如果这个文件之前已经算过并且有缓存记录,直接复用,连算都不用算。

3.2 秒传判断的前置流程

秒传不是“传完了再判断”,那样就失去意义了。正确顺序是:客户端先算好MD5,带着MD5和文件大小访问秒传接口,服务端查库,命中就返回“无需上传”,同时把文件在存储系统中的地址返回给客户端,用于后续业务关联。

我把秒传接口设计为三步:

第一步,客户端调用 preCheck 接口,参数是 fileMd5、fileSize、fileName。服务端先在文件指纹表里查,如果命中,返回 code=1(秒传成功),直接结束。如果没有命中,返回 code=0,继续走正常上传。

第二步,服务端在返回 code=0 的同时,创建 uploadId 和分片参数,把总片数、每片大小一并返回给客户端。客户端拿到这些参数后开始切分并逐个上传分片。

第三步,全部上传完成后,客户端调用 complete 接口,服务端做分片合并和整文件MD5复算,更新指纹表。这样下次再有人传相同文件,第一步就直接秒传了。

这里有一个容易被忽略但很影响体验的环节:第一次传的文件如果在中途失败了,指纹表里不应该有记录;但如果所有分片都传完了,只是合并时出了错,指纹表已经写了怎么办?所以我把指纹表的写入放在了合并成功之后,失败时不写,宁可让用户重新触发一次秒传判断。

3.3 秒传的“双写校验”逻辑

只靠客户端的MD5来做秒传判断,存在一个信任问题:客户端完全可以伪造一个假的MD5。这在对外网开放的保险业务场景里是个隐患——如果有人恶意提交一个已知MD5的假请求,服务端就认为文件上传成功,但实际并没有任何真实数据落盘。

我的做法是双写校验:秒传请求命中指纹表后,服务端不会只返回一个成功状态,而是把这个文件的实际存储地址、大小、最后一个分片的MD5一并返回。前端拿到这些元数据后可以在业务侧做展示,但真正的文件读取还是走后端存储系统,不走前端透传。这就把“秒传是不是真的成功”的判断权收回到服务端手里。

另外还要补一个安全细节:秒传命中后,文件在存储系统中的归属权限要重新绑定。保险业务里不同机构之间的文件权限是隔离的,A机构上传的文件,B机构不能因为MD5相同就直接访问。所以秒传成功后我会做一次文件归属拷贝,把文件从公共指纹区复制一份到当前业务空间,而不是简单地返回一个公共地址。

4. 断点续传的落地细节:状态记录、恢复与并发控制

4.1 恢复不等于重传,先搞清楚缺了哪些分片

断点续传的实现,最核心的不是“怎么继续传”,而是“怎么知道哪些传过了”。我采用的状态管理方案是服务端记录 + 客户端记录双重的。

服务端的记录来自保存到数据库和Redis的分片状态。客户端每次启动上传前,先调用 queryUploadStatus 接口,传入 uploadId,服务端返回一个分片序号列表,已经收到的返回1,缺失的返回0。客户端拿到这个列表后,只上传缺失的分片。

这有个天然的好处:即使客户端本地状态完全丢失,比如换了一台电脑,只要 uploadId 还在,服务端就能把已传分片告诉新的客户端。当然,保险场景里大多数是同一个客户端重复上传,但服务端状态兜底是必须的。

客户端这边也要做一份本地状态缓存。我在客户端用一个JSON文件记录每个分片的上传状态,放在临时目录里,文件名就是 uploadId.json。里面包含分片序号、是否上传成功、分片的MD5、上次上传时间。每次上传分片成功后立即更新这个JSON文件。这样做的价值在于:网络断掉连不上服务端的时候,客户端本地还能知道传到了哪里,可以省掉一次状态查询。

4.2 合并前的一次性校验

分片全部上传完之后,服务端不能直接合并。我加了一道二次校验:遍历所有分片文件,核对数量是否等于 totalChunks,大小是否总和等于 totalSize,再抽几片算一下分片MD5是否与上传时记录的MD5一致。校验通过才允许执行合并。

这一步在实际业务中救过我好几次。有一回某个分片因为网络抖动,HTTP请求返回了200,但服务端写盘时发生部分写入,文件长度比预期小了几个字节。如果不校验,合并出来的文件就是损坏的;核赔系统读取理赔影像时往往不会立刻报错,等用到第N页才发现数据错位,那时候问题就大了。加了合并前校验后,这种情况在源头就被拦截,客户端会收到提示重新上传该分片。

4.3 并发上传与线程池设计

分片天然支持并行上传,但并发数不是越大越好。客户端上传分片时,我按机器性能和网络带宽做了并发数动态调整:默认4个并发,弱网环境降到2个,内网千兆环境下最多开到8个。

实现上就是线程池 + 计数器:提交N个分片到线程池,每个分片上传成功后计数器减一,任一失败则把当前批次标记为失败。这里最关键的细节是幂等:同一个分片允许被重复上传,重复上传时服务端直接覆盖同名文件,不影响其他分片。因为网络超时导致客户端实际已经上传成功、但客户端以为失败的情况很常见,允许重传同一个分片是保证最终一致性的基础。

服务端接收分片的接口也必须是幂等的。我设计成:如果该分片已存在且大小一致,直接返回“已有”,不重复写盘。这样即使客户端重复提交,也不会造成资源浪费。

4.4 合并时的IO性能优化

合并阶段最怕的是把分片一个个全读进内存再写文件,大文件夹场景下很容易OOM。我的合并代码用的是 NIO 通道 transferTo 方法,流式地将分片内容搬运到目标文件,不经过JVM堆内存。这个做法在保险公司的老机器上运行得很稳。

另外合并时还要考虑磁盘空间。临时分片和合并文件同时存在,峰值磁盘占用接近原文件的两倍。所以在合并流程中,每写完一个分片的内容,就删除对应的 .part 文件,实时释放空间。这样峰值只维持在原文件大小的基础上多一点点,而不是两倍。

5. 核心代码实现:从切分、上传到合并的完整链路

5.1 客户端切分与分片初始化

客户端的切分逻辑用 RandomAccessFile 实现。先读文件长度,然后按 chunkSize 从0字节偏移开始循环截取。每个分片除了原始数据,还要在请求头里带上 uploadId 和 chunkIndex。

public List<FileChunk> splitFile(File file, int chunkSize) throws IOException { List<FileChunk> chunks = new ArrayList<>(); long fileLength = file.length(); int chunkIndex = 0; try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { byte[] buffer = new byte[chunkSize]; long offset = 0; while (offset < fileLength) { raf.seek(offset); int readLength = raf.read(buffer, 0, (int) Math.min(chunkSize, fileLength - offset)); FileChunk chunk = new FileChunk(); chunk.setIndex(chunkIndex++); chunk.setOffset(offset); chunk.setLength(readLength); chunk.setData(Arrays.copyOfRange(buffer, 0, readLength)); chunks.add(chunk); offset += readLength; } } return chunks; }

这段代码是直接可跑的,注意最后一片的长度是不足 chunkSize 的,拷数据时用 copyOfRange 截断,避免把缓冲区的空位带上。

5.2 秒传预检与上传任务初始化

秒传预检接口用Spring Boot的Controller实现,核心逻辑是先查指纹表,再决定是直接返回成功还是创建上传任务。

@PostMapping("/upload/preCheck") public Result<?> preCheck(@RequestBody PreCheckRequest req) { FileFingerprint fp = fileFingerprintMapper.findByMd5AndSize(req.getFileMd5(), req.getFileSize()); if (fp != null) { // 命中指纹,秒传成功 return Result.success(ImmutableMap.of( "fastUpload", true, "fileId", fp.getFileId(), "storagePath", fp.getStoragePath() )); } // 未命中,创建上传任务 String uploadId = UUID.randomUUID().toString(); int chunkSize = resolveChunkSize(req.getFileSize(), req.getClientEnv()); int totalChunks = (int) Math.ceil((double) req.getFileSize() / chunkSize); UploadTask task = new UploadTask(); task.setUploadId(uploadId); task.setFileMd5(req.getFileMd5()); task.setFileSize(req.getFileSize()); task.setFileName(req.getFileName()); task.setChunkSize(chunkSize); task.setTotalChunks(totalChunks); task.setStatus(0); uploadTaskMapper.insert(task); return Result.success(ImmutableMap.of( "fastUpload", false, "uploadId", uploadId, "chunkSize", chunkSize, "totalChunks", totalChunks )); }

注意 resolveChunkSize 要根据客户端网络环境动态计算,我现在是读配置中心的参数,内网默认4194304字节,外网弱网默认2097152字节。

5.3 分片接收与状态落库

分片接收的接口用 MultipartFile 接收内容,写到 uploadId 对应的目录中。写完文件之后更新Redis位图和数据库 received_chunks 字段。

@PostMapping("/upload/chunk") public Result<?> uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("uploadId") String uploadId, @RequestParam("index") Integer index) { // 校验上传任务状态 UploadTask task = uploadTaskMapper.findByUploadId(uploadId); if (task == null || task.getStatus() != 0) { return Result.error("上传任务不存在或已关闭"); } // 幂等判断:该分片已存在且大小一致,直接返回成功 Path chunkPath = Paths.get(BASE_DIR, uploadId, index + ".part"); if (Files.exists(chunkPath)) { long existSize = Files.size(chunkPath); if (existSize == file.getSize()) { markChunkReceived(uploadId, index); return Result.success(); } } // 写入分片文件 Files.createDirectories(chunkPath.getParent()); try (InputStream in = file.getInputStream()) { Files.copy(in, chunkPath, StandardCopyOption.REPLACE_EXISTING); } markChunkReceived(uploadId, index); return Result.success(); }

markChunkReceived 方法里做两件事:把Redis位图对应位置1,同时更新数据库的 received_chunks 字符串。更新数据库时用追加逗号加序号的方式,不用每次读全量再覆盖。

5.4 合并与整文件校验

合并前先校验完整性,再执行文件拼接。拼接我用的是 FileChannel 的 transferTo,可以理解成操作系统级的零拷贝搬运,内存占用极低。

public File mergeChunks(String uploadId, String dstPath) throws IOException { UploadTask task = uploadTaskMapper.findByUploadId(uploadId); // 1. 完整性与大小预校验 if (task.getTotalChunks() != listChunks(uploadId).size()) { throw new IllegalStateException("分片数量不匹配,缺失部分分片"); } // 2. 按序号排序后顺序拼接 Path dest = Paths.get(dstPath); try (FileChannel outChannel = FileChannel.open(dest, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { for (int i = 0; i < task.getTotalChunks(); i++) { Path part = Paths.get(BASE_DIR, uploadId, i + ".part"); if (!Files.exists(part)) { throw new IllegalStateException("缺少分片: " + i); } try (FileChannel inChannel = FileChannel.open(part, StandardOpenOption.READ)) { long remaining = inChannel.size(); long position = 0; while (remaining > 0) { long transferred = inChannel.transferTo(position, remaining, outChannel); position += transferred; remaining -= transferred; } } Files.delete(part); // 合并完立即删除分片 } } return dest.toFile(); }

合并完成后,还要对最终文件重新计算MD5,和客户端上传时的 fileMd5 比对。不一致就删除合并文件并报错,一致才允许更新指纹表,把状态置为完成。这一步是整个链路里绝对不能省的最后一道门槛。

5.5 大文件夹批量上传的任务编排

单个文件切片方案做好后,文件夹批量上传就是在这个基础上的任务编排问题。我实现的思路是:先扫描文件夹,生成文件清单,按文件大小从大到小排序,然后并行发起多个文件的上传任务。每个文件的上传流程和单文件完全一致,互不干扰。

文件夹里不同文件之间没有顺序依赖,但同一文件的分片之间不能跨文件并发,否则服务端不便管理。所以我为每个文件单独分配一个线程池,避免同一个文件的多个分片被不同的线程乱序处理。总体并发数控制在一个上限之下,比如同时最多处理5个文件,每个文件最多4个分片并发。

进度统计也要按文件夹维度汇总:总文件数、已完成文件数、当前文件的分片进度,前端展示的时候按文件夹整体进度来,用户体验比单文件进度好得多。

6. 常见问题与排查技巧实录

6.1 问题速查表

我整理了一份在金融保险系统环境里实际遇到过的典型问题,附带现象描述和排查思路。

问题现象可能原因排查与解决
秒传总是提示成功但文件打不开指纹表提前写入,合并尚未完成检查完整合并后再写指纹表,确认 status 为3时才返回秒传成功
分片传完后合并报“缺少分片”分片文件被清理定时任务误删清理任务的过期时间应大于上传任务最大生命周期,建议至少24小时
上传到一半服务端内存飙升分片太大或并发数太高检查线程池并发数,降低 chunkSize,或改用 NIO 流式写盘
弱网环境下分片反复失败单次请求超时时间设置太短客户端HTTP超时时间按“分片大小/最低带宽”计算,至少30秒
合并后文件MD5与原始MD5不一致客户端切分时数据错位或服务端写盘不完整给每个分片额外记录独立MD5,合并前逐个校验分片MD5
断点恢复后进度丢失客户端本地状态文件未同步确保每个分片上传成功后立即更新本地JSON缓存,恢复时优先读取本地状态
文件夹上传时某些文件秒传不生效指纹只匹配内容,不同文件名不触发秒传指纹匹配应忽略文件名差异,只比对MD5和文件大小
页面显示100%但业务侧查不到文件合并完成后未更新业务表的文件关联合并成功并迁移到正式目录后,再更新业务表的 fileId 和 storagePath

6.2 几个必须注意的实战细节

第一个细节是HTTP客户端超时设置。很多人默认用30秒超时,弱网下传4MB分片可能不够,我在客户端里按“分片大小除以预估最低带宽再加10秒”动态计算超时时间。比如预估带宽512KB/s,4MB分片至少8秒,超时就设到20秒以上。这个细节不处理,弱网下断点续传会被大量假失败淹没。

第二个细节是服务端接收分片的幂等。客户端在网络超时后重传同一个分片是常态,服务端接口必须能识别“这个分片我已经收过了”。我的做法是:同名分片文件已存在且大小一致,直接返回成功。这个设计让断点恢复逻辑变得非常简单——客户端只需要无脑补传缺失分片,重复传也安全。

第三个细节是状态更新的顺序。先写Redis位图,再更新数据库,可以减少对数据库的压力。但如果Redis和数据库都更新完,然后合并失败,要把状态回滚成可重试。我的做法是合并失败后不删分片,只把status改成4,客户端拿到失败状态后可以从任意缺失分片继续上传。

6.3 上线前一定要做的压力测试

金融保险系统上线这种切片上传功能,不能只在小规模环境里测一下就完事。我给团队定的测试标准是:单机2万并发分片请求、连续跑1小时,观察服务端线程池饱和度和磁盘IO。实测中最大的瓶颈往往不是应用本身,而是临时目录所在磁盘的IOPS,尤其是机械硬盘上合并大文件时特别慢。所以我会要求在SSD上单独划一块分区给临时目录,业务文件落在另一个存储上,两者互不干扰。

还有一点是关于完整性的抽检逻辑。压测时不能只看所有请求都是200,要在压测结束后随机抽取上传任务,把合并后的文件和原始文件做字节级比对。我见过不少系统压测时服务端没有报错,但某个分片因为并发写盘的时序问题,写入的数据错位了,导致合并文件字节数和原始文件一致但内容不对。字节级比对是最好的兜底测试。

6.4 关于“忽略不需要的步骤”

工程上有一个很重要的经验,就是断点恢复时不要盲目地把所有分片都重传一遍。正确的顺序是:先调用状态查询接口拿到服务端已确认的分片列表,把客户端本地状态和服务端状态做交集,然后只上传两边都确认缺失的分片。这个逻辑我用过一个很形象的比喻来形容:这就好比看视频,你拖到35分钟,播放器只需要下载35分钟之后的数据,35分钟之前的内容已经存在本地缓存里了,没必要重新下载。

实际生产里,由于网络抖动导致某些分片上传成功但客户端没收到响应的情况很常见。这种情况下客户端本地会把该分片标记为失败,但服务端状态是成功。恢复的时候如果只看客户端状态,就会多传一片;只看服务端状态,又可能漏掉真正缺失的。所以必须取“两边状态的差集”,而不是简单用任一边的判断。这也是断点续传做得严谨与否的分水岭。

7. 如果再让我做一次,我会调整什么

这套方案在几个保险业务项目里跑了一年多,整体稳定,但我复盘下来有几个点如果能重来,会在一开始就调整。第一是把文件指纹从MD5直接升级成“MD5+文件大小+SHA-256”三重校验,早期为了兼容老系统只用了MD5,后期再改就涉及数据迁移和存量文件重算,麻烦很多。第二是给临时分片文件加一层加密存储,金融监管对敏感影像资料的静态加密有明确要求,当时是先上线后补的,中间有过一段不合规风险期。第三是分片并发参数应该从配置中心动态下发,而不是写死在客户端的配置文件里,我刚上线时就因为不同分公司网络差异大,被迫做了两版配置。

最后想提醒后来者的是:切片算法的代码实现本身并不复杂,真正难的是把“网络异常”“服务重启”“文件损坏”“重复提交”这些非正常路径都想清楚。我在开发这个模块的大半年时间里,至少有一半的时间花在测试这些异常分支上。切片上传没有银弹,但只要你把状态管理做实、把幂等做透、把校验做足,这套方案在金融保险这种对可靠性要求苛刻的场景里是完全能扛住的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询