上一篇 下一篇 分享链接 返回 返回顶部

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

发布人:Minchunlin 发布时间:2025-09-21 11:16 阅读量:817


那天晚上 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
目录结构
全文