做了几年IoT平台从单机设备接入到视频上云最后沉淀出来的那套技术组合翻来覆去其实就那几样底层协议靠 MQTT 和 GB28181 撑着媒体流转发交给 ZLMediaKit文件落库走 MINIO业务侧 Spring、Vert.x 混着用Redis 扛缓存和分布式锁MybatisPlus 管持久层。这套东西看似零散但每一个都是掐着痛点选的。这篇文章就围绕这套组合讲清楚每个组件到底解决什么问题、怎么落地、以及我实际踩过的那些坑。这套组合最适合两类人一是准备从零搭建物联网平台、尤其带视频监控能力的团队二是已经在用 SpringBoot 做后端、想往流媒体和对象存储方向扩展的开发者。你不需要对每个组件都精通但得知道它们在体系里扮演什么角色否则后面调协议、查丢帧、追文件权限问题的时候会非常痛苦。1. 整体架构与技术选型思路1.1 为什么不是“一套框架打天下”很多团队做物联网平台第一反应是找一个超级框架最好消息订阅、视频播放、文件管理全都内置。我试过结果是最痛苦的阶段不是开发而是定制和排查。超级框架通常把底层封装得太死物联网场景恰恰是设备和协议的“不守规矩”最严重的场景——有的设备 MQTT 报文不按标准来有的摄像头国标信令交互时序倔得很这时候你就需要每个环节都能单独拿出来折腾的组件。所以我最后定的架构是“异步网关 媒体独立 存储独立 业务收敛”的拆分模式设备接入层用 Vert.x 网关处理高并发连接、协议解析和心跳保活视频信令和流媒体转发放 ZLMediaKit利用它成熟的 GB28181/RTSP 能力文件持久化走 MINIO兼容 S3 API后续换云厂商不费劲业务层用 SpringBoot MybatisPlus把设备管理、告警、用户权限这些做成标准后台Redis 做分布式会话、缓存、设备在线状态和分布式锁。这里有个常被忽略的设计原则媒体流和数据流必须分离。设备上报的温湿度数据走 MQTT 到业务系统视频流走 GB28181 到 ZLMediaKit两条链路独立伸缩。如果不分离一路视频流的高带宽占用就可能拖垮整个业务接口的响应。1.2 组件版本与选型前置确认选型的时候有几个坑是提前确认能省大量的时间的。我自己第一次踩的时候就是没做这些功课后面折腾了好几轮确认 ZLMediaKit 版本与编译选项不同版本对 CPU 指令集、HLS/WebRTC 支持有差异确认 MINIO 的部署模式单机还是分布式直接影响后面的纠删码配置和扩容方式确认 MybatisPlus 与 SpringBoot 版本兼容尤其分页插件这块和低版本有兼容问题确认 Redis 是直接用原生客户端还是 Redisson这决定了分布式锁的写法复杂度。这些看起来是细枝末节但物联网项目联调周期长一旦运行时才发现组件配合有问题定位成本是整个项目的数倍。2. 物联网协议与数据格式处理2.1 MQTT 之外其他的协议为什么也要保留物联网接入层最常聊的协议是 MQTT——轻量、支持 QoS、发布订阅解耦。但“只用 MQTT”在真实项目里是不够的。实际设备接入场景中总有那么一批设备不支持 MQTT要么是走 TCP 私有协议要么是通过 HTTP 上报 JSON还有视频类设备默认走 GB28181。这时候 Vert.x 的价值就出来了。它不是只做 HTTP 的框架而是对各种协议都有原生或者生态支持。我在网关层用 Vert.x 分别启了 MQTT Endpoint、TCP Server 和 HTTP Router相当于给外界设备开了多个“入口”但进来之后统一转成内部的标准消息结构。2.2 GB28181 国标协议里最容易被忽略的细节GB28181有的文档里写成 GB282 或者 GB/T28181是视频监控设备接入平台的主要国标方式它实际上是一个“SIP 媒体协商”的组合协议。设备注册不是一次 HTTP 请求就结束的它需要设备向 SIP 服务器发REGISTER服务器回200 OK然后还要定时刷新。这个流程很多第一次接触的开发者会忽略心跳超时的问题。如果设备乱发或者网络抖动SIP 注册状态就假死了平台显示在线实际已经断开。另一个坑在媒体协商。GB28181 的视频流通过 RTP 承载 PS 封装ZLMediaKit 内部会解析 PS 流并转换成 RTSP/RTMP/HLS。但有些摄像头厂商对 PS 封装的规范执行不到位会出现音视频时间戳不同步、RTP 丢包后花屏这些现象。遇到这种情况优先查 ZLMediaKit 的parse_ps相关日志其次看交换机端口镜像抓包而不是急着调平台代码。2.3 设备数据格式统一策略不管设备上来的是 JSON、自定义二进制还是 XML进入业务端之前我都建议统一做一层“设备影子”转换。所谓设备影子就是内部维护一个标准化的设备数据模型包含设备唯一标识、上报时间、数据类型、原始报文摘要、处理状态。这样做的原因很简单业务服务端永远不直接依赖原始报文格式。设备固件升级导致报文结构变化时我们不需要改业务逻辑只需要改网关层的解析脚本。项目后期接入第三方设备平台也可以在这一层做协议适配。3. 流媒体服务器 ZLMediaKit 实战3.1 部署与基础配置ZLMediaKit 我用的是源码编译方式因为官方预编译包在某些 Linux 发行版上的兼容性不够好。编译依赖包括 cmake、gcc、openssl、ffmpeg 库照着 README 来就行没有太特殊的步骤。比较关键的是config.ini里的几个配置项portSIP 信令监听端口GB28181 默认 5060注意不要和业务端口冲突httpPortRESTful API 端口后续业务平台调它来控制推流拉流hook事件回调地址ZLMediaKit 会把推流成功、断流、播放器上下线等事件 POST 到你配置的 URL 上enable_audio和enable_hls根据场景决定是否开启如果走得是低延时播放线路HLS 可以先不开启省掉切片开销。还有个容易被忽略的点ZLMediaKit 默认会开启鉴权secret配置在 URL 的 sign 参数里。如果你在局域网里做测试可以先关闭鉴权但上生产环境一定开启并且把拉流 URL 里加上过期时间戳防止播放地址被恶意扩散。3.2 与业务系统的“信令 流媒体”协同ZLMediaKit 的责任是“处理流”它不负责“告诉业务系统现在轮到谁播放”。因此需要业务平台自己维护设备当前的状态映射设备是否在线、是否有推流 session、播放器请求哪个设备的流。我当时用一个比较轻量的做法把 ZLMediaKit 的hook回调指向 Vert.x 网关的某个接口回调里带着mediaServerId、app、stream、regist这些字段。网关收到回调后更新 Redis 里这个设备流的在线状态。然后业务前端想看某个摄像头画面时向 SpringBoot 后端请求后端查 Redis 判断流是否存在存在则返回 ZLMediaKit 的拉流地址拼接结果。这套“前端不直接感知 ZLMediaKit”的设计很重要。否则每次媒体验证逻辑有变动比如加签名、改线路前端都要跟着改最后全是联调的苦。3.3 拉流性能的常见瓶颈ZLMediaKit 本身转发性能很能打我压测单路 1080P 转 HLS 时 CPU 占用可以接受。但要注意几个场景容易把服务器拖垮多路同时开大码流且转协议每个协议栈都会产生自己的开销不是只转一次播放器没有及时关流导致持续占用会话资源这需要依赖hook的播放器事件去主动回收或者设置无播放器自动断流参数内网外网带宽不对称拉了外网流再转发给多个内网客户端上行带宽会打满。解决方式通常是建“拉流代理”的缓存复用——同一路流ZLMediaKit 本身就是转发节点天然支持多播放器复用同一份流。所以前端播放器的数量增加只要带宽扛得住就不会指数级消耗源站资源。4. MINIO 对象存储的工程化落地4.1 从图片存储到大文件分片上传MINIO 在物联网平台里最常见的用途是存储三类数据设备抓图、录像文件、业务系统上传的临时文件。它兼容 S3 API所以代码逻辑有机会平滑迁移到阿里云 OSS、腾讯云 COS这也是我坚持选它的原因。但直接给前端一个 MINIO 地址让它上传大视频文件这种方案我强烈不建议。第一MINIO 的预签名 URL 虽然有有效期但如果是长期有效的预签名等于文件裸奔第二大文件单次上传失败后重传成本太高。我实际做下来的大文件方案是三步业务后端向 MINIO 请求一个multipart upload的 uploadId前端把文件按分片大小 5MB~10MB 切分依次请求预签名 URL 并上传全部上传完成后后端提交completeMultipartUploadMINIO 自动合并。实现上不需要自己从头写 S3 签名用官方 SDK 的CreateMultipartUpload、UploadPart、CompleteMultipartUpload三个方法即可。注意分片大小要在 5MB 以上这个是 S3 的硬限制我一开始用 2MB 分片被拒了几次。4.2 权限模型与 public 只读流的坑MINIO 提供 Bucket 级别的访问策略。有一个需求很常见让抓图文件能以公开 URL 的方式被前端直接访问。做法是通过mc命令行设置 Bucket 策略为 download具体是mc anonymous set download myminio/device-images这里容易出问题的是download策略会让整个 Bucket 下的对象都可读。如果你这个 Bucket 里既有公开图片又有隐私文件那就不能用这个简单策略得考虑用Prefix分类把公开和私密文件放在不同前缀下然后对公开前缀单独授权。生产环境还有一个现实问题如果 MINIO 在内网而前端页面在公网直接把 MINIO 的地址给前端明路是不通的。我当时的做法是 Nginx 反代 MINIO 的 API 端口对外统一走 HTTPS然后把 Nginx 的域名作为返回给前端文件的 URL。这样能统一控制缓存策略也能利用 Nginx 的访问日志审计文件被谁拿走了。4.3 HTTPS 化改造经验MiniO 自身支持 TLS但实际配置起来有些繁琐。我的做法是放弃在 MINIO 进程上直接挂证书而是在 Nginx 层做 TLS 终结服务间通过内网 HTTP 访问 MINIO公网统一由 Nginx 出口。这个方案的优势是证书更新只动 Nginx不用重启 MINIO 集群内网服务调用没有 TLS 握手开销延迟更低。需要注意一个细节如果你已经给前端页面分配了 HTTPS 域名页面里面请求的图片 URL 必须也是 HTTPS否则浏览器会拦截“混合内容”。很多人在这个点上翻车排查的时候还以为是 MINIO 权限配置错了。4.4 MINIO 数据迁移与替代方案MINIO 最常见的一个声音是“社区版够不够用”另外一个场景是“我打算把数据迁到 OSS/COS”。这两件事我都做过结论是迁移本身不复杂复杂的是迁移后的路径映射改造。如果是从 MINIO 迁到云厂商 OSS用各自提供的迁移工具比如 Rclone配置好两端 accessKey、bucket 后直接同步即可。大部分对象数据都是冷数据同步过程中业务可以继续写只是期间不允许线上修改文件。但代码层面的改造要注意MINIO 默认的 SDK endpoint 是http://minio:9000OSS 的 endpoint 是https://oss-cn-hangzhou.aliyuncs.com同时签名算法有 V2 和 V4 的区别需要做一层适配。我的建议是业务服务内封装一个StorageService接口所有文件操作都走这个接口后面换存储时只改实现类业务代码一行不动。5. Spring、MybatisPlus 与异常处理5.1 MybatisPlus 分页失效和 500 条限制的真实原因MybatisPlus 很多人吐槽“分页突然不管用了”“单页最多 500 条”其实根因很统一PaginationInnerInterceptor没有被正确装配。在旧版本里你只要注入一个 Bean 就行。但 SpringBoot 3 和 MybatisPlus 3.5.x 组合下很多配置默认不复用导致分页插件没有进入 SQL 拦截链。表现就是你调用Page查询返回条数超过 10 条或者 limit 条件根本没拼上去。我建议直接按下面方式装配不要走被各种教程简化的路径Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(1000L); interceptor.addInnerInterceptor(pagination); return interceptor; }不要同时配多个 MybatisPlusInterceptor Bean否则会互相覆盖。我遇到过同事复制了多个配置类每个类里都建了一个拦截器结果 SQL 执行了两次 limit数据直接查不出来。5.2 实体与数据库字段映射的细节物联网平台的设备表、录像表经常有 JSON 字段存储冗余结构。MybatisPlus 里处理 JSON 有两种做法一种是JacksonTypeHandler配合TableField(typeHandler ...)另一种是把 JSON 序列化放到 Service 层。我更推荐后一种因为前者的类型处理器在复杂嵌套和更新时容易指错类型信息丢失。具体做法实体类里用String类型承接 JSON 原文Service 层拿到后手动ObjectMapper转换。虽然代码多一点但可读性高排查问题时不至于在 ORM 底层绕。另外MybatisPlus 的updateById默认是不会更新null字段的这在设备上报场景里会造成问题你想把一个字段清空但数据库里旧值还在。解决办法是用UpdateWrapper显式设置字段为 null或者开启全局更新策略。5.3 异常与异常处理的实战体会“异常与异常处理”这个点在标题里看着基础但我在物联网项目里发现很多问题的暴露和掩盖全在异常处理这一层。物联网项目的异常来源比其他项目多设备断连是常态、Redis 缓存击穿是常态、ZLMediaKit 回调失败是常态、MINIO 临时不可用是常态。所以后台系统一定不能把这些全部堆成 500 响应。我的做法是分三层设备接入层Vert.x异常直接记录日志返回设备一个 ACK 码不往业务层抛业务层SpringBoot使用RestControllerAdvice统一捕获业务异常BizException代码只对真正状态码有意义的结果抛异常基础设施层Redis/MINIO/ZLMediaKit每个外部调用都要做超时和降级外部组件异常时先返回降级数据并记录一个异步告警事件。如果你看到某一条业务接口偶尔 500但又不确定到底是设备数据问题还是数据库问题十有八九就是异常处理没有分层底层异常直接穿透到顶层返回了。我们内部的BizException至少带两个字段code业务错误码和message对用户友好的描述。比如设备不存在、流通道未开启、文件上传超时都有独立的 code。前端拿到 code 后做对应的交互提示而不是解析一段堆栈文本。5.4 事务边界与跨服务调用Spring 的事务管理在物联网场景里要特别小心。设备上报告警生成、通知事件推送、文件索引创建一旦被包进一个大事务性能会急剧下降。我建议事务只包住“写数据库”的核心操作外部组件调用Redis、MINIO、ZLMediaKit放在事务外面通过 Spring 的TransactionSynchronization注册事务成功后的回调来执行。这样做的好处是数据库事务尽可能短外部组件不稳定时不会因为 long transaction 拖死连接池。代价就是代码结构上多了一层编排但对物联网这种大流量、弱一致场景来说利远大于弊。6. Redis 在物联网项目里的真实玩法6.1 数据类型的选型与使用场景Redis 在物联网项目里几乎是“会用的人能玩出花不会用的人只当缓存”。我给常见场景做个对应数据类型使用场景备注String设备当前状态、临时验证码、图片预签名URL缓存直接用 SET/GET注意过期时间Hash设备的属性集合哈希里的字段按设备属性维度存储避免反序列化整对象List设备告警消息队列、摄像头抓拍事件流配合 LTRIM 限制长度防堆积Set在线设备集合、黑名单SADD/SREM/SISMEMBER 判断在线O(1)ZSet排行榜、按时间排序的告警事件SCORE 用时间戳直接做时间窗口Stream轻量级消息队列相比 Pub/Sub 可持久化消费组更实用6.2 Redis 序列化问题为什么存进去是乱码Redis 的RedisTemplate默认序列化器是JdkSerializationRedisSerializer存进去的 key 会带一串二进制前缀肉眼看着就是\xAC\xED\x00\x05t\x00...。这个现象很多人遇过然后困惑为什么 key 明明设置了过期时间却不生效。正确做法是指定 key 用 StringRedisSerializervalue 用 Jackson 序列化并且给 Jackson 传入具体的类型参数防止反序列化后是 LinkedHashMap 而不是对应实体类redisTemplate.setKeySerializer(new StringRedisSerializer()); redisTemplate.setHashKeySerializer(new StringRedisSerializer()); redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());Redis Desktop Manager 这种可视化工具里看到的乱码问题其实也是序列化不规范造成的。生产里我建议统一以 String 作为 keyvalue 入库存 JSON可视化排查问题的时候一眼能看懂。6.3 分布式锁的正确打开方式我最早写分布式锁时用的是SETNXEXPIRE两条命令后来发现会死锁——如果 SETNX 之后服务进程挂掉EXPIRE 根本不会执行。后来改成一条原子命令SET lock_key unique_value NX EX 30释放锁时用 Lua 脚本先对比unique_value再删除防止误删其他线程的锁。这一步很多教程会提但真正落实到代码里的团队不多。如果项目中已经引入 Redisson那不需要自己写这些。Redisson 的RLock自己实现了看门狗续期默认 30 秒超时锁业务没执行完会自动续期。它的性能稍低于原生 Redis但稳得多。我在整个物联网平台的接口幂等和定时任务防重上统一用的 Redisson。6.4 缓存治理与主从部署物联网设备的状态读取频率非常高如果每次都查 MySQL连接池很容易成为瓶颈。所以我把设备影子status、property 最近值放到 Redis并为每条数据设置 TTL。TTL 不是越长越好过长的 TTL 会导致“幽灵在线”——设备已经掉线几个小时状态还是在线。主从部署我用的方式是用 docker-compose 起一个主两个从开replicaof配置。读写分离后主库负责写入从库负责读。这里要特别注意从库默认是只读的如果你不小心把写操作打到从库会直接报READONLY错误。排查这类问题最简单的方法是INFO replication看角色是否对得上。7. 典型问题速查表与思路补充整套体系跑久了重复的问题也就那么几类。我整理了一张排查速查表遇到类似情况可以先从这些方向下手。现象优先排查项思路补充MybatisPlus 分页失效拦截器是否重复注入检查是否有多个MybatisPlusInterceptorBean 重叠MybatisPlus 分页超过单页限制PaginationInnerInterceptor的setMaxLimit该值默认可能限制 500 条业务需要时显式调大MINIO 上传文件 URL 带 Access DeniedBucket 的匿名策略和对象 ACL先确认 URL 是预签名还是匿名地址预签名地址不要手动改参数ZLMediaKit 设备拉流黑屏看是否进入has key状态再抓包看 RTP黑屏大概率不是流媒体服务器问题而是设备 PS 封装问题Redis key 全部带乱码前缀RedisTemplate 的 key 序列化器换成 StringRedisSerializer设备显示离线但平台认为在线Redis 里设备 TTL 是否过期调整心跳过期判断或改造 TTL 续期逻辑调用 OSS SDK 连 MINIO 报 SignatureDoesNotMatch确认 endpoint 末尾是否带路径MINIO 兼容模式要多一层 bucket 路径拼接跨域请求拿不到预签名文件CORS 配置在 MINIO 或 Nginx 层确保你的前端域名在 Allow-Origin 里还有两个实操里的“软经验”第一日志一定要带traceId。物联网链路很长从设备到网关到业务到流媒体到对象存储没有一个贯穿全局的 ID出问题全靠猜。我在 Vert.x 网关生成 traceId 后放入 MQTT 消息头和 HTTP Header下级系统全部透传。第二监控先行。Redis 的INFO stats、MINIO 的审计日志、ZLMediaKit 的getAllSession接口这些我建议在项目第一天就接到告警上。否则等设备规模上来再补监控历史问题早就淹没在数据里了。8. 这套组合的后续演进方向如果规模继续做大我个人的建议是先拆存储和接入层。MINIO 可以从单节点升级为分布式模式数据打散到多块磁盘Redis 从主从过渡到 Cluster或者直接上云托管的 Redis。Vert.x 网关可以拆成独立进程集群用一致性哈希把同一设备的连接固定到同一节点。这些演进都不是突然推倒重来而是顺着最开始的分层架构自然长出来的。我在实际项目里最受益的一件事就是当初没有为了技术热点引入多余组件每加一个东西都问自己“它到底解决了当前哪个痛”。这套组合之所以稳不是因为某个组件多强而是它们各自的边界足够清晰。如果你现在还处于搭建阶段建议按“接入层 - 媒体层 - 存储层 - 业务层”的顺序先跑通最小闭环每一步都用最简单的方案验证可行性再逐步完善。这套组合的容错性很好踩坑大多是因为配置不规范而不是架构方向选错了。