1. 收流服务器的架构设计与选型分析
前几篇我们已经把SIP信令层的注册、邀请、心跳这些环节捋清楚了,设备也正常返回了200 OK,并且开始往我们这边推流。但真正到了这一步,很多人才会发现,信令通了只是万里长征第一步,收流才是真正考验工程能力的地方。
先说个我自己的体会:GB28181的收流服务器,难点从来不在“收”这个动作本身,而在并发管理和数据流转。一台设备推流可能很简单,但当你面对几十路、上百路摄像机同时推流的时候,如果没有一个好的网络层框架,EPOLL那些底层细节能把你折磨到怀疑人生。
所以我选了Netty。原因很简单:第一,它的事件驱动模型和GB28181这种长连接、多路复用的场景是绝配;第二,内存池、零拷贝这些特性对处理RTP这种高频小包非常友好;第三,社区成熟,踩坑成本低。当然你也可以用Java原生的NIO自己撸,但我真心不建议,除非你想重新造一遍轮子并且有充足的时间去填坑。
1.1 为什么必须同时支持UDP和TCP
GB28181标准里,媒体传输有两种模式:UDP和TCP passive/TCP active。不同的厂商设备默认模式不一样,有的老设备只支持UDP,有的新设备为了过防火墙方便默认走TCP。如果你只实现一种,大概率会在对接现场被设备方的兼容性问题搞得焦头烂额。
从实际部署角度来看,UDP模式简单直接,RTP包发过来我们收就行,但丢包不可控;TCP模式有重传机制,链路更可靠,但需要处理粘包半包,而且连接管理更复杂。两种模式一起上,工作量并不是简单的加法,因为你要维护两套接收链路,还要保证它们最终汇聚到同一个处理流程里。
我见过不少项目,一开始图省事只做了UDP,结果到了现场发现某品牌的NVR只愿意推TCP流,又回来加班补TCP支撑。所以建议从一开始就双栈并行,省得后面返工。
1.2 端口分配的两种策略对比
UDP和TCP的端口使用策略,网上的实现大概有两派:
一派是固定端口:不管多少路流,都用同一个端口接收。优点是配置简单,防火墙规则好开;缺点也很致命——你没法区分这个RTP包属于哪路会话,必须通过SSRC(同步源标识)来映射。虽然GB28181确实带了SSRC字段(通常和SIP通话ID关联),但实际对接中,总有设备不按规矩填,SSRC和SIP Call-ID的对应关系不一定可靠。
另一派是动态端口:每一路SIP会话建立时,动态分配一个独立的端口。优点是天然隔离,不同会话之间互不干扰,排查问题也方便;缺点是端口资源占用多,而且SIP响应的Contact头里要带上实际监听端口,对信令层的联动要求更高。
我个人的倾向是UDP用动态端口,TCP用固定端口+会话ID区分。理由后面细说。
2. Netty服务端的工程落地细节
这里我直接上干活的经验,从工程结构到核心代码,把关键点一个个过一遍。
2.1 工程目录的核心划分
如果你是用Spring Boot来搭这个收流服务,我建议不要把Netty的handler一股脑全扔在一个包下面,按职责拆开,不然代码腐化速度会远超你的预期。我的工程结构大致是这样:
com.example.gb28181 ├── netty │ ├── udp │ │ ├── UdpServerInitializer.java │ │ └── UdpServerHandler.java │ ├── tcp │ │ ├── TcpServerInitializer.java │ │ └── TcpServerHandler.java │ └── server │ ├── MediaServerManager.java │ └── ServerStartupListener.java ├── rtp │ ├── RtpPacketParser.java │ └── RtpPacket.java ├── session │ ├── SipSession.java │ └── SessionRegistry.java └── stream └── StreamDispatcher.java这里最核心的是MediaServerManager,它负责管理UDP和TCP服务器的生命周期,以及端口分配策略。我遇到过有人把Netty的启动写在Controller里,请求一次启动一次,这种操作直接导致端口被疯狂占用,最终OOM。启动应该跟着Spring容器走,ApplicationRunner里做初始化,DisposableBean里做销毁。
2.2 UDP接收服务器的Netty配置
先写UDP的启动代码,这个相对简单,但有几个参数坑必须要说。
@Component public class UdpServerInitializer { private EventLoopGroup workerGroup; public void start(int port) throws InterruptedException { workerGroup = new NioEventLoopGroup(4); Bootstrap bootstrap = new Bootstrap(); bootstrap.group(workerGroup) .channel(NioDatagramChannel.class) .option(ChannelOption.SO_RCVBUF, 1048576) .option(ChannelOption.SO_SNDBUF, 1048576) .option(ChannelOption.RCVBUF_ALLOCATOR, new FixedRecvByteBufAllocator(65535)) .handler(new ChannelInitializer<NioDatagramChannel>() { @Override protected void initChannel(NioDatagramChannel ch) { ch.pipeline().addLast(new UdpServerHandler()); } }); Channel channel = bootstrap.bind(port).sync().channel(); System.out.println("UDP media server started at port " + port); } }这里最容易被忽略的是RCVBUF_ALLOCATOR。GB28181的RTP包承载PS流时,单个包大小上限通常是MTU 1500,但PS流封包某些厂商会超过这个值,而且UDP理论最大载荷是65507字节。如果你用默认的AdaptiveRecvByteBufAllocator,Netty会根据历史包大小动态调整缓冲区,遇到突发大包时可能触发TooLongFrameException或者丢包。
我这边直接用FixedRecvByteBufAllocator(65535),一次性分配够大,虽然每个连接会多占一些内存,但换来了接收的稳定性。实测下来,处理100路UDP流完全没压力。
还有一个点是线程数。不要盲目地给workerGroup配很多线程,UDP收包本身是无连接的,瓶颈通常在网卡中断和内核协议栈,不在Netty的IO线程。4到8个线程足够了,配多了反而增加上下文切换开销。
2.3 UDP Handler的消息入站处理
@Component public class UdpServerHandler extends SimpleChannelInboundHandler<DatagramPacket> { @Autowired private StreamDispatcher streamDispatcher; @Override protected void channelRead0(ChannelHandlerContext ctx, DatagramPacket packet) { ByteBuf content = packet.content(); if (content.readableBytes() < 12) { return; } // 这里只做初步解析,实际分发逻辑在StreamDispatcher里 int ssrc = content.getInt(8); streamDispatcher.dispatch(ssrc, content.retain(), packet.sender()); } }注意这里的坑:ByteBuf的引用计数。UDP的DatagramPacket默认是自动释放的,但如果你要把数据异步交给其他线程处理(比如丢到业务线程池解析PS流),必须retain(),否则消费完就释放了,后面线程拿到空包直接报错。
另外一个常见问题是半包。UDP虽然不会像TCP那样粘包,但RTP包在GB28181里有时会被IP分片。Netty的DatagramPacket封装已经帮我们处理了IP层重组,但如果上层没有正确解析RTP扩展头或者Padding位,有可能把两个RTP包误判成一个。这个在解析器里要注意。
2.4 TCP接收服务器与UDP的本质区别
TCP模式下,设备会主动连上我们监听的端口,然后在这个TCP连接上源源不断地发送RTP数据。这种情况下,不需要每个会话分配独立端口,固定一个端口即可,靠TCP连接本身来区分会话。
但TCP有一个UDP没有的大麻烦:粘包半包。RTP over TCP在GB28181里,数据包结构通常是4字节头(1字节$符号 + 1字节标识 + 2字节长度)加上RTP负载。这个4字节头就是用来解决粘包问题的关键。
public class RtpOverTcpDecoder extends ByteToMessageDecoder { private static final int HEADER_LENGTH = 4; @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) { if (in.readableBytes() < HEADER_LENGTH) { return; } in.markReaderIndex(); byte magic = in.readByte(); if (magic != 0x24) { // '$' // 数据异常,可能是错位了,只能重置连接 ctx.close(); return; } in.skipBytes(1); // 通道标识,通常为0x00 int length = in.readUnsignedShort(); if (in.readableBytes() < length) { // 半包,等待更多数据 in.resetReaderIndex(); return; } ByteBuf rtpPacket = in.readRetainedSlice(length); out.add(rtpPacket); } }这个解码器我建议直接放进TCP pipeline里,作为第一个handler。注意两个地方:
- magic字节校验。如果第一个字节不是0x24,说明TCP流错位了,有可能是设备端异常发送了非标准数据。这种情况下不要试图自行恢复同步,直接关闭连接让设备重连,否则解析逻辑会越走越偏。
- readRetainedSlice。切出来的ByteBuf要retain,因为后续处理器可能是异步的。
TCP模式下Netty启动的Bootstrap配置也不一样,需要使用ServerBootstrap而不是Bootstrap,因为TCP是有连接的:
ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 1024) .childOption(ChannelOption.TCP_NODELAY, true) .childOption(ChannelOption.SO_KEEPALIVE, true) .childHandler(new TcpServerInitializer());2.5 TCP连接的会话绑定与生命周期
TCP模式下,设备连上来之后,我们需要判断这个连接对应哪路SIP会话。常见的判断依据是RTP包的SSRC,或者连接建立时的源IP和端口。这里建议用SSRC为准,IP端口为辅助参考。因为有些设备经过NAT后,源端口会变化,但SSRC在推流过程中一般是稳定的。
连接绑定后,还要处理心跳和超时。很多设备推流时TCP连接一旦建立,如果没有数据发送或接收,中间网络设备可能会把空闲连接回收。所以我通常在pipeline里加上IdleStateHandler:
ch.pipeline().addLast(new IdleStateHandler(60, 0, 0)); // 读空闲60秒超过60秒没有收到任何数据,就判定这条流已经挂了,主动关闭连接,并在SessionRegistry里清理对应SIP会话。如果不做这个操作,时间久了会有大量半开连接堆积,文件描述符耗尽,服务器直接不可用。
3. 会话管理与数据分发链路
收流服务器的核心不只是把UDP/TCP的数据接进来,还需要把数据和SIP会话关联,再转发给后端的解析模块。这一层出了问题,前面收得再好也白搭。
3.1 会话注册表的设计
我定义了一个SessionRegistry,它是一个ConcurrentHashMap,key是SSRC,value是SipSession对象。SipSession里保存了该路流的相关信息:SIP Call-ID、设备编号、通道编号、收流时间、最近活跃时间、传输模式(UDP/TCP)等。
public class SipSession { private String callId; private String deviceId; private String channelId; private TransportMode mode; private String ssrc; private volatile long lastActiveTime; }这里有一个容易被忽视的细节:SSRC的生成规则。GB28181规范里,媒体流的SSRC是由SIP消息中的Call-ID经过特定算法生成的,通常取Call-ID中间几位再拼上序号。但实际对接中,有些设备不按规范生成,所以你不能在SIP层就推算好SSRC然后死等对应包。
我的做法是:在收到RTP包时动态更新SSRC到SipSession的映射。第一次收到某路包的SSRC时,就用它注册,后续都靠这个SSRC查找。即使设备端临时变了SSRC,也只是重新注册一次,不影响主流程。
3.2 数据从网络层到业务层的流转
一个比较常见的设计误区是:直接在Netty的handler里做RTMP封装、PS解复用这种重活。我强烈不建议这样做。Netty的IO线程应该保持轻量,只做网络数据的收发和初步解析,复杂的业务逻辑丢给独立的业务线程池处理。
我的流转链路可以简化为:
UDP/TCP接收 -> 解析RTP头 -> 按SSRC查会话 -> 提交业务线程池 -> 解PS流 -> 封装RTMP在Spring Boot环境下,最简单的方式是使用@Async注解配合线程池,或者直接注入一个ExecutorService。这里请务必设置合理的队列大小和拒绝策略——当业务处理速度跟不上网络接收速度时,宁可丢弃最老的帧,也不能让内存无限增长。我在实际项目中用的是ThreadPoolExecutor.CallerRunsPolicy,这个策略在极端情况下能反压到Netty的线程,避免OOM。
3.3 使用Netty的ChannelGroup管理TCP客户端连接
TCP模式下的连接管理,我建议使用Netty自带的ChannelGroup,它内部实现了线程安全的连接注册和批量关闭。
public class TcpConnectionManager { private static final ChannelGroup channels = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); public static void add(Channel channel) { channels.add(channel); } public static void closeAll() { channels.close().awaitUninterruptibly(); } }这个类的价值在服务关闭时特别明显——Spring容器销毁时,如果不统一关闭所有TCP连接,client端的设备会一直处于半连接状态,直到超时。而且ChannelGroup配合GlobalEventExecutor使用,很多定时任务可以直接基于它来批量操作,省去遍历循环的麻烦。
4. RTP报文的解析要点
收到字节流只是第一步,能不能从RTP包里正确解析出负载,决定了下游PS解复用是否顺畅。这里把RTP包头拆开揉碎讲一遍。
4.1 RTP固定头的12字节结构
RTP固定头一共12字节,对GB28181场景来说,关注这几个关键位:
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |V=2|P|X| CC |M| PT | sequence number | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | timestamp | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | synchronization source (SSRC) identifier | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+- PT(Payload Type):GB28181中PS封装的RTP包,PT值通常为96或98,但这不是硬性规定。如果设备配置成其他值(比如33),你的解析器必须能兼容,不能写死。
- sequence number:16位序号,用来检测丢包和排序。UDP模式下这个字段尤其重要。有的设备序号不是从0开始,有的中间会跳变,处理时要以相对序号为准。
- timestamp:时间戳,单位是90kHz时钟,也就是每秒钟增加90000。PS流里每个帧的时间戳间隔大概是3600(对应40ms)。如果设备端时间戳抖动得厉害,后续在音视频同步上会很痛苦。
- SSRC:同步源标识,这是我们在会话注册表里用到的核心字段。
4.2 解析代码示例
public class RtpPacketParser { public RtpPacket parse(ByteBuf byteBuf) { if (byteBuf.readableBytes() < 12) { return null; } byte firstByte = byteBuf.getByte(0); int cc = firstByte & 0x0F; boolean hasPadding = (firstByte & 0x20) != 0; boolean hasExtension = (firstByte & 0x10) != 0; int version = (firstByte >> 6) & 0x03; if (version != 2) { return null; } int payloadType = byteBuf.getByte(1) & 0x7F; int sequence = byteBuf.getUnsignedShort(2); long timestamp = byteBuf.getUnsignedInt(4); long ssrc = byteBuf.getUnsignedInt(8); int headerLength = 12 + cc * 4; if (hasExtension) { short extLength = byteBuf.getShort(headerLength + 2); headerLength += 4 + extLength * 4; } if (hasPadding) { byte paddingSize = byteBuf.getByte(byteBuf.writerIndex() - 1); byteBuf.writerIndex(byteBuf.writerIndex() - paddingSize); } ByteBuf payload = byteBuf.slice(headerLength, byteBuf.readableBytes() - headerLength); return new RtpPacket(payloadType, sequence, timestamp, ssrc, payload); } }这里有几个细节必须提醒:
- **CSRC计数(cc字段)**不能忽略。正常情况下GB28181的RTP包cc=0,但某些视频会议类设备会带多个CSRC,导致头部长度变化,不去处理解析出来的负载就是乱的。
- **扩展头(X位)**同样要处理。一些设备会携带扩展头用于传输私有信息,不跳过的话payload的起始位置算错了,后面PS流解析会彻底乱掉。
- Padding位要注意。UDP模式下网络传输不会主动加Padding,但有些设备实现RTP封装时可能遵循CRTP规范加了Padding字节,需要按最后一个字节的值把尾部裁掉。
4.3 UDP收流时排序和去重策略
UDP的不可靠传输意味着你收包可能乱序、可能重复、也可能丢失。乱序会对PS流解析产生比较大的影响,因为PS流本身是按顺序解析的,前一个包丢了,后一个包可能就解析出错误的信息。
我的做法是:每个SSRC维护一个轻量的接收窗口,窗口大小取64个包。收到新包时,先判断序号是否落在窗口内,如果落在窗口前,直接丢弃;如果落在窗口内,缓存并尝试按序交给解码器;如果落在窗口后,说明发生了跳变或丢包,继续接收但标记该路流有丢包。
public class RtpSequenceWindow { private static final int WINDOW_SIZE = 64; private final ArrayDeque<Integer> sequenceQueue = new ArrayDeque<>(); private final Set<Integer> sequenceSet = new HashSet<>(); public boolean accept(int sequence) { if (sequenceSet.contains(sequence)) { return false; // 重复包,丢弃 } if (sequenceQueue.size() == WINDOW_SIZE) { Integer oldest = sequenceQueue.pollFirst(); sequenceSet.remove(oldest); } sequenceQueue.offerLast(sequence); sequenceSet.add(sequence); return true; } }这个窗口本身不排序,只是丢弃重复包,真正的排序交给PS解析模块的缓冲队列。PS解析手里拿到的数据,理论上已经是按序的,但保险起见还是做一次乱序检测比较好。
5. 常见问题与排查实录
收流这块的坑,我踩过的数量两只手数不过来,这里挑几个最有代表性的,作为速查表分享出来。
5.1 UDP端口收不到数据,但抓包能看到包
这个现象非常经典。用Wireshark在服务器上抓包,明明能看到UDP包已经到了网卡,但应用层就是收不到。排查方向从下往上:
| 检查项 | 操作 | 原因 |
|---|---|---|
| 防火墙规则 | iptables -L或ufw status | UDP端口未放行 |
| 网卡多队列 | ethtool -l eth0看队列数 | 多队列导致CPU核心间分散,部分队列没绑定 |
| 服务监听地址 | 确认bind的是0.0.0.0 | 有些代码绑定了127.0.0.1 |
| 内核丢包统计 | netstat -su看RcvbufErrors | 缓冲区太小导致内核直接丢包 |
我遇到最多的情况是云服务器的安全组规则。很多人本地测没问题,一放到云上就收不到包,查了好久发现是安全组没加UDP端口规则。TCP有连接状态,建立失败还能看到SYN重传的日志;UDP是直接丢弃,没任何痕迹,排查难度大得多。
这里有个小技巧:在服务器上用nc -u -l port先手动起一个UDP监听,然后让设备推流试试,如果nc能收到数据,说明网络层面没问题,问题肯定在你的应用代码里。
5.2 TCP模式下频繁断连重连
TCP模式下设备连上来发几包数据就断开,然后又重连,反复循环。先排除网络原因,重点看这几个方面:
第一,IdleStateHandler的超时设置太激进。有些设备推流不是均匀的,视频帧可能间隔一两秒,如果你把读超时设成10秒,碰到网络波动大一点的场景就会误判。
第二,业务线程池处理不过来导致TCP背压。当Netty的watermark(高水位标记)触发时,写操作会被阻塞,如果handler里还有同步阻塞操作,TCP栈缓冲区很快被打满,设备端发送超时,自然断开。
第三,解复用模块抛异常导致pipeline异常关闭。每次解析到异常数据时,如果不捕获异常直接抛出,Netty会认为这个连接出了严重错误,主动关闭。建议在pipeline末尾加一个ExceptionCaught兜底:
@Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { log.warn("TCP connection exception: ", cause); ctx.close(); }5.3 RTP包序号跳变或者SSRC变化
设备端实现不规范,可能导致推流过程中SSRC变了,或者序号从某个非零值开始。这种问题在对接测试中很常见。
我的应对策略是:不在SIP层硬编码期望的SSRC,而是在收到第一个RTP包时动态学习。如果同一路TCP连接上收到的SSRC和之前不一样,就更新映射关系,同时把之前的缓存清掉。虽然这可能在播放器端看到短暂卡顿,但总比完全不处理导致花屏强。
对于序号跳变的情况,跳过序号检测逻辑,只把序号记录到日志里。如果持续跳变超过一定阈值,则丢弃当前帧并强制请求关键帧,避免花屏持续下去。
5.4 PS流解析出来的时间戳乱跳
这个问题的根源往往不在RTP层,而在设备端的时间基准不稳定。有的设备用的是系统时间,有的用的是音视频时钟,两种混用会导致时间戳突然倒退或者跳变几千。
一个实用的方案是在RTP层做时间戳平滑:记录上一个RTP包的时间戳,如果当前时间戳和上一个的差值超过一个阈值(比如超过1秒或倒退超过1秒),就忽略这个异常值,用上一个值加一帧的间隔代替。这种做法牺牲了精确性,但换来了音视频播放的稳定,在这个场景下是值得的。
### 5.5 服务重启后设备不推流了 这个问题也经常被问到。原因是设备端SIP会话状态没有超时,服务重启后之前的会话在这台设备上还存活着,但服务器的会话状态已经丢失,导致设备认为还在推流,实际我们的新进程在等新的INVITE请求。 处理办法:服务启动时主动向所有在线设备发送一条MESSAGE或者BYE,让它们的旧会话复位。具体可以在启动监听器里遍历设备列表,发送一条SIP BYE通知。这些设备的域名或编号信息,可以从自己数据库或者Redis里获取。 ## 6. 收流服务与信令服务的联动机制 信令和媒体在GB28181里虽然是两条独立的链路,但在业务逻辑上必须紧密配合。信令层负责会话建立和拆除,媒体层负责实际数据收发,两者之间需要共享会话信息。这里我说说我的联动设计。 ### 6.1 会话建立时的资源预留 在SIP层收到INVITE请求时,不要等到设备真的推流了才分配端口和资源,而是在**响应200 OK之前就完成媒体端口初始化**。这意味着SDP里的端口一定是真实可用的,避免设备连不上我们告诉它的端口导致推流失败。 这里有一个容易踩的坑:UDP模式下,你告诉设备的端口,在进程重启前应该一直被占用。如果进程每次启动都随机分配端口,而设备端的SIP会话没有超时,它还会继续往旧端口推流。所以建议**UDP端口按会话复用**——同一个SIP会话的媒体端口,在会话结束前保持一致。 我的实现方式是在应用启动时预分配一个端口段(比如从20000到30000),用一个`BitSet`标记哪些端口已经被用。INVITE到来时取一个空闲端口,会话销毁时归还。 ### 6.2 BYE和超时清理 当信令层收到BYE或者设备离线时,媒体层也要做对应的清理动作:关闭TCP连接,释放端口,清空序列号窗口,重置SSRC映射。 超时清理同样重要。设备推流过程中如果突然断网,TCP连接可能不会立刻断(因为系统TCP超时很慢),这时候如果你不做应用层超时清理,会一直占用端口和线程资源。我设置了两层超时:一是前面提到的`IdleStateHandler`的空闲检测,二是SessionRegistry里的定期扫描,用ScheduledExecutorService每隔30秒扫描一次,超过120秒未活跃的会话强制清理。 ### 6.3 与RTMP推流模块的衔接 收流和推流是天然解耦的两块,中间用队列接驳最合适。收流完成后,我这边直接把RTP负载(也就是PS流)封装成一个`MediaFrame`对象,塞进一个LinkedBlockingQueue。推流模块从队列另一端取帧,做PS解封装、转H264/AAC,再封装成FLV标签推给RTMP服务。 队列的容量建议不要设置太大,我一般设1000帧左右。超过这个值就丢最老的帧,保证实时性优先。监控场景下,**延迟比画质重要得多**,用户宁愿看到画面卡一下,也不愿意看到延迟半分钟的“准直播”。 ## 7. 一些压测数据与性能调优记录 讲一点压测时得到的实际数据,给大家一个参考系。我的测试环境:8核16G虚拟机,CentOS系统,100路设备同时推1080P流,每路码率约2Mbps。 ### 7.1 线程与内存配置参考 | 配置项 | 参数 | 说明 | |-------|------|------| | Netty Boss线程数 | 1 | TCP accept线程,1个足够 | | Netty Worker线程数 | 8 | 与CPU核心数相当即可 | | UDP接收线程数 | 4 | 分离于Worker | | 业务线程池核心数 | 16 | 处理PS解析、格式转换等 | | 业务线程池队列 | 1024 | 拒绝策略CallerRunsPolicy | | JVM堆内存 | 4G | 稳定运行所需最小值 | 实测中,UDP模式比TCP模式更省CPU,因为少了TCP协议栈的处理开销。但TCP模式的丢包率明显更低,在网络抖动时表现更稳定。 ### 7.2 一个容易忽视的调优点:SO_RCVBUF Linux系统默认的UDP接收缓冲区大约是208KB,这对高码率视频流来说完全不够。如果接收缓冲区满了,内核会直接丢弃UDP包,造成应用层看到的是随机丢包,很难排查。 建议把`SO_RCVBUF`调大,并且在系统层面同步修改:sysctl -w net.core.rmem_max=8388608 sysctl -w net.core.rmem_default=8388608
注意,Netty里的`SO_RCVBUF`设置的值如果超过内核上限,会被系统强制截断,所以系统参数也要一起调。我这边调到8MB,实测100路流并发时基本不再出现`RcvbufErrors`。 ### 7.3 连接数限制 TCP模式下,单机可承载的连接数还受`ulimit`限制。默认1024,100路设备可能还够用,但如果设备端每路流维护多个TCP连接(有些设备会重新连接),就必须调大:ulimit -n 65535
或者在系统层面修改`/etc/security/limits.conf`,这个在部署文档里一定要写清楚,否则现场运维踩到坑大概率会先怀疑你的代码有问题。 ## 8. 对接实测中的兼容性细节 最后聊几个实测中遇到的设备兼容性细节,算是给准备做对接的同学提前打个预防针。 不同厂商对GB28181的实现差异是真的存在,而且远比文档描述的要复杂。有的设备UDP推流时,每帧数据会被拆成多个RTP包发送,但RTP序号不是线性递增的,而是按分片递增,同一帧的不同分片之间序号可能跳变。所以解析时不能只靠序号来还原帧,还需要结合RTP时间戳和PS头信息综合判断。 还有的设备在TCP推流时不是每包一个RTP头,而是在TCP流里连续放多个RTP包,中间没有任何分隔符。这就涉及到我前面说的4字节`$`头协议,但它并不总是遵守。**我建议TCP decoder做成双模式:优先尝试解析`$`头格式,如果失败则回退到纯RTP流模式。** 这个“自适应”能力在对接现场几乎是必备的。 还有一个细节是关于RTP时间戳的单位。虽然规范里明确是90kHz,但我在实际对接中遇到过按45kHz、甚至是按帧率递增的设备。如果后面做音视频同步时发现AV不同步,先检查一下时间戳的增量是否符合预期,不要先怀疑播放器。 ## 9. 后续的演进方向 收流服务器这一层稳定了之后,后面的业务扩展空间其实是很大的。 最直接的演进是把收流和推流拆成两个微服务独立部署,中间走Kafka或者Redis Stream。这样做的收益很明显:收流服务专注于海量设备接入,推流服务专注于CDN分发,中间可以完全解耦,互不拖累。如果后续要在收流环节加AI分析(比如人脸识别、车牌识别),从Kafka里取数据也不会影响收流的稳定性。 另一个方向是**协议扩展**:现在RTMP已经逐渐被HTTP-FLV和WebRTC取代,如果业务上需要低延迟播放,收流服务接收GB28181后,完全可以再封装一层WebRTC网关。底层的RTP接收、PS解复用、H264帧还原这些逻辑可以完全复用,只是出口从RTMP变成SRTP。 还有就是在收流层做**智能丢包隐藏**。TCP模式下网络质量好,丢包问题不突出;UDP模式下如果网络稍有波动,PS流的丢弃会导致画面上出现马赛克。可以在RTP接收端做前向纠错(FEC),也就是冗余发送一部分数据,接收端通过异或运算还原丢掉的包。GB28181标准里没有强制FEC,但做一些私有扩展是可以的,只要两端配合好。 --- 从Netty服务器搭建到RTP数据解析,收流这一块的基本盘就这些了。整体做下来最大的感触是:**GB28181协议本身并不复杂,复杂的是设备和网络的多样性**。代码层面能做的就是尽量解耦、做好容错、动态自适应,剩下的就是拿着设备清单一个一个去怼兼容性了。这些实战经验,比任何协议文档都来得实在。