Java实现GB28181 RTP PS流解包与RTMP推流实战
1. 项目背景与整体设计思路1.1 为什么要在Java里做RTP PS流解包推RTMP做过安防流媒体对接的朋友应该都有体会GB28181协议在国内安防行业几乎是绕不开的存在。摄像头、NVR、平台之间信令走SIP媒体流走RTP承载PS封装这套组合拳已经跑了很多年。但问题在于后端业务系统往往更习惯吃RTMP或者HTTP-FLV这类流尤其是需要对接Web播放器、CDN分发、录播服务的时候PS流就显得有点原始了。这个项目的核心目标很明确用JavaSpringBoot框架接收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设备推流的端口。每个设备对应一个独立的SessionSession里维护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 -------------------------------- |V2|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 typeGB28181里通常是96PS流或者98H264。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帧、5IDR的I帧、7SPS、8PPS、6SEI。H265的NALU头部是2字节格式更复杂一些但核心信息类似。Type的范围是0到63其中16到21是B帧、P帧相关的19和20是IDR帧32是VPS33是SPS34是PPS。识别帧边界的时候我一般会扫描ES流找到起始码的位置然后根据NALU类型判断帧的边界。一个完整的视频帧可能包含多个NALU比如一个I帧通常包含SPS、PPS、SEI和IDR NALU。这些NALU需要一起打包成一个FLV Tag推给RTMP。public ListNalu splitNalu(byte[] esData) { ListNalu nalus new ArrayList(); int start -1; for (int i 0; i esData.length - 3; i) { if (esData[i] 0x00 esData[i1] 0x00 esData[i2] 0x00 esData[i3] 0x01) { if (start ! -1) { nalus.add(new Nalu(esData, start, i - start)); } start i 4; i 3; } else if (esData[i] 0x00 esData[i1] 0x00 esData[i2] 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的类型。如果是5H264 IDR或者19/20H265 IDR就是I帧。如果是1H264非IDR或者1H265非IDR就是P帧。SPS和PPS这些参数集NALU我一般会缓存起来在推流开始的时候先发一次后续如果检测到参数集变化再重新发送。注意H264的SPS和PPS必须在I帧之前发送否则播放器无法解码。有些设备会把SPS和PPS放在I帧的ES流里一起发有些设备会单独发处理的时候要兼容这两种情况。3. 实操过程与核心环节实现3.1 Netty UDP服务搭建与RTP包接收先搭Netty的UDP服务。SpringBoot项目里集成Netty很简单加个依赖就行dependency groupIdio.netty/groupId artifactIdnetty-all/artifactId version4.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 ChannelInitializerNioDatagramChannel() { 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是核心的处理器继承SimpleChannelInboundHandlerpublic class RtpHandler extends SimpleChannelInboundHandlerDatagramPacket { 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流的解复用我写了一个专门的类核心逻辑是状态机加缓冲区。先定义一个PsStreamParserpublic 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[offset1] 0xFF)) 20; scrBase | ((long)(data[offset2] 0xF8)) 12; scrBase | ((long)(data[offset2] 0x03)) 13; scrBase | ((long)(data[offset3] 0xFF)) 5; scrBase | ((long)(data[offset4] 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的握手分三步客户端发C0C1服务端回S0S1S2客户端再发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 ByteBufCPU过高解析逻辑低效分析火焰图优化状态机或使用ByteBuf4.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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter/artifactId version2.7.18/version /dependency dependency groupIdio.netty/groupId artifactIdnetty-all/artifactId version4.1.90.Final/version /dependency dependency groupIdorg.slf4j/groupId artifactIdslf4j-api/artifactId version1.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的推流目前只处理了H264H265的NALU格式不同需要单独处理。还有音频的AAC转码如果设备推的是G711而下游需要AAC就需要做转码。另外多路复用的优化也值得做。目前每个设备一个Session如果设备数量很多内存和CPU的压力会比较大。可以考虑用共享的解析器和推流器减少对象创建。还有可以加一个Web管理界面实时查看推流状态、丢包率、码率等指标方便运维。最后如果要做集群部署需要考虑会话的共享和迁移。可以用Redis存储会话信息用一致性哈希做负载均衡。不过这是比较大的改造适合规模上来之后再考虑。我个人在实际操作中的体会是流媒体处理这个领域理论很重要但更重要的是动手调试。很多问题只有实际跑了才会遇到比如时间戳回绕、NALU边界识别、RTMP握手失败等。建议大家在开发的时候多抓包、多打日志、多对比这样才能快速定位和解决问题。

相关新闻

宁波执业律师张娟:离婚后孩子判给谁——抚养权判断规则与争取要点梳理

宁波执业律师张娟:离婚后孩子判给谁——抚养权判断规则与争取要点梳理

结论先行:抚养权不归收入高的一方,也不默认归母亲,而是按年龄分档、以孩子利益为轴心综合判断。民法典第1084条确立了不满两周岁以母亲直接抚养为原则、已满八周岁尊重孩子真实意愿、两至八周岁按有利于未成年子女原则裁判的基本框架。本文由…

2026/10/13 7:00:55 阅读更多 →
共享汽车调度落地实践:LSTM需求预测+GA-SA多目标优化

共享汽车调度落地实践:LSTM需求预测+GA-SA多目标优化

简介:本资源是一篇聚焦单向共享电动汽车(OW-EC)运营优化的学术论文,面向新能源汽车领域研究者、智能交通系统开发者及高校交通运输/车辆工程专业师生,旨在解决因用户需求潮汐性与不均衡性导致的站点车辆库存失衡这一行…

2026/10/13 7:00:55 阅读更多 →
Agent沙箱全解析:从隔离原理到容器、进程级与代码级落地实践

Agent沙箱全解析:从隔离原理到容器、进程级与代码级落地实践

做Agent开发的朋友聚在一起聊天时,“沙箱”这个词出现的频率高得吓人。无论你是搭一个能自动写代码的助手,还是让模型按流程调用外部工具的系统,文档里几乎必有一章在讲“沙箱”。我最早接触它时,以为就是一个“把环境隔离开”的省…

2026/10/13 7:00:55 阅读更多 →

最新新闻

链表分解:从内存布局到指针偏移的底层实践

链表分解:从内存布局到指针偏移的底层实践

1. 项目概述:这不是“拆链子”,而是把链表“掰开揉碎”讲清楚 “链表的分解”这五个字,乍看像一道算法题的标题,但实际在一线教学、代码评审和系统调试中,它代表一种极其关键的底层思维训练——不是单纯地写个反转或删…

2026/10/13 7:37:09 阅读更多 →
Python网络协议编辑器与分析器:从抓包到构造的全链路实现

Python网络协议编辑器与分析器:从抓包到构造的全链路实现

简介:计算机网络课程设计作品,包含网络协议编辑器与网络协议分析器,采用Python编程实现,面向计算机专业本科生、网络编程爱好者及需要完成类似课设的开发者。资源内包括两个主要Python脚本:packet_sender.py用于创建、…

2026/10/13 7:37:09 阅读更多 →
Unity触发器实战:翻滚闪避技能与无敌帧实现

Unity触发器实战:翻滚闪避技能与无敌帧实现

最近在做动作类游戏原型时,我发现很多新手在实现“翻滚/闪避”这类技能时,会遇到一个很典型的问题:动画已经播了、位移也加了,但角色在翻滚过程中要么被敌人的攻击触发器误判命中,要么翻滚结束后触发器没有正确恢复状态…

2026/10/13 7:37:09 阅读更多 →
SwiftUI 与 Agent 架构解析:声明式 UI 驱动端侧智能体

SwiftUI 与 Agent 架构解析:声明式 UI 驱动端侧智能体

1. 从标题拆解 SwiftUI-Agent-Skill 到底想解决什么问题第一次看到 “SwiftUI-Agent-Skill” 这个组合词,我的直觉是:它不是一个单纯的 UI 框架教程,也不是一个泛泛的 AI 工具介绍,而是把三个原本分属不同层面的东西强行捏在了一起…

2026/10/13 7:37:09 阅读更多 →
网页正文提取实战:从URL到干净Markdown的命令行工具

网页正文提取实战:从URL到干净Markdown的命令行工具

1. rea 的定位:从网页 URL 到本地 Markdown 的一站式工具前两个月整理个人知识库的时候,我经常干一件事:把网上看到的好文章存到本地。试过浏览器收藏夹,时间一长全是死链;试过截图,检索和引用都不方便&…

2026/10/13 7:37:09 阅读更多 →
SpringBoot+Vue打造装饰工程管理系统:完整实战解析

SpringBoot+Vue打造装饰工程管理系统:完整实战解析

做装饰工程项目管理的系统,很多老牌软件公司都停留在单体架构时代,界面老旧、流程僵硬,项目经理宁可自己拉Excel表格也不愿意用。而新团队上来就搞微服务分布式,对一家中小型装饰公司来说,根本就是杀鸡用牛刀&#xff…

2026/10/13 7:36:08 阅读更多 →

日新闻

物联网系统集成避坑指南:从选型到验收的关键要点

物联网系统集成避坑指南:从选型到验收的关键要点

做了这么多年物联网系统集成的项目,我最大的感受是,大部分坑不是出在技术前沿,而是出在一些看起来很平常的地方。设备选了便宜的、协议没问清楚、现场没摸透、验收标准写得含糊——这些细枝末节,最后都会变成延期、超支和扯皮的大…

2026/10/13 0:00:57 阅读更多 →
ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

如果你玩ESP32有一阵子了,很难不碰到舵机控制这道坎。做智能小车要转方向,做机械臂要抬关节,做云台要偏航俯仰,背后都是同一个东西在干活:舵机。而我见过太多人卡在“代码能编译、舵机就是不听话”这个阶段&#xff0c…

2026/10/13 0:00:57 阅读更多 →
CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

1. 为什么CAN总线至今仍是工业现场的“硬通货”?——从汽车底盘到智能温室的真实需求你有没有拆过一辆老款燃油车的中控台?或者调试过某高校实验室里那台跑着Modbus协议却频频丢帧的PLC控制柜?又或者,在某个农业物联网项目里&…

2026/10/13 0:00:57 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/13 2:54:56 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/12 0:16:38 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/13 1:58:08 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 21:05:09 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 14:48:49 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 18:11:17 阅读更多 →