1. 项目背景与整体设计思路
1.1 这个项目到底在做什么
先把场景说清楚。手头有一路国标流媒体设备(比如网络摄像机、NVR 之类),它按照 GB28181 规范把音视频数据打包成 RTP,再封装进 PS 流往外推。现在需要做的事情是:在 Java 侧把这路流接进来,一层层剥开——先解 RTP,再解 PS,拿到里面的音视频裸流,最后重新封装成 RTMP 推给直播服务,让浏览器或者播放器能直接看。
这个链路听起来长,其实拆开就是四个动作:收流、解 RTP、解 PS、推 RTMP。标题里说的“简单解包”,重点在“简单”两个字——不追求把 GB28181 全套信令、SIP 交互、复杂容错都做全,而是聚焦在媒体数据本身的拆包和转发上。适合谁看?适合已经能把 RTP 包收上来、但对 PS 层怎么剥、时间戳怎么对齐、RTMP 怎么封包还比较模糊的开发者。如果你正在做国标接入网关、视频转码中转、或者只是想把某路设备流接到自己的直播系统里,这套思路可以直接参考。
1.2 为什么选 Java + SpringBoot 这套组合
很多人一提到流媒体处理,第一反应是 C/C++ 或者 Go,觉得 Java 做这个“不够快”。我实际做下来,Java 在这个场景里完全够用,原因有几个。
第一,这个链路的瓶颈不在语言,在网络 IO 和内存拷贝。RTP 收包、PS 拆包本质上是字节数组的解析和搬运,单路 1080P 的码流也就几 Mbps,现代 JVM 处理这点吞吐毫无压力。真正吃性能的是大量并发路数和频繁的 GC,那是架构问题,不是语言问题。
第二,SpringBoot 提供了极好的工程化基础。配置管理、依赖注入、生命周期管理、日志、监控,这些在流媒体服务里同样需要。用 SpringBoot 起一个服务,把收流、解包、推流做成 Bean,配置写在 application.yml 里,运维和扩展都方便。
第三,生态成熟。Netty 处理 UDP/TCP 收包,各种字节操作工具类齐全,RTMP 也有现成的封装思路可以借鉴。相比之下,用 C 写一套还要自己管内存、管线程池,开发效率低不少。
提示:如果你的场景是单机几百上千路并发,那确实要慎重考虑 JVM 调优和堆外内存;但如果是几十路以内的中转场景,Java 方案落地快、维护成本低,性价比很高。
1.3 整体数据流设计
把整条链路画成一条线,大概是这样的:
设备 --RTP(UDP/TCP)--> 收流模块 --> RTP解包 --> PS解包 --> 音视频帧 --> RTMP封装 --> 推流 --> 直播服务每一层的职责要分清楚,这是设计的关键:
- 收流模块:只负责把 RTP 包按顺序收上来,不关心内容。UDP 收包要注意乱序和丢包,TCP 收包要注意粘包拆包。
- RTP 解包:剥掉 RTP 头,拿到 payload。这里要处理时间戳、序列号、marker 位。
- PS 解包:PS 流是 MPEG-PS 格式,里面有 PES 包,PES 里才是真正的 H264/H265 和音频。这一层最复杂,也是标题里“解包”的核心。
- RTMP 封装:把裸流按 FLV Tag 格式重新打包,通过 RTMP 协议推出去。
我踩过的一个坑是:一开始想把所有逻辑塞在一个类里,结果代码越写越乱,调试时根本分不清是 RTP 层的问题还是 PS 层的问题。后来拆成独立的 Handler,每层只做一件事,出问题时看日志就能定位到具体哪一层。这个拆分思路强烈建议一开始就做好。
2. RTP 解包的核心细节与实操要点
2.1 RTP 头部结构逐字段拆解
RTP 头固定 12 字节,后面可能跟 CSRC 列表和扩展头。先把这 12 字节吃透,后面才不迷糊。
| 字段 | 位数 | 说明 |
|---|---|---|
| V(版本) | 2 | 固定为 2,不是 2 直接丢弃 |
| P(填充) | 1 | 为 1 时末尾有填充字节,要减掉 |
| X(扩展) | 1 | 为 1 时头部后面有扩展头 |
| CC(CSRC 计数) | 4 | 影响头部实际长度 |
| M(标记) | 1 | 视频里通常表示一帧结束 |
| PT(载荷类型) | 7 | 区分音视频和编码格式 |
| 序列号 | 16 | 用于排序和丢包检测 |
| 时间戳 | 32 | 同一帧的多个包时间戳相同 |
| SSRC | 32 | 标识一路流 |
解析代码大概长这样:
public RtpPacket parse(byte[] data) { int version = (data[0] >> 6) & 0x03; if (version != 2) { return null; // 非法包直接丢 } boolean padding = ((data[0] >> 5) & 0x01) == 1; boolean extension = ((data[0] >> 4) & 0x01) == 1; int csrcCount = data[0] & 0x0F; boolean marker = ((data[1] >> 7) & 0x01) == 1; int payloadType = data[1] & 0x7F; int seq = ((data[2] & 0xFF) << 8) | (data[3] & 0xFF); long timestamp = ((long)(data[4] & 0xFF) << 24) | ((data[5] & 0xFF) << 16) | ((data[6] & 0xFF) << 8) | (data[7] & 0xFF); long ssrc = ((long)(data[8] & 0xFF) << 24) | ((data[9] & 0xFF) << 16) | ((data[10] & 0xFF) << 8) | (data[11] & 0xFF); int headerLen = 12 + csrcCount * 4; if (extension) { // 扩展头前 4 字节是 profile 和长度 int extLen = ((data[headerLen + 2] & 0xFF) << 8) | (data[headerLen + 3] & 0xFF); headerLen += 4 + extLen * 4; } int payloadLen = data.length - headerLen; if (padding) { payloadLen -= data[data.length - 1]; // 末尾填充长度 } byte[] payload = new byte[payloadLen]; System.arraycopy(data, headerLen, payload, 0, payloadLen); // ... 组装 RtpPacket 返回 }这里有个细节容易忽略:时间戳是 32 位无符号数,会回绕。当它从 0xFFFFFFFF 跳到 0 时,如果你直接做减法算差值,会得到一个巨大的负数。正确做法是用无符号比较,或者记录回绕次数。我在实际项目里就遇到过播放器画面突然卡住,查了半天发现是时间戳回绕没处理。
2.2 收流方式的选择:UDP 还是 TCP
GB28181 里设备推流可以用 UDP 也可以用 TCP,两者处理方式完全不同。
UDP 收流:一个包一个包独立到达,可能乱序、可能丢包。优点是延迟低,缺点是网络不好时花屏。处理时要维护一个接收缓冲区,按序列号排序,发现丢包要么等重传(RTP 本身不重传,得靠上层),要么直接跳过。我的做法是维护一个滑动窗口,序列号连续的直接处理,跳号的先缓存一小段时间,等乱序包补齐。
TCP 收流:流是连续的字节,没有包边界,必须自己拆包。RTP over TCP 通常有两种模式:一种是每个 RTP 包前加 2 字节长度前缀,另一种是直接裸流。国标里常见的是前者。拆包逻辑就是先读 2 字节长度,再读对应长度的数据,循环往复。
// TCP 拆包示意 private void onTcpData(ByteBuf buf) { while (buf.readableBytes() >= 2) { buf.markReaderIndex(); int len = buf.readUnsignedShort(); if (buf.readableBytes() < len) { buf.resetReaderIndex(); // 数据不够,等下次 return; } byte[] packet = new byte[len]; buf.readBytes(packet); handleRtpPacket(packet); } }注意:TCP 模式下如果长度字段算错,整个流就全乱了。建议在拆包处加日志,把每次读到的长度打出来,和实际收到的字节数对一下,能快速定位问题。
2.3 序列号与时间戳的实战处理
序列号和时间戳是 RTP 层的两个“命门”。
序列号是 16 位,同样会回绕(从 65535 到 0)。判断丢包时不能简单用seq - lastSeq > 1,要考虑回绕。我一般用一个辅助方法:
private boolean isNext(int last, int current) { return ((current - last) & 0xFFFF) == 1; }时间戳的处理更关键,因为它直接决定播放是否流畅。同一帧的多个 RTP 包时间戳相同,不同帧时间戳递增。递增的步长取决于采样率:视频一般是 90000Hz,音频常见 8000Hz 或 48000Hz。这个步长在后续转 RTMP 时要换算成毫秒,换算公式是:
毫秒 = 时间戳差值 * 1000 / 采样率比如视频时间戳从 90000 跳到 180000,差值 90000,除以 90000 再乘 1000,正好是 1000ms,也就是 1 秒。这个换算在推 RTMP 时天天要用,务必记牢。
3. PS 解包:整个链路里最硬的一块骨头
3.1 PS 流的整体结构认知
PS(Program Stream)是 MPEG-2 系统层的一种封装格式。它的结构是层层嵌套的:
PS 流 └── PS 包头(Pack Header) └── PES 包(Packetized Elementary Stream) └── PES 包头 └── 原始流数据(H264/H265/音频)PS 包头以固定的起始码00 00 01 BA开头,后面跟着系统时钟等信息。PES 包以00 00 01加一个流 ID 开头,流 ID 决定了这是视频还是音频。视频常见的是0xE0,音频常见的是0xC0。
理解这个嵌套关系是解包的前提。很多人卡在 PS 层,就是因为没搞清楚“PS 包”和“PES 包”是两层不同的东西。打个比方:PS 流像一个快递大箱子,里面装着若干个小盒子(PES 包),小盒子里才是真正的货物(H264 数据)。你要先拆大箱子,再拆小盒子。
3.2 定位起始码与流 ID 识别
解 PS 的第一步是找起始码。整个 PS 流就是一堆起始码分隔的段,找到起始码就知道下一段是什么。
private static final int PACK_START_CODE = 0x000001BA; private static final int SYSTEM_HEADER_START_CODE = 0x000001BB; private static final int PES_START_CODE_PREFIX = 0x000001; // 扫描起始码 private int findStartCode(byte[] data, int offset) { for (int i = offset; i < data.length - 3; i++) { if (data[i] == 0x00 && data[i+1] == 0x00 && data[i+2] == 0x01) { return i; } } return -1; }找到00 00 01后,看第 4 个字节:
0xBA:PS 包头,跳过固定长度(一般是 14 字节,具体看 stuffing 长度)0xBB:系统头,跳过0xE0~0xEF:视频 PES0xC0~0xDF:音频 PES- 其他:按需处理或跳过
这里有个实战经验:PS 包头长度不是固定的。它有一个 stuffing length 字段,表示后面填充了多少字节。如果不处理这个,就会把填充字节当成 PES 数据,导致解析错位。我一开始就栽在这,画面全是绿屏,后来把 stuffing 长度算进去才正常。
3.3 PES 包头解析与负载提取
PES 包头结构比 RTP 复杂,关键字段有:
| 字段 | 说明 |
|---|---|
| 起始码前缀 | 00 00 01 |
| 流 ID | 区分音视频 |
| PES 包长度 | 后面数据的长度,可能为 0(表示不定长) |
| 标志位 | 包含 PTS/DTS 标志 |
| PES 头长度 | 可选头部分的长度 |
| PTS/DTS | 时间戳,用于同步 |
PTS(Presentation Time Stamp)和 DTS(Decoding Time Stamp)是音视频同步的关键。视频因为有 B 帧,解码顺序和显示顺序可能不一致,所以需要 DTS 指导解码、PTS 指导显示。音频一般 PTS 等于 DTS。
解析 PTS 的代码有点绕,因为它用了特殊的 5 字节编码:
private long parsePts(byte[] data, int offset) { // PTS 是 33 位,分三段存储 long pts = ((long)(data[offset] & 0x0E) << 29) | ((long)(data[offset+1] & 0xFF) << 22) | ((long)(data[offset+2] & 0xFE) << 14) | ((long)(data[offset+3] & 0xFF) << 7) | ((long)(data[offset+4] & 0xFE) >> 1); return pts; }提示:PTS 的解析一定要对照规范逐位核对,错一位整个时间轴就偏了。建议拿一段已知的 PS 流,用工具先解析出正确的 PTS,再和自己代码的结果对比,确认无误后再往下走。
3.4 音视频分流与帧边界处理
PES 负载里是 H264 或 H265 的 NAL 单元。视频流需要按 NAL 边界切分,音频(一般是 AAC 或 G711)按帧切分。
H264 的 NAL 单元以00 00 01或00 00 00 01开头。切分时要注意:一个 PES 包里可能包含多个 NAL,也可能一个 NAL 跨多个 PES 包。跨包的情况最麻烦,需要维护一个缓冲区,把不完整的 NAL 拼起来。
// 简化的 NAL 切分思路 private List<byte[]> splitNal(byte[] pesPayload) { List<byte[]> nals = new ArrayList<>(); int start = findStartCode(pesPayload, 0); while (start != -1) { int next = findStartCode(pesPayload, start + 3); if (next == -1) { // 最后一个 NAL,可能不完整,缓存起来 cacheRemain(pesPayload, start); break; } byte[] nal = Arrays.copyOfRange(pesPayload, start, next); nals.add(nal); start = next; } return nals; }音频相对简单,AAC 一般是 ADTS 格式,每帧有固定头,按头里的长度字段切分即可。
4. RTMP 封装与推流实现
4.1 FLV Tag 结构与封装逻辑
RTMP 推流的核心是把音视频数据封装成 FLV Tag。FLV Tag 结构是:
Tag Header (11 字节) ├── Tag Type (1 字节):8=音频,9=视频,18=脚本 ├── Data Size (3 字节):数据长度 ├── Timestamp (3 字节):时间戳低 24 位 ├── Timestamp Extended (1 字节):时间戳高 8 位 └── Stream ID (3 字节):固定为 0 Tag Data视频 Tag 的数据部分,第一个字节是帧类型和编码 ID:高 4 位是帧类型(1=关键帧,2=普通帧),低 4 位是编码 ID(7=AVC/H264,12=HEVC/H265)。后面跟着 AVCPacketType(0=序列头,1=NALU)和 CompositionTime。
这里有个关键点:推流前必须先发序列头(Sequence Header)。H264 的序列头就是 SPS 和 PPS,播放器拿到它才能初始化解码器。序列头一般在流的开头出现,也可能中途变化(比如分辨率切换)。我的做法是缓存最新的 SPS/PPS,每次遇到关键帧前先确保序列头已发送。
// 视频 Tag 封装示意 private byte[] buildVideoTag(byte[] nal, boolean keyFrame, long timestamp) { ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(keyFrame ? 0x17 : 0x27); // 帧类型 + AVC out.write(0x01); // AVCPacketType = NALU // CompositionTime,一般填 0 out.write(0x00); out.write(0x00); out.write(0x00); out.write(nal); return out.toByteArray(); }4.2 时间戳对齐与音视频同步
RTMP 的时间戳是毫秒,而 RTP/PS 里是采样率单位,必须换算。换算时要注意:音视频要基于同一个基准时间,否则会出现音画不同步。
我的做法是记录第一帧的时间戳作为基准,后续所有帧的时间戳都减去这个基准,再换算成毫秒。这样时间轴从 0 开始,播放器处理起来最省心。
private long baseVideoTs = -1; private long baseAudioTs = -1; private long toRtmpTs(long pts, long base, int sampleRate) { if (base < 0) { base = pts; } return (pts - base) * 1000 / sampleRate; }音视频同步还有个坑:音频和视频的基准时间要尽量对齐。如果视频先到、音频后到,音频的基准时间应该参考视频的基准,而不是各自独立。否则播放器会认为音频从 0 开始、视频也从 0 开始,但实际它们差了半秒,就不同步了。我一般以先到的那个流为基准,另一个流用相同的时间原点。
4.3 推流连接与重连机制
RTMP 推流用现成的库(比如某些 Java RTMP 客户端库)能省很多事,但重连机制必须自己做。网络抖动、服务端重启都会导致连接断开,如果不重连,流就断了。
重连策略我一般这样设计:
- 检测到连接断开后,先等 1 秒重连
- 失败则指数退避:2 秒、4 秒、8 秒,最多到 30 秒
- 重连成功后,重新发送序列头,再继续推流
- 重连期间收到的数据先缓存,重连成功后补发(但缓存不能无限大,超过阈值就丢弃旧数据)
注意:重连后时间戳要连续,不能从头开始,否则播放器会认为流重置了。我的做法是重连后继续用之前的时间轴,只是把断流期间的时间“跳过”,保证时间戳单调递增。
5. 常见问题与排查技巧实录
5.1 画面花屏、绿屏、卡顿的排查路径
这类问题最让人头疼,因为现象相似但原因可能完全不同。我整理了一个排查顺序:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 全绿屏 | PS 包头长度算错,数据错位 | 打印 PS 包头 stuffing 长度,核对 |
| 花屏但能看 | RTP 丢包或乱序未处理 | 统计丢包率,检查序列号排序逻辑 |
| 卡顿 | 时间戳不连续或回绕未处理 | 打印时间戳差值,检查回绕逻辑 |
| 只有声音没画面 | 视频序列头未发送 | 检查 SPS/PPS 是否在关键帧前发送 |
| 只有画面没声音 | 音频流 ID 识别错误 | 打印 PES 流 ID,确认音频流被正确分流 |
我遇到最多的是花屏,十有八九是 RTP 层丢包没处理好。UDP 收流时,如果网络抖动,包会乱序到达,如果直接按到达顺序处理,PS 层就会拿到错乱的数据。解决办法就是前面说的滑动窗口排序。
5.2 内存泄漏与 GC 频繁的优化经验
流媒体服务跑久了,最容易出的问题就是内存泄漏。常见泄漏点:
- ByteBuf 没释放:用 Netty 时,收到的 ByteBuf 用完必须 release,否则堆外内存会涨。
- 缓存无上限:为了处理乱序或跨包 NAL 而设的缓存,如果没有上限和清理机制,会越积越多。
- 监听器未注销:连接断开后,相关的监听器、定时任务没清理。
GC 频繁的话,重点看两个地方:一是每帧都 new 大量小对象(比如每个 RTP 包都 new 一个 byte[]),可以改用对象池或复用缓冲区;二是日志打太多,尤其是 debug 级别在高频路径上打日志,开销很大。
我的经验是:在高频路径上,能复用就复用,能不打日志就不打。需要调试时临时开,调完就关。
5.3 推流失败的典型原因速查
推流失败一般分两类:连不上和连上了推不动。
连不上:检查 RTMP 地址、端口、鉴权参数是否正确;检查网络是否通;检查服务端是否限制了来源。
连上了推不动:多半是 FLV Tag 格式不对。用工具抓一下推出去的包,对照 FLV 规范逐字段核对。最常见的是时间戳字段没填对,或者序列头格式错误。我建议先用一个已知能播的 FLV 文件,把它的 Tag 结构 dump 出来,和自己生成的对比,差异一目了然。
6. 一些实操心得与扩展思路
这套链路我前后调了挺久,有几个心得值得分享。
第一,分层日志是调试利器。在 RTP 层、PS 层、RTMP 层各打一类日志,带上序列号或时间戳,出问题时能快速定位是哪一层的问题。我甚至做过一个开关,可以只对某一路流开详细日志,避免日志刷屏。
第二,先用文件流验证,再接实时流。PS 解包和 RTMP 封装这两块,可以先把一段 PS 流存成文件,用代码读文件解析,验证逻辑正确后再接实时收流。这样能把网络问题和解析问题分开,效率高很多。
第三,时间戳处理要贯穿始终。从 RTP 到 PS 到 RTMP,时间戳一直在换算和传递,任何一环出错都会导致播放异常。建议在关键节点都打印时间戳,形成一条完整的时间线,方便对照。
后续如果要扩展,可以考虑几个方向:支持 H265(流程类似,主要是 NAL 类型和序列头格式不同)、增加多路流管理(用 SSRC 或流 ID 区分)、加入简单的转码能力(比如统一分辨率或码率)。不过这些都是后话,把当前这条链路跑通、跑稳,才是第一步。