视频上传功能上线后,遇到最多的反馈不是“传得慢”,而是“同一个视频重复传了好几次”,既浪费用户时间,又把 OSS 存储和带宽成本抬得很高。我们在做智能视频推荐小程序时,也踩了这个坑。后来在整体链路里补上了“秒传检查”这一步,效果非常明显:相同文件第二次上传基本是瞬间完成,服务端存储成本也降了下来。
这篇文章是《基于 SpringBoot + Vue + Uniapp + LangChain4j + SpringAI 的智能视频推荐小程序》系列的第十三讲,重点拆解“秒传检查”这个功能。内容包括原理、数据表设计、后端接口、Uniapp 端和 Vue 管理端如何计算文件 MD5、如何与分片上传配合,以及线上经常遇到的坑和排查思路。已经熟悉上传流程的朋友可以直接看第 3 章之后的内容。
1. 为什么要做秒传检查:视频上传的流量与体验痛点
1.1 秒传检查是什么
秒传检查,又叫“文件去重检查”。用户在客户端选择一个文件后,客户端先计算这个文件的唯一指纹(最常见的是 MD5),然后把这个指纹发送给服务端。服务端在数据库里查询这个指纹是否已经存在。如果存在,就直接告诉客户端“文件已经有了,不用再传了”,这就是秒传。
用一句话说明:秒传不是上传速度变快,而是把“重复上传”变成“上传 0 字节”。
1.2 秒传检查在智能视频推荐小程序中的位置
在这个项目里,用户通过 Uniapp 小程序录制、上传视频,视频经过服务端存储后,会进入推荐系统的数据管道,结合 LangChain4j 和 SpringAI 做内容理解和个性化推荐。整个链路的入口就是“视频上传”。
如果入口不做去重,会出现两类问题:
- 用户体验差:同一个视频反复上传,等待时间长、流量消耗大。
- 服务端压力大:重复文件反复写存储、反复做转码和 AI 分析,浪费计算资源。
秒传检查的作用是在用户真正上传文件之前,先用一次轻量请求拦截重复文件。这样既改善了体验,也保护了下游存储和 AI 分析链路。
1.3 秒传、分片上传、断点续传三者的关系
这三个概念经常一起出现,但别混淆:
- 秒传:文件已经存在,客户端不用再传。
- 分片上传:一个大文件切成多个小块,逐块上传,解决单文件过大、网络波动问题。
- 断点续传:上传中断后,从已上传的分片继续传,而不是重头开始。
三者的配合关系是:先做秒传检查,未命中再进入分片上传;分片上传过程中可以通过已上传分片记录实现断点续传;所有分片合并完成后,服务端登记文件指纹,下一次再上传相同文件时就能走秒传。秒传检查是这条链路的第一道关卡。
2. 整体技术栈与环境准备
2.1 项目技术架构
本系列项目后端采用 SpringBoot,持久层使用 MyBatis-Plus,文件元信息存储在 MySQL 中,文件本体可存放在本地磁盘或 OSS 上,实际部署时按团队情况选择。前端有两个端:
- Uniapp 小程序端:面向普通用户上传视频。
- Vue 管理端:用于运营人员后台管理视频。
LangChain4j 和 SpringAI 主要用于推荐侧的文本理解、标签提取和推荐结果生成,秒传检查本身不依赖它们,但上传完成的文件会通过消息队列进入推荐分析链路。
2.2 环境与版本说明
下面这些环境以我们项目中的常见配置为例,你可以根据自己的实际项目调整版本:
| 组件 | 说明 |
|---|---|
| JDK | 17,SpringBoot 3.x 环境 |
| SpringBoot | 3.x |
| MyBatis-Plus | 3.5.x |
| MySQL | 8.x |
| Redis | 6.x,用于缓存秒传哈希 |
| Node.js | 18+,运行 Vue3 / Uniapp 构建 |
| Uniapp | HBuilderX 创建的项目 |
| Vue | 3.x |
版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。
2.3 示例工程结构
后端工程结构如下:
video-recommend-server/ ├── src/main/java/com/example/video/ │ ├── controller/ │ │ └── FileUploadController.java │ ├── service/ │ │ ├── FileCheckService.java │ │ └── FileUploadService.java │ ├── mapper/ │ │ └── VideoFileRecordMapper.java │ ├── entity/ │ │ └── VideoFileRecord.java │ ├── common/ │ │ └── Result.java │ └── VideoRecommendApplication.java └── src/main/resources/ └── application.yml前端使用 HBuilderX 创建 Uniapp 项目,文件结构大致为:
uniapp-video-app/ ├── pages/ │ └── upload/ │ └── upload.vue ├── utils/ │ └── md5.js └── api/ └── file.js3. 秒传检查整体设计与数据表
3.1 秒传检查的执行流程
秒传检查的完整流程可以用下面这个图来理解:
选择视频文件 ↓ 计算文件 MD5(本地,不消耗上传流量) ↓ 请求后端 /api/file/check ↓ 服务端按 MD5 查询数据库 ↓ ├─ 命中:返回秒传成功,直接进入后续业务(如发布视频) └─ 未命中:返回需要上传,客户端进入分片上传流程这里有两个关键细节:
- MD5 必须由客户端计算,因为服务端如果先接收文件再计算哈希,就没有节省上传流量。
- 服务端查询时不能只看 MD5,还要看文件大小,进一步降低哈希冲突概率。
3.2 文件记录表设计
秒传检查需要一个文件元信息表,用于登记所有已上传完成的文件。表结构如下:
CREATE TABLE `video_file_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `file_md5` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '文件MD5', `file_name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件原始名称', `file_size` BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小,单位字节', `file_path` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '文件存储路径', `upload_status` TINYINT NOT NULL DEFAULT 0 COMMENT '上传状态:0-上传中,1-上传完成', `video_id` BIGINT DEFAULT NULL COMMENT '关联视频ID,用于推荐链路', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_md5_status` (`file_md5`, `upload_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='视频文件秒传记录表';注意几个设计点:
file_md5字段长度设为 64,因为 MD5 是 32 位十六进制字符串,留出扩展空间。upload_status区分上传中和上传完成,避免用户在上传过程中再次检查时,错误命中一条未完成的记录。- 索引使用
file_md5和upload_status的组合索引,满足秒传查询条件。
3.3 分片记录表与哈希索引
当秒传未命中时,大文件会走分片上传。分片记录表如下:
CREATE TABLE `video_file_chunk` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `file_md5` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '文件整体MD5', `chunk_index` INT NOT NULL DEFAULT 0 COMMENT '分片序号,从0开始', `chunk_size` INT NOT NULL DEFAULT 0 COMMENT '分片大小,单位字节', `chunk_path` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '分片存储路径', PRIMARY KEY (`id`), KEY `idx_md5_index` (`file_md5`, `chunk_index`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='视频文件分片记录表';分片表通过file_md5关联同一文件的多个分片。这里存的file_md5是整个文件的 MD5,而不是每个分片的 MD5,这样分片合并后可以直接复用秒传检查的哈希结果。
4. 后端 SpringBoot 实现
4.1 实体类
后端工程中,我们使用 MyBatis-Plus。先创建对应实体类。
// 文件路径:src/main/java/com/example/video/entity/VideoFileRecord.java package com.example.video.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.util.Date; @Data @TableName("video_file_record") public class VideoFileRecord { @TableId(type = IdType.AUTO) private Long id; private String fileMd5; private String fileName; private Long fileSize; private String filePath; private Integer uploadStatus; private Long videoId; private Date createTime; private Date updateTime; }4.2 Mapper
Mapper 继承BaseMapper,同时提供两个秒传检查需要的方法。
// 文件路径:src/main/java/com/example/video/mapper/VideoFileRecordMapper.java package com.example.video.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.video.entity.VideoFileRecord; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; public interface VideoFileRecordMapper extends BaseMapper<VideoFileRecord> { @Select("SELECT * FROM video_file_record " + "WHERE file_md5 = #{md5} AND file_size = #{fileSize} " + "AND upload_status = 1 LIMIT 1") VideoFileRecord selectByMd5AndSize(@Param("md5") String md5, @Param("fileSize") Long fileSize); @Select("SELECT COUNT(1) FROM video_file_record " + "WHERE file_md5 = #{md5} AND upload_status = 1") int countByMd5(@Param("md5") String md5); }4.3 Service
Service 层负责保存记录和查询记录。秒传检查的 Service 代码如下:
// 文件路径:src/main/java/com/example/video/service/FileCheckService.java package com.example.video.service; import com.example.video.entity.VideoFileRecord; import com.example.video.mapper.VideoFileRecordMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @Service public class FileCheckService { @Autowired private VideoFileRecordMapper videoFileRecordMapper; /** * 秒传检查 * @param fileMd5 文件MD5 * @param fileSize 文件大小 * @return 已存在的文件记录,没有则返回 null */ public VideoFileRecord checkFile(String fileMd5, Long fileSize) { if (StringUtils.isEmpty(fileMd5) || fileSize == null || fileSize <= 0) { return null; } return videoFileRecordMapper.selectByMd5AndSize(fileMd5, fileSize); } }这里有个容易被忽略的问题:不能只查 MD5,必须同时带文件大小。MD5 虽然冲突概率很低,但理论上存在不同文件相同哈希的可能。加上文件大小条件后,命中记录的可信度更高。
4.4 Controller
Controller 提供秒传检查接口和上传接口入口。先定义统一返回结构:
// 文件路径:src/main/java/com/example/video/common/Result.java package com.example.video.common; import lombok.Data; @Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }请求参数和响应对象:
// 文件路径:src/main/java/com/example/video/controller/FileCheckRequest.java package com.example.video.controller; import lombok.Data; @Data public class FileCheckRequest { private String fileMd5; private Long fileSize; }// 文件路径:src/main/java/com/example/video/controller/FileCheckResponse.java package com.example.video.controller; import lombok.Data; @Data public class FileCheckResponse { private Boolean exists; private String fileUrl; public FileCheckResponse(Boolean exists) { this.exists = exists; } }Controller 核心代码:
// 文件路径:src/main/java/com/example/video/controller/FileUploadController.java package com.example.video.controller; import com.example.video.common.Result; import com.example.video.entity.VideoFileRecord; import com.example.video.service.FileCheckService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/file") public class FileUploadController { @Autowired private FileCheckService fileCheckService; /** * 秒传检查接口 * 客户端上传文件前,先调用该接口判断文件是否已存在 */ @PostMapping("/check") public Result<FileCheckResponse> check(@RequestBody FileCheckRequest request) { VideoFileRecord record = fileCheckService.checkFile( request.getFileMd5(), request.getFileSize() ); if (record != null) { FileCheckResponse response = new FileCheckResponse(true); response.setFileUrl(record.getFilePath()); return Result.success(response); } return Result.success(new FileCheckResponse(false)); } }到这里,秒传检查接口已经可以工作。客户端拿到exists=true时,就可以跳过上传,直接进入发布成功流程;拿到exists=false时,继续走分片上传。
4.5 上传完成后的登记逻辑
秒传检查依赖“已经上传完成并登记”的文件记录。如果分片合并完成后没有登记 MD5,秒传永远命中不了。下面给一个合并完成后登记记录的核心片段:
// 文件路径:src/main/java/com/example/video/service/FileUploadService.java package com.example.video.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.example.video.entity.VideoFileRecord; import com.example.video.mapper.VideoFileRecordMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class FileUploadService { @Autowired private VideoFileRecordMapper videoFileRecordMapper; /** * 分片合并完成后,登记文件指纹。 * 同一个 MD5 重复登记时,通过唯一记录判断幂等。 */ @Transactional(rollbackFor = Exception.class) public VideoFileRecord registerFile(String md5, String fileName, Long fileSize, String filePath) { VideoFileRecord existing = videoFileRecordMapper.selectOne( new LambdaQueryWrapper<VideoFileRecord>() .eq(VideoFileRecord::getFileMd5, md5) .eq(VideoFileRecord::getFileSize, fileSize) .eq(VideoFileRecord::getUploadStatus, 1) .last("LIMIT 1") ); if (existing != null) { return existing; } VideoFileRecord record = new VideoFileRecord(); record.setFileMd5(md5); record.setFileName(fileName); record.setFileSize(fileSize); record.setFilePath(filePath); record.setUploadStatus(1); videoFileRecordMapper.insert(record); return record; } }需要注意registerFile的幂等性。现实中可能出现两个用户同时上传同一个文件,两个请求几乎同时完成合并,如果不做控制,会产生两条相同 MD5 的记录。此时需要配合数据库唯一索引,或使用 Redis 锁来保证幂等。
5. 前端 Uniapp 实现
5.1 引入哈希计算库
小程序端计算文件 MD5,最常见的是使用spark-md5。在 Uniapp 项目的根目录执行:
npm install spark-md5然后在工具文件中引入:
// 文件路径:utils/md5.js import SparkMD5 from 'spark-md5';如果项目没有 npm 环境,也可以通过 “原生 JS 实现 MD5” 的方式,但建议优先使用成熟的spark-md5,它支持增量计算,处理大文件时更友好。
5.2 跨端文件读取与 MD5 计算
Uniapp 运行在不同的平台上,获取文件的方式不同,所以需要写跨端逻辑:
- 微信小程序:使用
uni.getFileSystemManager().readFile()读取文件内容。 - App 端:使用
plus.io读取文件。 - H5 端:通过
<input type="file">获取文件后,使用 FileReader 读取。
下面是一个封装好的 MD5 计算方法:
// 文件路径:utils/md5.js import SparkMD5 from 'spark-md5'; /** * 计算文件 MD5 * @param {String} filePath 文件路径 * @returns {Promise<String>} */ export function calcFileMd5(filePath) { return new Promise((resolve, reject) => { // #ifdef MP-WEIXIN const fs = uni.getFileSystemManager(); fs.readFile({ filePath: filePath, success(res) { try { const spark = new SparkMD5.ArrayBuffer(); spark.append(res.data); const md5 = spark.end(); resolve(md5); } catch (e) { reject(e); } }, fail(err) { reject(err); } }); // #endif // #ifdef APP-PLUS plus.io.resolveLocalFileSystemURL(filePath, (entry) => { entry.file((file) => { const reader = new plus.io.FileReader(); reader.readAsArrayBuffer(file); reader.onloadend = (e) => { try { const spark = new SparkMD5.ArrayBuffer(); spark.append(e.target.result); const md5 = spark.end(); resolve(md5); } catch (err) { reject(err); } }; reader.onerror = reject; }); }, reject); // #endif }); }这里要注意两种readFile方式在大文件场景下的区别。微信小程序的readFile会一次性把文件读进内存,视频文件较大时可能出现内存占用过高。更稳妥的做法是使用FileSystemManager.readSplitString或分片读取,但小程序的读取能力有限,所以一般建议在 100MB 以下的个人短视频场景直接读取,超大文件则优先考虑 Web 端或原生 App 端上传。
5.3 调用秒传检查接口
拿到 MD5 后,上传前先请求后端接口:
// 文件路径:api/file.js export function checkFile(fileMd5, fileSize) { return uni.request({ url: '/api/file/check', method: 'POST', data: { fileMd5, fileSize } }); }上传页面的核心逻辑:
// 文件路径:pages/upload/upload.vue async handleChooseVideo(res) { const file = res.tempFiles[0]; const filePath = file.path; const fileSize = file.size; const fileMd5 = await calcFileMd5(filePath); const checkRes = await checkFile(fileMd5, fileSize); if (checkRes.data.exists) { uni.showToast({ title: '检测到重复文件,已秒传', icon: 'success' }); // 直接进入后续业务,比如绑定视频信息、触发推荐分析 this.handlePublishSuccess(checkRes.data.fileUrl); return; } // 未命中,进入分片上传流程 this.uploadByChunks(filePath, fileMd5, fileSize); }注意checkFile的返回值在不同平台略有差异,实际项目里建议统一用 Promise 封装uni.request,避免回调嵌套。
6. Vue 管理端秒传检查实现
6.1 Web 端文件读取与 MD5 计算
Vue 管理端运行在浏览器中,计算 MD5 的方式与小程序不同。Web 端使用FileReader读取文件内容,交给spark-md5计算。
<!-- 文件路径:src/views/upload/FileUpload.vue --> <template> <input type="file" @change="handleFileChange" /> </template> <script setup> import SparkMD5 from 'spark-md5'; function handleFileChange(event) { const file = event.target.files[0]; if (!file) return; const spark = new SparkMD5.ArrayBuffer(); const reader = new FileReader(); reader.readAsArrayBuffer(file); reader.onprogress = (e) => { if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); console.log(`读取进度:${percent}%`); } }; reader.onload = (e) => { spark.append(e.target.result); const fileMd5 = spark.end(); // 调用秒传检查接口 checkFile(fileMd5, file.size).then((res) => { if (res.data.exists) { alert('文件已存在,秒传成功'); } else { // 普通上传 uploadFile(file); } }); }; reader.onerror = (err) => { console.error('文件读取失败', err); }; } </script>Web 端需要注意两个问题:
FileReader也是一次性读取整个文件,大文件时建议使用Blob.prototype.slice分片增量计算。- 视频文件通常比较大,建议在计算 MD5 时显示“正在计算文件指纹”的进度提示,否则用户会以为页面卡死了。
6.2 管理端上传组件接入
管理端上传场景和用户端略有不同。运营人员上传的视频尺寸可能更大,还可能需要批量上传。在接入秒传检查时,建议把 MD5 计算封装成通用函数,并放在上传前置钩子中执行:
// 文件路径:src/utils/fileHash.js import SparkMD5 from 'spark-md5'; export function calcFileHashWithProgress(file, onProgress) { return new Promise((resolve, reject) => { const chunkSize = 2 * 1024 * 1024; // 2MB const spark = new SparkMD5.ArrayBuffer(); let currentChunk = 0; const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; const totalChunks = Math.ceil(file.size / chunkSize); const loadNext = () => { const start = currentChunk * chunkSize; const end = start + chunkSize >= file.size ? file.size : start + chunkSize; const reader = new FileReader(); reader.readAsArrayBuffer(blobSlice.call(file, start, end)); reader.onload = (e) => { spark.append(e.target.result); currentChunk++; if (onProgress) { onProgress(Math.round((currentChunk / totalChunks) * 100)); } if (currentChunk < totalChunks) { loadNext(); } else { resolve(spark.end()); } }; reader.onerror = reject; }; loadNext(); }); }这种分块计算方式适合浏览器端使用,能有效避免大文件一次读入内存导致浏览器崩溃。
7. 完整联调流程与验证
7.1 联调时序
完整的联调过程可以拆成下面几步:
| 步骤 | 角色 | 动作 |
|---|---|---|
| 1 | 客户端 | 用户选择视频文件 |
| 2 | 客户端 | 计算文件 MD5 和文件大小 |
| 3 | 客户端 | POST 请求/api/file/check |
| 4 | 服务端 | 按 MD5 + size 查询记录表 |
| 5 | 服务端 | 返回 exists=true 或 false |
| 6 | 客户端 | true:秒传成功,进入发布流程 |
| 7 | 客户端 | false:进入分片上传 |
| 8 | 服务端 | 合并分片,登记文件 MD5 |
| 9 | 客户端 | 再次请求秒传接口,此时可以命中 |
在实际测试时,建议先用手动上传的方式登记一个文件,再用相同文件做一次秒传检查,验证接口返回结果。
7.2 使用 curl 验证后端接口
如果暂时没有前端页面,可以直接用 curl 验证后端接口:
curl -X POST http://localhost:8080/api/file/check \ -H "Content-Type: application/json" \ -d '{ "fileMd5": "d41d8cd98f00b204e9800998ecf8427e", "fileSize": 0 }'注意以上是一个空文件的 MD5,实际测试时需要使用你真实计算出的文件 MD5。可以通过下面的命令计算本地文件的 MD5:
md5sum /path/to/video.mp47.3 预期结果
- 如果数据库中没有对应记录,接口返回:
{ "code": 200, "message": "success", "data": { "exists": false, "fileUrl": null } }- 如果数据库中存在相同 MD5 和文件大小的记录,接口返回:
{ "code": 200, "message": "success", "data": { "exists": true, "fileUrl": "/data/video/2024/xxx.mp4" } }exists=true时,客户端不需要再调用分片上传,直接把fileUrl作为文件地址写入视频发布记录即可。
8. 常见问题与排查思路
8.1 问题排查清单
我把落地过程中经常遇到的几类问题整理成一张表格,方便快速定位:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 前端计算的 MD5 和后端不一致 | 文件读取方式不同,或使用了 base64 转换 | 统一使用 ArrayBuffer 读取,避免二次编码 |
| 大文件计算 MD5 卡死 | 一次性读取整个文件,内存占用过高 | 使用分块读取、增量 append 计算 |
| 秒传接口命中,但视频播放不了 | 源文件被清理或迁移,记录成了脏数据 | 秒传检查时同时校验物理文件是否存在 |
| 同一个文件重复入库 | 并发上传,缺少幂等控制 | 加唯一索引 + 数据库锁或 Redis 锁 |
| 小程序端读取不到文件 | filePath 失效或权限不足 | 使用wx.chooseVideo返回的临时文件路径 |
| 秒传一直不命中 | 上传完成后的登记逻辑没写 | 检查合并流程是否落库video_file_record |
| 分片上传合并后 MD5 变化 | 合并顺序错误或漏掉分片 | 按 chunk_index 严格排序,并校验分片数量 |
8.2 典型问题详解
第一个高频问题:前端计算 MD5 时使用 base64,而后端使用文件的二进制内容计算 MD5,结果永远对不上。原因是把文件转成 base64 后,计算哈希的对象已经不是原始文件字节了。解决方法是前后端统一使用 ArrayBuffer 或原始二进制数据计算。
第二个高频问题:秒传检查命中后直接返回成功,但没有验证服务器上的真实文件是否完整。如果文件曾经上传成功但后来被人工清理掉,数据库里还有记录,秒传接口会把一条失效记录返回给用户。生产环境建议在秒传命中时增加一个文件存在性校验,例如:
- 本地存储:判断
File是否存在。 - OSS 存储:调用 SDK 的
doesObjectExist方法。
如果文件不存在,则删除对应的数据库记录,并让用户重新上传。这一步不能省,否则秒传功能会成为线上故障的隐患。
第三个高频问题:并发重复上传同一个文件。比如两个用户同时上传同一个热门视频,两个请求同时完成分片合并,然后同时执行registerFile,可能产生两条相同 MD5 的记录。建议在video_file_record表上增加唯一索引:
ALTER TABLE `video_file_record` ADD UNIQUE KEY `uk_md5_size` (`file_md5`, `file_size`);同时,在registerFile中捕获重复插入异常并返回已有记录,保证接口幂等。
9. 最佳实践与工程建议
9.1 哈希策略
MD5 计算速度快,是秒传检查的主流选择,但它的碰撞安全性并不适合做签名认证。如果你的业务对哈希冲突特别敏感,可以考虑两层校验:
- 第一层:MD5 + 文件大小。
- 第二层:记录文件的前 128 字节或 CRC32,在秒传命中时二次确认。
对视频推荐场景来说,MD5 + 文件大小已经足够,不需要过度设计。
9.2 并发幂等
秒传检查接口本身是只读操作,不会有并发写入问题。真正的并发压力在“分片合并完成后的登记动作”。除了加唯一索引,还可以在 Redis 中设置一个upload:file:{md5}锁,获取锁后再执行查询和插入,避免数据库重复写入。
示例思路:
String lockKey = "upload:file:" + md5; boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofMinutes(5)); if (!locked) { // 说明其他线程正在处理相同文件,直接返回已有状态 return videoFileRecordMapper.selectByMd5AndSize(md5, fileSize); } try { // 执行登记逻辑 } finally { redisTemplate.delete(lockKey); }9.3 存储与安全
- 文件路径不要使用用户传入的文件名,应使用系统生成的 UUID 或日期目录,防止路径穿越和文件名冲突。
- 秒传接口需要登录鉴权,避免未登录用户探测服务器上是否存在某个文件。
- 上传接口要限制文件大小、类型和视频时长,防止恶意上传超大文件耗尽磁盘和带宽。
- 生产环境启用 HTTPS,避免用户上传的视频内容在传输过程中被抓包。
9.4 与 AI 推荐链路打通
秒传检查命中时,文件已经存在但 video 记录可能还没有。用户发布视频后,需要把 videoId 和文件记录关联起来,并触发推荐分析流程。
建议流程为:
- 秒传命中后创建 video 记录,关联已有文件记录。
- 发送一个 MQ 消息,例如
video.upload.complete。 - 消费者收到消息后,调用 LangChain4j / SpringAI 做视频标题理解、标签提取和向量化。
- 推荐服务根据标签和向量结果更新用户推荐池。
这样以来,同一个视频即使被多个用户上传,也只会在第一次上传时进入 AI 分析链路,后续秒传直接复用分析结果,整体系统压力会小很多。
10. 总结与下一步学习方向
这一讲我们完成了秒传检查的完整闭环:客户端计算文件 MD5,服务端查询文件指纹,命中则跳过上传,未命中则进入分片上传,合并后登记指纹。通过秒传检查,重复文件的带宽消耗基本降为零,用户上传视频的体验也明显提升。
在实际项目落地中,我建议你优先关注两件事:一是“合并完成后登记 MD5”的幂等处理,这是秒传能否命中的前提;二是“秒传命中后校验物理文件是否存在”,这是秒传功能在线上长期稳定运行的关键。
下一步可以继续学习分片上传的合并细节、断点续传的实现,以及把文件上传事件接入 RabbitMQ / RocketMQ 后触发 AI 推荐的完整链路。秒传检查只是上传模块的第一环,后面还有很多工程细节值得深入实践。
如果你在实现秒传检查时遇到过其他奇怪的坑,欢迎在评论区留言,我们一起讨论排查思路。