简介:面向Java开发者的RTP实时传输协议学习资料包,内含jlibrtp-0.2.2开源库及其完整示例源码,重点演示了RTP客户端与服务端的实现方式。资源覆盖RTP数据包结构、RTCP控制报文、会话管理(SSRC设置、网络参数配置)等核心知识点,适合需要快速上手实时音视频传输开发的初中级Java工程师。包体共45个文件,其中39个Java源码文件提供了RTP/RTCP会话、数据收发和测试等完整实现,另有3个HTML帮助文档和3个TXT说明文件(含README)辅助阅读,整个压缩包仅108KB,轻量易用。已有327人学习下载。借助库中的单播示例与音频收发演示,读者可对照代码理解sendPacket、RTPListener回调及RTCP反馈机制,并基于readme指南自行搭建客户端-服务端交互流程,为构建低延迟多媒体通信应用打下基础。
1. Java 写 RTP 客户端:到底在写什么,值不值得自己动手
如果接到一个“把摄像头 RTP 流接进 Java 服务”的需求,很多人的第一反应是搜一个现成的 javartp 客户端库。实际搜索一圈会发现,Java 生态里能开箱即用的 RTP 客户端远比想象中少:JMF 早就不更新了,GitHub 上几个轻量库也多年没动,真正能长期维护的业务代码往往是在 DatagramSocket 之上自己写一套几百行的接收解析。RTP 本身是纯媒体传输协议,不负责播放、不负责重传、也不管数据怎么编码,所以客户端真正的工作是四件事:收 UDP 包、拆 RTP 头、按 Payload Type 组帧、交给解码器或扬声器。这篇笔记适合需要自己实现或维护 RTP 客户端的 Java 工程师,也适合刚接手音视频模块、想搞懂序列号回绕和分片重组的人。
2. 选型与协议基础:为什么 JMF 不能用,RTP 客户端的最小组成
2.1 先看事实:Java 生态里可用的 RTP 库其实只有这三条路
我在好几个项目里都见过同一种开场:先搜“Java RTP 客户端”,然后找到一个看起来很全的库,引入依赖,跑 Demo,结果要么在 64 位 JDK 上编译报错,要么处理不了 H.264 的 FU-A 分片,最后还是回到自己写解析。这不是 Java 不行,而是 RTP 在 Java 生态里一直处于“有实现,但没有统一维护”的状态。
老牌实现里,JMF(Java Media Framework)是绕不开的名字,它确实自带 RTP 接收和播放链路,但已经停止活跃维护很多年。FMJ 是它的开源延续分支,把很多类路径和 Native 调用做了兼容,但 RTP 这块对现代编码格式的支持仍然停留在老协议时代。jlibrtp 这类轻量库能完成 RTP 包的收发和解析,但编解码、RTCP、抖动缓冲都得自己接,而且更新频率很低。Netty 在传输层很好用,但它不提供 RTP 协议解析,只给你一个 UDP 通道和 ByteBuf。换句话说,Java 里没有一个像 FFmpeg 那样一揽子解决“解析 + 解码 + 输出”的官方库。
选择方案时我会先画一条边界。如果项目只需要低并发、简单的 PCM 音频流,jlibrtp 这类库还能勉强凑合,但一旦涉及多路流、动态 Payload Type、H.264 分片重组、RTCP 统计,库反而成了限制。真正生产环境里,我一般会选择自己在 DatagramSocket 或 Netty 之上实现 RTP 客户端,把协议解析控制在几百行内。这样做的好处是每一层都看得见,出问题能用 Wireshark 对照,而不是在黑匣子里猜。坏处是需要对协议细节足够熟悉,尤其字节序、回绕、分片重组这三块。
| 方案 | 维护状态 | 是否自带 RTP 解析 | 适合场景 |
|---|---|---|---|
| JMF | 停止活跃维护 | 有,但较老 | 教学演示,不建议新项目 |
| FMJ | 社区维护慢 | 有,兼容老接口 | 老项目迁移 |
| jlibrtp | 多年少更新 | 有,较轻量 | 学习、简单实验 |
| Netty + 自研解析 | 活跃 | 无 | 高并发、可控性要求高的生产项目 |
| 纯 DatagramSocket + 自研 | 自己维护 | 无 | 中小规模、想完全掌控协议 |
2.2 RTP 包头与 Java 字节序:读懂这 12 个字节
RTP 固定头一共 12 字节,所有字段都是网络字节序,也就是大端。Java 的 ByteBuffer 默认就是大端,所以解析时不要自己手写移位,直接用 ByteBuffer 按顺序读就行。最容易翻车的是“无符号”这件事。Java 的 byte 是 -128 到 127,short 是 -32768 到 32767,但 RTP 协议里的 Sequence Number、Timestamp、SSRC 都是无符号的。解析时必须用& 0xFF、& 0xFFFF、& 0xFFFFFFFFL把它们还原成真正的数值。
固定头的布局看起来简单,但位操作很容易错。第一个字节的高两位是 Version,RTP 固定为 2;接下来一位是 Padding 标记,表示负载末尾有填充字节;再一位是 Extension 标记,表示后面有扩展头;低四位是 CSRC Count。第二个字节的最高位是 Marker,低七位是 Payload Type。Sequence Number 是 16 位,Timestamp 和 SSRC 都是 32 位。
我在项目里要求所有新人都先背下这个结构,因为后面所有的乱序、花屏、静音问题,最后都要回到这 12 个字节上排查。解析时还要处理 CSRC 列表和扩展头。CSRC 一般多播会议才用,单播流里通常为 0。Extension 头如果置位,紧接着会有一个 16 位的长度字段,单位是 32 位字,所以要按长度 * 4跳过。Padding 的处理则是看负载最后一个字节,它表示末尾有多少个填充字节,真正有效负载要减去这个数。
public final class RtpPacket { public int version; public boolean padding; public boolean extension; public boolean marker; public int payloadType; public int sequenceNumber; public long timestamp; public long ssrc; public byte[] payload; public static RtpPacket parse(DatagramPacket datagram) { ByteBuffer buf = ByteBuffer.wrap(datagram.getData(), datagram.getOffset(), datagram.getLength()); if (buf.remaining() < 12) { throw new IllegalArgumentException("RTP 包不足 12 字节"); } RtpPacket pkt = new RtpPacket(); int first = buf.get() & 0xFF; pkt.version = (first >> 6) & 0x03; pkt.padding = ((first >> 5) & 0x01) == 1; pkt.extension = ((first >> 4) & 0x01) == 1; int csrcCount = first & 0x0F; int second = buf.get() & 0xFF; pkt.marker = ((second >> 7) & 0x01) == 1; pkt.payloadType = second & 0x7F; pkt.sequenceNumber = buf.getShort() & 0xFFFF; pkt.timestamp = buf.getInt() & 0xFFFFFFFFL; pkt.ssrc = buf.getInt() & 0xFFFFFFFFL; for (int i = 0; i < csrcCount; i++) { buf.getInt(); } if (pkt.extension) { int extensionLength = buf.getShort() & 0xFFFF; buf.position(buf.position() + extensionLength * 4); } int payloadLength = buf.remaining(); if (pkt.padding) { int paddingLength = buf.get(buf.limit() - 1) & 0xFF; payloadLength -= paddingLength; } byte[] payload = new byte[payloadLength]; buf.get(payload); pkt.payload = payload; return pkt; } }这个解析器是 RTP 客户端的核心,后面所有逻辑都建立在它之上。buf.getShort() & 0xFFFF这行尤其重要,如果把& 0xFFFF去掉,序列号大于 32767 时就会变成负数,乱序判断和丢包统计会全部失效。时间戳字段用 long 接收,是因为 32 位无符号数超过 int 上限后需要保留完整值。Version 不等于 2 的包应该直接丢弃,常见于把 RTCP 或其他 UDP 协议误认为是 RTP。
2.3 RTCP、SDP 与 RTP 的关系:客户端不能只处理 RTP
很多 RTP 客户端写着写着才发现,客户端还需要处理 RTCP 和 SDP。RTP 本身不负责协商 Payload Type 是多少,也不负责告诉对端用什么采样率。这些信息在 SDP 里,比如m=video 5004 RTP/AVP 96表示视频流在 5004 端口,Payload Type 96;a=rtpmap:96 H264/90000表示 PT 96 对应 H.264,时钟频率 90000。如果客户端先拿到 SDP,就可以在建立传输前把 PT 和编码格式的映射关系准备好。
RTCP 则承担质量控制。Sender Report 里带有 NTP 时间和 RTP Timestamp 的对应关系,这是做音画同步和精确播放时钟的关键。Receiver Report 用于反馈丢包统计,虽然不会触发重传,但能让发送端调整码率。默认情况下,RTP 使用偶数端口,RTCP 使用相邻的奇数端口;但 SDP 里有a=rtcp-mux时,两者会复用同一个端口。这也是“端口通但收不到包”的常见来源之一:你只开了一个 Socket 接收,对端却把 RTCP 发到了另一个端口,或者反过来把 RTCP 混进了 RTP 端口。
判断一个包是 RTP 还是 RTCP,不能只靠端口。因为两者 Version 都是 2,且都可能有 Marker 位。最实用的办法是看第二个字节的 Payload Type:RTCP 的 Packet Type 是 200 到 204(SR、RR、SDES、BYE、APP),而 RTP 的 Payload Type 通常小于 128。虽然动态 PT 理论上有碰撞风险,但实际流媒体系统里几乎不会把 RTP PT 配到 200 以上。
3. 用 DatagramSocket 把 RTP 接进来:接收、拆包、发送的最小代码
3.1 接收循环与 RtpPacket 解析:一个能跑的最小接收器
实现 RTP 客户端的第一步不是引入框架,而是用 DatagramSocket 做出一个能持续收包的循环。UDP 的特点是连接less,每次 receive 拿到的 DatagramPacket 需要完整记录源地址、源端口和数据长度。缓冲区大小我一般给 2048 字节,因为 RTP 负载通常不超过 MTU 1500 字节,加上 IP 和 UDP 头也远不到 2048。如果后面要处理超过 MTU 的分片,那是发送端的问题,接收端只需要按 RTP 分片重组,不需要一个超大缓冲。
接收循环要处理两类问题:阻塞和关闭。DatagramSocket 默认是永久阻塞的,所以要么用setSoTimeout(100)让 receive 每 100 毫秒超时一次,要么在另一线程调用 close 让 receive 抛 SocketException。我一般用超时方式,因为 RTP 客户端需要定期清理超时的分片缓存,这个清理动作正好可以放在超时异常里做。不要在主线程里做任何编解码或 IO 操作,RTP 到达速率受发送端控制,接收线程只做解析和入队,处理工作交给工作线程。
public class RtpReceiver { private final DatagramSocket socket; private final boolean closed = false; public RtpReceiver(int port) throws SocketException { socket = new DatagramSocket(port); socket.setSoTimeout(100); } public void run() { byte[] buffer = new byte[2048]; DatagramPacket packet = new DatagramPacket(buffer, buffer.length); while (!closed) { try { socket.receive(packet); RtpPacket rtp = RtpPacket.parse(packet); handleRtp(rtp, packet.getAddress(), packet.getPort()); } catch (SocketTimeoutException e) { cleanExpiredFragments(); } catch (IOException e) { if (!closed) { // 记录异常,但不要退出循环 } } } } private void handleRtp(RtpPacket rtp, InetAddress address, int port) { if (rtp.version != 2) { return; } // 按 SSRC + Payload Type 路由到对应的处理链 route(rtp); } }这里的handleRtp只是一个入口,真正处理要按 SSRC 分流。多个 RTP 流可能同时到达同一个端口,只靠目标端口无法区分,所以路由 key 必须包含 SSRC。如果项目里用的是 Netty,可以把这段逻辑平移到 ChannelHandler 里,DatagramPacket 换成io.netty.channel.socket.DatagramPacket,ByteBuffer 换成 ByteBuf,但协议解析逻辑完全不用变。接收线程里严禁分配大对象,每个包都 new 一个 RtpPacket 在高并发下会产生明显 GC 压力,后面第 5.4 节会专门讲怎么优化。
3.2 发送端:把 G.711 数据封装成 RTP 包并推进时间戳
接收客户端通常也要发送,比如回音、对讲、包中转场景。发送端的核心是正确填充 RTP 固定头,并且保证时间戳按采样数递增。很多人在这里犯的第一个错是把时间戳当 Unix 毫秒填,第二个错是把时间戳增量按系统时间算。
RTP 时间戳的单位不是毫秒,而是采样周期。对于 8kHz 的 G.711,每秒 8000 个采样,所以 20 毫秒的音频包是 160 个采样,时间戳增量就是 160。对于 48kHz 的 Opus,20 毫秒是 960 个采样,时间戳增量就是 960。对视频来说,常见时钟频率是 90000,所以每帧增量是90000 / 帧率,25 帧就是 3600。这些值都来自 SDP 的a=rtpmap字段,不能只看包长。
public class RtpSender { private final DatagramSocket socket; private final InetAddress remoteAddress; private final int remotePort; private final int payloadType; private final long ssrc = 0x12345678L; private int sequenceNumber = 0; private long timestamp = 0; public void sendPayload(byte[] payload, int samplesInPacket, boolean marker) throws IOException { int rtpLength = 12 + payload.length; byte[] rtp = new byte[rtpLength]; ByteBuffer buf = ByteBuffer.wrap(rtp); buf.put((byte) 0x80); // V=2, P=0, X=0, CC=0 buf.put((byte) ((marker ? 0x80 : 0x00) | (payloadType & 0x7F))); buf.putShort((short) sequenceNumber); buf.putInt((int) timestamp); buf.putInt((int) ssrc); buf.put(payload); DatagramPacket packet = new DatagramPacket(rtp, rtp.length, remoteAddress, remotePort); socket.send(packet); sequenceNumber = (sequenceNumber + 1) & 0xFFFF; timestamp += samplesInPacket; } }sequenceNumber = (sequenceNumber + 1) & 0xFFFF这行是发送端的回绕处理。序列号是 16 位,到 65535 后必须回到 0,如果不做这个处理,发送超过六万包后会出现一段负数序列号,接收端按无符号解析时反而没问题,但对端如果按有符号处理就会乱。时间戳是 32 位,加到溢出也无所谓,Java 的int溢出正好等价于 32 位回绕,但为了后续作差值,我建议接收端统一用 long 保存完整值。
发送端的 Marker 位要按媒体类型处理。G.711 音频通常在静音段起始包或讲话突发第一包置位,视频通常在每一帧的最后一个 RTP 包置位。如果拿不准,最简单做法是音频不置位,H.264 视频每个访问单元的最后一个包置位。Marker 不直接影响解码,但接收端会拿它辅助组帧和播放状态判断,错了会出现音频忽大忽小、视频卡顿边界不准。
3.3 序列号与时间戳回绕:为什么不能用 int 直接比较
RTP 客户端最常被问的问题之一就是:序列号会不会溢出?答案是会,而且是设计上就必须会。16 位序列号最大 65535,之后回绕到 0。32 位时间戳同样会回绕,如果按 48000Hz 采样率算,大概一天多回绕一次。如果代码里写if (currentSequence > lastSequence),回绕瞬间会把新包当成老包,导致缓存错误、丢包统计异常。
正确做法是用有符号差值来判断前后关系。RTP 规范里推荐的做法是:把两个无符号数做差,然后看差值是否在半个周期范围内。序列号用 16 位,所以(current - last) & 0xFFFF的结果如果小于 32768,说明 current 在 last 之后;如果大于 32768,说明 current 在 last 之前。时间戳是 32 位,判断范围变成了2^31。
public static boolean isNewerSequence(int current, int last) { int diff = (current - last) & 0xFFFF; return diff != 0 && diff < 32768; } public static boolean isNewerTimestamp(long current, long last) { long diff = (current - last) & 0xFFFFFFFFL; return diff != 0 && diff < 0x80000000L; }这段代码几乎每个 RTP 客户端都会用到。& 0xFFFF和& 0xFFFFFFFFL保证了差值在回绕后仍然能正确判断方向。注意不要直接返回diff > 0,因为回绕后这个结果会反。真正的序列号比较不是数值大小比较,而是环形空间上的方向判断,类似时钟指针,超过半圈就视为“更旧”。
在实现乱序缓存和丢包检测时,我用这个差值做两件事:一是判断新包是否比缓存中的最后一个包更新,二是计算diff - 1得到丢包数量。如果 diff 太大,比如超过 1000,往往不是真丢了 1000 个包,而是流中断后重连或 SSRC 变更,这时候应该重置状态而不是补一堆空洞。
4. 按 Payload Type 处理媒体:G.711、H.264、Opus 的组帧与分片重组
4.1 G.711:20 毫秒一包,最怕字节序和静音包
G.711 是 RTP 里最简单的负载,Payload 就是压缩后的音频采样,一个字节一个采样。8kHz 采样率下,20 毫秒是 160 字节,30 毫秒是 240 字节。客户端把连续几个 RTP 包的 payload 按时间戳拼接,就能得到完整的音频帧序列。G.711 有两种变体,PCMU 和 PCMA,对应 Payload Type 0 和 8,在 SDP 里通常写PCMU/8000和PCMA/8000。
G.711 虽然简单,但有一个 Java 特有的坑:G.711 的每个字节是无符号 8 位,而 Java 的 byte 是有符号。如果直接把 byte 转成 short 播放,大于 127 的采样值全会变成负数,声音会严重失真。正确做法是(byteValue & 0xFF)后转换成 16 位 PCM,再送给音频输出。很多新手拿到 RTP 数据后直接ByteBuffer.getShort(),出来的声音是爆炸噪音,根源就在符号扩展。
另一个坑是静音包。很多网关实现会在静音时不发 RTP 包,或者发送舒适噪声(CN,Payload Type 13)。如果接收端等到下一个包才计算间隔,播放线程会因为没有新数据而等待,表现为卡顿。正确做法是维护一个基于 RTP 时间戳的播放时钟:即使没有包到达,播放线程也按上次时间戳和采样率继续推进,超过一定时间再进入静音状态。Java 的 javax.sound.sampled.SourceDataLine 适合做这件事,数据来了写进去,数据没来就写静音采样。
G.711 直接播放一般不需要 JitterBuffer,因为包很小、采样率低,网络抖动 20 到 40 毫秒都可以通过音频设备缓冲吸收。但如果经过公网传输,我会加一个 60 到 120 毫秒的 JitterBuffer,收到包先按时间戳排队,播放线程按时间戳取出,而不是一到就播。
4.2 H.264 RTP 分包:Single NAL、FU-A 与 STAP-A 的重组顺序
H.264 是 RTP 客户端里最容易写错的负载。因为 H.264 的 NAL 单元大小经常超过 MTU,发送端会把一个 NAL 拆成多个 RTP 包传输,这就是 FU-A 分片。如果客户端不重组分片,只把每个 RTP payload 直接丢给解码器,视频必然花屏。H.264 在 RTP 里的封装方式主要看 RTP payload 的第一个字节:
- 1 到 23:单 NAL 单元,一个 RTP 包就是一个完整 NAL。
- 24:STAP-A,多个 NAL 聚合在一个 RTP 包里。
- 25:STAP-B,带 DoN 的聚合包,少用。
- 28:FU-A,分片单元。
- 29:FU-B,带 DoN 的分片单元,少用。
判断分片类型的代码很简单:取 payload 第一个字节的低 5 位。如果是 28,说明是 FU-A。FU-A 的 payload 结构是两字节头加分片数据:第一个字节叫 FU indicator,格式是 F、NRI、28;第二个字节叫 FU header,高三位是 S 和 E 标志加 R,低五位是真正的 NAL type。S 表示分片起始,E 表示分片结束。重组时要把这两个字节替换成原始 NAL Header,再拼上所有分片的负载部分。
public static byte[] assembleFua(List<RtpPacket> fragments) { if (fragments.isEmpty()) { return null; } byte[] firstPayload = fragments.get(0).payload; byte fuIndicator = firstPayload[0]; byte fuHeader = firstPayload[1]; int forbiddenBit = (fuIndicator >> 7) & 0x01; int nri = (fuIndicator >> 5) & 0x03; int nalType = fuHeader & 0x1F; byte nalHeader = (byte) ((forbiddenBit << 7) | (nri << 5) | nalType); ByteArrayOutputStream output = new ByteArrayOutputStream(4096); output.write(nalHeader); for (RtpPacket packet : fragments) { output.write(packet.payload, 2, packet.payload.length - 2); } return output.toByteArray(); }这段重组代码能跑,但只适合小的测试样本。生产级重组必须做三件事:校验第一个包 S 标志为 1,校验最后一个包 E 标志为 1,校验中间包既不是 S 也不是 E。实际网络丢包时,很可能只收到 S 没收到 E,或者收到 E 但中间缺了包,这时必须丢弃整个 NAL,不能把不完整的数据交给解码器。解码器收到被截断的 NAL 后,通常要等到下一个 IDR 帧才能恢复,所以花屏会持续很久。
我一般用一个Map<Long, FragmentBuffer>按 SSRC 缓存分片,每个缓存记录当前 NAL 的分片列表、上一个 RTP 时间戳、起始时间。收到新分片时,如果 S 标志为 1,说明上一个 NAL 已经结束或丢失,立刻清空旧缓存再开始新缓存。再配合一个每 200 毫秒执行一次的清理任务,把没有收到 E 的超时缓存丢弃,避免内存里堆积大量半截 NAL。这个“收到 S 就清空”的规则是花屏问题的最大救星。
4.3 Opus 与动态 PT:拿到 Payload Type 后先查 SDP,别硬编码
Opus 在现代 VoIP 里越来越常见,它的 RTP 封装比 H.264 简单得多,每个 RTP 包就是一个 Opus 包,不需要分片重组。真正复杂的反而是 Payload Type 映射。Opus 没有固定的 RFC 默认 PT,通常由 SDP 动态分配,比如a=rtpmap:111 opus/48000/2。如果客户端把 111 当成 H.264,或者把 PT 111 直接按 PT 8 处理,解码立刻失败。
处理动态 PT 的正确方式不是维护一张“常见 PT 表”,而是先解析 SDP,建立从 PT 到 Codec 的映射后再创建接收链路。SDP 里还有两个参数对接收端很重要:a=fmtp:111 minptime=10; useinbandfec=1,前者是包最小时长,后者表示是否启用带内 FEC。RTP 客户端解出 Opus 包后,可以把这些参数传给 Opus 解码器。注意 Opus 的解码时间戳单位是 48000,不是 8000,如果沿用了 G.711 的 8000 去算播放时间,声音会变快约 6 倍。
Opus 还有一个特点是支持 DTX(不连续传输),静音时可能不发包。接收端要维持播放时钟,不能依赖“包到了才播”。我见过一个客户端,Opus 流静音 500 毫秒后播放线程直接卡死,其实就是把接收间隔当成播放节奏。解决方法是把播放线程设计成独立时钟,每 20 毫秒醒来一次,按时间戳决定是播真实数据还是播舒适噪声。
对于视频的 H.264 和音频的 Opus 混跑场景,音画同步要靠 RTCP Sender Report。RTP 时间戳与 NTP 时间的关系会周期性在 SR 包里给出,客户端维护两条流各自的(RTP timestamp, NTP timestamp)映射,播放时把两条流都换算到同一 NTP 轴上再对齐。只靠收到顺序对齐音视频,在网络抖动下必然对不上。
5. 常见问题与避坑:RTP 客户端半年运行下来的 6 个实操记录
5.1 现象:音频忽快忽慢,听感一顿一顿
这个现象最容易误导人,新手第一反应是网络抖动,于是拼命加 JitterBuffer,结果越加越慢。真正原因是播放线程没有按 RTP 时间戳驱动,而是按“收到一个包就播一个包”驱动。网络一旦乱序或轻微抖动,播放节奏就会跟着乱。
原因在于 RTP 时间戳已经包含了采样时刻信息,G.711 在 8kHz 下每 160 时间戳就是 20 毫秒,播放线程应该按照这个间隔去消费数据,而不是依赖包到达间隔。解决方法是给客户端加一个独立的播放时钟,音频输出设备每次回调时计算“当前应该播放到的时间戳”,然后从 JitterBuffer 里取对应数据;取不到就补静音或舒适噪声。对于 8kHz 的 G.711,JitterBuffer 初始深度建议 100 到 150 毫秒,带宽窄的网络可以放到 200 毫秒,但不要无限加大,否则延迟会高到影响对讲体验。
5.2 现象:视频花屏,而且切流后长时间不恢复
这类问题常见于 H.264 流。表面现象是画面花、绿、撕裂,持续好几秒甚至直到下次 I 帧才恢复。排查时抓包发现 FU-A 分片都在,每个 RTP 包头也正常,问题出在重组缓存的清理逻辑。
原因通常是收到一个新的 S 分片时,没有清空上一个未完成的 NAL 缓存。比如前一个 NAL 丢了最后一个分片,缓存里残留了 90% 的数据;新的 S 包进来后,旧数据和旧数据拼接,解码器拿到的是两个 NAL 的缝合体。解决方法是无论当前缓存是否完整,只要收到 S 标志为 1 的分片,就先清除同 SSRC 的分片缓存。另一个连带措施是超时清理,超过 200 到 300 毫秒没有收完的分片缓存直接丢弃。这个清理动作可以放在接收线程的 SocketTimeout 分支里,不额外增加线程。
5.3 现象:端口通、网络通,但客户端一直收不到 RTP
先确认一件事:你绑定的端口是不是 SDP 里媒体流实际使用的端口。很多 RTP 流默认偶数端口发 RTP、奇数端口发 RTCP,如果发送端把 RTP 发到 5004,RTCP 发到 5005,而客户端只绑定 5004,RTCP 会丢但也影响不大。真正的坑是a=rtcp-mux,这时 RTP 和 RTCP 共用一个端口,客户端如果收到 RTCP 包后直接按 RTP 解析,会因为 RTCP 头格式不同产生各种异常。
解决办法是在路由逻辑的最前面判断包类型。RTP 和 RTCP 的 Version 都是 2,但 RTCP 的 Packet Type 是 200 到 204。解析完第二个字节后,先判断payloadType >= 200,如果成立就进入 RTCP 处理分支,而不是继续走 RTP 解析。同时,绑定本地端口建议开启SO_REUSEADDR,多路流快速重连时避免端口被 TIME_WAIT 状态占用。
5.4 现象:Java 进程内存缓慢上涨,GC 越来越频繁
RTP 客户端比一般 Web 服务更容易触发 GC 问题,因为每个包都会产生新对象。如果接收线程每包都 new 一个 byte[]、new 一个 RtpPacket、再 new 一个 ByteArrayOutputStream,在 24 小时连续流下,对象数量是惊人的。把 JVM 堆从 1GB 调到 8GB 只能延后 OOM 的时间,不能解决对象分配速率问题。
解决方向有三个:第一,接收缓冲区复用,不要在循环里new DatagramPacket;第二,解析后的负载尽量复用 byte[],用ByteBuffer.wrap或数组切片,避免复制;第三,分片重组用预分配的输出流,不要在每包上创建 ByteArrayOutputStream。如果项目用的是 Netty,直接使用PooledByteBufAllocator和ByteBuf的retain机制,注意及时release。判断是否到位,可以用 JFR 或-Xlog:gc观察 GC 频率,连续跑 24 小时,Full GC 次数应当趋近于 0。
5.5 现象:多路流同时接入后,画面串流、音画不对应
只靠源 IP 和源端口来区分会话,在 RTP 场景下不够可靠。原因是一个 RTSP 会话重新建立后,源端口可能不变,但 SSRC 变了;或者同一个服务器向同一端口发送多路媒体流时,源地址完全相同。这时候唯一稳定标识是 RTP 头里的 SSRC。
我习惯在路由层用Map<Long, StreamState>,key 是 SSRC,value 里放当前 Payload Type、分片缓存、序列号、最后活跃时间。新包到达时先查 SSRC,如果不存在且包的 version 合法,就创建新状态;如果 SSRC 变化但源地址相同,要把旧状态的缓存清掉并打印告警。每次 RTCP SR 到达时,还要校验 SR 里的 SSRC 是否与当前 RTP 流一致,不一致说明发端发生了切换,必须重置 RTP 解包状态。
5.6 现象:Java 启动失败怎么解决,报 OutOfMemoryError 或 GC overhead
音视频服务常见启动参数问题是把堆设置过小,或者机器上有多个 JDK,启动脚本用错了版本。先确认实际运行的是哪个 JDK,用java -version和echo $JAVA_HOME对照一下。如果环境变量里配了多个 JDK,优先在启动脚本里显式写死 JDK 路径,避免 Java 服务在重启后用了另一个版本。
对于 RTP 客户端,我更推荐把堆内存控制在一个合理范围,比如 1GB 到 2GB,然后通过减少对象分配来降低压力。不要依赖-Xmx8000m这种“大堆保平安”方案,因为超大堆会导致 GC 停顿时间边长,音视频延迟反而更不稳定。如果已经出现java.lang.OutOfMemoryError: Java heap space,先抓一份堆转储看是谁占的内存,大概率是分片缓存没有按 SSRC 清理,而不是单纯堆不够。
6. 验证与进阶:给 RTP 客户端加一个离线回放测试台
验证 RTP 客户端不能只靠连一次真实摄像头。真实流不可控,网络抖动和编码配置天天变,出了问题很难判断是客户端 bug 还是对端问题。我建议在项目里保留一个离线回放测试台:把抓包导出的 RTP payload 保存成本地文件,客户端离线读取并按原始时间戳注入解析链路,这样每次改完代码都能跑同一份数据回归。
最简单做法是用 Wireshark 打开 pcap,过滤出 RTP 包后导出为 CSV 或 raw 文件,然后在 Java 里按行读取,把十六进制 payload 转成 byte[] 后调用接收处理函数。回放时要按时间戳控制注入节奏,最好用真实时间间隔,否则看不出乱序和超时清理逻辑是否正常。
稳定性测试我一般跑 72 小时。判定标准有三条:一是收到的包数减去解析成功的包数为 0;二是缓存中残留的未完成分片数始终小于 10;三是 GC 日志里没有 Full GC。跑完一遍再故意注入丢包,把每 100 个包里随机丢掉 3 个,看客户端能否在下一帧关键帧恢复。H.264 流允许丢包后的短暂花屏,但必须在收到下一个 IDR 帧后恢复。
进阶顺序上,我建议先做 JitterBuffer,再做 RTCP Receiver Report,最后做 FEC 或关键帧请求。JitterBuffer 是音视频质量的基石,没有它,任何丢包重传都是空中楼阁。实现时按 SSRC 独立排队,每个队列记录当前最大的连续时间戳,播放线程按时间戳消费,而不是按入队顺序消费。做 Receiver Report 时,把统计信息按发送间隔回传,但不要为了精度频繁发 UDP 包。
最后说一个自己的习惯:每次改协议解析代码,我都会用同一份抓包文件跑回归,并打印每个包的 version、marker、payloadType、sequenceNumber、timestamp、ssrc。这个习惯救过我三次,其中两次是改字节序改坏了,一次是 CSRC 解析偏移算错导致后续全乱。先让脚本告诉你哪里变了,再谈优化。希望帮到你。
本文还有配套的精品资源,点击获取