Java大文件上传:分片上传与断点续传实战指南
做后端这些年大文件上传是绕不开的一道坎儿。早年间我做模拟项目X需要把一个1.8GB的数据库备份传到服务器当时用的还是最原始的form表单整包提交结果传到一半网络闪断用户那边进度条直接归零第二天又来一遍还是断整个人都快崩溃了。后来专门花时间把“java实现大文件断点续传和分片上传”这套逻辑完整落地从那以后但凡项目里出现文件上传需求我都是用这套方案打底没有再翻过车。这篇文章就把整个过程的原理、接口设计、代码实现和踩坑经验一次性写完适合正在做文件上传功能、经常被大文件折磨的后端开发者参考。我会把每个关键决策背后的原因都讲清楚不写那种“能跑但不知道为什么能跑”的代码。你会发现分片上传本身不复杂真正的复杂度全在边界情况里并发、校验、断点恢复、服务器配置。这些才是让一个demo变成生产级功能的分水岭。1. 为什么大文件上传必须分片先看清一把梭的问题1.1 直接把大文件post到后端会撞上哪些墙很多人第一反应是大文件不也就是一个请求吗我调大配置不就行了真这么干过的人都知道不是这么回事。一个几百MB的文件意味着整个文件要在一次HTTP请求里完整传输这里面有四个绕不开的硬伤。网络抖动是整个链路里最不可控的变量尤其是移动网络或者跨地域传输。一次抖动就可能让连接超时断开这个请求的生命周期里已经传了80%的数据全部作废客户端只能从头再来。这不是概率问题而是文件越大暴露在抖动里的时间越长失败概率越接近必然。另外服务端框架层面对单次请求的数据量有一套默认限制比如Spring Boot里spring.servlet.multipart.max-file-size默认只有1MBTomcat本身也有请求参数大小上限nginx层的client_max_body_size默认甚至只有1m——你还没走到业务代码文件就被网关或容器的前置检查拦掉了。就算你把这些全调大整包上传时服务端通常也是先把完整文件读进内存或临时磁盘再处理一个1GB文件并发来几个内存直接报警GC频繁到接口响应变成秒级。更隐蔽的是用户体验问题。整包上传没有任何中间反馈用户看到的是一个卡了很久的进度条不知道传了多少、还剩多少、服务器到底收没收到。真要传一个多小时中间任何一次刷新页面或点击返回整个任务就彻底断了。断点续传和分片上传解决的正是这一连串问题。1.2 分片上传和断点续传到底在解决什么分片上传的思路一句话就能说清把大文件按固定大小切成N块分别上传全部传完后在服务端按顺序合并成完整文件。切完之后单个分片的大小通常只有几MB到十几MB一次请求的数据量小传输失败的成本就低网络抖动的影响范围从“整个文件”缩小到“一个分片”。因为分片之间是独立的即便其中某一两片传失败了只需要重传失败的那几片不用动其他分片。断点续传本质上是在分片上传的基础上做“状态记录”。既然文件被拆成了多个独立分片服务端自然可以记录每一片是不是已经收到了。客户端再次发起上传时先问服务端哪些分片已经存在然后把缺失的分片补齐就行。整个过程不需要重新传整个文件这就是“断点续传”四个字的含义。所以这两个概念不是两套独立方案而是同一个方案的两个侧面分片是物理拆分断点续传是状态管理。举个例子你就明白了。假设一个文件切成100片你传了73片之后网络断了。没有断点续传下次要从第1片开始有断点续传客户端启动后向服务端查询发现第1到第73片都已经在服务器上于是从第74片继续传。省下的不只是27片流量更是从零开始那段时间内再次断网的风险。1.3 这套方案的边界什么时候该用云端分片上传这里得说句公道话如果公司已经在用对象存储服务比如OSS或者S3它们本身就提供了multipart upload能力前端直接对接SDK比自研后端省事得多。自研分片上传的合理场景是文件需要先经过你自己的业务服务做处理、鉴权、入库不能直接暴露到对象存储或者文件本身就在内网、私有化环境压根没有对象存储可用又或者你需要对上传过程做深度定制比如自定义校验规则、跟业务数据绑定。我遇到过的一个典型场景是数据迁移工具用户把本地数据库导出文件传到内网服务器整个环境没有公网对象存储而且文件动辄几个GB网络条件还很随机。这种场景自研分片上传就是最务实的方案。现实中很多人把自研和用云对立起来其实不必我很多项目是业务服务负责分片接收最终落库时再异步转存到对象存储两者配合着用既保证业务可控又拿到云端存储的红利。2. 接口与时序设计从一次完整上传的全过程说起2.1 初始化、传分片、合并、校验的完整流程分片上传的接口设计比绝大多数人想的复杂一点因为它不是“一个上传接口”就完事而是需要四个接口协同工作。我在项目里固定分成四个阶段初始化、上传分片、合并文件、校验结果。客户端拿到文件后先做本地分片调用初始化接口告诉服务端“我要传一个什么样的文件”服务端返回一个全局唯一的uploadId并记录文件大小、分片数、分片大小这些元数据。拿到uploadId之后客户端逐片调用上传分片接口每一片请求都带着uploadId和分片编号服务端收到分片后写入临时目录并更新该任务的上传进度。当所有分片都传完后客户端调用合并接口服务端校验分片是否齐全确认无误后按分片编号顺序把临时分片合并成完整文件最后返回可访问的文件地址。校验可以放在合并阶段一起做也可以单独提供一个查询接口。为什么不让客户端直接循环调用一个上传接口、传完自动合并因为客户端根本不知道服务端到底收到了多少片如果中间有分片因为网络问题没到客户端以为自己传完了服务端一合并发现缺片。所以必须有一个独立的合并触发动作服务端在合并前做一次完整性的校验这层保护是必须加上的。同理进度查询接口也不是锦上添花它是断点续传的核心依赖。2.2 分片大小怎么定不要拍脑袋分片大小是我被问得最多的问题之一。太小了比如1MB一片一个1GB文件就有一千多片每次上传都有HTTP请求的固定开销管理状态和查询进度的成本也会高起来一千多个分片的状态维护在服务端也不轻松。太大了比如50MB一片单个分片的传输失败概率又会上去断点续传的“恢复粒度”就变粗了而且大多数网关层面对单请求大小还有限制搞不好又触发413。我个人的经验值是普通业务场景用5MB到10MB网络条件差的场景降到2MB到5MB内网环境可以放到20MB甚至50MB。选10MB作为默认值有一个实际的计算逻辑假设带宽是10Mbps传一个10MB分片大概需要8-10秒这个时间窗口内的断网概率相对可控同时1GB文件只需要100个分片状态管理和进度查询都很轻量。更重要的是这个大小能同时兼容绝大多数网关的请求体限制不需要额外调配置文件。这里提醒一句分片大小要跟服务端的临时存储设计配套。因为分片合并时我通常是按照分片大小计算偏移量写入目标文件的如果最后一个分片不满合并逻辑要能正确处理否则文件尾部会错位甚至多出脏数据。后面讲合并代码时我会具体展示这个边界怎么处理。2.3 已上传分片的状态怎么存内存、Redis还是数据库断点续传的核心能力来自“知道哪些分片已经传过了”。这个状态存哪里直接决定方案的复杂度上限。最简做法是服务端内存里放一个ConcurrentHashMapkey是uploadIdvalue是已上传分片编号的集合。这种方案实现最简单适合单机部署、并发量不高的内部系统我开发调试阶段也一直用这种方式。但内存方案有两个致命问题服务重启状态全丢多实例部署时状态不共享。一旦服务器重启所有未完成的上传任务全部失效客户端只能重新初始化。因此生产环境我通常建议用Redis存储上传状态key设计成upload:状态信息和upload:chunks:uploadId两个前者存文件元数据后者存已上传分片编号的Set。Redis的SADD、SCARD、SISMEMBER这几个命令天然匹配分片登记、数量统计、存在判断这些操作写入和查询都是O(1)性能完全不是瓶颈。还有更重的做法是用数据库表记录分片状态好处是可以做关联查询和审计但除非业务上真的需要否则我不推荐因为每个分片的写入都是一次insert一千个分片就一千次数据库IO完全没有必要。我的原则是单机demo用内存Map正式环境用Redis数据库方案只在状态需要跟业务数据强关联时才考虑。2.4 顺手把秒传做出来分片状态存好之后秒传是一个非常自然的副产品。很多用户会重复上传同一个文件可能只是换了目录或者忘了自己传过。既然客户端在初始化时已经提交了文件MD5服务端在初始化接口里先查一遍这个MD5对应的最终文件是否已经存在存在就直接返回“文件已存在无需上传”客户端连分片都不用开始传。这一步省掉的流量和用户等待时间非常可观。要注意的是秒传查询不能只比对文件名文件名可以变文件内容变了MD5必然变所以MD5才是唯一可信的指纹。有些场景我还会额外比对文件大小把“MD5相同但大小不同”的极端冲突也排除掉双条件命中才算秒传成功。虽然MD5理论上存在碰撞概率但在这个场景里属于可接受的工程权衡如果真的遇到敏感行业或极高一致性要求可以升级用SHA-256。3. Java后端完整实现初始化接口、分片接口、合并接口3.1 基础定义上传参数与存储结构代码落地之前先把基础结构定好。我用Spring Boot做演示上传参数用一个对象承载前端提交的内容包括uploadId、文件名、文件MD5、总大小、分片大小、总分片数、当前分片编号这些字段。分片编号从1开始方便人读和排查存储目录则按uploadId隔离所有临时分片都放在这个目录下避免不同任务之间的分片文件互相干扰。public class UploadRequest { private String uploadId; // 上传任务ID初始化接口返回 private String fileName; // 原始文件名 private String fileMd5; // 整个文件的MD5用于秒传和校验 private Long totalSize; // 文件总大小 private Integer chunkSize; // 分片大小 private Integer totalChunks; // 总分片数 private Integer chunkNumber; // 当前分片编号从1开始 // getter/setter 省略 }文件存储路径我用两个常量区分一个是临时分片目录/data/fileupload/tmp/{uploadId}/一个是最终文件目录/data/fileupload/store/。上传中的分片只进临时目录合并成功后才落到存储目录。这样设计的好处很明显磁盘清理策略可以分开做临时目录定期清扫超过24小时没合并的任务直接删存储目录反而要小心保护不能误删。接下来是上传状态的数据结构。单机场景我直接用ConcurrentHashMap加ConcurrentSkipListSet后者线程安全的同时还保持分片编号有序查询缺失分片和后续列表展示都很方便。public class UploadStatus { private String uploadId; private String fileName; private String fileMd5; private Long totalSize; private Integer chunkSize; private Integer totalChunks; private SetInteger uploadedChunks new ConcurrentSkipListSet(); // getter/setter 省略 } Service public class UploadService { // 单机演示用内存存储生产环境替换为Redis实现 private final ConcurrentMapString, UploadStatus statusMap new ConcurrentHashMap(); private static final String TMP_DIR /data/fileupload/tmp/; private static final String STORE_DIR /data/fileupload/store/; }3.2 初始化接口与秒传检查初始化接口是整个上传任务的起点。客户端把文件的基本信息发过来服务端先做两件事第一件事查秒传第二件事分配uploadId并登记上传状态。秒传的查询条件我前面说了MD5一样且大小一样才算命中。PostMapping(/init) public Result init(RequestBody UploadRequest request) { // 1. 秒传检查 File targetFile new File(STORE_DIR request.getFileMd5() _ request.getFileName()); if (targetFile.exists() targetFile.length() request.getTotalSize()) { return Result.ok(秒传成功, targetFile.getAbsolutePath()); } // 2. 生成上传任务ID String uploadId UUID.randomUUID().toString().replace(-, ); // 3. 登记上传状态 UploadStatus status new UploadStatus(); status.setUploadId(uploadId); status.setFileName(request.getFileName()); status.setFileMd5(request.getFileMd5()); status.setTotalSize(request.getTotalSize()); status.setChunkSize(request.getChunkSize() null ? 10 * 1024 * 1024 : request.getChunkSize()); status.setTotalChunks(request.getTotalChunks()); statusMap.put(uploadId, status); // 4. 创建临时分片目录 File tmpDir new File(TMP_DIR uploadId); if (!tmpDir.exists()) { tmpDir.mkdirs(); } return Result.ok(初始化成功, uploadId); }这里有一个细节uploadId不应该由客户端生成而必须由服务端统一分配。原因有两层第一层是防止客户端伪造或篡改任务信息第二层是服务端生成的ID可以天然保证全局唯一。如果你让客户端自己生成一旦两个用户生成了相同的ID分片就会互相覆盖排查起来非常恶心。这个约束我在实际项目中吃过亏后来就写死在规范里了。3.3 分片上传接口与状态登记分片上传接口接收一个MultipartFile和一个UploadRequest对象。Spring MVC里文件用RequestParam(file)接收其他字段可以用一个对象整体接收。接口逻辑分四步查任务是否存在、判重、落盘、登记状态。判重这一步很多人会忽略但它在断点续传场景里是必须的因为客户端在恢复上传时可能重复提交已经传过的分片服务端需要直接返回“该分片已存在”避免重复写入。PostMapping(/chunk) public Result uploadChunk(RequestParam(file) MultipartFile file, UploadRequest request) { UploadStatus status statusMap.get(request.getUploadId()); if (status null) { return Result.error(上传任务不存在或已过期); } // 分片判重已上传过的分片直接跳过 if (status.getUploadedChunks().contains(request.getChunkNumber())) { return Result.ok(该分片已上传跳过); } // 分片大小粗略校验防止异常客户端提交超限数据 if (file.getSize() status.getChunkSize()) { return Result.error(分片大小超过设定值); } try { // 分片落盘到临时目录 String chunkDir TMP_DIR request.getUploadId(); File chunkFile new File(chunkDir, request.getChunkNumber() .part); file.transferTo(chunkFile); // 登记该分片为已上传 status.getUploadedChunks().add(request.getChunkNumber()); } catch (IOException e) { return Result.error(分片保存失败); } return Result.ok(分片上传成功); }落盘时文件名直接用分片编号加.part后缀简洁又可读。生产环境我还会在落盘后做一次分片文件的MD5校验防止网络传输过程中分片数据被篡改或损坏这个放在第二层校验里统一讲。3.4 合并接口与分片清理所有分片传完之后客户端调用合并接口。合并前必须先做完整性校验已上传分片数必须等于总分片数否则直接返回缺失分片列表。合并用RandomAccessFile定位写入每个分片按编号计算它在目标文件中的起始偏移量这样即使分片不是按顺序到达的合并结果也一定正确。PostMapping(/merge) public Result merge(UploadRequest request) { UploadStatus status statusMap.get(request.getUploadId()); if (status null) { return Result.error(上传任务不存在); } int expected status.getTotalChunks(); // 完整性校验返回缺失分片列表 if (status.getUploadedChunks().size() ! expected) { ListInteger missing new ArrayList(); for (int i 1; i expected; i) { if (!status.getUploadedChunks().contains(i)) { missing.add(i); } } return Result.error(分片不完整缺少分片 missing); } String chunkDir TMP_DIR request.getUploadId(); File targetFile new File(STORE_DIR status.getFileMd5() _ status.getFileName()); try (RandomAccessFile raf new RandomAccessFile(targetFile, rw)) { for (int i 1; i expected; i) { File partFile new File(chunkDir, i .part); byte[] data Files.readAllBytes(partFile.toPath()); long offset (long) (i - 1) * status.getChunkSize(); raf.seek(offset); raf.write(data); } raf.setLength(status.getTotalSize()); } catch (IOException e) { return Result.error(合并失败); } // 合并成功后清理临时分片和状态 deleteDir(new File(chunkDir)); statusMap.remove(request.getUploadId()); return Result.ok(合并成功, targetFile.getAbsolutePath()); }合并完成后调setLength(totalSize)这一步很多人想不到。如果最后一个分片恰好没传满前面写入过程会让文件尾部残留多余数据setLength可以把文件截断到真实大小这是文件合并正确性的最后一道保险。删除临时分片和内存状态这个动作一定要放在合并成功之后如果合并失败需要保留现场用于排查。3.5 进度查询与断点恢复进度查询接口是断点续传的关键配套。客户端在启动上传前先调用一次查询接口拿到已上传分片列表然后跳过这些分片。查询接口返回三个数据已上传分片编号列表、缺失分片编号列表、上传进度百分比。GetMapping(/check) public Result check(String uploadId) { UploadStatus status statusMap.get(uploadId); if (status null) { return Result.error(上传任务不存在请重新初始化); } SetInteger uploaded status.getUploadedChunks(); ListInteger missing new ArrayList(); for (int i 1; i status.getTotalChunks(); i) { if (!uploaded.contains(i)) { missing.add(i); } } int progress (int) (uploaded.size() * 100.0 / status.getTotalChunks()); MapString, Object data new HashMap(); data.put(uploadedChunks, uploaded); data.put(missingChunks, missing); data.put(progress, progress); return Result.ok(data); }客户端拿到missingChunks之后就从缺失列表的第一个分片开始继续上传。这就是断点续传的完整闭环恢复现场靠查询补齐缺失靠上传最终合并时再做一次完整性兜底。三层保障叠加断点续传才真正可靠。4. 实际落地必须处理的边界问题4.1 并发上传时状态一致性怎么保证分片上传天然是并发场景客户端通常不会一片一片地串行传而是同时传3到5片甚至更多来提速。服务端必须保证多线程同时登记分片状态时不乱、不丢。ConcurrentSkipListSet的add操作是线程安全的但仍然存在一个竞态如果服务端没有判重逻辑两个线程同时上传同一个分片文件会被覆盖写入状态却只登记一次之后合并时文件数据可能是损坏的。我用的策略是“先判重再写入”但判重和写入之间也不是原子的。要彻底解决这个问题可以做两层保护第一层依赖文件系统的天然覆盖写能力同一个分片文件重复写入没有关系只要最终文件内容一致就行因为接口层已经限制分片大小同一分片编号的多次传输数据一定是同一段内容这个前提是客户端遵守约定第二层用状态集合的update操作代替add保证只有这次写入成功后状态才可见。实际上分片上传的并发场景里客户端一般不会对同一个分片发起双写判重加上覆盖写容忍已经足够。还要注意内存状态和磁盘文件的同步。如果先更新内存状态、后写磁盘进程崩溃时会出线“状态有但文件没有”的情况反之“文件有但状态没有”则会导致客户端重复上传。我倾向于先写磁盘、再登记状态因为重复上传一个分片最多浪费一次IO而状态提前登记却会导致合并时找不到文件。这是一个很小的顺序问题却能避免一类很隐蔽的生产事故。4.2 分片校验别等到合并才发现文件坏了很多初版实现把校验放在合并之后合并完发现文件打不开再回滚这时候临时分片可能已经删了想排查都没有现场。更好的做法是分层校验上传阶段做分片大小校验和可选的分片MD5校验合并阶段做文件总大小校验最后再做一次完整文件的MD5比对。我之前做过一个医疗影像上传的项目对数据完整性要求极高当时除了分片MD5之外还在合并后对完整文件做了SHA-256校验换算成本并不高因为一次文件读取而已却能挡住绝大多数磁盘坏道或者错误写入造成的静默损坏。日常项目可以不需要这么重但分片MD5校验值得做分片只有几MB到十几MB算一个MD5只要几十毫秒完全不影响用户体验。private boolean checkChunkMd5(MultipartFile file, String expectedMd5) { try { String actual DigestUtils.md5DigestAsHex(file.getInputStream()); return actual.equalsIgnoreCase(expectedMd5); } catch (IOException e) { return false; } }4.3 断点恢复的完整链路实测断点恢复听起来简单实际操作中有一个容易被忽略的问题客户端恢复上传前服务端临时分片文件是否真的还在。如果服务端有定期清理任务把超过24小时没合并的临时目录删了但内存状态还在客户端查询时就会得到一堆“已上传”的分片真正上传时服务端却找不到文件出现状态和实际不符。这个问题我遇到过两次后来总结出一个经验状态查询时不仅要查内存状态还要同步校验临时文件是否存在凡是状态标记已上传但文件不存在的全部从已上传集合里移除当作未上传处理。这个“状态校正”逻辑虽然多了一次磁盘IO但换来的是断点恢复的可靠性。你也可以用另一种思路清理临时目录时把对应的内存状态一起删掉客户端查询时发现uploadId不存在就会自动重新初始化代价是用户之前传的分片白传了。两种方案各有取舍我更倾向于状态校正因为用户体验更好。private void correctStatus(UploadStatus status, String uploadId) { String chunkDir TMP_DIR uploadId; IteratorInteger iterator status.getUploadedChunks().iterator(); while (iterator.hasNext()) { Integer chunkNumber iterator.next(); File f new File(chunkDir, chunkNumber .part); if (!f.exists()) { iterator.remove(); } } }4.4 服务端配置与生产环境参数分片上传跑通代码只是第一步部署配置配不对接口写得再好也是白搭。三个地方必须同时调整Spring Boot的multipart配置、Tomcat的请求限制、nginx或网关的请求体大小。这三个配置是串联关系任何一个没放开分片请求都会在中途被拦下。spring: servlet: multipart: max-file-size: 20MB # 单个分片不超过20MB max-request-size: 30MB # 单次请求体总大小nginx层要注意别只改client_max_body_size还要确认代理超时时间proxy_read_timeout够长否则慢网络下分片传输时间超过120秒nginx先断开连接分片上传失败。这是我见过最多的问题代码完全没问题接口偶尔超时查到最后是nginx默认超时太短。生产环境建议把proxy_send_timeout和proxy_read_timeout都调到300秒以上。5. 常见问题排查与避坑速查5.1 高频故障对照表整理一份我实际排查过的故障对照表按出现频率排序。现象大概率原因解决方案分片上传返回413网关或容器请求体限制同时检查nginxclient_max_body_size、Spring Boot multipart配置断点续传后文件损坏合并顺序错误或最后分片没截断合并时按编号定位offset合并后调用setLength(totalSize)上传到一半重启服务全失效上传状态存在JVM内存改用Redis存储状态或结合临时目录持久化查询进度不稳定忽多忽少内存状态与磁盘文件不同步先写盘再登记状态查询时做文件存在性校正合并报“分片不完整”客户端没严格按响应重传缺失片接口返回missingChunks后客户端必须循环补齐大文件合并极慢用字节流逐片拼接或频繁随机写改用FileChannel的transferTo或加大分片尺寸5.2 三个我印象最深的翻车现场第一个翻车现场是测试环境一切正常生产环境一片都传不上去所有分片请求都在nginx直接被拦了。排查半天发现测试环境的nginx配置改过生产环境的配置一直没人动client_max_body_size还是默认1m。后来我把服务器配置检查和分片大小选型一起纳入上线检查单再也没犯过同样的错。第二个翻车现场跟文件名编码有关。用户上传了一个带中文和特殊符号的文件名合并时生成最终文件路径直接报了NoSuchFileException。查到最后是URL参数在传递过程中被转义了一遍服务端接收到的文件名已经跟客户端不一致。解决办法是前端对fileName做encodeURIComponent后端显式URLDecoder.decode而且合并时的文件名一律以初始化请求里登记的元数据为准不要信任后续请求带过来的文件名。第三个翻车现场印象最深是磁盘空间不足导致合并写到一半写不进去然后临时目录和最终文件各留了一堆残缺数据。从那以后我在合并前必须检查目标目录的可用空间不够就提前返回错误。磁盘剩余空间的预估公式很简单临时目录需要约等于文件总大小如果客户端同时传输多个任务还要乘以并发数。5.3 磁盘与性能优化建议性能瓶颈通常不在接口本身而在文件IO的方式上。合并阶段我前面用了RandomAccessFile逐片读取写入分片小的时候没问题但如果分片是50MB甚至更大逐片读入内存会吃掉大量堆内存。更优的做法是用FileChannel的transferTo做零拷贝传输让内核直接搬运数据减少用户态和内核态之间的拷贝次数。try (FileChannel outChannel new FileOutputStream(targetFile).getChannel()) { long position 0; for (int i 1; i expected; i) { File partFile new File(chunkDir, i .part); try (FileChannel inChannel new FileInputStream(partFile).getChannel()) { inChannel.transferTo(0, partFile.length(), outChannel); } position partFile.length(); } }这个版本省内存、速度快也是生产环境常见的写法。另一个优化方向是临时分片目录的清理策略我用Spring的Scheduled定时任务每天凌晨扫描一遍临时目录删除超过24小时没有更新的任务目录避免磁盘被半途而废的上传任务占满。清理的时候记得同步清理上传状态或者把状态存储也统一放到Redis用TTL自动过期Redis的TTL机制天然适合这种场景省得自己写定时任务。还有一点容易被忽视临时分片目录和最终文件目录最好放在不同的磁盘分区。上传过程中的大量随机写不会影响正式数据区万一临时盘坏了也只影响未完成的任务不会伤及已合并的文件。磁盘布局这种事事前设计成本几乎为零事后出问题的代价却很大。最后再分享一个我个人的实操体会。分片上传这套方案代码层面一天就能写出来真正花时间的永远是边界情况和生产环境配置。如果你正在做这个功能我先劝你把分片大小、状态存储、磁盘清理这三件事在设计阶段就定下来而不是写代码的时候再想。等线上真的出现“传一半全没”的用户投诉时再回头补这些设计就是事故处理而不是功能开发了。我每次接手上传相关的模块第一件事永远是检查清理策略和超时配置这两处安全了整个方案才能说站得住脚。