gortmplib vs go2rtc vs FFmpeg:RTMP 推流源码对比与国内直播平台对接

在 MiBee NVR 项目里要把摄像头画面推到国内某直播平台,候选方案有三个:直接调 FFmpeg、用纯 Go 的 gortmplib、用 go2rtc。三个都能「推 RTMP」,但实际对接国内直播平台时表现差异巨大——有的秒断、有的几秒后断、有的稳如老狗。这篇从源码层面把三者拆开对比,并梳理对接国内 FMS 兼容平台时的坑和解法。

三个库的所有结论都来自源码逐行核对(go2rtc master / gortmplib main / FFmpeg master),不是文档推测。

先跑通:FFmpeg 已经能把画面推到直播间

在纠结用哪个库之前,先用最稳妥的方案把链路打通——在 MiBeeNVR 的「转推输出」里配一个 RTMP 目标,后端走 FFmpeg 子进程。配置界面长这样:

MiBeeNVR 转推输出配置:RTMP 目标指向某直播平台

配上之后,桌面端 MiBeeNVR 推流、某直播平台桌面伴侣接收,两边各跑各的:

桌面端:MiBeeNVR 推流 + 直播伴侣接收

最终在手机端直播间里能看到画面——链路是通的:

手机端直播间:推流画面已生效

所以用 FFmpeg 是能成的。但 FFmpeg 是外部进程,要单独装、单独管生命周期,部署和资源开销都不理想。我更希望整个推流链路纯 Go 实现——不依赖外部进程、能嵌进 NVR 进程统一管理。这才有了后面三个库(FFmpeg 作为参考、gortmplib、go2rtc)的源码对比:到底哪个 Go 库能把这条纯 Go 路走通。

三个库的定位

先把三个选手分清楚——它们根本不是一个量级的东西:

维度FFmpeggortmplibgo2rtc
定位媒体处理瑞士军刀纯 RTMP 客户端/服务器库终极摄像头流媒体应用
形态命令行/C 库Go 库(可 import)独立二进制/Docker
RTMP 角色20+ 协议之一唯一核心20+ 协议之一
代码规模巨型推流核心 ~1000 行RTMP 核心 ~580 行
典型用法ffmpeg -i ... -f flv rtmp://...嵌进自家 Go 程序开箱即用的转分发

简单说:FFmpeg 是「能推流的全能选手」,gortmplib 是「专为 RTMP 生的库」,go2rtc 是「主打多协议互转的应用」。理解了定位,后面的差异就顺理成章。

RTMP 握手对比

RTMP 连接第一步是握手:C0/C1/C2(客户端→服务端)和 S0/S1/S2。握手分简单和复杂两种,区别在 C1 的 1536 字节里有没有 digest:

特性简单握手复杂握手
C1 内容time+version+1528 随机字节同上,但含 HMAC-SHA256 digest
digest keyGenuine Adobe Flash Player 001
服务端验证仅 echo C1→S2验证 C1 digest

三个库的握手实现(源码核对):

go2rtc——最简,函数名在源码里就拼错了(clienHandshake,少个 t):

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// go2rtc/pkg/rtmp/client.go
func (c *Conn) clienHandshake() error {
    b := make([]byte, 1+1536)  // 全零初始化
    b[0] = 0x03                 // C0=3,C1 全零
    c.conn.Write(b)             // 发 C0+C1
    io.ReadFull(c.rd, b)        // 读 S0+S1
    c.conn.Write(b[1:])         // 把 S1 当 C2 回写
    io.ReadFull(c.rd, b[1:])    // 读 S2,丢弃不校验
    return nil
}

C1 全零、无 digest、不校验服务端返回。

gortmplib——按版本号分两条路:

go
1
2
3
4
5
6
7
// gortmplib/pkg/handshake/handshake.go
func DoClient(rw io.ReadWriter, encrypted bool, strict bool) ([]byte, []byte, error) {
    if encrypted {
        return doClientEncrypted(rw)  // version=6,有 digest
    }
    return nil, nil, doClientPlain(rw, strict)  // version=3,无 digest
}

普通模式(v3)走 fillPlain() 只填随机数、不算 digest;只有加密模式(v6/RTMPE)才走 fill() 算 HMAC-SHA256 digest。

FFmpeg——实现复杂握手,C1 含 digest,发送前用 rtmp_player_key 计算 HMAC-SHA256 写入。

注意:握手差异是不是卡点,取决于具体后端。部分云 FMS 接受简单握手;但也有严格的后端(如实测中的某直播平台桌面伴侣)在 version=3 下强制验证 digest——后文实战记录里就先栽在这上面。

客户端推流时序:控制消息才是关键

握手过后是 connect 命令,然后是一套精密的协议控制消息流。FMS 标准(某直播平台/虎牙/B站都是 FMS 兼容后端)的完整流程:

mermaid
sequenceDiagram
    participant C as 客户端
    participant S as 该平台 FMS
    C->>S: connect(app,flashVer,tcUrl)
    S-->>C: WindowAckSize(2500000)
    S-->>C: SetPeerBandwidth(2500000,2)
    C->>S: WindowAckSize(2500000) 回应
    S-->>C: _result(Connect.Success)
    C->>S: createStream / FCPublish / publish
    S-->>C: onStatus(Publish.Start)
    Note over C,S: ⏱️ 5秒计时开始<br/>每收 2.5MB 必须回 ACK
    C-->>S: ❌ 无 ACK → 5秒超时
    S->>C: StreamReset(RST) 断开

关键在 WindowAcknowledgementSize:服务端声明一个 2.5MB 窗口,客户端每累计收到 2.5MB 就必须回一个 Acknowledgement(type 3)。5 秒内没等到 ACK,服务端直接 RST。三个库在这里分出了高下:

控制消息FFmpeggortmplibgo2rtc
发 WindowAckSize✅ 主动发(2500000)❌ 不发
处理收到的 WindowAckSize❌ 忽略
发 SetPeerBandwidth✅ 主动发❌ 不发
周期发 Acknowledgement✅ 每1MB✅ 自动回调从不发

go2rtc 的 readResponse() 是问题根源——它只认两类消息:

go
1
2
3
4
5
6
// go2rtc/pkg/rtmp/conn.go
switch msgType {
case TypeSetPacketSize:   // type 1
case TypeCommand:         // type 20
// type 3(Ack)、type 5(WindowAck)、type 6(PeerBW) 全部丢弃
}

这是 go2rtc 在强制 ACK 的云 FMS 上的一种典型失败模式:握手和推流命令都成功,几秒后因从不发 ACK 被 RST。但必须强调:这不是唯一的失败点——后文实战记录里,直播伴侣在握手层(digest)和帧层都有独立的坑,它 74ms 就 RST 的现象对不上 ACK 超时那种 5 秒断开。FFmpeg 和 gortmplib 都做了周期 ACK,至少不踩这一个。

推流数据写入机制

握手和控制消息之外,数据怎么写进 RTMP 连接,两套 Go 库哲学完全不同。

gortmplib——强类型消息:每个编解码器有专属方法,字段都是强类型:

go
1
2
3
4
5
6
7
8
// gortmplib/writer.go
func (w *Writer) WriteH264(track *Track, pts, dts time.Duration, au [][]byte) error {
    avcc, _ := h264.AVCC(au).Marshal()
    return w.Conn.Write(&message.Video{
        Codec: CodecH264, IsKeyFrame: h264.IsRandomAccess(au),
        Type: VideoTypeAU, AU: avcc, DTS: dts, PTSDelta: pts - dts,
    })
}

go2rtc——FLV 转换层:把 RTMP 当 FLV 的传输层,你喂 FLV 字节流,它翻译成 RTMP chunk:

go
1
2
3
4
5
// go2rtc/pkg/rtmp/flv.go
func (c *Conn) Write(p []byte) (int, error) {
    // 解析 FLV tag → 写 RTMP chunk
    c.writeMessage(4, tagType, timeMS, payload)
}

gortmplib 给你协议级精细控制,go2rtc 代码量少但牺牲了控制力。各有所长。

编解码器与多轨道支持

这是 gortmplib 的核心卖点——它实现了 Enhanced RTMP v2,支持标准 RTMP 装不下的新编解码器和多轨道:

编解码器gortmplibgo2rtcFFmpeg
H.264/AVC
H.265/HEVC✅ 原生⚠️ 需配置
AV1✅ 原生⚠️
AAC/MP3/G.711
Opus/FLAC/AC-3✅ 原生
多视频/音频轨道✅ 原生

要推 H.265/AV1 或多视角多语言,gortmplib 是唯一选择。标准 H.264+AAC 三家都行。

认证与加密支持

能力gortmplibgo2rtcFFmpeg
RTMPS(TLS)✅ 自动填 SNI注册了 scheme 但无实现
RTMPE 加密✅ DH+RC4
Adobe 鉴权(challenge-response)✅ 两阶段 HMAC-MD5
URL query 鉴权仅明文参数

对接需要 Adobe 鉴权的 Wowza/strict FMS,gortmplib 是唯一能打的。

Chunk 协议细节

Chunk 特性gortmplibgo2rtcFFmpeg
写 Chunk Size6553640964096
读 Chunk Size 初始值128128(偏小)4096
大 Chunk ID(>63)✅ 支持❌ 部分不支持
并发写保护ReadWriter 保证sync.Mutex 手动
视频数据 CSID646

go2rtc 的 rdPacketSize 初始值 128 偏小,虽然收到服务端 SetChunkSize 后会更新,但首轮交互效率低;且它注释明说不支持 chunkID>63。

connect 参数与元数据

connect 命令的 flashVer(源码核对,容易踩坑):

flashVer
FFmpegFMLE/3.0 (compatible; FMSc/1.0)
go2rtcFMLE/3.0 (compatible; FMSc/1.0)
gortmplibLNX 9,0,124,2(Linux Flash Player 9)

部分 FMS 会校验 flashVer,gortmplib 的 LNX 值可能被拒——这是 gortmplib 对接国内平台时一个值得优先排查的点。

元数据(@setDataFrame/onMetaData):gortmplib 的 onMetaData 只有 4 个字段(缺 width/height/framerate),而 FFmpeg 有 15+ 字段。FMS 用宽高初始化解码器,字段缺失可能导致服务端拒绝初始化。

国内直播平台对接:问题与解决思路

国内的某直播平台、虎牙、B站、战旗、龙珠等,后端基本都是 Adobe FMS 兼容实现。它们有一套共性要求,三个库踩坑各不相同。下面按问题分层梳理。

问题 1:ACK 超时(go2rtc 在云 FMS 上的典型坑)

现象:推流命令都成功了,几秒后连接被 RST,日志里看到「第二帧断开」。

根因:平台要求客户端每收 2.5MB 回一个 Acknowledgement,5 秒没等到就 RST。go2rtc 完全没实现这个机制。

解决

  • go2rtc:在 readMessage() 里加字节计数,达窗口就发 ACK(type 3),并处理 type 5/6。
  • gortmplib/FFmpeg:已实现,无需处理。

问题 2:握手 digest(是否强制,因后端而异)

现象:推流失败时,有人一口咬定是 digest 没算,也有人一口咬定 digest 无关。

真相取决于后端。部分云 FMS 接受简单握手;但也有严格的——实测某直播平台桌面伴侣在 version=3 下强制验证 digest,补上 digest 后握手才通过(见后文实战记录)。所以别一刀切,用抓包确认握手是否真的失败。

解决:握手过不了就考虑 digest;握手能过就跳过这条。

问题 3:flashVer 被拒

现象:connect 后服务端不回 Connect.Success 或直接拒绝。

根因:部分 FMS 校验 flashVer,非 FMLE 格式可能被拒。gortmplib 默认发 LNX 9,0,124,2

解决:把 flashVer 改成 FMLE/3.0 (compatible; FMSc/1.0) 重试。

问题 4:鉴权参数(wsSecret/wsTime)

现象:某直播平台推流地址形如 rtmp://<平台推流域名>/live/{推流码}?wsSecret=xxx&wsTime=xxx,鉴权信息在 URL query 里。

根因:query 参数必须原样保留在 tcUrl 里传给 connect,丢掉或错误解析都会鉴权失败。

解决:确认 URL 解析逻辑保留了 query;go2rtc 默认把 ?wsSecret=... 拼进 stream,gortmplib 的 splitPath 也保留 RawQuery,一般没问题,但要核对。

解决思路总结:分层排查

mermaid
flowchart TD
    A[推流失败] --> B{握手过没?}
    B -->|没过| C[查 digest/网络]
    B -->|过了| D{connect 成功?}
    D -->|没| E[查 flashVer/鉴权参数]
    D -->|成功| F{publish 后几秒断?}
    F -->|是| G[ACK 超时-补控制消息]
    F -->|否| H[查元数据/CSID]

    style A fill:#2196F3,color:#fff
    style G fill:#f44336,color:#fff
    style E fill:#FF9800,color:#fff

图示:按「握手→connect→publish 后是否秒断」三层定位,能快速锁定是 ACK、flashVer 还是 digest 问题。最终定论靠抓包,别靠猜。

选型建议

选 FFmpeg:要最快对接国内平台、不在乎外部进程依赖。它的控制消息和兼容性最全,是 OBS/Streamlabs 底层用的方案。

选 gortmplib:要推 H.265/AV1/多轨道、对接需要 Adobe 鉴权或 RTMPS 的服务器、想把 RTMP 嵌进 Go 程序做精细控制。注意它 flashVer 默认是 LNX,对接国内平台可能要改。

选 go2rtc:要在 RTMP/RTSP/WebRTC/HLS 之间互转、要开箱即用的多协议转发、推流编解码在 H.264+AAC 标准范围内。但对接国内 FMS 平台前必须先补齐控制消息,否则必被 ACK 超时 RST。

调试方法:抓包定论

源码分析能定位方向,最终靠 tcpdump 钉死:

bash
1
2
3
4
5
sudo tcpdump -i eth0 'tcp port 1935' -w live-push.pcap
# Wireshark 解析为 RTMP,看三件事:
# 1. C0+C1 后服务端是否回 S0+S1+S2?(回=握手通过)
# 2. 客户端有没有发 Acknowledgement(type 3)?(没有=确认 ACK 超时)
# 3. RST 发生在哪一步、距 publish 多久?

三步就能分清是 ACK 超时、flashVer 被拒、还是握手 digest。

实战记录:对接某直播平台桌面伴侣(进行中)

这是我们项目里真实踩坑的记录,问题尚未完全解决——握手层已攻克,视频帧传输仍失败。放在这里作为前文理论的实证对照,也提醒一句:源码分析能给方向,但「这个后端到底卡哪」只有抓包说了算。

目标架构:直播伴侣 ≠ 该平台云端

一个容易搞错的点:我们对接的不是该平台云端(<平台推流域名>),而是装在主播电脑上的某直播平台桌面伴侣——一个本地 RTMP 接收器 + 转码器,它收到流后再解码→重编码→推到该平台云端:

text
1
2
NVR(Go) ──RTMP(局域网)──> 某直播平台桌面伴侣(Win) ──RTMP(互联网)──> 该平台云端 FMS
                          本地接收 + 转码                    最终落地

本地推流地址形如 rtmp://<伴侣局域网IP>:1955/live/<推流码>,推流码由伴侣动态生成。伴侣本身实现了一套 Adobe FMS 兼容的 RTMP 服务端,行为和该平台云端不完全一样——这直接影响了下面的结论。

实测推翻了「digest 无关论」

前文按源码推断「部分云 FMS 接受简单握手」。但实测打脸:直播伴侣在 version=3 下强制验证 C1 digest。我们给 relay 补上复杂握手 digest(用 Genuine Adobe Flash Player 001 算 HMAC-SHA256 写入 C1)后,握手立刻通过、NetStream.Publish.Start 也收到了。

教训很直接:「这个后端要不要 digest」只有实测说了算。直播伴侣比一般云 FMS 更严格。

五层问题模型

把整个推流链路拆成五层,逐层定位:

问题FFmpeggortmplibgo2rtc我们的状态
L1 握手 digestC1 的 HMAC-SHA256❌ fillPlain 跳过❌ 无✅ 已补
L2 chunk size写入分块大小128655364096✅ 改 4096
L3 chunk headerType 0/1/2/3 选择Type 0Type 1/2/3 优化Type 0✅ 强制 Type 0
L4 后台读消息消费服务端控制消息内置goroutine✅ 加 goroutine
L5 视频帧格式未知正常未知正常未解决

已攻克的四层

  • L1 digest:自建握手函数,按 scheme 0 位置算 HMAC-SHA256 写入 C1;握手通过。
  • L2 chunk size:在 connect 之前发 SetChunkSize=4096(对齐 OBS),减少续包。
  • L3 chunk header:覆盖写入逻辑,视频/音频 AU 强制走 Type 0 + Type 3,绕开 gortmplib 的 Type 1/2/3(怀疑伴侣解析不了)。
  • L4 后台读取publish 后起 goroutine 持续读服务端消息,防接收缓冲区撑爆被 RST。

仍未解决:第一帧后 RST

修完 L1–L4 后,握手、connect、publish、Publish.Start、onMetaData、AVC seq header 全部成功,但第一个视频帧写入后约 74–252ms,伴侣就 RST

注意这个时序:74–252ms 远快于 ACK 超时(5 秒 / 2.5MB)。所以前文说的「ACK 超时」在这里对不上——伴侣是在帧级别就拒绝了,不是连接维持层的超时。这说明 ACK 超时只是 go2rtc 在云 FMS 上的一种失败模式,不是直播伴侣的卡点

已排除:digest、chunk header 类型、chunk size、关键帧/P 帧、后台读取——都不是。当前怀疑(均未证实):

  1. MessageStreamID:gortmplib 硬编码 0x1000000,go2rtc 用 createStream 返回值。
  2. onMetaData 字段不全:gortmplib 只有 4 个字段,FFmpeg 有 15+(含 width/height/framerate)。
  3. 缺音频轨道:源摄像头无声,relay 不发音频;伴侣可能要求至少一路音频。
  4. AVCC 字节级差异:gortmplib 的 AVCC 封装可能与 FFmpeg 略有不同。
  5. 伴侣端帧格式校验:收到首帧后做 SPS/PPS、profile 校验,不符则 RST。
  6. 限流残留:调试期快速重连(每 2 秒几百次)可能触发持久 IP 黑名单——当时连 FFmpeg 也被 RST,重启伴侣 + 等待后才恢复。

下一步与现状

最有价值的下一步是 pcap 逐字节对比:同条件下分别抓一次成功的 FFmpeg 推流和失败的 Go relay 推流,用 Wireshark 的 RTMP dissector 找第一个差异点。

现状(诚实交代):原生 Go relay 直推直播伴侣还没跑通;目前用 FFmpeg 子进程作为某直播平台的推流方式(项目里有开关),这条路稳定。理论分析帮我们排除了握手层,但视频帧层的问题还得靠抓包继续查。后续定位到会更新这篇。

全景对比总表

维度FFmpeggortmplibgo2rtc国内平台要求
握手 digest✅ 复杂❌ 简单(v3)❌ 全零简单多数接受简单
WindowAckSize✅ 发❌ 不发必需
Acknowledgement✅ 周期✅ 周期❌ 从不必需
SetPeerBandwidth✅ 发❌ 不发必需
flashVerFMLELNXFMLE偏好 FMLE
releaseStream/FCPublish✅ 合并发可选
Chunk Size4096655364096无强要求
H.265/AV1/多轨道⚠️N/A
RTMPS/RTMPE/Adobe鉴权部分✅ 全视平台
国内 FMS 推流✅ 稳⚠️ 需补 digest + 改 flashVer,帧层待解❌ 需补 digest + 控制消息

总结

三个库各有定位:FFmpeg 兼容性最全、是对接国内平台最稳的「保险方案」;gortmplib 协议实现最完整、是新编解码和精细控制的唯一选择,但默认 flashVer 要留意、握手 digest 也得自己补;go2rtc 多协议互转最省事,但握手 digest 和控制消息都得补齐,才推得动国内严格后端。

对接国内直播平台的坑是分层的:握手(digest,是否强制因后端而异——直播伴侣要、部分云 FMS 不要)、connect(flashVer / 鉴权参数)、连接维持(ACK 超时,主要咬 go2rtc 这种不发 ACK 的实现)、甚至帧格式(我们项目就卡在这层,未解决)。所以别信任何一刀切的结论——按「握手→connect→publish 后多久断」分层排查,配合 tcpdump 逐字节对比,坑才会现形。理论给方向,抓包定结论。