低延迟直播WHIP-WHEP落地
把 WebRTC 当作直播协议来用。WHIP 标准化推流入口(OBS / ffmpeg → 服务端),WHEP 标准化拉流出口(服务端 → 浏览器播放器)。 端到端延迟可以稳定压到 500ms 以内,且只用一个 HTTP 请求完成信令,对 CDN 化部署友好。
本文聚焦四个工程问题:协议怎么实现、Pion 自研 WHIP Server 怎么写、CDN 边缘怎么部署、怎么和 RTMP / HLS 共存。
1. 为什么需要 WHIP / WHEP
传统直播链路是 OBS → RTMP → 转码集群 → HLS/DASH → CDN → 播放器,端到端延迟 6 ~ 30s。即便上 LL-HLS、CMAF 低延迟切片,理论下限也在 2s 量级,且对编码器 GOP、播放器缓冲都有强约束。
而 WebRTC 走 SRTP over UDP,自带丢包重传、拥塞控制、抖动缓冲,延迟下限在 100 ~ 300ms。问题在于过去的 WebRTC 没有标准的"推流 / 拉流"信令,每家自研一套 WebSocket 协议,OBS、ffmpeg 这种工业级编码器接不进来。
WHIP(RFC 9725,2025 年 1 月发布)和 WHEP(draft-ietf-wish-whep,2026 年仍在 IETF 流程中)解决的就是这一层:把信令固化成一次 HTTP 请求。
什么时候适用:
- 互动直播、电商带货、云游戏、远程协作、体育赛事等对延迟敏感(< 1s)的场景。
- 已经有 OBS / ffmpeg 工作流,又希望客户端零插件起播。
- 单流并发观看在万级以下,或上层有 CDN 调度的中型直播。
什么时候不适用:
- 纯录播 / 点播,没有低延迟需求,HLS / DASH 更便宜更稳。
- 单流百万并发的头部赛事,纯 WebRTC 边缘集群成本远高于 HLS CDN,必须配 LL-HLS / CMAF 兜底。
- 终端在仅允许 80/443 TCP 出网、且无法部署 TURN/TCP 的极端 NAT 环境,UDP 通道存在概率性失败。
失败时的样子:浏览器拉不出画面又不报错,OBS 推流码率正常但服务端 ssrc 一直收不到 RTP,或者 TURN 流量打爆带宽——这些都是 WHIP/WHEP 自研最常遇见的现场。
2. 协议详解(WHIP / WHEP)
2.1 WHIP 推流流程
WHIP 用三条 HTTP 动词承载完整生命周期:
# 1) 推流端 → 服务端:POST 一个 SDP Offer
POST /whip/{streamKey} HTTP/1.1
Host: ingest.example.com
Content-Type: application/sdp
Authorization: Bearer eyJhbGciOi...
v=0
o=- 4611732250081257 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1
m=audio 9 UDP/TLS/RTP/SAVPF 111
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98
...
# 2) 服务端响应:201 + Location + Answer SDP
HTTP/1.1 201 Created
Location: https://ingest.example.com/whip/{streamKey}/resource/8f3a91
Content-Type: application/sdp
ETag: "8f3a91-v1"
v=0
...(服务端 Answer,已包含 ICE-Lite candidate 与 DTLS 指纹)
# 3) 推流端追加 ICE 候选(Trickle ICE)
PATCH /whip/{streamKey}/resource/8f3a91 HTTP/1.1
Content-Type: application/trickle-ice-sdpfrag
If-Match: "8f3a91-v1"
a=ice-ufrag:abcd
a=ice-pwd:efgh
m=audio 9 UDP/TLS/RTP/SAVPF 111
a=mid:0
a=candidate:1 1 UDP 2122252543 192.168.1.10 50000 typ host
# 4) 释放资源
DELETE /whip/{streamKey}/resource/8f3a91 HTTP/1.1
Authorization: Bearer eyJhbGciOi...
几个工程细节:
- 必须返回 201 而不是 200。RFC 9725 用 201 + Location 来表达"创建了一个会话资源",CDN 调度层依赖这个语义。
- Location 必须是绝对路径或绝对 URL,且后续 PATCH/DELETE 都打到这个 URL,跨节点透传时要带上路由信息(见第 6 节 CDN 部署)。
- PATCH 用
application/trickle-ice-sdpfrag,body 是 SDP 片段而不是完整 SDP,且不需要走 setLocalDescription/setRemoteDescription 的二次协商。 - ETag/If-Match 是可选但强烈建议,防止重连竞态把已迁移的会话挂回老节点。
2.2 WHEP 拉流流程
WHEP 与 WHIP 完全对称,只是 transceiver 方向相反:
POST /whep/{streamKey} HTTP/1.1
Accept: application/sdp
Content-Type: application/sdp
Authorization: Bearer ...
v=0
...(播放端 Offer,全部 m= 行 a=recvonly)
HTTP/1.1 201 Created
Location: /whep/{streamKey}/resource/c12e88
Content-Type: application/sdp
WHEP 端点的扩展能力(draft 阶段,行业实现已落地):
- Layer 选择:
POST的 SDP 里通过a=ssrc-group:SIM协商 Simulcast / SVC 层,运行时通过 PATCHapplication/whep-layer+json切换。 - Server-Sent Events:服务端可以下行码流变化、连接状态,避免播放端轮询。
什么时候不适用:如果你的客户端能完整托管 WebSocket,又需要更细粒度的房间逻辑(连麦、举手、共享屏幕),WHEP 反而限制太多,直接用自研信令 + SFU 更合适。
2.3 与传统 WebRTC 信令的差异
| 维度 | 自研 WS 信令 | WHIP / WHEP |
|---|---|---|
| 信令通道 | 长连接 WebSocket | 一次 HTTP 请求即可起会话 |
| 重协商 | renegotiate via WS | PATCH SDP frag(或重新 POST) |
| CDN / LB 友好度 | 需要 sticky session、心跳保活 | 标准 HTTP,CDN 可缓存 ICE 配置 |
| 客户端兼容 | 必须自研 SDK | OBS / ffmpeg / 浏览器原生 |
| 失败模式 | 心跳掉线、WS 重连风暴 | TCP 连接 / DNS / 4xx,易诊断 |
3. 整体架构
设计取舍:
- Ingest 和 Origin 分层,原因是 WHIP 入口需要靠近主播以保证上行质量,而 Origin 需要稳定的多分发能力(含转码)。两层之间用 RTP over QUIC 或 SRT 做骨干传输,比 WebRTC 自身的 SFU 级联更可控。
- Edge SFU 只做 WHEP 出口,不接 WHIP 推流;这样故障域是分离的,主播侧的 ICE 风暴不会击穿观看侧。
- 转码作为旁路而不是主链路,避免转码失败影响 WHEP 主路。
为什么不直接 Ingest → Edge:因为单一推流要分发到 N 个 Edge 时,缺一个 Origin 做扇出会让 Ingest 节点 CPU / 带宽爆掉,且无法在中间做一次"权威 RTP 时间戳"标准化。
4. Pion 自研 WHIP Server
下面是一个可运行的最小 WHIP Server 骨架。它实现:
- POST / PATCH / DELETE 三个端点
- TrackLocalStaticRTP 作为转发缓冲
- PLI(Picture Loss Indication)节流防止反复打爆推流端
- 资源 ID 与 streamKey 绑定,方便 WHEP 端订阅
package main
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/pion/rtcp"
"github.com/pion/webrtc/v4"
)
// Stream 表示一路源流,承载音视频两条 TrackLocalStaticRTP,供 WHEP 订阅
type Stream struct {
mu sync.RWMutex
StreamKey string
ResourceID string
PC *webrtc.PeerConnection
AudioTrack *webrtc.TrackLocalStaticRTP
VideoTrack *webrtc.TrackLocalStaticRTP
LastPLI time.Time
}
type Hub struct {
mu sync.RWMutex
streams map[string]*Stream // key: streamKey
}
func NewHub() *Hub { return &Hub{streams: map[string]*Stream{}} }
func (h *Hub) Put(s *Stream) {
h.mu.Lock()
defer h.mu.Unlock()
h.streams[s.StreamKey] = s
}
func (h *Hub) Get(key string) *Stream {
h.mu.RLock()
defer h.mu.RUnlock()
return h.streams[key]
}
func (h *Hub) Delete(key string) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.streams, key)
}
// ---- WHIP POST ----
func (h *Hub) handleWHIPPost(w http.ResponseWriter, r *http.Request) {
streamKey := strings.TrimPrefix(r.URL.Path, "/whip/")
if streamKey == "" {
http.Error(w, "missing stream key", http.StatusBadRequest)
return
}
if !authBearer(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
offerSDP, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// 1. 创建 PeerConnection(ICE-Lite 模式:服务端公网直挂)
cfg := webrtc.Configuration{}
api := webrtc.NewAPI(webrtc.WithSettingEngine(newServerSettingEngine()))
pc, err := api.NewPeerConnection(cfg)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
stream := &Stream{StreamKey: streamKey, ResourceID: uuid.NewString(), PC: pc}
// 2. 预声明两条 TrackLocal,便于 WHEP 端复用
stream.AudioTrack, _ = webrtc.NewTrackLocalStaticRTP(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus, ClockRate: 48000, Channels: 2},
"audio", "whip-"+stream.ResourceID,
)
stream.VideoTrack, _ = webrtc.NewTrackLocalStaticRTP(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264, ClockRate: 90000},
"video", "whip-"+stream.ResourceID,
)
// 3. 收到 RTP 后转发到 TrackLocal;并在视频轨上做 PLI 节流
pc.OnTrack(func(remote *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
go h.forwardLoop(stream, remote)
if strings.HasPrefix(remote.Codec().MimeType, "video/") {
go h.pliLoop(stream, remote.SSRC())
}
})
// 4. ICE 连接状态:失联即清理
pc.OnICEConnectionStateChange(func(s webrtc.ICEConnectionState) {
if s == webrtc.ICEConnectionStateFailed || s == webrtc.ICEConnectionStateClosed {
h.Delete(streamKey)
_ = pc.Close()
}
})
if err := pc.SetRemoteDescription(webrtc.SessionDescription{
Type: webrtc.SDPTypeOffer, SDP: string(offerSDP),
}); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
answer, err := pc.CreateAnswer(nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 等待 ICE 收敛后再回 SDP,简化 Trickle(也可启用真正的 Trickle,见 §4.2)
gatherComplete := webrtc.GatheringCompletePromise(pc)
_ = pc.SetLocalDescription(answer)
<-gatherComplete
h.Put(stream)
w.Header().Set("Location", fmt.Sprintf("/whip/%s/resource/%s", streamKey, stream.ResourceID))
w.Header().Set("Content-Type", "application/sdp")
w.Header().Set("ETag", `"`+stream.ResourceID+`-v1"`)
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, pc.LocalDescription().SDP)
}
// ---- 转发 RTP 到 TrackLocal ----
func (h *Hub) forwardLoop(s *Stream, remote *webrtc.TrackRemote) {
buf := make([]byte, 1500)
var target *webrtc.TrackLocalStaticRTP
if strings.HasPrefix(remote.Codec().MimeType, "audio/") {
target = s.AudioTrack
} else {
target = s.VideoTrack
}
for {
n, _, err := remote.Read(buf)
if err != nil {
return
}
if _, err := target.Write(buf[:n]); err != nil && err != io.ErrClosedPipe {
return
}
}
}
// ---- PLI 节流:1s 内最多 1 次,避免被刷卡顿 ----
func (h *Hub) pliLoop(s *Stream, ssrc webrtc.SSRC) {
tk := time.NewTicker(1 * time.Second)
defer tk.Stop()
for range tk.C {
s.mu.Lock()
if time.Since(s.LastPLI) < 1*time.Second {
s.mu.Unlock()
continue
}
s.LastPLI = time.Now()
s.mu.Unlock()
_ = s.PC.WriteRTCP([]rtcp.Packet{
&rtcp.PictureLossIndication{MediaSSRC: uint32(ssrc)},
})
}
}
// ---- WHIP PATCH(Trickle ICE)----
func (h *Hub) handleWHIPPatch(w http.ResponseWriter, r *http.Request) {
// 形如 /whip/{streamKey}/resource/{resourceID}
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 5 {
http.Error(w, "bad url", http.StatusBadRequest)
return
}
streamKey := parts[2]
s := h.Get(streamKey)
if s == nil || s.ResourceID != parts[4] {
http.Error(w, "resource gone", http.StatusNotFound)
return
}
frag, _ := io.ReadAll(r.Body)
for _, c := range parseICEFragment(string(frag)) {
_ = s.PC.AddICECandidate(c)
}
w.WriteHeader(http.StatusNoContent)
}
// ---- WHIP DELETE ----
func (h *Hub) handleWHIPDelete(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
streamKey := parts[2]
if s := h.Get(streamKey); s != nil {
_ = s.PC.Close()
h.Delete(streamKey)
}
w.WriteHeader(http.StatusNoContent)
}
func main() {
hub := NewHub()
mux := http.NewServeMux()
mux.HandleFunc("/whip/", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
hub.handleWHIPPost(w, r)
case http.MethodPatch:
hub.handleWHIPPatch(w, r)
case http.MethodDelete:
hub.handleWHIPDelete(w, r)
default:
w.Header().Set("Allow", "POST, PATCH, DELETE")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
// WHEP 与 WHIP 镜像:用 hub.Get(streamKey) 取 Stream,把 AudioTrack/VideoTrack 加到新建 PC
mux.HandleFunc("/whep/", whepHandler(hub))
srv := &http.Server{Addr: ":8443", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
_ = srv.ListenAndServeTLS("server.crt", "server.key")
_ = context.Background()
}
WHEP 端点关键代码:
func whepHandler(hub *Hub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
streamKey := strings.TrimPrefix(r.URL.Path, "/whep/")
s := hub.Get(streamKey)
if s == nil {
http.Error(w, "no such stream", http.StatusNotFound)
return
}
offerSDP, _ := io.ReadAll(r.Body)
api := webrtc.NewAPI(webrtc.WithSettingEngine(newServerSettingEngine()))
pc, _ := api.NewPeerConnection(webrtc.Configuration{})
// 把源流的 TrackLocal 加进来;recvonly 由 SDP Offer 表达
_, _ = pc.AddTrack(s.AudioTrack)
_, _ = pc.AddTrack(s.VideoTrack)
_ = pc.SetRemoteDescription(webrtc.SessionDescription{
Type: webrtc.SDPTypeOffer, SDP: string(offerSDP),
})
answer, _ := pc.CreateAnswer(nil)
_ = pc.SetLocalDescription(answer)
<-webrtc.GatheringCompletePromise(pc)
w.Header().Set("Location",
fmt.Sprintf("/whep/%s/resource/%s", streamKey, uuid.NewString()))
w.Header().Set("Content-Type", "application/sdp")
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, pc.LocalDescription().SDP)
}
}
4.1 关键工程点
- ICE-Lite 模式:服务端公网部署、不主动发 STUN binding request,靠对端打洞。
SettingEngine.SetLite(true),并通过SetNAT1To1IPs注入公网 IP。否则在云主机的 floating IP 场景下 SDP 里写的是内网地址。 - TrackLocalStaticRTP vs TrackLocalStaticSample:转发场景必须用前者,直接转发原始 RTP,不解码不重新打包,CPU 几乎为零。后者面向 Sample 输入(如 ffmpeg 解码后),会重打 RTP,仅用于服务端合成场景。
- PLI 节流:实测如果不限频,10 个 WHEP 观众同时连入会导致每秒数十个 PLI 涌向推流端,OBS 会反复重发关键帧把上行码率打到 3x。1s/次的窗口是经验值。
- GatheringCompletePromise:示例里用了等待 ICE 收敛,简化客户端无需 PATCH。生产环境推荐启用 Trickle ICE(见 4.2),首帧时间能再省 200 ~ 500ms。
4.2 Trickle ICE 的正确实现
Trickle ICE 是 WHIP 性能的关键。如果不实现:
- 服务端必须等
iceGatheringState=complete才能回 Answer,公网主机一般要 300ms ~ 1.5s。 - 客户端在拿到 Answer 之前画面就出不来,首帧延迟暴涨。
实现要点:
// 服务端在 OnICECandidate 触发时通过 SSE 推送给客户端
pc.OnICECandidate(func(c *webrtc.ICECandidate) {
if c == nil { return }
sseBroadcast(stream.ResourceID, c.ToJSON())
})
// 客户端通过 PATCH /whip/.../resource/xx 提交候选
// 服务端解析 trickle-ice-sdpfrag → pc.AddICECandidate
服务端 → 客户端的候选传递有两种选型:
- 同 SDP 一次给齐(最简单):要求服务端 ICE-Lite + 1:1 NAT 映射。OBS 31+ 接受这种模式。
- SSE 推送增量候选:HEAD
Link: <...>;rel="ice-server"引导客户端订阅。复杂度高但兼容多 NAT。
什么时候不实现 Trickle:服务端是 ICE-Lite 且只有一个公网候选时,可以省。但只要服务端有多个候选(IPv4/IPv6、TCP fallback、TURN relay),就必须实现 Trickle,否则首帧时间会塌方。
4.3 PLI / NACK / FIR 策略
- PLI:1s 节流,且只在有 WHEP 订阅者存在时下发,否则属于无效请求。
- NACK:Pion 默认开启
webrtc.RegisterDefaultInterceptors,无需额外处理。但要注意 NACK buffer 默认 8192 packet,弱网下不够,建议调到 32768。 - FIR:行业普遍废弃,OBS / ffmpeg 都用 PLI。除非你接的是老旧硬编码器,不要主动发 FIR。
失败时的样子:观众端首屏白屏 1 ~ 3 秒,然后突然亮起——这是 PLI 节流不当或没发 PLI,等关键帧自然到达造成的。
5. OBS 31+ WHIP 接入
OBS Studio 30.0 引入 WHIP 输出,31.0 起加入 AV1 / HEVC,配置稳定下来。
5.1 配置步骤
- 设置 → 推流 → 服务 选择
WHIP。 - 服务器:
https://ingest.example.com/whip/{streamKey}(注意要 https,OBS 会强制校验 TLS)。 - Bearer Token:填鉴权令牌,OBS 会自动加
Authorization: Bearer ...头。 - 编码器:见 5.2。
5.2 推流端编码参数
| 参数 | 推荐值 | 原因 |
|---|---|---|
| 视频编码 | H.264 baseline / constrained baseline | 兼容所有 WebRTC 浏览器;High Profile 在 Safari 18 之前会拒收 |
| 速率控制 | CBR | WebRTC 拥塞控制 GCC 假设码率稳定,VBR 会让 estimator 抖动 |
| keyframe interval | 1 ~ 2s | 越短首帧越快,但带宽开销越大;2s 是经验平衡点 |
| B 帧 | 关闭(bframes=0) | B 帧引入解码乱序,WHEP 抖动缓冲会被打爆 |
| Profile | baseline | 同上,最大兼容性 |
| Tune | zerolatency(x264) | 关闭 lookahead,编码延迟降到 1 帧 |
| 码率 | 1080p60 → 4 ~ 6 Mbps;720p30 → 2 ~ 3 Mbps | 高于此值边际收益递减 |
| 音频编码 | Opus 48kHz stereo 128kbps | WebRTC 强制 Opus,AAC 会被拒 |
AV1 选不选:
- 选 AV1 的场景:码率敏感、画面以静态或缓慢运动为主(PPT 直播、桌面共享);目标观众用 Chrome 105+ / Safari 18+。
- 不选的场景:移动端为主(解码功耗显著高于 H.264)、需要快速首帧、推流端无硬件 AV1 编码。
什么时候不适用 OBS WHIP:
- 需要复杂的混流(多源、多机位、跨主播 PK),OBS 单线程渲染容易吃满 CPU,应改用专业混流服务(vMix、Wirecast)+ ffmpeg WHIP。
- 需要硬件低延迟,OBS 自带编码 pipeline 引入 ~80ms 缓冲,竞品如 Tencent Yueliang、声网 RTSA 端到端可压到 200ms 以内。
失败时的样子:OBS 显示已连接但红色重连图标,常见原因是服务端没回 201(回了 200),或 Location 头是相对路径但跨域。
6. CDN 边缘部署
6.1 Anycast 与就近接入
WHIP / WHEP 的 HTTP 入口天然适合 Anycast:
- 客户端解析
ingest.example.com拿到一个 Anycast IP,运营商 BGP 路由就近送到最近的 PoP。 - PoP 上跑一层 L7 LB(Envoy / Nginx)做 TLS 终止和 streamKey 路由。
- 起会话后,WebRTC 媒体面通过 SDP 里的 candidate 直挂到具体 Ingest Pod 的公网 IP,不再走 Anycast。这是关键:Anycast 适合 stateless HTTP,不适合 stateful UDP(路由波动会切对端 IP,破坏 ICE 绑定)。
部署示意:
DNS: ingest.example.com → Anycast IP 192.0.2.1
(HTTP only)
│
▼ edge PoP 北京 / 上海 / 广州
L7 LB (Envoy)
│
▼ 按 streamKey 哈希
Ingest Pod 1.2.3.4 (具体公网 IP,写进 SDP candidate)
│
▼ WebRTC UDP 5060x (不走 Anycast)
主播终端
6.2 回源策略
Edge SFU 怎么从 Origin 拉源流?三种主流方案:
| 方案 | 协议 | 优点 | 缺点 |
|---|---|---|---|
| RTP over QUIC | 私有 | 拥塞控制好、丢包恢复快、多路复用 | 实现成本高,需要双向 KCP / QUIC stack |
| SRT | UDP | 工业级、运维工具齐全 | 单流单连接,不适合超多 Edge 扇出 |
| WHEP 级联 | HTTP + WebRTC | 协议统一、复用已有栈 | DTLS 多次握手成本,超过 50 个 Edge 时 Origin CPU 顶不住 |
中小规模(< 30 Edge)用 WHEP 级联最省心;大规模用 RTP over QUIC。
6.3 与 RTMP / SRT 共存
不要试图让 WHIP 取代所有协议。务实的做法是:
┌── WHIP(OBS 31+, ffmpeg 7.1+)
源接入 ├── RTMP(旧 OBS、移动直播 SDK、第三方推流软件)
└── SRT(专业级远程制作、跨洋骨干传输)
↓
统一进 Origin(协议归一化为 RTP)
↓
分发出口 ┌── WHEP(500ms,互动场景)
├── LL-HLS(2~4s,海量观看)
├── HLS(10s,长尾客户端、SmartTV)
└── RTMP→HTTP-FLV(3~5s,低端 Android、嵌入式)
关键工程约束:
- 协议归一化在 Origin 完成,不要在 Edge 多次转码。
- HLS 和 WHEP 切片来自同一个 GOP,否则同步切流时画面会跳。
- WHEP 关键帧间隔与 LL-HLS chunk 时长对齐,例如都用 2s。
7. 多协议共存的延迟与场景
| 协议 | 典型延迟 | 适用观众 | 不适用 |
|---|---|---|---|
| WHEP | 200 ~ 500ms | 互动直播、电商连麦、云游戏 | 单流百万级并发(成本爆炸) |
| LL-HLS / LL-DASH | 2 ~ 4s | 中等互动、赛事直播 | 真互动(PK / 连麦) |
| HLS / DASH | 6 ~ 30s | 长尾客户端、SmartTV、海外网络差 | 互动 |
| HTTP-FLV | 3 ~ 5s | 国内低端 Android、嵌入式播放器 | 海外 / WebKit |
| RTMP 拉流 | 3 ~ 5s | 已淘汰,仅历史链路 | 浏览器 |
工程上常见的策略是 Hybrid 起播:
- 客户端先并发请求 WHEP 和 LL-HLS。
- 哪个先出帧用哪个。
- 如果 WHEP 在 1.5s 内没建联(NAT 失败、TURN 失效),切到 LL-HLS。
- 用户进入连麦 / 弹幕互动状态时强制切回 WHEP。
什么时候不要 Hybrid:纯单向直播(央视新闻)、延迟容忍度高(> 10s),直接 HLS 就够,引入 WHEP 反而增加运维面。
8. 鉴权方案
8.1 Bearer Token
最朴素,OBS 原生支持。生产建议:
- Token 是短期 JWT(5 ~ 30 分钟),claim 里带
streamKey、expire、tier(推流 / 观看)。 - 后端用 HMAC-SHA256 签名,密钥按业务线分发。
- Edge / Origin 都验签,Edge 不联管控面也能完成鉴权(无状态)。
type WhipClaims struct {
StreamKey string `json:"sk"`
Tier string `json:"tier"` // "ingest" | "play"
jwt.RegisteredClaims
}
func authBearer(r *http.Request) bool {
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
parsed, err := jwt.ParseWithClaims(tok, &WhipClaims{}, func(t *jwt.Token) (any, error) {
return []byte(os.Getenv("WHIP_HMAC_KEY")), nil
})
if err != nil || !parsed.Valid { return false }
c := parsed.Claims.(*WhipClaims)
return c.Tier == "ingest" && c.StreamKey != ""
}
8.2 签名 URL
适合 WHEP 拉流,CDN 友好:
https://edge.example.com/whep/abc123?
exp=1750684800&
sign=hmac_sha256(streamKey|exp|clientIP, secret)
服务端用同样的 HMAC 重算比对。Edge 不需要 Token 解析,性能开销近乎零。
8.3 防盗链
Referer白名单:弱防御,能挡爬虫但挡不住伪造。Origin校验:浏览器场景下有效,OBS 不会发。- IP 频率限制:单 IP 同时活跃 WHIP 会话 > 3 直接拒。
- 推流端 RTMP 鉴权可参考 nginx-rtmp-module 的
on_publish回调,WHIP 可直接在 POST 阶段同步回调业务后端。
什么时候不适用:跨地区直播签名 URL 的 clientIP 校验会误杀,建议改成 streamKey + 短 TTL token,不绑 IP。
9. 监控指标
WHIP / WHEP 上线后必须监控的指标:
| 指标 | 含义 | 告警阈值 | 失败时的样子 |
|---|---|---|---|
| 首帧时间 (TTFB-Video) | POST 到第一帧解码成功 | P95 < 800ms | 黑屏 1~3s 后才出画面 |
| 卡顿率 | 200ms 无视频帧次数 / 总帧数 | < 0.5% | 画面冻结,音频继续 |
| 端到端延迟 | 推流端时钟戳到播放端渲染戳 | P95 < 800ms | 主播说完话后观众明显延迟反馈 |
| 上行码率波动 | OBS 实际码率 / 目标码率 | 0.8 ~ 1.2 | OBS 红色重连,PLI 风暴 |
| JitterBuffer 长度 | playoutDelay (ms) | < 300ms | 卡顿率上升、延迟上升 |
| PLI 频率 | 每分钟下发数 | < 6 次 / 路 | 上行码率被打到 2 ~ 3x |
| ICE 建联成功率 | (成功 / 尝试) | > 99% | NAT 穿透失败,必须看 TURN 使用率 |
| TURN 占比 | relay 候选选中比例 | < 20% | TURN 带宽爆炸,成本飙升 |
数据来源:
- 客户端:
pc.getStats()每秒采集一次,关键字段framesPerSecond/jitterBufferDelay/nackCount/firCount/pliCount。 - 服务端:Pion
InterceptorRegistry内置 StatsInterceptor,能拉到 ssrc 级码率与丢包。 - 链路:在每个 Edge / Origin 节点埋 Prometheus exporter,按 streamKey 维度聚合。
// 客户端最小化采集
async function collect(pc) {
const stats = await pc.getStats();
let latencyMs = 0, jbDelay = 0, framesPerSecond = 0, pliCount = 0;
stats.forEach(report => {
if (report.type === 'inbound-rtp' && report.kind === 'video') {
jbDelay = report.jitterBufferDelay * 1000 / report.jitterBufferEmittedCount;
framesPerSecond = report.framesPerSecond;
pliCount = report.pliCount;
}
if (report.type === 'remote-outbound-rtp') {
latencyMs = report.roundTripTime * 500; // 单向
}
});
reportToBackend({ latencyMs, jbDelay, framesPerSecond, pliCount });
}
10. 失败模式与兜底
10.1 NAT 打不通要 TURN 兜底
主播端在企业网 / 校园网 / 公共 WiFi 时,UDP 出网常被限制。失败时的样子:OBS 显示已连接但服务端 OnTrack 不触发。
兜底:
- 部署 coturn,开 UDP 3478 + TCP 443(伪装 HTTPS 出网)。
- WHIP Server 在 Answer SDP 里写入 TURN candidate 时,使用
turns:over TCP 443,绕过限制 UDP 的网络。 - 监控
relay candidate selected比例,> 20% 说明 TURN 成本会爆,需要排查就近接入。
什么时候不需要 TURN:纯 ToB 内网会议系统,所有终端在同一 VLAN。
10.2 OBS 推流码率突变
OBS 在网络变差时会自动调整码率(Network → Dynamically change bitrate)。问题是:
- WebRTC GCC(Google Congestion Control)也会主动调整 estimator。
- 两个调节算法叠加可能震荡,码率从 6Mbps 跳到 800kbps 再跳回。
应对:
- 关闭 OBS 动态码率,让 WebRTC 自己控。OBS 的码率自适应是面向 RTMP 的,与 GCC 重复。
- 或反过来:服务端发 REMB / TWCC 时给一个相对稳定的目标值,避免抖动放大。
10.3 Edge 节点失联切换
Edge SFU 挂掉时观众端会黑屏。处理:
- 客户端
pc.oniceconnectionstatechange检测到disconnected超过 3s,主动发起重新 WHEP POST。 - DNS 层 Edge IP 必须有健康检查,5s 摘除故障节点。
- 重新 POST 时用一个新的 resourceID,不要复用旧的,否则会拼到已断开的会话。
什么时候适用降级到 LL-HLS:连续两次 WHEP POST 都失败(10s 内),切换到 LL-HLS 播放器。
10.4 PLI 风暴 / NACK 风暴
- PLI 风暴:N 个观众同时进入,每人在解码失败时各自请求 PLI,推流端被反复要关键帧。前文已述用 1s 节流。
- NACK 风暴:弱网下连续丢包,NACK 反向打爆上行。服务端用 InterceptorRegistry 限制单流每秒 NACK 数(如 200 个)。
10.5 时间戳跳变
OBS 重启编码器时 RTP 时间戳可能不连续,WHEP 端表现为画面冻结后突然快放。Pion 默认不处理,需要在 Forward 层加 timestamp rewriter,保证单调递增。
11. 反模式
以下做法在生产线上反复踩坑,强烈不建议:
- WHIP 不实现 Trickle ICE。服务端等 ICE gathering 完成再回 Answer,首帧时间会从 200ms 拉到 1 ~ 1.5s。除非你能保证服务端是单候选的 ICE-Lite,否则一定要 Trickle。
- 不做 PLI 节流。每个 WHEP 订阅者独立请求 PLI 时,上行码率会被打成原来的 2 ~ 3 倍,主播侧画面糊掉。
- Edge 不做就近调度。跨区域走骨干网,RTT 200ms 起,WHEP 抖动缓冲被迫拉到 400ms+,等于把"低延迟"前缀去掉了。
- WHIP 走 200 而不是 201 + Location。OBS / ffmpeg 都按 RFC 严格校验,会直接断开重试,进入死循环。
- DELETE 不回收 PeerConnection。Go 里 PC 没 Close 不会被 GC,每路源流泄漏 30 ~ 50MB 内存,几小时后服务雪崩。
- 同一 streamKey 允许多人同时推。后入者覆盖前者,前者还在不停发 RTP,会引发 SSRC 冲突;要么先到先得 + 409 拒绝,要么后到顶替 + 主动 DELETE 前者。
- WHEP 直接挂在 Anycast IP 上做媒体面。Anycast 路由波动会让 UDP 5-tuple 漂移,ICE 直接断。Anycast 只能跑 HTTP 信令面。
- 把 HLS 转码做在主链路而不是旁路。转码挂了,WHEP 也跟着挂。必须旁路。
- 客户端轮询 GET 状态而不是用 SSE / WHEP 扩展。轮询 1s 一次的成本远大于 SSE 长连接,并且无法及时感知错误。
- TURN 不分区域。所有客户端走同一个 TURN,会引发跨洲 relay,体验比直连还差。TURN 必须按用户接入区域分组。
- OBS 同时开 B 帧 + 动态码率。两个坑叠加,WHEP 端画面会反复"卡住 → 快放"。
- 不监控 TURN 占比。一旦 NAT 穿透成功率掉到 80%,TURN 流量翻 5 倍,账单上千美元/天起。
12. 验收 Checklist
上线前对照确认:
- OBS WHIP 推流 → 浏览器 WHEP 拉流,端到端延迟 P95 < 800ms。
- Pion WHIP Server 处理 1000 路并发 WHEP 观众,CPU < 60%。
- 主播断开 OBS,服务端 5s 内释放资源(PeerConnection / TrackLocal)。
- 弱网(300ms RTT + 5% 丢包)下卡顿率 < 1%。
- NAT 失败时 TURN 兜底,且 TURN 占比 < 20%。
- WHEP / LL-HLS / HLS 同源分发,关键帧对齐。
- 鉴权:JWT 过期、签名错误、IP 频率限制全部生效。
- 故障切换:Edge 摘除后 10s 内新观众不再被路由到该节点。
- Prometheus 看板覆盖首帧时间、卡顿率、码率、PLI 频率、TURN 占比。
- Trickle ICE 启用且 PATCH 验签。
- 与 RTMP 链路并存,主播只推一份,CDN 分发同源。
13. 权威资料
- IETF RFC 9725 WebRTC-HTTP Ingestion Protocol (WHIP): https://www.rfc-editor.org/rfc/rfc9725
- IETF draft WHEP (WebRTC-HTTP Egress Protocol): https://datatracker.ietf.org/doc/draft-ietf-wish-whep/
- IETF draft Murillo WHEP 原始草案: https://datatracker.ietf.org/doc/draft-murillo-whep/
- Pion WebRTC 项目主页: https://github.com/pion/webrtc
- Pion WHIP/WHEP 示例: https://github.com/pion/webrtc/tree/master/examples/whip-whep
- Pion InterceptorRegistry 文档: https://pkg.go.dev/github.com/pion/interceptor
- OBS Studio WHIP 输出文档: https://obsproject.com/wiki/WebRTC-Output
- OBS Studio 31.0 Release Notes(AV1 / HEVC for WHIP): https://obsproject.com/blog
- ffmpeg WHIP muxer(7.1+): https://ffmpeg.org/ffmpeg-formats.html#whip
- LiveKit Ingress / WHIP 实现参考: https://docs.livekit.io/home/ingress/overview/
- Cloudflare Stream WHIP / WHEP: https://developers.cloudflare.com/stream/webrtc-beta/
- Millicast WHIP 部署经验: https://docs.dolby.io/streaming-apis/docs/webrtc-https-ingest-whip
- Google Congestion Control 论文: https://datatracker.ietf.org/doc/html/rfc8298
- 上一层 08 进阶专题
- 实战项目 L4 WHIP/WHEP 低延迟直播
核对日期:2026-06-22