☰
视频秒传检查实战:SpringBoot+Uniapp+Vue实现文件MD5去重与分片上传
2026/10/18 20:56:38 网站建设 项目流程

视频上传功能上线后,遇到最多的反馈不是“传得慢”,而是“同一个视频重复传了好几次”,既浪费用户时间,又把 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 环境与版本说明

下面这些环境以我们项目中的常见配置为例,你可以根据自己的实际项目调整版本:

组件说明
JDK17,SpringBoot 3.x 环境
SpringBoot3.x
MyBatis-Plus3.5.x
MySQL8.x
Redis6.x,用于缓存秒传哈希
Node.js18+,运行 Vue3 / Uniapp 构建
UniappHBuilderX 创建的项目
Vue3.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.js

3. 秒传检查整体设计与数据表

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.mp4

7.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 推荐的完整链路。秒传检查只是上传模块的第一环,后面还有很多工程细节值得深入实践。

如果你在实现秒传检查时遇到过其他奇怪的坑,欢迎在评论区留言,我们一起讨论排查思路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询