首帧时间都花在哪了? GB28181实时点播,从用户点击“播放”到画面出现,中间经历了什么?
分解一下:
前端发起点播请求到后端
后端构造INVITE,发给设备
设备回200 OK,带SDP
后端发ACK,设备开始发RTP流
流媒体服务器(ZLMediaKit)接收RTP,启动转发
前端播放器拉流,首帧渲染
每一步都有延迟。INVITE往返可能50-200ms,SDP协商可能20-50ms,RTP端口分配可能10-100ms,流媒体启动可能100-300ms,首帧渲染可能50-200ms。
加起来,很容易超过1秒。
要把首帧压到500ms以内,必须每一步都优化。
RTP端口池预分配 传统做法:点播请求来了,临时找一个空闲端口,创建RTP接收器,绑定端口,通知设备往这个端口发流。
问题:端口分配、socket创建、绑定,这些都是系统调用,耗时不稳定。高峰期可能几十毫秒甚至上百毫秒。
优化:启动时预分配一批RTP端口,每个端口对应一个预创建的socket。 点播时直接从池里取一个,不用临时创建。
代码大概长这样: class RTPPortPool: def init(self, start_port=30000, size=200): self.pool = [] for i in range(size): port = start_port + i sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind(('0.0.0.0', port)) self.pool.append(RTPPort(port, sock, in_use=False))
def acquire(self):
for rtp in self.pool:
if not rtp.in_use:
rtp.in_use = True
return rtp
raise NoAvailablePort()
这一步能把端口分配时间从几十毫秒降到微秒级。
四路并行INVITE GB28181点播,通常要同时拉主码流和子码流。传统做法是串行:先发主码流INVITE,等响应,再发子码流INVITE。
问题是,两个INVITE之间没有依赖关系,完全可以并行。
优化:同时发四路INVITE。 主码流两路(UDP和TCP各一路),子码流两路。哪路先响应就用哪路,其余取消。
为什么是四路?因为GB28181设备对UDP和TCP的支持程度不一样。有的设备UDP丢包严重,但TCP稳定。有的设备TCP延迟高,UDP更快。同时探测,选最快的。
代码用asyncio.gather:
async def parallel_invite(device, media): tasks = [ invite(device, media, transport='UDP'), invite(device, media, transport='TCP'), invite(device, media, transport='UDP', stream='sub'), invite(device, media, transport='TCP', stream='sub'), ] done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) for task in pending: task.cancel() return done.pop().result() 这一步能把SDP协商时间砍掉一半。
RTT自适应定时器 SIP事务层有很多定时器,初始值都是RFC 3261规定的。比如T1=500ms,T2=4s。
但GB28181设备差异很大。有的设备响应快,50ms就回200 OK。有的设备慢,500ms才回。如果定时器统一用500ms,快设备浪费时间,慢设备频繁重传。
优化:根据历史RTT动态调整定时器。 每个设备维护一个RTT滑动窗口,INVITE的T1定时器根据RTT调整。快设备用更短的T1,慢设备用更长的T1。 class DeviceRTT: def init(self): self.samples = deque(maxlen=10)
def update(self, rtt_ms):
self.samples.append(rtt_ms)
def get_t1(self):
if not self.samples:
return 500 # 默认
avg = sum(self.samples) / len(self.samples)
return max(100, min(2000, int(avg * 1.5)))
这一步能减少无效重传,降低延迟。
与ZLMediaKit的Hook对接 流媒体层用ZLMediaKit,信令层和流媒体层通过Hook对接。
点播流程:
信令层收到点播请求
信令层调用ZLMediaKit的openRtpServer API,申请一个RTP接收端口
ZLMediaKit返回端口号
信令层构造INVITE,SDP里媒体端口填这个端口
设备往这个端口发RTP
ZLMediaKit收到RTP,启动转发,生成FLV/HLS/WebRTC流
前端播放器拉流
关键优化:提前调用openRtpServer。 不要等INVITE响应了再申请端口,而是在发INVITE之前就申请好。这样设备发RTP的时候,ZLMediaKit已经准备好了。
UDP → TCP自动降级 GB28181点播默认走UDP。但UDP在复杂网络环境下丢包严重,画面花屏、卡顿。
优化:实时监测UDP质量,劣化时自动切换到TCP。
怎么监测?看RTCP RR(Receiver Report)里的丢包率。丢包率超过阈值(比如5%),触发降级。
降级流程:
信令层发BYE,结束当前UDP会话
重新发INVITE,SDP里m=video的传输协议改成TCP
设备回200 OK,开始走TCP发流
这个过程对用户来说,画面会有短暂卡顿,但不会断。比一直花屏强。
RTCP NACK丢包重传 即使走UDP,也不是所有丢包都要降级。偶尔丢一两个包,用NACK重传就行。
RTCP NACK(RFC 4585)允许接收端告诉发送端:“我丢了序列号X的包,请重发。”
PyGBSentry在流媒体层实现了NACK处理:
接收端检测到序列号不连续,发NACK
发送端收到NACK,从缓存里找到对应包,重传
接收端收到重传包,恢复画面
这一步能解决大部分偶发丢包,避免不必要的降级。
双缓冲无缝切换 主码流和子码流切换,传统做法是先停主码流,再起子码流,中间会黑屏或花屏。
优化:双缓冲。 主码流和子码流同时接收,播放器缓存两路流。切换时,直接从主码流缓冲切到子码流缓冲,画面无缝过渡。
实现上,流媒体层维护两个RTP接收器,分别对应主码流和子码流。播放器根据网络状况,动态选择播放哪一路。切换时,用时间戳对齐,避免花屏。
实测数据 测试环境:8核16G云服务器,Docker部署,ZLMediaKit + PyGBSentry。
指标 数值 500路设备同时在线 稳定 并发点播200路 稳定 首帧平均延迟 480ms 首帧P95延迟 620ms 持续运行4小时 无掉线,内存稳定 UDP丢包率5%时自动降级 切换时间 < 1s NACK重传恢复率 > 90% 这个数据不算惊艳,但足以支撑大多数安防项目。一个园区、一个工厂、一个县级平台,几百路设备的规模,完全够用。
项目现状 PyGBSentry已经开源,AGPL v3.0协议,GitHub和Gitee同步维护。支持GB/T 28181-2022,向下兼容2016版。设备注册、实时预览、录像回放、云台PTZ、语音对讲、平台级联,功能完整。
部署三条命令: git clone gitee.com/suoten/PyGB… cd PyGBSentry/editions/open-source python tools/generate_env.py --docker && docker compose up -d
Gitee: suoten/PyGBSentry GitHub: suoten/PyGBSentry
如果你也在做GB28181性能优化,欢迎来交流。代码全透明,随便看,随便改。