接手一个把几十路网络摄像机接进内部平台的活儿时,我原以为最难的是解码和转码。结果真正让我连着熬了三个晚上的,是RTSP 服务器逻辑这套看着简单、实际处处是坑的交互流程——OPTIONS 回了、DESCRIBE 也回了、SETUP 也对上了,PLAY 之后画面就是不出来;偶尔出来了,跑两分钟开始花屏;再偶尔一切正常,但客户端一多,内存跟开了闸一样往上飙。这些问题没有一个是"协议字段写错了"能概括的,绝大多数是逻辑层的取舍出了问题:会话该活多久、RTP 怎么打包、多路客户端怎么共享同一路源、丢包了要不要补、缓冲堆多深。这篇就把这套逻辑摊开讲,从状态机到打包,从并发布局到排查手段,都是能直接抄进项目里的东西。
适合谁看:正在写流媒体接入网关的、要对接摄像机/NVR 的、做录播和转发服务的、以及被"拉流卡顿花屏"折磨过的后端和嵌入式同行。我尽量不写成协议手册,字段细节只挑那些真正影响代码结构的讲,剩下的你自己查 RFC 更快。
1. 先把 RTSP 服务器想清楚:它到底在解决什么问题
1.1 控制面与数据面分离,这是理解一切的起点
RTSP 本身不传流,它只是个遥控器。真正跑数据的是 RTP,控制反馈走 RTCP,媒体描述走 SDP。我第一次写的时候栽就栽在这——以为 PLAY 之后服务器要用 RTSP 这条连接把视频数据吐出去,结果客户端一直等到超时。不是的。RTSP 只负责告诉对端:准备好收、往哪个端口收、什么时候开始、什么时候停、什么时候拆。数据走的是另一条通道,要么是独立的 UDP 端口对,要么是复用同一条 TCP 连接做交织。
这个分离带来的第一个结构性决策就是:连接对象和会话对象必须是两个独立的东西。连接是 TCP 层面的——一个 socket、一个读缓冲、一个解析状态、一个待发送队列;会话是媒体层面的——一个媒体源引用、一组接收端、一个播放状态、一个鉴权上下文。一条 RTSP 连接上可以挂多个会话,一个会话理论上也能被多条连接引用。你要是在代码里把这两者揉进同一个结构体,后面加"同一路流被多个播放器拉"、"播放中途客户端重连"这类需求时会非常难受,基本等于重写。
还有一个新手常犯的错:把 RTSP 当成"长连接推送协议"来设计,想着服务器主动往里灌数据。它不是。RTSP 的每条请求都是独立的、带 CSeq 的,服务器必须能对同一会话并发处理多条请求,或者至少保证不串号。真机上有客户端会在 PLAY 的同时发 GET_PARAMETER 保活,你要是单线程串行处理又没有队列,CSeq 就会对不上,客户端直接判定协议错误。
注意:别用一个全局大锁把整个服务器串起来。RTSP 请求本身很轻,每秒撑死一两条,但 RTP 是每秒几百上千个包,锁粒度搞错,表现就是"单路流畅、十路卡死"。
1.2 一个最小可用的 RTSP 服务器需要哪几块拼图
我习惯把它拆成六块,缺一块就会在某个具体场景里露馅。这张表是我自己项目里的模块划分,你可以对照着看自己缺哪块:
| 模块 | 核心职责 | 缺了会怎样 |
|---|---|---|
| 连接管理 | socket 生命周期、读缓冲、请求头解析 | 大包被截断、粘包解析出错 |
| 会话管理 | session id、超时、状态机、鉴权上下文 | 客户端重连后状态错乱、旧会话泄漏 |
| 媒体源管理 | 流的注册/查找/下线、源与订阅者关系 | 源重启后客户端拉不到 |
| SDP 生成 | 媒体描述、编码、参数集、控制属性 | 客户端解析失败,根本不发 SETUP |
| RTP 打包发送 | 分片、时间戳、序号、发送队列 | 花屏、卡顿、延迟越拉越大 |
| RTCP 处理 | SR 发送、RR 接收、NTP 同步 | 音视频不同步、丢包无从反馈 |
前四块是逻辑,后两块是数据。绝大多数人写的第一个版本,逻辑部分能跑通,数据部分全是暗病——因为逻辑错误会立刻报错,数据错误只是"画面上偶尔闪一下",很难定位。所以下面篇幅会明显往数据面倾斜。
1.3 先确定你的服务器是哪种角色,再谈实现
这一步很多人跳过,直接开写,写到一半发现方向不对。RTSP 服务器至少有三副面孔,逻辑差别很大:
第一种,拉流服务端(Play 模式)。你持有媒体源,客户端来 DESCRIBE、SETUP、PLAY,你往外推 RTP。摄像机接入网关、NVR 对外提供流,都是这种。核心难点是一路源对多个客户端的分发。
第二种,推流服务端(Announce/Record 模式)。客户端 ANNOUNCE 一段 SDP 过来,SETUP 协商好,然后 RECORD,由客户端往你这里灌 RTP,你负责收、解包、落地或转出。核心难点是接收侧的重排序、丢包检测和参数集缓存,打包逻辑反过来变成了拆包逻辑。
第三种,中转/级联。一路进来、多路出去,典型的是把摄像机流分发给多个下游。它实际上是前两种的组合,但在结构上要求你把"源"和"订阅者"彻底解耦,中间可能还要加一层转封装(比如把私有封装转成标准 RTP)。
我在实际项目里吃过亏:一开始只按"拉流服务端"设计数据结构,源对象里直接塞了发送队列。后来要加推流接入,发现接收缓冲没地方放,只能硬塞,越写越乱。后来重构时把**源(Source)和订阅(Subscription)**拆开,一个源管采集和解封装,每个订阅管自己的发送队列和序号状态,代码一下清爽了,多客户端共享同一路源也变成天然支持的事。
2. 协议交互链路拆解:从 OPTIONS 到 TEARDOWN 的每一步
2.1 方法语义与状态机设计
RTSP 的状态机不复杂,一共就三个有效状态:初始化(Init)→ 就绪(Ready)→ 播放中(Playing),加上暂停回退和拆除终止。但很多人写状态机时把它写成了"请求类型判断",而不是"状态迁移",结果就是各种非法序列处理不干净。
我推荐的做法是:用一张二维表来描述"当前状态下收到某个方法该怎么响应",代码里就是查表 + 分发。
| 方法 | Init 状态 | Ready 状态 | Playing 状态 | 说明 |
|---|---|---|---|---|
| OPTIONS | 200 | 200 | 200 | 任何时候都回,列出支持的方法 |
| DESCRIBE | 200 + SDP | 200 + SDP | 200 + SDP | 幂等,别限制状态 |
| SETUP | 200 → Ready | 200(可重协商) | 455 或 200 | 同一会话重复 SETUP 要谨慎 |
| PLAY | 454 | 200 → Playing | 200 | 已经播放中再 PLAY 也回 200 |
| PAUSE | 455 | 455 | 200 → Ready | 很多客户端不发,但要支持 |
| TEARDOWN | 200 | 200 | 200 | 必须无条件清理 |
| GET_PARAMETER | 200 | 200 | 200 | 最常用的保活手段 |
| SET_PARAMETER | 200 | 200 | 200 | 部分客户端用来设置静音等 |
几个实际踩过的点:DESCRIBE 不要限制状态,有些客户端会在 PLAY 之后重新 DESCRIBE 用于刷新信息;PLAY 在播放中重复收到要回 200 而不是报错,否则某些播放器会直接判定断流;TEARDOWN 必须无条件成功,哪怕会话已经超时被清理,也要回 200,回 454 会让客户端重试,白白占资源。
状态迁移本身还要配套一个"非法请求"的统一处理:CSeq 缺失、Session 头无效、URL 不匹配,这几种要回 400 而不是 500。很多人一律回 500,客户端日志里一片报错,排查起来很费劲。
2.2 SDP 协商:描述写不好,后面全白搭
SDP 是整个链路里最容易被低估的一环。我见过太多"服务器逻辑没毛病,就是拉不出画面"的案例,最后定位到 SDP 里的a=control少了一个斜杠,客户端拼出来的 SETUP URL 根本不存在。
一个视频流的典型描述长这样,我按行解释:
v=0 o=- 1234567890 1 IN IP4 0.0.0.0 s=Live c=IN IP4 0.0.0.0 t=0 0 a=control:* m=video 0 RTP/AVP 96 a=rtpmap:96 H264/90000 a=fmtp:96 packetization-mode=1;sprop-parameter-sets=Z0LAHtkDxWhAAAADAEAAAAwDxYuS,aMuMsg== a=control:trackID=0o=行的会话 ID 和版本号建议用"时间戳 + 递增计数器",别用固定值,某些客户端会缓存;c=写0.0.0.0是标准做法,表示"地址由传输层决定",实际地址在 SETUP 的 Transport 头里再定;m=video 0里的端口写 0,因为端口要到 SETUP 才协商。
a=fmtp里的sprop-parameter-sets是 SPS 和 PPS 的 Base64 编码,逗号分隔,这是能否出首帧的关键。如果客户端拿不到参数集,它就无法初始化解码器,表现就是"连接正常、有 RTP 包、画面全黑"。我的做法是:源对象里常驻缓存最近一次的 SPS/PPS,DESCRIBE 时直接填进去;如果源还没出关键帧,就先回 SDP 但不给参数集,等出了第一个 I 帧再补——但更稳妥的是等参数集就绪再允许 DESCRIBE 成功,宁可让客户端多等两百毫秒。
a=control有两种写法:*表示聚合控制,trackID=0表示单轨控制。SETUP 时客户端会把 URL 拼成rtsp://host/path/trackID=0,你的 URL 路由必须能解析出这个后缀。我一般把a=control显式写成完整的相对路径,避免客户端拼接规则不一致。
音频轨要额外注意:m=audio 0 RTP/AVP 97配a=rtpmap:97 MPEG4-GENERIC/44100/1和a=fmtp:97 streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1210。config字段就是 AudioSpecificConfig 的十六进制,写错一个字节,声音就是噪音。
2.3 会话标识、超时与心跳:最容易埋雷的地方
Session ID 的生成规则没什么讲究,但要求两点:全局唯一和不可预测。我用的是"随机 8 字节 + 单调计数器"再转换成十六进制字符串,长度控制在 16 位以内,太长有些老客户端会截断。
超时是重灾区。SETUP 的响应里必须带超时:
Session: 1A2B3C4D;timeout=60这个 60 是秒,意思是"客户端 60 秒没有任何请求,服务器就可以回收会话"。客户端收到后会按这个节奏发保活。但实际环境里,各种客户端的保活行为差异极大:有的每 25 秒发一次 GET_PARAMETER,有的 50 秒发一次 OPTIONS,还有的完全不发,只在 PLAY 之后一路收数据。
如果你严格按超时清理,那些不发保活的客户端会在 60 秒后被你踢掉,用户看到的就是"看一分钟必断"。我的处理是:把"收到 RTCP RR"也算作保活。客户端在收流时必然周期性发接收报告,这本身就是活着的最好证明。实现上,每次收到该会话的 RTCP 包就刷新一下最后活跃时间,比等 RTSP 请求可靠得多。
提示:清理会话的定时器不要用"每个会话一个定时器线程",几百个会话就是几百个线程。用一个时间轮或者 1 秒一跳的扫描任务,扫一遍所有会话判断超时,成本可以忽略。
2.4 鉴权与 URL 路由:别等到上线才补
鉴权放在哪一层?我建议放在DESCRIBE 之前。OPTIONS 不需要鉴权(客户端通常拿它探测服务器能力),但从 DESCRIBE 开始所有方法都要校验。
用摘要认证比明文认证靠谱,流程是:第一次请求回 401 带WWW-Authenticate,客户端用 nonce 算摘要后重发。实现上有两个细节:nonce 要带时效(比如 60 秒过期),过期后回 401 让客户端重新申请;qop=auth时的计算包含 cnonce 和 nc,算错一个字段客户端就一直 401,你会以为是密码错,其实是算法错。
URL 路由则是把rtsp://host:554/live/cam01/trackID=0映射到一个具体的源。我一般把路径拆成三段:/{业务类型}/{源标识}/{控制后缀},用一个前缀匹配的哈希表找源,避免正则匹配带来的开销。找不到源就回 404,别回 500——这直接影响客户端重试策略。
3. 数据面实现:RTP/RTCP 的组织方式决定体验
3.1 打包与分片:MTU 算不准,花屏跑不了
RTP 包有大小上限,超过 MTU 就要在 IP 层分片,一旦分片丢一个就整包废掉。所以应用层必须先分片。算一下:以太网 MTU 1500,减 IP 头 20、UDP 头 8、RTP 头 12,剩 1460。但中间可能有 PPPoE(减 8)、可能有隧道封装,所以实践值取 1400 甚至 1200。我自己的默认值是 1400,做成可配置,遇到特殊链路再调。
以 H.264 为例,一个 NAL 单元小于阈值就单包发(Single NAL Unit),大于就切成 FU-A 分片。FU-A 的规则是:原始 NAL 头拆成 FU indicator(保留 F 和 NRI,类型置 28)和 FU header(S 位标记首片、E 位标记末片、原始类型放低 5 位)。这里最容易错的是同一帧的所有分片必须共用同一个 RTP 时间戳,序号连续递增,并且末片要设 M 位(Mark bit)表示帧结束。搞错了,解码器就等着补齐,画面卡住不动。
// 关键片段:FU-A 分片,maxPayload 已扣除 RTP 头 bool pack_fu_a(const uint8_t* nal, size_t len, std::vector<Packet>& out, uint32_t ts, uint16_t& seq, uint32_t ssrc, size_t maxPayload) { if (len <= maxPayload) { Packet p; build_rtp_header(p, 12, nal[0], ts, seq++, ssrc, false); p.append(nal, len); out.push_back(p); return true; } const size_t chunk = maxPayload - 2; // 扣掉 FU indicator + FU header size_t offset = 1; // 跳过原始 NAL 头 bool first = true; while (offset < len) { size_t n = std::min(chunk, len - offset); Packet p; // FU indicator:F|NRI 沿用原 NAL 头,类型固定 28 uint8_t fu_ind = (nal[0] & 0xE0) | 28; // FU header:首片置 S,末片置 E,中间类型放低 5 位 uint8_t fu_hdr = (nal[0] & 0x1F); if (first) fu_hdr |= 0x80; if (offset + n >= len) fu_hdr |= 0x40; bool last = (offset + n >= len); build_rtp_header(p, 12, fu_ind, ts, seq++, ssrc, last); p.push(fu_hdr); p.append(nal + offset, n); out.push_back(p); offset += n; first = false; } return true; }H.265 的分片单位是 FU,头结构不一样(两字节头 + 三字节 NAL 头拆分),别把 H.264 的代码直接改个类型就复用,我见过有人这么干,结果 265 的流只出第一帧。音频侧则是另一种逻辑:AAC 要加 AU-headers-length 和 AU-header 前缀,Opus 直接按包发,时间戳时钟也不一样。
3.2 时间戳、序号与 SSRC:每个订阅必须独立
这里是我认为整个服务器逻辑里最关键的一条经验:当一路源被多个客户端拉取时,每个客户端必须拥有独立的序号空间和独立的时间戳基线。
原因很简单:RTP 的序号是用来检测丢包和重排序的。如果你把同一批包原样复制给三个客户端,其中一个网络抖动丢了两个包,它的接收报告里就会出现丢包,而你无法知道是哪个链路丢的。更严重的是,如果某个客户端期待从它 SETUP 时的随机序号开始,你给的是源自己的连续序号,加解密或者校验严格的客户端会直接丢弃。
所以正确做法是:源只负责"产生逻辑帧"(带一个单调递增的 DTS 和编码数据),每个订阅在发送时自己组装 RTP 头——序号自己加、SSRC 自己生成、时间戳按 90kHz 基准折算。
时间戳的换算:视频时钟固定 90000Hz,一帧的增量是90000 / fps。25fps 就是 3600,30fps 是 3000。注意这个值必须是整数,累加时用整数累加而不是每次用浮点乘,浮点累积误差跑几个小时就能导致音视频错位几百毫秒。我的做法是维护一个pts_90k累加器,每帧加上帧率对应的整数值,遇到非整数帧率(如 29.97)用分子分母的形式做定点累加。
还有一个隐蔽的坑:B 帧的时间戳会乱序。有 B 帧的流,DTS 是单调的但 PTS 不是。你的发送顺序必须按 DTS,但 RTP 时间戳要填 PTS。如果源本身是带 B 帧的,你就要在解封装层做 PTS/DTS 分离;如果不想处理,就在编码参数里关掉 B 帧——绝大多数监控场景根本不需要 B 帧,关掉能省一大堆麻烦。
3.3 RTCP:不发 SR,音视频就永远不同步
很多人只发 RTP,不管 RTCP,结果就是音频和视频大概对得上,但看久了嘴型会有几百毫秒偏差。根因是没有 SR(发送者报告)来告诉客户端"NTP 时间 X 对应 RTP 时间戳 Y"。
RTCP SR 里有两个关键字段:NTP 时间戳(64 位,秒的高 32 位 + 小数低 32 位)和对应的 RTP 时间戳。客户端用这两者的映射关系把音频和视频对齐到同一个墙上时间。我一般每 5 秒发一次 SR,这个间隔是经验值:太频繁占带宽,太稀疏客户端同步精度差。
发送 SR 的时候要填准:packet_count是到此刻为止发送的 RTP 包总数,octet_count是载荷字节数(不含 RTP 头)。这两个值客户端拿去做丢包率估算,填错了你会在客户端日志里看到"丢包率 300%"这种荒唐数字。
接收侧也一样重要。客户端发来的 RR 里带丢包率和累计丢包数。我在实际项目里用这个做链路质量监控:某个订阅的丢包率连续 5 秒超过 5%,就降级——要么把码率上限调低,要么把 UDP 切成 TCP 交织。这个自动降级逻辑上线之后,"用户投诉卡顿"的工单少了一大半。
3.4 TCP 交织模式:不是备胎,是刚需
UDP 在公网或者复杂内网里经常打不通,尤其是客户端在 NAT 后面、或者有防火墙策略。这时要用 RTSP over TCP 的交织模式:SETUP 时 Transport 写成RTP/AVP/TCP;unicast;interleaved=0-1,之后 RTP 数据直接复用到 RTSP 那条 TCP 连接上。
格式很简单:一个$字符(0x24),跟着 1 字节通道号、2 字节大端长度、然后是 RTP 包本体。通道号 0 是 RTP,1 是 RTCP,对应interleaved=0-1的约定。
实现上有两个必须注意的点:第一,发送时要保证原子性,不能一个线程写 RTP、另一个线程写 RTSP 响应,否则字节流交错,客户端解析直接崩。要么加发送锁,要么用一个统一的发送队列,由一个写线程出队。第二,TCP 是有背压的,客户端处理不过来会导致你的发送缓冲堆积,进而让延迟越滚越大。所以交织模式下必须设一个水位线,队列超过阈值就丢帧——而且丢的时候要整帧丢,不能只丢一半。
4. 并发与资源管理:多路接入时的稳定性设计
4.1 线程模型:一连接一线程撑不住,但也别一步跨到全异步
我试过三种模型,说说各自的真实体感。
一连接一线程:写起来最快,跑十路以内没问题。但每路线程里有阻塞读,加上每个订阅的定时发送,线程数很容易上到几百。上下文切换一多,表现就是"CPU 不高但就是卡"。而且线程栈默认 8MB,几百个线程光虚拟内存就吃掉几个 G。
IO 多路复用 + 小线程池:这是我现在的默认选择。用一个 epoll(或对应平台的等价机制)管所有 RTSP 连接和 TCP 交织的数据发送,业务逻辑在事件回调里跑,重活(解封装、转封装)丢给固定的几个工作线程。连接数几百、会话数上千,CPU 占用很稳。代价是状态机必须写成非阻塞的,读缓冲要自己管理——收到半条请求要存着,等下一批数据拼起来。
每路源一个线程 + 每订阅非阻塞发送:源侧的解封装通常是阻塞式的(等下一帧),所以给每个源配一个线程是合理的。订阅侧只往队列里塞数据,由统一的发送线程出队,避免订阅数增长带来线程数增长。
这里有个细节:发送队列必须是每订阅独立的,且容量有上限。我吃过一次亏,队列不限长,某个客户端网络断了但 TCP 连接还没断,数据一直往里堆,十分钟后进程内存涨到 4G 被系统干掉。后来加了硬上限(按"秒数"算,比如缓存 2 秒的量),超了就丢最旧的整个 GOP。
4.2 GOP 缓存:新客户端接入为什么要等首帧
客户端 PLAY 之后,如果源当前正在发 P 帧,它收到的第一批包全是参考帧缺失的垃圾,解码器要么报错要么出绿屏,直到下一个 I 帧到来才恢复正常。25fps、GOP 50 的情况下,最坏要等 2 秒。用户体感就是"点了播放,黑屏两秒才出画面"。
解决方案是在源侧缓存最近一个 GOP 的头:从最近一个 I 帧(含 SPS/PPS)开始缓存,遇到下一个 I 帧时清空重置。新订阅接入时,先把缓存里的这一组帧按自己的时间戳基线重打包发出去,然后接上实时流。
这么做有两个副作用要处理:第一,缓存会带来内存开销,一个 4Mbps 的流缓存 2 秒就是 1MB,几十路就是几十 MB,可接受但要设上限,比如最多缓存 60 帧或者 2MB,超了就从中间截断(只保留 I 帧和参数集,其余丢弃,让客户端自己等下一个 I 帧)。第二,缓存帧的时间戳要整体平移,让第一帧的 PTS 成为该订阅的基线,否则客户端会认为你的时间戳倒退,直接重置。
4.3 定时器、超时与资源回收
服务器里跑着的定时任务至少有四类:会话超时清理、RTCP SR 周期发送、发送队列水位检查、源的存活探测。这四类都别用独立线程 + sleep,一是精度差,二是数量一多就乱。
我的做法是一个 100ms 粒度的调度轮,把所有定时任务注册成"下次执行时间 + 回调",每轮扫一遍到期就执行。会话超时用 1 秒粒度就够了,SR 用 5 秒,水位检查用 20ms——不同粒度可以在同一轮里做,只要记录上次执行时间。
资源回收的重点是引用计数。源被订阅引用,订阅被连接引用,任何一环提前释放都会导致野指针,而这种崩溃往往延迟几分钟才发生,堆栈完全对不上。我的经验是:源只由"源管理器"持有,连接只持有订阅的 ID 而不是指针,所有跨线程访问都通过 ID 查表 + 加锁。多一层间接,但换来的是不会莫名其妙崩。
5. 常见问题与排查技巧实录
5.1 拉不出画面:先分清是信令问题还是数据问题
排查顺序我固定成三步:看 RTSP 交互有没有走完、看 RTP 有没有到、看包内容对不对。
信令层面最常见的三个错误:一是 SDP 的a=control和后缀路由对不上,客户端 SETUP 到你没实现的路径,回 404;二是 Transport 头解析时没处理client_port和interleaved二选一的情况,导致回应里给的端口跟客户端期待的不一致;三是 Session 头没在 SETUP 响应里返回,客户端后续请求带的是空 Session,被判定为非法。
数据层面,如果抓包能看到 UDP 包往外发,但客户端不显示画面,八成是 SPS/PPS 没给对。这时候去对比一下实际流的 SPS 和你 SDP 里写的 Base64 是否一致。有个很隐蔽的坑:SPS 长度必须按 4 字节对齐的 Base64 编码,中间不能有换行,多个参数集用逗号分隔——某些编码器输出的 SPS 里带了 emulation prevention 字节,直接 Base64 会错,要先还原。
5.2 花屏、绿屏、马赛克:几乎都是打包问题
按出现规律可以快速定位:
| 现象 | 大概率原因 | 排查手段 |
|---|---|---|
| 整块绿、只有部分区域对 | SPS/PPS 中的分辨率/裁剪参数与实际流不符 | 对比参数集与解码输出尺寸 |
| 随机位置马赛克,几秒后恢复 | 分片丢包,MTU 太大导致 IP 分片 | 把打包阈值降到 1200 试试 |
| 固定位置一直花 | 某个 NAL 单元被截断或长度算错 | 检查 FU-A 末片 E 位和长度字段 |
| 画面卡住不动,日志无异常 | 分片时 M 位没设,解码器等帧结束 | 核对末片的 Mark bit |
| 首帧黑,之后正常 | GOP 缓存没从 I 帧开始 | 检查缓存重置逻辑的触发条件 |
我印象最深的一次:某型号编码器输出的 SPS 里带了 VUI 参数,我们的解析器只认固定长度,把后面的 PPS 吃掉了,导致部分通道花屏、部分正常(因为分辨率不同)。定位方法是把原始码流 dump 出来用通用工具解一遍,确认码流本身没问题,再逐个字段比对。
5.3 延迟越跑越大:一定是缓冲在偷偷累积
这个问题特别典型:刚开始延迟 200ms,看半小时变成 5 秒。根因永远是某一级缓冲只进不出。
第一处检查:发送队列。如果你用阻塞写或者不设上限,网络慢一点队列就开始涨。加水位线,超了就丢整帧,别心疼——对实时监控来说,旧画面比没画面更糟糕。
第二处检查:源侧的采集缓冲。有些解封装接口默认带深度缓冲,你读得慢它就攒着,读到的是几十秒前的数据。要么把缓冲深度设成 0 或 1,要么每次读之前判断时间戳,落后超过阈值就跳到最新。
第三处检查:TCP 交织模式下的内核发送缓冲。这个最阴,因为你在应用层看不到。判断方法是看SO_SNDBUF相关指标,或者干脆在应用层做端到端延迟测量——发送时记录帧的采集时刻,客户端如果能配合上报接收时刻,差值就是真实延迟。我一般会在测试阶段用这个办法量一遍,确认延迟稳定在 300ms 以内才敢上线。
5.4 内存缓慢上涨:多半是会话没回收干净
跑了三天内存涨 200MB,重启就好,这种问题最难查。我总结的排查清单:
- 会话表只增不删:超时清理任务因为异常没跑起来,或者判断条件写错(比如用创建时间而不是最后活跃时间)。
- 订阅没解绑:客户端断连后 TCP 连接被回收了,但订阅还挂在源上,源的订阅列表越来越长。
- 缓存无上限:GOP 缓存、参数集缓存、日志缓冲,任何一个没有上限都会慢慢涨。
- RTCP 统计表只增不减:每个订阅维护的丢包统计如果按 SSRC 建表而不清理,长跑必涨。
我的做法是在服务里内置一个/stats之类的内部接口,输出当前连接数、会话数、订阅数、各队列深度、缓存字节数。上线后每天看一眼曲线,比出事之后再查强一百倍。
6. 落地经验与参数速查
6.1 参数表:这些值我基本不调了
| 参数 | 推荐值 | 理由 |
|---|---|---|
| RTP 打包上限 | 1400 字节 | 兼容 PPPoE 和常见隧道封装 |
| 会话超时 | 60 秒 | 兼顾客户端保活差异和资源回收速度 |
| SR 发送间隔 | 5 秒 | 同步精度与带宽的平衡点 |
| GOP 缓存上限 | 2MB 或 60 帧 | 首帧等待与内存的折中 |
| 订阅发送队列 | 2 秒的帧量 | 超时即丢,防止延迟累积 |
| 定时器粒度 | 100ms | 足够覆盖所有周期任务 |
| 请求头大小上限 | 8KB | 防止恶意大包打爆内存 |
| TCP 交织写超时 | 5 秒 | 超时即判定客户端僵死并断开 |
这些值不是拍脑袋来的,每一条背后都对应过一次线上问题。比如那个 8KB 的请求头上限,是有客户端在 GET_PARAMETER 里塞了超长的自定义头,直接把解析缓冲区撑爆,进程段错误。加了上限之后回 414,客户端乖乖降级。
6.2 日志与观测:出了问题能自己说话
RTSP 服务器的日志有几个原则:按会话打标签、按阶段记录、错误带上下文。我一般每条日志前缀带会话 ID 的前 8 位,这样一条链路上的 DESCRIBE、SETUP、PLAY 能串起来看。
必打的几个点:SETUP 成功时记录协商出的传输方式和端口;PLAY 成功时记录当前 GOP 缓存状态(是否命中、从第几帧开始);每 10 秒打一条统计(发送包数、字节数、队列入队/出队速率、丢弃帧数);断开时打一条汇总(持续时长、平均码率、丢帧总数)。
这套日志在排查时太值钱了。有一次用户反馈"某路流看几分钟就断",日志里那条会话的汇总显示播放 65 秒、丢帧 0、发送正常——那就是客户端主动断的,问题在客户端侧,省了我半天的排查。
最后分享一个小技巧,也是我这些年最受用的一条:任何实时流系统,都要给自己留一条"可以绕过所有缓冲"的通路。调试时用它验证源是否正常,比一层层怀疑中间环节快得多。我通常会在源对象上加一个直通模式,把解封装出来的一帧直接吐到本地文件或者发给一个固定地址,确认码流没问题后再回头查分发逻辑。这个开关平时关着,出事时一开,基本五分钟能定位到底是谁的锅。