ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Java实现GB28181:SIP服务核心组件架构与实战解析

Java实现GB28181:SIP服务核心组件架构与实战解析 做安防平台或者流媒体服务的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:10086example.com这种“号码域名”的形式国标里直接改成设备编码比如sip:340200000013200000013402000000。前面是设备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 为什么是JavaNetty而不是直接用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字段表示SSRCf字段表示媒体格式描述s字段可能不是标准的会话名而是固定字符串。用JAIN-SIP解析这些扩展字段不是不行只是每次都要从RawMessage里自己抠数据反而绕远路。我个人更倾向的方案是用Netty做传输自研一个轻量级的SIP解析器只覆盖国标需要的那些SIP方法、头域和扩展字段。这样做的好处有三个代码完全可控特殊设备返回的不规范报文可以随时加兼容逻辑。解析性能更好不需要为用不到的场景做无谓的分支处理。项目依赖更少部署时不会因为协议栈版本问题翻车。自研解析器的核心工作量集中在两个方向一是SIP起始行、头域、body的划分和映射二是XML body的解析和序列化。前者靠字符串解析加正则辅助后者直接使用成熟的XML解析库即可没必要重复造轮子。3. 注册流程从REGISTER到设备上线的完整实现3.1 接收并解析REGISTER请求先说最简单的部分Netty收到一个UDP报文后经过解码器变成SipMessage对象。REGISTER请求的起始行长这样REGISTER sip:3402000000192.168.1.10:5060 SIP/2.0 Via: SIP/2.0/UDP 192.168.1.64:5060;rport;branchz9hG4bK12345 From: sip:340200000013200000013402000000;tagabc To: sip:340200000013200000013402000000 Call-ID: 1234567890192.168.1.64 CSeq: 1 REGISTER Contact: sip:34020000001320000001192.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 realm3402000000, noncea1b2c3d4e5f6 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 version1.0 encodingGB2312? Notify CmdTypeKeepalive/CmdType SN214/SN DeviceID34020000001320000001/DeviceID StatusOK/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 OKbody携带设备侧的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国标格式大致如下v0 o34020000001320000001 0 0 IN IP4 192.168.1.64 sPlay u34020000002000000001:0 cIN IP4 192.168.1.64 t0 0 mvideo 6000 RTP/AVP 96 98 asendonly artpmap:96 PS/90000 artpmap:98 H264/90000 y0101234567 fv/2/5/25/1逐行拆开看o会话发起者信息里面的IP地址往往是设备用于媒体传输的IP但也可能是私有地址。s会话名国标里常见值是Play、Playback、Download。uURI格式通常为“平台设备编码:流ID”。c连接数据媒体流目的IP。平台点播时构造的INVITE里这个IP要填平台自己接收媒体流的地址。m媒体描述video 6000 RTP/AVP 96 98表示视频媒体端口是6000RTP/AVP表示RTP封装后面的96 98是动态负载类型。artpmap负载类型映射96对应PS流98对应H264。ySSRC信息这一行是国标扩展表示会话的同步源标识。f媒体格式描述包含分辨率、帧率、码率等参数。解析SDP时不要假设字段顺序固定。有的设备把y放在m之前有的放在最后有的设备没有u有的是f为空。所以要按行解析每一行独立处理而不是把整个SDP当结构化对象强绑定字段顺序。m行里有时候会出现多个媒体描述比如视频和音频同时存在但国标里绝大多数场景只有视频。你解析时可以把第一个mvideo当主媒体流如果找不到video再看mapplication这样可以兼容部分特殊设备。5.3 会话管理把SIP会话和媒体流状态串起来点播不是发一条INVITE就完事后续可能持续几分钟甚至几小时必须有一个会话状态机全程跟踪。我会维护一个SipSession对象重点属性包括callId整个会话的唯一标识。fromTag和toTagSIP标签信令续传和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-TypeINVITE里写application/sdp没问题但少数设备在MESSAGE里写application/xml还有的写text/xml。解析时统一按内容特征判断不要只信头域。最后再分享一点个人体会我做了几个国标接入项目之后最大的感受是SIP协议本身并不难难在兼容那些不严谨的厂商实现。同一个报文格式A厂设备按规范走B厂设备就会多带点私有头域C厂设备可能给你乱序排字段。因此SIP服务核心组件的目标不是“规范之上做标准实现”而是“在规范框架内兼容尽可能多的实现”。组件和代码层面我建议尽早把报文日志、会话状态可视化、设备状态变更事件三件事做好。这三件事看着不起眼但等设备量上来以后它们是你排查线上问题最趁手的工具。后续如果要扩展可以考虑在SIP服务之上再做一个协议适配层把海康、大华、宇视等差异化的设备行为收敛成统一接口这样平台业务层就不会被厂商差异绑架了。希望这篇拆解能帮你少踩几个坑做出一套真正抗造的SIP核心服务。
返回列表