个人站点的视频越来越多:产品演示、教程录屏、活动回放,甚至只是站长自己拍的几分钟素材。一开始大家通常的做法是「上传到 usr/uploads/,然后在文章里打个 <video> 标签」。这套做法在第一个 10MB 的小视频上完全没问题,但只要视频超过 100MB、或者同时有十几个人在播,问题就会集中爆发:首页从 0.3 秒变成 8 秒、晚上十点以后整站卡顿、服务器账单上的流量费比主机费还高。
这篇文章不讨论「用哪个视频平台」,而是把自建站托管视频这件事拆开:先讲清楚源头上的技术难点在哪里,再对比「源站直接托管」与「对象存储加 CDN」两条路线的真实成本,最后给出一套可以在单台服务器上落地的配置与一套迁移流程。全文以 Debian 12、Nginx 1.24、PHP 8.2 为例,涉及到对象存储的部分用通用的 S3 兼容接口描述,具体厂商换成阿里云 OSS、腾讯云 COS 或 Cloudflare R2 都一样。
一、视频为什么和图片、JS 不是一类文件
很多人以为视频只是「大一点的静态文件」,直接沿用图片的托管方式就行。实际上有三点本质差别,这三点决定了后面的所有架构选择。
第一是请求只能取到一部分。浏览器播放视频不会像下载图片那样一次性把整个文件拉下来,而是通过 HTTP Range 请求按需读取字节区间。用户拖动进度条时,浏览器会发出 Range: bytes=1048576- 这样的请求,服务器必须返回 206 Partial Content 加上正确的 Content-Range 头。如果服务器对 Range 请求懵然不知、每次返回整个 200MB 文件,那么拖动进度条就等于重新下载,体验非常糟糕。
第二是单个连接可能持续几分钟到几十分钟。一个 30 分钟的课程视频,用户在播放期间这条 HTTP 连接会一直活着,中间还不断有新的 Range 请求。这意味着并发连接数会长期停留在「在线观看人数」这个量级上,而不是像普通页面那样几百毫秒就释放。Nginx 的 worker_connections 默认 512,看似够用,但视频连接会把它们长期占住。
第三是流量成本按倍数额外放大。一个 50MB 的视频,假设每天 100 次播放,其中一半的人会拖动两次进度条,那么一天的出流量不是 5GB,而是 7.5GB 到 10GB。一个月就是 200GB 到 300GB。这在 VPS 上意味着:要么超流量被限速,要么按量付费的账单比你预期的多三倍。
二、源站直接托管的真实上限
如果你的视频总量在 2GB 以内、日播放次数在 100 次以下,源站托管是完全可行的,而且是最省事的选择。但你需要知道它的上限在哪里,否则会在某个时间点突然被卡住。
# 先算出你上个月的实际出流量,按网卡统计比按日志更准
# 网卡名用 ip -br link 查,云主机通常是 eth0 或 ens3
vnstat -i eth0 -m
# 或者用 /proc/net/dev 累计值做差分
awk '/eth0/ {print "RX", $2, "TX", $10}' /proc/net/dev源站托管的第一个硬上限是上行带宽。绝大多数 VPS 和独立服务器给的入带宽远大于出带宽,20Mbps 的出带宽意味着理论峰值 2.5MB/s。一个 50MB 的视频被一个人从头看,需要 20 秒;三个人同时看,每个人的缓冲时间就变成 60 秒,足够让用户直接关掉页面。而中国到海外机房的跨境链路抖动很大,实际速度经常只有标称值的一半。
第二个硬上限是磁盘 IO 与内存页缓存。视频读是顺序 IO,本身不算重,但一旦有几个大文件同时被读,页缓存会被这些大文件挤占光,动态页面用到的模板文件和数据库索引全部从缓存中被踢出去,于是首页开始变慢。这在内存 2GB 的小机器上非常明显,表现为「一有人看视频,整站就慢」。
第三个是备份体积。如果视频和网站在同一个目录、走同一套备份脚本,那么每天 tar 出来的包会多出几 GB,压缩时间、上传时间、异地存储费用全部翻倍。这一点经常被忽略,直到某天发现备份从两分钟变成了四十分钟,备份窗口已经挤到了业务高峰。
三、对象存储加 CDN:为什么是这类场景的标准解
视频托管的行业标准做法是把文件放进对象存储,再在前面挂一个 CDN。这不是为了「显得专业」,而是因为对象存储和 CDN 分别解决了源站的两个硬上限。
对象存储解决的是磁盘与带宽的解耦。文件不再占源站的磁盘,读取流量也不再消耗源站的出带宽,源站只负责生成签名 URL 或做 302 跳转。CDN 解决的是距离与回源。视频被缓存在离用户最近的边缘节点上,第二次播放同一段视频时,源站完全不出流量。
但有一个事实必须先说清楚:视频并不总是在 CDN 上命中的。播放量低的视频可能每天只被点一次,每个边缘节点的缓存都会被逐出,导致大量回源。所以真正的成本结论是:一个视频的 CDN 命中率取决于它的播放频次,冷门视频的成本几乎等于「回源流量加存储费」,热门视频才会显著便宜。这也意味着访客主要来自一两个省份时,单节点 CDN 就够了,没必要买全地域。
下面是我在几个自建站上用的成本估算方法。先按「月播放次数 × 视频平均大小 × 1.5(拖动进度条与重复播放的放大系数)」得到总出流量,再看 CDN 的回源比例。缓存有效期设 7 天、且视频内容不常变的话,热门视频的回源比例可以压到 5% 以下;冷门视频可能高达 60%。两类视频分开统计才看得清账。
四、给源站 Nginx 加上正确的 Range 与缓存头
即使你把视频迁到了对象存储,源站上往往还留着一部分小视频(比如几 MB 的产品演示)。这部分文件需要正确的响应头,否则浏览器拖动进度条会出问题。Nginx 默认就支持 Range,但有几个地方要确认。
location /videos/ {
root /home/wwwroot/www.example.com;
add_header Accept-Ranges bytes;
add_header Cache-Control "public, max-age=604800";
expires 7d;
limit_rate_after 5m;
limit_rate 1m;
}逐条解释。第一行 add_header Accept-Ranges bytes; 不是必须的,因为 Nginx 对静态文件默认就会带这个头,但如果你通过 proxy_pass 转发到后端,就必须手工补上,否则浏览器会认为服务器不支持分段请求,转而一次性下载全部内容。这一点在自建下载站和视频站上是新手最常踩的坑。
第二行的缓存头要给足。视频内容不会变(如果变了就应该换文件名),所以用长缓存加不可变资源的方式最安全。如果视频文件可能被覆盖更新,那就给一个较短的过期时间,或者在文件名里加版本号。
limit_rate_after 5m 与 limit_rate 1m 的组合是「先快后慢」策略:前 5MB 全速传输保证起播快,之后限速到 1MB/s。这既保证了体验,又控制了单连接的带宽占用。注意 limit_rate 是单连接限速,不是总带宽限制,如果要做总量控制还需要 limit_conn。
还有一个容易漏掉的点:如果站点启用了 gzip,视频文件会被尝试压缩一遍。video/mp4 本身已经是压缩格式,再压缩没有任何收益,只是白烧 CPU 并让响应头的 Content-Length 失效(变成 chunked 传输),导致 Accept-Ranges 失效。所以视频目录一定要从 gzip 里排除:
gzip off;
# 或者在 http 级写
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml image/svg+xml;
# gzip_types 的默认值里本就不含 video/mp4,但很多主题模板会写 gzip_types *;如果某个主题模板里写了 gzip_types *;,那就一定会把视频也压缩。改成上面的白名单写法,把视频、图片、压缩包全部排除掉。
配置改完必须验证 206 响应是否正常:
curl -s -o /dev/null -D - -H "Range: bytes=0-1023" https://cdn.example.com/videos/demo.mp4
# 期望看到 HTTP/1.1 206 Partial Content 与 Content-Range: bytes 0-1023/12345678五、迁移流程:先把路径摸清,再搬家
把一个已经在跑的站点上的视频迁到对象存储,最大的风险不是迁移本身,而是迁移之后哪里还在引用老路径。文章正文里的 <video src="...">、封面图、附件下载链接、甚至 RSS 和 sitemap 里都可能有旧的直链。
第一步是盘点。在数据库里把包含视频路径的记录全部取出来,搞清楚有多少条、分布在哪些表。以 Typecho 为例,正文在 typecho_contents.text,附件在 typecho_metas 与 typecho_relationships 里没有实际路径,所以只需要扫正文:
mysql -uroot -p zz1984 -e "SELECT cid, title FROM typecho_contents WHERE text LIKE '%/videos/%' AND type='post'"第二步是把文件按「热度」分成两批。从 Nginx 访问日志里按 URL 聚合出播放次数,取前 20% 的作为热门视频,先迁这一批——它们的收益最大、回源最少。剩下的冷门视频可以观察一段时间再决定:要么一起迁,要么干脆下线。
# 统计每个视频的请求次数与出流量,字段位置以你的 log_format 为准
awk '$7 ~ /^\/videos\// {c[$7]++; b[$7]+=$10} END {for (u in c) print c[u], b[u], u}' /var/log/nginx/access.log | sort -rn | head -30第三步是上传与切换。上传到对象存储后,先通过对象存储的域名或 CDN 域名验证一次可播放、可拖动,然后才批量改数据库里的链接。改链接这一步一定要做成可逆的:先把原始正文备份到一个临时表,再执行替换,出问题可以一条 SQL 还原。
第四步是确认源站不再出流量。在 Nginx 配置里给 /videos/ 目录加一条访问日志,观察一天。如果还有请求进来,说明有漏网的引用没改干净,逐个排查。这一步很多人跳过,结果迁移完一个月后才发现源站的磁盘还是满的。
六、签名 URL 与防盗链:别让存储桶变成公共资源
对象存储最容易犯的错误是「桶权限设成公共读写」。一旦如此,任何人拿到 URL 就能直接拉取,你的存储流量会在某个深夜被不明脚本刷爆。正确的做法是桶保持私有,通过后端生成带过期时间的签名 URL。
# PHP 8 生成 S3 兼容签名 URL 的核心步骤(以 HMAC-SHA256 为例)
$expires = time() + 3600; # 一小时后失效
$stringToSign = "GET" . str_repeat(chr(10), 3) . $expires . chr(10) . "/" . $bucket . "/" . $key;
$signature = base64_encode(hash_hmac('sha1', $stringToSign, $secret, true));
$url = "https://" . $endpoint . "/" . $key . "?Expires=" . $expires . "&Signature=" . urlencode($signature);过期时间不宜太长。视频播放过程中浏览器会发多次 Range 请求,如果签名只有 60 秒有效期,长视频播到一半会 403。一般给 1 到 2 小时比较合适。同时要注意:签名 URL 会被浏览器缓存和分享,所以它本身不是严格的防盗链手段,只是防止「永久公开」。真正的防盗链还是依赖 Referer 白名单或者短时效配合频率限制。
如果使用带 CDN 的对象存储,CDN 侧一般有「URL 鉴权」功能,支持时间戳与密钥拼接。这个方案比在后端生成签名更省服务器 CPU,但要注意 CDN 的鉴权密钥必须和源站一致,且时钟偏差不能超过允许范围,否则全部请求会 403。
七、四个真实踩过的坑
第一个坑:迁移后视频能播但不能拖动。九成的原因是 CDN 前面的那一层改写了响应头,丢掉了 Accept-Ranges,或者把 206 改写成了 200。排查方法是在浏览器开发者工具的网络面板里看单个 Range 请求的状态码,如果是 200 就直接去查 CDN 的「回源头部改写」配置。
第二个坑:对象存储的 CORS 没配,字幕文件全部加载失败。视频本身用 <video> 播放不涉及跨域,但 <track> 字幕、以及用 JavaScript 读取视频元数据(比如获取时长做缩略图)都是跨域请求,需要对象存储的 CORS 规则允许你的站点域名。这个错误在控制台里只报一条含糊的「无法加载轨道」,很难一眼看出原因。
第三个坑:把视频放进 uploads 又被 sitemap 收录。有些 sitemap 生成插件会把附件也写进 XML,结果搜索引擎去爬这些几百 MB 的文件,每天白烧几个 GB 的流量。解决办法是在 robots.txt 里排除视频目录,或者在 sitemap 生成规则里过滤掉非 HTML 的扩展名。
第四个坑:迁完之后忘记清理源站残留。文件已经迁走,但源站目录里的旧文件还在,磁盘一直没有释放。切换完成后要确认一次「目录里的文件数与数据库里的引用数是否一致」,然后清理残留。
八、决策清单:什么规模该选哪条路
把上面的内容压成一张判断表,方便你在三分钟内做出决定。
- 视频总量小于 2GB、日播放小于 100 次、访客集中在同一地区:源站托管加 Nginx Range 与限速就够了,不要提前上对象存储。
- 视频总量 2GB 到 50GB、日播放几百次:对象存储加单节点 CDN,这是性价比最高的区间。
- 视频总量超过 50GB 或日播放上千次:必须上 CDN 并且做分片与多码率,单文件直传的体验和成本都会失控。
- 视频是核心内容(比如课程站):再额外做一层转码与 HLS 分片,用
ffmpeg生成多个码率,让移动端和小带宽用户能自适应切换。
最后提醒一句:不论选哪条路,都要给视频目录单独做一套监控。至少监控三个数字——每天的出流量、命中率、以及 4xx 与 5xx 的比例。前两个数字决定账单,第三个数字决定体验。这三个数字在 Nginx 日志里都能算出来,一条 awk 就够,不需要额外的分析平台。
九、小结
视频托管的本质不是「存文件」,而是把大流量的读取从源站挪走,同时保证分段请求和缓存语义正确。源站托管适合小规模,但一定要配好 Range 头、gzip 排除和单连接限速;规模上去之后,对象存储加 CDN 是唯一能把带宽成本压下来的路径,代价是要接受签名 URL、CORS 与缓存策略这一套新复杂度。迁移时最容易出事的不是搬文件,而是搬完之后还散落在各处的旧链接,所以盘点和验收这两步不能省。把这三个数字(出流量、命中率、错误率)盯住,剩下的就是按播放热度分批推进,边迁边看。