大文件上传实战:分片上传、断点续传与秒传的完整方案

发布时间:2026/10/18 12:30:11
大文件上传实战:分片上传、断点续传与秒传的完整方案
做视频类小程序开发团队通常会花大量精力在推荐算法和内容分发上。但真正上线之后第一个被用户大量投诉的往往不是推荐不准而是上传一个几百 MB 的视频失败或者传到 90% 网络一波动又得从头再来。视频上传虽然不是核心业务却是内容生态的入口入口体验一旦崩塌后面的智能推荐再强用户也感知不到。本文是智能视频推荐小程序系列的第十四讲重点解决大文件上传这个看似基础、实则坑很多的工程问题。我会从分片上传的完整流程讲起结合 SpringBoot 后端、Uniapp 小程序端和 Vue 管理端给出可直接落地的代码实现并解释断点续传、秒传、并发控制、服务端状态管理等容易被忽略的工程细节。文章最后还会说明上传完成后如何衔接到 LangChain4j SpringAI 的视频理解链路。先给一个明确判断大文件上传真正要解决的不是怎么把文件塞进 HTTP 请求而是一整套状态管理问题。前端要知道传到哪了服务端要知道哪些分片到了失败之后双方还能对上话。想清楚这一点再来写代码就顺手了。1. 大文件上传到底难在哪里如果只是上传一张头像几十 KB 的文件用uni.uploadFile或multipart/form-data直接搞定完全不需要分片方案。但视频文件动辄几百 MB甚至超过 1 GB上传过程会集中暴露几个问题。问题一单次 HTTP 请求体过大。后端常见的spring.servlet.multipart.max-file-size默认值只有 1MB就算调大一次传输大文件也要占据大量内存和网络带宽。Tomcat 在接收大请求体时会把数据先写入临时文件但整体内存峰值、超时时间、连接带宽都会成为瓶颈而且一旦失败没有任何断点。问题二弱网环境下的不可控中断。小程序用户最常见的上传场景是手机流量、地铁、电梯、商场这些地方网络抖动频繁。一个 500 MB 的视频在弱网下传递往往要几分钟到十几分钟中间随便一个切换网络、小程序切后台、系统回收 WebView请求就断了。普通上传一次失败等于全部重来用户没有耐心再试第二次。问题三上传状态没有上下文。传统上传请求是无状态的后端收到一个请求处理一个请求。但大文件上传是一个多请求协作的长任务后端必须知道这个文件分了多少片、已经收到哪些片、还缺哪些片。没有状态管理就没办法断点续传也没办法做并发上传。这三个问题叠加最终表现为用户的真实体验进度条一直在走但永远走不完走完了也可能失败失败后没有任何恢复手段。分片上传方案就是针对这些问题设计的把大文件切成多个小块逐块上传服务端记录每一块的到达状态全部到位后再合并成完整文件。这样一来单次请求体变小、失败只需重传缺失分片、上传进度可以精确到每一片。2. 分片上传的核心原理与整体设计分片上传不是单一技术而是围绕分片展开的一整套机制其中三个概念最容易混淆。概念解决什么问题底层逻辑分片上传单次请求体太大、内存和带宽压力大把大文件切成 N 个小块逐个上传断点续传网络中断后需要从头再来服务端记录已到达的分片前端跳过已上传的分片秒传相同内容重复上传浪费流量和存储用文件内容计算唯一标识服务端已有则直接成功三条机制互相关联但并不相同。实际项目中通常一起落地先计算文件的 MD5 做秒传校验如果服务端不存在再初始化上传任务前端把文件切片后并发上传同时记录已上传分片全部上传完成后服务端合并分片并校验 MD5。整体流程可以描述为前端选择视频文件。计算文件 MD5 值用于秒传和最终完整性校验。调用秒传校验接口判断服务端是否已有相同文件。如果不存在调用初始化接口获得uploadId。前端将文件按固定大小切片。并发上传各个分片并实时更新进度。前端记录已上传的分片列表用于失败后续传。所有分片上传完成后调用合并接口。服务端合并分片、校验 MD5、清理临时文件、保存业务记录。异步触发后续处理视频转码、AI 特征提取、推荐索引构建等。这里最关键的设计决策是上传状态放在哪里。我采用 Redis 保存上传上下文。原因有三一是上传接口被调用多次状态必须跨请求共享二是 Redis 天然支持多实例部署哪怕后端扩容也不影响状态读取三是可以为每条上传记录设置过期时间防止客户端中断后服务端状态永久残留。2.1 Redies 中保存什么数据每一条上传记录对应一个UploadContext对象包含以下字段{ uploadId: uuid, fileName: xxx.mp4, fileSize: 524288000, chunkSize: 5242880, totalChunks: 100, md5: 文件的md5值, uploadedChunks: [0, 1, 2, 5, 6] }uploadedChunks就是断点续传的核心。每一次分片上传成功后端都会往这个列表里追加分片编号。客户端重新上传时先查询这个列表就知道哪些分片不需要再传。3. 后端环境准备与项目依赖这一讲的重点是上传链路因此后端使用最基本的 SpringBoot Web 工程加上 Redis 做状态存储。版本信息这里不做硬编码以你自己项目的实际版本为准本文演示的是通用思路。3.1 添加依赖在pom.xml中至少需要以下依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency如果需要把上传上下文序列化成 JSON可以用fastjson2或jackson甚至可以手动拼接字符串。为了简洁我这里使用fastjson2dependency groupIdcom.alibaba.fastjson2/groupId artifactIdfastjson2/artifactId version2.0.43/version /dependency实际项目中如果你用的是 SpringBoot 3 的jakarta体系上面的依赖不需要变化。3.2 配置文件在application.yml中除了 Redis 连接配置还需要设置上传相关的目录和请求体限制。spring: servlet: multipart: max-file-size: 10GB max-request-size: 20GB data: redis: host: localhost port: 6379 password: database: 0 upload: chunk-dir: /data/video-tmp/ merge-dir: /data/video-final/ chunk-size: 5242880max-file-size和max-request-size必须调大因为分片上传虽然每片只有 5MB但 Spring 在收到带文件的请求时仍会套用 multipart 解析规则上限太小会导致分片请求被拒。实际部署时这两个值要根据分片大小和并发数调整至少要大于单片大小。chunk-dir存放上传过程中的临时分片merge-dir存放合并后的最终视频文件。生产环境这两个目录建议放在独立的数据盘上并做好容量监控。3.3 上传上下文实体类// 文件路径src/main/java/com/example/video/upload/UploadContext.java Data public class UploadContext { private String uploadId; private String fileName; private Long fileSize; private Long chunkSize; private Integer totalChunks; private String md5; private ListInteger uploadedChunks; }4. 分片上传后端核心接口实现后端上传模块划分成四个接口秒传校验、初始化上传、上传分片、合并分片。下面的代码是完整的 Controller 核心逻辑。// 文件路径src/main/java/com/example/video/controller/VideoUploadController.java Slf4j RestController RequestMapping(/api/video/upload) public class VideoUploadController { private static final String UPLOAD_CONTEXT_KEY video:upload:; Resource private StringRedisTemplate stringRedisTemplate; Resource private VideoUploadService videoUploadService; Value(${upload.chunk-dir}) private String chunkDir; Value(${upload.merge-dir}) private String mergeDir; /** * 1. 秒传校验根据文件MD5判断服务端是否已存在相同视频 */ PostMapping(/check) public ResultCheckVO check(RequestBody CheckDTO dto) { VideoFile videoFile videoUploadService.findByMd5(dto.getMd5()); CheckVO vo new CheckVO(); if (videoFile ! null) { vo.setExist(true); vo.setVideoId(videoFile.getId()); } else { vo.setExist(false); } return Result.success(vo); } /** * 2. 初始化上传分配uploadId保存文件元信息和上传状态 */ PostMapping(/init) public ResultUploadInitVO init(RequestBody UploadInitDTO dto) { String uploadId UUID.randomUUID().toString().replace(-, ); UploadContext context new UploadContext(); context.setUploadId(uploadId); context.setFileName(dto.getFileName()); context.setFileSize(dto.getFileSize()); context.setChunkSize(dto.getChunkSize()); context.setTotalChunks(dto.getTotalChunks()); context.setMd5(dto.getMd5()); context.setUploadedChunks(new ArrayList()); stringRedisTemplate.opsForValue().set( UPLOAD_CONTEXT_KEY uploadId, JSON.toJSONString(context), 24, TimeUnit.HOURS ); return Result.success(new UploadInitVO(uploadId, dto.getChunkSize())); } /** * 3. 上传分片保存单块分片并更新Redis中已上传分片列表 */ PostMapping(/chunk) public ResultUploadChunkVO uploadChunk( RequestParam(uploadId) String uploadId, RequestParam(chunkIndex) Integer chunkIndex, RequestParam(file) MultipartFile file) throws IOException { UploadContext context getContext(uploadId); if (context null) { return Result.error(上传会话不存在或已过期请重新初始化); } // 如果该分片已经上传过直接返回成功用于断点续传 if (context.getUploadedChunks().contains(chunkIndex)) { return Result.success(new UploadChunkVO(chunkIndex, true)); } // 创建临时目录并保存分片 File tmpDir new File(chunkDir); if (!tmpDir.exists()) { tmpDir.mkdirs(); } File partFile new File(chunkDir, uploadId _ chunkIndex .part); file.transferTo(partFile); // 更新已上传分片列表 context.getUploadedChunks().add(chunkIndex); stringRedisTemplate.opsForValue().set( UPLOAD_CONTEXT_KEY uploadId, JSON.toJSONString(context), 24, TimeUnit.HOURS ); return Result.success(new UploadChunkVO(chunkIndex, true)); } /** * 4. 合并分片校验完整性合并文件比对MD5清理临时文件 */ PostMapping(/merge) public ResultVideoVO merge(RequestBody MergeDTO dto) throws IOException { UploadContext context getContext(dto.getUploadId()); if (context null) { return Result.error(上传会话不存在或已过期); } // 校验分片是否全部上传完成 if (context.getUploadedChunks().size() context.getTotalChunks()) { return Result.error(分片不完整缺失分片 getMissingChunks(context)); } // 按顺序合并分片到最终文件 File mergeDirFile new File(mergeDir); if (!mergeDirFile.exists()) { mergeDirFile.mkdirs(); } File finalFile new File(mergeDir, context.getFileName()); try (FileOutputStream fos new FileOutputStream(finalFile)) { for (int i 0; i context.getTotalChunks(); i) { File partFile new File(chunkDir, context.getUploadId() _ i .part); Files.copy(partFile.toPath(), fos); } } // 计算合并后的MD5与前端提交的MD5进行比对 String finalMd5; try (FileInputStream fis new FileInputStream(finalFile)) { finalMd5 DigestUtils.md5DigestAsHex(fis); } if (!finalMd5.equalsIgnoreCase(context.getMd5())) { // 文件不一致说明中间有分片损坏清理并报错 deleteChunks(context.getUploadId(), context.getTotalChunks()); finalFile.delete(); return Result.error(文件完整性校验失败请重新上传); } // 清理临时分片和Redis状态 deleteChunks(context.getUploadId(), context.getTotalChunks()); stringRedisTemplate.delete(UPLOAD_CONTEXT_KEY dto.getUploadId()); // 保存视频业务记录 VideoFile videoFile videoUploadService.saveVideoFile(finalFile, context); // 异步触发AI视频理解任务交给LangChain4j / SpringAI链路处理 videoUploadService.triggerAiProcess(videoFile); return Result.success(VideoVO.from(videoFile)); } private UploadContext getContext(String uploadId) { String json stringRedisTemplate.opsForValue().get(UPLOAD_CONTEXT_KEY uploadId); if (json null) { return null; } return JSON.parseObject(json, UploadContext.class); } private ListInteger getMissingChunks(UploadContext context) { ListInteger missing new ArrayList(); for (int i 0; i context.getTotalChunks(); i) { if (!context.getUploadedChunks().contains(i)) { missing.add(i); } } return missing; } private void deleteChunks(String uploadId, int totalChunks) { for (int i 0; i totalChunks; i) { File partFile new File(chunkDir, uploadId _ i .part); if (partFile.exists()) { partFile.delete(); } } } }这段代码已经覆盖了大文件上传的所有后端逻辑但有几个细节需要强调。初始化接口返回的是uploadId 分片大小客户端后续所有操作都依赖这个uploadId。它相当于一次上传任务的会话 ID用于把多次分片请求关联到同一个文件上下文。分片接口做了幂等处理如果uploadedChunks中已经包含当前分片编号直接返回成功。这样客户端重试时不需要担心重复上传同一个分片服务端也不会多次写入同一个文件。分片文件名用uploadId _ chunkIndex来命名天然具备顺序性合并时按索引读取即可。合并接口必须做 MD5 校验因为网络传输过程可能出现分片丢失或损坏。前端在选完文件后计算 MD5服务端合并完成后也计算 MD5两者一致才认为文件完整。这个校验同时也支撑了秒传逻辑既然 MD5 是文件内容哈希服务端判断已存在相同 MD5 的文件就说明内容完全相同。合并完成后触发triggerAiProcess这一步是把上传链路和推荐系统打通的关键。视频文件落盘后服务端通过 SpringAI 调用大模型生成视频描述和标签再通过 LangChain4j 对内容做向量化最终进入推荐系统的候选池。这里是异步执行避免用户上传后长时间等待。5. Uniapp 小程序端分片上传实现小程序端是上传的起点也是用户体验最敏感的一层。Uniapp 提供的uni.chooseVideo负责选择视频uni.uploadFile负责上传文件但分片逻辑需要自己处理。5.1 选择视频并获取文件信息// 文件路径pages/upload/index.js const CHUNK_SIZE 5 * 1024 * 1024; // 每片 5MB const MAX_CONCURRENT 3; // 并发上传数 export function chooseAndUploadVideo() { return new Promise((resolve, reject) { uni.chooseVideo({ sourceType: [album, camera], maxDuration: 60, success: async (res) { try { const filePath res.tempFilePath; const fileName res.name || video_${Date.now()}.mp4; // 1. 计算文件 MD5用于秒传和完整性校验 const md5 await calculateFileMd5(filePath); // 2. 执行完整的上传流程 const result await uploadVideo(filePath, fileName, md5); resolve(result); } catch (err) { reject(err); } }, fail: reject, }); }); }计算大文件 MD5 是一个相对耗时的操作小程序端通常使用spark-md5库按分片读取文件并增量计算哈希值避免一次性把整个文件读入内存。// 文件路径utils/md5.js import SparkMD5 from spark-md5; export function calculateFileMd5(filePath) { return new Promise((resolve, reject) { const fs uni.getFileSystemManager(); const fileInfo fs.getFileInfo({ filePath }); const chunkSize 2 * 1024 * 1024; // 每次读取 2MB const chunks Math.ceil(fileInfo.size / chunkSize); const spark new SparkMD5.ArrayBuffer(); let current 0; function readNext() { fs.readFile({ filePath, position: current * chunkSize, length: Math.min(chunkSize, fileInfo.size - current * chunkSize), success: (res) { spark.append(res.data); current; if (current chunks) { readNext(); } else { resolve(spark.end()); } }, fail: reject, }); } readNext(); }); }注意fs.getFileInfo在小程序端返回的字段是size而readFile的position和length参数用于指定读取区域分片读取正是利用了这个能力。H5 端如果使用uni的兼容接口API 会有所差异这一点在跨端发布时需要单独适配。5.2 分片上传核心逻辑计算完 MD5 后进入核心的uploadVideo函数。这段代码把前面说的秒传、初始化、分片上传、并发控制、合并调用全部串起来。// 文件路径pages/upload/index.js import { calculateFileMd5 } from /utils/md5.js; import { request, uploadFile } from /utils/request.js; async function uploadVideo(filePath, fileName, md5) { const fs uni.getFileSystemManager(); const fileInfo fs.getFileInfo({ filePath }); const fileSize fileInfo.size; const totalChunks Math.ceil(fileSize / CHUNK_SIZE); // 1. 秒传校验 const checkRes await request({ url: /api/video/upload/check, method: POST, data: JSON.stringify({ md5 }), }); if (checkRes.data.exist) { uni.showToast({ title: 视频已存在上传成功, icon: none }); return checkRes.data.videoId; } // 2. 初始化上传 const initRes await request({ url: /api/video/upload/init, method: POST, data: JSON.stringify({ fileName, fileSize, chunkSize: CHUNK_SIZE, totalChunks, md5, }), }); const uploadId initRes.data.uploadId; // 3. 查询服务端已上传的分片实现断点续传 const uploadedRes await request({ url: /api/video/upload/progress?uploadId uploadId, method: GET, }); const uploadedChunks uploadedRes.data || []; // 4. 并发上传分片 let uploadedCount uploadedChunks.length; let tasks []; for (let i 0; i totalChunks; i) { if (uploadedChunks.includes(i)) { continue; } tasks.push( uploadChunk(uploadId, i, filePath, i * CHUNK_SIZE, Math.min(fileSize, (i 1) * CHUNK_SIZE)) .then(() { uploadedCount; // 这里可以触发进度回调 onProgress onProgress(Math.floor((uploadedCount / totalChunks) * 100)); }) ); // 达到并发上限时等待这一批完成 if (tasks.length MAX_CONCURRENT) { await Promise.all(tasks); tasks []; } } // 收尾等待剩余分片完成 if (tasks.length 0) { await Promise.all(tasks); } // 5. 合并分片 const mergeRes await request({ url: /api/video/upload/merge, method: POST, data: JSON.stringify({ uploadId }), }); return mergeRes.data.videoId; } function uploadChunk(uploadId, chunkIndex, filePath, start, end) { return new Promise((resolve, reject) { const fs uni.getFileSystemManager(); // 从原始文件中读取当前分片的数据 fs.readFile({ filePath: filePath, position: start, length: end - start, success: (readRes) { // 将分片写入临时文件再交给 uploadFile 上传 const tempPath ${wx.env.USER_DATA_PATH}/chunk_${chunkIndex}.tmp; fs.writeFile({ filePath: tempPath, data: readRes.data, success: () { uni.uploadFile({ url: BASE_URL /api/video/upload/chunk, filePath: tempPath, name: file, formData: { uploadId: uploadId, chunkIndex: chunkIndex, }, success: (upRes) { const json JSON.parse(upRes.data); if (json.code 200) { resolve(json.data); } else { reject(new Error(json.message)); } }, fail: reject, }); }, fail: reject, }); }, fail: reject, }); }); }这段代码里有一个容易被忽略的细节Uniapp 的uni.uploadFile只能上传一个已存在的文件路径不能直接传内存中的 ArrayBuffer。所以分片上传前需要先把readFile读到的二进制数据通过writeFile写成本地临时文件再用uploadFile上传。这在wx.env.USER_DATA_PATH下操作是安全的不会污染用户相册。并发数MAX_CONCURRENT设置为 3这是一个比较稳妥的默认值。并发太高移动端上行带宽会被打满反而导致请求排队并发太低大文件上传速度太慢。3 到 5 是实践中比较合适的区间。断点续传的实现集中在uploadedChunks.includes(i)判断上。每次进入分片循环前先调用进度查询接口拿到已经上传过的分片编号跳过它们。为了支持这个逻辑后端需要在chunk接口之外增加一个查询进度的接口或者把已上传分片列表直接放在初始化接口的返回中二选一即可。5.3 进度计算与上传体验进度 已上传分片数 / 总分片数。虽然这个百分比不精确反映字节数但对用户来说已经足够直观。真实的字节进度可以通过已上传字节 / 文件总大小来算但分片上传时每个分片大小几乎一致用分片数计算更稳定不会因为某个分片重传导致百分比回跳。小程序切后台是另一个体验关键点。很多开发者发现小程序切到后台后网络请求会被系统挂起甚至回收。实际项目中更推荐把上传状态上报到服务端回到前台时自动调用进度查询接口继续上传未完成的分片。这就是服务端保存uploadedChunks的价值上传状态不依赖前端内存前端丢了可以恢复换一台设备也能恢复。6. Vue 管理端上传组件实现管理端通常需要上传视频素材、课程视频或运营内容操作环境是浏览器处理方式比小程序端简单一些但核心逻辑一致。区别在于浏览器端可以直接使用FileAPI、Blob.slice()和spark-md5代码更简洁。下面是用 Element Plus 的el-upload组件实现自定义上传的完整思路。!-- 文件路径src/views/video/VideoUpload.vue -- template div classvideo-upload el-upload drag :auto-uploadfalse :show-file-listfalse acceptvideo/* :on-changehandleFileChange el-icon :size48UploadFilled //el-icon div classel-upload__text拖拽视频到此处或 em点击选择文件/em/div /el-upload el-progress v-ifshowProgress :percentageprogress :statusprogressStatus stylemargin-top: 16px; / /div /template// 文件路径src/views/video/VideoUpload.vue script 部分 import SparkMD5 from spark-md5; import { ref } from vue; import { ElMessage } from element-plus; import request from /utils/request; const CHUNK_SIZE 5 * 1024 * 1024; const MAX_CONCURRENT 3; const progress ref(0); const showProgress ref(false); const progressStatus ref(); async function handleFileChange(file) { const rawFile file.raw; if (!rawFile) return; showProgress.value true; progress.value 0; progressStatus.value ; // 1. 计算 MD5 const md5 await calculateMd5(rawFile); progress.value 2; // 2. 秒传校验 const checkRes await request.post(/api/video/upload/check, { md5 }); if (checkRes.data.exist) { progress.value 100; progressStatus.value success; ElMessage.success(视频秒传成功); return; } // 3. 初始化上传 const totalChunks Math.ceil(rawFile.size / CHUNK_SIZE); const initRes await request.post(/api/video/upload/init, { fileName: rawFile.name, fileSize: rawFile.size, chunkSize: CHUNK_SIZE, totalChunks, md5, }); const uploadId initRes.data.uploadId; // 4. 分片并发上传 let uploadedCount 0; const tasks []; for (let i 0; i totalChunks; i) { const start i * CHUNK_SIZE; const end Math.min(start CHUNK_SIZE, rawFile.size); tasks.push( uploadChunk(uploadId, i, rawFile.slice(start, end)).then(() { uploadedCount; progress.value Math.floor((uploadedCount / totalChunks) * 100); }) ); if (tasks.length MAX_CONCURRENT) { await Promise.all(tasks); tasks.length 0; } } if (tasks.length) { await Promise.all(tasks); } // 5. 合并分片 const mergeRes await request.post(/api/video/upload/merge, { uploadId }); progress.value 100; progressStatus.value success; ElMessage.success(视频上传完成); return mergeRes.data; } function uploadChunk(uploadId, chunkIndex, blob) { const formData new FormData(); formData.append(uploadId, uploadId); formData.append(chunkIndex, chunkIndex); formData.append(file, blob, chunk_${chunkIndex}.tmp); return request.post(/api/video/upload/chunk, formData, { headers: { Content-Type: multipart/form-data }, timeout: 60000, }); } function calculateMd5(file) { return new Promise((resolve, reject) { const fileReader new FileReader(); const chunkSize 2 * 1024 * 1024; const chunks Math.ceil(file.size / chunkSize); const spark new SparkMD5.ArrayBuffer(); let currentChunk 0; fileReader.onload (e) { spark.append(e.target.result); currentChunk; if (currentChunk chunks) { loadNext(); } else { resolve(spark.end()); } }; fileReader.onerror reject; function loadNext() { const start currentChunk * chunkSize; const end Math.min(start chunkSize, file.size); fileReader.readAsArrayBuffer(file.slice(start, end)); } loadNext(); }); }浏览器端的Blob.slice()比小程序端的readFile writeFile省事很多尤其是在视频文件较大时FileReader配合slice可以稳定地分片读取。Element Plus 的el-upload设置:auto-uploadfalse后文件选中不会自动上传on-change会拿到uploadFile对象。通过:http-request自定义请求也可以但这里直接调用后端接口更直白。管理端的断点续传实现逻辑与小程序的思路一致进入分片循环前先请求一次进度查询接口跳过已上传分片。这里我就不重复贴代码了但请不要遗漏这一步。很多后台管理系统上线后的第一轮 Bug 就出在这里页面刷新后上传任务直接失败没有恢复机制。7. 常见问题与排查方法大文件上传开发过程中以下几类问题出现频率最高建议先收藏这份排查清单。问题现象可能原因排查方式解决方案上传分片时提示 413Nginx 或网关限制请求体大小查看 Nginx 错误日志检查client_max_body_size调整网关上传上限或改用分片上传绕过限制分片接口报 500后台有FileNotFoundException临时目录不存在或目录权限不足检查chunk-dir配置和目录权限启动时自动创建目录并设置可写权限合并后文件播放损坏分片缺失或顺序错误断点调试合并流程计算合并前后 MD5合并前校验分片完整性合并后校验 MD5断点续传不生效每次从头开始前端没有查询进度或uploadedChunks未持久化查看 Redis 中是否保存上传上下文在 init 或 progress 接口返回已上传分片列表并发上传时进度条回跳并发请求乱序回调触发顺序不一致检查进度计算逻辑按分片编号计算结果不依赖回调顺序Redis 中上传状态大量堆积客户端上传中断后没清理查看 Redis key 数量设置过期时间并增加定时清理任务上传大视频时小程序内存暴涨一次性读完整个文件再上传查看分片读取逻辑严格按position length分片读取边读边传服务端收到分片数量少于总分片数并发上传时部分请求失败被静默忽略查看前端 Promise 错误捕获任何分片失败都抛出错误并重试不能吞异常这里最需要提醒的是Nginx 或网关层的请求体限制。很多团队后端配置了 10GB但忘记改 Nginx 的client_max_body_size导致分片上传时频繁失败。分片方案虽然把单次请求体控制到了 5MB但如果后端和网关之间有一层 Nginx 配置过小仍然会把请求拦在外面。另一个常见的坑是分片文件的并发写入冲突。如果同一个分片被并发上传两次file.transferTo()会同时写同一个文件轻则文件内容部分覆盖重则抛FileAlreadyExistsException。所以分片接口的幂等判断很关键发现分片已存在就不要再写文件。8. 工程最佳实践与 AI 链路衔接分片上传的代码写完后距离生产环境还有一段距离。下面这六条实践建议是我在项目中沉淀下来的经验直接照做能少踩很多坑。第一分片大小选 5MB 到 10MB 之间。分片太小时上传请求数量暴增网络开销反而变大分片太大时弱网环境下一个分片容易超时重传。5MB 在移动网络下是一个比较均衡的取值。第二并发数控制在 3 到 5。移动端并发请求过多Socket 会被占满而且还容易触发运营商的连接限制。在浏览器端可以适当放宽到 6但不要超过 8。第三上传接口必须幂等。客户端网络请求失败后会自动重试如果服务端不幂等重试时就会出现重复写文件的问题。分片接口的判断逻辑是uploadedChunks.contains(chunkIndex)已经存在的分片直接返回成功不要再次写入。第四Redis 上传状态必须设置过期时间。用户可能在上传中途退出服务端不能永远保留这个状态。我建议设置为 24 小时并配合一个定时任务定期清理超过 24 小时且没有完整上传的分片文件。第五临时文件必须定期清理。上传中断时分片文件可能残留在服务器上。最稳妥的做法是合并成功后立即删除对应分片同时每天凌晨执行一次全量清理删除超过 24 小时的.part文件。第六文件校验是最后一道防线。前端计算 MD5 时读取的文件内容要和最终合并的文件完全一致。合并成功后后端必须用同样的 MD5 算法再做一次校验。这样即使某个分片在网络传输中被截断也能在合并前及时发现。8.1 上传完成后的 AI 处理链路这一讲虽然重点是大文件上传但上传本身不是终点。视频上传完成后后端需要把视频送入智能推荐系统的下一环。在当前的系统架构中videoUploadService.triggerAiProcess(videoFile)会异步执行三个任务通过 SpringAI 调用大语言模型根据视频文件名和基础信息生成视频描述和推荐标签。通过 LangChain4j 构建视频内容的文本向量存入向量数据库。将向量与视频元信息写入推荐候选池供后续的推荐算法调用。这就能串起整个项目的 AI 链路了。SpringAI 负责调用大模型做内容理解LangChain4j 负责把理解结果转化成可被推荐系统检索的向量形态而大文件上传是这些能力得以触达用户内容的入口环节。9. 总结与后续规划大文件上传的问题本质上是一个多请求协作任务的状态管理问题。分片上传、断点续传、秒传这三种能力组合起来才能真正解决视频上传的稳定性问题。本文最核心的代码逻辑有三个服务端用 Redis 保存上传上下文、客户端按分片并发上传、服务端合并后二次校验 MD5。把这三点想明白上传模块的骨架就搭起来了。如果你正在做视频类小程序下一步建议按顺序做三件事先把分片上传的代码跑通再去测试弱网环境下的断点续传效果最后把后端 merge 结束后的 AI 链路接上。视频从传上来到被理解再到被推荐每一步都会决定用户留存而上传只是入口。这套上传方案也可以迁移到其他场景比如文档上传、图片批量上传、日志文件上传。分片 状态管理 幂等校验的思路是通用的只要理解了这里的uploadId和uploadedChunks换成任何业务都能快速落地。