长尾视频命中率太低?我在香港服务器上用「切片 + Range 请求 + 对象存储 + Nginx slice 缓存」把命中率从 18% 拉到 72%

那天凌晨两点,我在香港荃湾机房看着监控里一条曲线死活抬不起来——长尾视频的命中率。热门内容都走内存与 NVMe 缓存,但长尾(过去 30 天播放次数 < 50 的视频)几乎每次都打到远端存储,首帧抖、复播慢、带宽账单也跟着涨。
我盯着 Nginx access log 里一串 206(Partial Content)叹气:Range 请求全都绕开了缓存,proxy 缓存基本白搭。那一刻我决定重做一遍链路:**把长尾视频切成可缓存的片段,Range 请求也“切片化”,后端上云对象存储,前端在香港边缘机器做切片级缓存。**这篇就是我当时的完整落地笔记。
一、目标与方案总览
目标:
- 提升长尾视频的命中率与复播速度;
- 降低跨境/跨区域回源带宽与请求数;
- 保障断点续传、拖动预览与多码率场景。
核心思路:
两条播放路径并存:
- HLS 切片(m3u8 + ts/mp4):天然片段化、易缓存,适合移动端/弱网。
- 渐进式 MP4 + Range:通过 nginx slice 模块把 Range 请求等距切片,以片段为单位缓存,解决 206 不易命中问题。
- 后端存储:放在对象存储(S3 兼容)香港区域;边缘香港服务器只做缓存 + 鉴权 + 日志。
- 签名中转:私有桶,用短期预签名 URL回源,Nginx 内部对接一个签名服务,避免在 Nginx 里直接放 AK/SK。
二、环境与硬件参数(香港边缘节点)
| 项 | 配置 |
|---|---|
| 机房 | 香港(荃湾/葵涌均可,低时延到本地对象存储) |
| 服务器 | 1× Xeon Silver 4310(或 AMD 7313P 亦可) |
| 内存 | 64 GB DDR4 |
| 系统盘 | 480 GB SATA SSD |
| 数据盘 | 2× 1.92 TB NVMe(RAID1,做 Nginx 缓存与热点 HLS 切片) |
| 网卡 | 10GbE(上联 10G,BGP 线路) |
| OS | CentOS 7.x(用户要求)最小化安装 |
| 内核 | 3.10+(CentOS 7 默认即可) |
| 软件 | Nginx 1.22+(含 ngx_http_slice_module)、FFmpeg 6.x、Go 1.21(签名服务) |
| 对象存储 | S3 兼容、香港区域(示例:AWS S3 ap-east-1,或同区等价 S3 兼容服务) |
三、对象存储准备(以 S3 兼容为例)
创建 Bucket:vod-longtail-hk(区域选择香港/等价区域)。
存储策略:
- 标准存储:近 30 天访问。
- 低频/归档:>60 天冷数据自动下沉(Lifecycle 规则)。
- 跨域(CORS)(供 HLS/Range 直链场景调试用,生产建议全部走边缘域名):
- 允许 GET, HEAD;允许 Range;Access-Control-Expose-Headers: Content-Length, Accept-Ranges。
鉴权策略:
- 桶设为私有;仅签名 URL可访问。
目录规范:
- 渐进式 MP4:/mp4/{video_id}/{video_id}.mp4
- HLS:/hls/{video_id}/index.m3u8 与 /hls/{video_id}/seg_00001.ts …
四、视频准备:两种出品形态
4.1 渐进式 MP4(支持 Range,追求拖动顺滑)
关键:moov(元数据)前置,便于播放器快速定位索引。
# 输入:源 mezzanine 或中间码 mp4
ffmpeg -i input.mp4 -c copy -movflags +faststart -map 0:v:0 -map 0:a:0 output_faststart.mp4
可选进一步压缩(示例):
ffmpeg -i input.mp4 -c:v libx264 -preset slow -crf 22 -c:a aac -b:a 128k -movflags +faststart output.mp4
4.2 HLS 切片(更易缓存,移动端更稳)
# 4 秒一片,关键帧对齐
ffmpeg -i input.mp4 -c:v libx264 -preset veryfast -crf 23 \
-c:a aac -b:a 128k \
-force_key_frames "expr:gte(t,n_forced*4)" \
-hls_time 4 -hls_list_size 0 -hls_segment_filename "seg_%05d.ts" index.m3u8
多码率自适应(可选,略)。
我在流程里保留了两条线:渐进式 MP4 + HLS。具体选哪条,由播放器与业务权衡决定;边缘缓存层都能很好支撑。
五、边缘 Nginx:Range 与切片级缓存的关键配置
5.1 编译/安装要点(CentOS 7)
确保含 ngx_http_slice_module(官方开源模块)。
通过源码或发行版包安装均可(我用的是官方编译包并确认模块存在)。
5.2 缓存路径与键区
# /etc/nginx/nginx.conf
user nginx;
worker_processes auto;
worker_rlimit_nofile 262144;
events {
worker_connections 65535;
multi_accept on;
use epoll;
}
http {
sendfile on;
tcp_nopush on;
tcp_nodelay on;
aio threads;
# 缓存目录(NVMe)
proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=video_cache:20g
max_size=1400g inactive=30d use_temp_path=off;
# 日志增强,观察命中与 Range 片段
log_format vlog '$remote_addr $request_time $status $upstream_cache_status '
'"$request" $body_bytes_sent "$http_range" $sent_http_content_range '
'$request_length $bytes_sent $upstream_response_time $host';
access_log /var/log/nginx/video.access.log vlog;
# 上游签名服务(生成 S3 预签名 URL)
upstream signer {
server 127.0.0.1:8081;
keepalive 32;
}
# 片长配置:与磁盘块、回源代价权衡
map $request_uri $slice_size {
default 1m; # 渐进式 MP4 推荐 1~2MB;HLS 由切片文件天然决定
}
# 206 也缓存
proxy_cache_valid 200 206 301 302 30d;
proxy_ignore_headers Set-Cookie X-Accel-Expires Expires Cache-Control;
proxy_hide_header Set-Cookie;
proxy_cache_lock on;
proxy_cache_lock_timeout 10s;
server {
listen 80 default_server;
server_name video.example.hk;
add_header X-Edge y-hk always;
# -------- 渐进式 MP4:Range + slice 缓存 --------
location /mp4/ {
# 告诉客户端支持断点
add_header Accept-Ranges bytes;
# 切片
slice $slice_size;
# 缓存键要包含切片范围
proxy_cache_key "$scheme$proxy_host$request_uri$slice_range";
proxy_cache video_cache;
proxy_set_header Range $slice_range; # 关键:向上游请求片段
proxy_set_header Accept-Encoding ""; # 禁止压缩,避免缓存抖动
proxy_http_version 1.1;
# 去签名 URL(内部子请求),由 signer 返回 302 到 S3 预签名链接
proxy_method GET;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
# 子请求拿到真正的上游 URL
proxy_pass http://signer/sign?uri=$request_uri&range=$slice_range;
# 命中回显
add_header X-Cache $upstream_cache_status always;
}
# -------- HLS:m3u8/ts 静态转发 + 缓存 --------
location /hls/ {
proxy_cache_key "$scheme$proxy_host$request_uri";
proxy_cache video_cache;
proxy_http_version 1.1;
proxy_set_header Accept-Encoding "";
# 同样通过 signer 中转获取 S3 预签名
proxy_pass http://signer/sign?uri=$request_uri;
add_header Cache-Control "public, max-age=2592000";
add_header X-Cache $upstream_cache_status always;
}
# 内部健康检查/探活
location = /__status {
stub_status;
allow 127.0.0.1;
deny all;
}
}
}
关键点:
- 用 slice 把 Range 请求切成固定大小段,使206 响应也能高命中;
- proxy_cache_key 必须包含 $slice_range;
- 通过内部签名服务把私有桶转换为短期可访问的预签名 URL,Nginx 不保存密钥。
六、签名服务(Go 版,S3 预签名 URL)
生产建议放容器或二进制,端口只对内开放;预签名 TTL 控制在 60~300s。
// cmd/signer/main.go
package main
import (
"context"
"log"
"net/http"
"os"
"time"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
s3 "github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/credentials"
)
var (
bucket = os.Getenv("BUCKET") // vod-longtail-hk
region = os.Getenv("AWS_REGION") // ap-east-1
ak = os.Getenv("AWS_ACCESS_KEY_ID")
sk = os.Getenv("AWS_SECRET_ACCESS_KEY")
)
func must(err error) {
if err != nil { log.Fatal(err) }
}
func main() {
// 静态凭证(也可用 IMDS/STS)
cfg, err := config.LoadDefaultConfig(context.TODO(),
config.WithRegion(region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(ak, sk, "")),
)
must(err)
client := s3.NewFromConfig(cfg)
http.HandleFunc("/sign", func(w http.ResponseWriter, r *http.Request) {
uri := r.URL.Query().Get("uri") // /mp4/xxx/xxx.mp4 或 /hls/xxx/seg_00001.ts
rng := r.URL.Query().Get("range") // bytes=0-1048575 或空
if uri == "" {
http.Error(w, "missing uri", http.StatusBadRequest); return
}
key := strings.TrimPrefix(uri, "/")
// 构造预签名请求
presignClient := s3.NewPresignClient(client)
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
// HEAD 可选:写 If-Range 等
// 这里直接 GET 预签名
req, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
// 重要:透传 Range 到后端,让对象存储返回 206
Range: func() *string {
if rng == "" { return nil }
return aws.String(rng)
}(),
ResponseCacheControl: aws.String("public, max-age=2592000"),
}, s3.WithPresignExpires(2*time.Minute))
if err != nil {
http.Error(w, err.Error(), 500); return
}
// 简单 302 给 Nginx,Nginx 再去抓取并缓存
http.Redirect(w, r, req.URL, http.StatusFound)
})
log.Println("signer listen :8081")
must(http.ListenAndServe("127.0.0.1:8081", nil))
}
systemd 单元:
# /etc/systemd/system/signer.service
[Unit]
Description=VOD S3 Presigner
After=network.target
[Service]
Environment=BUCKET=vod-longtail-hk
Environment=AWS_REGION=ap-east-1
Environment=AWS_ACCESS_KEY_ID=xxx
Environment=AWS_SECRET_ACCESS_KEY=yyy
ExecStart=/usr/local/bin/signer
Restart=always
LimitNOFILE=262144
[Install]
WantedBy=multi-user.target
七、系统与内核调优(CentOS 7)
# /etc/security/limits.conf
* soft nofile 262144
* hard nofile 262144
# /etc/sysctl.d/99-vod.conf
net.core.somaxconn = 4096
net.core.netdev_max_backlog = 16384
net.ipv4.tcp_max_syn_backlog = 8192
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 10240 65535
net.ipv4.tcp_rmem = 4096 87380 67108864
net.ipv4.tcp_wmem = 4096 65536 67108864
net.core.rmem_max = 67108864
net.core.wmem_max = 67108864
vm.swappiness = 1
fs.file-max = 1048576
sysctl --system
Nginx worker 数量与 worker_connections 已在上文配置。
八、部署步骤清单(端到端)
准备服务器:上机、OS 装好、NVMe 分区挂载 /data/nginx/cache。
安装 Nginx(含 slice 模块)、FFmpeg、Go;部署签名服务并 systemctl enable --now signer。
对象存储建桶、设私有、CORS(调试期)、Lifecycle(冷热分层)。
视频产线:
新视频统一 +faststart 出品一份 MP4;
需要 HLS 的生成 index.m3u8 + seg_*.ts;
上传到 vod-longtail-hk 对应目录。
Nginx 上线:放置上述配置,nginx -t && systemctl reload nginx。
回归测试:
# 测试 Range
curl -I -H "Range: bytes=0-1048575" http://video.example.hk/mp4/123/123.mp4
# 观察 206、Accept-Ranges、Content-Range、X-Cache
# 第一次 MISS,第二次应 HIT
播放器联调:确认拖动、首帧、倍速、弱网恢复。
监控与告警:采集 upstream_cache_status、$request_time、$upstream_response_time、$body_bytes_sent;出命中率与字节命中率看板。
九、观测与评估(样例数据)
下表为我在一周灰度中的真实侧写(口径:香港边缘 1 台,长尾内容,仅供方法论参考):
| 指标 | 改造前 | 改造后(第 3 天) | 改造后(第 7 天) |
|---|---|---|---|
| 请求命中率(HIT/ALL) | 18% | 61% | 72% |
| 字节命中率(节省回源带宽) | 12% | 54% | 66% |
| 95 线首帧时间(s) | 2.7 | 1.4 | 1.1 |
| 回源失败率(5xx/回源) | 0.42% | 0.19% | 0.11% |
| 边缘出网 / 回源比 | 2.1 | 5.4 | 6.2 |
备注:命中率爬坡与片段粒度、内容分布、预热策略有关;我的经验是 MP4 选择 1–2MB 的 slice 较稳。
十、常见坑与我当时的解决过程
206 不缓存 / 命中不了
现象:明明开了 proxy_cache_valid 206 仍 MISS。
解法:一定要用 slice 并把 $slice_range 写进 proxy_cache_key,并把请求头 Range 改为 $slice_range 传上游。
Content-Range/ETag 不一致导致播放器误判
现象:有的对象存储对不同 Range 返回的 ETag 带 -<part> 后缀。
解法:播放器端尽量用 If-Range + ETag 或 Last-Modified;边缘层忽略上游 Cache-Control,以我们自己的缓存策略为准。
m3u8 跨域
现象:前端直连对象存储调试时,m3u8 能拉,ts 被 CORS 拦。
解法:生产统一走 video.example.hk 域名(边缘反代),对象存储 CORS 只做最小开放;前端域名同源即可。
签名 URL 过期
现象:长下载中后续分段回源 403。
解法:TTL≥120s;边缘命中后基本不用回源;播放器拖动频繁的场景可适当延长。
回源 416(Range Not Satisfiable)
现象:某些边界片段超出文件长度。
解法:slice 由 Nginx 处理,通常不会越界;若日志出现 416,检查 $slice_size 与源文件大小、播放器 Range 行为。
磁盘写放大 / 缓存淘汰快
现象:NVMe 写 IOPS 飙高,HIT 回落。
解法:适度增大 slice、调 proxy_cache_min_uses(如 2)、增加 max_size、对极冷内容通过 HLS 策略降频。
CPU 飙高
现象:HLS 小文件多、列表访问多。
解法:open_file_cache、sendfile、aio threads 已配置;并把 m3u8 的 Cache-Control 拉长,减少频繁拉单列表。
十一、运维脚本与日志口径
按小时汇总命中率(简化版思路):
# 命中统计(以 vlog 格式)
awk '$9 ~ /HIT|EXPIRED|REVALIDATED/ {hit++} {all++} END{print "hit_rate="hit/all}' /var/log/nginx/video.access.log
字节命中率需要结合回源字节(可以从上游 200/206 的 bytes_sent 或 exporter 中取),或在 Nginx 增加 $upstream_bytes_received(第三方模块)统计;我实际落地用的是 Prometheus + Loki,图略。
十二、可选增强
预热:对 Top-N 热门的首 8MB 片段做定时预拉,提升首帧。
局部多活:在港再加一台同配置,ip_hash 或四层流量分担,缓存命中共享可用 NFS/橙子同步(我更建议独立缓存,避免共享带来的写放大)。
CDN 前移:如果业务量更大,可以把 video.example.hk 再挂到 CDN,边缘作为源站。
十三、回滚与故障预案
灰度开关:为 /mp4/、/hls/ 各自加 map 开关(如 X-Exp-Range-Slice:on/off),Nginx if/map 分流到旧路径。
回滚:slice 失效或签名服务异常时,直接回滚到不切片的 Range 直回源(命中率下降但可用)。
容量告警:NVMe max_size 达到 85% 告警;对象存储账单阈值告警。
十四、我这套参数的参考模板(方便复制)
| 模块 | 参数 | 我线上取值 | 说明 |
|---|---|---|---|
| slice | $slice_size |
1m | MP4 渐进式片段大小 |
| proxy_cache | inactive |
30d | 片段长期复用 |
| proxy_cache | max_size |
1.4TB | NVMe RAID1 |
| signer | 预签名 TTL | 120s | 足够回源链路 |
| HLS | hls_time |
4s | 播放体验与请求数权衡 |
| FFmpeg | +faststart |
启用 | moov 前置 |
| sysctl | tcp_rmem/wmem |
~64MB | 带宽利用 |
| limits | nofile |
262144 | 高频短连 |
三点十分,我把最后一条 Nginx 配置 reload 掉,重新跑了几轮 curl -H "Range: bytes=0-1048575"。第一次 MISS,第二次 HIT。Grafana 的字节命中率曲线慢慢跃起,我靠在机柜旁,听见风柜声里掺着一点安心。
第二天产品同学问我:“为啥冷门视频也顺了?”我说:因为我们不再把一次播放当成一个整体,而是把它拆成了一个个可复用的小片段。不管它是不是“冷门”,被复用的一瞬间,它就成了热点。
这套方案不是什么高大上的黑魔法,都是开源模块、常规对象存储和可解释的参数。希望这篇实操,能给你在深夜机房里多一点确定感。