客户端的状态机是「我在哪一步」,服务器的状态机是「这一堆连接里谁是谁」。 所以服务器要维护三张表:会话表、连接表、流表——少一张就会串线或漏端口。
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 秒实验
- 把最后一步 TEARDOWN 换成 PAUSE,看会话表剩余条目变成 1——这就是「不发 TEARDOWN 会泄漏」。
- 在
SESS[SID]里加一个begin字段记下进入 Playing 的时刻,算一算这一段播了多久——这就是计费与统计的雏形。 - 把
handle里del SESS[SID]那行注释掉,看会怎样——会话留着,端口不释放,重连时会拿到重复的端口冲突。
常见误区
- 「服务器状态机和客户端一样」——客户端一进一出,服务器一进对多出。服务器还要维护会话表、连接表、流表。
- 「心跳回了 200 就算保活成功」——还得刷新「最后活跃时刻」。少了这一步,客户端明明在发心跳,会话还是被自己拆掉。
- 「URL 相同就复用同一条会话」——不行。同一个流被两个客户端拉,是两个 Session、两对端口。
自测三问(点开对答案)
1. 服务器端要维护哪三张表? —— **会话表(Session→状态)、连接表(socket→会话)、流表(URL→媒体源)。**三者靠 Session 号串起来。 2. 为什么服务器处理完请求一定要刷新时间戳? —— **因为超时是按「最后活跃时刻」算的。**不刷新,心跳就白发了。 3. 漏发 TEARDOWN 对服务器的实际影响是什么? —— **端口被占 60 秒才回收。**摄像头并发只有 4~6 路,会「退出了却连不上」。- ☐ (本节完,下一节 2.7 VLC作为RTSP流媒体服务器)