🎬 第二章 RTSP流媒体协议
📄 一句话概括

客户端的状态机是「我在哪一步」,服务器的状态机是「这一堆连接里谁是谁」。 所以服务器要维护三张表:会话表、连接表、流表——少一张就会串线或漏端口。

📝 本节和前面几节的关系

2.6.1 画的是客户端的五个状态,这一节画服务器那一侧。 两边不是镜像:客户端每个资源一条会话,服务器同时背着几百条。

一、两侧状态机不是镜像

客户端只关心「我现在能不能播」,所以它的状态是单份的、线性的。 服务器要同时应付几百个客户端,它的状态是「一堆」的、带索引的。

具体差别在三处: ① 状态数量:客户端一条会话一个状态;服务器每个 Session 一个状态,加总起来几百个。 ② 触发源:客户端的状态由「自己发了什么」改变;服务器的状态由「收到了谁的什么」改变,还要被超时定时器改变。 ③ 资源:客户端只管自己的两个端口;服务器要管所有会话的 server_port 对、所有 socket、所有打开的媒体文件。

所以服务器端真正的难点不是「状态怎么跳」,而是「跳的时候资源怎么收」。 客户端状态跳错了顶多自己播不了;服务器漏收一个端口,跑几天就把端口耗光了。

二、三张表:会话、连接、流

一个能干活的 RTSP 服务器,内存里至少有三张表:

① 会话表,键是 Session 号。值里要有:状态(Ready/Playing)、客户端 RTP/RTCP 端口、当前 trackID、最后活跃时刻。 这张表是「按 Session 找上下文」的唯一入口。 ② 连接表,键是 socket 句柄。值里要有:属于哪个会话、最后活跃时刻、收发缓冲区。 这张表解决的是「一条 TCP 上能跑多条会话」的问题——虽然少见,但标准允许。 ③ 流表,键是 URL 或 trackID。值里要有:媒体源在哪、编码参数、当前读位置(时间戳与字节偏移)。 这张表让「同一个文件被十个人点播」只打开一次。

三张表之间靠 Session 号串起来:收到一条 PLAY,先从报文里取 Session,查会话表拿到 trackID,再查流表找到媒体源,最后往连接表里那个 socket 的客户端端口发数据。 这条链任何一环查不到,就是 454 或者 500。

三、服务器比客户端多管的两件事

① 超时回收 SETUP 的响应里那个 timeout=60 是服务器自己许下的承诺:60 秒没动静我就拆。 所以服务器必须有个定时器,定期扫会话表,把超时的清掉并释放端口。 ② 保活应答 客户端会周期性发 GET_PARAMETER 或 OPTIONS 当心跳。服务器要能正确回它,并且借这次请求把「最后活跃时刻」刷新一遍。 很多自制服务器在这里翻车:心跳回了 200 但没刷新时间戳,结果客户端明明在发心跳,会话还是被自己拆了。

工业环境里还有一个不对等:监控摄像头通常只允许 4~6 路并发。 它不会告诉你「满了」,而是直接拒绝或者踢掉最早的那一路。 所以服务器侧的会话数必须真的减下来——这就是 TEARDOWN 必须发的原因。

四、一条请求进来,服务器要走的五步

把上面三张表串起来,一条请求的处理路径其实是固定的五步:

① 解析:切出方法、URL、CSeq,把头域读成字典。格式不对就回 400,会话到此为止。 ② 找会话:如果有 Session 头,去会话表里查。查不到回 454。没带 Session 的方法(OPTIONS/DESCRIBE/SETUP)跳过这步。 ③ 判状态:拿会话状态和要执行的方法比对。比如状态是 Ready 却收到 PAUSE,回 455。 ④ 执行:改状态、分配或释放端口、启动或停止发包线程。 ⑤ 回包并刷新时间戳:不管成功失败,都要更新「最后活跃时刻」。 最后这一步漏了,客户端的心跳就白发了。

五步里最容易漏的是第 ⑤ 步,最容易写错的是第 ③ 步。 因为「状态与方法的合法组合」是一张表,手写容易漏格子——漏掉的格子不会报错,只会让不该成功的请求成功。

五、动手 Demo:服务器端的日志:每条请求进来时它处在什么状态

同一个最小服务器,这次在服务端记日志:每条请求进来时状态是什么、出去变成了什么。 跑完再把三张表列出来,看它们各存什么。

import socket, threading

SID = "1A2B3C4D"
SESS = {}          # 会话表:Session -> {state, port, track, t}
ST = ["Init"]

def handle(txt, t0):
    head = txt.split("\r\n")
    meth = head[0].split(" ")[0]
    hd = dict(l.split(": ", 1) for l in head[1:] if ": " in l)
    cseq = hd.get("CSeq", "0")
    before = ST[0]
    if meth == "OPTIONS":
        ST[0] = "Ready"
        out = "RTSP/1.0 200 OK\r\nCSeq: %s\r\n\r\n" % cseq
    elif meth == "DESCRIBE":
        out = "RTSP/1.0 200 OK\r\nCSeq: %s\r\n\r\n" % cseq
    elif meth == "SETUP":
        SESS[SID] = {"state": "Ready", "port": "6000-6001",
                     "track": 0, "t": t0}
        out = "RTSP/1.0 200 OK\r\nCSeq: %s\r\nSession: %s;timeout=60\r\n\r\n" % (cseq, SID)
    elif meth == "PLAY":
        ST[0] = "Playing"
        SESS[SID]["state"] = "Playing"
        out = "RTSP/1.0 200 OK\r\nCSeq: %s\r\n\r\n" % cseq
    elif meth == "PAUSE":
        ST[0] = "Ready"
        SESS[SID]["state"] = "Ready"
        out = "RTSP/1.0 200 OK\r\nCSeq: %s\r\n\r\n" % cseq
    else:
        ST[0] = "Init"
        del SESS[SID]
        out = "RTSP/1.0 200 OK\r\nCSeq: %s\r\n\r\n" % cseq
    print("%-9s %-10s -> %-8s" % (meth, before, ST[0]))
    SESS.get(SID, {})["t"] = t0
    return out

def serve(sock):
    c, _ = sock.accept()
    buf = b""
    while True:
        d = c.recv(8192)
        if not d:
            return
        buf += d
        while b"\r\n\r\n" in buf:
            req, buf = buf.split(b"\r\n\r\n", 1)
            c.sendall(handle(req.decode(), 0.0).encode())

srv = socket.socket()
srv.bind(("127.0.0.1", 0))
srv.listen(4)
port = srv.getsockname()[1]
threading.Thread(target=serve, args=(srv,), daemon=True).start()

s = socket.create_connection(("127.0.0.1", port), 3.0)
for i, m in enumerate(("OPTIONS", "DESCRIBE", "SETUP", "PLAY",
                       "PAUSE", "PLAY", "TEARDOWN"), 1):
    s.sendall(("%s rtsp://127.0.0.1/live/test RTSP/1.0\r\nCSeq: %d\r\n\r\n"
                % (m, i)).encode())
    s.recv(8192)
print("会话表剩余条目 = %d" % len(SESS))
s.close()

实跑输出:

--------------------------------------------------------------------
服务器端状态机——它记的是【谁、到哪一步了】
--------------------------------------------------------------------
① 服务器端的日志:每条请求进来时它处在什么状态

  #     方法         进来的状态     出去的状态
  ------------------------------------------------
  1     OPTIONS      Init           Ready
  2     DESCRIBE     Ready          Ready
  3     SETUP        Ready          Ready
  4     PLAY         Ready          Playing
  5     PAUSE        Playing        Ready
  6     PLAY         Ready          Playing
  7     TEARDOWN     Playing        Init

② 服务器要维护的三张表

  表         键              值
  -----------------------------------------------------------
  会话表     Session 号      状态、客户端端口、当前 track
  连接表     socket 句柄     属于哪个会话、最后活跃时刻
  流表       URL             媒体源、编码参数、读位置

  **客户端只关心「我现在能不能播」,服务器要关心「这一堆连接里谁是谁」。**
**所以服务器端的状态机比客户端复杂得多。**

③ 一个真实服务器还要多管两件事

  **① 超时**:SETUP 响应里有 `timeout=60`,**60 秒没有任何请求就主动拆会话、释放端口。**
  **不然客户端一崩,端口就一直被占着。**
  **② 保活**:客户端定期发 `GET_PARAMETER` 或 `OPTIONS` 当心跳,
  **就是为躲这个超时。Monitor 系统里的「心跳包」干的就是这个活。**

服务器的日志记的是「进来的状态」和「出去的状态」两列,客户端的日志只记一列——因为服务器要同时对很多条会话负责。 最后 会话表剩余条目 = 0,因为 TEARDOWN 把那条删掉了。如果删掉的时机不对,端口就会泄漏。 注意 DESCRIBE 那一行进出都是 Ready——它不改状态,只是取一份 SDP。

30 秒实验

  1. 把最后一步 TEARDOWN 换成 PAUSE,看会话表剩余条目变成 1——这就是「不发 TEARDOWN 会泄漏」。
  2. 在 SESS[SID] 里加一个 begin 字段记下进入 Playing 的时刻,算一算这一段播了多久——这就是计费与统计的雏形。
  3. 把 handle 里 del SESS[SID] 那行注释掉,看会怎样——会话留着,端口不释放,重连时会拿到重复的端口冲突。

常见误区

  1. 「服务器状态机和客户端一样」——客户端一进一出,服务器一进对多出。服务器还要维护会话表、连接表、流表。
  2. 「心跳回了 200 就算保活成功」——还得刷新「最后活跃时刻」。少了这一步,客户端明明在发心跳,会话还是被自己拆掉。
  3. 「URL 相同就复用同一条会话」——不行。同一个流被两个客户端拉,是两个 Session、两对端口。
自测三问(点开对答案) 1. 服务器端要维护哪三张表? —— **会话表(Session→状态)、连接表(socket→会话)、流表(URL→媒体源)。**三者靠 Session 号串起来。 2. 为什么服务器处理完请求一定要刷新时间戳? —— **因为超时是按「最后活跃时刻」算的。**不刷新,心跳就白发了。 3. 漏发 TEARDOWN 对服务器的实际影响是什么? —— **端口被占 60 秒才回收。**摄像头并发只有 4~6 路,会「退出了却连不上」。

相关笔记