【EasyPan】项目常见问题解答(自用&持续更新中…)汇总版
文件上传方法解析
一、方法总览
@Transactional(rollbackFor = Exception.class)
public UploadResultDto uploadFile(...)
核心能力:
- 秒传验证:通过MD5+文件大小实现文件秒传
- 分片处理:支持大文件分块上传与合并
- 空间管理:实时校验用户存储空间
- 事务保障:数据库操作原子性
- 异步转码:视频/图片文件后台处理
- 自动重命名:同名文件自动添加序号
二、模块化解析
1. 秒传处理模块
// 首片触发秒传检查
if (chunkIndex == 0) {
List<FileInfo> dbFileList = this.fileInfoMapper.selectList(infoQuery);
if (!dbFileList.isEmpty()) {
// 空间校验
if (dbFile.getFileSize() + spaceDto.getUseSpace() > spaceDto.getTotalSpace())
// 创建引用记录
dbFile.setFileId(fileId);
this.fileInfoMapper.insert(dbFile);
// 返回秒传结果
resultDto.setStatus(UploadStatusEnums.UPLOAD_SECONDS.getCode());
}
}
设计亮点:
- 仅首片触发查询,减少数据库压力
- 复用已有文件的物理存储路径(file_path)
- 原子化更新用户空间
2. 分片处理模块
// 分片暂存逻辑
String tempFolderName = appConfig.getProjectFolder() + Constants.FILE_FOLDER_TEMP;
File newFile = new File(tempFolderName.getPath() + "/" + chunkIndex);
file.transferTo(newFile);
// 临时空间记录
redisComponent.saveFileTempSize(webUserDto.getUserId(), fileId, file.getSize());
// 合并条件判断
if (chunkIndex < chunks - 1) {
return resultDto.setStatus(UploadStatusEnums.UPLOADING.getCode());
}
关键技术:
- 分片按序号存储:
用户ID+文件ID/chunkIndex
- Redis记录分片累计大小
- 10MB缓冲区减少IO次数
3. 文件入库模块
FileInfo fileInfo = new FileInfo();
fileInfo.setFilePath(month + "/" + realFileName); // 按月份分目录
fileInfo.setStatus(FileStatusEnums.TRANSFER.getStatus()); // 转码中状态
this.fileInfoMapper.insert(fileInfo);
// 事务提交后触发异步操作
TransactionSynchronizationManager.registerSynchronization(() -> {
fileInfoService.transferFile(fileId, webUserDto);
});
创新设计:
- 文件路径动态生成:
年月目录/用户ID文件ID.后缀
- 状态机管理:TRANSFER->USING/TRANSFER_FAIL
- 事务边界控制:确保数据入库后再转码
4. 转码处理模块
@Async
public void transferFile(...) {
// 合并分片
union(dirPath, targetFilePath);
// 视频处理
if (FileTypeEnums.VIDEO == fileTypeEnum) {
cutFile4Video(); // HLS切片
createCover4Video(); // 生成封面
}
// 更新文件状态
updateInfo.setStatus(FileStatusEnums.USING.getStatus());
fileInfoMapper.updateFileStatusWithOldStatus(...);
}
核心技术:
- FFmpeg视频转码:MP4->TS切片+m3u8索引
- 缩略图生成:视频首帧+图片缩放
- 异常恢复机制:失败状态可重新触发
三、流程主副线分析
主线流程
副线处理(异常路径)
异常类型 | 处理方式 | 技术实现 |
---|---|---|
空间不足 | 立即终止 | Redis实时校验 |
MD5冲突 | 重新计算 | 文件内容比对 |
分片丢失 | 断点续传 | Redis记录进度 |
转码失败 | 状态标记 | 人工介入恢复 |
四、时序图解析
五、性能优化策略
-
分片并行上传:
- 支持多分片并发上传
- 分片大小动态调整(2MB-10MB)
-
内存管理:
byte[] b = new byte[1024 * 10]; // 10KB缓冲区
-
存储优化:
- 临时文件自动清理
- 视频文件HLS自适应码率
-
Redis优化:
redisUtils.setex(key, value, 1小时); // 临时数据自动过期
-
异步队列:
- 转码任务进入线程池
- 失败任务重试机制
六、安全防护措施
-
校验机制:
- MD5+大小双校验防碰撞
- 文件后缀白名单校验
-
防篡改保护:
if (!FileStatusEnums.TRANSFER.getStatus().equals(fileInfo.getStatus())) { return; // 状态校验 }
-
临时文件清理:
finally { FileUtils.deleteDirectory(tempFileFolder); }
文件秒传处理模块深度解析
一、核心机制图解
二、代码模块拆解
1. 触发条件判断
// 仅在首片上传时触发秒传检查
if (chunkIndex == 0) {
// 核心处理逻辑
}
设计考量:避免每个分片都进行数据库查询,减少系统压力
2. 秒传核验核心
FileInfoQuery infoQuery = new FileInfoQuery();
infoQuery.setFileMd5(fileMd5); // MD5指纹
infoQuery.setSimplePage(new SimplePage(0, 1)); // 限制查询1条
infoQuery.setStatus(FileStatusEnums.USING.getStatus()); // 仅检查可用文件
List<FileInfo> dbFileList = this.fileInfoMapper.selectList(infoQuery);
技术要点:
- 分页查询避免全表扫描
- 状态过滤确保文件可用
3. 空间校验逻辑
if (dbFile.getFileSize() + spaceDto.getUseSpace() > spaceDto.getTotalSpace()) {
throw new BusinessException("空间不足");
}
双重保障机制:
- Redis实时校验:毫秒级响应
- 数据库事务保障:最终一致性
4. 引用记录创建
dbFile.setFileId(fileId); // 生成新文件ID
dbFile.setFilePid(filePid); // 继承目录结构
dbFile.setUserId(webUserDto.getUserId()); // 绑定新用户
dbFile.setCreateTime(curDate); // 更新时间戳
fileName = autoRename(filePid, userId, fileName); // 自动重命名
this.fileInfoMapper.insert(dbFile); // 创建新记录
创新设计:
- 物理文件复用:
file_path
直接引用原文件 - 逻辑记录独立:文件树结构、权限信息隔离
5. 空间更新操作
private void updateUserSpace(SessionWebUserDto webUserDto, Long useSpace) {
// 数据库更新
userInfoMapper.updateUserSpace(userId, useSpace, null);
// Redis更新
spaceDto.setUseSpace(spaceDto.getUseSpace() + useSpace);
redisComponent.saveUserSpaceUse(userId, spaceDto);
}
双写策略:
- Redis:高频访问数据缓存,保证实时性
- MySQL:持久化存储,保证可靠性
三、异常处理机制
1. 碰撞处理流程
try {
// 秒传核心逻辑
} catch (BusinessException e) {
// 空间不足等业务异常
logger.error("文件上传失败", e);
throw e;
} finally {
// 清理临时文件
}
异常类型:
CODE_904
:存储空间不足SQLIntegrityConstraintViolationException
:唯一约束冲突
2. 事务回滚保障
@Transactional(rollbackFor = Exception.class)
public UploadResultDto uploadFile(...) {
// 整个操作在事务中执行
}
原子性保证:出现任何异常时,引用记录创建和空间更新操作同时回滚
四、时序图解析
五、代码
/**
* 上传文件(含秒传处理)
* 事务注解确保数据一致性:当空间不足时回滚数据库操作
*/
@Override
@Transactional(rollbackFor = Exception.class)
public UploadResultDto uploadFile(SessionWebUserDto webUserDto, String fileId, MultipartFile file, String fileName,
String filePid, String fileMd5, Integer chunkIndex, Integer chunks) {
// 初始化上传结果对象
UploadResultDto resultDto = new UploadResultDto();
try {
// 生成文件唯一ID(类似网盘分享链接的短ID)
if (StringTools.isEmpty(fileId)) {
fileId = StringTools.getRandomString(Constants.LENGTH_10); // 生成10位随机字符串
}
resultDto.setFileId(fileId);
// 获取用户空间使用情况(Redis缓存优化查询性能)
UserSpaceDto spaceDto = redisComponent.getUserSpaceUse(webUserDto.getUserId());
//==================== 秒传处理核心逻辑 ====================//
if (chunkIndex == 0) { // 仅在上传第一个分片时触发秒传检查
// 构建MD5查询条件(命中idx_md5_size索引)
FileInfoQuery infoQuery = new FileInfoQuery();
infoQuery.setFileMd5(fileMd5); // MD5指纹
infoQuery.setSimplePage(new SimplePage(0, 1)); // 限制返回1条记录
infoQuery.setStatus(FileStatusEnums.USING.getStatus()); // 只查询可用文件
// 查询数据库是否存在相同文件
List<FileInfo> dbFileList = this.fileInfoMapper.selectList(infoQuery);
if (!dbFileList.isEmpty()) {
FileInfo dbFile = dbFileList.get(0);
// 双重空间校验(Redis缓存校验 + 数据库最终校验)
if (dbFile.getFileSize() + spaceDto.getUseSpace() > spaceDto.getTotalSpace()) {
throw new BusinessException(ResponseCodeEnum.CODE_904); // 空间不足异常
}
//==================== 创建秒传文件记录 ====================//
// 复用文件实体(类似创建快捷方式)
dbFile.setFileId(fileId); // 新文件ID
dbFile.setFilePid(filePid); // 继承目录结构
dbFile.setUserId(webUserDto.getUserId()); // 绑定当前用户
dbFile.setCreateTime(new Date()); // 重置时间戳
dbFile.setStatus(FileStatusEnums.USING.getStatus()); // 设置可用状态
dbFile.setDelFlag(FileDelFlagEnums.USING.getFlag()); // 删除标记
// 自动重命名处理(类似"文件名(1).txt"的生成逻辑)
fileName = autoRename(filePid, webUserDto.getUserId(), fileName);
dbFile.setFileName(fileName);
// 写入数据库(实际是创建新的元数据记录)
this.fileInfoMapper.insert(dbFile);
// 更新用户空间使用量(原子操作)
updateUserSpace(webUserDto, dbFile.getFileSize());
// 返回秒传成功状态码
resultDto.setStatus(UploadStatusEnums.UPLOAD_SECONDS.getCode());
return resultDto;
}
}
//==================== 正常上传流程继续执行 ====================//
// ...(后续为普通上传处理逻辑)
} catch (BusinessException e) {
// 特殊异常处理(包含空间不足等业务异常)
logger.error("文件上传失败", e);
throw e; // 抛出异常触发事务回滚
}
return resultDto;
}
/**
* 自动重命名策略(防止同一目录下文件重名)
* 实现逻辑类似Windows的"同名文件(1)"处理
*/
private String autoRename(String filePid, String userId, String fileName) {
FileInfoQuery query = new FileInfoQuery();
query.setUserId(userId);
query.setFilePid(filePid);
query.setDelFlag(FileDelFlagEnums.USING.getFlag());
query.setFileName(fileName);
// 查询同名文件数量(命中idx_user_pid_name索引)
Integer count = this.fileInfoMapper.selectCount(query);
if (count > 0) {
// 调用字符串工具生成带序号的文件名(如"文档(1).pdf")
return StringTools.rename(fileName);
}
return fileName;
}
/**
* 用户空间更新(双写策略)
* 先更新数据库 -> 再更新Redis缓存
*/
private void updateUserSpace(SessionWebUserDto webUserDto, Long useSpace) {
// 数据库更新(使用乐观锁防止超卖)
int count = userInfoMapper.updateUserSpace(webUserDto.getUserId(), useSpace, null);
if (count == 0) { // 更新行数为0表示空间不足
throw new BusinessException(ResponseCodeEnum.CODE_904);
}
// Redis缓存更新(保证读取性能)
UserSpaceDto spaceDto = redisComponent.getUserSpaceUse(webUserDto.getUserId());
spaceDto.setUseSpace(spaceDto.getUseSpace() + useSpace);
redisComponent.saveUserSpaceUse(webUserDto.getUserId(), spaceDto);
}
文件转码模块深度解析
一、核心流程图示
二、代码模块拆解
1. 状态校验模块
if (fileInfo == null || !FileStatusEnums.TRANSFER.getStatus().equals(fileInfo.getStatus())) {
return;
}
功能:确保只有处于"转码中"状态的文件才会被处理
设计考量:防止重复处理或无效操作
2. 路径生成模块
String targetFolderName = appConfig.getProjectFolder() + Constants.FILE_FOLDER_FILE;
File targetFolder = new File(targetFolderName + "/" + month);
目录结构:
project_root/
├── file/
│ └── 202309/
│ └── user123_file456.mp4
优化点:按月份分目录存储,避免单目录文件过多
3. 文件合并模块
union(fileFolder.getPath(), targetFilePath, true);
实现要点:
- 使用RandomAccessFile进行随机读写
- 10KB缓冲区平衡内存与IO效率
- 自动清理临时目录(delSource=true)
三、关键技术实现
1. 视频处理流程
// 视频转TS格式
final String CMD_TRANSFER_2TS = "ffmpeg -y -i %s -vcodec copy -acodec copy -vbsf h264_mp4toannexb %s";
// 生成HLS切片
final String CMD_CUT_TS = "ffmpeg -i %s -c copy -map 0 -f segment -segment_list %s -segment_time 30 %s/%s_%%4d.ts";
输出结构:
video.mp4
├── video.m3u8
├── video_0001.ts
├── video_0002.ts
└── video_0003.ts
2. 缩略图生成
// 视频封面截取
ScaleFilter.createCover4Video(源文件, 150px, 输出路径);
// 图片缩略图生成
ScaleFilter.createThumbnailWidthFFmpeg(源文件, 150px, 输出路径);
降级策略:缩略图生成失败时直接复制原文件
3. 乐观锁实现
UPDATE file_info
SET status = #{newStatus}
WHERE file_id = #{fileId}
AND status = #{oldStatus}
并发控制:确保只有初始状态为TRANSFER的记录能被更新
四、异常处理机制
1. 错误日志记录
catch (Exception e) {
logger.error("文件转码失败,文件ID:{},userId:{}", fileId, webUserDto.getUserId(), e);
transferSuccess = false;
}
2. 状态回滚
finally {
updateInfo.setStatus(transferSuccess ? USING : TRANSFER_FAIL);
fileInfoMapper.updateFileStatusWithOldStatus(...);
}
五、代码
/**
* 文件转码处理服务
* 使用@Async实现异步处理,避免阻塞主线程
*/
@Async
public void transferFile(String fileId, SessionWebUserDto webUserDto) {
// 初始化转码结果标识
Boolean transferSuccess = true;
String targetFilePath = null; // 最终文件存储路径
String cover = null; // 封面图路径
FileTypeEnums fileTypeEnum = null; // 文件类型枚举
// 1. 查询文件基础信息
FileInfo fileInfo = this.fileInfoMapper.selectByFileIdAndUserId(fileId, webUserDto.getUserId());
try {
// 2. 状态校验(双重检查)
if (fileInfo == null || !FileStatusEnums.TRANSFER.getStatus().equals(fileInfo.getStatus())) {
return; // 非转码状态文件直接返回
}
// 3. 准备文件存储路径
// 临时文件目录:/temp/userId_fileId/
String tempFolderName = appConfig.getProjectFolder() + Constants.FILE_FOLDER_TEMP;
String currentUserFolderName = webUserDto.getUserId() + fileId;
File fileFolder = new File(tempFolderName + currentUserFolderName);
// 4. 解析文件信息
String fileSuffix = StringTools.getFileSuffix(fileInfo.getFileName()); // 获取文件后缀
String month = DateUtil.format(fileInfo.getCreateTime(), DateTimePatternEnum.YYYYMM.getPattern());
// 5. 创建目标目录(按年月分类)
// 最终存储路径:/file/yyyyMM/
String targetFolderName = appConfig.getProjectFolder() + Constants.FILE_FOLDER_FILE;
File targetFolder = new File(targetFolderName + "/" + month);
if (!targetFolder.exists()) {
targetFolder.mkdirs(); // 不存在则创建目录
}
// 6. 合并分片文件
String realFileName = currentUserFolderName + fileSuffix; // 构建唯一文件名
targetFilePath = targetFolder.getPath() + "/" + realFileName;
union(fileFolder.getPath(), targetFilePath, fileInfo.getFileName(), true);
// 7. 根据文件类型进行特殊处理
fileTypeEnum = FileTypeEnums.getFileTypeBySuffix(fileSuffix);
// 7.1 视频文件处理
if (FileTypeEnums.VIDEO == fileTypeEnum) {
// 视频切片(HLS协议)
cutFile4Video(fileId, targetFilePath);
// 生成视频封面(首帧截图)
cover = month + "/" + currentUserFolderName + Constants.IMAGE_PNG_SUFFIX;
String coverPath = targetFolderName + "/" + cover;
ScaleFilter.createCover4Video(new File(targetFilePath), Constants.LENGTH_150, new File(coverPath));
}
// 7.2 图片文件处理
else if (FileTypeEnums.IMAGE == fileTypeEnum) {
// 生成缩略图
cover = month + "/" + realFileName.replace(".", "_."); // 缩略图命名规则
String coverPath = targetFolderName + "/" + cover;
// 尝试用FFmpeg生成缩略图
Boolean created = ScaleFilter.createThumbnailWidthFFmpeg(
new File(targetFilePath),
Constants.LENGTH_150,
new File(coverPath),
false
);
// 降级方案:生成失败直接复制原图
if (!created) {
FileUtils.copyFile(new File(targetFilePath), new File(coverPath));
}
}
} catch (Exception e) {
// 8. 异常处理
logger.error("文件转码失败,文件ID:{},userId:{}", fileId, webUserDto.getUserId(), e);
transferSuccess = false;
} finally {
// 9. 更新文件状态(使用乐观锁)
FileInfo updateInfo = new FileInfo();
updateInfo.setFileSize(new File(targetFilePath).length()); // 设置实际文件大小
updateInfo.setFileCover(cover); // 设置封面路径
updateInfo.setStatus(transferSuccess ? FileStatusEnums.USING.getStatus() : FileStatusEnums.TRANSFER_FAIL.getStatus());
/**
* 乐观锁实现说明:
* UPDATE file_info
* SET status = #{newStatus}
* WHERE file_id = #{fileId}
* AND user_id = #{userId}
* AND status = #{oldStatus}
*
* 确保只有状态为TRANSFER的记录会被更新,防止并发操作导致状态不一致
*/
fileInfoMapper.updateFileStatusWithOldStatus(
fileId,
webUserDto.getUserId(),
updateInfo,
FileStatusEnums.TRANSFER.getStatus()
);
}
}
/**
* 视频切片处理方法
*/
private void cutFile4Video(String fileId, String videoFilePath) {
// 创建切片目录(与视频文件同名目录)
File tsFolder = new File(videoFilePath.substring(0, videoFilePath.lastIndexOf(".")));
if (!tsFolder.exists()) {
tsFolder.mkdirs();
}
// FFmpeg命令模板
final String CMD_TRANSFER_2TS = "ffmpeg -y -i %s -vcodec copy -acodec copy -vbsf h264_mp4toannexb %s";
final String CMD_CUT_TS = "ffmpeg -i %s -c copy -map 0 -f segment -segment_list %s -segment_time 30 %s/%s_%%4d.ts";
// 1. 先转成TS格式
String tsPath = tsFolder + "/" + Constants.TS_NAME;
String cmd = String.format(CMD_TRANSFER_2TS, videoFilePath, tsPath);
ProcessUtils.executeCommand(cmd, false);
// 2. 生成HLS切片和m3u8索引
cmd = String.format(CMD_CUT_TS,
tsPath,
tsFolder.getPath() + "/" + Constants.M3U8_NAME,
tsFolder.getPath(),
fileId
);
ProcessUtils.executeCommand(cmd, false);
// 3. 清理临时TS文件
new File(tsPath).delete();
}
文件合并模块深度解析
一、核心流程图示
二、代码模块拆解
1. 参数说明
/**
• @param dirPath 分片存储目录(如:/temp/user123_file456/)
• @param toFilePath 合并后文件路径(如:/file/202309/user123_file456.mp4)
• @param fileName 原始文件名(仅用于日志记录)
• @param delSource 是否删除源分片(true-合并后自动清理)
*/
private void union(String dirPath, String toFilePath, String fileName, Boolean delSource)
2. 文件校验模块
File dir = new File(dirPath);
if (!dir.exists()) {
throw new BusinessException("目录不存在"); // 快速失败机制
}
设计考量:前置检查避免无效操作
3. 核心合并逻辑
try (RandomAccessFile writeFile = new RandomAccessFile(targetFile, "rw")) {
byte[] buffer = new byte[1024 * 10]; // 10KB缓冲
for (int i = 0; i < fileList.length; i++) {
try (RandomAccessFile readFile = new RandomAccessFile(chunkFile, "r")) {
while ((len = readFile.read(buffer)) != -1) {
writeFile.write(buffer, 0, len); // 增量写入
}
}
}
}
关键技术点:
- 使用
RandomAccessFile
实现随机读写 - 固定10KB缓冲区平衡内存与IO效率
- try-with-resource自动关闭资源
三、关键处理逻辑
1. 分片读取机制
while ((len = readFile.read(b)) != -1) {
writeFile.write(b, 0, len);
}
工作流程:
read(b)
从当前文件指针读取数据- 返回实际读取字节数(len),-1表示EOF
write(b,0,len)
写入目标文件- 指针自动后移,下次读取继续
2. 异常处理机制
catch (Exception e) {
logger.error("合并分片失败", e);
throw new BusinessException("合并分片失败"); // 业务异常封装
}
finally {
if (null != writeFile) {
writeFile.close(); // 确保资源释放
}
}
保障措施:
- 记录详细错误日志
- 异常转换(Exception -> BusinessException)
- 资源释放兜底
四、代码
/**
* 合并分片文件到完整文件
*
* @param dirPath 分片文件存储目录(格式:/temp/userId_fileId/)
* @param toFilePath 合并后的目标文件路径(格式:/file/yyyyMM/userId_fileId.ext)
* @param fileName 原始文件名(仅用于日志记录)
* @param delSource 是否删除源分片文件(true=合并后自动清理)
*
* 实现原理:
* 1. 顺序读取编号为0-N的分片文件
* 2. 使用10KB缓冲区流式合并
* 3. 支持事务回滚(异常时中断合并)
*/
private void union(String dirPath, String toFilePath, String fileName, Boolean delSource) {
// 1. 校验分片目录是否存在
File dir = new File(dirPath);
if (!dir.exists()) {
throw new BusinessException("目录不存在"); // 快速失败
}
// 2. 获取分片文件列表(按文件名排序)
File[] fileList = dir.listFiles();
File targetFile = new File(toFilePath);
RandomAccessFile writeFile = null;
try {
// 3. 初始化目标文件(随机访问模式)
writeFile = new RandomAccessFile(targetFile, "rw");
byte[] buffer = new byte[1024 * 10]; // 10KB缓冲区
// 4. 遍历所有分片文件(命名格式:0,1,2...)
for (int i = 0; i < fileList.length; i++) {
File chunkFile = new File(dirPath + "/" + i);
RandomAccessFile readFile = null;
try {
// 5. 打开当前分片文件(只读模式)
readFile = new RandomAccessFile(chunkFile, "r");
int bytesRead;
// 6. 流式读取分片内容(自动维护文件指针)
while ((bytesRead = readFile.read(buffer)) != -1) {
// 7. 写入目标文件(追加模式)
writeFile.write(buffer, 0, bytesRead);
}
} catch (Exception e) {
logger.error("合并分片[{}]失败", i, e);
throw new BusinessException("合并分片失败");
} finally {
// 8. 确保关闭当前分片文件
if (readFile != null) {
try {
readFile.close();
} catch (IOException e) {
logger.warn("关闭分片文件失败", e);
}
}
}
}
} catch (Exception e) {
logger.error("合并文件[{}]失败", fileName, e);
throw new BusinessException("合并文件" + fileName + "出错了");
} finally {
// 9. 资源清理工作
try {
if (writeFile != null) {
writeFile.close(); // 关闭目标文件
}
// 10. 按需删除源分片(事务提交后执行)
if (delSource && dir.exists()) {
try {
FileUtils.deleteDirectory(dir); // 递归删除目录
logger.debug("已清理分片目录:{}", dirPath);
} catch (IOException e) {
logger.error("删除分片目录失败", e);
}
}
} catch (IOException e) {
logger.error("关闭文件流失败", e);
}
}
}
/**
* 技术要点说明:
* 1. 文件指针机制:
* - RandomAccessFile自动维护读取位置指针
* - 每次read()都会从上次结束位置继续读取
*
* 2. 内存优化:
* - 固定10KB缓冲区避免大内存占用
* - 流式处理支持超大文件合并
*
* 3. 异常处理:
* - 分片级异常记录具体失败分片编号
* - 文件级异常携带原始文件名
*
* 4. 资源管理:
* - 使用finally确保文件句柄释放
* - 删除操作放在最后确保主流程完成
*/
异步转码机制技术解析
一、代码片段关键逻辑说明
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
fileInfoService.transferFile(fileInfo.getFileId(), webUserDto);
}
});
此代码实现了:
- 事务边界控制:确保转码操作在数据库事务提交后触发
- 异步执行保障:通过Spring代理调用实现真正的异步
- 数据可见性:保证转码任务读取到已持久化的文件记录
二、异步转码的必要性
1. 防止事务未提交导致数据不可见
场景 | 同步转码 | 异步转码(当前方案) |
---|---|---|
事务提交前 | 可能读取到未提交的临时数据 | 不会触发转码任务 |
事务提交后 | 正常执行但阻塞主线程 | 通过事务回调保证数据可见性 |
2. 性能优化对比
// 同步方式(伪代码)
@Transactional
public void uploadFile() {
saveToDB(); // 耗时1ms
transcodeFile(); // 耗时30s → 接口响应延迟30s+
}
// 异步方式(当前实现)
@Transactional
public void uploadFile() {
saveToDB(); // 耗时1ms
registerAsyncTask(); // 耗时0.5ms → 接口响应延迟≈1.5ms
}
三、具体技术实现分析
1. 事务同步器工作原理
2. 关键组件说明
组件 | 作用 |
---|---|
TransactionSynchronization | Spring事务同步器接口,提供事务生命周期钩子 |
afterCommit() | 事务成功提交后的回调入口点 |
@Async代理机制 | 通过CGLIB生成代理类,实现线程池任务提交 |
四、设计优势体现
1. 数据一致性保障
// 文件信息插入语句(事务内)
this.fileInfoMapper.insert(fileInfo);
// 转码任务执行时(事务已提交)
FileInfo dbFile = fileInfoMapper.selectByFileId(fileId); // 确保读取到已提交数据
2. 异常处理机制
场景 | 处理方式 |
---|---|
事务回滚 | afterCommit()不会执行,转码任务不会被触发 |
转码失败 | 通过finally块更新状态为TRANSFER_FAIL,记录详细日志 |
服务重启 | 通过TRANSFER状态的任务扫描机制进行补偿 |
五、扩展设计思考
1. 消息队列增强方案
// 事务提交后发送MQ消息
@Transactional
public void uploadFile() {
saveToDB();
TransactionSynchronizationManager.registerSynchronization(() -> {
rocketMQTemplate.sendAsync("transcode_topic", fileId);
});
}
// 消费者端
@RocketMQMessageListener(topic = "transcode_topic")
public class TranscodeConsumer {
public void process(String fileId) {
fileService.transferFile(fileId);
}
}
2. 分布式事务保障
该设计通过事务同步机制与异步处理的结合,实现了:
- 高响应速度:主流程耗时从秒级降到毫秒级
- 数据强一致:通过事务边界控制保证可见性
- 资源隔离:转码任务使用独立线程池
- 系统可扩展:可平滑升级为分布式任务系统
视频切割处理流程解析
核心处理步骤
1. 准备切片目录
File tsFolder = new File(videoFilePath.substring(0, videoFilePath.lastIndexOf(".")));
tsFolder.mkdirs(); // 创建与视频同名的目录用于存放切片
示例:
/data/video.mp4
→ /data/video/
目录
2. 视频转TS格式(关键步骤)
final String CMD_TRANSFER_2TS =
"ffmpeg -y -i %s -vcodec copy -acodec copy -vbsf h264_mp4toannexb %s";
参数说明:
参数 | 作用 |
---|---|
-vcodec copy | 视频流直接复制(无重编码) |
-acodec copy | 音频流直接复制 |
-vbsf h264_mp4toannexb | 将MP4封装转为TS支持的格式 |
执行效果:
生成临时TS文件:/data/video/index.ts
3. 生成HLS切片(核心处理)
final String CMD_CUT_TS =
"ffmpeg -i %s -c copy -map 0 -f segment -segment_list %s -segment_time 30 %s/%s_%%4d.ts";
关键参数:
参数 | 值 | 作用 |
---|---|---|
-f segment | 启用分段模式 | |
-segment_time | 30 | 每段30秒 |
-segment_list | x.m3u8 | 生成索引文件 |
%4d.ts | 切片命名格式(0001.ts) |
输出结构:
/data/video/
├── playlist.m3u8 # HLS主索引文件
├── file_0001.ts # 第一段切片
├── file_0002.ts # 第二段切片
└── ... # 其他切片
4. 清理临时文件
new File(tsPath).delete(); // 删除中间文件index.ts
技术原理图解
典型HLS文件结构
playlist.m3u8 示例
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:30
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:30.000000,
file_0001.ts
#EXTINF:28.000000,
file_0002.ts
#EXT-X-ENDLIST
代码
/**
* 视频文件切割处理方法(HLS协议)
* @param fileId 文件唯一标识(用于生成切片文件名)
* @param videoFilePath 原始视频文件完整路径
*/
private void cutFile4Video(String fileId, String videoFilePath) {
// 1. 创建切片存储目录(与视频文件同名目录)
// 示例:/data/video.mp4 -> /data/video/
File tsFolder = new File(videoFilePath.substring(0, videoFilePath.lastIndexOf(".")));
if (!tsFolder.exists()) {
tsFolder.mkdirs(); // 递归创建多级目录
}
// 2. 定义FFmpeg命令模板
// 命令1:将MP4转换为TS格式(不重新编码)
final String CMD_TRANSFER_2TS = "ffmpeg -y -i %s -vcodec copy -acodec copy -vbsf h264_mp4toannexb %s";
// 命令2:将TS文件切片并生成m3u8索引
final String CMD_CUT_TS = "ffmpeg -i %s -c copy -map 0 -f segment -segment_list %s -segment_time 30 %s/%s_%%4d.ts";
// 3. 生成中间TS文件路径
// 示例:/data/video/index.ts
String tsPath = tsFolder + "/" + Constants.TS_NAME;
// 4. 执行格式转换(MP4->TS)
// 参数说明:
// -y 覆盖输出文件
// -vcodec copy 视频流直接复制
// -acodec copy 音频流直接复制
// -vbsf h264_mp4toannexb 转换视频比特流格式
String cmd = String.format(CMD_TRANSFER_2TS, videoFilePath, tsPath);
ProcessUtils.executeCommand(cmd, false); // 执行命令行
// 5. 执行切片操作并生成m3u8索引
// 参数说明:
// -c copy 音视频流都不重新编码
// -map 0 处理所有数据流
// -f segment 启用分段模式
// -segment_time 30 每段30秒
// -segment_list 生成m3u8索引文件路径
// %04d.ts 生成形如0001.ts的切片文件
cmd = String.format(
CMD_CUT_TS,
tsPath,
tsFolder.getPath() + "/" + Constants.M3U8_NAME, // m3u8文件路径
tsFolder.getPath(), // 切片输出目录
fileId // 切片文件名前缀
);
ProcessUtils.executeCommand(cmd, false);
// 6. 清理临时文件(中间TS文件)
// 示例:删除/data/video/index.ts
new File(tsPath).delete();
}