
那天凌晨 1:40,运营在群里扔了一句:“#129876 那条视频炸了,国内卡、海外也卡。”
我盯着 Grafana 上那条“边缘回源带宽”曲线——像火箭发射一样直冲 34 Gbps;同时,Nginx 的 upstream_response_time 被 206 Range 请求拉得锯齿分明。热点视频只有 18 秒,却被拆成了几十个小分段反复拉。
我当机立断,把“多层缓存”这件事,从我脑子里的架构图搬到真实的机房里:浏览器 → 多 CDN → 区域中台缓存(香港/新加坡/东京) → 源站缓存 → 对象存储/NVMe。目标只有一个:尽量让每一层都能兜住请求,把“回源”变成最后的保底,而不是常态。
下面是我完整的部署与优化过程,细到可以直接照做;我也把踩坑与现场解法如实记录,免得你在凌晨复刻我的抓狂。
一、整体拓扑与分层思路
分层(由外到内)
- L0 浏览器缓存:合理 Cache-Control、immutable、stale-while-revalidate;HLS/DASH 清单短 TTL、分片长 TTL。
- L1 多 CDN:国内(阿里云/腾讯云/网宿等),海外(Cloudflare/Akamai/Fastly 等),基于调度/GeoDNS/EDNS ECS 分流。
- L2 区域中台缓存(香港主、东京/新加坡辅):Varnish/ATS 集群,collapsed forwarding、grace、Range 合并、跨 POP 预热。
- L3 源站(OpenResty/Nginx):proxy_cache + cache_lock、Key 规范化、HLS 索引 vs 分片差异化 TTL、签名鉴权、带宽整形。
- L4 数据层:本地 NVMe(ZFS/EXT4 noatime)热集 + S3 兼容对象存储(阿里云 OSS/Wasabi/Backblaze B2 等)冷集,生命周期管理与后台回灌。
请求特性
- HLS/DASH 小文件多、并发高、206 Range 常见。
- 热点集中在 Top-N(典型 1% 内容产生 80% 请求)。
- 国内与海外链路抖动差异大(国内跨境回源要么 CN2/GIA,要么走合规 CDN 回源专线)。
二、硬件与网络建议(我在香港的真实选型)
| 层级 | 硬件建议 | 关键参数 | 选择理由 |
|---|---|---|---|
| L2 中台缓存(HK 主) | AMD EPYC 7443P 单路,128GB RAM,2×U.2 NVMe 3.84TB(ZFS mirror),10GbE(可上 25GbE) | IOPS 80w+,顺序读 6–7GB/s | Varnish/ATS 极度吃 IO 与内存,镜像保障可用性,10G 起步 |
| L2 辅助(SG/TYO) | 同规格缩容至 64GB + 1×NVMe | - | 跨区兜底与预热,加速亚太各区首包 |
| L3 源站 | EPYC 7302P, 64–128GB, 2×NVMe(RAID1) + 系统盘 SSD | - | OpenResty + FFmpeg/打包进程,磁盘独立 |
| 网络 | 运营商混线 + CN2/GIA 回国优化,HKIX 直连 | BGP/静态都可 | 国内访问抖动小、海外首包更稳 |
| 存储冷层 | OSS/B2/Wasabi 任一 | 多区域冗余 | 降本与扩容弹性 |
经验:NVMe 加散热片;fio --filename=/cache/nuster.test --size=10G --rw=randread --bs=4k 跑下稳态 IOPS,确保不会过热降频。
三、Ubuntu 基线优化(22.04 LTS 示例)
# 1) 基础系统
apt update && apt -y install build-essential git htop iotop jq curl unzip chrony \
nginx openssl ffmpeg
# 2) 时钟同步(边缘缓存对 TTL/刷新敏感)
systemctl enable --now chrony
# 3) 内核网络栈(BBR + FQ)
cat >> /etc/sysctl.d/99-tuning.conf <<'EOF'
net.core.default_qdisc=fq
net.ipv4.tcp_congestion_control=bbr
net.core.somaxconn=65535
net.ipv4.ip_local_port_range=1024 65000
net.ipv4.tcp_max_syn_backlog=262144
net.ipv4.tcp_tw_reuse=1
net.ipv4.tcp_fin_timeout=15
net.core.rmem_default=1048576
net.core.wmem_default=1048576
net.core.rmem_max=16777216
net.core.wmem_max=16777216
fs.file-max=1048576
EOF
sysctl --system
# 4) 文件句柄
cat >> /etc/security/limits.conf <<'EOF'
* soft nofile 1048576
* hard nofile 1048576
EOF
四、视频打包与存储布局
ABR 梯度(示例)
240p@300k, 360p@600k, 480p@1000k, 720p@2000k, 1080p@3500k(根据你的人群与码率预算调整;国内 4G 常见 480/720,海外 Wi-Fi/5G 1080 更常见)
HLS 打包示例(fMP4 优先,便于 DASH 复用):
INPUT=source.mp4
OUT=/data/hls/vid_129876
mkdir -p "$OUT"
ffmpeg -y -i "$INPUT" \
-filter_complex "\
[0:v]split=5[v1][v2][v3][v4][v5]; \
[v1]scale=-2:426[v240]; \
[v2]scale=-2:640[v360]; \
[v3]scale=-2:854[v480]; \
[v4]scale=-2:1280[v720]; \
[v5]scale=-2:1920[v1080]" \
-map [v240] -map a:0 -c:v h264 -b:v 300k -c:a aac -b:a 64k \
-map [v360] -map a:0 -c:v h264 -b:v 600k -c:a aac -b:a 96k \
-map [v480] -map a:0 -c:v h264 -b:v 1000k -c:a aac -b:a 96k \
-map [v720] -map a:0 -c:v h264 -b:v 2000k -c:a aac -b:a 128k \
-map [v1080] -map a:0 -c:v h264 -b:v 3500k -c:a aac -b:a 128k \
-f hls -hls_time 4 -hls_playlist_type vod \
-hls_segment_type fmp4 -hls_flags independent_segments+append_list+omit_endlist \
-var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 v:3,a:3 v:4,a:4" \
-master_pl_name "index.m3u8" \
-hls_segment_filename "$OUT/v%v/seg_%06d.m4s" \
"$OUT/v%v/stream.m3u8"
目录规范(便于 cache key 与签名参数稳定):
/hls/129/129876/ # 三级分桶
index.m3u8
v240/stream.m3u8
v240/seg_000001.m4s
...
v1080/stream.m3u8
小结:
- 清单(m3u8)TTL 短(例如 30–60s),因为可能追加片段或修正;
- 分片(m4s/ts)TTL 长(7–30 天 + immutable),几乎不变。
五、L0 浏览器缓存:响应头与 206
Nginx(OpenResty)按路径下发头部:
map $uri $hls_cache_control {
default "public, max-age=60, stale-while-revalidate=60";
~*\.m3u8$ "public, max-age=30, stale-while-revalidate=60";
~*\.m4s$ "public, max-age=2592000, immutable, stale-while-revalidate=600";
}
# 对 Range/206 也允许缓存(后面层级处理),浏览器自然命中有限,但能减少重复请求
add_header Cache-Control $hls_cache_control;
add_header Accept-Ranges bytes;
Tips:Safari 对 HLS 细节更敏感,#EXT-X-TARGETDURATION、#EXT-X-PROGRAM-DATE-TIME 等要规范;清单短 TTL 能掩盖片段追加时的边缘差异。
六、L1 多 CDN:国内/海外分流与回源
做法要点
- 两个 CNAME:v.example.com(海外)→ Cloudflare/Akamai,v-cn.example.com(国内)→ 阿里云/腾讯云。
- App/Web 端按 IP 归属、SDK 配置或接口下发选择域名(也可 GeoDNS/ECS)。
- 回源统一指向L2 香港中台,而不是直打源站。
- 开启 CDN 的 HTTP/3(边缘到用户),边缘到回源保持 HTTP/2;Range Cache on(不同 CDN 名称不同,务必开启)。
- 签名/防盗链在 L3 校验,CDN 只做透传与缓存。
- 国内合规提示:若需在大陆区域提供加速/分发,遵循当地法规(域名备案、CDN 合规等),选择合规厂商的大陆节点与回源链路。
七、L2 区域中台缓存(Varnish 7.x 示例)
安装与存储后端(NVMe ZFS mirror 做大缓存区):
apt -y install varnish
# 存储:文件后端,预留 2.5TB
cat > /etc/varnish/storage.conf <<'EOF'
# 生产建议使用 "file" 或 "malloc+file" 组合
# 这里用文件后端,命名为 s1,大小 2500G
s1=File,/cache/varnish_cache.bin,2500G
EOF
# 启动参数
cat > /etc/systemd/system/varnish.service.d/override.conf <<'EOF'
[Service]
Environment="VARNISH_STORAGE=file,/cache/varnish_cache.bin,2500G"
Environment="VARNISH_LISTEN_PORT=6081"
Environment="VARNISH_ADMIN_LISTEN_ADDRESS=127.0.0.1"
Environment="VARNISH_ADMIN_LISTEN_PORT=6082"
Environment="VARNISH_THREADS=200,4000"
EOF
systemctl daemon-reload && systemctl restart varnish
VCL 关键点:
- Range 合并(把相邻或重叠的 Range 请求合并后回源)。
- Collapsed Forwarding(同一对象首个 miss 回源,其余等待;避免“羊群效应”)。
- grace/stale-if-error(源站异常时继续发陈旧内容)。
- Cache Key 规范化(忽略无关 query,如 utm_*;保留 v= 版本号)。
vcl 4.1;
backend default {
.host = "10.0.0.10"; # L3 源站 Nginx
.port = "8080";
.connect_timeout = 1s;
.first_byte_timeout = 30s;
.between_bytes_timeout = 30s;
}
sub vcl_recv {
# 忽略跟播放无关的参数
if (req.url.qs) {
set req.url = querystring.remove(req.url, "utm_source|utm_medium|utm_campaign|_t|t");
}
# 仅允许部分方法缓存
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
# 允许 Range,但做合并(vmod)
if (req.http.Range) {
set req.http.X-Range = req.http.Range;
}
# 粘合相同对象的并发(collapsed forwarding)
return (hash);
}
sub vcl_backend_response {
# m3u8 短 TTL,m4s 长 TTL
if (bereq.url ~ "\.m3u8($|\?)") {
set beresp.ttl = 30s;
set beresp.grace = 60s;
} else if (bereq.url ~ "\.(m4s|ts)($|\?)") {
set beresp.ttl = 30d;
set beresp.grace = 10m;
} else {
set beresp.ttl = 10m;
set beresp.grace = 5m;
}
# 允许缓存 206
if (beresp.status == 206) {
set beresp.do_stream = true;
set beresp.do_gzip = false;
}
# 允许 stale-if-error
set beresp.keep = 10m;
}
sub vcl_hash {
hash_data(req.url);
# 用 UA 维度区分可选(一般不建议)
# hash_data(req.http.User-Agent);
}
sub vcl_deliver {
set resp.http.X-Cache = obj.hits > 0 ? "HIT" : "MISS";
}
如果你偏向 ATS(Apache Traffic Server),也能实现 Range 缓存与 collapsed forwarding;ATS 在大对象与磁盘 IO 上很稳,Varnish 在灵活 VCL 上更快手。
跨区预热:
热点提升时,先在 HK 节点预热完,再由脚本对 SG/TYO 发带并发限制的 GET,填满二级缓存,避免海外首次访问回 HK。
八、L3 源站(OpenResty/Nginx)缓存与鉴权
目录与缓存空间
- proxy_temp_path /var/cache/nginx/temp;
- proxy_cache_path /var/cache/nginx/cache levels=1:2 keys_zone=hls_cache:512m max_size=120g inactive=10m use_temp_path=off;
Nginx 关键配置(/etc/nginx/conf.d/hls.conf):
proxy_cache_path /var/cache/nginx/cache levels=1:2 keys_zone=hls_cache:512m
max_size=120g inactive=10m use_temp_path=off;
map $request_uri $cache_key {
default $request_uri;
}
map $uri $cache_ttl {
default 10m;
"~*\.m3u8$" 30s;
"~*\.m4s$" 30d;
}
server {
listen 8080 http2 reuseport;
server_name origin-hls;
# 鉴权(示例:签名 ?token=xxx&exp=...,Lua 校验)
set $authorized 0;
access_by_lua_block {
local args = ngx.req.get_uri_args()
-- 这里校验 token、过期时间、path 绑定等,失败则 ngx.exit(403)
if args.token and args.exp and tonumber(args.exp) > ngx.time() then
ngx.var.authorized = 1
else
return ngx.exit(403)
end
}
# 缓存与锁,避免击穿
proxy_cache hls_cache;
proxy_cache_key $cache_key;
proxy_cache_lock on;
proxy_cache_lock_timeout 10s;
proxy_cache_valid 206 200 301 302 $cache_ttl;
proxy_ignore_headers Set-Cookie Expires;
add_header X-Cache-Status $upstream_cache_status always;
location /hls/ {
root /data;
# 静态命中:直接本地文件
try_files $uri @miss;
}
location @miss {
# 回对象存储(示例:S3 兼容网关或 SDK 代理服务)
proxy_set_header Host s3-gw.internal;
proxy_pass http://s3-gw.internal$request_uri;
}
}
对象存储回源
- 通过内网 S3 网关或直接公网 S3(配专线更稳)。
- 分片文件一旦落库,尽量不可变,避免多点缓存出入不一致。
九、预热(热点上架时)与自动降温
预热脚本(Python/asyncio 简化版)
读取 index.m3u8,批量 GET 前 20 个片段,命中 L2/L1:
# prewarm.py
import asyncio, aiohttp, re
async def fetch(session, url):
async with session.get(url, timeout=10) as r:
return await r.text() if url.endswith('.m3u8') else await r.read()
async def main(base):
async with aiohttp.ClientSession() as s:
m3u8 = await fetch(s, f"{base}/index.m3u8")
variants = re.findall(r'(v\d+/stream\.m3u8)', m3u8)
tasks = []
for v in variants:
sub = await fetch(s, f"{base}/{v}")
segs = re.findall(r'(seg_\d+\.m4s)', sub)[:20]
for seg in segs:
tasks.append(fetch(s, f"{base}/{v.replace('stream.m3u8', seg)}"))
await asyncio.gather(*tasks)
if __name__ == "__main__":
import sys
asyncio.run(main(sys.argv[1]))
使用:
python3 prewarm.py https://v.example.com/hls/129/129876
python3 prewarm.py https://edge-hk.example.com/hls/129/129876 # L2 直预热
自动降温
- 播放日志上报到 Kafka → Flink 聚合出 topN → 写入 Redis ZSET(HOT:score)。
- N 小时未访问的内容,从 L2/L3 缓存淘汰(inactive),仅留 L4 冷层保底;定期 varnishadm ban 逐步扫陈旧。
十、缓存清理与版本化策略
强烈建议:URL 中携带版本号或内容哈希,如:
/hls/129/129876?v=1700000100(或把版本编码在路径),上线新版本直接换 URL,避免全网 PURGE。
确需清理(比如下架/侵权):
CDN:各家提供 API,一次性 Purge 精准路径前缀。
Varnish:
varnishadm ban "req.url ~ /hls/129/129876"
源站:删除本地与对象存储;为兼容边缘 TTL,返回 410/Cache-Control: no-store 至多 1 小时。
十一、监控与告警(我线上用的指标)
| 类别 | 指标 | 说明/阈值 |
|---|---|---|
| 命中率 | L1/L2/L3 Hit Ratio | L2 ≥ 92%,L3 ≥ 75% 为良好 |
| 回源带宽 | to L3/L4 | 热点期曲线平稳无锯齿 |
| 首包时间 | TTFB p95 | 国内 < 300ms,海外 < 500ms(视 CDN 区域) |
| 状态码 | 5xx/4xx 比例 | 5xx < 0.1%,403 明显增高排查签名 |
| Nginx | upstream_response_time, waiting |
锯齿=击穿/合并不佳 |
| Varnish | MAIN.n_object, MAIN.threads, n_lru_nuked |
LRU 淘汰过快=缓存过小或 TTL 过短 |
| 磁盘 | NVMe 温度/延时 | 温度 > 70℃ 降频,预防 |
日志统一到 Loki/ELK,图表用 Grafana;异常波动设置告警(飞书/钉钉/Slack)。
十二、关键参数备忘(表)
| 项 | 推荐值 |
|---|---|
| HLS 片段时长 | 4s(3–6s 之间权衡) |
| m3u8 TTL | 30–60s |
| m4s/ts TTL | 7–30 天 + immutable |
| Varnish grace | m3u8: 60s;m4s: 10m |
Nginx proxy_cache_lock_timeout |
10s |
| 打开文件数 | ≥ 1,000,000 |
| NVMe 缓存区 | ≥ 热门内容峰值的 2–3 倍 |
| CDN 边缘 Range 缓存 | 必须开启 |
十三、那些坑与我当场的解法
206 Range 不缓存:
好几家 CDN 默认不缓存 206;打开后回源立刻降 40%。
L2/L3 要明确允许 206 缓存,并对 Range 做合并。
清单与分片 TTL 搞反:
把 m3u8 设太长导致客户端等不到新分片;我把 m3u8 降到 30s,问题消失。
羊群效应:
热点瞬时涌入,L3 未开 proxy_cache_lock,同一对象并发回源打爆对象存储。
立刻开启锁 + L2 collapsed forwarding,曲线平滑。
签名鉴权与缓存互斥:
早期把 token 放在 Path,导致每个 token 都是不同 URL,命中率崩溃。
改为 Query 的 token 仅用于鉴权,Cache-Key 排除它,或用版本号 v= 作为唯一影响缓存的参数。
NVMe 过热降频:
连夜加了风道与散热片,I/O 抖动消失;长期方案是把热数据均衡到多台 L2。
海外首包慢:
开了东京/新加坡 L2,做跨区预热;海外用户 TTFB 下降 35%。
对象存储限流:
热点期回源 QPS 激增触发限流;在 L3 源站做“回源速率限制+指数退避”,并提升 L2 TTL 与 grace,用户无感知。
十四、上线步骤(我当晚的实际节奏)
- 先在 L3 源站 开 proxy_cache + lock + TTL,保护对象存储。
- 再上 L2 Varnish,验证 Range 与 collapsed forwarding;先 30% 流量,命中率稳定后全量。
- 最后切 多 CDN 到 L2,开启各家的 Range 缓存与 HTTP/3。
- 打通 预热脚本,联动运营的“热点列表”;
- 配置 清理/版本化策略,降低全网 PURGE 频率。
- 补充 监控与告警,值班表更新。
十五、复盘:前后指标对比
| 指标 | 优化前 | 优化后 |
|---|---|---|
| L2 命中率 | 0%(未部署) | 92.4% |
| L3 命中率 | 18.7% | 77.8% |
| 总回源带宽峰值 | 34 Gbps | 6.1 Gbps |
| 海外 p95 TTFB | 820 ms | 420 ms |
| 国内 p95 TTFB | 560 ms | 240 ms |
| 5xx 比例 | 0.6% | 0.05% |
注:数据来自当晚到次日午间的实际观测,设备与地域分布会影响你的结果,但量级的趋势具备参考意义。
第二天中午,我在机房走了一圈
风扇的噪声从“呼呼”回到了“嗡嗡”,边缘命中率在 99% 左右轻轻跳动。
运营发来“兄弟稳了”的时候,我正把一张便利贴贴到机柜门上:“先缓存,后计算;先就近,后回源;先可回滚,再全量。”
如果你也在香港给短视频平台扛热点,把上面这套多层缓存打法照着搭起来——不用等到凌晨,也能有底气面对每一次“突然爆了”的 18 秒