做安防平台或者流媒体服务的Java开发者,大概率迟早会碰上GB28181这几个字。我第一次介入国标设备接入的时候,最先看到的是SIP、SDP、RTP这一堆缩略语,第一反应是又要处理音视频流了,结果发现真正绕人的反而是SIP信令。GB28181的设备注册、心跳、点播、回放,全部由SIP信令驱动,SIP服务核心组件做得好不好,直接决定整个平台能不能稳定接入几千上万个摄像头。
这篇文章不聊大而全的国标规范,就聚焦到“基于Java实现GB28181时,SIP服务侧的核心组件”这件事上。我会从SIP在国标里的角色讲起,再拆一个可落地的Java组件架构,然后逐个过注册、心跳、点播、会话管理这些关键流程,最后把我在实际项目中踩过的坑和排查方法一并整理出来。如果你是正在做国标平台、希望自研一个SIP信令网关的Java工程师,或者是准备面试时被问到GB28181接入原理的开发同学,这篇内容应该能省你不少自己翻文档和抓包的时间。
1. GB28181里的SIP,先搞清楚它是什么角色
1.1 国标SIP和传统VoIP SIP不是一回事
SIP全称Session Initiation Protocol,本身是一个用于建立、修改和终止多媒体会话的信令协议,传统场景是VoIP呼叫。但GB28181对SIP做了非常明显的“本土化改造”,它不是照搬RFC3261,而是在RFC基础上做了大量简化,同时又扩展了一些国标特有的字段和用法。
国标里的SIP主要用于两类事情:
- 设备与平台之间的状态维护:注册、注销、心跳。
- 媒体会话的控制:点播实时视频、回放录像、云台控制等。
最直观的差异在SIP URI上。传统VoIP的URI一般是sip:10086@example.com这种“号码+域名”的形式,国标里直接改成设备编码,比如sip:34020000001320000001@3402000000。前面是设备ID,后面是SIP服务器域。也就是说,国标设备在网络里不靠IP寻址,而是靠那一串20位数字编码寻址,SIP服务必须维护“设备编码到实际IP和端口”的映射关系。
还要注意传输层习惯。GB28181规范里默认SIP走UDP,端口一般是5060,但很多厂商设备支持TCP,也有的平台为了穿透防火墙希望叠加TCP承载方式。所以Java实现里,最稳的方案是UDP和TCP同时监听同一个端口,不要把自己焊死在一个传输协议上。另外国标SIP消息体里很多地方带着XML,这个和纯VoIP里SDP为主的交互风格不一样,后面解析时要特别小心编码格式。
1.2 一次完整的设备接入,SIP消息怎么流转
拿一个最典型的“摄像头接入平台”来看,SIP信令大概走这样一条链路:
- 设备启动后,主动向上级SIP服务器发送REGISTER注册请求。
- 服务器如果没有收到认证信息,返回401响应,并带上认证挑战参数。
- 设备重新携带Authorization字段再发一次REGISTER。
- 服务器校验通过后,返回200 OK,设备成功上线。
- 设备每隔30秒或60秒发送一条MESSAGE消息,内容为Keepalive,告诉平台“我还活着”。
- 用户在客户端点播这个设备,SIP服务主动向设备发INVITE请求,携带SDP信息,告诉设备把RTP流送到哪个IP和端口。
- 设备返回200 OK,携带设备的SDP响应信息。
- SIP服务回ACK,设备开始向指定端口推送RTP媒体流。
- 停止播放时,SIP服务或设备发送BYE请求,结束会话。
整条链路里,SIP服务要兼顾两种角色:面对设备时,它像“上级平台”;面对上层业务系统时,它又是一个可信的信令服务。这也是为什么很多Java项目里,会把SIP核心组件单独抽成服务,而不是直接写在业务工程里。
2. Java技术栈下,SIP核心组件怎么拆分
2.1 为什么是Java+Netty,而不是直接用C++栈
不少老牌安防厂商的国标平台是C/C++实现的,性能和底层socket控制确实有优势。但Java做这块也不会吃亏,尤其是团队大多数人只有Java经验时,硬上C++反而会拖慢项目节奏。GB28181的SIP信令不像媒体转发那样需要大规模拷贝数据,它本质上是控制面,消息量虽然可能很高,但单个消息很小,Java的NIO模型完全扛得住。
Netty是Java这边做SIP服务的事实标准底座。无论你最终选不选用某个现成的SIP协议栈,Netty这一层基本逃不掉。它天然支持UDP和TCP,提供了统一的ChannelPipeline事件模型,处理粘包、拆包、断线重连、空闲检测都有成熟方案。你只需要关注业务逻辑,不需要自己管理socket生命周期。
这里也顺带回应一个常见疑问:为什么不用Spring Boot自带的Servlet容器处理SIP?因为Servlet容器是为HTTP设计的,走的是请求-响应模式,而真正底层的UDP报文接收还是得靠Netty或者更底层的Java NIO。硬要把SIP塞进HTTP框架里,后面处理双向异步消息、多路复用时会非常别扭。
2.2 核心组件清单:每种组件负责什么
我把一个完整的SIP服务核心组件拆成下面几块,每一项都可以单独演进和替换:
| 组件层 | 职责 | 常见选型 |
|---|---|---|
| 传输层 | 监听UDP/TCP端口,接收和发送SIP报文 | Netty |
| 协议解析层 | 将字节流解析成SIP消息对象,或把对象序列化成字节流 | 自研Parser / JAIN-SIP |
| 业务处理层 | 注册、心跳、呼叫、云台等业务逻辑 | 自研业务Service |
| 会话管理层 | 维护SIP会话与媒体会话的状态 | 自研状态机 |
| 定时任务层 | 心跳超时检查、信令重发、会话过期清理 | ScheduledExecutorService |
| 设备信息存储 | 设备编码、IP、端口、认证信息、在线状态 | ConcurrentHashMap / Redis |
很多人一开始会忽略“定时任务层”。实际用起来,这一层的重要性不亚于协议解析。SIP信令基于UDP时存在消息丢失问题,REGISTER请求丢了、INVITE响应丢了,都需要有一个可靠的重发和清理机制。所以定时任务组件必须有,且要设计成一个独立的调度器,避免和业务线程互相干扰。
2.3 JAIN-SIP还是自研解析?我的选择
这个问题几乎每个做Java国标的人都会纠结。JAIN-SIP是Java社区的标准SIP协议栈,支持完整的RFC3261,文档和社区资料相对丰富。但坏消息是,国标设备和标准SIP有差异,尤其INVITE消息的SDP里带了大量国标扩展字段,比如y字段表示SSRC,f字段表示媒体格式描述,s字段可能不是标准的会话名而是固定字符串。用JAIN-SIP解析这些扩展字段不是不行,只是每次都要从RawMessage里自己抠数据,反而绕远路。
我个人更倾向的方案是:用Netty做传输,自研一个轻量级的SIP解析器,只覆盖国标需要的那些SIP方法、头域和扩展字段。这样做的好处有三个:
- 代码完全可控,特殊设备返回的不规范报文可以随时加兼容逻辑。
- 解析性能更好,不需要为用不到的场景做无谓的分支处理。
- 项目依赖更少,部署时不会因为协议栈版本问题翻车。
自研解析器的核心工作量集中在两个方向:一是SIP起始行、头域、body的划分和映射,二是XML body的解析和序列化。前者靠字符串解析加正则辅助,后者直接使用成熟的XML解析库即可,没必要重复造轮子。
3. 注册流程:从REGISTER到设备上线的完整实现
3.1 接收并解析REGISTER请求
先说最简单的部分,Netty收到一个UDP报文后,经过解码器变成SipMessage对象。REGISTER请求的起始行长这样:
REGISTER sip:3402000000@192.168.1.10:5060 SIP/2.0 Via: SIP/2.0/UDP 192.168.1.64:5060;rport;branch=z9hG4bK12345 From: <sip:34020000001320000001@3402000000>;tag=abc To: <sip:34020000001320000001@3402000000> Call-ID: 1234567890@192.168.1.64 CSeq: 1 REGISTER Contact: <sip:34020000001320000001@192.168.1.64:5060> Expires: 3600 Content-Length: 0处理注册消息时,业务逻辑大致分为三步:
- 从From或To头里提取设备编码。
- 判断是否携带Authorization字段,如果没有,返回401挑战。
- 如果携带了,做摘要鉴权,通过后记录设备地址和端口,返回200 OK。
这里有一个很容易犯的错:有些设备在注册时并不会每次都带Contact头,或者Contact头里的IP地址与实际源地址不一致。严谨的做法是用“源IP:源端口”作为设备当前网络地址,Contact头只作为参考。否则设备在NAT后面时,你按Contact里的私网IP回消息,永远到不了设备。
3.2 摘要鉴权:nonce与response怎么算才不被设备卡住
GB28181的注册鉴权基本沿用HTTP Digest摘要认证的思路。服务端收到无凭证REGISTER时,返回401响应,并在WWW-Authenticate头里带上认证域和随机数:
SIP/2.0 401 Unauthorized Via: 原样返回 From: 原样返回 To: 原样返回 Call-ID: 原样返回 CSeq: 原样返回 WWW-Authenticate: Digest realm="3402000000", nonce="a1b2c3d4e5f6" Content-Length: 0设备收到401后,会重新提交带Authorization的REGISTER。服务端需要从Authorization头里取出username、realm、nonce、uri、response等字段,然后按摘要算法重新计算期望的response值,比对两者是否一致。
核心计算逻辑是这样的:
private boolean checkDigest(String username, String password, String method, String uri, String nonce, String response) { String ha1 = md5(username + ":" + realm + ":" + password); String ha2 = md5(method + ":" + uri); String expected = md5(ha1 + ":" + nonce + ":" + ha2); return expected.equalsIgnoreCase(response); }需要注意几个细节:
- 密码不是设备编码本身,而是设备在平台侧配置的独立密码。
- nonce不能一成不变,也不能每次请求都变。我通常用“随机字符串+时间戳”生成,并保存到一个本地缓存里,有效期5分钟。同一个nonce在校验期间保持不变,避免设备第一次带认证信息请求时,因为nonce过期而被误判。
- Authorization头里的uri字段,很多设备会带上自己的设备编码,有的带完整SIP URI。校验时最好从Authorization里原样取uri,不要自己拼接。
- 部分老设备对MD5摘要支持不严格,会少带一些参数,比如没有uri。兼容做法是uri允许为空或使用REGISTER请求行里的URI兜底。
3.3 设备信息表:注册之后怎么把设备记住
注册成功后,需要把设备关键信息保存下来,最基础的数据结构是这样的:
public class DeviceRegistry { private String deviceId; private String ip; private int port; private int transport; // UDP or TCP private String contact; private long registerTime; private long lastKeepAliveTime; private long expireTime; private boolean online; }在单机部署时,用ConcurrentHashMap维护deviceId -> DeviceRegistry的映射就够了。只有部署多个实例时,才需要把这个表放到Redis或分布式缓存里,同时用一个消息通知机制让其他实例感知设备状态变化。
注册响应里的Expires头表示注册有效期,常见值是3600秒。设备不会等快过期时才续传,而是由设备自身决定续传时机。SIP服务侧要做的就是:在注册表中刷新这个过期时间,同时在定时任务里扫描,发现已经超过有效期的设备,主动把它标记为离线。
我习惯在注册成功返回200 OK时,顺带在响应里回显设备的Call-ID、CSeq、Via,这样做能减少很大一部分设备兼容问题。很多设备对响应报文的要求比较死板,Via头不原样返回就可能认为信令异常。
4. 心跳与在线状态检测:让“在线”两个字变得可信
4.1 Keepalive消息长什么样
设备上线之后,SIP服务最常收到的消息就是心跳。国标心跳用MESSAGE方法,body是一段XML。一个典型的心跳报文body如下:
<?xml version="1.0" encoding="GB2312"?> <Notify> <CmdType>Keepalive</CmdType> <SN>214</SN> <DeviceID>34020000001320000001</DeviceID> <Status>OK</Status> </Notify>解析的重点是CmdType和DeviceID。有些厂商设备会在心跳里额外携带一些自定义节点,比如在线用户数、报警状态等,解析时不要因为多节点就报错,尽量做成“取用到的字段,忽略无关字段”的宽松模式。
处理逻辑也不复杂,收到Keepalive后做两件事:更新设备注册表里的lastKeepAliveTime;返回200 OK。如果设备已经处于离线状态,收到心跳后应该自动恢复为在线,很多场景下设备网络恢复后并不会重新发起注册,而是靠心跳让平台恢复状态。
还需要注意编码问题。XML声明的charset是GB2312,但实际很多设备发来的是UTF-8,甚至有的设备声明GB2312却发GBK。稳妥做法是解析XML时不要信任声明编码,而是先尝试按UTF-8解码,失败后再按GBK处理,或者干脆用流式方式读取后手动指定解析编码。
4.2 超时离线机制与误判规避
纯UDP场景下,不能依赖连接断开来判定设备离线,只能靠心跳超时。常规设计是每隔一个固定周期扫描一次设备注册表,把所有最后心跳时间距离当前时间超过阈值的设备标记为离线。
心跳超时阈值怎么定,一般取设备心跳间隔的3倍。如果设备30秒心跳一次,阈值设成90秒;60秒心跳一次,阈值设成180秒。不要统一拍脑袋设一个30秒,否则你会看到许多“设备频繁上下线”的假告警。
具体实现可以用ScheduledExecutorService,每10秒扫一次表:
private void scanOfflineDevices() { long now = System.currentTimeMillis(); long timeout = 3 * 60 * 1000L; // 默认180秒 for (String deviceId : registry.keySet()) { DeviceRegistry device = registry.get(deviceId); if (device != null && device.isOnline() && now - device.getLastKeepAliveTime() > timeout) { device.setOnline(false); notifyDeviceOffline(deviceId); } } }这里要提醒一句:不要只靠Netty的IdleStateHandler来判断UDP设备离线。IdleStateHandler在UDP模式下,检测到的是Channel长时间没有读写事件,但UDP是无连接的,这个空闲状态并不能等价于对端设备真实离线。UDP通道是复用的,有A设备发心跳,不代表B设备也活着。所以还是老老实实维护每台设备的业务级心跳表最靠谱。
5. 点播/回放INVITE会话处理与SDP协商细节
5.1 INVITE点播消息的完整序列
当用户在前端点播某台设备时,业务系统调用SIP服务,SIP服务作为主叫方向设备发INVITE请求。这时整个信令序列是:
- 平台侧收到业务请求,组装SDP(媒体接收地址、端口、SSRC、媒体格式)。
- SIP服务向设备发送INVITE请求,body携带SDP。
- 设备收到后先回100 Trying,表示已收到并处理中。
- 设备准备好媒体通道后,回复200 OK,body携带设备侧的SDP。
- SIP服务处理200 OK,解析设备SDP中的媒体IP、端口、编码信息,然后回复ACK。
- 设备收到ACK后,开始向媒体地址推送RTP流。
这个序列里最容易出错的是ACK。INVITE是一个三次握手过程,设备返回200 OK后,如果SIP服务不回ACK,设备一定不会推流。很多“点播失败”的问题,最后抓包发现都是ACK丢失或者ACK里的To标签与200 OK不一致。
ACK请求不需要再构造完整的SDP,但必须携带正确的Call-ID、From标签、To标签和CSeq。To标签必须从200 OK响应中取,很多设备的实现严格校验这个字段。
BYE的序列类似。平台主动停止点播时发起BYE,带当前会话的Call-ID和标签信息;设备主动断开时会发BYE过来,SIP服务收到后清理会话并返回200 OK。
5.2 SDP解析,要抓哪些关键字段
设备返回200 OK时携带的SDP国标格式大致如下:
v=0 o=34020000001320000001 0 0 IN IP4 192.168.1.64 s=Play u=34020000002000000001:0 c=IN IP4 192.168.1.64 t=0 0 m=video 6000 RTP/AVP 96 98 a=sendonly a=rtpmap:96 PS/90000 a=rtpmap:98 H264/90000 y=0101234567 f=v/2/5/25/1逐行拆开看:
o:会话发起者信息,里面的IP地址往往是设备用于媒体传输的IP,但也可能是私有地址。s:会话名,国标里常见值是Play、Playback、Download。u:URI,格式通常为“平台设备编码:流ID”。c:连接数据,媒体流目的IP。平台点播时构造的INVITE里,这个IP要填平台自己接收媒体流的地址。m:媒体描述,video 6000 RTP/AVP 96 98表示视频媒体,端口是6000,RTP/AVP表示RTP封装,后面的96 98是动态负载类型。a=rtpmap:负载类型映射,96对应PS流,98对应H264。y:SSRC信息,这一行是国标扩展,表示会话的同步源标识。f:媒体格式描述,包含分辨率、帧率、码率等参数。
解析SDP时不要假设字段顺序固定。有的设备把y放在m之前,有的放在最后;有的设备没有u,有的是f为空。所以要按行解析,每一行独立处理,而不是把整个SDP当结构化对象强绑定字段顺序。
m行里有时候会出现多个媒体描述,比如视频和音频同时存在,但国标里绝大多数场景只有视频。你解析时可以把第一个m=video当主媒体流,如果找不到video,再看m=application,这样可以兼容部分特殊设备。
5.3 会话管理:把SIP会话和媒体流状态串起来
点播不是发一条INVITE就完事,后续可能持续几分钟甚至几小时,必须有一个会话状态机全程跟踪。我会维护一个SipSession对象,重点属性包括:
- callId:整个会话的唯一标识。
- fromTag和toTag:SIP标签,信令续传和BYE时都要用。
- deviceId:被点播设备编码。
- ssrc:从SDP的y字段解析出来,后续RTP收流和拉流需要。
- mediaIp和mediaPort:设备实际推流的地址和端口。
- status:各种状态,比如INVITING、CONFIRMED、CLOSING。
- createTime和lastActiveTime:用于会话超时清理。
会话状态流转的核心规则:
- 发出INVITE后,状态置为
INVITING,同时启动一个定时器,比如5秒没收到100或200就重发INVITE,最多重发2次,超过后标记失败。 - 收到200 OK后,先回复ACK,再置为
CONFIRMED,并通知媒体模块更新SSRC信息。 - 收到BYE或CANCEL,置为
CLOSING,清理RTP接收器,最后移除会话。 - 如果媒体流模块在约定时间内没有收到RTP包,可以选择主动发BYE中断会话。
从工程角度说,会话管理最好单独维护一张callId -> SipSession的Map,不要和设备的注册表混在一起。设备表服务生命周期,会话表服务单次媒体业务,两者分开才能各自演进。
6. 线程模型与组件协同设计
6.1 Netty线程模型与业务线程池的边界
SIP服务收到信令后,如果直接在Netty的EventLoop线程里做数据库查询、加密、解析XML等耗时操作,会阻塞整个NIO线程,导致其他设备的信令排队等待。正确做法是在解码器拿到完整SipMessage后,通过自定义的业务线程池异步执行后续逻辑。
业务线程池建议使用ThreadPoolExecutor,核心线程数和最大线程数根据设备规模设定。比如规划接入1万台设备,日常注册和心跳并发不高,核心线程数16、最大线程数32、有界队列1000就够用。拒绝策略不要用AbortPolicy,否则高峰时直接抛异常丢消息;用CallerRunsPolicy并在调用方做保护,或者记录丢弃消息的日志便于事后排查。
还要注意信令的顺序性。同一个设备的多个SIP消息,最好保证处理顺序,尤其是CSeq递增的请求。简单做法是通过设备ID哈希到固定线程处理,这样同一台设备的信令天然有序,又能避免不同设备互相阻塞。实测下来,这个设计能减少很多“设备不响应序号靠后请求”的问题。
6.2 日志与抓包:SIP调试的救命稻草
SIP交互出问题时,第一件事永远是看完整报文。我强烈建议在开发环境把收到的每条SIP原始报文完整打印出来,包括起始行、所有头域和body。到了生产环境,可以通过配置开关把SIP报文级别调整成只打印关键信令摘要。
日志至少要覆盖这些节点:
- REGISTER收到和响应状态。
- 401返回的nonce信息。
- 鉴权通过或失败。
- Keepalive收到及设备状态变化。
- INVITE发出、收到响应、ACK发出。
- BYE收到及会话清理。
除日志外,Wireshark抓包是更底层的排障手段。抓包时过滤条件用sip和rtp,看报文交互顺序和内容。比看日志更准的一点是,抓包能看到真实网络层的报文,有些问题在自己的日志里永远看不出来,比如NAT改写了端口、防火墙丢弃了包。
6.3 性能与稳定性
单机SIP服务在Java技术栈下,用Netty接收信令、用线程池处理逻辑,每天处理百万级信令压力不算大。真正的稳定性隐患往往来自资源泄漏和状态不一致,而不是并发量。
我踩过的典型坑包括:
- 会话表只增不删,长时间运行后内存持续上涨。解决方法是定时清理
CONFIRMED状态但长期没有RTP流更新的会话。 - 定时任务里做了耗时操作,导致扫描线程堆积。扫描线程里只做判断和状态切换,真正需要通知外部的操作异步发送。
- 设备下线后,它的注册表数据没及时清理,导致内存里堆积大量离线设备。建议设备离线超过一天后,把注册信息降级到一个单独的缓存区域。
- 重发机制和定时清理相互打架。比如设备已经连续3次发INVITE没有响应,重发线程还在继续重发,而会话清理线程又把这个会话删了,导致重复创建多个会话。统一的做法是以
callId加一个全局去重,发现已存在会话时不重复创建。
7. 常见问题与排查技巧实录
7.1 注册失败,一直401或者没有任何响应
先抓包确认设备是否真的发来了REGISTER。如果网卡上根本没包,优先查网络连通性,比如平台SIP端口是否开放、防火墙是否拦截UDP 5060端口。如果设备发了但是一直401,判断优先级是:
- 密码是否错误。去设备管理页面重新确认密码。
- nonce是否过期或格式不对。换个不依赖时间的随机nonce试试。
- 是否大小写问题。有些设备把response转成大写,你校验时用
equalsIgnoreCase就能解决。 - 设备是否在注册前要求先获取时间同步。少数设备会先请求NTP,时间不对时拒绝注册,这个容易被忽略。
7.2 点播后没有视频流
这是最常见的故障。排查思路分两条线:
信令线:用抓包看INVITE请求是否发出、设备是否返回200、你回了ACK没有、ACK里的标签是否正确。很多情况下,设备返回200 OK后要隔几秒才推流,SIP服务如果过早超时释放会话,也会导致没流。
媒体线:用抓包看RTP包是否到达平台媒体服务器。如果RTP到了但画面黑屏,那是媒体格式不匹配或PS流解复用问题;如果RTP压根没到,重点查SDP里的c和m行端口是否可路由、防火墙是否阻挡媒体端口、SSRC是否被媒体模块正确识别。
7.3 设备频繁上下线
原因通常有两种。一是心跳阈值设得太紧,网络轻微抖动丢一个心跳包就判定离线。二是NAT超时时间短于心跳周期,设备经过NAT后映射关系被清理,平台回的心跳响应到不了设备,设备认为平台失联而主动重新注册。
处理建议:
- 心跳超时阈值放宽到设备心跳周期的3到5倍。
- 设备本身无法调整心跳周期时,平台侧适当忽略偶发的短暂离线。
- 条件允许时,让设备改为TCP注册,TCP连接比UDP的NAT映射更稳定。
7.4 设备400/500响应频繁
很多是解析问题。设备发来的SIP报文里如果带了未知头域,或者body不是合法XML,会导致解析报错。自研解析器务必做成一“在头部解析时遇到无法识别的头域,跳过而不是报错;在body解析时忽略未知XML节点”的宽容模式。协议栈的目标是尽量把报文“接住”,而不是把报文“判错”。
有些设备还会混淆Content-Type,INVITE里写application/sdp没问题,但少数设备在MESSAGE里写application/xml,还有的写text/xml。解析时统一按内容特征判断,不要只信头域。
最后再分享一点个人体会
我做了几个国标接入项目之后最大的感受是:SIP协议本身并不难,难在兼容那些不严谨的厂商实现。同一个报文格式,A厂设备按规范走,B厂设备就会多带点私有头域,C厂设备可能给你乱序排字段。因此SIP服务核心组件的目标不是“规范之上做标准实现”,而是“在规范框架内兼容尽可能多的实现”。
组件和代码层面,我建议尽早把报文日志、会话状态可视化、设备状态变更事件三件事做好。这三件事看着不起眼,但等设备量上来以后,它们是你排查线上问题最趁手的工具。后续如果要扩展,可以考虑在SIP服务之上再做一个协议适配层,把海康、大华、宇视等差异化的设备行为收敛成统一接口,这样平台业务层就不会被厂商差异绑架了。希望这篇拆解能帮你少踩几个坑,做出一套真正抗造的SIP核心服务。