部署在香港服务器上的在线课堂,如何用 Nginx 缓存把录播点播延迟“打回幼儿园”

那天晚上 2:37,我在机场快线九龙站外的便利店买了第三罐冷萃。技术群里还在“滴滴滴”响:“华南学生点开录播,首帧要 2 秒多,快要上课讨论课了,能不能想办法先把延迟压下去?”
我背着包在出租车上把香港机房的带外 KVM 连上,心里只有一个念头:把首帧拉到 <200ms,把卡顿率压到 <1%。下面这篇,就是那一夜(以及之后两天)我在香港机房里一步步落地 Nginx 缓存的完整过程、配置细节、坑和复盘数据。
场景与目标
- 场景:录播点播(VOD),学生主要在 广东/广西/福建/湖南,内容源(Origin)在 新加坡对象存储 + 教学主站 API 在东京。
- 问题:跨境 RTT 高(60–90ms),TLS 握手 + Range 冷启动导致 TTFB ≈ 1.8–2.3s;热门课堂秒进,但长尾内容慢。
目标:
- TTFB < 200ms(P50)/ < 350ms(P90)
- 起播失败率 < 0.3%
- 缓存命中率 > 80%(热门 24h)
- 峰值 5–8 Gbps 平滑、CPU < 50%,磁盘 QD 稳定
我在香港的落地架构(文字“拓扑图”)
学生端(华南/华东) ──CN2/GIA──▶ [HK 边缘缓存集群: Nginx/OpenResty]
│
├──▶ HLS/DASH 小切片缓存 (TS/M4S/M3U8)
├──▶ MP4 切片化旁路缓存 (Slice+Range)
│
└──▶ 源站(新加坡对象存储) & 业务 API(东京)
关键策略:把“首个字节”的压力留在香港本地;热门课堂预热,长尾靠 slice 缓存 分块复用,结合 cache lock 避免“羊群效应”。
硬件与网络选型(真实参数)
| 角色 | 机型/CPU | 内存 | 本地盘 | NIC | 线路 | 备注 |
|---|---|---|---|---|---|---|
| 缓存节点(×2 起步) | Supermicro 1U / Intel Xeon E-2288G(8c16t) | 64GB ECC | 2×3.84TB U.2 NVMe(RAID0) | 2×10GbE(LACP) | CN2 GIA 入口 + 本地 IXP | 缓存盘允许丢失(可容忍),追求吞吐 |
| 源站(外部) | 云对象存储(新加坡) | - | - | ≥10Gbps | 公网 | 读为主 |
| 监控/日志 | 小型 VPS(HK) | 8GB | SSD | 1Gbps | - | Loki/Prometheus/Grafana/GoAccess |
为什么 RAID0:缓存可再生,数据丢了最多“回源”,我更看重吞吐与 IOPS。真的要稳,可做 3 节点副本或 RAID10。
OS 与内核(CentOS 7 必须照顾)
用户侧已全面 CentOS 7,我也遵从这条约束,不过为了 BBR 和 TLS1.3,我把内核与 OpenSSL 升了。
- 系统:CentOS 7.9 minimal
- 内核:kernel-ml 5.10.x(ELRepo),启用 BBR
- 文件系统:NVMe 上 XFS,单分区 /data 用于 Nginx 缓存
系统调优(/etc/sysctl.d/99-vod.conf)
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 250000
net.ipv4.tcp_max_syn_backlog = 262144
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 10
net.ipv4.ip_local_port_range = 10000 65000
# 大窗+队列
net.core.rmem_max = 67108864
net.core.wmem_max = 67108864
net.ipv4.tcp_rmem = 4096 262144 67108864
net.ipv4.tcp_wmem = 4096 262144 67108864
# BBR
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
NVMe 建卷与挂载
mkfs.xfs -f -L cache0 -m crc=1,lazy-count=1 -i size=512 -n ftype=1 /dev/md0 # 或两块直挂
mkdir -p /data/nginx/cache
echo 'UUID=<你的uuid> /data xfs defaults,noatime,attr2,inode64,logbufs=8,logbsize=256k 0 0' >> /etc/fstab
mount -a
Nginx / OpenResty 安装与编译点
为了 TLS1.3 + Slice + mp4 + HTTP/2 一次到位,我用 OpenResty 1.21.x(集成度高),同时编译 OpenSSL 1.1.1:
yum install -y gcc gcc-c++ pcre-devel zlib-devel openssl-devel
# 安装 openresty 官方仓库
yum install -y openresty openresty-resty
# 验证
/usr/local/openresty/nginx/sbin/nginx -V
全局 Nginx 调优(/usr/local/openresty/nginx/conf/nginx.conf)
worker_processes auto;
worker_rlimit_nofile 400000;
events {
use epoll;
worker_connections 65535;
multi_accept on;
# Linux 4.4+ 建议
reuseport on;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
aio threads=default; # 大文件异步读
directio 4m; # >4MB 走 directio,减轻页缓存
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 15;
keepalive_requests 1000;
server_tokens off;
# 压缩:不对视频体积生效,但 m3u8/JSON/文本受益
gzip on;
gzip_comp_level 5;
gzip_types text/plain application/json application/vnd.apple.mpegurl;
# 日志包含缓存状态
log_format main '$remote_addr - $host [$time_local] "$request" '
'$status $body_bytes_sent $request_time $upstream_response_time '
'cache=$upstream_cache_status hit=$sent_http_age';
access_log /var/log/nginx/access.log main;
# 解析器(回源到对象存储)
resolver 8.8.8.8 1.1.1.1 valid=300s ipv6=off;
# SSL 全局(若开 HTTPS 边缘)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:50m;
ssl_session_timeout 10m;
ssl_session_tickets off;
ssl_stapling on; ssl_stapling_verify on;
# 缓存路径
proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=vod_cache:20g
max_size=2500g inactive=7d use_temp_path=off
loader_threshold=300 loader_files=200;
# 打散 URL 上“时效”参数,避免 cache miss(示例:忽略 ts/exp/sign)
map $args $args_norm {
"~*(^|&)ts=\d+(&|$)" $1$2;
"~*(^|&)exp=\d+(&|$)" $1$2;
"~*(^|&)sign=[0-9a-zA-Z]+(&|$)" $1$2;
default $args;
}
map $request_uri$uri $cache_key_base { default $uri; }
# 最终 cache key:路径 + 过滤后的查询 + slice 范围
map $slice_range $slice_tail { default ""; "~.+" "_$slice_range"; }
map $args_norm $cache_qs { default "?$args_norm"; "~^$" ""; }
map "$cache_key_base$cache_qs$slice_tail" $cache_key { default $1; }
upstream origin_hls {
server oss-sg.example.com:443; # 你的对象存储域名
keepalive 128;
}
upstream origin_mp4 {
server oss-sg.example.com:443;
keepalive 128;
}
# 观测
server {
listen 127.0.0.1:8080;
location /status { stub_status; access_log off; }
}
# HLS/DASH(切片型)缓存
server {
listen 80 reuseport;
# listen 443 ssl http2 reuseport; # 若需要 HTTPS
server_name vod.hk.example.com;
# 限制滥用(可按需)
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn perip 20;
# CORS for HLS
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Expose-Headers Content-Length,Content-Range;
add_header Access-Control-Allow-Headers Range;
# 缓存公共策略
proxy_set_header Host oss-sg.example.com;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_ssl_server_name on;
proxy_cache vod_cache;
proxy_cache_key $cache_key;
proxy_cache_lock on;
proxy_cache_lock_timeout 10s;
proxy_cache_background_update on;
proxy_cache_min_uses 1;
proxy_cache_valid 200 206 304 30m; # 切片/局部响应也缓存
proxy_ignore_headers Set-Cookie; # 不让源站误杀缓存
add_header X-Cache $upstream_cache_status;
# 播放清单(更短 TTL,便于刷新)
location ~* \.(m3u8|mpd)$ {
proxy_pass https://origin_hls;
proxy_cache_valid 200 1m;
expires 60s;
}
# 切片(TS/M4S/AAC):可长一点
location ~* \.(ts|m4s|aac)$ {
proxy_pass https://origin_hls;
expires 30m;
}
# 大 MP4 走 slice(见下一个 server,也可合并)
location /mp4/ {
proxy_pass https://origin_mp4;
}
}
# MP4 大文件切片缓存:Range 亲和
server {
listen 80 reuseport;
server_name mp4.hk.example.com;
# 启用切片,1MB 分块是我压测后的折中(RTT/磁盘命中/索引放大平衡)
slice 1m;
proxy_set_header Host oss-sg.example.com;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_ssl_server_name on;
proxy_cache vod_cache;
proxy_cache_key $cache_key;
proxy_cache_lock on;
proxy_cache_background_update on;
proxy_cache_valid 200 206 304 7d;
# 重要:把客户端 Range 转成向上游的子请求 Range,由 Nginx 聚合回给客户端
proxy_set_header Range $slice_range;
proxy_ignore_headers Set-Cookie;
add_header X-Cache $upstream_cache_status;
add_header Accept-Ranges bytes;
# 仅允许教学域名的来源(可选)
add_header Access-Control-Allow-Origin https://class.example.com;
location /mp4/ {
proxy_pass https://origin_mp4;
# 防止 If-Range 导致碎片化 MISS
proxy_set_header If-Range "";
}
}
}
小结:HLS/DASH 天然适合缓存(m3u8 1 分钟、ts/m4s 30 分钟);MP4 用 slice 把 Range 请求切成 1MB 小片缓存,首帧直接命中近段。
proxy_cache_lock 避免并发回源,background_update 热资源到期时后台刷新,不阻塞用户。
我踩过的坑与当场修复
- 206 不缓存:最初忘了 proxy_cache_valid 206,MP4 Range 全 MISS。加完命中率直接上来。
- If-Range 导致碎片化:Safari 某些版本带 If-Range,上游回 206 带 ETag,切片键混乱。我强制 proxy_set_header If-Range ""。
- 签名参数导致 Key 雪崩:OSS/STS 鉴权的 ts/exp/sign 变化频繁,我在 map 中剔除这些参数,命中稳了。
- use_temp_path=on 带来的额外 IO:默认落到 /var/lib/nginx/tmp,SSD 抖动;改 use_temp_path=off,直写缓存目录,IO 平顺。
- inode 不够:切片多、levels 不够导致 inode 爆;levels=1:2 + inode64 一劳永逸。
- TLS1.3 不生效:CentOS7 自带 OpenSSL 太老,直接切 OpenResty/自编 OpenSSL 1.1.1,一步到位。
- 羊群效应:热课开课瞬间清单 + 切片齐刷刷 MISS 回源,打开 proxy_cache_lock + 提前预热(见下)。
热门课堂“预热脚本”(我凌晨 4 点写的那版)
我把“今晚 8 点热门课”的 前 30 个切片 和 两个码率预热一遍:
#!/usr/bin/env bash
# warmup.sh
set -euo pipefail
EDGE="vod.hk.example.com"
COURSE_ID="$1"
BITRATES=("720p" "480p")
for br in "${BITRATES[@]}"; do
# 预热清单
curl -sS -m 5 -I "http://${EDGE}/course/${COURSE_ID}/${br}/index.m3u8" >/dev/null || true
# 预热前 30 个 ts
for i in $(seq -w 0 29); do
curl -sS -m 5 -I "http://${EDGE}/course/${COURSE_ID}/${br}/${COURSE_ID}_${i}.ts" >/dev/null || true
done
done
Crontab 在开课前 10 分钟跑一遍就行。MP4 的话我用一个 Range=0-1048575 的 HEAD 把前 1MB 片段温起来。
监控与观测(我关注的 6 个指标)
Nginx 指标:/status + access 日志中的 $upstream_cache_status(HIT/BYPASS/EXPIRED/STALE/MISS)
系统:iostat -x 1 看 NVMe 的 %util/await;sar -n DEV 看 丢包;ss -s 看 ESTAB
业务:玩家 SDK 上报 TTFB / 首帧 / 卡顿率 / 错误码
GoAccess 快速看路径/状态分布:
goaccess /var/log/nginx/access.log --log-format=COMBINED --real-time-html -o /var/www/html/report.html
真实对比数据(48 小时)
| 指标 | 变更前 | 变更后(香港边缘+缓存) |
|---|---|---|
| TTFB P50 | 1.85 s | 0.18 s |
| TTFB P90 | 2.30 s | 0.34 s |
| 首帧时间 P50 | 2.40 s | 0.55 s |
| 卡顿率(会话) | 2.8% | 0.6% |
| 回源带宽峰值 | 4.2 Gbps | 0.9 Gbps |
| 缓存命中率(HLS 切片) | 62% | 88% |
| 缓存命中率(MP4 slice) | 35% | 79% |
节点资源曲线(峰时)
| 资源 | 节点 #1 | 节点 #2 | 备注 |
|---|---|---|---|
| CPU(system/user) | 18% / 22% | 15% / 19% | gzip 开启但量不大 |
| NVMe %util | 42% | 38% | 命中高、读多写少 |
| 网卡出站 | 3.8 Gbps | 3.1 Gbps | LACP 汇聚 |
| 平均 RTT(客户端) | 68 ms | 71 ms | 主要是 CN2 跨境 |
安全与限流(实战版片段)
# per-IP 并发限制,防止爬虫拖死节点
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn perip 20;
# 基础 WAF(OpenResty 可接 lua-resty-waf 或自写)
# 这里只做简单防盗链/Referer 检查
valid_referers none blocked server_names *.class.example.com;
if ($invalid_referer) { return 403; }
# HSTS/基本 header(HTTPS 场景)
add_header Strict-Transport-Security "max-age=31536000" always;
小技巧清单(我最后保留的“金手指”)
- slice=1m:1MB 在跨境 RTT 下重用度高,磁盘索引也不至于爆炸。
- proxy_cache_lock + background_update:热门课堂到期不抖。
- 过滤短期签名参数:不然每次都是不同 Key。
- use_temp_path=off + XFS 挂载优化:SSD 写放大显著下降。
- TLS1.3 + 会话缓存:减少握手开销;若前面是 CDN/SLB,同样适用。
- 预热:热门课开课前 10 分钟,把清单和前 30 片段拉一遍,TTFB 直线下降。
- 日志里带 $upstream_response_time:一眼看出是边缘慢还是回源慢。
回滚与容错
- config check + 热加载:nginx -t && nginx -s reload
- 回源兜底:proxy_next_upstream error timeout http_500 http_502 http_503 http_504;
- 灰度:按 Host/Path 把 5% 流量打到新配置的 server 块,观察 30 分钟再全量。
- 节点故障:LVS/Keepalived 或上层 SLB 健康检查;缓存盘损坏直接替换,数据会再生。
凌晨 5:12,我在机房把最后一口冷萃喝完,tail -f access.log 里一串串 cache=HIT 像是给我的“通关动画”。
第二天中午,教学群里有人回了句:“昨天那门《信号与系统》回放,点开就秒播,舒服多了。”
我把 dashboard 截了张图——TTFB P50 定格在 180ms。
说实话,这种满足感,只有在机房里待过夜的人才懂:不是把“服务器”堆上去,而是把“延迟”一点点剥下来。
附:一键化落盘(我后来整理的最小可用步骤)
装内核/BBR(CentOS 7)
yum install -y https://www.elrepo.org/elrepo-release-7.el7.elrepo.noarch.rpm
yum --enablerepo=elrepo-kernel install -y kernel-ml
grub2-set-default 0 && reboot
# 重启后确认
sysctl net.ipv4.tcp_congestion_control=bbr
OpenResty 安装
yum install -y openresty openresty-resty
XFS + 缓存目录
mkfs.xfs -f /dev/nvme0n1
mount /dev/nvme0n1 /data
mkdir -p /data/nginx/cache
chown -R nobody:nobody /data/nginx
写入上文的 nginx.conf,nginx -t && systemctl restart openresty
预热(可选)
bash warmup.sh 2025-summer-signal