开学前两周教务老师在群里发了一条长消息“几百兆的课件传到平台每到一半就断失败之后只能从头再来。学生这边等得急老师这边传得更崩溃。”这是我在帮某高校做教学资源平台时遇到的一个典型场景。校园网不是单一局域网它由各楼宇无线AP、宿舍有线、多个运营商出口拼起来链路时通时断。文件一旦超过200MB普通单次上传接口基本就是碰运气。最后我确定的技术方案是用Java实现一套面向课程大附件的跨平台断点续传服务把Web、Android、iOS、Windows桌面端全部接入。这篇文章不聊泛泛的概念而是把断点续传的核心原理、服务端接口设计、状态存储、并发控制以及部署到校园网环境后遇到的真问题完整拆开来讲代码和操作步骤都直接可参考。1. 为什么“单次上传”在校园网里走不通1.1 用户看到的表象是进度条归零先还原一下最初的形态。最早平台用的是最普通的HTTP上传前端一个input typefile后端Spring MVC收一个MultipartFile整个文件一次性读入请求体。性能瓶颈和故障隐患非常明显。用户体验是上传到70%或80%进度条突然卡住过一会儿前端报错重试之后进度条又从0%开始。很多人以为这是前端进度条没有缓存导致的其实根本原因是这次上传对应的HTTP请求已经被中断了服务端根本没有收到完整文件。当时我检查了后端的临时目录确实能看到一些上传到一半留下的半截文件但这类文件既不能被课程列表引用也无法从文件名判断属于哪门课、哪个学生只能作为垃圾数据定期清理。这种全量上传方式还有一个隐藏问题校园网环境下的连接并不稳定任何一次TCP断链都可能让整个请求作废而重新上传的代价是整个文件的全部时间。网络抖动是概率事件文件体积越大单次请求持续时间越长被抖动中断的概率就越高。这本质上是一个概率叠加问题光靠前端“重试”按钮解决不了。1.2 校园网链路的几个现实特征如果不把校园网当作普通公网来对待后端的很多参数设计就会跑偏。我自己归纳了校园网的四个链路特征第一上行带宽远小于下行带宽。宿舍区普遍是下行几百兆、上行几十兆大文件上传很容易占满上行带宽一旦多个学生同时上传整栋楼的网络体验都会受影响。第二网络拓扑复杂。无线漫游、楼宇交换、运营商出口层层转发任何一个环节出现拥塞或会话超时长连接就会静默断开。很多校园网出口设备还会对空闲连接做强制老化一个上传请求如果长时间没有数据流动很容易被中间设备掐断。第三网络切换频繁。学生从宿舍走到教室移动网络从Wi-Fi切到蜂窝网络TCP连接必然中断。PC端没有切换问题但无线信号弱时丢包率会飙升。第四高并发集中在固定时间窗口。不是全天均匀负载而是集中在开学前两周、选课结束、期末提交作业这种节点上。平时每秒几个请求高峰期可能每秒几百个请求存储和带宽压力完全不是一个量级。这些特征决定了一件事上传链路必须设计成“可中断、可恢复”的形态而不是依赖一条连接从头发到尾。这就是断点续传技术介入的原因。2. 断点续传的技术本质切片、记账与归并2.1 先把大文件切成“小份”断点续传最核心的思路就是切片。把一个1GB的文件按固定大小切割比如每片4MB一共得到256个分片。每个分片作为一次独立的HTTP请求上传到服务端服务端收到多少分片就保存多少分片。某一分片失败了恢复时只需要补传失败的那一片其他分片不用再动。切片大小怎么定我试过1MB、2MB、4MB、8MB、16MB几种规格最终建议使用4MB作为默认分片大小。太小的分片会导致请求数量过多和数据库、日志的交互次数暴增太大的分片又会让单个请求时长过长失去“小粒度恢复”的意义。4MB在Web端、Android、iOS上都能稳定处理不会带来明显的内存压力比较均衡。当然这个值应该做成可配置的不同网络环境可以用不同大小。2.2 服务端如何“记账”和“找续传点”切片上传不是把分片随便扔到服务器就完事服务端必须知道“这个大文件已经收了多少片、还缺哪几片”。我把它叫作“记账”。具体操作是客户端在真正传第一个分片之前先调用一个初始化接口告知文件名、文件大小、分片大小、总分片数以及文件的整体校验值。服务端根据这些信息创建一个上传任务分配唯一的任务IDuploadId并建立一张分片状态表。状态表里记录每个分片索引是否已上传。客户端断线后再次发起续传时先请求这个任务的状态服务端把已上传的分片索引列表返回客户端对比后只传缺失的索引。这样一来无论中途断多少次最终都能在最后一次把所有分片补齐。2.3 最后一步分片合并与完整性校验所有分片都上传完成后客户端调用合并接口。服务端验证分片数量足够之后按照分片索引顺序把每个分片文件流式写入一个最终文件这就是完整的课程资料。合并之后还有一个关键动作整体校验。分片阶段我们可以给每个分片单独计算校验值但分片校验通过不代表整文件一定正确因为可能存在分片覆盖写错乱、合并顺序错乱这类情况。所以合并完成后需要计算最终文件的整体校验值和初始化时客户端提交的校验值做对比。不相同就判定合并失败把文件删除让客户端重新上传异常分片。这里的整个流程可以用一个三段式来理解切片降低故障粒度记账实现可恢复合并加校验保证最终完整性。断点续传的技术本质就是这三件事后面所有服务端代码和客户端适配都围绕它们展开。3. Java服务端核心实现接口、状态与并发控制3.1 上传任务的完整生命周期我在设计服务端时把它分成四个阶段创建任务客户端提交文件元数据服务端生成上传任务的唯一标识。上传分片客户端按照分片索引逐个提交二进制数据。查询状态客户端随时查询已上传分片列表。合并完成服务端把所有分片合并成一个正式文件。对应这套流程Spring Boot里我定义了四个接口接口方法主要参数返回内容/api/upload/taskPOST文件名、文件大小、分片大小、总分片数、文件哈希uploadId、分片大小/api/upload/partPOSTuploadId、分片索引、分片文件、分片哈希分片状态/api/upload/task/{uploadId}GETuploadIId已上传分片索引列表/api/upload/mergePOSTuploadId最终文件路径、状态我特别强调一下创建任务这个前置动作很多第一次做断点续传的人会把它省略掉直接用固定规则生成上传ID结果就是服务端没有原始终数据无法校验分片大小和分片总数也无法判断一个上传请求到底是“新上传”还是“续传”。在分片客户端异常并发的情况下这种接口设计很容易产生脏数据。所以前置任务创建是必须的它是后面所有状态记录的锚点。3.2 状态存储选型MySQL配合Redis分片状态有必要持久化到数据库。最初我很天真地想把所有状态放在Redis里因为接口访问很快代码也简单。但在一次服务重启后Redis里的任务状态全部丢失所有上传到一半的文件直接变成孤儿数据这个教训让我重新调整了方案。最终我采用的是MySQL存储任务信息 Redis做状态缓存的双层结构。MySQL里建了一张上传任务表字段包括uploadId、文件名、文件大小、分片大小、总分片数、已上传分片标记、整体哈希、状态、创建时间等。每上传一个分片就把对应分片索引更新到数据库。Redis只用来缓存“哪些分片已上传”的查询结果避免频繁查库。如果Redis丢了数据库还在重启后可以重新加载任务客户端依然能续传。分片标记这里有个实现细节如果为每个分片单独存一行记录一张表会有几十万行后续查询和清理都很麻烦。我选择用一个字符串字段保存位图第N个字符表示第N个分片是否已上传用“0”和“1”表示。比如1024个分片的任务只需要一条记录加一个1024位的字符串查询和更新都非常快。3.3 分片上传接口的处理要点分片上传接口的核心逻辑是查任务、校验参数、保存文件、更新状态。PostMapping(/upload/part) public UploadPartResult uploadPart(RequestParam String uploadId, RequestParam int partIndex, RequestParam String partHash, RequestPart MultipartFile partFile) { // 1. 根据uploadId查任务 UploadTask task uploadTaskMapper.selectByUploadId(uploadId); if (task null) { throw new BusinessException(上传任务不存在); } // 2. 校验分片索引是否合法 if (partIndex 0 || partIndex task.getPartCount()) { throw new BusinessException(分片索引越界); } // 3. 校验分片大小不能超过设定大小太多 if (partFile.getSize() task.getPartSize() 1024) { throw new BusinessException(分片大小超出限制); } // 4. 保存分片到临时目录 Path partPath Paths.get(STAGING_DIR, uploadId, partIndex .part.tmp); Files.copy(partFile.getInputStream(), partPath, StandardCopyOption.REPLACE_EXISTING); // 5. 计算服务端收到的分片哈希与客户端声明值核对 String serverHash DigestUtils.sha256Hex(Files.newInputStream(partPath)); if (!serverHash.equalsIgnoreCase(partHash)) { Files.deleteIfExists(partPath); throw new BusinessException(分片数据校验不一致); } // 6. 重命名为.part正式分片文件 Files.move(partPath, partPath.resolveSibling(partIndex .part), StandardCopyOption.REPLACE_EXISTING); // 7. 更新MySQL的分片位图和Redis缓存 uploadTaskMapper.markPartUploaded(uploadId, partIndex); redisOps.hset(upload: uploadId, parts, getUpdatedBitMap(uploadId)); return UploadPartResult.success(partIndex); }有一点很重要分片请求必须是幂等的。客户端在重试机制下会把同一个分片索引提交多次。如果服务端每次都覆盖写入高并发下立刻会出现文件互相覆盖的问题。我采用的策略是先检查分片是否已上传如果已上传就再比较一次哈希一致则直接返回成功不需要重写文件。3.4 并发上传下的锁与覆盖保护客户端在上传分片时不是串行的一个一个来为了速度Web端和App端都会同时并发上传多个分片比如同时传3到5个分片。这样就有两个风险一个是同一个分片被重复请求并发写入另一个是不同分片之间写入正常但读位图时会读到中间状态。针对同一个分片并发写入的问题我采用了两层保护。单机内用JVM锁维护一个ConcurrentHashMapString, ReentrantLock锁的key是uploadId加分片索引。写入前先获取锁写完后释放。这能解决同一台服务器上的竞争问题。跨节点场景则可以通过Redis的SETNX加锁再处理因为如果服务端本身部署了多台机器JVM锁只能锁住某台机器自己。更重要的是把“先写临时文件、再原子重命名”这个习惯养成。分片文件先写成.part.tmp全部数据写完并校验通过后再用Files.move原子替换为.part。这样即使并发请求同时到达读到的也只会是完整文件不会出现一个半截文件被另一个请求覆盖的错乱。3.5 合并接口与失败回滚合并操作不能简单地在接口里用循环读文件再逐段拷贝这个操作耗时较长应该做成异步任务。客户端调用合并接口后服务端返回“合并中”的状态另起一个线程池任务去做public void mergeParts(String uploadId) { UploadTask task uploadTaskMapper.selectByUploadId(uploadId); Path dir Paths.get(STAGING_DIR, uploadId); // 校验分片数量是否等于总分片数 ListPath partFiles Files.list(dir) .filter(p - p.toString().endsWith(.part)) .sorted(Comparator.comparingInt(p - extractPartIndex(p))) .collect(Collectors.toList()); if (partFiles.size() ! task.getPartCount()) { throw new BusinessException(分片数量不足缺少 (task.getPartCount() - partFiles.size()) 个分片); } Path target Paths.get(FILE_ROOT, generateFileUUID()); // 按照索引顺序流式合并 try (OutputStream out Files.newOutputStream(target)) { for (Path partFile : partFiles) { Files.copy(partFile, out); } } // 计算最终文件的哈希和任务创建时的整体哈希进行比对 String finalHash DigestUtils.sha256Hex(Files.newInputStream(target)); if (!finalHash.equalsIgnoreCase(task.getFileHash())) { Files.deleteIfExists(target); throw new BusinessException(合并文件完整性校验失败); } // 更新任务状态为已完成把最终文件入库 uploadTaskMapper.markCompleted(uploadId, target.getFileName().toString()); // 清理临时目录和Redis缓存 deleteDirectory(dir); }这里踩过的一个坑是合并完成后不能只判断文件大小相等。两个内容不同的文件只要字节数相同大小也会相等。所以必须用哈希校验。合并前重新校验每个分片的哈希固然能减少故障但合并阶段再算一次整体哈希才是最后一道防线。4. 跨平台客户端的接入策略4.1 先定协议再分别适配跨平台是这套方案里比较麻烦的部分。Web、Android、iOS、Windows桌面端各自有一套文件读取方式但它们和服务端的交互协议完全可以是同一套。我先把HTTP接口固定下来然后每个端只做自己的切片、状态记录和续传逻辑。4.2 Web端File对象与Blob切片浏览器端的核心是利用File对象自带的slice()方法它不需要把整个文件读入内存而是直接按字节位置切出一部分。const file input.files[0]; const CHUNK_SIZE 4 * 1024 * 1024; const partCount Math.ceil(file.size / CHUNK_SIZE); async function uploadPart(index) { const blob file.slice(index * CHUNK_SIZE, (index 1) * CHUNK_SIZE); const form new FormData(); form.append(uploadId, uploadId); form.append(partIndex, index); form.append(partHash, await computeHash(blob)); form.append(partFile, blob, file.name .part index); return fetch(/api/upload/part, { method: POST, body: form }); }浏览器的一个明显痛点在于页面一旦刷新上传进度信息就丢了。我建议把任务ID、已传分片列表写进localStorage下次打开页面后先查询服务端状态再做续传。这里不需要完全依赖服务端因为查询完状态之后很快就能确定该传哪几片。4.3 Android与iOS的差异处理Android端使用OkHttp库处理分片上传很方便把每片包装成MultipartBody.Part发送即可。需要注意的是Android端在上传过程中随时可能因为电话、通知、用户切后台导致进程被杀所以分片必须采用持久化队列不能放在内存变量里。每传完一个分片就把状态写入本地数据库或文件下次启动根据队列续传。iOS端的体验稍微好一些系统自带的NSURLSession提供了background模式APP进入后台后系统会继续执行上传任务。但要注意并不是所有接口都支持后台上传NSURLSessionUploadTask的body必须来自本地文件不能来自NSData否则无法在后台恢复。4.4 Windows桌面端本地文件与断点恢复很多老师并不喜欢用网页上传桌面端工具对这种场景反而更友好。Windows桌面端可以用原生技术实现也可以基于Electron把Web的逻辑直接复用。桌面端有个天然优势就是可以把上传任务进度持久化到本地用户关闭程序、重启机器后再次打开上传进度依然在。我做的桌面端实现会在启动时检查本地记录的上传任务列表自动检测哪些任务没有合并然后调用查询接口继续上传。整个过程不需要用户手动操作体验上基本等同于“把文件拖进去就完事了”。4.5 跨平台需要注意的编码与授权问题跨平台还有个细节所有接口都需要携带身份认证信息不然任何一个有上传凭证的人都可以冒充他人上传文件。我用的是Bearer Token放在请求头里。另外文件名在不同系统上有不同的敏感字符。Windows下文件名不能包含反斜杠、冒号、星号等macOS和Linux的限制又不完全一样。所以服务端保存文件时的一个通用做法是物理文件名统一用UUID生成原文件名只存数据库。这样既规避了跨平台文件名冲突也防止了下发文件时被URL编码问题折腾。5. 校园网高并发与带宽波动的适配优化5.1 高峰期上传并发比想象中可怕开学第一周一个年级几百号人同时提交课程视频瞬间并发能顶到多少我实际看到的情况是单个上传服务在高峰期每秒要接收大量分片请求很多分片请求体积还不小。如果后端线程池和IO队列不做限制服务整体响应时间会雪崩包括那些只有几十KB的普通API请求。因此我建议在网关层或服务层做信号量限流不是简单限制QPS而是限制“同时在处理的分片请求数量”。比如说同时允许200个分片请求在处理中超出部分直接排队。处理完一个再放一个进来避免线程池被打爆。5.2 客户端并发数自适应固定并发数并不理想。校园网里不同时段、不同区域的网络质量差距很大白天宿舍区可能很差晚上机房可能很流畅。客户端如果用固定的4并发网络差时会给链路雪上加霜。我做的自适应策略是客户端记录最近10个分片的平均耗时如果平均耗时较长说明网络拥塞自动降低并发数到2或1如果平均耗时短自动提升并发数到5。这不算公式化设计就是一个简单的滑动窗口判断在真实场景里效果很明显。伪代码逻辑如下最近10个分片平均耗时 sum(耗时) / 10 if 平均耗时 8000ms: 并发数 max(1, 当前并发数 - 1) else if 平均耗时 2000ms: 并发数 min(最大并发数, 当前并发数 1)5.3 超时、重试与指数退避分片请求的超时时间不能太长也不能太短。太短会导致正常慢速链路被误判失败太长则无法及时腾出连接资源。我基给定的配置是连接超时10秒、读超时30秒。写超时在分片场景下本质不会发生因为分片体积在几MB之内。重试要配合指数退避第1次失败隔1秒重试第2次隔2秒第3次隔4秒最多重试5次。重试本身不可怕可怕的是所有客户端在同一时刻集体重试制造出重试风暴。因此我把客户端重试间隔都加了随机抖动比如1秒到1.5秒之间随机取一个数降低同时冲击的概率。5.4 临时目录的容量规划服务端存放分片文件的临时目录是很容易被忽略的容量瓶颈。试想一下如果100个学生同时上传5GB的视频每个1GB分片临时文件就会占掉100GB以上的空间。而且如果合并失败、任务过期、用户放弃上传临时文件并不会自动消失。我的处理方案是给每个上传任务设置有效期默认24小时过期后定时任务扫描清理整个临时目录。同时限制了每个用户同时在途的上传任务数量比如最多5个防止有人在服务端堆积大量垃圾数据。正式课程资料才进入教学资源的正式存储区临时目录不承担长期存储职责。6. 我在实际部署中踩过的坑与排查记录6.1 分片覆盖错乱合并成功但文件打不开第一版上线后进行内测有个老师上传了一个PPT压缩包服务端返回“合并成功”文件大小也对但双击打不开提示压缩包已损坏。当时我第一反应是合并顺序排序出了问题实际上排序代码并没有问题。排查后发现是分片覆盖导致的。场景是这样的某个分片第一次上传时网络中断客户端自动重试。此时第一部分数据没写完第二部分把文件覆盖掉了然后旧数据又回来被写入……最终保存下来的这个分片内容居然是前一部分旧数据和后一部分新数据的拼接。虽然分片文件还在但内容已经不对了。修复方案就是我在3.3和3.4里写的先写.tmp临时文件、校验哈希、再原子重命名并且重复提交时先确认已存在的分片文件哈希和内容一致不一致就删除重写。合并前再重新计算每个分片的哈希双重校验。6.2 Windows和Linux的文件名编码不一致有段时间上传带中文名的文件一直失败服务端日志显示文件名解析异常。原因是不同浏览器对中文文件名的处理方式不同有的URL编码用UTF-8有的用GBK后端解析时如果不统一就会出现乱码或直接报错。我最后采用了分层方案前端不做任何文件名编码把文件原始名称放在表单字段里服务端统一用UTF-8解码并把物理文件名替换为UUID字符串。数据库里存原文件名和UUID的对应关系下载时用标准HTTP头处理中文文件名。这从根上规避了跨平台编码差异。6.3 服务重启后任务丢失这个坑前面提过也是我早期把上传状态完全放在Redis里导致的。当时服务一重启所有进行中的上传任务状态清零客户端再来查询状态时发现任务不存在只能提示“重新上传”。几百个学生的作业资料全部前功尽弃这个问题排在了我当时的修复列表第一位。彻底解决方法是把任务信息持久化到MySQLRedis只做热缓存。同时我在服务启动时加了一个恢复逻辑查询MySQL里所有“未完成且未过期”的任务把其上分片索引重新加载进Redis这样服务重启对用户基本是无感的。6.4 移动端切换网络时的连接悬挂iOS和Android端在Wi-Fi切换到蜂窝网络时活动中的HTTP请求并不会立刻报错而是会长时间悬挂客户端等到超时才认为失败。用户体验就是进度条卡住很久然后才提示重新连接。移动端的处理策略是监听网络状态变化一旦检测到Wi-Fi切换或者网络类型变化立即取消当前所有在途请求重新查询上传状态然后从缺失分片继续上传。而不是等超时。这个策略显著提升了移动端在校园网漫游场景下的恢复速度。6.5 “备用接口多路径”的优化思路最后提一个在校园网场景下很有用的补充方案如果文件较大且跨运营商访问同学容易出现特定出口链路不稳定。可以给服务端配置多个上传入口域名客户端在初始化阶段先做一次测速选择当前最快的入口上传。如果上传过程中某入口连续失败自动切换到备用入口分片切片粒度小切换代价也小。这套方案不改变Java服务端本身的逻辑只是在网关层配置多个入口前端在初始化时增加一次测速请求。对于跨校区的场景这往往比单纯调整TCP参数更有效。我自己的体会是断点续传看似只是一个“上传功能”但真正上线后面临的挑战几乎都来自“客户端行为不可控”和“网络特征不可控”。把分片状态记账、幂等处理、临时文件原子写入、校验闭环这四件事做好系统就能在校园网这种恶劣链路下长时间稳定运行。如果之后有人把方案的临时空间清理和成功后的异步回调再完善一步基本就是一个可以直接复用的课程附件传输能力了。