YAOTU INSIGHTS

SpringBoot整合海康SDK视频接入:PS流转MP4实战与踩坑记录

SpringBoot整合海康SDK视频接入:PS流转MP4实战与踩坑记录
SpringBoot整合海康SDK做视频接入这块我在生产环境里摸爬滚打过好几轮踩过的坑比写过的代码还多。从最开始拿着官方demo在Windows上调试到后来部署到Linux服务器上发现一堆动态库问题再到PS流转MP4时被时间戳折磨得欲仙欲死这一整套流程走下来确实有挺多值得记录的东西。这篇文章我就把完整的整合过程、关键代码、以及那些官方文档里压根不会告诉你的坑一次性讲清楚。1. 项目背景与整体方案选型先说下这个项目的实际需求。当时要做的是一个视频巡检平台需要对接园区里的几十台海康威视摄像头核心功能就两个实时预览和海量录像文件的存储管理。海康设备输出的原始码流是PS流格式Program Stream节目流这种格式适合网络传输和实时预览但不能直接拿来存储和播放必须转封装成MP4或者其他通用格式。为什么需要转MP4而不是直接用海康的录像文件原因很简单海康的录像文件有自己的私有格式播放端必须装插件才能看。在Web端和移动端场景下MP4是最通用的格式浏览器直接支持播放移动端也不需要额外装任何东西。所以整个技术链路就是SpringBoot服务调用海康SDK连接设备获取PS流数据再通过转封装生成MP4文件。在方案选型上我对比过两条技术路线第一条是用FFmpeg的命令行工具做转封装把PS流转成MP4简单粗暴但问题是要维护一个外部进程而且如果摄像头路数多进程管理会非常痛苦。第二条是纯Java方案直接用海康SDK的取流回调拿到PS流数据在Java层解析、转封装成MP4虽然编码量多一些但完全内嵌在SpringBoot应用里没有外部依赖后面做集群扩展也方便。最终选了第二条路线。原因有三一是生产环境的服务器不一定方便安装FFmpeg二是纯Java方案方便做水平扩展三是转封装逻辑可以做成一个独立的组件后面如果有需求可以复用到别的项目里。再说说SDK版本的选择。海康的设备网络SDK从5.3.x版本开始就分开提供Linux和Windows两套库文件我这里用的是V5.3.6.35版本。这个版本的接口定义相对稳定网上资料也比较多遇到问题好排查。新版本虽然有一些新特性但接口变动大踩坑成本高生产环境我一般倾向于用经过验证的稳定版本。2. 环境准备与SDK接入的细节坑2.1 依赖引入和库文件放置SDK接入的第一步是把海康官方提供的Java库包引入到项目里。海康的SDK包跟普通Maven依赖不一样它不是一个标准件需要手动把jar包和动态库文件一起处理好。官方的demo工程结构大致是这样的HCNetSDK ├── lib │ ├── jna-4.4.0.jarJNA依赖 │ ├── examples │ └── HCNetSDK.java └── lib库文件 ├── hc_sdk.dllWindows动态库 ├── hcnet.dll ├── HCPreview.dll ├── libhcnetsdk.soLinux动态库 └── ...这个HCNetSDK.java就是把海康的C语言头文件翻译成JNA接口定义是整个SDK的Java层入口。官方demo里提供了完整的文件直接拿过来用就好不要自己去改接口定义SDK数据结构里的字段顺序和内存布局是严格对齐的改错了会导致内存越界甚至是JVM崩溃。在SpringBoot工程里我把jar文件放到了项目的lib目录下然后在pom.xml里用system scope引入dependency groupIdcom.hikvision/groupId artifactIdhcNetSDK/artifactId version5.3.6.35/version scopesystem/scope systemPath${project.basedir}/lib/HCNetSDK.jar/systemPath /dependency同时把JNA的依赖也要加上海康SDK是依赖JNA做动态库调用的dependency groupIdnet.java.dev.jna/groupId artifactIdjna/artifactId version4.4.0/version /dependency动态库文件的位置是个容易出问题的点。官方demo是直接把dll或者so文件放在工程根目录或者jdk的bin目录下的但SpringBoot打成jar包之后这个问题就复杂了。我最终的方案是把so文件放在服务器指定目录比如/opt/hikvision/lib/启动脚本里手动指定java.library.path或者直接在代码里加载时指定绝对路径System.setProperty(jna.library.path, /opt/hikvision/lib);注意Linux服务器上必须用root权限执行chmod 755给so文件赋予执行权限否则JNA会报“Unable to load library”之类的错误。另外还需要安装海康SDK依赖的系统库比如libstdc在CentOS上可以用yum install libstdc解决。2.2 HCNetSDK接口初始化的正确姿势SDK初始化是整个链路的第一步代码逻辑不复杂但有几个隐藏的坑。先看初始化代码public class HikvisionClient { private HCNetSDK hcNetSDK; private boolean initialized false; public synchronized void init() { if (initialized) { return; } hcNetSDK HCNetSDK.INSTANCE; // SDK初始化 boolean initResult hcNetSDK.NET_DVR_Init(); if (!initResult) { int errorCode hcNetSDK.NET_DVR_GetLastError(); throw new RuntimeException(SDK初始化失败错误码 errorCode); } // 设置连接超时时间和尝试次数 hcNetSDK.NET_DVR_SetConnectTime(2000, 1); hcNetSDK.NET_DVR_SetReconnect(10000, true); // 设置日志记录这个很重要排查问题全靠它 HCNetSDK.NET_DVR_SetLogToFile(3, /opt/hikvision/logs, true); initialized true; } public synchronized void cleanup() { if (initialized) { hcNetSDK.NET_DVR_Cleanup(); initialized false; } } }初始化这步有几个细节值得注意NET_DVR_Init()和NET_DVR_Cleanup()是成对出现的整个应用生命周期里只需要调用一次。如果每次请求都重新初始化经过一段时间后会因为句柄泄漏导致资源耗尽。NET_DVR_SetLogToFile我建议一定要开启SDK内部的错误日志会写到指定目录很多棘手问题靠这个日志才能定位。日志级别设置成3ERROR级别就好级别太高日志量太大会拖垮性能。NET_DVR_SetReconnect(10000, true)是设置断线重连这个参数在生产环境必须开启。摄像头或者网络抖动时SDK会自动重连不用业务层去处理异常。这里我再多说一句SDK初始化的线程安全性。海康SDK的JNA接口底层是C语言实现的很多方法并不是线程安全的尤其多个摄像头并发取流时我建议在连接设备、开始预览这些关键操作上加锁保护或者通过一个调度线程统一发起操作避免并发冲突导致JVM崩溃。2.3 JNA接口映射的几个深层坑HCNetSDK.java这个文件虽然是从官方拿来的但实际使用中还是会碰到几个问题这里提前说清楚。第一个是版本兼容问题。HCNetSDK.java接口定义跟dll/so文件必须严格对应。比如某些结构体字段增减会导致内存布局变化表现为调用某些接口时返回值是-1但GetLastError返回的却是0。如果遇到这种诡异的现象第一反应应该是SDK jar包和动态库版本不一致去官网下载配套的版本替换。第二个是回调函数的问题。海康SDK的实时取流是通过回调函数把数据推给应用的JNA里需要自定义一个Callback接口比如public interface RealDataCallback extends StdCallCallback { void invoke(int lRealHandle, int dwDataType, ByteByReference pBuffer, int dwBufSize, Pointer pUser); }回调函数是在SDK的内部线程里执行的线程模型是C层直接回调Java层所以回调里不能做任何耗时操作尤其不能做阻塞的IO操作。我一开始就是直接在回调里写文件结果摄像机码率一高4K的摄像头码率能到8Mbps以上SDK的线程被阻塞丢包严重视频画面一顿一顿的后面痛定思痛改成回调里只把数据放到内存队列或者环形缓冲区里由专门的处理线程去消费问题才解决。第三个是内存管理。JNA的Callback返回时Java侧不能持有pBuffer指向的内存地址因为这块内存是在C层申请的回调结束后可能被释放。正确做法是在回调里立刻拷贝数据byte[] bufferData new byte[dwBufSize]; pBuffer.read(0, bufferData, 0, dwBufSize);3. 设备接入与实时取流3.1 设备登录和用户参数配置设备登录是取流之前必须完成的一步SDK里主要负责的是NET_DVR_Login_V40。这个接口传入设备的IP、端口、用户名、密码等信息返回一个设备句柄后续的取流、抓图、云台控制等操作都依赖这个句柄。public Integer login(String ip, int port, String username, String password) { HCNetSDK.NET_DVR_USER_LOGIN_INFO loginInfo new HCNetSDK.NET_DVR_USER_LOGIN_INFO(); loginInfo.wPort (short) port; loginInfo.sDeviceAddress new byte[HCNetSDK.NET_DVR_DEV_ADDRESS_MAX_LEN]; System.arraycopy(ip.getBytes(), 0, loginInfo.sDeviceAddress, 0, ip.getBytes().length); loginInfo.sUserName new byte[HCNetSDK.NET_DVR_LOGIN_USERNAME_MAX_LEN]; System.arraycopy(username.getBytes(), 0, loginInfo.sUserName, 0, username.getBytes().length); loginInfo.sPassword new byte[HCNetSDK.NET_DVR_LOGIN_PASSWORD_MAX_LEN]; System.arraycopy(password.getBytes(), 0, loginInfo.sPassword, 0, password.getBytes().length); HCNetSDK.NET_DVR_DEVICEINFO_V40 deviceInfo new HCNetSDK.NET_DVR_DEVICEINFO_V40(); int userId hcNetSDK.NET_DVR_Login_V40(loginInfo, deviceInfo); if (userId -1) { int errorCode hcNetSDK.NET_DVR_GetLastError(); throw new RuntimeException(登录设备失败错误码 errorCode); } // 缓存设备能力集信息 this.userIds.add(userId); return userId; }这里有个容易踩的坑loginInfo里还有很多其他字段比如bUseTransport、byLoginMode、cbLoginResult等如果不置空默认值可能是未知的随机值。官方demo里是用this.loginInfo.write()方法一次性写入内存Java侧如果不熟悉JNA的结构体映射建议在new出来之后先调用loginInfo.write()然后再给字段赋值避免某些字段还保留着上次使用的脏数据。登录成功之后拿到的userId是后续所有操作的凭证需要在应用里统一管理。我这边用了一个ConcurrentHashMap来维护摄像头设备序列号跟userId的对应关系方便后面断线重连时恢复会话。登录失败的错误码这块我总结过几个常见的新手排查能省不少时间错误码含义处理建议7用户名或密码错误检查设备本地账户配置17设备不存在或IP不可达检查网络、IP是否拼写错误23端口号设置错误确认设备端口默认800029设备通道号错误确认通道范围球机通常从1开始71请求过于频繁设置认证失败锁定时间3.2 实时取流与码流回调处理登录成功后开始预览的关键接口是NET_DVR_RealPlay_V40。这里有几个前置参数需要设置好一个是预览参数结构体一个是码流回调函数。实时的码流数据就是在回调里主动推给我们的回调的数据类型由dwDataType参数区分。对PS流录像来说我们关心的是NET_DVR_SYSHEAD系统头就是SPS/PPS那一坨数据和NET_DVR_STREAMDATA视频数据。public void startRealPlay(int userId, int channel, RealDataCallback callback) { HCNetSDK.NET_DVR_PREVIEWINFO previewInfo new HCNetSDK.NET_DVR_PREVIEWINFO(); previewInfo.lChannel channel; previewInfo.dwStreamType 0; // 主码流 previewInfo.dwLinkMode 0; // TCP取流 int realHandle hcNetSDK.NET_DVR_RealPlay_V40(userId, previewInfo, callback, null); if (realHandle -1) { int errorCode hcNetSDK.NET_DVR_GetLastError(); throw new RuntimeException(启动实时预览失败错误码 errorCode); } this.realHandles.add(realHandle); }关于码流类型的选择主码流0和子码流1的区别不仅仅是分辨率还直接影响录像文件的清晰度和体积。如果录像只是用作存储备查子码流基本够用文件大小能小好几倍。但如果要用于AI分析或者细节取证必须用主码流。我在生产里把主码流和子码流的选择做成了配置项按实际场景灵活调整。dwLinkMode这个参数也值得注意。TCP方式取流0适合跨网段或者网络不稳定的场景UDP方式1延迟低但容易丢包。实时预览用UDP效果更好但录像建议用TCP稳定性优先。重点说下回调处理这部分的性能优化。我经历过一次比较严重的线上事故摄像头数量一多回调线程成了瓶颈数据积压导致内存飙升。后面把方案改成了“回调Disruptor/环形队列消费线程”的架构// 回调里只做一件事把数据放入队列 public void invoke(int lRealHandle, int dwDataType, ByteByReference pBuffer, int dwBufSize, Pointer pUser) { if (dwDataType HCNetSDK.NET_DVR_SYSHEAD) { byte[] headData new byte[dwBufSize]; pBuffer.read(0, headData, 0, dwBufSize); // 这里保存系统头用于后续转MP4时写入SPS/PPS sysHeaderQueue.offer(headData); } else if (dwDataType HCNetSDK.NET_DVR_STREAMDATA) { byte[] streamData new byte[dwBufSize]; pBuffer.read(0, streamData, 0, dwBufSize); dataQueue.offer(streamData); } }这里要注意队列的容量不能无限大否则内存迟早被打满。我在生产环境里把这个环形队列的容量配成4096个槽位每个槽位默认存储64KB的数据块这样单路视频的内存占用被限制在256MB以内。一旦队列满了策略是丢弃最老的数据保活新数据避免录像时间轴出现明显的空洞。4. PS流解析与MP4转封装实现4.1 PS流格式解析的核心原理PS流全称是Program Stream是MPEG-2标准里定义的一种复用流格式。它把一路或多路基本流比如视频的H.264流、音频的AAC流按照一定的语法规则打包到一起形成了适合存储或传输的节目流。PS流的基本结构是PS包Program Stream Pack每个PS包的包头有一个固定的起始码00 00 01 BA。PS包内部可以包含多个PES包Packetized Elementary Stream每个PES包以起始码00 00 01 E0视频或00 00 01 C0音频开头。PES包里承载的就是实际的编码数据比如H.264的NALU数据。海康摄像头输出的PS流其内部实际封装的是H.264编码的视频数据可能还有音频数据取决于摄像头配置。我们要做的转封装其实就是把PS流里的H.264 NALU数据提取出来重新按照MP4的格式排列组织。这里稍微说一下PS流和TS流的关系很多人会把它们搞混。TSTransport Stream是面向广播和流媒体传输的包长固定188字节PSProgram Stream是面向存储和本地播放的包长可变。海康设备的网络SDK回调给出来的原生码流主要是PS流而RTSP协议取流通常给出的是RTP封装的打包数据这两种格式的处理逻辑不一样代码不能复用。4.2 PS流解析的代码实现PS流解析的完整代码量比较大我挑核心的解析逻辑出来讲。整个过程的输入是回调得到的原始码流输出是H.264的NALU数据列表或者直接写入文件。public class PsStreamParser { // PS流起始码 private static final byte[] PACK_START_CODE new byte[]{0x00, 0x00, 0x01, (byte) 0xBA}; // PES视频起始码 private static final byte[] PES_VIDEO_START_CODE new byte[]{0x00, 0x00, 0x01, (byte) 0xE0}; public Listbyte[] parse(byte[] psData) { Listbyte[] h264Frames new ArrayList(); int offset 0; while (offset psData.length) { // 查找PS包起始码 int packStart findPattern(psData, offset, psData.length, PACK_START_CODE); if (packStart -1) { break; } // PS包头的长度解析起始码(4字节) 2字节的MPEG-2头 3字节的系统头... // 这里简单处理直接找到下一个PS包起始码作为包边界 int nextPackStart findPattern(psData, packStart 4, psData.length, PACK_START_CODE); if (nextPackStart -1) { nextPackStart psData.length; } // 在PS包内部查找视频PES包 int pesStart findPattern(psData, packStart, nextPackStart, PES_VIDEO_START_CODE); if (pesStart ! -1) { // 解析PES头 // PES头长度字段在指向pesStart8的位置 int headerDataLength (psData[pesStart 8] 0xFF); int videoDataStart pesStart 9 headerDataLength; int videoDataEnd nextPackStart; if (videoDataEnd videoDataStart) { byte[] h264Data Arrays.copyOfRange(psData, videoDataStart, videoDataEnd); h264Frames.add(h264Data); } } offset nextPackStart; } return h264Frames; } }这个解析逻辑有个需要注意的地方海康设备输出的PS流里PES头之后的数据并不完全等于一个完整的H.264 NALU单元可能包含多个NALU也可能一个NALU被切分到多个PS包里。如果直接拿解析出来的数据去封装MP4会导致视频花屏或者无法播放。所以严谨的做法是把解析出来的数据继续做H.264的NALU分帧处理以00 00 00 01或00 00 01为分隔符把NALU切分出来然后完整地写入MP4。我在这块的处理是维护了一个跨包的攒帧缓冲区把一个PS包解析完之后不立刻处理而是攒到缓冲区里直到拿到完整的一帧可以通过00 00 00 01 65表示IDR帧开始00 00 00 01 41表示非IDR帧开始等NALU类型来识别再交给MP4封装器。4.3 MP4封装的核心实现MP4的封装是整个流程里技术含量最高的部分。MP4文件和PS流的结构完全不同MP4是基于box结构的一个完整的MP4文件至少包含ftypbox、moovbox、mdatbox。其中moovbox里又包含trak、mdia、minf、stbl等重要box这些box记录了视频的编码参数、帧的时间信息、帧的索引等信息。用Java实现MP4封装可以用开源的MP4Parser库或者自己按ISO/IEC 14496-12标准一步步构建box结构。用JCodec、MP4Parser这类现成库是最靠谱的自己手撸MP4封装代码的工作量非常大而且很容易在某些边缘box上踩坑。我用的方案是MP4Parser库具体的依赖是dependency groupIdorg.mp4parser/groupId artifactIdisoparser/artifactId version1.9.41/version /dependency转封装的完整代码逻辑比较复杂我简化成一个关键的处理流程public class Mp4Muxer { private Movie movie; private Track track; private ListSample samples new ArrayList(); private ListSampleDescriptionBox sampleDescriptions; private long previousTimeStamp -1; public void open(byte[] sps, byte[] pps) { // 创建H264视频轨道描述 // 这里需要把SPS/PPS信息填入AVCDecoderConfigurationRecord AVCDecoderConfigurationRecord avcConfig new AVCDecoderConfigurationRecord(); avcConfig.setSequenceParameterSets(Collections.singletonList(sps)); avcConfig.setPictureParameterSets(Collections.singletonList(pps)); // ... 省略设置profile、level等参数 sampleDescriptions Collections.singletonList( new VisualSampleEntry(avc1, avcConfig) ); movie new Movie(); } public void addSample(byte[] h264Frame, long timestamp) { long duration calculateDuration(timestamp); Sample sample new SampleImpl( ByteBuffer.wrap(h264Frame), sampleDescriptions ); samples.add(sample); timingInfos.add(new TimingInfo(timestamp, duration)); } public void close(String outputPath) { // 构建Track // 计算每个sample的duration // 创建MP4容器 Container mp4file new DefaultMp4Builder().build(movie); FileOutputStream fos new FileOutputStream(outputPath); mp4file.writeContainer(fos.getChannel()); fos.close(); } }MP4封装里最核心、最容易被搞错的点是时间戳和duration的计算。H.264视频的帧率通常是25fpsPAL制或者30fpsNTSC制MP4文件的time scale默认是90000或者1000。这里的换算关系是duration 时间差 / 帧率 * timeScale。如果时间戳计算错误视频播放就会加速或者减速甚至会出现音画不同步的问题。海康SDK回调的码流是不带时间戳的RTP方式取流才会带时间戳所以我在回调里是自己在Java层记录系统时间作为时间戳。这里有一个隐性坑不同的海康设备、不同的分辨率和帧率回调的帧间隔时间并不是恒定的。直接拿System.currentTimeMillis()做时间戳偶尔会出现两个相邻帧的时间戳完全相同因为回调线程处理太快同一毫秒处理了两帧在MP4封装时duration就变成0MP4播放器解析时会有兼容性问题。我的处理方式是在写入MP4之前做一次时间戳校准如果当前帧时间戳跟上一次相同就强制累加一个最小的帧间隔比如40ms对应25fps。5. 完整录像链路整合与SpringBoot集成5.1 录像服务的核心流程把上面拆开的各个模块串起来一个完整的录像服务流程是启动时初始化SDK根据配置的设备列表登录设备对每个摄像头通道启动实时取流回调线程把码流写入环形队列解析线程从队列消费数据做PS流解析解析出的H.264 NALU送进MP4封装器按录像计划切分文件比如每30分钟生成一个MP4文件生成完成后落盘并把文件信息写入数据库这个过程在SpringBoot工程里的呈现就是一个大的后台任务框架。我用了Spring的Scheduled注解来做定时任务调度同时用Async注解来做异步处理Component public class VideoRecordScheduler { Autowired private CameraDeviceService deviceService; Autowired private VideoRecordService recordService; // 每5秒检查一次哪些摄像头需要录像 Scheduled(fixedDelay 5000) public void checkRecordingTasks() { ListCameraDevice devices deviceService.listEnabledDevices(); for (CameraDevice device : devices) { if (device.isShouldRecord() !recordService.isRecording(device.getChannelId())) { recordService.startRecording(device); } } } // 每30分钟执行一次文件切换 Scheduled(cron 0 0/30 * * * ?) public void rotateRecordFiles() { recordService.rotateAllFiles(); } }这里我遇到过一个比较隐蔽的问题SpringBoot定时任务默认是单线程的如果几个定时任务执行时间太长后边的任务就会被阻塞。解决方法是配置一个独立的线程池Configuration public class ScheduledConfig implements SchedulingConfigurer { Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.setScheduler(Executors.newScheduledThreadPool(10)); } }5.2 录像文件的分片和存储策略录像文件的分片策略直接影响后续的检索效率和存储管理。我见过有些项目直接把一整天的录像写成一个文件查询起来是方便了但文件损坏风险极大——一旦文件中途损坏整天的录像都看不到。我这边采用的分片策略是每30分钟一个文件同时支持按摄像头通道号 日期 时间段来组织目录/record-data ├── 192.168.1.64 │ ├── 2024-01-15 │ │ ├── 08-00-00.mp4 │ │ ├── 08-30-00.mp4 │ │ └── ... │ └── 2024-01-16 └── 192.168.1.65 └── ...分片的时间粒度要考虑两个因素一是文件大小不能太大也不能太小太小会导致文件数量爆炸太大则容易在切换时出现录像丢失二是要跟业务查询的粒度对齐用户查回放通常需要精确到分钟级别。30分钟是比较合理的折中方案。文件切换时有个细节必须先停掉当前的MP4封装器把当前文件完整地写盘、关闭文件句柄再开启新的MP4封装器。如果切换过程中的状态处理不严谨很容易出现文件损坏。我写了一个Mp4RecordSession来封装单个录像文件的整个生命周期public class Mp4RecordSession { private Mp4Muxer muxer; private File outputFile; private long sessionStartTime; public synchronized void switchFile() { // 关闭当前会话 muxer.close(); // 创建新文件 outputFile createNewFile(); // 重新初始化muxer muxer.open(sps, pps); sessionStartTime System.currentTimeMillis(); } }5.3 SpringBoot中的SDK生命周期管理SDK的初始化和销毁需要跟随Spring容器的生命周期。我用PostConstruct和PreDestroy来管理Component public class SdkLifecycleManager { PostConstruct public void initSdk() { sdkClient.init(); // 启动时重新登录所有配置的设备 deviceService.loginAllDevices(); } PreDestroy public void destroySdk() { // 停止所有录像任务 recordService.stopAllRecording(); // 释放所有取流句柄 deviceService.logoutAllDevices(); // 清理SDK sdkClient.cleanup(); } }在SpringBoot应用启动时PostConstruct方法是在bean实例化后立即执行的。如果SDK初始化失败我建议直接抛异常终止应用启动避免应用起来了但视频能力不可用的假死状态。另外在用SpringBoot的ConfigurationProperties做配置绑定时可以把设备的连接参数做成配置文件hikvision: sdk-lib-path: /opt/hikvision/lib log-path: /opt/hikvision/logs reconnect-enabled: true devices: - ip: 192.168.1.64 port: 8000 username: admin password: admin123 channels: - channel: 1 record-enabled: true record-duration: 30配置文件里不要直接明文保存密码起码做一层简单的加解密或者用环境变量引用。生产环境的安全这块还是要重视的摄像头账号往往有公网访问权限一旦被破解后果会比较严重。6. 常见问题与排查技巧实录6.1 设备登录报错错误码7和错误码17错误码7是用户名或密码错误这个好排查但有一种情况容易被忽略海康设备默认有“非法登录锁定”策略连续输错几次密码后即使密码对了也会被锁定一段时间。碰到这种情况要么等待锁定期过去再去设备Web端解锁要么在配置登录参数时设置NET_DVR_SetConnectTime的尝试次数同时注意登录失败后要有合理的退避策略。错误码17是设备不存在或IP不可达。我手头遇到过明明ping得通设备但SDK报17的情况。后来排查发现是服务器到设备的防火墙把8000端口限制了SDK默认端口是8000如果网络环境有防火墙策略需要确认端口放通。6.2 实时取流报错错误码23错误码23通常是对应的端口号错误但取流时报23还有一种情况预览参数里设置的通道号超出设备实际通道数。比如一个4路的NVR通道号是从1到4如果误填了5就会报23。另外还有个容易踩坑的地方IPC直连时通道号固定是1而通过NVR接入时通道号可能是NVR的录像通道编号这个要看设备的具体型号。6.3 转MP4后视频无法播放或花屏这个问题先要区分是播放器兼容性问题还是文件本身的问题。用VLC和PotPlayer各试一下如果能播说明是播放器不兼容MP4某个高级特性通常可以在封装时把moovbox放到文件头部DefaultMp4Builder默认就是faststart模式或者换用更通用的H.264 profile比如Main Profile而不是High Profile。如果所有播放器都花屏或者无法播放优先怀疑是SPS/PPS写入有问题。转封装的MP4必须要携带SPS和PPS信息这俩数据从回调解码出的系统头里拿。如果系统头里没有SPS/PPS比如设备配置异常封装出来的文件就无法解码。我这边是在启动预览后等待系统头的到来如果长时间拿不到就发一个关键帧请求让设备主动推送或者重新发起一次预览。6.4 视频时间长度不对MP4文件的时长信息存储在mvhdbox里播放器显示的总时长是从mvhd的duration字段读出的。如果视频播放总时长明显短于实际录像时间通常是时间戳记录有误比如回调线程处理时延导致部分帧的duration被算错。还有个比较隐蔽的问题是如果录像过程中有过丢帧网络波动导致的回调里得到的帧数并不等于实际应该录的帧数。拿帧数乘以帧率去估算时长会偏短但MP4的时长应该基于真实时间戳来算而不是基于帧数估算。我的做法是在MP4封装的addSample里记录真实到达的时间戳close()的时候拿最后一次的时间戳减去第一次的作为视频时长这样即使中间有丢帧时长也是准确的。6.5 服务器重启后录像任务不恢复生产环境里服务器重启是常态但重启后SDK自动登录和录像恢复这块容易出问题。主要是两个原因一是SDK的重连机制是基于设备句柄的应用重启后句柄全部丢失必须在启动时重新登录所有设备二是部分海康设备有“非法重启锁”短时间内频繁重启登录可能会被设备短时间内拒绝。我的处理方案是应用启动时先等10秒再执行设备登录给设备留出开机初始化时间然后逐台登录每台登录之间间隔1秒如果登录失败则指数退避重试最大重试次数5次。这样可以最大程度避免重启后设备拒绝连接的尴尬情况。6.6 内存泄漏和句柄溢出问题长时间运行的录像服务最容易遇到的就是内存泄漏。我排查过几次发现主要来源有三个回调里的数据队列没有及时消费导致内存一直堆积。解决方法是给队列设置上限超限时丢弃旧数据。MP4封装过程中创建的sample对象没有正确释放。如果使用了非堆内存还要注意手动释放DirectMemory。登录设备后没有妥善管理userId反复登录失败导致句柄泄漏。登录失败后要立刻调用NET_DVR_Logout释放句柄不能只做错误提示。还有一个容易被忽视的点海康SDK在某些版本上开启日志记录后日志文件会无限增长长跑之后磁盘会被打满。必须在运维层面配置日志轮转和清理策略。6.7 排查工具推荐最后分享一下我排查问题时的工具集和方法论。日志是第一位的。SDK的日志功能要开业务日志也要打关键是要记录每个操作的入参和返回值。比如登录失败时除了打印错误码最好把设备IP、端口、用户名这些上下文也打印出来方便后面统计和排查。代码层面我用了Arthas做线上问题诊断。比如想看某个线程的堆栈可以用thread -n 3查看最忙的CPU线程想确认回调线程是否被阻塞可以用watch命令观察队列长度变化。网络层面Wireshark抓包是排查SDK连接问题的终极武器。通过过滤海康SDK默认端口8000的流量可以看到登录、取流这些操作在各个协议层的表现如果设备返回了RST说明被动端口可能被防火情拦截了。7. 结尾实操体会与下一步扩展整个SpringBoot整合海康SDK的项目走下来我最大的体会是海康SDK本身不算难用难的是把C语言接口的安全隐患、码流处理的性能瓶颈、MP4封装细节这些内容串联起来真正在生产环境稳定跑起来。如果让我重新做一遍这个项目我在两个地方会做得不一样。一是会更早地引入容器化部署。之前因为so动态库的加载问题一直没敢上Docker但实际上把so文件挂载到容器里启动脚本里加一行-Djna.library.path的JVM参数就能解决。容器化之后扩缩容就方便了摄像头多的时候可以按设备组做分组服务不至于所有摄像头都挤在一个实例上。二是会提前考虑云存储的适配。目前文件是落本地盘的后续如果要做多节点共享需要考虑把录像文件同步到对象存储MP4文件天生就适合通过HTTP Range请求做分段播放接入OSS或者S3后回放体验会比直接从服务器拉文件顺滑很多。这些就当作下一步的扩展方向吧。目前这套系统已经稳定运行了好几个月每天要处理近千个录像文件出问题的概率极低。如果你正在做类似的视频接入项目希望这篇实战记录能帮你省掉一些摸索的时间尤其是那些百度上找不到答案的坑照着做基本都能绕过去。