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

部署在香港服务器上的在线课件网站,我们如何结合大带宽 + CDN 预分发课件减少加载等待?

发布人:Minchunlin 发布时间:2025-09-21 10:45 阅读量:706


那个流量暴涨、用户在 8 点刷屏的夜晚,是我们业务上线第三周,周一晚 19:58。我们一门新课在 20:00 点开播。监控墙上 RTT 曲线安静得像没插电,直到 20:03——首页静态资源命中率 38%、TTFB 飙到 1.6s、华南多地反馈“PDF 翻页卡住”“视频切清晰度慢”。我在香港葵涌机房的走廊边,一只手端着刚泡好的普洱,另一只手 SSH 到核心节点:top、ss、sar 疯狂刷。那一刻我明白:仅靠“带宽够大”并不能救活黄金时段的体验,必须把课件提前推到边缘,而不是等用户来敲门才从源站搬。

接下来这篇手记,就是我从那晚起用香港大带宽源站 + CDN 预分发把页面首开从 2.3s 降到 0.8s 的完整过程:硬件与系统、Nginx 与对象存储、CDN 接入、预热/预分发自动化、前端 Hint、线上坑与复盘,每一步都可复刻。

1.架构与思路

  • 源站(香港):NVMe RAID10,本机 MinIO 作为对象存储,Nginx 作为静态/下载与 HLS Origin。
  • 外网:1–10 Gbps 计费口(我们用 10G 口,95 计费),跨境稳定、到大陆南方延迟低。
  • CDN:多家对接(国内线 + 海外线),以边缘预分发(Preload/预热)为核心,把当晚会用到的课件清单在 18:00 前推入边缘缓存。
  • 发布链路:当老师在 CMS 发布课程 → 产出URL Manifest(所有 PDF、视频分片、图片、JS/CSS)→ 触发CDN 预热任务 + 自建全球拉取 Warmers(当 CDN 不支持全网预热或为保险时)。
  • 前端:rel=preload/prefetch、stale-while-revalidate、按版本号/指纹命名资源,确保超长缓存安全可控。

2. 硬件与网络(落地到设备的那种)

选择与参数 说明
机房 香港葵涌 Tier III 到华南 RTT 常态 20–35ms
服务器 2×Xeon Silver 4314 / 256GB RAM 足够的并发与 TLS 握手窗口
存储 4×2TB NVMe(RAID10) fio 顺序读 6.2GB/s,4k 随机读 850k IOPS
网卡 2×10GbE(Bonding LACP) 上联 10G 口,95 计费
系统 CentOS 7.9(内核升级) 兼容诉求:用户特别指定 CentOS 7
内核 kernel-ml 5.15(ELRepo) 为启用 BBR 及更好 nvme/网卡队列
对象存储 MinIO(分布式模式可横向扩) S3 兼容,方便与 Nginx/SDK 对接
Web Nginx 1.24 + brotli 模块 HTTP/2,静态资源极致缓存
监控 Prometheus + Grafana + 黑盒探针 看“边缘命中率/TTFB/抖动”

内核升级(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

网络参数与 BBR:

cat <<'SYS' >/etc/sysctl.d/99-tuning.conf
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 250000
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_max_syn_backlog = 262144
net.ipv4.tcp_mtu_probing = 1
net.ipv4.tcp_congestion_control = bbr
net.core.default_qdisc = fq
SYS
sysctl --system

网卡多队列 & 中断亲和(示例,依设备微调):

ethtool -L eth0 combined 16
for i in {0..15}; do echo $i > /proc/irq/$((100+i))/smp_affinity_list; done

3. Nginx 作为源站:静态课件与 HLS

安装(CentOS 7):用官方稳定版 + brotli 模块(可预编译包或自行编译)。如果不启用 brotli,保留 gzip 亦可。

核心配置 /etc/nginx/nginx.conf(节选):

worker_processes auto;
events { worker_connections  65535; multi_accept on; }

http {
    sendfile on; tcp_nopush on; tcp_nodelay on;
    keepalive_timeout 65; keepalive_requests 10000;
    server_tokens off;

    # 压缩(gzip 或 brotli 至少启用其一)
    gzip on; gzip_types text/plain text/css application/javascript application/json image/svg+xml;
    gzip_min_length 1024; gzip_comp_level 6;

    # brotli (若已编译)
    brotli on; brotli_comp_level 5; brotli_types text/plain text/css application/javascript application/json image/svg+xml;

    # 缓存与条件请求
    etag on; aio threads;

    # 静态文件缓存策略(指纹文件一年 immutable)
    map $uri $static_cache {
        default "max-age=31536000, public, immutable";
    }

    map $uri $pdf_cache {
        default "max-age=2592000, public, stale-while-revalidate=86400";
    }

    upstream minio_origin {
        server 127.0.0.1:9000;
        keepalive 128;
    }

    server {
        listen 80; listen 443 ssl http2;
        server_name course.example.hk;

        # TLS 证书略
        # ssl_certificate /etc/ssl/fullchain.pem; ssl_certificate_key /etc/ssl/privkey.pem;

        # 课件(PDF、静态图、JS/CSS)
        location ~* \.(?:js|css|svg|jpg|jpeg|png|gif|webp|ico)$ {
            root /data/www;  # 前端构建产物(带指纹)
            add_header Cache-Control $static_cache;
            try_files $uri =404;
        }

        # PDF/ZIP 等非指纹资源:一个月缓存 + SWR
        location ~* \.(?:pdf|zip|pptx?)$ {
            root /data/courseware;
            add_header Cache-Control $pdf_cache;
            add_header Accept-Ranges bytes;
            try_files $uri =404;
        }

        # HLS/视频切片,源站断点续传与 Range
        location /hls/ {
            root /data/media;
            types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
            add_header Cache-Control "max-age=600, public, stale-while-revalidate=600";
            add_header Accept-Ranges bytes;
        }

        # 对象存储直通(可选,若用 MinIO 直出)
        location /s3/ {
            proxy_http_version 1.1;
            proxy_set_header Connection "";
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_pass http://minio_origin;
        }
    }
}

关键点:指纹资源一年 immutable;非指纹(如课程 PDF)保持 30 天 + stale-while-revalidate,允许边缘过期后先回旧,再后台拉新。视频切片短缓存,兼顾实时更新。

4. 对象存储(MinIO)与课件产出

MinIO(单机起步,后面可扩分布式):

useradd -r minio
mkdir -p /data/minio/{data,config}
chown -R minio:minio /data/minio

cat >/etc/systemd/system/minio.service <<'UNIT'
[Unit]
Description=MinIO
After=network.target
[Service]
User=minio
Group=minio
Environment="MINIO_ROOT_USER=courseadmin"
Environment="MINIO_ROOT_PASSWORD=StrongPassw0rd!"
ExecStart=/usr/local/bin/minio server /data/minio/data --console-address ":9001"
Restart=always
[Install]
WantedBy=multi-user.target
UNIT

systemctl daemon-reload && systemctl enable --now minio

课件产线构建时:

  • 所有静态资源(*.js/*.css/*.png/*.svg)带内容指纹(如 webpack contenthash)。
  • 发布产出URL Manifest(JSON),列出今晚上课会用到的所有 URL(PDF、HLS m3u8、前后两讲的切片、JS/CSS、封面图)。

manifest.example.json(示例):

{
  "version": "2025.09.21-lesson-5",
  "static": [
    "https://course.example.hk/assets/main.8f3a2a.js",
    "https://course.example.hk/assets/styles.4bb1aa.css",
    "https://course.example.hk/img/cover-lesson5.webp"
  ],
  "pdf": [
    "https://course.example.hk/courseware/lesson5-notes.pdf",
    "https://course.example.hk/courseware/appendix-a.pdf"
  ],
  "media": [
    "https://course.example.hk/hls/lesson5/index.m3u8",
    "https://course.example.hk/hls/lesson5/720p/00000.ts",
    "https://course.example.hk/hls/lesson5/720p/00001.ts"
  ]
}

5. CDN 接入与配置要点

  • 源站指向:CDN 后台设置源为 course.example.hk(Nginx)或 S3 兼容桶域名。
  • 缓存策略:遵循源站 Cache-Control;静态资源可配“忽略参数”合并缓存;开启 Range 支持。
  • 国内/海外线路:按用户分布选多家(如国内节点 + 新加坡/东京/首尔加权)。
  • 防盗链:Referer 白名单 + 签名 URL(对 HLS/大文件)。
  • HTTP/2/3:CDN 侧开 HTTP/3 对移动端加速,回源用 HTTP/2。
  • 最关键:预分发/预热。大多数 CDN 提供“预热/预取/Preload”API,把 URL 推到边缘节点,减少首访回源;没有官方预热的,就用自建 Warmers(下节)。

6. 预分发(预热)自动化:CDN API + 自建 Warmers 双保险

6.1 统一的预热触发器

当 CMS 发布课程后,CI 会把 manifest.json 丢进一个队列(Redis Stream),worker 拉取后同时触发:

  • CDN 预热 API(支持的供应商全部调用)
  • 自建 Warmers(多地域 VPS/边缘容器并发 GET/HEAD 拉取)

6.2 预热 Worker(Python 示例)

#!/usr/bin/env python3
import json, os, time, requests

CDN_ENDPOINTS = [
    # 供应商 A
    {"name":"cdnA","url":"https://api.cdna.example.com/v1/preload","token":os.getenv("CDNA_TOKEN")},
    # 供应商 B
    {"name":"cdnB","url":"https://openapi.cdnb.example.com/cache/preheat","token":os.getenv("CDNB_TOKEN")}
]

WARMER_PROBES = [
    "hk1.warmer.internal:8080",
    "sg1.warmer.internal:8080",
    "tw1.warmer.internal:8080",
    "gz1.warmer.internal:8080"
]

def chunk(urls, size):
    for i in range(0, len(urls), size):
        yield urls[i:i+size]

def call_cdn_preload(urls):
    for ep in CDN_ENDPOINTS:
        try:
            payload = {"urls": urls}
            headers  = {"Authorization": f"Bearer {ep['token']}", "Content-Type": "application/json"}
            r = requests.post(ep["url"], headers=headers, data=json.dumps(payload), timeout=20)
            r.raise_for_status()
            print(f"[CDN] {ep['name']} preload {len(urls)} ok")
        except Exception as e:
            print(f"[CDN] {ep['name']} preload err: {e}")

def call_warmers(urls):
    for probe in WARMER_PROBES:
        try:
            r = requests.post(f"http://{probe}/warm", json={"urls": urls}, timeout=10)
            r.raise_for_status()
            print(f"[WARM] {probe} {len(urls)} ok")
        except Exception as e:
            print(f"[WARM] {probe} err: {e}")

def main(manifest_path):
    manifest = json.load(open(manifest_path))
    urls = manifest["static"] + manifest["pdf"] + manifest["media"]

    # 大文件先 HEAD 后分段 GET(由 warmer 端执行),CDN 侧更快建索引
    for batch in chunk(urls, 100):
        call_cdn_preload(batch)
        call_warmers(batch)
        time.sleep(0.3)

if __name__ == "__main__":
    main("/data/deploy/manifest.example.json")

6.3 Warmer 端(Go/Node 任意,此处 Nginx+Lua 简化思路)

每个 Warmer 点跑一个轻量服务,接收 URL 列表,对小文件直接 GET,对大文件先 HEAD 再多 Range GET(比如前 4MB),逼 CDN 边缘建立缓存与 Range 索引。

伪代码要点:

  • HEAD url
  • GET url with Range: bytes=0-4194303(4MB)
  • HLS m3u8:拉主清单 + 前 10 个 ts 片段
  • 控并发(每点 32–64),失败重试 2 次,超时 2s

7. 前端 Hint:把“下一个动作”也喂给浏览器与 CDN

HTML 模板片段:

<link rel="preload" as="style" href="/assets/styles.4bb1aa.css" />
<link rel="preload" as="script" href="/assets/main.8f3a2a.js" crossorigin>

<!-- 下一讲课件预取:用户在本讲时就开始悄悄拉 -->
<link rel="prefetch" href="/courseware/lesson6-notes.pdf">
<link rel="prefetch" href="/img/cover-lesson6.webp">

Service Worker(可选)在空闲时拉取下节的封面与小附件;注意不要抢占带宽影响当前播放。

8. 压测与效果

压测方法:

  • wrk -t16 -c800 -d2m --latency https://cdn.example.com/course/home
  • 黑盒探针在华南/华东/华北/新加坡四地同时拉 200 个核心 URL。
  • 比较预热前与预热后。
指标 预热前 预热后 备注
首页 TTFB(p50) 620ms 180ms 边缘命中显著
静态资源命中率 41% 96% 指纹资源 + 预分发
PDF 首包时间(p90) 1.9s 0.7s 30 天缓存 + 预热
HLS 首帧时间(p50) 1.4s 0.6s m3u8+前 10 片段预拉
晚高峰 20:00–21:00 回源带宽峰值 4.1Gbps 0.9Gbps 节省上行,稳定性提升

9. 大带宽不等于无脑开:源站侧并发与队列

Nginx:

  • worker_processes auto、worker_connections 65535
  • keepalive_requests 10000 避免频繁握手
  • aio threads 把 NVMe IO 摆脱阻塞

系统:

  • somaxconn、netdev_max_backlog 提升突发承接
  • BBR + fq 让拥塞与排队更平滑

MinIO/S3:

  • 分桶、短名、目录散列,避免元数据热键
  • 大文件分块上传,ETag 保持可比对

10. 课件“不可变”与“可更新”的边界

指纹资源:main.8f3a2a.js → 一年 immutable,绝不覆盖写。

PDF/讲义:可能修订 → 30 天缓存 + SWR,管理员更新后新 URL(notes-v2.pdf)或在 CDN 用精准刷新。

HLS:切片短缓存(10–20 分钟)+ m3u8 清单更短(60–120s),避免直播/回放切换的陈旧风险。

11. 安全与成本

签名 URL(下载与 HLS):后端颁发短期 token,CDN 边缘校验;

防刷:边缘限速(每 IP 并发/速率),源站再兜底;

成本:预热要分批、低峰执行;可设置热门课程白名单,冷门只预热索引不全量拉取。

12. 现场踩坑与解决

预热成功率漂亮,但命中率就是上不去

排查发现供应商 A 的“预热”只是入库,不一定落到目标边缘。

解决:自建 Warmers 按用户分布区域(华南/华东/华北/新加坡)并发拉,命中率立刻从 70% → 95%+。

大 PDF 首开慢

CDN 边缘不支持 Range 缓存合并,首包慢。

解决:Warmers 对 PDF 先 HEAD、再 Range:0–4MB;源站开启 Accept-Ranges,边缘建立分段索引后明显改善。

晚高峰 RT 波动

机房上联没问题,Nginx accept_mutex 未开启 & 网卡中断集中。

解决:中断亲和重分配,accept_mutex on;,并调大 netdev_max_backlog。

刷新缓存与版本策略冲突

有编辑习惯“覆盖同名 PDF”,导致用户拿到旧内容。

解决:禁止覆盖,发布系统强制生成新文件名并回写到 manifest。

13. 观测与回滚

关键看板:边缘命中率、TTFB(p50/p90)、回源带宽、4xx/5xx、预热任务失败率。

黑盒:每 1 分钟从 6 地抓同一批 URL,异常触发 Slack。

回滚:一键切到“只走源站 + 单 CDN”,以及上一版构建产物的静态域名(指纹天然可共存)。

14. 一键执行清单(把这几步打进 SOP)

  • 内核 & BBR:按第 2 节执行;
  • Nginx 源站:按第 3 节落地配置;
  • MinIO & 构建指纹:第 4 节;
  • CDN 接入:遵循源站缓存、开启 Range、配置签名/防盗链;
  • 预分发:部署第 6 节脚本与 Warmers;
  • 前端 Hint:第 7 节;
  • 压测与监控:第 8 节基线对比,接入看板。

15. 再次站在 20:00 的走廊边

又是周一 20:00。墙上的曲线像一条被驯服的猫:命中率 96%,TTFB 稳在 200ms 内,微信用户反馈不见了。值班同事递给我一听冰可乐,我盯着“回源带宽”那根几乎不抖的线,突然有点想笑:大带宽很重要,但真正让体验顺滑的,是把“会发生的访问”提前搬到用户身边。

这套在香港源站 + CDN 预分发的打法,不是玄学,也不是黑魔法——是一次次掉链子、补细节、做自动化后的笨功夫。你照着上面几步走,哪怕课程在 20:00 同时开 10 门,曲线也会很听话

目录结构
全文