
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 时头部后面有扩展头CCCSRC 计数4影响头部实际长度M标记1视频里通常表示一帧结束PT载荷类型7区分音视频和编码格式序列号16用于排序和丢包检测时间戳32同一帧的多个包时间戳相同SSRC32标识一路流解析代码大概长这样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 还是 TCPGB28181 里设备推流可以用 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 流的整体结构认知PSProgram 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[i1] 0x00 data[i2] 0x01) { return i; } } return -1; }找到00 00 01后看第 4 个字节0xBAPS 包头跳过固定长度一般是 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时间戳用于同步PTSPresentation Time Stamp和 DTSDecoding 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[offset1] 0xFF) 22) | ((long)(data[offset2] 0xFE) 14) | ((long)(data[offset3] 0xFF) 7) | ((long)(data[offset4] 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 Listbyte[] splitNal(byte[] pesPayload) { Listbyte[] 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 位是编码 ID7AVC/H26412HEVC/H265。后面跟着 AVCPacketType0序列头1NALU和 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 区分、加入简单的转码能力比如统一分辨率或码率。不过这些都是后话把当前这条链路跑通、跑稳才是第一步。