用 NGINX 做媒体分发是老牌方案:nginx-rtmp-module 负责接收推流并切片,NGINX 本体负责把切片文件高效地吐给成千上万个观众。它没有商业 CDN 的运维自动化,但胜在可控、成本低、能与既有 NGINX 体系无缝衔接。本文从推流接收讲到边缘分发,重点落在 HLS 的延迟与切片参数、鉴权链路设计,以及那些只在真实流量下才暴露的坑。
1. 直播分发的整体链路
一条完整的直播链路可以拆成四段,每一段的关注点完全不同。
- 采集与推流:主播端(OBS、ffmpeg、移动端 SDK)通过 RTMP 或 SRT 把编码后的音视频推到接入节点。
- 接入与处理:接入节点接收流,做鉴权、转码、切片,产出 HLS 或 DASH 分片。
- 分发:切片文件通过 HTTP 被播放器拉取,这一层是纯静态文件服务,可以横向扩展并加缓存。
- 播放:浏览器用 hls.js、移动端用原生播放器拉取 m3u8 索引与 ts 分片。
NGINX 在这条链路里主要承担第 2 段和第 3 段。RTMP 协议本身是长连接、基于 TCP 的私有协议,适合上行推流;HLS 是「HTTP + 切片文件」,适合下行分发。把上行和下行分开,是理解整个架构的关键。
1.1 为什么不用 RTMP 直接分发
RTMP 播放需要 Flash 或专用播放器,浏览器早已不支持。更重要的是,RTMP 的长连接模型无法利用 HTTP 缓存与 CDN 的边缘节点,扩容成本远高于 HLS。因此现代方案几乎都是「RTMP 上行 + HLS/DASH 下行」。
HLS 的代价是延迟:切片长度决定了理论最小延迟。一个 6 秒的切片意味着播放器至少要缓冲 3 个切片才能稳定播放,端到端延迟普遍在 1530 秒。要把延迟压到 35 秒,需要把切片切到 1~2 秒并开启 LL-HLS,代价是请求数暴增与切片边界处理更复杂。
2. 编译与基础配置
nginx-rtmp-module 是第三方模块,官方 NGINX 包不含,需要自行编译或使用带模块的镜像。
./configure --with-compat \
--add-dynamic-module=../nginx-rtmp-module \
--with-http_ssl_module --with-http_v2_module
make && make modules
sudo cp objs/ngx_rtmp_module.so /etc/nginx/modules/
--with-compat 让动态模块能匹配官方包编译的 NGINX 二进制,是避免 ABI 不匹配的必备开关。
主配置里加载模块并引入 rtmp 块:
load_module modules/ngx_rtmp_module.so;
# rtmp 块与 http 块平级,不能嵌套
rtmp {
server {
listen 1935;
chunk_size 4096;
application live {
live on;
record off;
}
}
}
rtmp 块是顶层块,与 http、stream 平级。把它写进 http 里是最常见的配置错误,NGINX 会直接报 unknown directive "rtmp"。
2.1 推流与播放验证
# 用 ffmpeg 推一个本地文件到 live 应用
ffmpeg -re -i input.mp4 -c copy -f flv \
rtmp://push.example.com/live/teststream
# 拉流验证(需要支持 RTMP 的播放器,如 VLC 或 ffplay)
ffplay rtmp://push.example.com/live/teststream
推流地址的最后一段是 stream key,它既是流的唯一标识,也是后续鉴权的载体。
3. HLS 切片与低延迟
RTMP 模块的 hls 指令负责把直播流转成 HLS。
application live {
live on;
hls on;
hls_path /var/media/hls;
hls_fragment 2s;
hls_playlist_length 12s;
hls_nested on;
}
关键参数的含义与取舍:
| 参数 | 含义 | 调小的影响 | 调大的影响 |
|---|---|---|---|
hls_fragment | 单个 ts 切片时长 | 延迟降低、请求数增加 | 延迟升高、请求数减少 |
hls_playlist_length | m3u8 中保留的切片总时长 | 回看窗口变短、起播快 | 回看窗口长、起播慢 |
hls_nested | 每路流独立目录 | 目录结构清晰 | 需配合 hls_path 权限 |
经验值:hls_fragment 2s + hls_playlist_length 10s 可以把延迟压到 6~10 秒,同时对带宽的冲击可控。若追求更低延迟,hls_fragment 1s 会让请求数翻倍,需要确认切片目录所在磁盘的 IOPS 与文件系统 inode 足够。
3.1 切片目录的落盘与清理
切片文件写入磁盘再被 HTTP 服务读取,这一步有真实的 IO 压力。假设 1000 路流、每路 2 秒一个切片,每秒产生 500 个文件。若切片目录和日志目录在同一块盘上,很容易把 IO 打满。
# 把切片目录挂到 tmpfs,减少磁盘抖动(代价是内存占用)
mount -t tmpfs -o size=4G tmpfs /var/media/hls
# 观察目录文件数增长
watch -n 5 'ls -1 /var/media/hls | wc -l; df -h /var/media/hls'
模块自身会按 hls_playlist_length 清理过期切片,但如果进程被强杀,残留文件不会被回收,需要一个定时任务兜底:
# 清理 10 分钟未修改的切片
find /var/media/hls -name '*.ts' -mmin +10 -delete
3.2 用 HTTP 服务切片
切片本身是静态文件,交给 NGINX 的 http 块即可:
server {
listen 80;
server_name media.example.com;
root /var/media;
location /hls/ {
alias /var/media/hls/;
add_header Cache-Control "no-cache";
# 允许跨域,hls.js 需要
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, OPTIONS";
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
}
}
m3u8 索引必须 no-cache,否则播放器会拿到过期的切片列表。ts 切片本身是不可变的,可以设长缓存,但要注意 URL 复用问题——同一路流重推后切片文件名可能重复,此时需要版本化路径或改 hls_path。
静态文件服务本身的优化(sendfile、open_file_cache、gzip 取舍)参见 Nginx 静态资源性能优化 。
4. 鉴权与防盗链
直播的鉴权分两个环节:推流鉴权(谁能推)与播放鉴权(谁能看)。两者的威胁模型不同。
4.1 推流鉴权
RTMP 模块提供 on_publish 回调,在推流开始时向一个 HTTP 端点发请求,返回 2xx 才允许推流。
application live {
live on;
hls on;
hls_path /var/media/hls;
on_publish http://127.0.0.1:8080/auth/publish;
on_publish_done http://127.0.0.1:8080/auth/publish_done;
}
回调会带上 app、name(stream key)、addr(推流端 IP)等参数。鉴权服务据此判断 stream key 是否有效、是否已过期、是否与 IP 绑定。
from flask import Flask, request
app = Flask(__name__)
@app.post("/auth/publish")
def auth_publish():
name = request.form.get("name", "")
addr = request.form.get("addr", "")
if not verify_stream_key(name, addr):
return "forbidden", 403
return "ok", 200
注意 on_publish 是同步阻塞的:回调超时会导致推流失败。鉴权服务必须做本地缓存(如 Redis 缓存 stream key),避免每次推流都查数据库。
4.2 播放防盗链
HLS 的播放鉴权通常用「签名 URL」:给每个观众生成一个带过期时间与签名的 URL,NGINX 用 secure_link 模块校验。
location /hls/ {
secure_link $arg_st,$arg_e;
secure_link_md5 "$secure_link_expires$uri$remote_addr secret_key";
if ($secure_link = "") {
return 403;
}
if ($secure_link = "0") {
return 410;
}
alias /var/media/hls/;
}
签名 URL 形如 /hls/live/test.m3u8?st=xxxx&e=1700000000。secure_link_md5 里的变量组合决定了签名的粒度——把 $remote_addr 纳入签名可以防止链接被分享,但会破坏 CDN 边缘节点的缓存(每个 IP 的签名不同)。这是安全与成本的经典取舍:点播用 URI 签名换取缓存命中,直播用 IP 绑定换取防分享。
生成签名的服务端代码:
import base64, hashlib, time
def sign_url(uri: str, client_ip: str, ttl: int = 300) -> str:
expires = int(time.time()) + ttl
raw = f"{expires}{uri}{client_ip} secret_key"
digest = hashlib.md5(raw.encode()).digest()
st = base64.urlsafe_b64encode(digest).decode().rstrip("=")
return f"{uri}?st={st}&e={expires}"
secure_link_md5 的原文必须与生成端完全一致,包括变量顺序与分隔符。任何一处不同都会得到 403,且没有有用的错误信息——这是 secure_link 调试时最耗时的地方。
更完整的鉴权体系(JWT、OAuth、会话校验)可参考 Nginx 鉴权体系:JWT 与 OAuth 。
5. 多码率与转码
单码率无法同时满足弱网手机与宽带大屏。多码率需要把一路输入转成多档清晰度,再生成一个 master playlist。
RTMP 模块的 exec 指令可以在推流开始时拉起 ffmpeg 做转码:
application live {
live on;
# 原流转发到内部应用,由 ffmpeg 消费
exec ffmpeg -i rtmp://127.0.0.1/live/$name \
-c:v libx264 -preset veryfast -b:v 1500k -s 1280x720 \
-c:a aac -b:a 128k -f flv rtmp://127.0.0.1/hls/$name_720p \
-c:v libx264 -preset veryfast -b:v 800k -s 854x480 \
-c:a aac -b:a 96k -f flv rtmp://127.0.0.1/hls/$name_480p;
}
application hls {
live on;
hls on;
hls_path /var/media/hls;
hls_nested on;
hls_variant _720p BANDWIDTH=1600000,RESOLUTION=1280x720;
hls_variant _480p BANDWIDTH=896000,RESOLUTION=854x480;
}
hls_variant 会生成 master m3u8,播放器根据带宽自动切换档位。要点:
- 转码是 CPU 密集型,一台 8 核机器用
veryfast大约能转 3~5 路 720p。超过就要用独立转码集群或 GPU。 exec启动的 ffmpeg 由 NGINX 管理生命周期,推流断开时会收到信号退出,但异常退出不会自动重启,需要exec_publish/exec_publish_done配合监控。- 时间戳对齐:多档转码的切片边界必须对齐,否则切换档位时会花屏。ffmpeg 的
-force_key_frames "expr:gte(t,n_forced*2)"可以把关键帧强制对齐到 2 秒边界,与hls_fragment 2s匹配。
6. 边缘分发与回源
单机接入无法支撑大规模观众,标准做法是「中心源站 + 边缘节点」。
# 边缘节点:只做 HLS 分发与回源,不接收推流
proxy_cache_path /var/cache/hls levels=1:2
keys_zone=hls_cache:100m
max_size=200g inactive=1h;
server {
listen 80;
server_name media.example.com;
location /hls/ {
proxy_pass http://origin.example.com;
proxy_cache hls_cache;
proxy_cache_valid 200 2s; # m3u8 短缓存
proxy_cache_valid 200 1h; # ts 长缓存,按后缀区分
proxy_cache_use_stale error timeout updating;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status;
}
}
关键设计点:
- m3u8 与 ts 用不同的缓存时长。m3u8 会持续更新,缓存超过切片时长就会导致观众卡住;ts 一旦生成就不再变化,可以长缓存。
proxy_cache_lock on防止回源风暴。热门流的第一批观众会同时请求同一个切片,没有锁会导致大量并发回源。proxy_cache_use_stale updating让源站短暂抖动时用旧切片兜底,避免大面积 5xx。
边缘缓存的分层存储与回源保护策略,在 Nginx 边缘缓存与 CDN 架构 里有更完整的展开。
7. 排错与调优
直播问题大多表现为「卡顿」「黑屏」「延迟忽大忽小」,需要按链路分段定位。
观众卡顿但推流正常:先看边缘节点带宽与 X-Cache-Status。如果大量 MISS,说明缓存没生效;如果带宽打满,需要扩容或降码率。
切片生成延迟:ls -l /var/media/hls/<stream>/ 观察最新 ts 的 mtime,与当前时间比较。若差值持续大于 2 倍 hls_fragment,说明切片线程被阻塞,通常是磁盘 IO 或 ffmpeg 转码跟不上。
播放器反复重连:检查 m3u8 是否被错误缓存。用 curl -I 看响应头里的 Cache-Control,以及 X-Cache-Status 是否为 HIT。
# 观察 RTMP 连接数
ss -tn state established '( sport = :1935 )' | wc -l
# 观察 HLS 请求量与状态码分布
tail -f /var/log/nginx/access.log | awk '{print $9}' | sort | uniq -c
延迟持续增大:检查 hls_playlist_length 是否过大,以及播放器是否在追赶直播边缘。有些播放器为了流畅会主动加大缓冲,需要在播放器侧配置 maxBufferLength。
8. 总结
用 NGINX 搭建 RTMP 上行加 HLS 下行的分发链路,核心是把「接入」与「分发」解耦:接入节点负责鉴权、转码与切片,分发节点只做静态文件服务与缓存。延迟、成本、画质三者不可能同时最优,切片时长是调节这三者的主旋钮。
需要提醒的是,RTMP 模块是社区维护的第三方模块,更新频率低于 NGINX 本体,且不支持 QUIC 与 LL-HLS 的新特性。若业务对延迟要求进入 1 秒区间,应评估 SRT、WebRTC 或商业 CDN 方案;RTMP + HLS 的定位是「延迟可接受、成本可控、运维简单」的通用直播分发。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。