在 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_moduleUDP :8000 上 STUN/DTLS/SRTP 媒体收发
转发 RTC 处理模块ngx_rtmp_rtc_bridge_moduleRTMP 类型模块,经 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 播放客户端建立连接。

要点:

  1. 播放客户端一次 POST 同时携带播放 URL 和 offer SDP(信令契约兼容 SRS /rtc/v1/play/ JSON 格式,offer 与 play 请求合并),RTC 服务校验 key 后返回 answer SDP;
  2. session 由信令侧创建并从进程堆分配,它的生命周期跨越 HTTP 请求(返回 answer 后还要在 UDP 侧完成 DTLS/SRTP),不能用 request pool;
  3. 播放端解析 answer 发起 STUN binding,udp 服务按 ICE ufrag 匹配到 session 返回成功。匹配键是 answer 下发的 ufrag,所以 answer 必须携带 media 级 a=candidate,否则播放器(实测 werift)根本不会发起 STUN,表现为信令成功但零媒体;
  4. 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。

骨架部分照方案实现没有意外,卡时间的是两类东西:协议细节,和弱网与规模化的补课。

三个最容易踩的协议坑

  1. session 不能用 request pool 分配。session 生命周期跨越 HTTP 请求,返回 answer 之后还要在 UDP 侧完成 DTLS/SRTP。用 r->pool 会在响应返回后内存失效,后续 STUN 匹配读到垃圾。正确做法是 ngx_alloc 从进程堆分配,配合空闲 reap 定时器回收。
  2. DTLS server 要显式 SSL_set_accept_state。OpenSSL 不会因为用了 DTLS_server_method() 就自动进入 accept 状态,漏掉这一步报 ssl_read_internal:uninitialized,握手无感知失败。
  3. 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。