ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

大文件分片上传与断点续传方案详解:从原理到生产实践

2026/9/29 17:11:24 拓冰建站 浏览量
大文件分片上传与断点续传方案详解:从原理到生产实践 有些需求看起来简单真正上了生产环境才知道坑有多深。上传功能就是很典型的一个小文件随便拿个POST接口传一传就完事可一旦文件体积到了几百MB甚至几个GB网络抖动、请求超时、服务端内存打爆、用户等待时间过长各种问题一次性全冒出来。我最早在做视频素材库的时候被这种场景折磨过一轮后来才彻底把方案收敛成一套组合拳——大文件分片上传、断点续传、异步合并、再加上异步通知四个环节环环相扣才算把这个事解决得比较干净。这篇文章我不打算只贴一堆代码而是把整套方案的来龙去脉讲清楚为什么会是这个设计、每步怎么做、生产环境里会遇到哪些坑。适合正在做文件上传功能的后端开发、前端开发以及技术选型阶段的架构师参考内容不算新手向但也不晦涩你不需要看过源码也能把方案直接落地。1. 为什么大文件必须要分片上传完整上传的先天缺陷在哪里先回答一个问题直接把整个文件丢给后端为什么不行很多人第一反应是“小文件不都这么传嘛”没错几MB的文件一次性POST出去没有一点问题。但文件一大麻烦就来了。1.1 超时、内存、IO三重压力之下完整上传为什么扛不住假设一个2GB的文件用户点击上传后就往服务器传。如果不做任何处理请求是长连接持续占用中间只要网络波动一下TCP重传机制会帮你撑一会儿但撑不了太久。网关层如Nginx默认的请求超时时间通常设置在60秒左右后端容器如Tomcat的连接超时也限制得很死。2GB要传多久取决于带宽按家用宽带10Mbps上行算理论需要接近半小时。你想想看一个HTTP请求挂半小时中间任何一层代理、网关、负载均衡器都可能把它掐断一旦断的就是从零开始。后端内存也好不到哪去。如果用MultipartFile直接接Spring容器会先把整个文件读入临时目录虽然不全是内存但对临时磁盘、对文件描述符的开销非常大。如果是微服务架构中间还要过一层API网关或有请求体大小限制的代理——很多网关默认只允许10MB或50MB。对应到具体业务里就是“一传大文件就被Gateway拒绝”。这个问题在完整上传方案里无解只能绕开。IO层面同样难受。服务器接收一个2GB的文件磁盘要连续写入2GB数据如果上传到一半断了这2GB的临时文件全部作废占用着磁盘空间还很难自动清理。时间、带宽、磁盘、连接四个维度全部处于高压力状态而收益只是“简单”。所以当文件超过100MB这个量级后完整上传基本就不划算了。1.2 分片上传的解决思路把问题拆小分片上传的思路非常直白把一个2GB的文件按固定大小切成比如5MB一份得到约400个分片然后逐个或按并发窗口上传。每个分片都是一个独立的HTTP请求生命周期很短一个分片失败不会影响其他分片。哪个分片失败就重传哪个不用整个文件再来一遍。这就把原来“一个大请求传2GB”拆成了“400个小请求各传5MB”。请求超时的可能性大幅下降因为每个请求都是小体积、短连接。内存压力也变小了后端处理5MB的缓冲区完全不是个事。更关键的是分片天然给断点续传留好了位置——我们只要记录哪些分片已经成功上传下次从没传过的分片接着传就行。从工程视角看分片的意义不只是“能上传大文件”而是让整个上传过程变得可观测、可控制。进度可以精确到分片级别重试可以只针对失败分片并发数可以按带宽动态调整。这些能力是完整上传完全不具备的。1.3 断点续传和完整上传的本质区别完整上传是“一锤子买卖”成功就是成功失败就只能全部重来。你传到95%断网对不起下次还是一整份从头传起。这对用户来说非常崩溃因为网络环境不是我们能保证的而且移动端更明显。断点续传的本质是“基于已完成的进度继续干活”。它的前提是你把文件切了片并且后端持久化了每个分片的完成状态。前端在重新上传时不去傻乎乎地上传而是先发一个查询请求问后端“这个文件我传过哪几个分片”后端返回已存在列表前端把剩余的分片传完即可。这里要特别说一下断点续传不是简单把文件再打开从某个偏移量继续读。网络断掉之后连接上下文没了后端无法知道你这个请求传到哪个字节了。所以断点续传必须在“文件逻辑块”层面工作——分片就是逻辑块。记录分片是否成功比对的是分片级别的完成情况而不是字节级断点。也正是因为这个原因断点续传和分片上传总是成对出现。2. 整体流程拆解从一个上传请求开始到异步通知结束方案落地的第一步是先把时序理清楚。我不建议边写代码边想流程分片上传涉及的环节太多容易漏。2.1 一次分片上传的完整时序标准流程通常包含七个步骤第一步前端计算整个文件的唯一标识常用的做法是计算文件的MD5值或者MD5加上文件大小拼一个标识。第二步前端调用“初始化上传”接口把文件标识、文件名、文件大小、分片大小、总片数传给后端后端判断这个文件是否已经存在如果存在且已完成直接返回“秒传成功”。第三步后端为这个上传任务创建一条记录并返回一个uploadId或者taskId之后所有分片都关联到这个任务下。第四步前端拿到taskId后把文件切成N片启动并发上传每片带着taskId、分片序号、分片总数量上报。第五步后端收到每个分片后落盘存储同时更新这个任务的分片完成记录。第六步前端上传完所有分片后调用“完成上传”接口告诉后端所有分片都已推完。第七步后端尝试验证分片完整性触发异步合并任务合并完成后发送异步通知给业务方。这个流程里有一个容易被忽略的细节秒传。因为文件标识基于内容计算如果两个用户上传同一个文件理论上只需要存储一份文件。后端在初始化阶段发现记录存在、文件已经合并成功直接返回成功即可。这个逻辑既省带宽又省存储是分片上传里性价比最高的优化方案。2.2 关键数据结构设计任务记录、分片记录、文件记录表结构至少需要三张。上传任务表用于记录每次上传任务的元信息。核心字段包括task_id、file_identifier内容标识、file_name、file_size、chunk_size、chunk_count、存储路径、状态、创建时间、完成时间。状态可以设计为0初始化中、1上传中、2合并中、3完成、4失败。这张表是整个上传流程的主线所有环节都围绕它驱动。分片记录表用于记录每个分片的上传情况。核心字段包括id、task_id、chunk_index、chunk_size、存储路径、上传状态、创建时间。保存分片记录的意义在于支持断点续传前端查询接口直接从这张表返回已成功上传的分片序号数组。一致性的关键是在task_id和chunk_index上建唯一索引防止同一个分片被重复插入。文件记录表是最终成果的登记表。核心字段包括file_id、task_id、file_name、file_size、存储路径、完成时间。它的作用是把异步合并后的物理文件信息落到库里业务方来查询或通过异步通知拿到的文件ID就是这里的记录。这三张表并不复杂但它们承担了全部核心逻辑的持久化基础。在实际项目里有些人图省事只保留一张表把分片信息存成JSON字段开发阶段看着方便分片一多查询和更新性能就劣化了而且并发更新时容易丢数据。我的建议是规规矩矩拆开即使不上分库分表这三级结构在千万任务量级下依然能跑得动。2.3 分片大小与并发数怎么定算一笔实际账分片大小是一个需要权衡的参数。分片太小比如1MB请求次数会非常多2GB要切2048片每个分片都有HTTP请求头、网络往返、数据库更新前端与后端都忙不过来整体吞吐不升反降。分片太大比如50MB又退回到接近完整上传的问题单个请求的失败代价变大断点续传的粒度变粗。结合公网环境的普遍情况我建议分片大小设置在2MB到10MB之间。2GB文件按5MB切大约410片这个数量级是可控的。如果是内网环境带宽充裕、延迟低可以放宽到10MB或20MB。一个简单的估算方式是用带宽除以并发数得到一个分片的期望传输时间理想的传输时间在1秒到10秒之间。传输时间太短说明分片偏小请求开销占比高太长说明分片偏大失败重传的代价高。并发数方面前端同时发起的上传请求数量一般控制在3到6个。并发数过高并不会无限提升速度因为浏览器对同一域名的并发连接数有限而过多的并发会导致网络拥塞、TCP连接互相挤占带宽实测下来反而变慢。我项目里的经验取值是5个并发2GB文件在100Mbps带宽下通常能在几分钟内完成。3. 前端实现切片、并发控制与断点续传的真实玩法分片上传的前端实现是整个方案的起点比如切片怎么做、并发怎么控制直接决定用户体验。前端偷懒后端再怎么优化都白搭。3.1 切片怎么做文件对象切割与唯一标识生成浏览器端的File对象自带slice方法可以按字节切割文件。假设分片大小是5MB伪代码就是遍历文件长度按步长截取。const file fileInput.files[0]; const chunkSize 5 * 1024 * 1024; const fileSize file.size; const chunkCount Math.ceil(fileSize / chunkSize); const chunks []; for (let i 0; i chunkCount; i) { chunks.push(file.slice(i * chunkSize, Math.min((i 1) * chunkSize, fileSize))); }这里有个重要的细节文件唯一标识。切出来是很多块但后端要判断“这是同一个文件”不能让用户换个文件名就能绕过秒传或重复上传。所以必须基于文件内容计算标识。最常用的方案是文件MD5浏览器端用crypto.subtle.digest或者引入SparkMD5库来算。如果前端一次性读取整个文件计算MD52GB文件会直接把浏览器内存打爆。正确做法是分片读取加增量哈希。用SparkMD5的incremental模式逐个分片喂入等所有分片喂完拿到最终MD5。const spark new SparkMD5.ArrayBuffer(); for (let i 0; i chunks.length; i) { const buffer await chunks[i].arrayBuffer(); spark.append(buffer); } const fileMd5 spark.end();这个过程在文件较大时会有明显的耗时需要考虑加loading状态。如果进一步优化可以把计算放到Web Worker中避免主线程被阻塞导致页面卡死。在后端校验时也建议重新计算各分片的MD5进行比对保证数据在传输过程中没有被损坏。前端算标识是为了判重和续传后端算标识是为了完整性校验两者目的不同但缺一不可。3.2 并发控制手动控制切片上传队列浏览器自带的并发机制并不适合直接用于分片上传。如果不做控制一次性把所有分片全发出去几百个请求同时打在服务器上服务端连接数、线程池很容易被打满而且用户根本感知不到任何性能提升。更麻烦的是无法精确管理失败重试分片一多根本分不清哪个成功哪个失败。所以实践中要自己实现一个并发池。设计思想是维护一个任务数组限制并发数启动N个调度器每个调度器从队列中取一个任务执行执行完毕后取下一条直到队列为空。这种模型在Node端可以直接用async.queue在浏览器端就需要自己写或者用p-limit类的库逻辑不复杂。每次上传分片的请求体是FormData附加必要的元信息。const formData new FormData(); formData.append(chunk, chunk); formData.append(taskId, taskId); formData.append(chunkIndex, i); formData.append(chunkCount, chunkCount);请求发出后需要根据响应判断这一分片是否成功。如果失败放入待重试队列最多重试三次。三次都失败就暂停整个任务提示用户检查网络后手动恢复。自动重试不能无限做下去否则在服务器异常时会产生大量无意义的请求。3.3 断点续传在前端的实现方式前端这边的断点续传分两种情况页面刷新后的续传以及设备重启后的续传。页面刷新相对好处理把文件MD5、taskId、上传进度等元信息放进localStorage或sessionStorage即可。用户再次选择同一个文件时通过文件MD5去存储中查找对应记录如果有就恢复到断点位置。设备重启或换了浏览器本地存储就靠不住了。这时需要向后端发起查询后端根据文件MD5扫描分片记录表返回已经上传成功的分片序号集合。前端计算出哪些分片还没传直接传剩下那些。这个逻辑就是断点续传的核心实现。async function resumeUpload(fileMd5, chunks) { const response await fetch(/v1/upload/status?identifier${fileMd5}); const completedIndexes new Set(response.data.completedChunkIndexes); const pendingChunks chunks.filter((chunk, i) !completedIndexes.has(i)); // 继续上传 pendingChunks }有个容易忽略的问题分片是否完整不能只看序号。比如用户上次用的分片大小是5MB这次改成了10MB分片序号完全对不上续传就失去了意义。所以前端在查询续传状态时要把分片大小也带上后端比对存储的chunk_size是否一致不一致就要求客户端重置任务重新上传。3.4 进度展示与用户交互的细节进度展示听起来简单做细了才知道复杂度。单个分片的上传进度只是分片内部的事而用户关心的整体进度是“已上传字节数 / 总字节数”。计算时可以用成功上传的分片数量乘以分片大小也可以让后端在上传完成接口里返回精确的已接收字节数。千万别用“所有请求的平均进度”来算整体进度否则前面传几分片时进度瞬间就会跳到很高然后卡住不动。用户交互上有一个细节非常影响体验上传过程中如果某个分片失败不要立刻将整个进度条变红可以先在后台静默重试一两次重试失败后再明确提示用户并提供“继续上传”按钮。断点续传的价值就在这里用户点击继续后从本地记录查询已传分片只补传缺失的部分体验会很流畅。4. 后端实现接收、校验、异步合并后端实现是整个方案的核心稳定点。分片上传与普通接口的编写风格完全不同强校验、幂等、异步几乎是三个必备科目。4.1 分片接收接口与幂等设计分片接收接口的核心是两件事把上传的分片保存到临时目录以及更新分片完成记录。后端按taskId和chunkIndex组织目录结构推荐使用uploads/{taskId}/{chunkIndex}.part的形式落盘。接口需要做到幂等。同一分片如果因为客户端重试而重复提交后端不能产生两份不同的数据也不能报错。最简单可靠的做法是先检查分片记录是否存在如果存在且大小一致直接返回成功如果存在但大小不一致返回错误并提示客户端重置分片。检查加写入之间有并发窗口数据库的唯一索引在这时兜底。在分片记录表上建uk_task_chunk(task_id, chunk_index)重复插入会被数据库拦截捕获冲突异常后当作成功返回即可。接收分片时建议校验Content-Length。每个分片请求的体量应等于声明的大小如果客户端传了超出预期的数据要直接拒绝。这个校验能挡掉不少客户端异常或者恶意构造的请求。另外分片数据不能只存在内存里应使用文件流方式写入磁盘避免大并发上传时内存暴涨。4.2 校验逻辑MD5、大小、总片数不要等所有分片传完才做校验那样发现的问题太晚了。分片级校验应当在每次接收分片时执行计算接收到的分片数据MD5与客户端请求中携带的MD5比对不一致直接返回失败。前端拿到失败响应后自动重传这一片。完成上传接口的校验是最终防线。核心校验点有三个已收到的分片数与总片数是否一致每个分片的大小是否符合预期特别是最后一片的大小可能比标准分片小要允许不相等各分片的序号是否连续有没有缺号或重复。if (chunkRecordCount ! totalChunkCount) { throw new BizException(分片数量不一致已上传 chunkRecordCount 预期 totalChunkCount); } SetInteger indexes chunkRecordDao.selectIndexesByTaskId(taskId); for (int i 0; i totalChunkCount; i) { if (!indexes.contains(i)) { throw new BizException(缺失分片 i); } }校验通过后把任务状态置为“合并中”再触发异步合并。这一步的前置条件是给任务状态加上分布式锁或者用数据库原子更新状态字段避免重复触发。比如两个完成请求同时打到后端状态从0变1只能成功一次另一个会操作失败。4.3 异步合并的触发与实现为什么不能同步合并文件大的时候合并2GB文件涉及的磁盘读写可能有几GB的IO量耗时数十秒甚至更长。同步合并会让HTTP请求长时间挂起网关超时会直接断开而任务其实还在后台继续跑这就造成了前端“请求已失败”但后端“还在干活”的矛盾状态。正确的做法是完成接口只负责触发不负责执行。触发方式可以用消息队列也可以用线程池。考虑到分片上传与拆分是两个完全独立的阶段我倾向于在完成接口中把任务id投递到消息队列由独立的消费线程执行合并逻辑。这样即便合并过程很长也不影响前端拿到成功的响应。合并逻辑本身有以下步骤。先校验分区文件是否齐全再按序号顺序读取分片文件使用带缓冲的输出流写入目标文件。推荐用Files.newInputStream逐个读取分片用BufferedOutputStream写入合并路径。try (BufferedOutputStream bos new BufferedOutputStream(Files.newOutputStream(targetPath))) { for (int i 0; i chunkCount; i) { Path chunkPath Paths.get(uploadDir, taskId, i .part); byte[] buffer new byte[8192]; try (InputStream is Files.newInputStream(chunkPath)) { int len; while ((len is.read(buffer)) ! -1) { bos.write(buffer, 0, len); } } Files.deleteIfExists(chunkPath); } }合并一个特殊场景要处理合并中途失败。合并就要保证可重入最简单的方式是先合并到临时文件合并成功后再原子改名替换目标文件。改名在Linux的ext4文件系统上是原子操作这比直接往最终路径写要安全得多避免了“合并了一半目标文件残留半个文件”的尴尬局面。合并完成后删除临时目录下的分片文件。这一步一定不能省否则磁盘会被.part文件占满。如果合并失败要保留分片以便后续重试合并。4.4 合并后的落盘与元数据登记合并生成的物理文件需要转移到正式的存储目录可能是本地磁盘、OSS等对象存储或云存储。这一步的正确顺序是先把文件迁移到目标位置再向文件表写入记录最后更新任务状态为“完成”。顺序反了会出现文件表有记录但物理文件在迁移中的情况业务方此时来查文件就会踩空。如果使用对象存储建议直接把每个分片传到对象存储的临时路径合并时用对象存储的并发分片拷贝功能。这一步的具体实现各家不同但思想一致分片阶段是分散的临时对象合并阶段是组合成最终对象最后再落一次元数据。生成的文件ID建议用UUID或者雪花ID不要用自增数字。因为文件ID会出现在异步通知、对外API、日志里面自增ID容易被人遍历出来而且并发场景下自增ID的获取也可能成为瓶颈。5. 异步通知接收方验签、幂等与重试合并完成后系统需要告诉业务方“你的文件已经准备好了”。这一步就是异步通知。异步通知也是很有争议的设计很多开发图省事选择让客户端轮询状态但轮询的时效和压力平衡很不好把握。异步通知能把主动权牢牢握在服务端手里是正规做法。5.1 通知内容怎么定通知的本质是一个带签名的HTTP POST请求由服务端发往业务回调地址。通知内容要包含足够的信息让接收方不做二次查询也能定位文件。{ fileId: f_8f9c2a1b3d, taskId: t_20250321_001, fileName: demo.mp4, fileSize: 2147483648, status: success, timestamp: 1710000000000, nonce: a1b2c3d4e5f6 }其中nonce是一次性随机字符串配合timestamp用于防重放攻击。timestamp用于校验通知的时效性超过一定时间的通知应当丢弃。5.2 验签的核心思路既然通知是服务端主动往外发的请求任何能截获请求的人都可以伪装成服务端给业务方发一条“文件已就绪”的消息。如果业务方没有验签机制攻击者就能伪造文件完成事件诱导接收方去处理一个根本不存在或内容错误的文件。所以验签是异步通知的安全底线。签名算法通常用HMAC-SHA256密钥为双方约定的appSecret。服务端把关键参数按固定顺序拼接后计算签名放入请求头接收方用同样的算法和密钥重算签名并比对。String signContent fileId taskId timestamp nonce; String sign HmacSha256(signContent, appSecret);接收方逻辑// 1. 校验 timestamp与当前时间差超过 5 分钟则拒绝 // 2. 校验 nonce防止重放 // 3. 使用 appSecret 对拼接内容计算签名 // 4. 与请求头签名比对签名比对必须使用消息摘要比较工具防止时序攻击。开发者容易写错的地方是参与签名的字段范围没有定死服务端升级加了参数后签名拼接顺序变了接收方掉链子导致所有通知验签失败。解决方案是把参与签名的字段白名单定死新增字段不进签名计算。5.3 幂等处理与超时重试通知发送可能失败业务方处理通知也可能失败所以通知机制必须具备重试能力。服务端对同一个事件最多发送几次间隔通常采用指数退避比如1分钟、5分钟、30分钟、2小时总共4-5次。但接收方必须做到幂等处理同一个fileId的通知可能收到多次每次处理的结果应该一致。最简单的幂等方案是本地记录已处理的fileId通过唯一索引约束重复提交重复通知直接返回成功即可。Transactional public void handleCallback(String fileId) { if (processedRecordDao.exists(fileId)) { return; } // 业务处理更新订单状态、生成下载链接等 processedRecordDao.insert(new ProcessedRecord(fileId)); }这里如果把check和insert分开会有并发问题。最稳妥的方式是用数据库唯一索引兜底再捕获唯一键冲突异常冲突时说明已有其他请求处理过直接返回成功。6. 高发问题排查与避坑实录前面讲了设计思路和实现方法这部分我把实际踩过的坑集中列出来。很多问题在网上只有只言片语真正遇到时排查起来才麻烦。6.1 分片上传成功了但合并失败最常见的症状是前端显示上传100%后端任务状态却一直卡在“合并中”然后超时或失败。这类问题通常源于分片记录与磁盘文件对不上。比如分片记录已经插入但磁盘文件没写成功或者同一个分片被重复提交且后一次的请求覆盖了前一次的请求内容。排查方法是先检查数字对不上再去怀疑代码逻辑。对比分片记录表的count和临时目录下实际存在的part文件数量。如果记录多、文件少一定是写入时序问题重点排查分片数据落盘是否与数据库更新在同一事务中。更隐蔽的是分布式部署造成的本地文件缺失——所有分片打到A机器合并任务却被消费到B机器B机器上当然找不到A机器写的临时文件。这个问题在微服务架构下尤其常见解决办法是把分片临时目录放到共享文件系统或对象存储或者合并任务消费时绑定分片上传所在的实例。6.2 并发上传相同文件导致的分片冲突两个用户同时上传内容完全相同的文件会计算出相同的文件MD5。如果后端的任务表没有做好状态管理就会出现两个taskId对应同一个identifier同时进行分片写入和合并最终生成两份相同的物理文件。解决思路是在“秒传”逻辑里增加分布式锁以identifier为锁粒度第一个任务持有锁并完成合并后续任务直接复用文件记录。加锁的粒度必须精准否则初始化接口并发100个请求时会有99个锁互相等待体验也不好。可以结合数据库唯一索引做弱一致控制靠文件表唯一键兜底重复插入时捕获冲突然后查询已有记录返回。6.3 服务重启导致上传任务丢失如果上传任务只存在内存Map里服务一重启任务状态全丢分片文件变成孤儿磁盘垃圾用户还得重新上传。这个坑在demo项目里无所谓生产环境一定要避免。持久化是必须的但还要处理任务状态恢复。服务启动时可以扫描任务表中状态为“上传中”和“合并中”的任务对“合并中”的任务直接重新投递合并消息。对“上传中”的任务可以保留也可以把超过一定时间未更新的任务标记为失败让用户重新发起。同时启动一个定时清理任务定期清理“上传中”状态但最后一次更新超过24小时的临时分片避免磁盘被历史垃圾占满。6.4 秒传的坑只凭文件名做标识用文件名做文件标识是新手常见的做法看起来能省事实际上后患无穷。用户传两个同名不同内容的文件第二个会被直接识别为“已存在”返回秒传成功但业务方拿到的是第一个文件的内容。数据错误在文件上传场景里是最严重的。文件标识必须基于内容特征。如果担心计算完整文件MD5太慢可以采用“采样MD5”策略结合文件大小、头尾若干字节块、均匀间隔的若干分片计算混合标识。这个方案能在性能与准确性之间取得平衡但严谨程度不如完整MD5具体取舍得看业务。6.5 断点续传实际体验不佳的问题有一种情况很普遍用户中断上传后再次选择同一文件前端确实做到了继续传但发现后端查询到的已上传分片很少几乎等于重新传。原因是不同浏览器、不同时间切的文件分片大小不一样或者前端把分片大小改了序号完全错位。解决方式是前端启动续传前一定要向服务端上报文件MD5和chunkSize服务端不只返回分片序号还要返回chunkSize客户端做一致性比对后再决定是否复用已传分片。另一个优化是前端保存任务上下文时把文件改动的最后修改时间也存起来用户重选文件时先对比修改时间文件已变更就直接重置任务不做无谓的续传尝试。7. 数据一致性保障与性能优化复盘除了功能和流程外还有两个贯穿全方案的问题值得单独拿出来说说一致性和性能。7.1 分片状态与磁盘文件的最终一致整个系统里存在两套状态数据库分片记录和磁盘实际文件。任何时刻两者都可能不一致。要求强一致会拖垮性能而完全放任又会造成数据错乱。我的经验是采取“最终一致”策略以磁盘文件为准、以数据库记录为索引用定时对账和合并前校验两个手段兜底。合并前的强校验必须做分片记录数等于总片数且每个分片文件存在且大小正确。对账任务定期扫描任务表找出状态为“上传中”但分片数不再增长的任务自动标记失败避免用户放弃上传后任务永远悬挂。这套策略既保证了关键时刻的数据正确性又避免了每次操作都做分布式事务带来的性能损耗。7.2 性能瓶颈与优化关键点分片上传的性能瓶颈通常不在单个接口的响应速度而在整体吞吐。想提高吞吐顺序是优先优化分片写入磁盘的方式、再考虑增加网络带宽、最后才是堆机器。关键优化点有三个。第一分片写入用流式写入禁止一次性读入byte数组再写避免大分片时内存抖动。第二分片上传接口不能打数据库查一次写一次要利用批量插入每积累一批分片记录一次性写入但要注意服务重启时的数据损失窗口。第三合并阶段强烈建议使用零拷贝传输如果用的是Java NIO可以通过FileChannel.transferTo减少用户态和内核态的切换开销。我个人实测合并2GB文件时直接用法输出流大概要40多秒换成批量缓冲之后降到30秒左右再做零拷贝能到20秒出头。这个性能差距在文件数量大时非常可观。但也要注意合并性能再快也不能同步处理异步合并的设计不能动摇。7.3 从单机到集群的演进要点项目早期单机部署压力不大但如果接入的业务增多上传服务也要拆成无状态服务。无状态化的核心是把“分片文件存到本地磁盘”改成“存到分布式存储”。对象存储、分布式文件系统在这时都是可选项。如果暂不引入重型分布式存储可以用NFS或NAS挂载目录方式过渡所有服务节点共享同一个上传目录。但这方案有性能和单点风险仅适合中小规模。真正的集群方案要用对象存储前端分片直接传到对象存储后端只负责任务调度与合并。合并也利用对象存储的API避免数据在服务端和存储之间搬来搬去。这个演进路线我在项目里走过一遍踩过的坑包括NFS部署时缓存一致性问题、对象存储上传签名有效期问题等。提醒一点提前在代码里抽象存储层接口别把“本地路径”概念直接散落到业务代码里否则上集群时要改的接口太多。最后再分享一个实战技巧。在监控每个环节时除了看下载文件是否成功一定要加一层“文件内容比对”的回归测试——就是拿上传前的源文件与合并后的产出文件做MD5比对整个分片、续传、合并、通知流程走一遍自动化用例。这个用例成本不高但能拦截几乎所有核心链路的技术回归。我自己就是靠这个用例救回过一次上线事故希望大家不要等到文件在线上被用户打开才发现损坏。