“2 小时视频,午高峰集中上传,用户网络还差,最后算下来单 GB 存储和转码成本低得离谱?”——如果你接手过视频类产品的后端,大概一眼就能看出这不是在吐槽,而是在描述一个真实的系统设计难题。
超长视频和普通短视频不一样。短视频可以一把梭,直接传完整文件,后端收到后立刻转码、切片、上 CDN。但长达一两小时的视频,原始文件动辄几个 GB,如果还按“整文件上传”的老思路设计,午高峰两小时就能把应用服务器的内存和带宽打满,更不用说移动网络环境下动不动断流、超时,最后前端报错、用户重传、后端堆积,全链路一起雪崩。
这篇文章不聊业务层面的“单价高低”,而是把这个问题拆成一套技术方案:超长视频为什么要分片上传,午高峰流量如何削峰,弱网环境怎样做断点续传与重试,以及最容易被忽视的“成本单价”问题——单位视频从上传到可播放,到底消耗多少存储、转码和流量,如何用工程手段把单价降下来。读完你可以得到一套完整的设计思路、可落地的关键代码,以及生产环境里的避坑清单。
1. 这篇文章真正要解决的问题
先说明白一个判断:超长视频上传最核心的技术矛盾,不是“文件大”,而是“体验 + 成本 + 稳定性”三者的平衡。
所谓超长视频,通常指时长在 1 小时以上,常见于网课回放、在线培训、监控录像、体育赛事录像、直播录制存档等场景。文件大小一般从 1GB 到 20GB 不等。这种视频文件如果直接用 FormData 提交到后端,会出现几个非常现实的问题:
- 内存压力:应用服务器接收完整文件时,Node.js、Java 的 Multipart 解析器需要把文件写入临时目录或内存缓冲,大文件会拖垮容器。
- 网络中断代价高:用户上传到 80% 断网,整文件重传,成功率极低。
- 高峰期无法控制:午高峰两小时集中上传,带宽和存储写入会瞬间打满。
- 成本不可见:从上传到转码、切片、CDN 分发,每一环节都有成本,不做架构设计则单价不可控。
本文要解决的,就是这几个问题。我会基于主流的“客户端分片 + 后端合并 + 异步转码 + 存储回调”方案,讲清楚架构选择、代码实现和成本控制。适合正在设计文件上传系统的后端工程师,也适合考虑自建视频处理平台的架构师。如果你只是做简单短视频上传,没必要用全套重型方案,但分片的思路同样有参考价值。
1.1 为什么不建议用“整文件上传”方案
先给出结论:整文件上传只适合小文件。把几十 MB 的文件整包上传,简单直接,几乎没有架构成本。但文件一旦进入 GB 级别,尤其是超长视频,整文件上传的缺陷会被急剧放大。
第一,HTTP 连接的有效期有限。移动网络下,一个连接可能几十秒就被运营商断开,整文件上传很难在一个连接内完成。第二,服务端需要配置超大的请求体限制,这等于把整个后端暴露在内存溢出风险中。第三,失败重启的成本是 O(文件大小)。用户下一次断点,就要重新传整个大文件,产品体验上几乎不可接受。
所以更稳妥的方案,是把大文件切成若干分片(Chunk),每个分片单独上传,服务端按顺序合并。这样单个请求的事务体量小、失败代价低、并发可控,还可以利用分片做秒传和断点续传。
2. 超长视频上传的核心概念与适用场景
2.1 分片上传
分片上传是把一个大文件切分成多个二进制块,逐块上传到服务端临时存储区,等所有分片上传完成后,由服务端按顺序合并成完整文件。
分片大小的选择需要权衡。分片太小,请求数太多,网络开销大;分片太大,单请求失败概率变高。一般经验值是 4MB 到 10MB。对超长视频,我习惯用 8MB 或 10MB 分片,这取决于用户的平均网络状况。从实际工程看,4MB 更适合弱网,8MB 更均衡。
2.2 秒传
秒传不是真正的“秒传”,而是通过文件内容唯一标识(通常取文件 MD5,或取前若干字节 + 大小组合的指纹)到服务端查询。如果服务端已经存在相同内容的文件,就跳过上传,直接返回成功。
超长视频场景下,秒传的价值很高。比如同一场直播的回放视频,多个用户重复上传源文件;或者运维把同一份监控录像反复归档。通过内容指纹去重,可以节省大量带宽和存储成本。
2.3 断点续传
断点续传依赖分片级状态记录。客户端每次上传前,先向服务端查询该文件已上传了哪些分片,然后只上传缺失的分片。这样即使中途断网、应用被杀,重试时不需要从头开始。
实现上需要三个要素:全局文件标识(fileId)、分片编号(chunkIndex)、已上传分片列表。Redis 是常见的选择,因为需要高频读写分片状态,并且天然支持过期时间。
2.4 异步转码
上传完成后,不能立刻认为“大功告成”。超长视频需要转码成适合浏览器播放的 HLS 流,生成多码率版本,再切片成 .ts 片段,最后上传到对象存储/CDN。这个过程耗时可能超过原视频时长,必须放在异步任务队列中执行,而不能在 HTTP 请求里同步等待。
| 概念 | 解决什么问题 | 常见实现 |
|---|---|---|
| 分片上传 | 大文件传输不稳定 | 前端 File.slice + 后端分片合并 |
| 秒传 | 重复文件浪费带宽 | 文件 MD5 + 存储侧指纹去重 |
| 断点续传 | 网络中断后整包重传 | Redis 记录分片偏移 |
| 异步转码 | 超长视频处理耗时 | 消息队列 + 转码 Worker |
3. 整体架构设计
超长视频上传系统的架构,可以用一条主线描述:客户端切片 → 预签名直传对象存储 → 服务端确认合并 → 异步转码 → 产物回调 → 可播放。
之所以不把分片先传到应用服务器再转存,是为了避免应用服务器成为带宽瓶颈。比较推荐的做法是用对象存储的预签名 URL,让客户端分片直传 MinIO/OSS/S3,应用服务器只负责校验状态和触发合并。
模块划分大致如下:
- 网关层:负责限流、鉴权、路由。
- 上传接入服务:负责创建上传任务、查询分片状态、合并分片。
- 临时存储层:存放已上传的分片,可用 Redis 记录分片元数据,用对象存储存放分片实体。
- 转码任务队列:上传完成后投递任务,实现削峰填谷。
- 转码 Worker:消费任务,调用 FFmpeg 转码、切片、上传产物。
- 元数据服务:维护视频状态、分片信息、播放地址。
这个架构的特点是:应用服务器不直接传输文件实体,只处理“谁在传、传到哪、传完没有”的控制流。数据流走对象存储,控制流走应用服务,两者解耦,高峰并发时也不容易打爆后端。
4. 环境准备与依赖选型
为了让示例具备可操作性,这里用一套通用技术栈说明。版本不写死,以你实际项目为准,核心思路不变。
后端推荐:
- Java 17 或 JDK 11+,Spring Boot 3 或 2.7。
- MinIO 作为对象存储,兼容 S3 API,本地部署方便。
- Redis 6+ 保存分片元数据。
- RabbitMQ 或 Kafka 做异步任务队列。
- FFmpeg 5.x 做转码切片。
如果你熟悉 Node.js,也可以用 Node 实现同样的逻辑,但本文代码以 Java + Spring Boot 为例,思路同样适用于 Go、Python。
客户端推荐:
- Web 端用 Vue/React,通过 axios 或者 XMLHttpRequest 实现分片并发。
- 微信小程序端用 wx.uploadFile,每次上传一个分片。
- 移动端 App 用 OkHttp 或原生 HttpClient。
我们以 Web 端为例,因为逻辑最清晰。
4.1 启动本地依赖服务
本地开发时,需要先启动 Redis 和 MinIO。MinIO 可以使用 Docker 快速启动:
docker run -d \ --name minio \ -p 9000:9000 \ -p 9001:9001 \ -e "MINIO_ROOT_USER=minioadmin" \ -e "MINIO_ROOT_PASSWORD=minioadmin" \ minio/minio server /data --console-address ":9001"启动后,打开http://localhost:9001,用minioadmin/minioadmin登录,创建video-upload桶。权限建议默认私有,后续通过预签名 URL 临时授权。
Redis 的启动方式取决于本机安装方式,通常可直接执行:
redis-server4.2 创建 Spring Boot 项目
可以直接使用 Spring Initializr 创建项目,引入以下依赖:
spring-boot-starter-web spring-boot-starter-data-redis minio amqp(如果使用 RabbitMQ)另外需要手动引入 MinIO SDK,Maven 坐标如下:
<dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.5.7</version> </dependency>版本以 Maven 仓库最新稳定版为准,不建议使用过旧版本。
5. 完整示例代码实现
下面采用一个精简但完整的分片上传示例,路径和类名保持简单,方便直接对照。核心流程是:
- 前端调用
POST /upload/init创建上传任务,返回uploadId。 - 前端把文件切片,逐个调用
POST /upload/chunk上传分片,服务端存到临时目录。 - 上传完成后调用
POST /upload/complete,服务端合并分片成完整文件,并投递转码任务。
5.1 后端:初始化上传任务
文件路径:src/main/java/com/example/upload/controller/UploadController.java
package com.example.upload.controller; import com.example.upload.service.UploadService; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequestMapping("/upload") public class UploadController { private final UploadService uploadService; public UploadController(UploadService uploadService) { this.uploadService = uploadService; } @PostMapping("/init") public Map<String, Object> init(@RequestBody InitRequest req) { return uploadService.initUpload(req.getFileName(), req.getFileSize(), req.getChunkSize()); } } class InitRequest { private String fileName; private long fileSize; private long chunkSize; public String getFileName() { return fileName; } public void setFileName(String fileName) { this.fileName = fileName; } public long getFileSize() { return fileSize; } public void setFileSize(long fileSize) { this.fileSize = fileSize; } public long getChunkSize() { return chunkSize; } public void setChunkSize(long chunkSize) { this.chunkSize = chunkSize; } }对应 Service 实现:
package com.example.upload.service; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.util.Map; import java.util.UUID; import java.util.concurrent.TimeUnit; @Service public class UploadService { private final StringRedisTemplate redisTemplate; public UploadService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } public Map<String, Object> initUpload(String fileName, long fileSize, long chunkSize) { String uploadId = UUID.randomUUID().toString().replace("-", ""); long chunkCount = (fileSize + chunkSize - 1) / chunkSize; // 保存上传元数据,24 小时后如果没有完成上传则自动清理 redisTemplate.opsForHash().put(uploadId, "fileName", fileName); redisTemplate.opsForHash().put(uploadId, "fileSize", String.valueOf(fileSize)); redisTemplate.opsForHash().put(uploadId, "chunkCount", String.valueOf(chunkCount)); redisTemplate.opsForHash().put(uploadId, "chunkSize", String.valueOf(chunkSize)); redisTemplate.expire(uploadId, 24, TimeUnit.HOURS); return Map.of( "uploadId", uploadId, "chunkCount", chunkCount, "chunkSize", chunkSize ); } }初始化阶段的核心作用是拿到一个全局唯一的uploadId,并把文件的基本信息记录下来。后续所有分片上传请求都要携带这个uploadId,服务端才能知道分片归属于哪个文件。
5.2 后端:上传分片与查询已上传状态
文件路径:src/main/java/com/example/upload/service/UploadService.java(追加方法)
public Map<String, Object> uploadChunk(String uploadId, int chunkIndex, MultipartFile file) throws IOException { String finalDir = "/tmp/video-chunks/" + uploadId; File dir = new File(finalDir); if (!dir.exists()) { dir.mkdirs(); } File target = new File(dir, String.valueOf(chunkIndex)); file.transferTo(target); // 标记该分片已上传 redisTemplate.opsForHash().put(uploadId, "chunk:" + chunkIndex, "1"); return Map.of("success", true, "chunkIndex", chunkIndex); }Controller 中需要调整接收方式:
@PostMapping("/chunk") public Map<String, Object> uploadChunk( @RequestParam("uploadId") String uploadId, @RequestParam("chunkIndex") int chunkIndex, @RequestParam("file") MultipartFile file) throws IOException { return uploadService.uploadChunk(uploadId, chunkIndex, file); }注意,生产环境不建议把分片写到本地磁盘,这里仅为演示。生产更推荐直接写入 MinIO 的临时桶:一个分片就是一个 object,分片名为{uploadId}/{chunkIndex}。这样即使用户上传失败,分片数据落在对象存储,也不会占用后端服务器磁盘。
查询已上传分片,可以帮助前端实现断点续传:
public Map<String, Object> getUploadedChunks(String uploadId) { Map<Object, Object> entries = redisTemplate.opsForHash().entries(uploadId); List<Integer> uploaded = new ArrayList<>(); for (Map.Entry<Object, Object> entry : entries.entrySet()) { String key = entry.getKey().toString(); if (key.startsWith("chunk:")) { uploaded.add(Integer.parseInt(key.substring("chunk:".length()))); } } return Map.of("uploadedChunks", uploaded); }5.3 后端:合并分片
文件路径:src/main/java/com/example/upload/service/UploadService.java(追加方法)
public Map<String, Object> completeUpload(String uploadId) throws IOException { String finalDir = "/tmp/video-chunks/" + uploadId; File dir = new File(finalDir); File[] chunkFiles = dir.listFiles(); if (chunkFiles == null || chunkFiles.length == 0) { throw new IllegalStateException("分片不存在"); } // 按分片序号排序 Arrays.sort(chunkFiles, Comparator.comparingInt(f -> Integer.parseInt(f.getName()))); String mergedFileName = "/tmp/video-final/" + uploadId + ".mp4"; File mergedFile = new File(mergedFileName); if (!mergedFile.getParentFile().exists()) { mergedFile.getParentFile().mkdirs(); } try (FileOutputStream fos = new FileOutputStream(mergedFile)) { for (File chunk : chunkFiles) { Files.copy(chunk.toPath(), fos); } } // 合并完成后清理分片临时文件,投递转码任务 deleteRecursively(dir); redisTemplate.delete(uploadId); // 这里可以发送 MQ 消息,触发异步转码 // rabbitTemplate.convertAndSend("video.transcode", uploadId); return Map.of("success", true, "filePath", mergedFileName); } private void deleteRecursively(File dir) { if (dir == null || !dir.exists()) { return; } File[] files = dir.listFiles(); if (files != null) { for (File f : files) { if (f.isDirectory()) { deleteRecursively(f); } else { f.delete(); } } } dir.delete(); }合并的逻辑比较简单:读取所有分片,按序号顺序写入同一个输出流。但这里隐藏着一个问题:必须保证所有分片都上传完整,否则合并出来的文件是损坏的。所以在 complete 之前,需要校验实际分片数量是否等于初始化时的chunkCount。
校验逻辑:
public Map<String, Object> completeUpload(String uploadId) throws IOException { Map<Object, Object> entries = redisTemplate.opsForHash().entries(uploadId); long expectCount = Long.parseLong(entries.getOrDefault("chunkCount", "0").toString()); String finalDir = "/tmp/video-chunks/" + uploadId; File dir = new File(finalDir); File[] chunkFiles = dir.listFiles(); if (chunkFiles == null || chunkFiles.length != expectCount) { throw new IllegalStateException("分片不完整,期望 " + expectCount + ",实际 " + (chunkFiles == null ? 0 : chunkFiles.length)); } // 后续合并逻辑相同 }这个校验必须做,否则弱网场景下前端漏传一个分片,后端合并后得到的视频时长会缩短或直接损坏。
5.4 前端:分片上传核心代码
文件路径:web/upload.js
const CHUNK_SIZE = 8 * 1024 * 1024; // 8MB async function uploadLargeFile(file) { const chunkCount = Math.ceil(file.size / CHUNK_SIZE); // 1. 初始化上传任务 const initResp = await fetch('/upload/init', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: file.name, fileSize: file.size, chunkSize: CHUNK_SIZE }) }); const { uploadId, chunkCount: serverChunkCount } = await initResp.json(); // 2. 查询已上传分片,实现断点续传 const uploadedSet = new Set(); try { const statusResp = await fetch(`/upload/status?uploadId=${uploadId}`); const { uploadedChunks } = await statusResp.json(); uploadedChunks.forEach(index => uploadedSet.add(index)); } catch (e) { // 首次上传无状态,忽略 } // 3. 并发上传分片,控制并发数避免把带宽占满 const CONCURRENCY = 3; let cursor = 0; async function worker() { while (cursor < chunkCount) { const index = cursor++; if (uploadedSet.has(index)) { continue; } const start = index * CHUNK_SIZE; const end = Math.min(file.size, start + CHUNK_SIZE); const blob = file.slice(start, end); const formData = new FormData(); formData.append('uploadId', uploadId); formData.append('chunkIndex', index); formData.append('file', blob, file.name); let retry = 0; while (retry < 3) { try { await fetch('/upload/chunk', { method: 'POST', body: formData }); uploadedSet.add(index); break; } catch (e) { retry++; if (retry >= 3) { console.error(`分片 ${index} 上传失败`); throw e; } await new Promise(resolve => setTimeout(resolve, 1000 * retry)); } } } } await Promise.all(Array.from({ length: CONCURRENCY }, () => worker())); // 4. 通知后端合并 const completeResp = await fetch('/upload/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uploadId }) }); return completeResp.json(); }并发数需要控制,不是越多越好。午高峰时段,大量用户同时上传,如果每个客户端都开 10 个并发分片,网关和对象存储的请求数会暴涨。建议并发数在 3 到 5 之间,同时在网关层设置单用户上传并发限制。
6. 弱网与恶劣场景的容错策略
“恶劣天气”在视频上传场景里,转化为技术问题就是弱网、高延迟、高丢包、连接频繁中断。这种情况下,分片上传能解决“断点续传”,但还需要额外策略降低失败率。
6.1 分片级重试与指数退避
客户端对每个分片独立重试,重试间隔采用指数退避,而不是立即重试。比如第一次失败等 1 秒,第二次等 2 秒,第三次等 4 秒。避免弱网时大量重试请求叠加,反而堵死信道。
6.2 感知网络质量并动态调整并发
可以通过navigator.connectionAPI(如果浏览器支持)读取effectiveType,如果是slow-2g或2g,把并发数降为 1,减小分片大小至 2MB。分片更小,单次请求失败的成本更低。这种做法对移动端用户尤其有效。
6.3 服务端幂等性
同一个分片重复上传,服务端必须能覆盖或者忽略。上面的uploadChunk实现天然满足幂等:文件直接覆盖写入对应序号,Redis 状态置为已上传。不要让“重复上传”变成报错,否则弱网下的自动重试会反过来造成分片状态混乱。
6.4 完成态兜底扫描
当用户弱网导致最后一个分片迟迟传不过来,前端可能会反复请求 complete,但服务端因为分片不完整而拒绝合并。更好的做法是:前端定期查询已上传分片,只补传缺失的分片,避免盲目重试所有分片。代码里已经有uploadedSet跳过逻辑,这正是断点续传的价值。
7. 高峰两小时的削峰设计
午高峰两个小时内,所有用户集中上传,系统面临的是双高压力:一方面是带宽压力,另一方面是分片合并与转码的 CPU 压力。这两者有不同的削峰策略。
7.1 带宽层削峰
对象存储的预签名直传是首选。客户端拿到预签名 URL 后,直接把分片 PUT 到对象存储,应用服务器完全不经过文件数据。这样可以避免后端带宽成为瓶颈,也是云上视频系统的基本架构。
MinIO 生成预签名 URL 的示例:
import io.minio.GetPresignedObjectUrlArgs; import io.minio.MinioClient; import io.minio.http.Method; MinioClient client = MinioClient.builder() .endpoint("http://localhost:9000") .credentials("minioadmin", "minioadmin") .build(); String url = client.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.PUT) .bucket("video-upload") .object(uploadId + "/" + chunkIndex) .expiry(3600) .build());前端拿到 URL 后,直接把分片 PUT 到该 URL,而不经过你的应用服务器。
7.2 合并层削峰
如果把合并操作放在 complete 请求里同步执行,午高峰每个 complete 请求都会占用一个 Tomcat 线程去合并一个大文件,服务很容易被打死。建议把合并操作改造成异步任务:complete 接口只负责校验分片数量,然后发一条 MQ 消息,由合并 Worker 消费。这样 complete 接口的响应时间会非常短,系统吞吐量显著提高。
7.3 转码层削峰
转码是 CPU 密集型操作。一小时视频转码可能需要几分钟到几十分钟,取决于服务器配置。如果在高峰时段同时触发大量转码,CPU 会满负荷。解决办法:
- 消息队列按 RPS 限流消费。
- 转码 Worker 数量固定,不因为任务多而无限扩容。
- 设置队列积压监控,一旦积压超过阈值,自动扩容 Worker。
整体上,高峰两小时的流量可以被队列缓冲成“全天持续消化”,这就是削峰填谷的核心思想。
8. 运行结果与验证方式
以本地 Demo 为例,完成前端上传后,你可以在内存桶或临时目录看到合并后的文件。用工具直接验证文件的完整性,是最简单也最可靠的确认方式。
8.1 校验合并后视频完整性
ffprobe /tmp/video-final/{uploadId}.mp4如果输出正常显示 Duration、Stream 信息,说明合并成功。如果ffprobe直接报错,大概率是分片缺失或排序问题。
8.2 观察上传状态
对超长视频,前端可以打印每个分片的上传耗时和重试次数:
console.log(`分片 ${index} 上传成功,耗时 ${Date.now() - startTime}ms`);观察弱网环境下重试次数是否在可控范围,如果某个分片重试超过 5 次仍然失败,就应该中止会话并提示用户切换网络。
8.3 验证断点续传
可以手动模拟断网再恢复。上传到一半,断开网络 10 秒,恢复后重新执行uploadLargeFile。如果uploadedSet生效,前端不会重复上传已成功的分片,日志中会看到大量“跳过分片”的记录。
8.4 判断系统是否打满
用 JVM 监控或者top命令观察应用服务器的 CPU 和内存。如果应用服务器在峰值期间 CPU 使用率正常,而对象存储或带宽成为瓶颈,说明架构已经达到了削峰目的;反之,如果应用服务器的线程数打满、内存飙高,说明仍有大量数据流经过后端,需要检查是否走了直传路径。
9. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 合并后的视频损坏或时间短 | 分片缺失或分片顺序错误 | 对比 Redis 中的 chunkCount 与磁盘分片数 | complete 前严格校验分片数量,并按序号排序合并 |
| 上传到一半失败,前端重试后上传量巨大 | 没有实现断点续传或查询已上传分片逻辑 | 查看客户端请求中是否存在 status 接口调用 | 前端增加 status 查询,跳过已上传分片 |
| 午高峰应用服务器内存飙升 | 文件数据经过应用服务器中转 | 查看网络流量和 Tomcat 线程状态 | 改用 MinIO/OSS 预签名直传 |
| 分片上传成功但 complete 超时 | 合并操作耗时太长,同步阻塞在请求链路中 | 查看 complete 接口的耗时 | 合并操作改为异步消费 MQ 消息 |
| 转码任务积压越来越严重 | 转码 Worker 数量不足或消费速率低 | 查看队列积压数量和 Worker CPU | 限制转码并发、提升 Worker 数量、增加机器 |
| Redis 中 uploadId 丢失 | 上传超时超过 24 小时被过期清理 | 查看 Redis TTL 配置 | 延长过期时间或增加续期逻辑 |
| 弱网环境上传成功率低 | 并发过高或分片过大 | 查看前端日志中的失败分片号 | 降低并发数、减小分片大小、增加重试退避 |
排查时遵循一个原则:先看控制流,再看数据流。用户上传失败,先确认初始化接口是否返回 uploadId,再确认分片是否到达对象存储,然后确认 complete 是否触发,最后看转码任务是否被消费。按这个链路逐段定位,能快速缩小问题范围。
10. 高峰降本与工程最佳实践
回到题目里的“恶劣天气单价这么低”,在视频上传系统里真正需要追问的是:单位视频的存储和处理成本,能不能通过架构手段降下来。以下几种手段在超长视频场景下收益很高。
10.1 内容去重存储
同一个视频文件可能被多个用户重复上传。通过文件 MD5 作为唯一标识,在上传前先查重,如果已经存在,则直接引用已有的存储对象,不再分配新的存储空间。超长视频体积大,去重省下的存储成本非常可观。
具体的做法是:初始化接口中携带文件 MD5,服务端判断该 MD5 是否已存在。如果存在,直接返回一个“秒传完成”标记,前端可以跳过所有分片上传。
10.2 分级存储策略
视频平台不是所有内容都有同样的热度。直播录像、监控回放这类超长视频,播放频率可能很低,完全可以放在低频存储层,单价更低。上传完成后先放在标准存储,根据播放统计,超过一定时间没有播放量就自动沉降到低频存储或归档存储。
这个策略对“恶劣天气单价低”的问题是最直接的回应:不是所有视频都值得用最贵的存储和 CDN。
10.3 转码产物只保留必要码率
超长视频的转码成本是 O(视频时长),如果每个视频都生成 1080p、720p、480p、360p 四个码率,存储和转码成本都会很高。更合理的做法是:根据视频用途决定码率档位。网课回放一般 720p 足够,监控录像 480p 即可;只有高质量视频才需要 1080p。
10.4 建设观测体系
成本问题不只看总量,要看单量。建议在元数据中记录每个视频的存储大小、转码耗时、转码消耗 CPU 时间、CDN 流量,从业务侧按天聚合。只有把“单价”可视化,才能知道降本策略是否生效。
10.5 安全与权限提醒
涉及对象存储和文件上传,有一点必须强调:预签名 URL 是有时效性的。不要生成有效期过长的 URL,建议控制在 30 分钟到 1 小时内。同时,上传接口必须做身份鉴权和配额限制,防止恶意用户用超长视频上传接口拖垮存储。
生产环境做任何配置变更(如修改存储桶策略、调整 Redis 清理策略),都要先在测试环境验证,做好备份和回滚方案,并遵循最小权限原则。
11. 总结与后续学习方向
超长视频上传不是一个“把文件传上去就行”的简单需求。它涉及分片协议设计、弱网容错、高峰削峰、异步转码、成本控制等多个环节。本文重点讲清楚了三条主线:如何用分片上传和断点续传解决超长视频传输不稳定的问题;如何用预签名直传、异步合并、消息队列解决午高峰集中上传的系统压力;如何用内容去重、分级存储、码率策略控制单位视频的处理成本。
建议你动手从最小 Demo 开始,先跑通分片上传 + 合并的逻辑,再加入 Redis 断点续传,最后接入对象存储直传和异步转码队列。每一步都验证成功后,再逐步补充秒传、鉴权、限流、监控等生产能力。
下一步可以继续深入的方向包括:基于内容指纹的跨节点去重、分片级校验码设计、基于 MQ 的任务可靠投递与重试、以及视频质量与转码成本的自动权衡策略。如果在生产环境落地,这些内容每一样都值得单独展开。