1. 混乱的项目现场:一次大附件上传引发的“血案”
大概每个做Java后端的人,都会被“文件上传”这件事恶心过几回。小文件还好,默认配置一开就能跑,但一旦文件体积从几MB跳到几百MB甚至几个GB,整个系统的脾气就全变了。
我印象特别深的一次,是某项目组做内部资料库系统,附件动辄三四百MB的CAD图纸和设计文档。上线第一周就炸了:运维反馈说服务器频繁内存告警,后来直接OOM,Tomcat进程被系统杀掉。当时负责这块的同事一脸懵,他配置的SpringMVC上传逻辑明明“看着没问题”——CommonsMultipartResolver是配了的,maxUploadSize也设了200MB,前端表单就是普普通通的multipart/form-data,点击提交,等就完事了。
结果呢?等来的不是成功提示,而是浏览器转圈转到超时,紧接着Nginx报413 Request Entity Too Large,后端日志里一串IOException: Connection reset by peer。更诡异的是偶尔能传上去几个小文件,但大文件每次必挂。
这不是个例。SpringMVC大附件上传这件事,很多团队是在生产环境被教育过之后才愿意认真思考方案的。今天这篇文章,我就把这个问题从头到尾拆开讲:为什么传统上传方式在大文件面前全线崩溃、分片上传到底该怎么设计、后端接口怎么落、断点续传和秒传怎么处理,以及部署阶段那些容易把人坑到怀疑人生的细节。
2. “传统三件套”为什么在大文件场景下全线溃败
2.1 CommonsMultipartResolver的“全量加载”逻辑
很多老项目用的是SpringMVC经典配置:CommonsMultipartResolver+MultipartFile+ 一个@RequestParam("file")。这套组合处理5MB以内的文件确实香——但是大文件场景下,它有一个致命的默认行为:Servlet容器解析multipart请求时,会把请求体整个读入内存。
具体点说,在Spring的配置里有一个隐藏开关:
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver"> <!-- 上传文件总大小上限 --> <property name="maxUploadSize" value="209715200"/> <!-- 单个文件大小上限 --> <property name="maxUploadSizePerFile" value="209715200"/> <!-- 内存阈值,默认1KB --> <property name="maxInMemorySize" value="1024"/> </bean>maxInMemorySize这个参数是很多人的盲区。它代表的是“文件在内存中缓冲的阈值”,默认只有1024字节。也就是说,超过1KB的文件会先落盘到临时目录,理论上大文件走的是磁盘临时文件路径,不至于直接整块塞内存。
但问题在于:临时文件仍然是一整个文件。SpringMVC的处理流程是先把整个multipart请求体完整解析完,把每个文件暂存到FileUpload的临时目录里,所有字段也全部解析到位后,才会进入你的Controller方法。在这个解析完成之前,整个请求的数据都以文件形式躺在磁盘上,Tomcat的worker线程则一直阻塞在等待解析上。假设用户上传一个500MB的文件,磁盘IO、CPU解析、内存拷贝几乎同时被拉满,如果放着不管,任何一个环节瓶颈都会导致请求超时或连接被重置。
2.2 被忽略的Tomcat与Nginx层限制
还有一层问题是请求体大小限制,很多人只调了Spring的配置,却不知道Tomcat和Nginx还有自己的闸门:
- Tomcat从8.5版本开始,
maxSwallowSize默认2MB,它决定的是连接器在请求体解析出错后“吞掉”剩余数据的最大字节数,超出的部分会直接导致连接关闭,前端表现为Connection reset by peer。 - Nginx的
client_max_body_size默认是1MB,超过就返回413。这几乎是所有人在“为什么我后端调大了还是传不上去”这个问题里第一个遇到的坑。
| 组件 | 默认限制 | 大文件常见异常表现 |
|---|---|---|
Nginxclient_max_body_size | 1MB | 413 Request Entity Too Large |
TomcatmaxSwallowSize | 2MB | IOException: Connection reset by peer |
SpringmaxUploadSize | 无限制 | 401/500,或OOM |
| 浏览器超时 | 无统一默认值 | 上传进度长时间不动后超时 |
即便你把所有配置都调大,传统方案还有一个不可回避的硬伤:没有进度反馈机制。浏览器就是干等,用户根本不知道传了一半还是卡了。一旦网络抖动导致中断,又要从头再传。几百MB的文件让用户一遍遍重传,体验可想而知。
2.3 避坑小结:传统方案“三宗罪”
归纳下来,传统方式在大附件场景的失败有三个本质原因:
- 整体性依赖——整个文件必须完整传到服务器才进入业务逻辑,中间任何环节断了都得重来,没有“部分成功”的概念。
- 资源占用峰值高——解析期间内存、磁盘IO、Tomcat线程同时被一个请求长期占用,并发一上来直接拖垮节点。
- 链路限制层次多——Nginx、Tomcat、Spring三层配置各自为政,一个没调就是一片红,排查起来令人发狂。
所以,方案切换不是“升级配置”的问题,而是架构思路的转变:把“一次大传输”拆成“多次小传输”,把“整体成功”变成“分片可重试”。
3. 分片上传的核心设计:从文件到分片的映射逻辑
3.1 为什么分片是唯一务实的选择
市面上的成熟方案,无论是某云OSS的断点续传,还是各种网盘的上传,本质都是分片(Multipart Upload)。核心思路是:
- 前端把大文件按固定大小切成若干分片,逐片上传。
- 每一片都是一个独立的HTTP请求,失败只重传失败的那一片。
- 所有分片传完后,后端按顺序合并成完整文件。
这个设计把一个大风险拆成了很多小风险,每个小风险都能单独恢复。而且单片体积小,单次请求耗时短,后端内存占用被压得很低,Tomcat线程也不会被长时间占用。
3.2 分片大小怎么定?一个掰开揉碎的计算
分片大小不是随便拍的。我一般建议在5MB到20MB之间,具体取决于你的网络带宽和并发策略。
给你一个实际算过的例子:假设目标文件是1GB,分片设为10MB,那就是102个分片。如果客户端上行带宽是10Mbps(约1.25MB/s实际速率),每片理论耗时8秒左右,102片串行跑完大约是13.6分钟。如果前端控制并发数为3,总耗时能压缩到5分钟左右。
但如果分片设成1MB,1024个分片会带来两个问题:一是HTTP请求数量太多,每次请求都有网络RTT和鉴权开销,反而拖慢整体速度;二是后端合并时打开、关闭文件句柄的次数成倍增加。分片设成50MB甚至100MB也可以,但单片传输时间变长,断点重试的粒度就变粗了,用户体验和容错性都会下降。
我个人的经验值是:10MB是通用性最好的分片大小。日活不大的内部系统用5MB也稳妥,带宽充足且文件普遍超大的场景可以提高到20MB。
3.3 前端必须处理的三个细节
分片上传虽然是后端主导,但前端的准备工作直接决定成败。
第一,读取文件分片。浏览器端用File.slice()方法即可,不需要读取整个文件进内存。有个反直觉的错误做法是用FileReader把整个文件读成base64再切,那会直接把浏览器内存撑爆。正确做法是只切分索引:
const FILE_CHUNK_SIZE = 10 * 1024 * 1024; // 10MB function createChunks(file) { const chunks = []; let start = 0; while (start < file.size) { const end = Math.min(start + FILE_CHUNK_SIZE, file.size); const blob = file.slice(start, end); chunks.push({ blob: blob, start: start, end: end, index: Math.floor(start / FILE_CHUNK_SIZE) }); start = end; } return chunks; }blob对象在这个阶段只是引用原文件的一部分,不会复制数据,所以放心用。
第二,全局唯一标识。给文件生成一个唯一的fileId,建议基于内容计算MD5(后面详细说),也可以在前端生成UUID。这个ID是整个上传流程的“主键”,后续的断点续传、合并、进度记录全都要靠它。
第三,并发控制。多数浏览器对同一域名的并发连接数有限制,而且如果一次把几十个分片全发出去,后端会同时收到一大堆请求,很容易把服务打崩。保险做法是限制并发数在3到5,这里给一个简单的并发调度示例:
async function uploadChunks(chunks, fileId, concurrency = 3) { const queue = chunks.map(chunk => ({ blob: chunk.blob, index: chunk.index, fileId: fileId, fileName: fileNameValue })); const pool = []; for (let i = 0; i < concurrency; i++) { const worker = (async () => { while (queue.length > 0) { const item = queue.shift(); await uploadOne(item); } })(); pool.push(worker); } await Promise.all(pool); }注意在实际工程里还要加上指数退避重试,避免网络抖动时连续失败导致任务整体卡死。
4. 后端接口与合并逻辑:一份可以直接落地的实现
4.1 接口清单与数据模型设计
分片上传后端至少要暴露三个接口:
| 接口 | 用途 | 关键参数 |
|---|---|---|
POST /api/file/upload/chunk | 上传单个分片 | fileId,chunkIndex,totalChunks,file |
POST /api/file/upload/merge | 合并全部分片 | fileId,fileName,totalChunks |
GET /api/file/upload/status | 查询已上传分片(断点续传用) | fileId |
设计数据模型时,建议用两张表:一张记录上传任务,一张记录分片完成状态。
上传任务表核心字段:
create table upload_task ( id bigint primary key auto_increment, file_id varchar(64) not null, file_name varchar(255) not null, total_size bigint not null, chunk_size bigint not null, total_chunks int not null, status tinyint default 0, -- 0新建 1上传中 2合并中 3完成 4失败 create_time datetime, update_time datetime, unique key uk_file_id (file_id) );分片状态表核心字段:
create table upload_chunk ( id bigint primary key auto_increment, file_id varchar(64) not null, chunk_index int not null, chunk_size bigint not null, upload_time datetime, unique key uk_file_chunk (file_id, chunk_index) );这张分片表是断点续传的核心依据:每次上传分片时先查它,如果该分片已经存在且大小一致,就跳过上传,从而避免重复传输。
4.2 分片上传接口的Controller实现
Controller的定义很简单,核心逻辑在Service层:
@RestController @RequestMapping("/api/file/upload") public class FileUploadController { @PostMapping("/chunk") public Result uploadChunk(@RequestParam("fileId") String fileId, @RequestParam("chunkIndex") Integer chunkIndex, @RequestParam("totalChunks") Integer totalChunks, @RequestParam("fileName") String fileName, @RequestParam("file") MultipartFile file) { uploadService.uploadChunk(fileId, chunkIndex, totalChunks, fileName, file); return Result.success(); } @PostMapping("/merge") public Result merge(@RequestBody MergeRequest request) { String filePath = uploadService.merge(request.getFileId(), request.getFileName(), request.getTotalChunks()); return Result.success(filePath); } }这里要重点说明:分片上传接口收到的每一片,本质上仍是一个小文件,用MultipartFile接收完全没问题。前面全量上传的问题在于“一个请求里塞了整个文件”,分片后每个请求最多10MB,压力完全可控。
4.3 Service层的分片落盘与去重逻辑
Service层最关键的是分片落盘逻辑。推荐的做法是:每个分片单独存储在独立目录里。目录结构可以是{存储根目录}/{fileId}/{chunkIndex}.part。
@Service public class UploadService { @Autowired private UploadTaskMapper taskMapper; @Autowired private UploadChunkMapper chunkMapper; private String rootDir = "/data/file_storage"; public void uploadChunk(String fileId, Integer chunkIndex, Integer totalChunks, String fileName, MultipartFile file) throws IOException { // 1. 首次上传时初始化任务记录 if (chunkIndex == 0) { UploadTask task = new UploadTask(); task.setFileId(fileId); task.setFileName(fileName); task.setTotalChunks(totalChunks); task.setStatus(1); taskMapper.insert(task); } // 2. 分片去重:已存在尺寸一致的分片直接返回 UploadChunk exist = chunkMapper.selectByFileIdAndIndex(fileId, chunkIndex); if (exist != null && exist.getChunkSize().equals(file.getSize())) { return; } // 3. 写分片到磁盘 Path chunkPath = Paths.get(rootDir, fileId, chunkIndex + ".part"); Files.createDirectories(chunkPath.getParent()); try (InputStream in = file.getInputStream()) { Files.copy(in, chunkPath, StandardCopyOption.REPLACE_EXISTING); } // 4. 记录分片元数据 UploadChunk chunk = new UploadChunk(); chunk.setFileId(fileId); chunk.setChunkIndex(chunkIndex); chunk.setChunkSize(file.getSize()); chunkMapper.insertOrUpdate(chunk); } }注意上面几个容易被忽略的细节:
- 目录隔离:每个
fileId一个目录,避免多个文件的分片混在一起。 - 分片去重:这个判断非常关键。如果前端因重试导致同一分片重复提交,后端不用重复写磁盘,直接忽略。
- 空文件边界:最后一个分片可能小于固定分片大小,所以不能默认假设每片都是10MB。我在代码里用
file.getSize()记录实际大小,而不是用固定值,就是为了兼容末片。
4.4 合并的实现与边界处理
合并是整个流程的最后一个关键动作。逻辑上不复杂:按chunkIndex从小到大把所有分片按顺序写入一个目标文件。
public String merge(String fileId, String fileName, Integer totalChunks) throws IOException { Path dir = Paths.get(rootDir, fileId); Path target = Paths.get(rootDir, fileName + System.currentTimeMillis() + ".dat"); // 1. 校验分片完整性:有多少分片已经上传 List<UploadChunk> uploadedChunks = chunkMapper.selectByFileId(fileId); if (uploadedChunks.size() != totalChunks) { throw new IllegalStateException("分片不完整,已上传 " + uploadedChunks.size() + " / " + totalChunks); } // 2. 按索引排序后逐个写入 Set<Integer> existsIndex = uploadedChunks.stream() .map(UploadChunk::getChunkIndex).collect(Collectors.toSet()); try (FileChannel out = FileChannel.open(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { for (int i = 0; i < totalChunks; i++) { if (!existsIndex.contains(i)) { throw new IllegalStateException("缺失分片: " + i); } Path chunkPath = dir.resolve(i + ".part"); try (FileChannel in = FileChannel.open(chunkPath, StandardOpenOption.READ)) { long size = in.size(); long transferred = 0; while (transferred < size) { transferred += in.transferTo(transferred, size - transferred, out); } } } } // 3. 清理临时分片目录 deleteDirectory(dir.toFile()); return target.toString(); }合并逻辑里有一个值得展开的点:不要用普通的InputStream读取再写入,更不要用Files.readAllBytes()。大文件场景下这种搞法会大量占用内存。正确姿势是用FileChannel.transferTo(),它底层走的是零拷贝路径,数据不经过用户态缓冲,合并1GB文件的内存占用非常稳定。
另外,校验逻辑里“缺失分片直接抛异常”不是最优解。更友好的做法是返回缺失分片的索引列表,让前端知道还差哪几片、继续补传。这里为了简洁用了异常,在真实项目中我建议改成返回状态码+缺失列表。
4.5 一个隐蔽坑:同名文件冲突与秒传顺带解决
合并目标文件名如果直接用fileName,会引发两个后果:一是不同用户上传同名文件时互相覆盖;二是重复上传同一个文件会白白占用磁盘。正确的设计思路是存储层文件名与业务文件名分离——存储层用fileId或UUID命名,业务层在数据库里记录原始文件名和存储路径的映射关系。
而且这个设计天然支持“秒传”功能:如果前端在上传前先计算出文件MD5,后端在数据库里按MD5查一下,发现已经存在相同内容的文件,直接返回“已有文件地址”,前端连分片都不用传了。我后面单独讲这块。
5. 断点续传、秒传与并发合并时的状态一致性
5.1 断点续传不仅仅是个“前端功能”
断点续传经常被误解成“前端重新传一遍,服务器跳过已传分片”。但其实后端必须提供两个能力:
- 查询已上传分片状态——前端恢复上传时,需要知道哪几片不用重传。
- 对重复分片的幂等处理——即使前端判断失误重传了某个分片,后端也不能报错。
实现幂等的关键就是我上面写的“分片去重”逻辑:先查upload_chunk表,如果分片记录存在且大小一致,直接返回成功。这个设计能让前端无脑重试,大大简化了断点续传的复杂度。
5.2 秒传的MD5方案:先查后传
秒传的实现思路很简单:在正式上传前,前端先计算整个文件的MD5,然后调用一个检查接口,后端在文件记录表里按MD5查找。
@GetMapping("/check") public Result checkFile(@RequestParam("md5") String md5, @RequestParam("fileName") String fileName) { FileRecord record = fileRecordMapper.selectByMd5(md5); if (record != null) { return Result.success(record.getStorePath()); } return Result.fail("文件不存在"); }如果返回了存储路径,前端直接跳过上传,用户体验就像“秒传”。
这里有两个实际工程里要特别留意的问题:
- 前端MD5计算性能。一个2GB的文件在浏览器里用
SparkMD5全量计算,可能需要好几十秒甚至几分钟。优化思路有两个:一是用File.slice分段增量计算,避免一次性读取;二是任务开始时就立即在后台线程算MD5,同时先把首个分片上传了,两者并行。不要为了算MD5把首次上传卡在原地。 - 后端MD5查重可靠性。MD5本身有极小概率碰撞,对安全性要求极高的系统(比如涉密件、合同扫描件),建议再加一层“文件大小+MD5”双重校验,甚至用SHA-256。正常业务场景MD5已经够用。
5.3 合并时的并发问题:同一个fileId的并发合并请求
合并接口有一个隐蔽的并发隐患:如果前端网络抖动导致合并请求被重复提交,后端会同时执行两份合并逻辑。两份逻辑都读到了“分片完整”,同时写入目标文件,会导致目标文件损坏或写入异常。
解决方案是在合并接口上加锁。用分布式系统常用的基于Redis的分布式锁,或者简化版用文件系统的原子操作。单体应用用synchronized(fileId.intern())也能顶一阵:
private final Map<String, Object> lockMap = new ConcurrentHashMap<>(); public String merge(String fileId, String fileName, Integer totalChunks) throws IOException { Object lock = lockMap.computeIfAbsent(fileId, k -> new Object()); synchronized (lock) { // 合并逻辑 } lockMap.remove(fileId); }注意lockMap要清理,否则文件一多内存就会泄漏。更稳妥的做法是设置过期时间或用Caffeine缓存管理。
5.4 合并中偶发的“分片不见了”排查
我在实际项目里遇到过一种诡异情况:合并时校验分片完整,但真正读文件的时候某个分片却找不到。定位到最后是分片清理线程和合并线程的竞争。如果后端设计了“定时清理未完成任务”的Job,这个Job可能会把尚在传输中但数据库状态还没更新的分片文件删掉。解决方案是在清理策略里留一个“最后活跃时间”的宽限期,比如超过2小时没有任何分片上传动作的任务才允许清理,并且清理前要锁定任务状态。
6. 部署层面的隐藏陷阱:上传目录、磁盘、JVM与监控
6.1 临时目录别放在/tmp
很多SpringMVC项目默认把上传临时文件放在/tmp下(Servlet容器的javax.servlet.context.tempdir)。Linux的/tmp目录有三个隐患:
- 系统重启会清空。如果系统恰好在你上传到一半时重启,所有已传分片直接蒸发。
- 分区空间有限。
/tmp通常是根分区的一部分,跟操作系统共用空间。几百MB的分片文件堆起来,很容易把磁盘写满,导致服务器崩溃。 - 权限混乱。多用户服务器上如果权限设置不严,可能存在其他进程读写风险。
建议统一在系统规划里单独挂一个数据盘,比如/data/file_storage,并且权限收紧到只有应用账号可读写。另外整个分片目录最好是“纯本地磁盘”,不要用NFS网络盘,分片频繁读写时网络盘IO延迟高、稳定性差,一直在生产环境给我惹麻烦。
6.2 磁盘空间与文件清理策略
大文件上传服务器的磁盘管理是一门必修课。我见过太多服务器因为忘记清理临时文件而被塞爆的案例。
设计清理机制有三个原则:
- 上传过程中不清理活动文件。通过记录每个任务的最新活跃时间,判断任务是否在推进。
- 合并完成后立即清理分片目录。不要等到“定时Job”来收尾,合并成功的路径里直接同步删除最快。
- 定期任务兜底清理僵尸任务。比如每小时扫描一次,找出超过30分钟没有任何分片更新的任务,删除分片目录,并把任务状态标记为失败。
这里再分享一个磁盘压力分摊的经验:上传高峰期,多分片同时写盘会让单块磁盘的IO队列非常深。如果系统规模稍大,建议把分片存储按fileId哈希分散到多个磁盘目录,比如/data/upload_shard_01、/data/upload_shard_02,用哈希值取模决定落在哪个盘。加个三行代码,就能显著降低单盘压力。
6.3 JVM与并发参数的配套调整
分片上传看似解决了“大请求一次性加载”的问题,但并发数上来了之后,JVM和线程池依然需要配套优化:
- Tomcat最大工作线程数。默认
maxThreads是200,假如每请求平均耗时1秒,200并发已经是不少了。但如果单片只有几MB,一个请求的CPU和IO开销都极低,一般不需要刻意调大。倒是进程句柄上限ulimit -n很重要,默认1024在多分片并发时很容易被耗尽。 - 文件句柄泄漏。代码里如果每一个分片都打开
InputStream而不关闭,一段时间后就会看到Too many open files。建议所有IO都使用try-with-resources。 - 上传接口的限流。为了不让用户疯狂点重传把服务压垮,可以用令牌桶或简单的并发信号量限制全局并发上传数,比如
Semaphore控制在50。
6.4 日志与监控:出问题时如何快速定位
整理分片上传的监控时,我最看重的四个指标:
| 指标 | 作用 |
|---|---|
| 分片上传TPS | 判断当前上传压力 |
| 分片平均耗时 | 判断网络和磁盘是否健康 |
| 失败分片数 | 判断链路稳定性 |
| 磁盘剩余空间 | 防止磁盘写满 |
日志方面,强烈建议在分片上传接口里记录fileId、chunkIndex、chunkSize、耗时四个字段。这样问题排查时可以精确到“某一个分片为什么慢”。我在实践中遇到过明明是同一个文件,前50片都很快,第51片突然耗时飙升的情况,排查后是磁盘因为其他业务写入导致瞬时IO拥塞。没有分片级日志,这种问题压根查不出来。
7. 一次完整排障实录:分片上传偶尔失败的那几片
最后分享一个真实的排障过程,过程比结论有参考价值。
某次上线后,有用户反馈“大文件上传有一定概率失败,报错说某一片上传超时”。同事一开始怀疑是网络问题,但重试后还是偶发失败,而且失败的分片索引随机。后来查后端日志,发现失败的请求全部卡在一个现象上:Request timed out,但看网络流量并没有异常。
继续往下挖,发现这批用户走的链路经过了Nginx和一层网关,网关有一个proxy_read_timeout 60s的配置。正常情况下10MB分片上传几秒钟就完成了,但有个别分片因为客户端所在网络比较差,TCP窗口协商加上TLS握手,耗时接近60秒,于是被网关掐断。
解决办法有两个方向:一是把网关和Nginx的proxy_read_timeout调大,比如120秒;二是把分片再切细一点(从10MB降到5MB),减少单片网络耗时。因为产品端用户网络环境确实较差,最终两个方案都做了,问题彻底消失。
这个案例说明一个容易被忽略的问题:分片上传方案不仅要考虑后端处理和合并逻辑,前端到后端之间的每一层代理的超时时间、请求头大小限制都要纳入设计参数。大附件上传是个全链路问题,不是单纯的后端接口问题。
还有一个类似的坑:有的负载均衡器对multipart/form-data请求会在转发时解析请求体,导致后端收到的分片变成内存对象,但没有文件头信息。一旦遇到“配置都对、上传就失败”的情况,优先检查链路中是否有额外组件做了请求体解析。
8. 回到项目视角:方案选型与落地顺序建议
聊了这么多细节,回到最早的“SpringMVC大附件上传有什么方案”这个问题上。我个人的建议是:10MB分片 + 后端分片目录存储 + 数据库状态跟踪 + 合并接口 + 前端并发控制,这套组合是通用性最强、实现成本可控、可维护性也好的方案。如果只想用现成框架,可以参考淘淘等开源项目,但理解原理依然重要,否则出了问题根本无从下手。
落地时建议按以下顺序推进:
- 先把基础分片上传跑通:前后端能分片传输、合并成功。
- 再加断点续传:后端提供分片查询接口,前端本地记录已传分片。
- 再加秒传:前端计算文件MD5,后端查重。
- 最后做运维加固:监控指标、清理任务、高可用配置。
每一步都不大,但叠加起来就是一个相当完整的企业级大文件上传服务。
关于分片大小、并发数、存储路径规划这些参数,不需要一开始就追求完美,上线前做一轮真实带宽、真实文件大小的压测,数据会告诉你该调哪。内部系统20并发、100MB文件起步压测,基本就能发现大部分隐藏问题。
我做了这么多年上传功能,最大的心得是:文件上传不是写完接口就结束的功能,它是一个涉及前端、后端、存储、网络、运维的完整闭环。先把原理吃透,再动手写代码,后面运维的日子会好过很多。