1. 一个视频上传功能,为什么值得单独拿出来讲
做后端接口这些年,我经手过不少文件上传需求,其中最容易出问题的就是视频。图片上传几百KB,出问题大不了超时重传;视频动辄几百MB甚至几个GB,一旦设计不到位,轻则用户上传到99%失败重来,重则服务端磁盘被打满、内存被撑爆、上传接口把整个应用拖垮。所以我一直觉得,视频上传是一个值得单独拆开讲清楚的功能点,它横跨前端交互、网络传输、后端存储、异步处理四条链路,任何一环掉链子,用户体验都会直接崩掉。
这篇内容想解决的问题很具体:如何从零实现一套可用的视频文件上传功能,前端怎么选方案、后端怎么接、大文件怎么切片、断点续传怎么做、上传完之后怎么做转码和回调。适合谁看?前端同学可以重点看切片和上传进度这块,后端同学可以重点看分片合并、存储选型和异步任务,刚接触这个需求的初中级开发者可以整篇顺着读,我会尽量把每一步的“为什么这么选”讲透,而不是只丢一段代码。
先把核心结论摆出来:小文件直传、大文件切片、全流程做校验、上传与处理解耦。这十六个字基本覆盖了视频上传的设计主线。下面我会按照方案设计、核心细节、实操实现、问题排查的顺序展开,中间会穿插我自己踩过的坑和一些不太方便写在官方文档里的经验。
2. 整体方案怎么设计:先想清楚四条链路
2.1 视频上传到底难在哪
很多人第一反应是“上传不就是选个文件发个请求吗”,这个认知在图片场景基本成立,放到视频上就不行了。视频文件有三个天然属性会让上传变复杂:体积大、耗时长、易中断。体积大意味着单次请求体可能超过网关限制;耗时长意味着用户要盯着进度条等几分钟,中途关页面、切网络都很常见;易中断意味着必须有恢复机制,否则用户重传三次就流失了。
除了传输本身,视频还有第二个麻烦:上传完成不等于可用。原始视频的编码格式、分辨率、码率五花八门,直接扔给播放器很可能播不了,或者在某些设备上卡顿。所以生产环境里,视频上传之后通常还要走一遍转码、截图、元信息提取,这些操作耗时从几十秒到几分钟不等,绝对不能塞在上传请求的同步流程里。
理解这两层难点之后,方案设计的思路就清晰了:传输层要解决大文件高效稳定传上去,业务层要解决传上去之后怎么处理。这两层必须解耦,否则一个转码超时就能把上传接口拖死。
2.2 四种上传方案怎么选
实际可选的方案大致有四类,我整理成一张表,方便对照自己的场景选:
| 方案 | 适用体积 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|---|
| 表单直传 | 50MB 以内 | 实现最简单,几行代码搞定 | 无法断点续传,大文件必超时 | 后台管理系统的短视频上传 |
| Base64 内嵌 | 1MB 以内 | 和 JSON 接口天然兼容 | 体积膨胀约 33%,内存压力大 | 头像、缩略图,不适合视频 |
| 分片上传 | 无上限 | 可续传、可并发、失败成本低 | 前后端都要多做不少工作 | 用户端视频上传的标准答案 |
| 直传对象存储 | 无上限 | 不占应用服务器带宽 | 需要签名服务,权限模型要设计 | 已有对象存储的中大型项目 |
我的建议很直接:面向用户的视频上传,直接上分片方案,不要犹豫。表单直传只适合内部系统、文件小、网络稳定的场景;Base64 完全不该出现在视频场景里,光是编码解码就能把内存吃掉一截。至于直传对象存储,它其实是分片方案的一种落地形式——前端拿着后端签发的临时凭证直接和存储服务交互,应用服务器只负责签名和记录,这个后面会细讲。
分片方案的核心思路不复杂:把一个大文件切成固定大小的块,每块单独上传,全部完成后由服务端合并。切了块之后,断点续传就变成了“记录哪些块已经传成功”,重传时跳过这些块即可。并发上传也顺理成章,多个块同时发请求,整体耗时能压下来不少。
2.3 上传与处理为什么要分开
这一点我想多说几句,因为见过太多项目在这里翻车。早期的做法是上传接口里同步调转码,请求一直挂着等转码完成再返回。问题在于:转码是 CPU 密集型任务,一个请求占住一个工作线程,几个用户同时上传就能把线程池占满,后续所有请求全部排队。更糟的是网关一般有 30 秒到 60 秒的超时限制,转码稍微久一点,网关直接把连接掐了,前端收到超时,用户以为失败又重传一遍,服务端白白多干一份活。
正确做法是上传和处理彻底解耦:上传接口只负责把文件安全落到存储里,落盘成功后立刻返回一个文件标识,然后往消息队列扔一条任务消息,由独立的处理进程去消费。这样上传接口的响应时间可以控制在秒级,转码进程可以按自己的节奏扩容缩容,两边互不影响。处理完成后通过回调或者轮询把结果同步给业务侧。
这里有个细节值得注意:状态机要设计清楚。一个视频从上传到可用,至少经历“已上传待处理 → 处理中 → 处理成功 / 处理失败”几个状态。前端拿到文件标识后,要么轮询状态接口,要么等推送通知。状态字段一定不要省,否则用户上传完看到一个空白播放器,你都不知道该从哪查起。
3. 核心细节拆解:分片、校验、存储三件事
3.1 分片大小怎么定,这里面有计算
分片大小不是随便拍脑袋的数字,它直接影响上传成功率和服务器压力。我的经验公式是这样的:单片大小控制在 2MB 到 10MB 之间,同时保证总片数不超过 1000。
为什么下限是 2MB?片太小,请求数就多,每个请求都有 TCP 握手、TLS 协商、请求头开销,这些固定成本摊到很小的片上,整体效率会明显下降。为什么上限是 10MB?片太大,单次请求失败重传的成本就高,而且移动网络下大请求更容易被中断。为什么总片数要控制?因为每个分片在服务端都要有一条记录,片数过多会让合并阶段的元数据操作变得笨重。
举个例子算一下:一个 1.5GB 的视频,取 5MB 一片,总片数是 1536 片 / 5 ≈ 307 片,落在合理区间。如果取 1MB 一片,就是 1536 片,虽然也能跑,但请求数翻了好几倍,移动端体验会明显变差。反过来,一个 20MB 的短视频,如果固定 5MB 一片,只有 4 片,这没问题;但如果你的分片逻辑强制最小片数,比如少于 10 片就报错,那就要出问题了。所以更稳妥的做法是动态计算分片大小:
function calcChunkSize(fileSize) { const MIN = 2 * 1024 * 1024; const MAX = 10 * 1024 * 1024; const MAX_COUNT = 500; let size = Math.ceil(fileSize / MAX_COUNT); size = Math.max(size, MIN); size = Math.min(size, MAX); return size; }这段逻辑的含义是:先按最大片数反推一个片大小,再夹在上下限之间。这样小文件不会切得太碎,大文件也不会片数爆炸。实测下来,这个策略在几百MB到几个GB的范围内都能跑得比较稳。
注意:分片大小一旦确定,同一个文件的所有分片必须使用同一个大小,否则合并时按偏移量拼接会错位。服务端要在初始化上传时把片大小记下来,合并阶段以它为准。
3.2 文件校验:别等传完才发现传错了
视频文件大,传一遍成本很高,所以校验一定要做,而且要尽量前置。我的做法是三层校验:
第一层是前端预校验,在用户选完文件、还没开始传的时候就检查。检查项包括扩展名、MIME 类型、文件头魔数、文件大小。扩展名和 MIME 都能伪造,真正靠谱的是读文件头几个字节判断容器格式,比如 MP4 的 ftyp box、MKV 的 EBML 头。这一步能把大部分“用户选错文件”的情况挡在门外,省掉一次无效传输。
第二层是分片级校验,每个分片上传时带上该片的校验和,服务端收到后比对,不一致直接返回失败,前端重传这一片即可。这一步能防住传输过程中的比特翻转,尤其是在弱网环境下,这种情况比想象中常见。
第三层是整体校验,所有分片合并完成后,对完整文件算一次哈希,和前端初始化时上报的哈希比对。这一步开销大,因为要读一遍完整文件,但它能保证最终落盘的文件和用户选的文件完全一致。我的建议是:整体校验做成可配置开关,对一致性要求高的场景(比如版权内容、法务存证)打开,普通场景可以只做分片校验。
哈希算法选什么?MD5 够快但抗碰撞性弱,SHA-256 更稳但计算慢。实际工程里,前端算哈希本身就是个耗时操作,1GB 文件用浏览器算 SHA-256 可能要好几秒。折中方案是用SparkMD5 这类增量计算库,分块读取文件边读边算,避免一次性把整个文件读进内存。如果对安全性要求不高,MD5 完全够用;要求高的话,可以在服务端合并后再补一次 SHA-256。
提示:前端算哈希时一定要用
File.slice()分块读取,配合FileReader逐块喂给哈希库。直接把整个 File 对象读成 ArrayBuffer,1GB 文件瞬间吃掉 1GB 内存,移动端浏览器很可能直接崩掉。
3.3 存储落点:本地盘、对象存储还是两者结合
存储选择直接决定后续的运维成本,我按项目规模给个参考:
单机小项目,用本地磁盘就行,按日期分目录,比如uploads/2024/06/15/。好处是简单,坏处是扩容麻烦、多实例部署时会出现文件不一致。如果走这条路,一定要把存储路径做成配置项,将来迁移到对象存储时不至于改得满城风雨。
有一定规模的项目,建议直接上对象存储。视频这类大文件放在对象存储里,带宽、持久性、CDN 加速都是现成的,应用服务器只保留签名和元数据。前端拿着后端签发的临时凭证直传,应用服务器完全不碰文件流,这是最省心的架构。
还有一种混合模式:上传时先落到应用服务器的临时目录,合并校验通过后再异步推到对象存储,本地临时文件延迟清理。这种模式适合需要在上传阶段做一些即时处理的场景,比如边传边抽帧。代价是多一次文件搬运,需要有清理机制兜底,防止临时目录堆积。
不管选哪种,有两条原则必须守住:一是文件名绝不使用用户原始文件名,统一用服务端生成的唯一 ID,原始文件名只作为元数据存库,这样可以规避路径穿越、特殊字符、重名覆盖等一系列问题;二是存储目录要有配额和清理策略,视频文件的增长速度往往超出预期,我见过一个项目上线三个月磁盘就满了,最后查出来是失败的上传残留没人清理。
4. 实操过程:从前端切片到后端合并的完整链路
4.1 接口设计:三个接口撑起整个流程
分片上传的接口不多,核心就三个,我把字段设计列出来,可以直接照着用:
| 接口 | 方法 | 关键入参 | 返回 |
|---|---|---|---|
| 初始化上传 | POST /api/upload/init | fileName、fileSize、fileHash、chunkSize、totalChunks | uploadId、已上传分片索引列表 |
| 上传分片 | POST /api/upload/chunk | uploadId、chunkIndex、chunkHash、二进制数据 | 该分片状态 |
| 完成上传 | POST /api/upload/complete | uploadId | fileId、文件状态 |
初始化接口的返回里带已上传分片索引列表,这是断点续传的关键。前端拿到这个列表,就知道哪些片可以跳过。如果这个文件之前传过一部分(比如用户上次关页面了),列表里就会有记录,前端直接从缺失的片开始传。
这里有个设计细节值得说:uploadId 的生成时机。我的做法是先用 fileHash 去查,如果库里已经有这个哈希对应的、状态未完成的上传记录,直接复用那个 uploadId 和已传分片列表;查不到才新建。这样即使用户刷新页面重新选同一个文件,也能续上之前的进度,而不是从头再来。fileHash 在这里充当了天然的去重键,顺带还能实现秒传——如果这个哈希对应的文件已经完整上传过,初始化接口直接返回完成状态,前端连一个字节都不用传。
// 前端初始化调用示例 async function initUpload(file, fileHash) { const chunkSize = calcChunkSize(file.size); const totalChunks = Math.ceil(file.size / chunkSize); const res = await fetch('/api/upload/init', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: file.name, fileSize: file.size, fileHash, chunkSize, totalChunks }) }); return res.json(); // { uploadId, uploadedChunks: [0,1,3...] } }4.2 前端并发上传与进度控制
拿到 uploadId 和已传列表后,前端要做的是把缺失的分片排队上传。这里有几个参数要调:
并发数,我一般设 3 到 5。太低浪费带宽,太高会把浏览器连接数占满,反而拖慢其他请求,而且移动端并发过高容易触发内存问题。单片重试次数,设 3 次比较合理,超过就认为这一片彻底失败,整体任务标记失败让用户重试。重试间隔,用指数退避,第一次 1 秒,第二次 2 秒,第三次 4 秒,避免网络抖动时疯狂重试把服务端打爆。
进度计算要注意一个坑:不能用已完成的片数除以总片数,因为最后一片往往比其他片小,这样算出来的进度会偏。正确做法是累计已上传的字节数除以文件总大小:
let uploadedBytes = 0; const totalBytes = file.size; async function uploadChunk(uploadId, file, index, chunkSize) { const start = index * chunkSize; const end = Math.min(start + chunkSize, file.size); const blob = file.slice(start, end); const chunkHash = await md5Blob(blob); const form = new FormData(); form.append('uploadId', uploadId); form.append('chunkIndex', index); form.append('chunkHash', chunkHash); form.append('chunk', blob); await fetch('/api/upload/chunk', { method: 'POST', body: form }); uploadedBytes += blob.size; onProgress(uploadedBytes / totalBytes); }进度回调除了更新 UI,还有一个用途是做节流。onProgress触发频率可能很高,如果每次回调都去操作 DOM,页面会卡。用requestAnimationFrame包一层,或者按 200 毫秒节流,体验会顺滑很多。
还有一点,暂停和继续要能真正生效。实现上可以用 AbortController 取消正在进行的请求,把当前上传位置记下来,继续时从断点恢复。不要简单地停掉定时器了事,已经发出去的请求还在占资源。
4.3 后端分片接收与合并
后端这边,分片接收接口要做的事情是:校验 uploadId 合法性、校验分片索引范围、把分片写到临时目录、记录分片状态。临时目录的命名建议用uploadId隔离,每个分片一个文件,文件名就是分片索引,合并时按索引顺序读。
分片落盘有两个细节容易忽略。一是要防并发写同一个分片,虽然正常流程不会出现,但重试机制下有可能两片同时到。简单做法是用文件锁或者先写临时名再原子重命名。二是要把分片状态持久化,不要只放内存,服务重启后内存状态丢失,用户的所有进度就白费了。用一张表记录 uploadId、分片索引、分片大小、校验和、状态,简单直接。
合并阶段的逻辑:
async function mergeChunks(uploadId, totalChunks, chunkSize, targetPath) { const fd = await fs.open(targetPath, 'w'); try { for (let i = 0; i < totalChunks; i++) { const chunkPath = getChunkPath(uploadId, i); const buf = await fs.readFile(chunkPath); await fd.write(buf, 0, buf.length, i * chunkSize); } } finally { await fd.close(); } // 合并后清理临时分片 await cleanupChunks(uploadId); }合并顺序必须严格按分片索引,不能依赖文件系统的遍历顺序。这里用文件描述符按偏移量写入,比反复 append 更可控。合并完成后,立刻算整体哈希做校验,校验通过再更新数据库状态、触发后续处理任务。
一个性能提醒:合并大文件时不要一次性读所有分片到内存,上面的写法是逐片读取逐片写入,内存占用恒定。如果分片很多,逐片 IO 的耗时会累积,可以考虑用流式管道,但要注意流的顺序控制,别把并发读写搞乱了。
4.4 上传完成后的转码与回调
合并完成只是文件“在那儿了”,距离“能用”还差一步。转码流程我一般这样设计:合并成功后往消息队列投一条任务,消息体包含 fileId、源文件路径、目标规格列表。转码进程消费消息,调底层转码工具输出多个规格,比如一份高码率原画、一份中等码率、一份低码率,同时抽取首帧作为封面。全部完成后更新数据库状态,回调业务方。
规格怎么定?我的经验是按终端和网络分档,不要贪多。三档足够覆盖绝大多数场景:移动端低码率、桌面端中码率、大屏高码率。每多一档,转码时间、存储成本、CDN 回源成本都会上升,收益却很有限。
回调这块要注意幂等和重试。转码任务可能因为各种原因重跑,状态更新和回调都必须做幂等处理,否则会出现重复通知、状态错乱。消息队列的消费也要做手动确认,处理成功才 ack,失败进重试队列,超过次数进死信队列人工介入。
5. 常见问题排查:那些只有实际跑过才知道的坑
5.1 上传到 99% 失败,问题往往不在网络
这个现象我遇到过不止一次,排查下来原因五花八门,整理成速查表:
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 99% 卡住后失败 | 合并阶段超时或 OOM | 看服务端日志,检查合并是否一次性读入了完整文件 |
| 某一片反复失败 | 该分片校验和始终不一致 | 检查前后端哈希算法是否一致、是否对二进制做了转换 |
| 偶发分片 500 | 并发写同一临时文件 | 加文件锁或改用原子重命名 |
| 上传成功但播放不了 | 未转码或转码失败 | 查任务队列积压、转码进程日志 |
| 移动端上传中断 | 页面切后台被系统挂起 | 前端监听 visibilitychange,切后台时暂停并保存进度 |
其中“99% 失败”最常见的原因是合并时把整个文件读进了内存。一个 2GB 的视频,readFile一下就是 2GB 内存,容器内存限制一到就被 kill,表现出来就是合并失败。改成逐片读写或者用流式处理就能解决。
5.2 断点续传看起来能用,其实没续上
这个坑很隐蔽。表面上前端确实跳过了已传分片,但仔细一查,跳过的依据是前端自己记的 localStorage,而不是服务端返回的真实状态。用户换个浏览器、清个缓存,进度就没了。更麻烦的是,前端记录和服务端实际分片可能不一致,导致合并时缺片。
正确做法是以服务端返回的已传列表为准,前端本地记录只作为辅助展示。初始化接口每次都去查服务端的真实状态,这样不管用户从哪台设备进来,只要 fileHash 相同,都能续上。这背后要求 fileHash 的计算必须稳定——同一份文件在不同设备上算出来的哈希必须一样,所以不要往哈希输入里掺时间戳、设备标识这类变量。
5.3 大文件上传把网关打挂
这个问题和业务代码无关,纯粹是配置问题。常见的情况有:网关的请求体大小限制没放开,分片稍大就返回 413;网关超时时间太短,合并接口耗时超过阈值被掐断;反向代理的缓冲区配置不当,大请求体全部缓冲到内存再转发,直接把代理进程撑爆。
我的建议是把上传相关接口的路由单独配置,请求体限制、超时时间、缓冲策略都和普通接口分开。合并接口本身不应该耗时太久(合并是 IO 操作,几GB 也就几十秒),如果它慢到超时,说明合并实现有问题,值得回头看看是不是走了低效的写法。
5.4 几个我觉得值得提前知道的实操心得
第一条,上传目录一定要有独立的磁盘配额和监控。视频文件的增长速度非常吓人,尤其是失败残留和重复上传。我给一个项目的建议是:临时分片目录保留 24 小时,未完成的上传定时清理,磁盘使用率超过 80% 就告警。这几条加上去之后,磁盘告警基本消失了。
第二条,进度条要诚实。不要为了好看把进度做成假动画,用户不傻。我见过一个实现,进度到 100% 后还在转圈等合并,用户以为卡死就刷新重传了。正确做法是把“上传中”和“处理中”分成两个阶段展示,上传到 100% 后明确告诉用户“正在处理,请稍候”,处理完再跳转。
第三条,前端要处理“页面关闭”场景。用户传了一半关页面,这是常态,不是你程序的 bug。beforeunload里提示一下,同时把 uploadId 和 fileHash 存进 localStorage,下次进来能自动续传。这个小小的体验优化,能显著降低用户的重传率。
第四条,日志要能串起来。整个链路涉及初始化、N 次分片上传、合并、转码、回调,出问题时如果只能看到零散日志,排查起来非常痛苦。我的做法是让 uploadId 贯穿始终,所有日志都带上它,一个 grep 就能把一次上传的完整轨迹拉出来。这个习惯帮我省下的时间,远超当初加上它的成本。