☰
大文件视频上传全链路:分片续传、转码与对象存储
2026/10/29 7:33:31 网站建设 项目流程

视频上传这个需求,看着跟前几年的图片上传没什么两样——不就是选个文件、发个请求、后端存一下?真动手做起来才发现坑比想象中深得多。一个两百兆的短视频,在弱网环境下能稳定失败三次;一个两G的课程录像,直接把服务端进程的内存吃到告警;用户点了上传按钮切到后台,回来发现进度条卡在 47% 一动不动。这些问题不是某个框架的 bug,而是"视频"这个介质本身的特性——体积大、耗时长、网络状态不可控——和"一次性 HTTP 请求"这种朴素模型之间的根本矛盾。这篇内容想聊的就是把这条路走通的全过程:从浏览器里选片、预校验、切片,到服务端接收、合并、转码、落存储,每一环该怎么做、为什么这么做、哪里最容易翻车。适合已经写过基础上传、正准备接视频业务的前端和后端同学,也适合被线上大文件上传问题折腾过、想系统性梳理一遍链路的同行。

1. 为什么视频上传不能照抄图片上传的那套方案

1.1 体积、时长与网络抖动构成的三重硬约束

图片上传的典型场景是几百 KB 到几 MB,一次请求发完,失败了大不了重传一次,用户几乎无感。视频完全不是这个量级。手机随手拍一段 4K 一分钟的素材,码率按 60 Mbps 算,就是一分钟 450 MB 左右;即便是压缩过的 1080p,一分钟也在 30 到 80 MB 之间。这意味着单次请求的传输时间从"毫秒级"变成了"分钟级",而传输时间越长,中途遇到网络切换、信号衰减、浏览器标签页被挂起的概率就越高。

我习惯把这归结为三重约束。第一是体积约束,单个请求体过大,会同时压垮浏览器内存、中间网关和服务端缓冲区。第二是时长约束,一次请求持续几分钟,HTTP 连接本身就不适合长期保持,超时配置稍微保守一点就会被掐断。第三是不确定性约束,用户在移动端上传视频时,Wi-Fi 切 4G、地铁进隧道、锁屏后台挂起都是常态,你必须假设传输过程随时会中断,并且中断之后能接着来,而不是从零开始。

这三条约束决定了视频上传必须走"切片 + 可续 + 异步处理"的路子。任何试图用一个请求把几百兆文件怼上去的方案,在小文件测试环境里可能跑得通,一上真实网络就会原形毕露。

1.2 分片续传与直传对象存储:两条路线的取舍

实现路径上,主流有两种。一种是服务端中转:前端切片发给自己的业务服务,服务端收齐后合并,再转存到对象存储。另一种是前端直传:服务端只负责签发临时上传凭证和分片预签名地址,前端拿着凭证把分片直接怼到对象存储,服务端最后调一次合并接口。

维度服务端中转前端直传对象存储
服务端带宽压力高,所有流量过一遍业务机低,流量走存储侧
实现复杂度低,逻辑都在自己手里中,要处理签名、跨域、回调
可控性高,每个分片都能做校验和审计中,校验点前移到前端
小文件性能好略差,多一次签名往返
适用规模日上传量几百 GB 以内大规模、多地域

我的建议是分阶段走。业务早期、上传量不大的时候,老老实实做服务端中转,因为调试成本低、问题定位直观,出了问题看自己服务的日志就行。等到带宽成本开始肉疼,或者前端用户分布变广之后,再迁移到预签名直传。迁移的时候前端改动其实不大,主要是把上传地址从自己的域名换成签名地址,再补一个"通知服务端合并"的调用。

需要注意的是,直传模式下服务端失去了一部分中间校验能力,前端传什么就存什么。所以文件类型白名单、大小上限、时长上限这些校验,要么在前端做严格的预校验,要么在合并完成后由服务端拉取文件头做二次校验,不能因为"反正是对象存储"就跳过。

1.3 一次内存雪崩:单请求上传 2GB 文件的现场还原

说个具体的。早些年接手过一个内部培训平台,上传逻辑写得很简单:前端选文件,FormData塞进去,axios.post一把梭;后端用某个成熟的 multipart 中间件,默认配置是把整个请求体先缓冲到内存再落盘。小文件测试毫无问题,上线后有人传了一个两 G 的课程录屏,服务进程内存瞬间被打满,紧接着是 OOM,同一台机器上的其他接口一起 502。

事后复盘,问题根本不在框架,而在默认配置。多数 multipart 解析中间件在文件字段上有一个内存阈值(常见默认值 1MB 到 10MB),超过阈值会切到临时文件;但如果直接把整个 body 读进内存,或者业务代码里用了"先buffer再处理"的写法,阈值就失效了。修复方案有两层:一是把中间件改成流式落盘,边收边写;二是网关层加client_max_body_size限制,从入口就把超大请求挡掉,同时前端限制单文件大小,超过上限直接提示走分片。

这个事故给我留下的最深印象是:上传功能的正确性,不取决于正常路径跑得多顺,而取决于异常路径兜得多严。后面所有上传相关的代码,我都会先问自己一句——如果用户传了一个比预期大十倍的文件,这个链路会在哪一层先崩?

2. 前端选片与预校验:把问题拦在上传之前

2.1 用文件头魔数判断真实格式,而不是后缀名

用户把.mkv改名成.mp4传上来,后端按 mp4 去解析,转码直接报错,这类事故在内容平台里非常高频。后缀名是用户可以随意改的,真正可靠的是文件头的魔数(magic number)。视频容器格式的魔数都落在文件起始的几个字节里,前端的File对象继承自Blob,可以直接切出前 16 个字节读出来判断。

async function sniffVideoType(file) { const head = new Uint8Array(await file.slice(0, 16).arrayBuffer()); const ascii = (start, end) => String.fromCharCode(...head.slice(start, end)); // MP4 / MOV 系列:第 4~8 字节是 'ftyp' if (ascii(4, 8) === 'ftyp') { const brand = ascii(8, 12); return brand === 'qt ' ? 'video/quicktime' : 'video/mp4'; } // WebM / MKV:1A 45 DF A3 if (head[0] === 0x1a && head[1] === 0x45 && head[2] === 0xdf && head[3] === 0xa3) { return 'video/webm'; } // AVI:RIFF....AVI if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'AVI ') { return 'video/x-msvideo'; } // FLV:'FLV' if (ascii(0, 3) === 'FLV') { return 'video/x-flv'; } return 'application/octet-stream'; }

这段代码只读文件头部,不加载整个文件,开销可以忽略。判断出真实类型之后再和用户选择的扩展名做比对,不一致的直接提示"文件格式与扩展名不符",避免后续转码阶段才发现问题。注意 MP4 的ftypbox 不保证一定在偏移 4 的位置——虽然绝大多数封装器都会把它放在最前面,但理论上允许在前面插入free或widebox。稳妥的做法是在前 32 字节内搜索ftyp字符串,而不是写死偏移。

2.2 读取时长、分辨率和码率的最低成本方案

上传前的元信息校验,主要目的是拦住"看起来是视频但实际是 10 小时监控录像"这类情况。浏览器里最低成本的方案是借用一个隐藏的video元素,监听loadedmetadata事件,能直接拿到时长和分辨率,这个过程只解析容器头,不会解码全片。

function probeVideo(file) { return new Promise((resolve, reject) => { const url = URL.createObjectURL(file); const video = document.createElement('video'); video.preload = 'metadata'; video.muted = true; const timer = setTimeout(() => { URL.revokeObjectURL(url); reject(new Error('元信息读取超时')); }, 8000); video.onloadedmetadata = () => { clearTimeout(timer); const info = { duration: video.duration, // 秒 width: video.videoWidth, height: video.videoHeight, // 码率估算:文件大小 / 时长 bitrate: Math.round((file.size * 8) / video.duration), }; URL.revokeObjectURL(url); resolve(info); }; video.onerror = () => { clearTimeout(timer); URL.revokeObjectURL(url); reject(new Error('无法解析该视频')); }; video.src = url; }); }

这里有几个实操细节值得说。一是必须加超时,某些损坏文件或特殊编码会让loadedmetadata永远不触发,没有超时兜底的话,上传按钮会一直转圈。二是用完必须revokeObjectURL,否则 blob 引用不释放,连续选几个大文件就能把标签页内存吃光。三是码率只能算平均码率,文件大小除以时长,对 VBR 编码的视频来说这个值误差可能到 20%,用来做粗筛足够,别拿它做精确计算。

有了这些信息,就能在上传前做友好拦截:时长超过平台上限的直接提示"请先剪辑到 X 分钟以内";分辨率超过 4K 的提示"建议压缩后再上传";时长读不出来、码率异常的,明确告诉用户这个文件可能已损坏。用户体验上,在上传前花两秒告诉用户"不行",远比传了五分钟再报错要好。

2.3 上传前的用户预期管理

这一点常被工程同学忽略,但它是"用户觉得这个功能好不好用"的分水岭。视频上传耗时天然很长,如果不给任何反馈,用户会以为页面卡死,然后疯狂点击上传按钮,产生一堆重复任务。

我的做法是上传前先弹一个明确的预估:根据文件大小和当前探测到的网络类型,给一个"预计需要 X 分钟"的提示,并强调"上传过程中请不要关闭页面"。进度展示上也别只给一个百分比,视频这种长任务可以拆成三段——校验中、上传中(带已传/总量)、处理中(服务端转码)——每段状态明确,用户就不会焦虑。另外必须提供暂停和取消,取消时要真正调用AbortController中断请求,并且通知服务端清理已上传的分片,否则临时文件会一直堆着。

const controller = new AbortController(); // 用户点击取消 cancelBtn.onclick = () => { controller.abort(); // 通知服务端释放该 uploadId 的临时分片 fetch(`/api/upload/abort?uploadId=${uploadId}`, { method: 'POST' }); }; // 上传分片时带上 signal await fetch(`/api/upload/chunk`, { method: 'POST', body: formData, signal: controller.signal, });

abort()之后fetch会抛出一个AbortError,记得在 catch 里区分处理,别把它当成普通网络错误弹给用户。

3. 分片策略的参数怎么定:切片大小、并发数与哈希设计

3.1 分片大小不是一个随意填的数字

分片大小直接决定了三件事:单片传输的最坏耗时、失败重传的代价、以及请求数量带来的开销。片太大,弱网下单片的超时风险高,断了重传一次损失惨重;片太小,请求数量暴涨,每个请求的 TLS 复用、头部开销、服务端调度成本都会累积。

经验值通常是5 MB 到 10 MB。这个区间的来历是有讲究的:一方面,对象存储的分片上传接口普遍对"除最后一片外"的分片有最小体积限制(常见是 5 MB),小于这个值合并会失败;另一方面,在 1 Mbps 的弱网下,5 MB 大约需要 40 秒,配合合理的超时设置(比如 60 秒)还留有余地。如果平台主要面向移动端弱网用户,可以把片调到 2 MB 到 4 MB,牺牲请求数换成功率。

分片数量也要有上限意识。假设一个 10 GB 的文件用 5 MB 切片,就是 2048 片,这个数量级对服务端维护分片元信息(数据库记录或 Redis 键)是有压力的。所以我会同时设置一个最大分片数(比如 1000),超过的话动态调大分片体积:

const MIN_CHUNK = 5 * 1024 * 1024; const MAX_CHUNKS = 1000; function calcChunkSize(fileSize) { const byLimit = Math.ceil(fileSize / MAX_CHUNKS); // 向上取整到 1MB 的整数倍,便于日志和排查 const aligned = Math.ceil(byLimit / (1024 * 1024)) * 1024 * 1024; return Math.max(MIN_CHUNK, aligned); }

3.2 并发数控制在 3 到 6 之间是为什么

并发上传能显著提升带宽利用率,但并发太高会适得其反。浏览器对同一域名的并发连接数本身就有限制(HTTP/1.1 通常是 6,HTTP/2 走多路复用但服务端仍有限流),超过这个数只是把请求排进队列,反而增加了整体往返。同时移动端设备的网络栈和 CPU 在高并发下压力更大,容易出现丢包和电量飙升。

我一般默认并发 3,桌面端可以给到 5 到 6。更重要的是并发必须可动态下调:一旦检测到连续两个分片失败或超时,就把并发降到 1,退回串行模式先确认链路是不是整体出了问题,避免一次网络抖动导致十几个请求同时失败、全部进入重试队列。

async function uploadWithConcurrency(chunks, limit, uploader) { let cursor = 0; const failed = []; const worker = async () => { while (cursor < chunks.length) { const index = cursor++; try { await uploader(chunks[index]); } catch (e) { failed.push(index); } } }; const workers = Array.from({ length: Math.min(limit, chunks.length) }, worker); await Promise.all(workers); return failed; // 交给重试层处理 }

重试要带指数退避,第一次失败等 1 秒、第二次 2 秒、第三次 4 秒,最多重试 3 次。超过就标记该分片失败,整体任务暂停并提示用户网络异常,而不是无限重试把请求打到服务端雪崩。

3.3 秒传和断点续传,哈希该怎么算才不拖慢体验

秒传的前提是能唯一标识一个文件。最直接的做法是算整个文件的 SHA-256,但对一个 2 GB 文件做全量哈希,在普通笔记本上也要几十秒,用户点了上传要干等半分钟,体验是负的。

实践中更常用的是抽样哈希 + 全量哈希双轨。上传前先算一个轻量指纹:文件大小 + 前 1 MB + 中间 1 MB + 后 1 MB 的 SHA-256。这个指纹足以在同一用户的历史文件里做快速匹配,命中就秒传,未命中再进入正式上传。抽样哈希有个理论风险——构造两个大小相同、抽样段相同但内容不同的文件——在实际业务里几乎可以忽略,而且即便撞了,后续完整上传的校验和合并阶段的总哈希还能兜住。

全量哈希该怎么算?如果确实需要(比如要做完整性校验),别用FileReader一次性读,用增量哈希。主流做法是引入一个增量计算的哈希库,配合File.slice分块读、分块喂,每块算完yield一下,避免长时间占用主线程。更好的方案是把整个计算过程扔进 Web Worker,主线程完全不受影响:

// worker 内部逻辑(简化) self.onmessage = async ({ data: { file, chunkSize } }) => { const hasher = createIncrementalHasher(); for (let offset = 0; offset < file.size; offset += chunkSize) { const buf = await file.slice(offset, offset + chunkSize).arrayBuffer(); hasher.update(new Uint8Array(buf)); self.postMessage({ type: 'progress', value: (offset + chunkSize) / file.size }); } self.postMessage({ type: 'done', hash: hasher.digest() }); };

断点续传的元信息一定要存到localStorage或 IndexedDB 里,键用"抽样指纹 + 文件大小"。页��刷新、浏览器崩溃后重新选同一个文件,能直接查到服务端已收到的分片列表,从断点继续。这个细节做不做,直接决定用户在地铁里传了一半的课程视频能不能救回来。

3.4 一个能跑通的分片上传骨架

把上面的东西串起来,前端核心流程大致是这样。为了让逻辑清晰,省掉了 UI 相关的状态管理代码,只保留数据流:

async function startUpload(file) { // 1. 预校验 const realType = await sniffVideoType(file); if (!ALLOW_TYPES.includes(realType)) throw new Error('不支持的视频格式'); const meta = await probeVideo(file); if (meta.duration > MAX_DURATION) throw new Error('视频时长超出限制'); // 2. 抽样指纹 + 秒传探测 const fingerprint = await quickHash(file); const probe = await fetch('/api/upload/prepare', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fingerprint, fileName: file.name, fileSize: file.size, mime: realType, }), }).then(r => r.json()); if (probe.instant) return { videoId: probe.videoId, instant: true }; const { uploadId, uploadedChunks } = probe; const chunkSize = calcChunkSize(file.size); const total = Math.ceil(file.size / chunkSize); const done = new Set(uploadedChunks); // 3. 只上传缺失分片 const pending = []; for (let i = 0; i < total; i++) { if (!done.has(i)) pending.push(i); } const failed = await uploadWithConcurrency( pending, 3, async (index) => { const start = index * chunkSize; const blob = file.slice(start, Math.min(start + chunkSize, file.size)); const fd = new FormData(); fd.append('uploadId', uploadId); fd.append('index', String(index)); fd.append('total', String(total)); fd.append('chunk', blob); const res = await fetch('/api/upload/chunk', { method: 'POST', body: fd }); if (!res.ok) throw new Error(`chunk ${index} failed`); } ); if (failed.length) throw new Error(`有 ${failed.length} 个分片上传失败`); // 4. 通知合并 return fetch('/api/upload/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uploadId, total }), }).then(r => r.json()); }

这段代码里最值得注意的不是函数本身,而是prepare 接口的设计:把"是否需要秒传"和"已上传哪些分片"合并成一次请求,减少往返;返回的uploadId由服务端生成,前端所有后续请求都必须带上,服务端凭它做鉴权和分片归属校验,防止别人拿到分片索引就乱传。

4. 服务端接收链路:从网关限制到分片落盘

4.1 三道体积限制,少配一道都会出事

服务端接视频,最常踩的坑不是代码写错,而是某一道中间层的默认限制没改。我习惯把链路拆成三层逐一确认:

层级典型配置项默认值风险建议
反向代理client_max_body_size默认 1 MB,直接 413设为略大于单片大小,如 16 MB
应用框架multipart 单文件上限常见 1~10 MB与分片大小对齐
业务逻辑内存缓冲阈值超阈值前全在内存强制流式落盘,阈值为 0

注意第一行的思路:既然已经分片了,网关层的限制就不该按"整个视频大小"来配,而应该按"单片大小"来配,稍微留点余量。见过不少项目把client_max_body_size配成 2 GB,觉得这样"保险",实际上是把网关的内存风险敞口放大了 200 倍,反而更危险。

框架层还有一个容易忽略的点:请求超时。分片上传的单个请求虽然不大,但在弱网下可能要几十秒,如果网关的proxy_read_timeout是默认的 60 秒,刚好卡在边缘。传小文件没事,一遇到差网络就零星报 504,排查起来极其费劲。我一般会把这个值调到 120 秒以上,同时保证前端的单片超时比它短,让前端能主动放弃并重试,而不是傻等服务端断开。

4.2 分片落盘的目录结构与流式写入

分片存储的目录结构建议按uploadId分目录,分片文件名直接用序号,零填充。零填充的意义在于后续按文件名排序时能保证字典序等于数字序,省掉一次排序逻辑。

/data/upload-tmp/ └── 8f3a2c1e-...-9d2b/ # uploadId ├── meta.json # 文件大小、分片大小、总分片数、指纹 ├── 000000.part ├── 000001.part └── ...

落盘必须用流式,别用"读进内存再写文件"。以 Node.js 为例,用busboy或formidable这类流式解析器,收到file事件后立刻createWriteStream管道过去:

const busboy = Busboy({ headers: req.headers, limits: { fileSize: 16 * 1024 * 1024 } }); let uploadId, index; busboy.on('field', (name, val) => { if (name === 'uploadId') uploadId = val; if (name === 'index') index = Number(val); }); busboy.on('file', (_field, stream) => { // 路径校验:uploadId 必须是 UUID 格式,杜绝路径穿越 if (!/^[0-9a-f-]{36}$/.test(uploadId)) { stream.resume(); return; } const target = path.join(TMP_ROOT, uploadId, String(index).padStart(6, '0') + '.part'); fs.mkdirSync(path.dirname(target), { recursive: true }); const ws = fs.createWriteStream(target); stream.pipe(ws); ws.on('finish', () => { /* 记录该分片已完成 */ }); stream.on('limit', () => { /* 超限,中断并清理 */ }); });

uploadId的格式校验千万别省。用path.join拼路径时,如果uploadId是../../etc这种内容,就会写到目录之外,这是最经典的一类路径穿越问题。凡是用户可控的值参与文件路径拼接,都必须做白名单式的格式校验,而不是简单地过滤几个特殊字符。

4.3 合并分片时的顺序、校验与清理

所有分片到齐后触发合并。合并的关键是严格按序号顺序写,而不是并发追加——并发写同一个文件,顺序完全不可控,合并出来的视频会花屏甚至无法解析。

async function mergeChunks(uploadId, total) { const dir = path.join(TMP_ROOT, uploadId); const finalPath = path.join(FINAL_ROOT, `${uploadId}.mp4`); await new Promise((resolve, reject) => { const ws = fs.createWriteStream(finalPath); let i = 0; const writeNext = () => { if (i >= total) { ws.end(); return; } const part = path.join(dir, String(i).padStart(6, '0') + '.part'); const rs = fs.createReadStream(part); rs.pipe(ws, { end: false }); rs.on('end', () => { i++; writeNext(); }); rs.on('error', reject); }; ws.on('finish', resolve); ws.on('error', reject); writeNext(); }); // 合并后校验总大小 const { size } = await fs.promises.stat(finalPath); const meta = JSON.parse(await fs.promises.readFile(path.join(dir, 'meta.json'), 'utf8')); if (size !== meta.fileSize) { await fs.promises.unlink(finalPath); throw new Error(`合并后大小不符:期望 ${meta.fileSize},实际 ${size}`); } return finalPath; }

这段代码里那个"大小校验"是我加过最值的一行。线上真出现过合并后少了若干字节的情况,原因是某个分片写入时进程被重启、文件只有半截,而分片完成标记已经先落库了。校验不通过就删掉重来,比带着一个损坏的视频进入转码队列、再在上游报一个莫名其妙的解封装错误要好得多。

清理策略也要提前设计。临时分片目录是典型的"只增不减",用户传一半跑了、合并失败没重试,都会留下垃圾。我的做法是一个定时任务,每小时扫一遍临时目录,把meta.json的创建时间超过 24 小时且未完成的目录整体删掉,同时在数据库里把对应的上传记录标记为过期。别指望"用户会自己取消",不会的。

5. 转码、抽帧与存储:视频真正落地的最后一公里

5.1 原始文件直接入库,迟早要还债

很多团队初期为了省事,合并完直接把原始文件往对象存储一放,返回一个地址就完事。这个做法在小规模内网场景能跑,一旦面向公网用户,问题会集中爆发:iPhone 拍的 HEVC 编码视频,安卓浏览器在线播放可能黑屏;用户上传的 4K 60 帧,网页端<video>一播就风扇狂转;一个 500 MB 的源文件,每次播放都拉全量,流量账单会教你做人。

正确的做法是统一转码成标准输出格式。我一般输出两路:一路是 720p 的 H.264 MP4,作为通用兼容版本;另一路是 HLS 切片,多码率自适应,适合移动端和长视频。源文件本身也保留,存到归档存储里,用于后续重新转码或做质量分析,但绝不对外暴露地址。

转码参数上,几个关键项值得盯:-c:v libx264保证兼容性,-crf 23是画质和体积的平衡点,-preset medium在速度和质量之间取中,-movflags +faststart把 moov box 移到文件头——这个不加的话,网页端播放器要等整个文件下载完才能开始播,用户体验差别巨大。

ffmpeg -i input.mp4 \ -c:v libx264 -crf 23 -preset medium \ -vf "scale=-2:720" \ -c:a aac -b:a 128k \ -movflags +faststart \ output_720p.mp4

scale=-2:720里的-2表示宽度按比例计算并向上取偶数,因为 H.264 的某些 profile 要求宽高都是偶数,写-1可能算出奇数导致编码失败。这种细节不看文档真的会踩。

5.2 异步任务队列与状态机设计

转码是 CPU 密集型操作,绝对不能放在 HTTP 请求里同步做。一个 10 分钟的视频转 720p,普通服务器可能要跑几分钟,请求早超时了。标准做法是上传完成后立刻返回,把转码任务扔进队列,前端轮询或通过长连接拿状态。

状态机我建议设计成这几个明确的值,别用一堆布尔字段拼:

状态含义前端表现
uploading分片上传中显示上传进度
merging分片合并中显示"处理中"
transcoding转码排队/执行中显示"视频处理中,请稍候"
ready转码完成,可播放显示封面,可点击播放
failed任一环节失败显示失败原因和重试入口

状态流转必须单向且可审计,每次变更都记一条流水,带上时间戳和触发方(上传接口 / 队列 worker / 人工重试)。这条流水在排查"用户说传了但看不到视频"这类问题时,能省下大量猜测时间。另外记得给transcoding加超时保护,比如超过 30 分钟还没完成就标记失败并告警,避免任务卡死在队列里永远不动。

5.3 抽帧做封面:一行命令里的三个坑

封面是视频列表页的门面,必须有。用 ffmpeg 抽第一帧或指定时间点的帧都很简单:

# 抽取第 1 秒的帧作为封面 ffmpeg -ss 00:00:01 -i input.mp4 -vframes 1 -vf "scale=640:-2" -q:v 3 cover.jpg

三个坑。第一,-ss放在-i前面是快速定位,会跳到最近的关键帧,可能不准;放在-i后面是精确定位,但需要解码前面的所有帧,速度慢。对于封面这种精度要求不高的场景,放前面更合适。第二,如果视频不足 1 秒(比如几帧的动图转成的视频),-ss 00:00:01会抽不到帧,得先探测时长再动态决定抽哪一帧,或者干脆用-ss 0。第三,抽出来的封面要单独做一次压缩和尺寸规整,别直接拿原始分辨率的 JPG 当列表缩略图,一个 4K 封面图能让列表页加载慢一倍。

5.4 对象存储与访问地址的规划

存储路径的设计要考虑到后续的迁移和权限控制。我习惯用业务/年月/唯一ID/文件名这种结构,比如course/2024-06/8f3a2c1e/720p.mp4,好处是按时间前缀可以方便地做生命周期策略和批量归档,唯一 ID 保证了不同文件之间不会覆盖。

访问地址上,源文件必须私有,通过带签名的临时地址访问;播放地址走 CDN,缓存在边缘节点。签名有效期别设太长,一般 1 到 2 小时足够,配合播放器在过期前自动刷新。还有个细节:如果业务涉及付费内容,签名要做成跟用户身份绑定的,不能只签路径——否则用户把地址分享出去,别人照样能看。

6. 稳定性、幂等与可观测性:让问题在发生时就暴露

6.1 重复分片、乱序到达和并发合并的处理

真实网络下,同一个分片被重复提交是常态,原因可能是前端重试、用户刷新页面后断点续传、或者网关层重发。服务端必须保证同一个uploadId + index写多次的结果是幂等的。最简单的做法是覆盖写——先写临时文件再rename到目标路径,rename在同一文件系统内是原子操作,不会出现半个文件被后续流程读到的中间态。

const tmp = target + '.tmp.' + process.pid; const ws = fs.createWriteStream(tmp); stream.pipe(ws); ws.on('finish', async () => { await fs.promises.rename(tmp, target); // 原子替换 await markChunkDone(uploadId, index); });

并发合并的防护用一把分布式锁,键是uploadId,SET NX加过期时间。抢不到锁的请求直接返回"正在合并中",让前端按轮询处理。没有这把锁的话,两个请求同时触发合并,会往同一个最终文件里写两遍数据,结果是一个体积翻倍、完全无法播放的文件——而且这种文件在大小校验那一关是通不了的,最后表现为"上传一直失败",但日志里看不出任何异常,非常难查。

6.2 该打哪些日志和指标

上传链路的日志有一个特点:单次任务跨度长、涉及多次请求,如果每条日志都是孤立的,排查时基本没法串起来。所以第一件事是把uploadId作为贯穿全链路的相关标识,前端生成或服务端下发之后,所有日志、所有接口、所有队列任务都带上它。

关键指标我一般盯这几项:

指标计算方式异常信号
分片成功率成功分片数 / 提交分片数低于 98% 说明网络或超时配置有问题
平均分片耗时分片耗时求和 / 分片数突然抬升说明存储或带宽瓶颈
合并失败率合并失败次数 / 合并请求次数非 0 就要查大小校验和锁
秒传命中率秒传次数 / 上传任务数过低说明指纹设计或缓存有问题
转码队列堆积队列待处理数持续增长说明 worker 不够或任务卡死

其中"秒传命中率"这项比较有意思,它不直接反映故障,但能反映成本。如果业务场景是大量用户上传同一条热门素材,命中率应该很高;如果命中率长期接近 0,要么是指纹算法有问题,要么是缓存过期策略设得太激进去,值得回头看看。

6.3 一份可以直接照着查的排查清单

线上出现"视频传不上去"时,我会按这个顺序过一遍,基本能定位到层:

  1. 前端能否正常读到元信息——读不到说明文件本身有问题或格式不支持,属于预期内拒绝。
  2. prepare 接口是否返回 200——返回 4xx 看是鉴权还是参数校验,返回 5xx 查服务端日志。
  3. 分片请求的状态码分布——413 是体积限制没配对,504 是超时,401/403 是凭证过期,全是 0 或网络错误看前端网络和 CORS。
  4. 已上传分片数和总数是否对得上——对不上说明有分片静默失败,检查前端重试逻辑是否真的在重试。
  5. 合并接口的耗时和结果——耗时异常长说明磁盘 IO 瓶颈,失败则看大小校验的差值是多少。
  6. 转码队列的状态——任务是否入队、是否被 worker 领取、日志里 ffmpeg 的退出码是多少。

这份清单的价值在于顺序——从最外层往最内层查,避免一上来就去翻转码日志,结果发现压根没走到那一步。我自己踩过这个坑,为了一个"视频转码失败"的报障查了两个小时 ffmpeg 参数,最后发现是网关的 413 把最后一片挡了,合并根本没发生。

7. 几个我踩过之后才记住的细节

用时间戳做分片名是个陷阱。早期图省事,分片文件名用了上传时的时间戳,想着天然有序。结果并发上传加上时钟回拨,顺序全乱,合并后的视频时间轴错位。改成从 0 开始的序号加零填充之后,世界清净了。文件名要表达的是"逻辑顺序",不是"物理时间"。

取消上传不等于资源释放。用户点了取消,前端 abort 了请求,但服务端已经落盘的分片还在。必须有一个显式的 abort 接口,或者在 prepare 阶段返回的uploadId上挂一个短生命周期(比如 2 小时),到期自动清理。我见过一个项目因为没做这个,临时目录一年攒了几个 T。

进度条要按字节算,不要按分片数算。按分片数算的话,最后一片往往远小于标准分片,进度会在 99% 卡很久,用户以为卡死了。正确做法是把每个分片的实际字节数累加,除以文件总大小。这个小改动对体感的提升非常明显。

移动端锁屏会挂起上传。iOS 和部分安卓机在锁屏后,页面的定时器和网络请求都可能被节流甚至暂停。指望纯前端保活不现实,所以断点续传不是可选项而是必选项——用户锁屏回来,重新选同一个文件,能直接从断点继续,这个体验才立得住。

别在上传接口里做同步转码。这条前面提过,但值得再说一遍:转码是分钟级操作,任何把它塞进 HTTP 请求的做法,最终都会以超时和资源耗尽收场。上传接口的职责就一件事——把文件完整地收下来,然后立刻返回。

最后分享一个我自己一直在用的做法:在上传链路的每个关键节点(prepare、每个分片完成、合并开始、合并完成、转码开始、转码完成)都往一条流水表里插一行,字段就是uploadId、stage、timestamp、detail。这条表的写入成本极低,但排障时的价值极高——用户报障时,拿到uploadId一查这条流水,卡在哪个环节一目了然,不用再一层层翻不同服务的日志去拼时间线。这个习惯是从那次内存雪崩之后养成的,到现在还没找到比它更省事的办法。

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

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

立即咨询