🎬 第四章 RTMP流媒体协议
📄 一句话概括

用户控制消息的外层类型是 4,消息体头 2 字节才是具体事件类型。 StreamBegin、SetBufferLength、PingRequest 是最常用的三个。

📝 本节和前面几节的关系

4.4.7 的协议控制消息管连接本身,这一套管流与心跳。 两者都是控制类,但一个用类型 1~6、一个统一用类型 4 加内层事件号。

一、两层类型号

用户控制消息的结构是「外层类型 4 + 内层事件类型 2 字节 + 数据」: ① 外层:RTMP 消息类型 4,表示「这是用户控制消息」。 ② 内层:消息体的头 2 字节,表示具体是哪种事件。

为什么要两层? 因为这一类事件太多,如果每种都占一个消息类型号,很快就不够用。 用「一个外层类型 + 一个内层事件号」可以扩到 65535 种。 这是协议演进时常用的手法。

六个常用事件号: 0 StreamBegin、1 StreamEOF、2 StreamDry、3 SetBufferLength、6 PingRequest、7 PingResponse。

二、StreamBegin 为什么必须最早到

它的作用是「告诉客户端:流 1 开始了,时间戳从现在算」。 也就是说,它是时间轴的起点声明。

如果它晚到会怎样? 客户端会先收到一堆带时间戳的音视频消息,却不知道该对齐到哪里。 表现有两种: ① 播放器一直转圈,因为它在等「起点」。 ② 时间轴乱跳,因为它把第一个收到的时间戳当成了起点。

所以顺序是固定的: play 之后先到 StreamBegin,再到 onMetaData,最后才是第一帧。 抓包时只看这三样的顺序,就能判断服务器的实现是否规范。

三、SetBufferLength 与水位

它带两个 4 字节:流 ID 与缓冲毫秒数。 意思是「请在客户端攒够这么多毫秒再开始播」。

这是一个「延迟与流畅」的调节旋钮: ① 设小了:首屏快,但网络一抖就卡。 ② 设大了:流畅,但延迟明显,而且观众能感知到「慢半拍」。

实际产品里这个值常常是自适应的: 检测到频繁卡顿就自动调大,网络好了再调小。 所以抓包会看到它被反复发送,这不是异常。

注意它是「服务器建议、客户端决定」——客户端完全可以不听,自己按固定缓冲跑。 这也是为什么同样的流、不同的播放器,延迟能差出一秒。

四、PingRequest 与 PingResponse

一对心跳,带一个 4 字节时间戳: ① 服务器发 PingRequest,带上「发出的时刻」。 ② 客户端原样回 PingResponse,把那个时间戳抄回去。 ③ 服务器拿到「收到响应」的时刻,减去那个时间戳,就是 RTT。

这是 RTMP 唯一的链路质量探测手段。 对比一下 RTCP:那边有丢包率、抖动、往返时延三样,RTMP 只有一个 RTT——因为 RTMP 跑在 TCP 上,丢包与乱序都由 TCP 处理了。

实际抓包会发现心跳很少,几秒到几十秒一次。 因为链路死活 TCP 自己知道,RTMP 不需要反复确认。 如果看到心跳突然变密,通常说明服务器在探测「客户端是不是还活着」。

五、动手 Demo:类型 4 里的六种事件

构造 StreamBegin、SetBufferLength、PingRequest 三条,看字节。 重点看「外层类型 4」与「内层事件号」两层结构。

import struct

def user(ev, body, ts=0):
    payload = struct.pack("!H", ev) + body
    basic = struct.pack("!B", 2)
    mh = (struct.pack("!I", ts)[1:]
          + struct.pack("!I", len(payload))[1:]
          + struct.pack("!B", 4)          # 外层类型 4
          + struct.pack("<I", 0))
    return basic + mh + payload

cases = [(0, "StreamBegin", struct.pack("!I", 1)),
         (3, "SetBufferLength", struct.pack("!II", 1, 1000)),
         (6, "PingRequest", struct.pack("!I", 999)),
         (7, "PingResponse", struct.pack("!I", 999))]
for ev, name, body in cases:
    p = user(ev, body)
    print("事件 %d %-16s 消息体 %d 字节,块 %d 字节"
          % (ev, name, 2 + len(body), len(p)))

print("")
p = user(3, struct.pack("!II", 1, 1000))
print("SetBufferLength 字节 %s" % p.hex())
print("外层类型 %d,内层事件 %d,流 %d,缓冲 %d 毫秒"
      % (p[7],
         struct.unpack("!H", p[12:14])[0],
         struct.unpack("!I", p[14:18])[0],
         struct.unpack("!I", p[18:22])[0]))

实跑输出:

--------------------------------------------------------------------
用户控制消息——类型 4 里的六种事件
--------------------------------------------------------------------
**① 用户控制消息的结构**

  **外层是 RTMP 消息类型 4,消息体头 2 字节是「事件类型」,后面跟数据。**
  **所以它有两层类型号:外层 4 表示「这是用户控制」,内层才是具体事件。**

  事件     名称                后面跟什么
  ---------------------------------------------------------
  0        StreamBegin         4 字节流 ID
  1        StreamEOF           4 字节流 ID
  2        StreamDry           4 字节流 ID
  3        SetBufferLength     4 字节流 ID + 4 字节毫秒
  6        PingRequest         4 字节时间戳
  7        PingResponse        4 字节时间戳

**② 真构造 StreamBegin 与 SetBufferLength**

  事件 0 StreamBegin      → 消息体 6 字节,块 18 字节
    020000000000060400000000000000000001
  事件 3 SetBufferLength  → 消息体 10 字节,块 22 字节
    0200000000000a0400000000000300000001000003e8
  事件 6 PingRequest      → 消息体 6 字节,块 18 字节
    0200000000000604000000000006000003e7

**③ StreamBegin 为什么必须最早到**

  **它告诉客户端「流 1 开始了,时间戳从现在算」。**
  **如果它晚到,客户端会先收到一堆带时间戳的音视频消息。**
  **却不知道该对齐到哪里**——**表现是播放器一直转圈,或者时间轴乱跳。**

  **PingRequest 与 PingResponse 是一对心跳。**
  **服务器发请求带一个时间戳,客户端原样回响应。**
  **服务器拿「发出到收到」的差算 RTT**——**这是 RTMP 唯一的链路质量探测手段,比 RTCP 简陋得多。**

  **实际抓包会发现心跳很少,通常几秒到几十秒一次。**
  **因为 RTMP 跑在 TCP 上,链路死活 TCP 自己知道,不需要 RTMP 反复确认。**

三条消息的外层类型都是 4,区别全在消息体开头的 2 字节事件号上。 SetBufferLength 是唯一带 8 字节数据的:流 ID 加缓冲毫秒数,两条信息缺一不可。 PingRequest 与 PingResponse 结构完全一样,区别只在事件号 6 与 7——服务端靠这个区分方向。

30 秒实验

  1. 把事件号 0 改成 1,看名字从 StreamBegin 变成 StreamEOF——结构不变,语义完全相反。
  2. 把缓冲从 1000 改成 3000,算延迟增加多少——两秒,观众能明显感觉到慢。
  3. 把 PingRequest 的时间戳改成 0,看 RTT 还怎么算——算不出来,因为时间戳是唯一的配对依据。

常见误区

  1. 「用户控制消息有自己的顶层类型号」——统一是类型 4,内层才是事件号。
  2. 「StreamBegin 可以晚点发」——它是时间轴起点,必须最早到。
  3. 「SetBufferLength 是强制要求」——是建议,客户端可以不采纳。
自测三问(点开对答案) 1. 用户控制消息怎么区分具体事件? —— **外层类型固定 4,消息体头 2 字节是事件号。** 2. StreamBegin 的作用是什么? —— **声明流的开始与时间轴起点。**晚了会导致播放器一直转圈或时间轴乱跳。 3. RTMP 怎么测 RTT? —— **PingRequest 带发出时刻,对方原样回 PingResponse,服务端用收到时刻减去它。**

相关笔记