
搞无人健身房系统真不是只写个接口那么简单。门禁要自动开、灯光要感应、设备要断电上锁、用户要扫码入场这些全靠后端把硬件“串”起来。这篇文章我把当时做的一套Java SpringBoot 物联网系统的完整源码思路拆开讲从网络协议到业务代码再到后续维护踩过的坑一次说清楚。这套系统解决的痛点很直接健身房没人值守用户扫码进店、自助用器材商家在后台实时看设备状态和入场记录。核心是 SpringBoot 作为应用主框架Netty 负责 TCP 长连接接入硬件设备MQTT 和 Redis 负责状态传递和并发控制最终实现“无人化”闭环。适合正在做物联网平台、智能硬件后台或者想从传统 CRUD 转向 IoT 开发的 Java 工程师参考照着这套架构改一改可以套用到共享自习室、无人便利店、智能门禁等场景。1. 项目整体设计与思路拆解1.1 无人健身房要解决的核心问题做之前我先把需求捋了一遍发现表面是“小程序扫码开门”实际牵扯的事很多用户自助化用户扫码进场、自助开器材电源、骑车跑步整个流程没有店员介入。设备自动管理人进门后灯光自动打开人走之后灯光空调延时关闭跑步机电源自动切断。这背后是人体红外传感器 继电器控制。异常兜底设备离线要能感知门磁被撬要报警紧急按钮要能触发断电时门锁必须保持常开安全逃生方向。计费与订单按时长计费用户进场开始计时出场结束计时余额不足不能开门。运营可视化商家需要看到每台设备在不在线、今天多少人入场、哪台机器报警频率高。这些需求直接决定了系统不是“管理后台 数据库”就完事而必须拆成“设备接入层 业务服务层 用户端 管理端”四个部分。设备接入层要能抗住长连接业务服务层要处理好状态变化和并发。1.2 技术选型与架构决策技术选型当时对比了几套方案核心争议点是要不要自己写 TCP 接入层还是直接买云平台。我最终选择自建 Netty 接入层理由有三数据私有化健身房的设备数据、用户行为数据都在自己手里不依赖第三方平台。协议可控市面上门禁、传感器、控制器的厂家协议五花八门统一封装成自己的内网协议更方便。扩展性后续接入更多设备类型智能体脂秤、空气净化器、淋浴控制器不用改架构。具体技术栈如下模块技术选型选型理由应用框架SpringBoot 2.7 JDK 8稳定、生态丰富、招人好招设备接入Netty 4.1高并发 TCP 长连接自带拆包粘包处理消息中转EMQXMQTT Broker设备状态上行、指令下行解耦数据存储MySQL 8.0 MyBatis-Plus业务数据强一致MP 提高开发效率缓存/分布式锁Redis缓存设备状态防重复开门实时推送WebSocket STOMP管理后台实时看到设备告警用户端微信小程序扫码、支付、查看记录这套架构最关键的思路是设备不直接连 SpringBoot 的 HTTP 接口而是走“TCP 长连接 MQTT”两条通道。上行设备 - 云端设备上报心跳、状态、告警通过 MQTT 发到 EMQXSpringBoot 订阅消息写入 Redis 和 MySQL。下行云端 - 设备SpringBoot 调用开门接口时通过 Netty 向设备 Channel 写入指令或者通过 MQTT 发指令让设备执行。有人会问直接用 Netty 一个通道不就行了为什么还要 MQTT因为设备端的嵌入式开发通常用 ESP32、STM32 之类的芯片它们对接 MQTT 的成本极低代码量小而且 MQTT 的 QoS 机制能保证消息不丢。而 Netty 用于传输更私密、实时的控制指令两套通道互相补充不会互相阻塞。2. 核心细节解析与实操要点2.1 设备接入自定义协议与 Netty 服务端设备接入层是整个系统地基地基不稳业务再花哨也没用。设备网络环境复杂经常丢包、乱序、粘包所以协议设计不能像本地调用那么简单。我用的协议帧格式是这样的帧头(2字节) 版本(1字节) 命令字(2字节) 数据长度(2字节) 数据体(N字节) CRC16校验(2字节) 0x55 0xAA 0x01 0x0001 0x000C {数据体} 0x1234帧头固定0x55 0xAA用来识别一个帧的开始。版本号兼容后续升级目前是0x01。命令字区分消息类型比如0x0001表示心跳0x0002表示状态上报0x0101表示开门指令。数据长度是数据体字节数解码时靠它知道要读多少字节。CRC16校验数据完整性防止网络传输过程中数据被篡改或损坏。Netty 服务端最关键的一环是解码器。我之前就是没写好解码器导致设备消息频繁解析错乱后来老老实实实现了一个ByteToMessageDecoderpublic class DeviceMessageDecoder extends ByteToMessageDecoder { private static final int HEADER_LENGTH 9; private static final byte[] FRAME_HEADER {(byte) 0x55, (byte) 0xAA}; Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, ListObject out) throws Exception { // 循环检查直到解析出完整消息 while (true) { if (in.readableBytes() HEADER_LENGTH) { return; } in.markReaderIndex(); byte[] header new byte[2]; in.readBytes(header); if (header[0] ! FRAME_HEADER[0] || header[1] ! FRAME_HEADER[1]) { // 帧头不匹配跳过这个字节重新找 in.resetReaderIndex(); in.skipBytes(1); continue; } byte version in.readByte(); short command in.readShort(); short length in.readShort(); if (length 0 || length 2048) { in.resetReaderIndex(); in.skipBytes(1); continue; } if (in.readableBytes() length 2) { in.resetReaderIndex(); return; } byte[] data new byte[length]; in.readBytes(data); short crcFromDevice in.readShort(); // 校验 CRC简化示例真实场景要遍历计算 short crcCalc Crc16Util.calcCrc(data); if (crcFromDevice ! crcCalc) { // 校验失败丢弃该帧 continue; } DeviceMessage message new DeviceMessage(); message.setVersion(version); message.setCommand(command); message.setPayload(data); out.add(message); } } }这里有个很关键的体验解码逻辑必须放在循环里并且要用markReaderIndex()和resetReaderIndex()配合。因为网络包到达不是恰好一帧可能半帧、可能两帧粘在一起。循环处理能保证“读完一帧继续读下一帧”而 mark/reset 能保证“数据不够一帧时等下次数据到达再继续”。2.2 设备状态管理与“无人化”控制逻辑设备状态管理是我另一个重点设计。设备状态不只是“在线/离线”这么简单我把状态设计成了枚举public enum DeviceStatus { OFFLINE(0, 离线), ONLINE(1, 在线), RUNNING(2, 运行中), ALARM(3, 告警), POWER_OFF(4, 断电); private final int code; private final String desc; // constructors/getters... }设备状态变化的核心流程是这样的设备上电 - 发送登录包 - Netty 服务端记录 Channel - 设备状态置为 ONLINE 设备正常运行 - 每 30 秒发送心跳包 - 服务端刷新 lastHeartbeatTime 用户扫码 - 业务层下发开门指令 - 门锁打开 - 设备上报门锁已开启 - 状态更新为 RUNNING 人体红外传感器检测无人 - 持续 30 秒上报无人状态 - 业务层触发关闭灯光和电源 设备断电或断网 - Netty 读超时 - 触发 IdleStateHandler - 状态置为 OFFLINE“无人化”控制逻辑我单独说这也是整套系统的灵魂。场馆内每个区域装一个人体红外传感器当它上报“有人”时SpringBoot 就通过 MQTT 下发开灯指令当它上报“无人”并且持续一段时间内没有再次触发“有人”系统自动下发关灯、关闭跑步机电源的指令。这里我加了个保护机制不是设备一上报“无人”就立刻关灯而是设置一个 30 秒的延时窗口避免用户弯腰系鞋带这种短暂的“无检测”就导致关灯。实现方式是用 Redis 的过期 key 配合延迟队列30 秒内如果收到“有人”上报就取消关灯任务。2.3 数据模型与缓存设计数据模型设计时我重点保证了两点设备表要能支撑多类型设备、记录表要能支撑高频率写入。核心表结构如下-- 设备表 CREATE TABLE t_device ( id BIGINT NOT NULL AUTO_INCREMENT, device_code VARCHAR(64) NOT NULL COMMENT 设备编码, device_name VARCHAR(128) DEFAULT NULL, device_type TINYINT NOT NULL COMMENT 1-门禁 2-红外 3-灯光控制 4-电源控制, location VARCHAR(128) DEFAULT NULL COMMENT 安装位置, status TINYINT NOT NULL DEFAULT 0 COMMENT 设备状态, last_online_time DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_device_code (device_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 入场记录表 CREATE TABLE t_access_record ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, device_code VARCHAR(64) NOT NULL, action_type TINYINT NOT NULL COMMENT 1-进入 2-离开, create_time DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_user_time (user_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 设备告警表 CREATE TABLE t_device_alarm ( id BIGINT NOT NULL AUTO_INCREMENT, device_code VARCHAR(64) NOT NULL, alarm_type TINYINT NOT NULL COMMENT 1-离线 2-门磁异常 3-紧急按钮, alarm_content VARCHAR(255) DEFAULT NULL, create_time DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_device_time (device_code, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;Redis 的 key 设计我花了不少心思device:{deviceCode}:status缓存设备最新状态管理端查询秒回。lock:door:{deviceCode}防重复开门的分布式锁。access:{deviceCode}:today:count当日开门次数统计运营报表直接取。delay:light:off:{areaId}无人关灯延时任务的标记。数据落库策略我一开始踩了坑每次状态变化都直接改 MySQL设备一多几百台设备、每 30 秒心跳一次数据库压力非常大。后来改成了状态实时更新到 Redis心跳信息只更新 Redis 的 lastHeartbeat每 5 分钟由定时任务批量把变更同步到 MySQL。这样既保证管理端看到的设备状态相对实时又不会把数据库打爆。3. 实操过程与核心环节实现3.1 环境准备与项目初始化实际开发前我先做环境准备。基础环境如下JDK 8MySQL 8.0Redis 6.xEMQX 4.xSpringBoot 2.7使用 IDEA 创建 SpringBoot 项目引入依赖dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdio.netty/groupId artifactIdnetty-all/artifactId version4.1.82.Final/version /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.2/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.eclipse.paho/groupId artifactIdorg.eclipse.paho.client.mqttv3/artifactId version1.2.5/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency /dependencies然后在application.yml里配置端口、数据库、Redis 和 MQTTserver: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/gym_iot?useUnicodetruecharacterEncodingutf8 username: root password: 123456 redis: host: localhost port: 6379 netty: tcp: port: 9000 mqtt: broker: tcp://localhost:1883 client-id: gym-server topic-prefix: gym3.2 服务端 TCP 接入实现Netty 服务端启动代码是整个系统的门面我直接贴出核心部分Component public class NettyServerRunner implements ApplicationRunner { Resource private DeviceMessageHandler deviceMessageHandler; Resource private DeviceMessageDecoder deviceMessageDecoder; private EventLoopGroup bossGroup; private EventLoopGroup workerGroup; private Channel serverChannel; Override public void run(ApplicationArguments args) throws Exception { bossGroup new NioEventLoopGroup(1); workerGroup new NioEventLoopGroup(); ServerBootstrap bootstrap new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 1024) .childOption(ChannelOption.TCP_NODELAY, true) .childOption(ChannelOption.SO_KEEPALIVE, true) .childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new DeviceMessageDecoder()); ch.pipeline().addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS)); ch.pipeline().addLast(deviceMessageHandler); } }); ChannelFuture future bootstrap.bind(nettyTcpPort).sync(); serverChannel future.channel(); log.info(Netty TCP Server started on port {}, nettyTcpPort); } }注意两点IdleStateHandler设置 60 秒读超时。设备每 30 秒发一次心跳60 秒没读到数据基本可以判定设备掉线。触发userEventTriggered后我会主动关闭该连接并从连接池移除。ChannelOption.SO_KEEPALIVE开启 TCP 层 keepalive但要注意这只是操作系统层的保活机制默认探测周期比较长不能替代应用层心跳。应用层心跳才是判断设备真实状态的主要手段。设备消息处理器是关键类它的职责是接收解码后的DeviceMessage然后分发到不同的业务处理器。因为 Netty 的 IO 线程不应该做阻塞操作所以我用线程池异步处理业务Component Slf4j public class DeviceMessageHandler extends ChannelInboundHandlerAdapter { private final ExecutorService bizExecutor new ThreadPoolExecutor( 4, 16, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(10000), new NamedThreadFactory(device-biz), new ThreadPoolExecutor.CallerRunsPolicy() ); private final MapString, Channel channelMap new ConcurrentHashMap(); Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { DeviceMessage message (DeviceMessage) msg; // IO 线程快速释放业务丢给线程池 bizExecutor.execute(() - dispatch(ctx.channel(), message)); } private void dispatch(Channel channel, DeviceMessage message) { String deviceCode getDeviceCode(channel); switch (message.getCommand()) { case 0x0001: handleHeartbeat(deviceCode); break; case 0x0002: handleStatusReport(deviceCode, message.getPayload()); break; case 0x0003: handleAlarm(deviceCode, message.getPayload()); break; default: log.warn(Unknown command: {}, message.getCommand()); } } Override public void channelActive(ChannelHandlerContext ctx) throws Exception { // 设备连接建立后先注册连接具体绑定 deviceCode 等第一次登录包到达 super.channelActive(ctx); } Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { // 连接断开清除 ChannelMap更新设备状态为离线 removeChannel(ctx.channel()); super.channelInactive(ctx); } Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { log.warn(Device read timeout, close channel); ctx.close(); } super.userEventTriggered(ctx, evt); } public void sendCommand(String deviceCode, byte[] payload) { Channel channel channelMap.get(deviceCode); if (channel ! null channel.isActive()) { ByteBuf buf PacketEncoder.encode(payload); channel.writeAndFlush(buf); } else { log.error(device offline, cannot send command: {}, deviceCode); } } }3.3 门禁控制与无人关灯实现门禁控制流程是这个项目的业务核心。用户在小程序扫码后调用后端接口POST /api/door/open body: { userCode: U001, doorCode: D001 }后端处理流程我整合成一个流程图文字版说明校验用户是否存在、会员状态是否正常。校验设备是否在线。Redis 防重复锁5 秒内同一用户开同一道门只允许一次。调用deviceCommandHandler.sendCommand(doorCode, openDoorPayload())下发开门指令。记录一条入场记录到数据库。异步推送 WebSocket 消息到管理端。核心代码Service Slf4j public class DoorService { Resource private StringRedisTemplate stringRedisTemplate; Resource private DeviceMessageHandler deviceMessageHandler; Resource private AccessRecordMapper accessRecordMapper; public boolean openDoor(String userCode, String doorCode) { // 1. 校验用户状态 User user userService.getByCode(userCode); if (user null || user.getStatus() ! 1) { throw new BizException(用户不存在或已被禁用); } // 2. 设备在线校验 String status stringRedisTemplate.opsForValue().get(device: doorCode :status); if (!String.valueOf(DeviceStatus.ONLINE.getCode()).equals(status)) { throw new BizException(设备不在线请稍后再试); } // 3. Redis 防重复锁5 秒内有效 String lockKey lock:door: doorCode; Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, userCode, Duration.ofSeconds(5)); if (Boolean.FALSE.equals(locked)) { throw new BizException(请勿重复扫码); } // 4. 下发开门指令 byte[] orderData new byte[] {0x01, (byte) 0x01, 0x00, 0x00}; deviceMessageHandler.sendCommand(doorCode, orderData); // 5. 记录入场 AccessRecord record new AccessRecord(); record.setUserId(user.getId()); record.setDeviceCode(doorCode); record.setActionType(1); accessRecordMapper.insert(record); log.info(Door [{}] opened by user [{}], doorCode, userCode); return true; } }无人关灯的实现核心是一个延迟任务。当红外传感器上报无人但用户可能还在某个角落锻炼时系统不能立刻关灯。我是这样做的public void handleInfraredNoPerson(String areaCode) { String delayKey delay:light:off: areaCode; Boolean setOk stringRedisTemplate.opsForValue().setIfAbsent(delayKey, 1, Duration.ofSeconds(30)); if (Boolean.TRUE.equals(setOk)) { // 30 秒后执行关灯 taskScheduler.schedule(() - { // 再次确认当前仍无人 String status stringRedisTemplate.opsForValue().get(area: areaCode :person); if (none.equals(status)) { sendCommandByArea(areaCode, LIGHT_OFF_CMD); sendCommandByArea(areaCode, POWER_OFF_CMD); } }, Instant.now().plusSeconds(30)); } }这个“30 秒延时 二次确认”的机制能避免人还在器械上但传感器暂时没捕捉到的误关灯情况。实际运行效果不错用户投诉“灯突然熄灭”的情况基本没有了。3.4 WebSocket 实时推送后台设备状态和告警要实时展示到管理端用轮询太浪费资源。我引入了 WebSocket把 Netty 连入的设备状态变化推给后台。配置一个 WebSocket 端点Configuration EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint(/ws).setAllowedOriginPatterns(*).withSockJS(); } Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker(/topic); registry.setApplicationDestinationPrefixes(/app); } }服务端推送设备告警时直接使用SimpMessagingTemplateResource private SimpMessagingTemplate messagingTemplate; public void pushAlarm(DeviceAlarm alarm) { messagingTemplate.convertAndSend(/topic/alarm, JSON.toJSONString(alarm)); }这套推送方案在管理端设备监控页上表现很好设备离线、门磁异常都能在 1 秒内刷新到页面比原来轮询的体验强多了。4. 常见问题与排查技巧实录4.1 设备连接不稳定、频繁断开这个现象在项目刚上线的几天特别突出。排查下来主要有三个原因防火墙/安全组没有放行 9000 端口。Netty 服务端监听 9000但机器防火墙默认只放行 80/443设备连接直接被拒。这个问题用telnet 服务器IP 9000可以快速验证。心跳间隔设置和服务器判定超时时间太接近。设备 30 秒发一次心跳服务端 60 秒读超时表面看没问题但设备网络稍卡一点30 秒的心跳就可能延迟到 60 秒以上直接被误判离线。后来我把设备心跳改成 20 秒一次服务端超时设为 75 秒留足冗余。设备端网络使用 4G 信号不稳定。有些设备放在地下室4G 信号波动大我在设备端增加了断线重连机制服务端也要处理同一个设备反复连接的情况使用ConcurrentHashMap保存连接新连接建立时关闭旧连接避免“幽灵连接”占用资源。4.2 重复开门与并发“抢锁”上线当天就遇到用户疯狂点“开门”按钮后端的开门接口被同时调用多次设备收到多条开门指令门锁执行了多次开锁动作。单纯在代码里加synchronized只能解决单机问题如果后面部署多个实例就没用。我用 Redis 分布式锁解决Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lock:door: doorCode, userCode, Duration.ofSeconds(5));这里有个细节锁的过期时间一定要设置。如果不设置过期时间一旦服务异常宕机锁永远不释放后面的用户就没法开门了。5 秒过期时间足够完成一次开门操作同时又能有效拦截用户疯狂点击。另一个保障是数据库层的幂等约束。我在t_access_record表上加了唯一索引user_id device_code 当天日期。即使用户绕过 Redis 锁、两个请求同时到达数据库也只有一个能插入成功。4.3 设备状态与数据库不一致设备状态明明在 Redis 里显示在线管理端却看不到最新数据或者设备上报了告警数据库里查不到记录。这类问题主要出在落库策略上。我前面提到过“5 分钟批量同步”如果定时任务挂掉或者 Redis 数据被清空数据库和真实状态就会不一致。解决方案是定时任务补偿每 10 分钟扫描一次设备表把 Redis 中缓存的lastHeartbeatTime和数据库里的last_online_time对比超过 3 分钟没更新就置为离线。告警数据直接落库不走 Redis 中转。因为告警本身量小并且要求可靠必须先写库成功再缓存避免丢告警。4.4 连接数泄漏与内存增长Netty 服务端跑了几天后我发现内存占用持续增长连接数也异常偏高。排查后定位到两个问题未正确移除连接。设备每次重连都会创建新的 Channel但channelMap里 key 是设备编码直接覆盖旧值旧 Channel 没有关闭导致连接一直没有释放。后来在保存新连接前先取出旧 Channel 并关闭。线程池未设置拒绝策略。业务线程池用CallerRunsPolicy当队列满时在 Netty IO 线程执行任务会导致 IO 线程被阻塞进而拖慢整个服务。后来把队列加大、增加自定义拒绝策略的变体监测线程池活跃度。排查手段上我强烈建议在 Netty Pipeline 里加一个LoggingHandler将入站、出站数据以 hex 格式打印出来。配合tcpdump抓包能快速定位是设备没发数据、还是服务端没处理对。线上环境则通过 Prometheus 监控 JVM 线程数和连接数异常波动能第一时间感知。4.5 消息重复消费与指令重复下发MQTT 的 QoS 虽然是“最多一次”“至少一次”可选但实际网络中可能出现消息重发导致服务端重复处理状态上报。我在设备上报消息里加了一个messageId字段服务端用 Redis 的SETNX做消费幂等Boolean firstConsume stringRedisTemplate.opsForValue() .setIfAbsent(mqtt:msg: messageId, 1, Duration.ofMinutes(5)); if (Boolean.FALSE.equals(firstConsume)) { // 已消费过直接返回 return; }这个操作成本很低但对保证数据一致性帮助很大。设备上报“门锁开启”状态如果被重复处理可能导致计费重复开始造成用户投诉。写在最后这套系统从设计到跑起来实际迭代了快一个月。现在回看最核心的收获不是某个具体框架的 API而是“硬件接入和业务开发是两套思维”这件事。硬件侧讲究稳定、简单、少占用资源业务侧讲究并发、一致性、可观测性两者必须通过清晰的协议层和消息层衔接起来否则后期会越改越乱。如果你准备做类似项目我的建议是先画清设备协议表再写业务先把 Netty 接入稳定性打磨好再上业务功能上线前一定要做断网、断电、重连这类压测不然现场设备出了问题你连排查的入口都没有。后续还可以扩展的方向很多人脸识别门禁、按次计费、器械使用报告、故障自检提醒。这个架构留的扩展位足够场景从无人健身房迁移到共享自习室、无人便利店都不是难事。希望我这套从零搭建的经验能给你省点时间。