在 nginx 框架内直接完成 RTMP 到 WebRTC 的转换,不引入 SRS 这样的独立 RTC 服务,端到端实测首包约 560 ms,延迟从 RTMP/HLS 的 1 到 10 秒降到 0.5 秒以内;视频 H264 761 包、362 KB,音频 Opus 389 包、67 KB,全链路可达。方案与实现开源:模块代码 github.com/DeguiLiu/nginx-rtc-module(纯 C,MIT),部署与示例 github.com/DeguiLiu/nginx-rtc-example。
延迟卡在 TCP 上
问题归结为两点:TCP 的重传与队头阻塞决定了延迟下界,客户端本地缓存决定了保密性。直播分发最常见的组合是 Nginx-rtmp 模块(支持 RTMP、HLS)和它的扩展 Nginx-http-flv-module,三条链路都绕不开码流走 TCP 这个前提。
RTMP 是 Adobe 为 Flash 平台设计的应用层协议,靠 TCP 保证可靠传输,局域网内延迟一般在 1 到 3 秒;主流浏览器早已放弃 Flash,iOS 端还需要第三方解码器。HLS 走 HTTP/80,穿透防火墙没问题,但服务端要把流切成 ts 小文件、客户端按序播放,延迟普遍在 10 秒以上,还会产生海量小文件。Http-Flv 把 RTMP 码流重新封装成 HTTP 长连接流,浏览器免插件就能播,实时性与 RTMP 相当,底子仍是 TCP,1 到 3 秒的瓶颈没有解决,且客户端本地会缓存流媒体资源,保密性差。
两条典型链路的形态如下:
flowchart LR
push["推流端"]:::cli
server["nginx-rtmp-module
流媒体服务器"]:::srv
pc["PC 播放客户端"]:::cli
mobile["移动播放客户端"]:::cli
push -->|"RTMP 推流
TCP"| server
server -->|"RTMP:1 到 3 秒"| pc
server -->|"HLS:10 秒以上"| mobile
classDef srv fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef cli fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
图 1:Nginx-rtmp 的 RTMP/HLS 分发。
flowchart LR
push["推流端"]:::cli
server["Nginx-http-flv-module
流媒体服务器"]:::srv
client["PC 浏览器或
移动播放客户端"]:::cli
push -->|"RTMP 推流
TCP"| server
server -->|"HTTP-FLV:1 到 3 秒
客户端本地缓存"| client
classDef srv fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef cli fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
图 2:Http-Flv 分发。
WebRTC 基于 UDP,面向无连接,规避了 TCP 拥塞控制的开销,且为浏览器原生设计,免插件、免安装、不在播放端落盘。两个瓶颈各对症一半:UDP 压延迟,不落盘保保密性。
整体架构与模块映射
整体思路一句话:推流端照旧走 RTMP,播放端换成 WebRTC。
flowchart LR
push["推流端"]:::cli
server["nginx-rtc-module
流媒体服务器"]:::srv
client["PC 浏览器或
移动播放客户端"]:::cli
push -->|"RTMP 推流
照旧"| server
server -->|"WebRTC over UDP/SRTP
首包约 560 ms"| client
classDef srv fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef cli fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
图 3:方案总体架构。
图中的 nginx-rtc-module 落地为挂在 OpenResty 1.31.1.1 上的一组模块,对应关系如下:
| 方案角色 | 落地实现 | 职责 |
|---|---|---|
| HTTP 服务接口 | ngx_rtc_http_module | /rtc/v1/play/ 信令,offer/answer 收发 |
| UDP 服务 | ngx_rtc_stream_module | UDP :8000 上 STUN/DTLS/SRTP 媒体收发 |
| 转发 RTC 处理模块 | ngx_rtmp_rtc_bridge_module | RTMP 类型模块,经 events 钩子订阅 RTMP 音视频消息 |
| RTC 协议处理 | 纯 C 核心 rtp/sdp/stun/dtls/srtp/audio | 无 nginx 依赖,可 host 单测 |
| 转码与封装 | AAC 转 Opus(FFmpeg 加 libopus)、NALU 转 RTP(RFC 6184) | 音频转码加 RTP 打包 |
落地约束里最要紧的一条:桥接模块通过 postconfiguration 把处理器注册进 cmcf->events[],不改动 nginx-http-flv-module 一行源码,HTTP-FLV 分发与 WebRTC 分发并行存在、互为兜底。
RTC 服务如何嵌进 nginx
业务流程的扩展
先看原 Nginx-rtmp 模块的工作流程,启动、初始化、握手、建通道、传数据、断开,六步线性:
flowchart TB
pusher["推流客户端"]:::cli
player["rtmp 播放客户端"]:::cli
n1["(1) nginx 初始化"]:::srv
n2["(2) nginx_rtmp 模块初始化
读取配置文件"]:::srv
n3["(3) 握手建立 rtmp 连接"]:::srv
n4["(4) 初始化网络连接
创建码流传输通道"]:::srv
n5["(5) 传输媒体数据
推送到播放客户端"]:::srv
n6["(6) 断开推送"]:::srv
n1 --> n2
n2 --> n3
n3 --> n4
n4 --> n5
n5 --> n6
pusher --> n3
n3 --> player
n5 --> player
classDef cli fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef srv fill:#dcfce7,stroke:#16a34a,color:#14532d
图 4:原有 Nginx-rtmp 流程。
方案的扩展在四个位置插入 RTC 环节:rtmp 初始化后并行做 RTC 服务初始化(a);播放端经 HTTP 建立 WebRTC 连接(b);传输媒体数据时把 RTMP 码流转发给 RTC 处理模块(c),做 RTP 封装(d)后推送给所有订阅者(e)。实际实现中,RTC 服务初始化与推流主流程并无先后耦合,播放端建链也是与推流解耦的独立路径,其结果是 session 注册为该流的订阅者,在推送环节(e)与主流程汇合。
flowchart TB
pusher["推流客户端"]:::cli
rtcplayer["rtc 播放客户端"]:::cli
n1["(1) nginx 初始化"]:::srv
n2["(2) nginx-rtmp 模块初始化
读取配置文件"]:::srv
a["a. RTC 服务初始化
注册 HTTP/UDP 服务"]:::rtc
n3["(3) 握手建立 rtmp 连接"]:::srv
n4["(4) 初始化网络连接
创建码流传输通道"]:::srv
n6["(6) 传输媒体数据"]:::srv
n7["(7) 断开推流"]:::srv
b["b. 建立播放
webrtc 网络连接"]:::rtc
c["c. 转发 rtmp 数据
至 rtc 处理模块"]:::rtc
d["d. 音视频 rtp 数据封装"]:::rtc
e["e. 将 rtp 数据推送至
所有 rtc 订阅者"]:::rtc
n1 --> n2
n2 --> n3
n3 --> n4
n4 --> n6
n6 --> n7
n2 --> a
pusher --> n3
pusher --> n7
rtcplayer --> b
a -.-> b
n6 --> c
c --> d
d --> e
b --> e
e --> rtcplayer
classDef cli fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef srv fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef rtc fill:#ede9fe,stroke:#7c3aed,color:#4c1d95
图 5:扩展后的 RTC 工作流程。a 到 e 为新增环节。
落地实现与图 5 的对应:a 对应 stream/http/bridge 三个模块的 nginx 标准初始化钩子;b 对应 /rtc/v1/play/ 信令;c、d、e 对应桥接模块订阅 NGX_RTMP_MSG_VIDEO / NGX_RTMP_MSG_AUDIO 事件后进入纯 C 核心的 RTP 封装与逐订阅者 SRTP 加密广播。
配置项与加载顺序
RTC 配置按 nginx 的模块体系分配到 rtmp、http、stream 三个上下文,真实指令如下:
# RTMP 侧(bridge 模块,nginx-http-flv-module 的 rtmp 上下文)
rtmp {
rtc_audio_bitrate 64000; # AAC→Opus 目标码率
rtc_rtcp_sr_interval 2000ms; # RTCP SR 发送周期
server {
listen 1935;
application live { live on; gop_cache on; }
}
}
# HTTP 信令侧(ngx_rtc_http_module)
http {
server {
listen 18082;
location /rtc/v1/play/ {
access_by_lua_file conf/auth.lua; # stream key 校验 + 限流
rtc_candidate_ip 172.16.48.122; # answer 下发的 candidate
rtc_candidate_port 8000;
rtc_play;
}
}
}
# UDP 媒体侧(ngx_rtc_stream_module)
stream {
server {
listen 8000 udp reuseport;
rtc; # 交给 stream 模块处理
rtc_handshake_timeout 10s; # 握手中 session 空闲回收阈值
rtc_ready_timeout 30s; # 就绪 session 空闲回收阈值
}
}
会话超时拆成 rtc_handshake_timeout 与 rtc_ready_timeout 两级(握手中与就绪后分别回收)。B 帧没有做成开关,实现里无条件丢弃(ngx_rtc_h264_is_b_frame,WebRTC 低延迟播放不支持 B 帧)。AAC 固定转 Opus,只保留 rtc_audio_bitrate 一个可调参数。
配置加载遵循 nginx 模块体系的固有顺序:先系统级、再已加载模块、最后新模块,RTC 服务配置自然排在 nginx-rtmp 之后。
flowchart LR
s1["Nginx 系统配置加载"]:::cfg
s2["nginx-rtmp 模块配置加载"]:::cfg
s3["RTC 服务配置加载
bridge / http / stream"]:::rtc
s1 --> s2
s2 --> s3
classDef cfg fill:#cffafe,stroke:#0891b2,color:#164e63
classDef rtc fill:#ede9fe,stroke:#7c3aed,color:#4c1d95
图 6:系统配置加载顺序。
HTTP 播放接口按 nginx 自定义模块规范创建 ngx_command_t、ngx_http_module_t、ngx_module_t 注册进 http 体系;UDP 服务同理走 stream 体系。
代码分两层,靠 addon config 脚本决定编译归属。纯 C 核心(rtp、sdp、stun、dtls、srtp、audio、rtcp、core)不引用 nginx 头文件、无全局可变状态,输入输出全部经参数传入,加解密与封装的回调由调用者持有 scratch buffer,因此可以脱离 nginx 在 host 上单测;nginx 胶水层只做"事件到纯 C 调用再到 socket 发送"的翻译,业务逻辑一律下沉。协议核心有 37 个 host 单测用例,覆盖 rtp/stun/sdp/rtcp/hsm/session_fsm,其中 B 帧识别的 Exp-Golomb 解析缺陷就是被单测断言拦下的。
媒体热路径没有引入协程和自建线程与队列,全部跑在 nginx 单事件循环内:桥接(生产者)与 stream(消费者)同处一个地址空间,每个 session 挂在 source 的订阅者队列上,RTP 到达时直接遍历订阅者逐个 SRTP 加密发送,回收交给 ngx_event_timer 周期 reap。这正是"在 nginx 框架内完成转换、不引入独立 RTC 服务"定位的直接结果。唯一的例外是 AAC 转 Opus 转码:FFmpeg 解码加 libopus 编码是 CPU 密集操作,同步跑在事件循环里会让多路推流互相挤压,因此每路转码放独立 pthread,nginx worker 只负责投递裸 AAC 帧、取回 Opus 帧,中间用有界环衔接。
建链与桥接
与播放客户端建链
一次 WebRTC 播放要跨三个协议面:HTTP 信令交换 SDP、STUN 做 ICE 连通性检查、DTLS 建安全通道,最后才是 SRTP 媒体。建链时序如下。注意 session 创建与 answer 生成都收敛在 HTTP 信令模块一次完成,udp 侧只负责按 ICE ufrag 匹配 session:
sequenceDiagram
participant P as 播放客户端
participant H as 信令模块 ngx_rtc_http_module
participant S as UDP 服务 ngx_rtc_stream_module
rect rgb(219, 234, 254)
Note over P,H: 第一段 信令交换 SDP
P->>H: POST /rtc/v1/play/ 携带播放 URL 与 offer SDP
H->>H: 校验 stream key,从进程堆创建 session
H-->>P: answer SDP,携带 media 级 a=candidate
end
rect rgb(254, 243, 199)
Note over P,S: 第二段 ICE 连通性检查
P->>S: STUN binding request
S->>S: 按 ICE ufrag 匹配 session
S-->>P: STUN binding success
end
rect rgb(220, 252, 231)
Note over P,S: 第三段 安全通道与媒体
P->>S: DTLS 握手
S->>S: 导出 SRTP 密钥,session 订阅 source
S-->>P: SRTP 音视频码流
end
图 7:与 RTC 播放客户端建立连接。
要点:
- 播放客户端一次 POST 同时携带播放 URL 和 offer SDP(信令契约兼容 SRS
/rtc/v1/play/JSON 格式,offer 与 play 请求合并),RTC 服务校验 key 后返回 answer SDP; - session 由信令侧创建并从进程堆分配,它的生命周期跨越 HTTP 请求(返回 answer 后还要在 UDP 侧完成 DTLS/SRTP),不能用 request pool;
- 播放端解析 answer 发起 STUN binding,udp 服务按 ICE ufrag 匹配到 session 返回成功。匹配键是 answer 下发的 ufrag,所以 answer 必须携带 media 级
a=candidate,否则播放器(实测 werift)根本不会发起 STUN,表现为信令成功但零媒体; - DTLS 握手完成后导出 SRTP 密钥,session 订阅到对应 source,进入订阅者列表,由 udp 服务推送加密后的音视频码流。
RTMP 到 RTC 桥接
这一步的关键是:不改 nginx-rtmp 的分发主干,只在推送码流前插一个旁路。
flowchart LR
RTMP["RTMP 推流连接"]:::cli
BR["ngx_rtmp_rtc_bridge_module
订阅 VIDEO / AUDIO 事件"]:::srv
CPY["推送前先拷一份码流给 RTC 服务"]:::srv
AUD["音频:AAC 转 Opus"]:::core
VID["视频:NALU 转 RTP
single / STAP-A / FU-A,剔除 B 帧"]:::core
SRTP["逐 session SRTP 加密发送"]:::core
DIST["RTMP 播放客户端
原分发主干不变"]:::cli
RTMP --> BR
BR --> CPY
CPY --> AUD
CPY --> VID
AUD --> SRTP
VID --> SRTP
RTMP --> DIST
classDef cli fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef srv fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef core fill:#ede9fe,stroke:#7c3aed,color:#4c1d95
图 8:Rtmp 转 RTC 桥接模块。
桥接模块做两件事:推流连接建立后用推流信息初始化桥接对象;在原发送逻辑把码流给 RTMP 播放器之前,先拷一份给 RTC 服务处理。封装细节:音频按标准转成 Opus,视频将 NALU 打包成 RTP single/STAP-A/FU-A 包,B 帧在封装前剔除。
落地实现中,这一步对应 ngx_rtmp_rtc_bridge_module(NGX_RTMP_MODULE 类型):它在 postconfiguration 阶段把视频与音频处理器注册进 cmcf->events[] 的 NGX_RTMP_MSG_VIDEO / NGX_RTMP_MSG_AUDIO 事件数组,只在 publishing 会话上激活,不改动 nginx-http-flv-module 源码。产出的明文 RTP 以 source 级共享(一份缓存、N 个订阅者),每个 session 加密前拷贝到自身缓冲并重写本 session 协商的 payload type。SRTP 密文逐 session 不同(DTLS 导出密钥独立),这是协议决定的拷贝下界。
实测效果与实现要点
端到端实测(单 worker,局域网口径):
| 指标 | 结果 |
|---|---|
| 首包延迟 | 约 560 ms(对比 RTMP/Http-Flv 1~3 s、HLS 10 s 以上) |
| 视频链路 | H264 转 RTP 761 包 / 362 KB,含 B 帧过滤与 FU-A 分片 |
| 音频链路 | AAC 转 Opus 389 包 / 67 KB |
| 信令鉴权 | 正确 stream key 返回 code=0,RTMP/HTTP-FLV/WebRTC 三路同源鉴权 |
| 兜底能力 | HTTP-FLV 分发保留,与 WebRTC 并行 |
首包 560 ms 的构成未做逐段拆分;真实出图延迟以播放器首个 IDR 到达为准,多分辨率转码档实测 476~554 ms。
骨架部分照方案实现没有意外,卡时间的是两类东西:协议细节,和弱网与规模化的补课。
三个最容易踩的协议坑
- session 不能用 request pool 分配。session 生命周期跨越 HTTP 请求,返回 answer 之后还要在 UDP 侧完成 DTLS/SRTP。用
r->pool会在响应返回后内存失效,后续 STUN 匹配读到垃圾。正确做法是ngx_alloc从进程堆分配,配合空闲 reap 定时器回收。 - DTLS server 要显式
SSL_set_accept_state。OpenSSL 不会因为用了DTLS_server_method()就自动进入 accept 状态,漏掉这一步报ssl_read_internal:uninitialized,握手无感知失败。 - STUN username 的顺序不一定是 RFC 里那个。UDP 侧要靠 ICE ufrag 从 username 里匹配 session,标准写法是
client_ufrag:server_ufrag,但实测 werift 发出来的是server_ufrag:client_ufrag,前半是服务端 ufrag。取错一半,STUN 永远匹配不到 session,媒体零包且没有任何报错。
推流端没有 RTC 上行,QoS 得自己造
RTMP 推流是 TCP 可靠输入,播放端唯一能给出的反馈就是 RTCP。推流端不是浏览器编码器,PLI(关键帧请求)无处可送,因此用"缓存最近一个 GOP 加订阅即发"替代:source 内 2048 槽环形缓存明文 RTP,新订阅者在 DTLS/SRTP 就绪时先回放最近一个 GOP(从最新 IDR 的 STAP-A 起),不用等下一个自然关键帧,首帧延迟压到一个关键帧间隔以内。
丢包恢复走发送侧 NACK:answer 声明 a=rtcp-fb:102 nack 与 nack pli,stream 模块解码 SRTCP,按 media_ssrc 过滤后从 ring 取包重传。两个容易忽略的点:音视频 seq 各自独立计数,重传缓存要么按媒体分环、要么按 ssrc 加 seq 双重匹配,否则会重传出错误流的数据;GOP 回放瞬间可突发上千包,socket 满时直接丢并计数,让客户端经 NACK/PLI 自行恢复,回放循环检测到发送失败即提前终止,避免冲击播放器的抖动缓冲区。
TWCC、GCC、REMB 这些上行带宽估计在本场景不适用:单向播放没有上行媒体,服务端也没有可下调码率的编码器,明确不做。
多 worker 与多分辨率
多 worker:信令落在 worker A、UDP 落在 worker B 时,进程内注册表查不到 session。解法是把跨 worker 必需的身份信息(name、SSRC/PT、seq/ts、SPS/PPS、ufrag/pwd、就绪标志、owner worker)放进 ngx_shm_zone 加 ngx_slab_pool,媒体数据走 per-worker 环形队列,eventfd 在 master fork 前创建、所有 worker 继承后互相唤醒,owner worker 消费环并做 SRTP 加密发送。一条硬约束贯穿始终:DTLS/SRTP/转码器这些私有句柄绝不进 shm,共享结构只放可以 memcpy 进 slab 的字段。
多分辨率:模块内不引 x264(GPL 风险),转码档用一路外部 ffmpeg 级联完成:拉源流转 1080p/720p/540p/360p 四档推回服务器成独立流,每档独立鉴权,播放端改流名即可切档。x264 侧 zerolatency + bf=0 保证每一档都保持 WebRTC 可播的无 B 帧低延迟码流;fps=30 滤镜放在滤镜链首,否则 testsrc 源经转码后帧率会翻倍。
更多实现细节(状态机、GOP 环结构、shm 布局、单测清单)见模块源码 github.com/DeguiLiu/nginx-rtc-module 与部署示例 github.com/DeguiLiu/nginx-rtc-example。