返回功能模块设计实现
功能模块设计实现 / 发布 2025-02-26 12:27 / 更新 2026-06-03 00:00

视频切片上传

基于当前项目实现,记录视频从前端切片上传、服务端合并入库、秒传断点续传,到提交后异步转码进入审核的完整链路。

文件上传MinIO视频处理

为什么要做切片上传

普通文件上传在小图片、小附件场景里没什么问题,但一旦换成视频,问题就会集中暴露:文件体积大、上传时间长、网络中断概率高,失败后如果只能从头再传,用户体验会非常差。

这个项目里的视频上传最终拆成了两段:

  • 文件服务负责原始文件上传:分片检查、分片落盘、合并、上传 MinIO、保存上传记录。
  • 社区服务负责视频业务状态:保存草稿、提交审核、转码回调、审核发布。

这样拆开后,上传链路只关心“文件能不能可靠拿到一个正式地址”,视频业务链路再基于这个地址继续处理标题、封面、标签、审核和转码。

整体设计

切片上传的核心目标不是把文件切开这么简单,而是让上传过程具备三个能力:

  1. 秒传:同一个 MD5 的文件已经上传过时,直接复用历史文件地址。
  2. 断点续传:只上传服务端缺失的分片,避免中断后从头开始。
  3. 可清理:用户取消上传或合并成功后,及时删除本地分片临时目录。

当前实现里,前端需要先计算完整文件 MD5,并按固定分片大小得到 total。服务端用 md5 作为一次完整上传任务的唯一标识,所有分片都会落到同一个临时目录下:

draft.file.chunk-dir/{md5}/{index}.part

合并成功后,正式文件会上传到 MinIO,并保存到 file_upload_record 表。这个表后续有两个作用:上传检查时用于秒传,视频转码时用于根据原始视频地址反查对象名称和本地源文件缓存。

上传流程

flowchart TD A[用户选择视频] --> B[前端计算完整文件 MD5 和分片总数] B --> C[查询服务端上传状态] C --> D{是否已经上传完成} D -- 是 --> E[直接拿历史 url 作为 videoUrl] D -- 否 --> F[获取已上传分片 uploadedIndexes] F --> G[只上传缺失的分片] G --> H[请求服务端合并分片] H --> I{是否有 missingIndexes} I -- 有 --> J[补传缺失分片] J --> H I -- 无 --> K[合并完整文件并上传 MinIO] K --> L[保存 file_upload_record 和 Redis 缓存] L --> M[删除本地分片目录] E --> N[保存视频草稿] M --> N

前端实际调用接口时,大概是这个顺序:

  1. GET /draft-file/file/upload-chunk/check?md5=...&total=...
  2. POST /draft-file/file/upload-chunk 上传缺失分片,表单字段是 chunkmd5indextotal
  3. POST /draft-file/file/merge-chunks 合并分片,参数是 md5totalsuffix
  4. 如果用户取消上传,调用 DELETE /draft-file/file/delete?md5=... 删除本地临时分片。

这里的网关路径会转发到文件服务内的 /file/... 接口。对于前端来说,只需要按网关路径调用即可。

服务端如何保存和合并分片

分片上传接口本身不做复杂逻辑,它只负责保存当前分片:

{chunk-dir}/{md5}/{index}.part

如果同一个 index 重复上传,后上传的分片会覆盖旧文件。这个特性反而方便前端做失败重试:某个分片传坏了,重新传同一个索引即可。

合并接口才是整个上传链路的关键。它会先扫描分片目录,确认 0total - 1 的分片都存在。如果缺少分片,就返回 missingIndexes,让前端补传后再合并。

合并成功后的服务端处理顺序是:

  1. 按索引顺序读取所有 .part 文件。
  2. 生成一个完整临时文件。
  3. 上传完整文件到 MinIO。
  4. 把完整源文件转存到 draft.file.source-dir
  5. 写入或更新 file_upload_record
  6. 写入 Redis 秒传缓存,默认缓存 7 天。
  7. 删除本地分片目录。

MinIO 对象路径当前按日期和 MD5 生成:

file/yyyyMMdd/{md5}.{suffix}

这个命名方式的好处是同一个文件天然稳定,后续通过 MD5 查到的记录也能直接复用。

秒传、断点续传和取消上传

秒传

上传前的检查接口会先查 Redis:

file:upload:md5:{md5}

Redis 没命中时再查 file_upload_record。只要找到 status = 1 的完成记录,就说明这个文件已经上传过,服务端会直接返回历史文件地址,前端可以跳过分片上传和合并。

断点续传

如果没有完成记录,服务端会扫描本地分片目录,把已经存在的 .part 文件下标返回给前端。前端拿到 uploadedIndexes 后,只上传缺失分片。

比如总共有 5 个分片,服务端返回:

[0, 1, 3]

那前端只需要继续上传 24。上传完成后再调用合并接口。

取消上传

用户取消上传时,前端只要把 md5 传给删除接口。后端会删除:

draft.file.chunk-dir/{md5}

删除后这个文件的本地分片状态就清空了。如果用户之后重新上传,需要重新提交所有分片,除非已经有完成记录能命中秒传。

上传后的视频发布与转码

文件上传完成后,前端拿到的是一个原始视频地址。它还不是最终用户播放时的多清晰度视频,而是视频业务的输入。

接下来的业务链路在社区服务里完成:

  1. 前端调用 /draft-community/video/draft 保存视频草稿,把上传结果里的 url 填到 videoUrl
  2. 用户点击提交审核后,调用 /draft-community/video/{id}/submit
  3. 社区服务校验视频归属和状态,把视频状态改为 TRANSCODING
  4. 社区服务通过 Feign 调用文件服务提交转码任务。
  5. 文件服务异步执行 FFmpeg 转码,生成 360P、480P、720P、1080P 和缩略图。
  6. 文件服务把转码结果回调给社区服务。
  7. 社区服务写入多清晰度地址,转码成功进入 REVIEWING,失败进入 TRANSCODE_FAILED

完整时序如下:

sequenceDiagram participant C as 前端 participant F as draft-file participant M as MinIO participant DB as DB/Redis participant V as draft-community participant T as VideoTranscodeService C->>F: 查询上传状态 F->>DB: 查询秒传记录 F-->>C: 返回 finished 或 uploadedIndexes loop 上传缺失分片 C->>F: 上传 chunk、md5、index F->>F: 保存 index.part end C->>F: 请求合并分片 F->>F: 校验完整性并合并文件 F->>M: 上传原始视频 F->>DB: 保存 file_upload_record 和缓存 F-->>C: 返回原始视频 url C->>V: 保存视频草稿 V-->>C: 返回 videoId C->>V: 提交审核 V->>F: 提交转码任务 F->>T: 异步执行 FFmpeg 转码 T->>M: 上传多清晰度视频和缩略图 T->>V: 回调转码结果 V->>DB: 更新视频状态和播放地址

视频状态流转也比较清晰:

stateDiagram-v2 [*] --> DRAFT: 保存草稿 DRAFT --> TRANSCODING: 提交审核 REJECTED --> TRANSCODING: 修改后重新提交 TRANSCODE_FAILED --> TRANSCODING: 修改后重新提交 TRANSCODING --> REVIEWING: 转码成功 TRANSCODING --> TRANSCODE_FAILED: 转码失败 REVIEWING --> PUBLISHED: 审核通过 REVIEWING --> REJECTED: 审核驳回

转码时为什么要保留完整源文件

分片合并后,服务端不仅会把文件上传到 MinIO,还会把完整源文件保存到本地 source-dir。这个设计是为了转码复用。

视频提交审核后,转码服务会优先根据 videoUrl 查询 file_upload_record,拿到 MinIO 的对象名称。然后它会先找本地源文件缓存,如果存在就直接使用;本地没有,再从 MinIO 下载;如果连对象名称都解析不到,最后才按原始 URL 下载。

这样做有两个好处:

  • 同一个源文件重复转码时,不需要每次都从 MinIO 下载。
  • 本地源文件目录和 MinIO 对象路径保持一致,排查问题时更容易定位。

完整源文件不会永久保存。项目里有定时清理任务,每 3 天扫描一次 source-dir,删除超过 3 天没有被使用的源文件,并清理空目录。

踩坑与注意点

MD5 必须是完整文件的 MD5

秒传、断点续传、分片目录隔离、上传记录复用都依赖这个 MD5。如果前端传的是分片 MD5,整个链路都会失效。

分片索引从 0 开始

当前合并逻辑会从 0 遍历到 total - 1。前端如果从 1 开始编号,服务端会认为 0.part 缺失,合并必然失败。

分片大小不能超过后端 multipart 限制

draft-file 当前开发配置里单个文件和请求大小限制是 70MB。前端分片大小要小于这个限制,否则单片上传就会失败。

多实例部署要注意分片目录

当前分片先落本地目录。如果文件服务部署多个实例,第一次上传的分片和后续合并请求可能落到不同机器,合并时就会提示分片缺失。生产环境要么使用共享存储,要么通过网关或负载均衡保证同一个 MD5 路由到同一个实例。

合并成功后才能保存视频草稿

草稿里的 videoUrl 应该使用合并接口返回的正式地址,而不是本地分片地址或临时地址。否则后续转码服务无法稳定回源。

转码失败不等于上传失败

上传成功只代表原始视频已经进入 MinIO,并写入上传记录。转码是提交审核后的异步链路,如果 FFmpeg 路径、源文件下载、水印资源或编码兼容性出问题,视频会进入 TRANSCODE_FAILED,但原始上传记录仍然存在。

总结

这套视频切片上传链路把“文件可靠上传”和“视频业务处理”拆开了:文件服务负责把大文件稳定地变成一个可访问的原始视频地址,社区服务负责围绕这个地址做草稿、提交、转码、审核和发布。

从实现效果看,MD5 秒传、分片目录、缺失分片返回、MinIO 入库、完整源文件缓存这几个点串起来后,已经能覆盖大部分视频上传场景。后续如果要继续增强,可以考虑并发分片上传、分片级校验、上传进度持久化、多实例共享分片目录,以及转码任务的重试和补偿机制。