GB28181首帧<500ms,信令与流媒体如何协同

0 阅读6分钟

首帧时间都花在哪了? 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性能优化,欢迎来交流。代码全透明,随便看,随便改。