🎬 第三章 RTP与RTCP流媒体协议
📄 一句话概括

RTP 会话只有三件事:建会话定 SSRC、发流按序号递增、收流按序号重排。 它不保证送达也不保证顺序,重建全靠接收端自己做。

📝 本节和前面几节的关系

3.1.2 讲了一帧怎么装进包,这一节讲一串包怎么组成一次会话。 3.3 会把它和 RTSP 的关系理清。

一、三件事,一件都不能少

① 建会话:分配 SSRC,约定 PT 与时钟频率。这三样都在 SDP 里交换,所以建会话其实是「读 SDP」。 ② 发流:每个包序号加一,同一帧的包时间戳相同。发送节奏要按帧率来,不能一口气全推。 ③ 收流:按序号重排,按时间戳对齐显示。这一步全在接收端做,协议本身不帮忙。

三件事里最容易做错的是第 ③ 件。 因为「收到就播」在局域网里看不出问题,一上公网就原形毕露:乱序导致画面跳、丢包导致花屏,而收包计数一切正常。

二、为什么不做可靠传输

因为直播要的是「及时」,不是「完整」。

算一笔账: 跨省 RTT 30 毫秒,丢一个包重传一次就是 30 毫秒。按 25fps 算,一帧只有 40 毫秒预算——重传一次这一帧就来不及显示了。 而丢了这一帧,下一帧照样能播(P 帧依赖前帧,但只依赖有限几帧)。

所以 RTP 的选择是:丢了就丢了,让上层决定怎么办。 上层的选项有三个: ① 不管:画面花一下,继续。直播默认这样。 ② 请求重传:RTCP 的 NACK。只在 RTT 很小时划算。 ③ 请求关键帧:RTCP 的 PLI。代价是整个 GOP 重来,但一次就好。

三、重排缓冲与抖动

接收端必须有一个重排缓冲,否则乱序到达的包会直接被丢掉。 缓冲多大?取决于网络抖动。

抖动的定义是「到达间隔与发送间隔之差的波动」。 网络越拥塞,抖动越大,缓冲就得越深。 而缓冲越深,延迟越大——这又是那个老矛盾。

实践中的做法是「自适应」: ① 先按 100 毫秒起,观察抖动统计。 ② 抖动变大就把缓冲加深,代价是延迟上升。 ③ 抖动稳定一段时间后再慢慢收回来,避免延迟永久变高。

所以「延迟」在实时流里不是一个固定值,是一个动态调整的量。 这也是为什么同一个房间的两个人,看到的画面可能差几百毫秒。

四、会话结束与 SSRC 冲突

结束不是「发个包说再见」,而是「不再发包」。 接收端靠超时(通常几秒)判断对端已经走了。

正式的告别是 RTCP 的 BYE 包,但它只是「礼貌通知」,丢了也不影响——超时照样能发现。

SSRC 冲突是少见但要处理的坑: 两路流碰巧生成了同一个 32 位随机数,接收端会把它们当一路。 标准要求「发现冲突就换一个值,并重新发一遍 SDP」。 32 位随机数撞的概率极低,但几百路并发的服务器上确实会遇到过。

还有一种「假冲突」:服务器重启后 SSRC 变了,而客户端还按老的认,于是觉得「这一路断了」。 所以客户端要把 SSRC 变化当成「新的一路流」处理。

五、动手 Demo:发 200 个包回来对序号

UDP 环回,一端发 200 个包(每两包一帧),另一端收回来对序号与时间戳。 环回不丢包,但能看清「序号按包、时间戳按帧」这个结构。

import socket, struct, threading

srv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
srv.bind(("127.0.0.1", 0))
port = srv.getsockname()[1]
got = []

def rx():
    srv.settimeout(1.0)
    while len(got) < 200:
        try:
            got.append(srv.recvfrom(2048)[0])
        except socket.timeout:
            break

threading.Thread(target=rx, daemon=True).start()
cli = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for i in range(200):
    ts = 3600 * 100 + (i // 2) * 3600
    cli.sendto(struct.pack("!BBHII", 0x80, 96, 1000 + i, ts,
                            0x1A2B3C4D) + b"x" * 100, ("127.0.0.1", port))

import time
time.sleep(1.2)
seqs = [struct.unpack("!BBHII", d[:12])[2] for d in got]
tss = [struct.unpack("!BBHII", d[:12])[3] for d in got]
print("收到 %d 个,序号 %d~%d" % (len(seqs), min(seqs), max(seqs)))

# 每两包一帧:看时间戳的变化点
for i in range(4):
    print("包 %d 时间戳 %d" % (seqs[i], tss[i]))
print("相邻两包时间戳差 = %d" % (tss[2] - tss[0]))

实跑输出:

--------------------------------------------------------------------
RTP 的会话过程——序号和时间戳是怎么用的
--------------------------------------------------------------------
**① 发 200 个包,收回来对序号**

  发出 200 个,收到 200 个,丢失 0 个
  序号范围 1000 ~ 1199

**② 时间戳是怎么排的**


  包序号     时间戳     说明
  -------------------------------------------
  1000       360000     同一帧的第 1 个包
  1001       360000     同一帧的第 2 个包
  1002       363600     同一帧的第 1 个包
  1003       363600     同一帧的第 2 个包
  1004       367200     同一帧的第 1 个包

  **每两个包共用一个时间戳**——因为它们属于同一帧。
  **时间戳只标「这一帧该在什么时候显示」,不标「包是什么时候发的」。**

**③ 会话过程要做的三件事**

  阶段       做什么                        失败会怎样
  -------------------------------------------------------------------
  建会话     分配 SSRC、约定 PT 与时钟     两路 SSRC 撞了就串流
  发流       序号递增、同帧同时间戳        序号乱跳就没法拼帧
  收流       按序号重排、按时间戳对齐      直接播会卡顿、音画错位

  **注意 RTP 本身不保证送达、不保证顺序。**
  **重建靠的是「序号 + 时间戳」这两样东西,全靠接收端自己做。**
  **这也是 RTP 比 TCP 轻的原因:它把可靠性交给了上层按需选择。**

200 个全收到、序号连续——环回不丢包也不乱序,所以这个实验只能验证结构,验证不了「抗丢包」。 时间戳每两包才跳一次 3600,因为我是按「每两包一帧」造的。 序号是 16 位,到 65535 会绕回 0;这个实验没跑到绕回,但真实长连接一定会遇到。

30 秒实验

  1. 把发送循环改成「隔一个不发」(模拟丢包),看接收端的序号出现空洞——这就是丢包检测的原理。
  2. 把发送顺序打乱(先发偶数后发奇数),看接收端按序号重排能不能还原——UDP 不保证顺序。
  3. 把序号从 65530 开始发 20 个,亲眼看一下绕回 0,再想想比较大小该怎么写。

常见误区

  1. 「RTP 会重传」——RTP 本身不重传。重传是 RTCP 的 NACK 机制,要单独实现。
  2. 「收到就播最简单」——局域网上看不出问题,公网上会因乱序而画面跳。必须有重排缓冲。
  3. 「序号大的一定新」——16 位会绕回。比较新旧只能算差值,不能直接比大小。
自测三问(点开对答案) 1. RTP 会话的三件事是什么? —— **建会话定 SSRC、发流序号递增、收流按序号重排。**第三方协议都不帮忙。 2. 为什么直播不做可靠传输? —— **重传一次就是一个 RTT,一帧只有 40 毫秒预算。**来不及,所以宁可丢。 3. 抖动缓冲越深越好吗? —— **不是。**缓冲深抗抖动强,但延迟跟着涨。**要自适应。**

相关笔记