1. 项目背景与整体设计思路
1.1 为什么要在Java里做RTP PS流解包推RTMP
做过安防流媒体对接的朋友应该都有体会,GB28181协议在国内安防行业几乎是绕不开的存在。摄像头、NVR、平台之间信令走SIP,媒体流走RTP承载PS封装,这套组合拳已经跑了很多年。但问题在于,后端业务系统往往更习惯吃RTMP或者HTTP-FLV这类流,尤其是需要对接Web播放器、CDN分发、录播服务的时候,PS流就显得有点"原始"了。
这个项目的核心目标很明确:用Java(SpringBoot框架)接收GB28181设备推上来的RTP包,把里面的PS流解出来,拿到裸的H264/H265视频帧和G711/AAC音频帧,再重新封装成RTMP推给下游服务。整条链路听起来不复杂,但真正动手做的时候,坑是一个接一个。
我选择Java而不是C++或者Go来做这件事,主要基于几个现实考量。第一,大部分企业级后端服务本身就是Java技术栈,用SpringBoot可以很方便地和现有业务系统集成,比如鉴权、设备管理、流媒体调度这些模块可以直接复用。第二,Java的Netty在处理UDP大量小包方面性能完全够用,单机扛几百路RTP流没什么压力。第三,团队维护成本低,招人也好招。当然,如果你追求极致的单机并发,C++确实有优势,但对于大多数中小规模场景,Java方案完全能打。
整个方案的设计思路是这样的:Netty负责UDP端口监听和RTP包接收,收到包之后先做RTP头部解析,去掉12字节的RTP头,拿到payload。然后判断是否是PS流的分片包,因为一个PS包可能被拆到多个RTP包里传输。接着做PS流的解复用,从PS包里提取出PES包,再从PES包里拿到ES流,也就是原始的H264/H265 NALU和音频帧。最后把这些帧按照RTMP的FLV Tag格式重新封装,通过RTMP协议推给下游。
这个链路里最核心的技术点有三个:RTP包的乱序和丢包处理、PS流的解复用逻辑、以及H264/H265的帧边界识别。后面我会逐一展开讲。
1.2 整体架构与模块划分
从工程结构上看,我把它拆成了几个独立的模块,每个模块职责单一,方便调试和替换。
接收层:基于Netty的UDP Server,监听GB28181设备推流的端口。每个设备对应一个独立的Session,Session里维护RTP包的序列号、时间戳、SSRC等信息。这一层的关键是要处理好NAT穿透和端口复用的问题,因为很多设备推流的目标端口是固定的,但源端口可能是随机的。
RTP解析层:负责解析RTP头部,提取序列号、时间戳、payload type、marker位等信息。这里要注意的是,GB28181的RTP包通常是PS流分片,一个PS包可能跨多个RTP包,所以需要根据时间戳和marker位来判断一个完整的PS包是否接收完毕。
PS解复用层:这是整个项目最复杂的部分。PS流有自己的封装格式,包含Pack Header、System Header、PES Packet等结构。需要按照PS协议逐层剥离,最终拿到PES包里的ES流数据。PS流的解析涉及到大量的位操作和边界判断,稍有不慎就会解析出错。
帧处理层:拿到ES流之后,需要识别H264/H265的NALU边界。H264的NALU以0x00000001或0x000001开头,H265稍微复杂一点,但原理类似。识别出完整的帧之后,还要判断是I帧还是P帧,因为RTMP推流的时候需要正确设置帧类型。
RTMP推流层:把处理好的视频帧和音频帧按照FLV Tag格式封装,通过RTMP协议推给下游。这里可以用现成的开源库,比如JavaCV或者自己实现RTMP协议。我选择自己实现核心的RTMP握手和消息封装,因为这样可控性更强,出问题的时候好排查。
会话管理层:管理每个设备的推流会话,包括会话创建、心跳保活、异常断开重连等。这一层需要和SIP信令层配合,但本文主要聚焦在媒体流的处理上,信令部分不展开。
整个数据流向是:UDP包 -> RTP包 -> PS流 -> PES包 -> ES流 -> H264/H265帧 -> FLV Tag -> RTMP流。每一层都有对应的数据结构和解析逻辑,层层递进。
2. 核心细节解析与实操要点
2.1 RTP包接收与解析的关键细节
RTP包的接收看起来简单,但实际上有很多细节需要注意。GB28181设备推上来的RTP包通常是UDP传输,UDP本身是不可靠的,会有丢包、乱序、重复的情况。虽然GB28181标准里建议用TCP传输,但实际项目中UDP还是主流,因为延迟更低。
先看RTP头部的结构。标准的RTP头是12字节,格式如下:
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 | +=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+ | contributing source (CSRC) identifiers | | .... | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+V是版本号,通常是2。P是填充位,X是扩展位,CC是CSRC计数。M是marker位,在视频流里通常表示一帧的结束。PT是payload type,GB28181里通常是96(PS流)或者98(H264)。sequence number是序列号,每发一个RTP包加1。timestamp是时间戳,同一个帧的多个RTP包时间戳相同。SSRC是同步源标识,用来区分不同的流。
解析RTP头的时候,我一般用Netty的ByteBuf来操作,因为它的读写索引管理很方便。核心代码大概长这样:
public RtpPacket parseRtpPacket(ByteBuf buffer) { RtpPacket packet = new RtpPacket(); byte firstByte = buffer.readByte(); packet.setVersion((firstByte >> 6) & 0x03); packet.setPadding((firstByte >> 5) & 0x01); packet.setExtension((firstByte >> 4) & 0x01); packet.setCsrcCount(firstByte & 0x0F); byte secondByte = buffer.readByte(); packet.setMarker((secondByte >> 7) & 0x01); packet.setPayloadType(secondByte & 0x7F); packet.setSequenceNumber(buffer.readUnsignedShort()); packet.setTimestamp(buffer.readUnsignedInt()); packet.setSsrc(buffer.readUnsignedInt()); // 跳过CSRC for (int i = 0; i < packet.getCsrcCount(); i++) { buffer.readUnsignedInt(); } // 处理扩展头 if (packet.isExtension()) { int extensionLength = buffer.readUnsignedShort(); buffer.skipBytes(extensionLength * 4); } // 剩余的就是payload byte[] payload = new byte[buffer.readableBytes()]; buffer.readBytes(payload); packet.setPayload(payload); return packet; }这里有几个实操要点。第一,CSRC和扩展头的处理不能漏,虽然GB28181里通常没有这些,但万一设备发了,不处理就会导致payload解析错位。第二,payload的读取要注意ByteBuf的引用计数,如果用完不释放,会导致内存泄漏。第三,序列号是16位的,会回绕,从65535变成0,做丢包判断的时候要处理这个回绕问题。
关于丢包处理,我的策略是这样的:每个Session维护一个期望序列号,收到包之后和期望值比较。如果相等,正常处理,期望值加1。如果大于期望值,说明中间有丢包,记录丢包数量,然后更新期望值。如果小于期望值,说明是重复包或者乱序包,直接丢弃。这个策略简单有效,能覆盖大部分场景。
注意:GB28181设备推流的时候,有时候会因为网络抖动导致大量丢包,这时候不要慌,只要关键帧没丢,后续的P帧丢了影响不大,播放器会自己处理。但如果I帧丢了,画面就会花屏,这时候可以考虑主动请求关键帧,不过这是信令层的事情,本文不展开。
2.2 PS流解复用的核心逻辑
PS流(Program Stream)是MPEG-2标准里定义的一种封装格式,GB28181用它来承载H264/H265视频和音频。PS流的结构是分层的,最外层是Pack,里面包含System Header和多个PES Packet。每个PES Packet里承载一个ES流(Elementary Stream),可能是视频,也可能是音频。
PS流的解析需要按照严格的字节顺序来。先看Pack Header的格式:
pack_start_code 32 bits (0x000001BA) '01' 2 bits system_clock_reference_base 33 bits marker_bit 1 bit system_clock_reference_extension 9 bits marker_bit 1 bit program_mux_rate 22 bits marker_bit 1 bit marker_bit 1 bit reserved 5 bits pack_stuffing_length 3 bitsPack Header的长度是14字节(不含stuffing)。解析的时候先找0x000001BA这个起始码,然后按照位域逐个读取。这里最容易出错的地方是system_clock_reference_base的读取,它是33位的,跨了多个字节,需要仔细处理位偏移。
System Header是可选的,起始码是0x000001BB,里面包含流的基本信息,比如视频流和音频流的PID、缓冲区大小等。在实际项目中,我一般会解析System Header,因为里面有些信息对后续处理有帮助,比如可以知道有几路流。
PES Packet是PS流的核心,起始码是0x000001加上stream_id。stream_id的范围是0xBD到0xEF,其中0xE0到0xEF是视频流,0xC0到0xDF是音频流,0xBD是私有流。PES Packet的头部格式如下:
packet_start_code_prefix 24 bits (0x000001) stream_id 8 bits PES_packet_length 16 bits '10' 2 bits PES_scrambling_control 2 bits PES_priority 1 bit data_alignment_indicator 1 bit copyright 1 bit original_or_copy 1 bit PTS_DTS_flags 2 bits ESCR_flag 1 bit ES_rate_flag 1 bit DSM_trick_mode_flag 1 bit additional_copy_info_flag 1 bit PES_CRC_flag 1 bit PES_extension_flag 1 bit PES_header_data_length 8 bitsPES头部的解析相对复杂,因为有很多可选的字段。PTS_DTS_flags决定了是否有PTS和DTS,这两个时间戳对音视频同步很重要。PES_header_data_length告诉你有多少字节的头部数据,解析完头部之后,剩下的就是ES流数据。
在实际代码里,我一般会写一个PS流的解析器,逐字节扫描,遇到起始码就进入对应的解析分支。这里有个技巧:不要试图一次性解析完整个PS包,而是维护一个状态机,逐字节喂数据,这样处理分片包的时候会很方便。
public class PsParser { private int state = 0; private ByteArrayOutputStream esBuffer = new ByteArrayOutputStream(); public void parse(byte[] data, int offset, int length) { for (int i = offset; i < offset + length; i++) { byte b = data[i]; switch (state) { case 0: // 寻找起始码 if (b == 0x00) { state = 1; } break; case 1: if (b == 0x00) { state = 2; } else { state = 0; } break; case 2: if (b == 0x01) { state = 3; } else { state = 0; } break; case 3: // 根据b的值判断是Pack Header还是PES Packet if (b == 0xBA) { // Pack Header state = 4; } else if (b >= 0xBD && b <= 0xEF) { // PES Packet state = 5; } else { state = 0; } break; // ... 后续状态处理 } } } }这个状态机的写法看起来有点笨,但实际用起来很稳,尤其是处理跨RTP包的PS流时,不需要担心数据不完整的问题。
实操心得:PS流的解析一定要做边界检查,因为网络传输的数据可能不完整,如果直接按固定长度读取,很容易数组越界。我一般会在每个读取操作前检查剩余字节数,不够就缓存起来等下一个包。
2.3 H264/H265帧边界识别与处理
从PES包里拿到ES流之后,下一步是识别H264/H265的NALU边界。H264的NALU以0x00000001或0x000001开头,前者是4字节起始码,后者是3字节。H265的NALU起始码也是类似的,但NALU头部的格式不同。
H264的NALU头部是1字节,格式如下:
+---------------+ |0|1|2|3|4|5|6|7| +-+-+-+-+-+-+-+-+ |F|NRI| Type | +---------------+F是禁止位,NRI是重要性指示,Type是NALU类型。常见的类型有:1(非IDR的P帧)、5(IDR的I帧)、7(SPS)、8(PPS)、6(SEI)。
H265的NALU头部是2字节,格式更复杂一些,但核心信息类似。Type的范围是0到63,其中16到21是B帧、P帧相关的,19和20是IDR帧,32是VPS,33是SPS,34是PPS。
识别帧边界的时候,我一般会扫描ES流,找到起始码的位置,然后根据NALU类型判断帧的边界。一个完整的视频帧可能包含多个NALU,比如一个I帧通常包含SPS、PPS、SEI和IDR NALU。这些NALU需要一起打包成一个FLV Tag推给RTMP。
public List<Nalu> splitNalu(byte[] esData) { List<Nalu> nalus = new ArrayList<>(); int start = -1; for (int i = 0; i < esData.length - 3; i++) { if (esData[i] == 0x00 && esData[i+1] == 0x00 && esData[i+2] == 0x00 && esData[i+3] == 0x01) { if (start != -1) { nalus.add(new Nalu(esData, start, i - start)); } start = i + 4; i += 3; } else if (esData[i] == 0x00 && esData[i+1] == 0x00 && esData[i+2] == 0x01) { if (start != -1) { nalus.add(new Nalu(esData, start, i - start)); } start = i + 3; i += 2; } } if (start != -1) { nalus.add(new Nalu(esData, start, esData.length - start)); } return nalus; }这里有个细节要注意:起始码的扫描要同时处理3字节和4字节的情况,而且要注意避免把0x000001误判为起始码的一部分。我一般会用KMP或者Boyer-Moore算法来加速扫描,但在数据量不大的情况下,简单的逐字节扫描也够用。
判断帧类型的时候,我一般看NALU的类型。如果是5(H264 IDR)或者19/20(H265 IDR),就是I帧。如果是1(H264非IDR)或者1(H265非IDR),就是P帧。SPS和PPS这些参数集NALU,我一般会缓存起来,在推流开始的时候先发一次,后续如果检测到参数集变化,再重新发送。
注意:H264的SPS和PPS必须在I帧之前发送,否则播放器无法解码。有些设备会把SPS和PPS放在I帧的ES流里一起发,有些设备会单独发,处理的时候要兼容这两种情况。
3. 实操过程与核心环节实现
3.1 Netty UDP服务搭建与RTP包接收
先搭Netty的UDP服务。SpringBoot项目里集成Netty很简单,加个依赖就行:
<dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.90.Final</version> </dependency>然后写一个UDP Server的启动类:
@Component public class UdpServer { private EventLoopGroup bossGroup = new NioEventLoopGroup(1); private EventLoopGroup workerGroup = new NioEventLoopGroup(); @PostConstruct public void start() throws InterruptedException { Bootstrap bootstrap = new Bootstrap(); bootstrap.group(workerGroup) .channel(NioDatagramChannel.class) .option(ChannelOption.SO_BROADCAST, true) .option(ChannelOption.SO_RCVBUF, 1024 * 1024) .handler(new ChannelInitializer<NioDatagramChannel>() { @Override protected void initChannel(NioDatagramChannel ch) { ch.pipeline().addLast(new RtpHandler()); } }); bootstrap.bind(50000).sync(); } }这里有几个参数需要调优。SO_RCVBUF是接收缓冲区大小,默认可能只有64KB,对于高码率的视频流来说不够用,我一般设成1MB或者更大。SO_BROADCAST如果不需要广播可以关掉。workerGroup的线程数默认是CPU核数乘以2,对于UDP接收来说,一般4到8个线程就够了。
RtpHandler是核心的处理器,继承SimpleChannelInboundHandler:
public class RtpHandler extends SimpleChannelInboundHandler<DatagramPacket> { @Override protected void channelRead0(ChannelHandlerContext ctx, DatagramPacket packet) { ByteBuf buffer = packet.content(); RtpPacket rtpPacket = parseRtpPacket(buffer); // 根据SSRC找到对应的Session StreamSession session = SessionManager.getSession(rtpPacket.getSsrc()); if (session == null) { session = SessionManager.createSession(rtpPacket.getSsrc()); } // 处理RTP包 session.handleRtpPacket(rtpPacket); } }SessionManager是一个全局的会话管理器,用ConcurrentHashMap存储SSRC到Session的映射。每个Session里维护一个PS解析器和一个RTMP推流器。
这里有个坑要注意:GB28181设备推流的时候,SSRC是设备生成的,可能会重复,也可能会变化。我一般会结合设备的IP和端口来做会话标识,SSRC只作为辅助。另外,会话要有超时机制,如果一段时间没收到包,就认为流断开了,释放资源。
3.2 PS流解复用与PES包提取
PS流的解复用我写了一个专门的类,核心逻辑是状态机加缓冲区。先定义一个PsStreamParser:
public class PsStreamParser { private static final int STATE_START_CODE = 0; private static final int STATE_PACK_HEADER = 1; private static final int STATE_SYSTEM_HEADER = 2; private static final int STATE_PES_HEADER = 3; private static final int STATE_PES_PAYLOAD = 4; private int state = STATE_START_CODE; private ByteArrayOutputStream buffer = new ByteArrayOutputStream(); private PesPacket currentPes; public void parse(byte[] data) { for (byte b : data) { processByte(b); } } private void processByte(byte b) { buffer.write(b); switch (state) { case STATE_START_CODE: if (isStartCode(buffer.toByteArray())) { state = STATE_PACK_HEADER; } break; // ... 其他状态处理 } } }这个写法比较直观,但性能一般,因为每个字节都要写缓冲区。优化的话可以用ByteBuf直接操作,减少内存拷贝。不过在大多数场景下,这个性能已经够用了。
解析Pack Header的时候,要注意system_clock_reference_base的读取。它是33位的,跨了5个字节,需要仔细处理位偏移:
private long readScrBase(byte[] data, int offset) { long scrBase = 0; scrBase |= ((long)(data[offset] & 0x38)) << 27; scrBase |= ((long)(data[offset] & 0x03)) << 28; scrBase |= ((long)(data[offset+1] & 0xFF)) << 20; scrBase |= ((long)(data[offset+2] & 0xF8)) << 12; scrBase |= ((long)(data[offset+2] & 0x03)) << 13; scrBase |= ((long)(data[offset+3] & 0xFF)) << 5; scrBase |= ((long)(data[offset+4] & 0xF8)) >> 3; return scrBase; }这段代码看起来有点绕,但逻辑是清晰的:按照PS协议里的位域定义,把各个位拼起来。实际写的时候,我建议对着协议文档一个位一个位地核对,不然很容易出错。
PES包的提取相对简单,找到PES起始码之后,读取PES_packet_length,然后根据PES_header_data_length跳过头部,剩下的就是ES流数据。这里要注意PES_packet_length可能是0,表示长度不固定,需要根据下一个起始码来判断结束位置。
实操心得:PS流解析的时候,建议把中间结果打印出来,比如Pack Header的SCR、PES包的stream_id和长度,方便和抓包工具对比。我一般会用Wireshark抓包,然后和代码里的解析结果对照,这样能快速定位问题。
3.3 RTMP推流封装与发送
RTMP推流这部分,我选择自己实现核心的握手和消息封装,因为用开源库有时候会遇到版本兼容问题,而且自己实现可控性更强。
RTMP的握手分三步:客户端发C0+C1,服务端回S0+S1+S2,客户端再发C2。C0和S0是1字节的版本号,通常是3。C1和S1是1536字节的随机数据加时间戳。C2和S2是对方发过来的数据的回显。
握手完成之后,就是建立连接和创建流。客户端发送connect命令,服务端返回_result,然后客户端发送createStream命令,服务端返回streamId。接着客户端发送publish命令,服务端返回onStatus,推流就正式开始了。
推流的时候,视频帧封装成FLV Tag,格式如下:
+----------------+ | FLV Tag Header | +----------------+ | Data | +----------------+ | Prev Tag Size | +----------------+FLV Tag Header是11字节,包含Tag类型(8视频,9音频,18脚本)、数据大小、时间戳、时间戳扩展和流ID。视频数据的前面要加一个字节的帧类型和编码ID,比如0x17表示关键帧加AVC。音频数据前面要加一个字节的音频格式和采样率信息。
public byte[] buildVideoTag(byte[] naluData, boolean isKeyFrame, int timestamp) { ByteArrayOutputStream out = new ByteArrayOutputStream(); // FLV Tag Header out.write(0x09); // 视频Tag writeInt24(out, naluData.length + 5); // 数据大小 writeInt24(out, timestamp & 0xFFFFFF); // 时间戳低24位 out.write((timestamp >> 24) & 0xFF); // 时间戳扩展 writeInt24(out, 0); // 流ID // 视频数据 out.write(isKeyFrame ? 0x17 : 0x27); // 帧类型+编码ID out.write(0x01); // AVC包类型,1表示NALU writeInt24(out, 0); // 组合时间偏移 // NALU数据,需要转成AVCC格式 out.write(naluData); // Prev Tag Size writeInt32(out, out.size()); return out.toByteArray(); }这里有个关键点:RTMP推流的时候,H264的NALU需要转成AVCC格式,也就是每个NALU前面加4字节的长度前缀,而不是起始码。这个转换在封装FLV Tag的时候要做。
另外,推流开始的时候,要先发送AVC Sequence Header,也就是SPS和PPS的封装。这个Tag的格式和普通视频Tag不同,AVC包类型是0,后面跟着SPS和PPS的数据。播放器收到这个Tag之后才能正确解码后续的视频帧。
public byte[] buildAvcSequenceHeader(byte[] sps, byte[] pps) { ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(0x17); // 关键帧+AVC out.write(0x00); // AVC Sequence Header writeInt24(out, 0); // 组合时间偏移 out.write(0x01); // configurationVersion out.write(sps[1]); // AVCProfileIndication out.write(sps[2]); // profile_compatibility out.write(sps[3]); // AVCLevelIndication out.write(0xFF); // lengthSizeMinusOne out.write(0xE1); // numOfSequenceParameterSets writeInt16(out, sps.length); out.write(sps); out.write(0x01); // numOfPictureParameterSets writeInt16(out, pps.length); out.write(pps); return out.toByteArray(); }音频的话,如果是AAC,也需要先发送Audio Specific Config。G711的话简单一些,直接封装成FLV Audio Tag就行。
注意:RTMP推流的时间戳单位是毫秒,而RTP的时间戳单位是采样率相关的。视频通常是90000Hz,音频是8000Hz或者44100Hz。转换的时候要注意单位换算,不然会导致音视频不同步。
4. 常见问题与排查技巧实录
4.1 花屏、卡顿、音视频不同步的排查思路
花屏是最常见的问题,原因通常有几个:I帧丢失、SPS/PPS丢失、NALU边界识别错误、时间戳不对。排查的时候,我一般先看日志里有没有I帧,如果长时间没有I帧,那肯定是花屏的。这时候可以检查RTP包的丢包率,如果丢包严重,需要优化网络或者调整缓冲区大小。
如果I帧正常但画面还是花,那可能是SPS/PPS的问题。有些设备在推流过程中会更换分辨率,这时候SPS/PPS会变化,如果没及时更新,播放器就会花屏。我的做法是每次收到SPS/PPS都缓存起来,并且检测是否和上一次不同,如果不同就重新发送AVC Sequence Header。
卡顿的原因通常是缓冲区设置不合理或者推流速度跟不上。Netty的SO_RCVBUF如果太小,高码率的时候会丢包。RTMP推流的发送缓冲区如果太小,也会导致数据积压。我一般会把这两个缓冲区都设成1MB以上,并且监控队列长度,如果持续增长就说明有问题。
音视频不同步是最头疼的问题。RTP的时间戳是相对时间,RTMP的时间戳是绝对时间,转换的时候要小心。我的做法是维护一个基准时间戳,第一个视频帧的时间戳作为基准,后续的帧都减去这个基准,转换成毫秒。音频同理,但音频和视频的基准要一致,不然会有偏移。
public class TimestampConverter { private long baseVideoTs = -1; private long baseAudioTs = -1; private long baseTime = System.currentTimeMillis(); public int convertVideoTs(long rtpTs) { if (baseVideoTs == -1) { baseVideoTs = rtpTs; } long diff = rtpTs - baseVideoTs; return (int)(diff / 90); // 90000Hz转毫秒 } public int convertAudioTs(long rtpTs) { if (baseAudioTs == -1) { baseAudioTs = rtpTs; } long diff = rtpTs - baseAudioTs; return (int)(diff / 8); // 8000Hz转毫秒 } }这个转换逻辑看起来简单,但实际用的时候要注意时间戳回绕的问题。RTP的时间戳是32位的,会回绕,处理的时候要用无符号数比较。
4.2 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 画面花屏 | I帧丢失 | 检查日志中I帧间隔 | 请求关键帧或增大缓冲区 |
| 画面花屏 | SPS/PPS丢失 | 检查是否收到SPS/PPS | 缓存并重发参数集 |
| 画面卡顿 | 缓冲区太小 | 监控队列长度 | 增大SO_RCVBUF和发送缓冲区 |
| 音视频不同步 | 时间戳转换错误 | 对比音视频时间戳 | 统一基准时间戳 |
| 推流失败 | RTMP握手失败 | 检查握手日志 | 确认RTMP地址和端口 |
| 推流断开 | 心跳超时 | 检查心跳发送 | 增加心跳频率 |
| 内存泄漏 | ByteBuf未释放 | 监控内存使用 | 确保release ByteBuf |
| CPU过高 | 解析逻辑低效 | 分析火焰图 | 优化状态机或使用ByteBuf |
4.3 性能优化与稳定性保障
性能优化方面,我主要做了几件事。第一,用对象池复用RtpPacket和ByteBuf,减少GC压力。第二,PS解析用ByteBuf直接操作,避免频繁的数组拷贝。第三,RTMP推流用单独的线程池,避免阻塞Netty的IO线程。第四,用Disruptor或者LinkedBlockingQueue做队列缓冲,平滑流量峰值。
稳定性方面,我加了几个机制。第一,会话超时自动清理,避免僵尸会话占用资源。第二,推流失败自动重连,重连的时候重新发送参数集。第三,关键指标监控,比如丢包率、推流延迟、队列长度,超过阈值就告警。第四,日志分级,正常情况只打INFO,出问题的时候开DEBUG,方便排查。
// 会话超时清理 @Scheduled(fixedRate = 10000) public void cleanTimeoutSessions() { long now = System.currentTimeMillis(); sessions.entrySet().removeIf(entry -> { StreamSession session = entry.getValue(); if (now - session.getLastActiveTime() > 30000) { session.close(); return true; } return false; }); }实操心得:调试的时候,我建议先用一个简单的RTMP服务器(比如自己写一个只接收不转发的)来验证推流是否正常,然后再对接真实的流媒体服务器。这样可以排除服务器端的问题,快速定位是推流端还是服务端的问题。
5. 工具选型与依赖说明
5.1 核心依赖与版本选择
这个项目用到的核心依赖不多,但版本选择很重要。Netty我用的是4.1.90.Final,这个版本比较稳定,而且对UDP的支持很好。SpringBoot用的是2.7.x,和Netty的兼容性没问题。日志用SLF4J加Logback,方便调试。
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> <version>2.7.18</version> </dependency> <dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.90.Final</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>1.7.36</version> </dependency> </dependencies>如果你不想自己实现RTMP协议,可以用JavaCV的FFmpegFrameRecorder,它封装了RTMP推流的功能。但JavaCV的依赖比较大,而且版本兼容性有时候会有问题。我个人的建议是,如果只是推流,自己实现核心逻辑更可控。
5.2 调试工具与抓包分析
调试的时候,Wireshark是必备的。用Wireshark抓UDP包,可以看RTP的序列号、时间戳、payload内容。我一般会先抓包,确认设备推上来的数据是正常的,然后再看代码解析的结果是否一致。
另外,FFmpeg也是很好的调试工具。可以用FFmpeg拉RTMP流,看是否能正常播放。如果FFmpeg能播但播放器不能播,那可能是播放器的问题。如果FFmpeg也不能播,那就是推流端的问题。
# 用FFmpeg拉流测试 ffmpeg -i rtmp://localhost:1935/live/stream1 -f null -这个命令会尝试解码流,如果有错误会打印出来。我一般用这个命令来快速验证推流是否正常。
注意:调试的时候,建议把日志级别调到DEBUG,把RTP包的序列号、PS包的解析结果、RTMP的发送情况都打出来。虽然日志会很多,但排查问题的时候非常有用。上线的时候再把日志级别调回INFO。
6. 项目扩展与后续优化方向
这个项目目前实现了基本的RTP PS流解包和RTMP推流,但还有很多可以优化的地方。比如支持H265的推流,目前只处理了H264,H265的NALU格式不同,需要单独处理。还有音频的AAC转码,如果设备推的是G711,而下游需要AAC,就需要做转码。
另外,多路复用的优化也值得做。目前每个设备一个Session,如果设备数量很多,内存和CPU的压力会比较大。可以考虑用共享的解析器和推流器,减少对象创建。还有,可以加一个Web管理界面,实时查看推流状态、丢包率、码率等指标,方便运维。
最后,如果要做集群部署,需要考虑会话的共享和迁移。可以用Redis存储会话信息,用一致性哈希做负载均衡。不过这是比较大的改造,适合规模上来之后再考虑。
我个人在实际操作中的体会是,流媒体处理这个领域,理论很重要,但更重要的是动手调试。很多问题只有实际跑了才会遇到,比如时间戳回绕、NALU边界识别、RTMP握手失败等。建议大家在开发的时候,多抓包、多打日志、多对比,这样才能快速定位和解决问题。