售货柜设备消息体系设计:心跳上报、故障告警、状态同步
作者:黒漂技术佬 适用读者:有RocketMQ基础,想设计一套IoT设备消息体系的同学
一、无人售货柜设备消息全景
一台无人售货柜,本质上是一个IoT设备。它需要跟服务端持续通信:报状态、告故障、收指令。这些通信全走RocketMQ。
消息分两个方向:
设备 → 服务端(上行) 服务端 → 设备(下行)
┌─────────────┐ ┌──────────────────┐
│ 心跳上报 │ │ 远程开门 │
│ 状态变更 │ │ 关门指令 │
│ 故障告警 │ │ 重启指令 │
│ 交易上报 │ │ 配置更新 │
└─────────────┘ └──────────────────┘
上行 :设备主动发,服务端消费。 下行:服务端发,设备消费。设备作为RocketMQ消费者常驻运行。
二、Topic和Tag设计
IoT设备消息量大、种类多,Topic和Tag的设计直接决定系统的可维护性和性能。
2.1 设计原则
- Topic粒度大:一个业务域一个Topic,不要一个消息类型一个Topic
- Tag粒度细:同Topic下用Tag区分不同事件
- 上下行分离:上行和下行用不同Topic,避免互相干扰
2.2 完整Topic/Tag规划
yaml
设备上行 Topic: device_upward
├── Tag: heartbeat 心跳上报
├── Tag: status 状态变更
├── Tag: alert 故障告警
└── Tag: transaction 交易上报
设备下行 Topic: device_downward
├── Tag: open_door 远程开门
├── Tag: close_door 关门指令
├── Tag: reboot 重启设备
└── Tag: config_update 配置更新
2.3 为什么不用一个Topic搞定?
有人问:为什么不全部用 device_topic,全部用Tag区分上下行?
原因有三:
- 消费隔离:上行消费者(服务端)和下行消费者(设备端)消费模式完全不同,混在一个Topic容易互相影响
- 权限控制:上行Topic只允许设备写,下行Topic只允许服务端写,便于权限管理
- 扩容独立:上行消息量大(1000台设备每5秒一次心跳),下行消息量小(偶尔发指令),两个Topic可以独立配置队列数和存储策略
三、心跳消息设计
心跳是设备消息体系的基础------服务端通过心跳判断设备是否在线。
3.1 消息体结构
java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class HeartbeatMessage implements Serializable {
private String deviceId; // 设备唯一标识
private Long timestamp; // 上报时间戳
private Double temperature; // 柜内温度(℃)
private Double voltage; // 电池电压(V)
private Integer signalStrength; // 网络信号强度
private String networkType; // 网络类型(4G/5G/WiFi)
private Integer cpuUsage; // CPU使用率(%)
private Integer memoryUsage; // 内存使用率(%)
private String softwareVersion; // 固件版本号
}
3.2 设备端发送心跳
设备端定时发送,通常5-10秒一次:
java
@Component
public class HeartbeatSender {
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Value("${device.id}")
private String deviceId;
/**
* 每5秒发送一次心跳(用Spring定时任务)
*/
@Scheduled(fixedRate = 5000)
public void sendHeartbeat() {
HeartbeatMessage heartbeat = HeartbeatMessage.builder()
.deviceId(deviceId)
.timestamp(System.currentTimeMillis())
.temperature(readTemperature())
.voltage(readVoltage())
.signalStrength(readSignal())
.networkType(getNetworkType())
.cpuUsage(readCpuUsage())
.memoryUsage(readMemoryUsage())
.softwareVersion(getSoftwareVersion())
.build();
// 心跳用单向发送,不需要确认结果
rocketMQTemplate.sendOneWay("device_upward:heartbeat", heartbeat);
}
// 以下方法读取设备硬件传感器数据(省略具体实现)
private Double readTemperature() { return 4.5; }
private Double readVoltage() { return 12.3; }
private Integer readSignal() { return 85; }
private String getNetworkType() { return "4G"; }
private Integer readCpuUsage() { return 23; }
private Integer readMemoryUsage() { return 45; }
private String getSoftwareVersion() { return "v2.1.3"; }
}
3.3 服务端消费心跳
服务端消费心跳做两件事:更新Redis在线状态 + 写InfluxDB时序数据。
java
@Slf4j
@Component
@RocketMQMessageListener(
topic = "device_upward",
selectorExpression = "heartbeat",
consumerGroup = "heartbeat-consumer-group",
messageModel = MessageModel.CLUSTERING
)
public class HeartbeatConsumer implements RocketMQListener<HeartbeatMessage> {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private InfluxDBMapper influxDBMapper;
@Override
public void onMessage(HeartbeatMessage heartbeat) {
String deviceId = heartbeat.getDeviceId();
// 1. 更新Redis设备在线状态(key设30秒过期,心跳超时自动过期)
String key = "device:online:" + deviceId;
redisTemplate.opsForValue().set(
key,
JSON.toJSONString(heartbeat),
30, TimeUnit.SECONDS
);
// 2. 写入InfluxDB时序数据库(用于历史趋势查询)
influxDBMapper.writeHeartbeat(heartbeat);
// 3. 温度异常检测
if (heartbeat.getTemperature() != null
&& heartbeat.getTemperature() > 10.0) {
log.warn("设备温度异常: deviceId={}, temp={}",
deviceId, heartbeat.getTemperature());
// 触发告警流程...
}
}
}
3.4 心跳超时检测:延时消息
Redis key设了30秒过期,但光过期不够------要主动检测超时设备。用RocketMQ的延时消息实现:
java
/**
* 发送延时检测消息
* 每台设备发心跳后,同时发一条30秒后到达的延时消息
* 延时消息到达时检查设备是否还在线
*/
public void sendHeartbeatCheck(String deviceId) {
Message<String> msg = MessageBuilder
.withPayload(deviceId)
.build();
// delayLevel=3 对应10秒延时(RocketMQ默认18个延时级别)
// 级别对应: 1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
rocketMQTemplate.syncSend(
"device_check_topic:heartbeat_timeout",
msg,
3000, // sendTimeout
3 // delayLevel=3 → 10秒后消费者收到
);
}
延时消息消费者:
java
@Slf4j
@Component
@RocketMQMessageListener(
topic = "device_check_topic",
selectorExpression = "heartbeat_timeout",
consumerGroup = "heartbeat-check-consumer-group"
)
public class HeartbeatTimeoutConsumer implements RocketMQListener<String> {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private AlertService alertService;
@Override
public void onMessage(String deviceId) {
String key = "device:online:" + deviceId;
// 检查Redis中设备是否还在线
Boolean exists = redisTemplate.hasKey(key);
if (Boolean.FALSE.equals(exists)) {
// Redis key已过期,说明设备心跳超时了
log.warn("设备心跳超时: deviceId={}", deviceId);
alertService.sendOfflineAlert(deviceId);
}
// 如果key存在,说明设备在30秒内又发了一次心跳,正常
}
}
四、故障告警消息
设备出故障时要及时上报,服务端根据告警级别走不同的处理链路。
4.1 告警消息体
java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AlertMessage implements Serializable {
private String deviceId; // 设备ID
private String alertId; // 告警唯一ID
private String alertLevel; // 告警级别: INFO/WARN/ERROR/CRITICAL
private String alertType; // 告警类型: temp_high/voltage_low/network_offline/motor_fault
private String description; // 告警描述
private Long timestamp; // 告警时间
private Map<String, String> extra; // 扩展字段
}
4.2 告警级别与处理链路
markdown
告警消息到达
│
▼
┌──────────────────────┐
│ 告警消费者 │
│ 1. 存入告警数据库 │
│ 2. 按级别分支处理 │
└──────┬───────────────┘
│
┌────┼────────────┬────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
INFO WARN ERROR CRITICAL
记日志 存库+通知 存库+推送通知 存库+电话告警
(企业微信/钉钉) + 立即创建工单
4.3 告警消费者实现
java
@Slf4j
@Component
@RocketMQMessageListener(
topic = "device_upward",
selectorExpression = "alert",
consumerGroup = "alert-consumer-group",
messageModel = MessageModel.CLUSTERING
)
public class AlertConsumer implements RocketMQListener<AlertMessage> {
@Autowired
private AlertMapper alertMapper;
@Autowired
private NotificationService notificationService;
@Autowired
private WorkOrderService workOrderService;
@Override
public void onMessage(AlertMessage alert) {
log.warn("收到设备告警: deviceId={}, level={}, type={}",
alert.getDeviceId(), alert.getAlertLevel(), alert.getAlertType());
// 1. 存库(所有级别都存)
alertMapper.insert(alert);
// 2. 按级别处理
switch (alert.getAlertLevel()) {
case "INFO":
// INFO级别只记录,不通知
break;
case "WARN":
// WARN级别推送通知
notificationService.sendNotification(
alert.getDeviceId(),
"设备告警: " + alert.getDescription(),
NotificationChannel.WECHAT
);
break;
case "ERROR":
// ERROR级别推送+创建工单
notificationService.sendNotification(
alert.getDeviceId(),
"设备故障: " + alert.getDescription(),
NotificationChannel.WECHAT,
NotificationChannel.DINGTALK
);
workOrderService.createWorkOrder(alert);
break;
case "CRITICAL":
// CRITICAL级别:电话告警+立即创建紧急工单
notificationService.sendUrgentNotification(alert);
workOrderService.createUrgentWorkOrder(alert);
break;
default:
log.warn("未知告警级别: {}", alert.getAlertLevel());
}
}
}
五、状态同步消息
售货柜有明确的状态机,状态变更需要保证顺序消费。
5.1 设备状态机
markdown
扫码开门
IDLE ──────────→ OPEN
│
检测到开门完成
│
▼
SHOPPING
│
检测到关门
│
▼
CLOSING
│
计算完成扣款
│
▼
TRANSACTION
│
事务完成
│
▼
IDLE (回到空闲)
5.2 状态变更消息与顺序消费
一台设备的状态必须按顺序处理:不能先处理"SHOPPING"再处理"OPEN",逻辑会乱。用RocketMQ的顺序消息保证:同一台设备的状态消息按发送顺序消费。
java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DeviceStatusMessage implements Serializable {
private String deviceId; // 设备ID
private String fromStatus; // 原状态
private String toStatus; // 目标状态
private Long timestamp; // 变更时间
private String trigger; // 触发原因(user_scan/close_detect/payment_done)
private Map<String, Object> extra; // 扩展数据
}
生产者发送顺序消息------关键在 hashKey 选 deviceId:
java
@Service
public class DeviceStatusProducer {
@Autowired
private RocketMQTemplate rocketMQTemplate;
public void sendStatusChange(DeviceStatusMessage message) {
// syncSendOrderly:发送顺序消息
// 第二个参数 hashKey = deviceId,保证同一设备的状态消息路由到同一队列
rocketMQTemplate.syncSendOrderly(
"device_upward:status",
MessageBuilder.withPayload(message).build(),
message.getDeviceId() // hashKey → 同一设备进同一队列
);
}
}
消费者使用顺序消费模式:
java
@Slf4j
@Component
@RocketMQMessageListener(
topic = "device_upward",
selectorExpression = "status",
consumerGroup = "device-status-consumer-group",
messageModel = MessageModel.CLUSTERING,
consumeMode = ConsumeMode.ORDERLY // ← 顺序消费
)
public class DeviceStatusConsumer
implements RocketMQListener<DeviceStatusMessage> {
@Override
public void onMessage(DeviceStatusMessage message) {
log.info("设备状态变更: deviceId={}, {} → {}, trigger={}",
message.getDeviceId(),
message.getFromStatus(),
message.getToStatus(),
message.getTrigger());
// 更新设备状态缓存
updateDeviceStatus(message.getDeviceId(), message.getToStatus());
// 根据目标状态执行对应逻辑
switch (message.getToStatus()) {
case "OPEN":
// 设备开门了,启动购物计时
startShoppingTimer(message.getDeviceId());
break;
case "SHOPPING":
// 用户在购物中
break;
case "CLOSING":
// 检测到关门,开始计算扣款
triggerPaymentCalculation(message);
break;
case "TRANSACTION":
// 交易完成
completeTransaction(message);
break;
case "IDLE":
// 回到空闲,释放设备锁
releaseDeviceLock(message.getDeviceId());
break;
}
}
}
小白疑问:ConsumeMode.ORDERLY 和 CONCURRENTLY 有什么区别?
- CONCURRENTLY:多线程并发消费,不保证顺序,速度快
- ORDERLY:单线程按队列顺序消费,保证同一队列内消息按序处理,速度慢
用ORDERLY时,hashKey选什么,就保证什么维度有序。选deviceId,就是同一设备的状态消息有序。
六、下行指令消息
服务端需要向设备下发控制指令:远程开门、重启、更新配置等。
6.1 指令消息体
java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CommandMessage implements Serializable {
private String commandId; // 指令唯一ID
private String deviceId; // 目标设备ID
private String commandType; // 指令类型: open_door/close_door/reboot/config_update
private Map<String, Object> params; // 指令参数
private Long timestamp; // 下发时间
private Integer timeout; // 超时时间(秒),超时未确认则重发
}
6.2 指令下发与ACK确认机制
scss
服务端 RocketMQ 设备
│ │ │
│ ① 发送指令消息 │ │
│────────────────────────→│ │
│ │ ② 设备消费指令 │
│ │────────────────────────→│
│ │ │ ③ 执行指令
│ │ ④ 设备回ACK │
│ │←────────────────────────│
│ │ │
│ ⑤ 发送延时检查消息(30秒后) │ │
│────────────────────────→│ │
│ │ │
│ (30秒后) │ ⑥ 延时消息到达 │
│←────────────────────────│ │
│ ⑦ 检查ACK是否收到 │ │
│ 收到 → 指令完成 │ │
│ 没收到 → 重发指令 │ │
6.3 服务端发送指令
java
@Service
public class DeviceCommandService {
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 远程开门
*/
public void sendOpenDoorCommand(String deviceId, String userId) {
String commandId = UUID.randomUUID().toString();
CommandMessage command = CommandMessage.builder()
.commandId(commandId)
.deviceId(deviceId)
.commandType("open_door")
.params(Map.of("userId", userId, "maxDuration", 120))
.timestamp(System.currentTimeMillis())
.timeout(30)
.build();
// 发送指令到下行Topic,用deviceId做hashKey保证顺序
rocketMQTemplate.syncSendOrderly(
"device_downward:open_door",
MessageBuilder.withPayload(command).build(),
deviceId
);
// 记录指令待确认状态到Redis
redisTemplate.opsForValue().set(
"device:command:pending:" + commandId,
JSON.toJSONString(command),
60, TimeUnit.SECONDS
);
// 发送延时检查消息(30秒后检查ACK)
sendAckCheck(commandId, deviceId, 30);
}
private void sendAckCheck(String commandId, String deviceId, int delaySeconds) {
// delayLevel映射: 1s=1, 5s=2, 10s=3, 30s=4
int delayLevel = delaySeconds <= 1 ? 1
: delaySeconds <= 5 ? 2
: delaySeconds <= 10 ? 3 : 4;
rocketMQTemplate.syncSend(
"device_check_topic:command_ack_timeout",
MessageBuilder.withPayload(
commandId + ":" + deviceId
).build(),
3000,
delayLevel
);
}
}
6.4 设备端消费指令 + 回ACK
设备端作为RocketMQ消费者常驻运行:
java
@Component
@RocketMQMessageListener(
topic = "device_downward",
selectorExpression = "open_door || close_door || reboot || config_update",
consumerGroup = "${device.id}-command-consumer-group",
messageModel = MessageModel.CLUSTERING,
consumeMode = ConsumeMode.ORDERLY
)
public class DeviceCommandConsumer
implements RocketMQListener<CommandMessage> {
@Value("${device.id}")
private String deviceId;
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Autowired
private HardwareController hardwareController;
@Override
public void onMessage(CommandMessage command) {
log.info("收到指令: commandId={}, type={}",
command.getCommandId(), command.getCommandType());
try {
// 执行指令
switch (command.getCommandType()) {
case "open_door":
hardwareController.openDoor();
break;
case "close_door":
hardwareController.closeDoor();
break;
case "reboot":
hardwareController.scheduleReboot();
break;
case "config_update":
hardwareController.updateConfig(command.getParams());
break;
}
// 回ACK:发送到上行Topic
sendAck(command.getCommandId(), "SUCCESS", null);
} catch (Exception e) {
log.error("指令执行失败: commandId={}", command.getCommandId(), e);
sendAck(command.getCommandId(), "FAILED", e.getMessage());
}
}
private void sendAck(String commandId, String status, String errorMsg) {
Map<String, Object> ack = new HashMap<>();
ack.put("commandId", commandId);
ack.put("deviceId", deviceId);
ack.put("status", status);
ack.put("timestamp", System.currentTimeMillis());
if (errorMsg != null) {
ack.put("error", errorMsg);
}
rocketMQTemplate.syncSend("device_upward:command_ack", ack);
}
}
6.5 ACK超时检查消费者
java
@Slf4j
@Component
@RocketMQMessageListener(
topic = "device_check_topic",
selectorExpression = "command_ack_timeout",
consumerGroup = "command-ack-check-consumer-group"
)
public class CommandAckCheckConsumer implements RocketMQListener<String> {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private DeviceCommandService commandService;
@Override
public void onMessage(String payload) {
// payload格式: commandId:deviceId
String[] parts = payload.split(":");
String commandId = parts[0];
String deviceId = parts[1];
// 检查Redis中ACK是否已收到
String ackKey = "device:command:ack:" + commandId;
Boolean ackReceived = redisTemplate.hasKey(ackKey);
if (Boolean.FALSE.equals(ackReceived)) {
log.warn("指令ACK超时未收到,重发: commandId={}", commandId);
// 重发指令(重试次数限制)
String pendingJson = redisTemplate.opsForValue()
.get("device:command:pending:" + commandId);
if (pendingJson != null) {
CommandMessage originalCmd = JSON.parseObject(
pendingJson, CommandMessage.class);
commandService.resendCommand(originalCmd);
}
}
}
}
七、完整消息体系架构
把所有组件拼起来,售货柜设备消息体系全景如下:
scss
┌─────────────────────────────────────────────────────────────┐
│ 无人售货柜设备 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │心跳发送器 │ │告警发送器 │ │状态发送器 │ │指令消费者 │ │
│ │(5秒/次) │ │(事件触发) │ │(状态变更) │ │(常驻监听) │ │
│ └─────┬────┘ └─────┬────┘ └─────┬────┘ └────▲─────┘ │
│ │ │ │ │ │
└────────┼─────────────┼─────────────┼──────────────┼─────────┘
│ │ │ │
═════╪═════════════╪═════════════╪══════════════╪════════
RocketMQ
│ │ │ │
═════╪═════════════╪═════════════╪══════════════╪════════
│ │ │ │
▼ ▼ ▼ │
┌─────────────────────────────────────┐ │
│ 服务端 │ │
│ │ │
│ ┌──────────┐ ┌──────────┐ │ │
│ │心跳消费者 │ │告警消费者 │ │ ┌──────────┐│
│ │→Redis │ │→存库 │ │ │指令发送器 ││
│ │→InfluxDB │ │→通知推送 │ │ │(远程开门等)│
│ │→超时检测 │ │→创建工单 │ │ └────┬─────┘│
│ └──────────┘ └──────────┘ │ │ │
│ │ │ │
│ ┌──────────────────────┐ │ │ │
│ │状态消费者(顺序消费) │ │ │ │
│ │→状态机流转 │ │ │ │
│ │→触发业务逻辑 │ │ │ │
│ └──────────────────────┘ │ │ │
│ │ │ │
│ ┌──────────────────────┐ │ │ │
│ │ACK超时检查消费者 │ │ │ │
│ │→检查指令是否确认 │ │ │ │
│ │→超时重发 │ │ │ │
│ └──────────────────────┘ │ │ │
└─────────────────────────────────────┘ │ │
│ │
device_downward ◀───┘ │
(服务端→设备) │
│
device_upward ─────────────┘
(设备→服务端)
八、消息量评估
量级测算在架构设计阶段就要做,否则上线后容量不足就尴尬了。
8.1 基础数据
假设1000台售货柜,每台5秒发一次心跳:
yaml
心跳消息量 = 1000台 / 5秒 = 200条/秒
| 消息类型 | 频率 | 日消息量 | 备注 |
|---|---|---|---|
| 心跳上报 | 200条/秒 | ~1700万条/天 | 量最大,用异步刷盘 |
| 状态变更 | 10条/小时/台 | ~24万条/天 | 中等 |
| 故障告警 | 0.1条/小时/台 | ~2400条/天 | 量小但重要 |
| 交易上报 | 5条/小时/台 | ~12万条/天 | 中等 |
| 下行指令 | 2条/小时/台 | ~5万条/天 | 量小 |
| ACK确认 | 2条/小时/台 | ~5万条/天 | 与下行指令对应 |
8.2 Broker容量规划
以心跳为例,单条心跳消息约200字节:
每秒写入量 = 200条 × 200字节 = 40KB/秒
每天写入量 = 40KB × 86400秒 ≈ 3.3GB/天
1000台设备每天约3.3GB心跳数据,加上其他消息,日总量约5GB。Broker存储建议保留7天,单节点35GB足够。考虑到主从复制和刷盘开销,用SSD硬盘。
8.3 消费者并发度
心跳消费者200条/秒,单线程消费可能跟不上。通过增加consumerGroup下的消费者实例来提升并发:
scss
200条/秒 / 单消费者处理能力(约50条/秒) = 4个消费者实例
部署4个心跳消费者实例,配合RocketMQ的CLUSTERING模式自动负载均衡。
九、总结
售货柜设备消息体系设计的核心套路:
| 设计点 | 方案 |
|---|---|
| Topic/Tag | 上下行分离,按业务域分Topic,按事件分Tag |
| 心跳检测 | 设备单向发送心跳 + Redis过期 + 延时消息超时检测 |
| 故障告警 | 按级别(INFO/WARN/ERROR/CRITICAL)分级处理 |
| 状态同步 | ShardingKey=deviceId + 顺序消费保证状态机正确流转 |
| 下行指令 | 指令下发 + 设备回ACK + 延时消息检查ACK超时重发 |
| 消息量 | 1000台设备≈200条/秒心跳,Broker用SSD+异步刷盘 |
一句话总结:上行消息按重要程度选发送方式,下行指令靠ACK+延时检查保可靠,状态变更靠顺序消费保正确。