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

那个流量暴涨、用户在 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 门,曲线也会很听话