1. 业务背景
我们在智能硬件实际项目中设备的网络环境是不稳定的,会发生和物联网平台通信断连,导致数据丢失,因此设备会重发丢失的数据。但是设备嵌入式这边保存数据是一块连续内存保存的,如果丢失整个连续内存块的数据都会重传。
还有一个因数,就是MQTT由于我们选择的服务质量是 至少一次,数据可能重复。如下:
@Bean
public MessageProducer inbound() {
String consumerId = "consumerClient" + UUID.randomUUID();
MqttPahoMessageDrivenChannelAdapter adapter = new MqttPahoMessageDrivenChannelAdapter(consumerId,
mqttClientFactory(), new String[]{topic});
adapter.setCompletionTimeout(completionTimeout);
adapter.setConverter(new DefaultPahoMessageConverter());
// 设置服务质量
// 0 最多一次,数据可能丢失;
// 1 至少一次,数据可能重复;
// 2 只有一次,有且只有一次;最耗性能
adapter.setQos(1);
adapter.setOutputChannel(mqttInputChannel());
return adapter;
}
因此业务后端订阅收到的设备数据会存在重复的情况。这些重复的设备数据肯定是不能直接入库存储,因此,我们首要的任务是在收到设备数据之后,进行去重处理。
设备数据去重处理
2. 设备某天的数据去重处理
去重我们的解决办法是:基于redis缓存来判断数据是否存在,存在则说明是重复数据,剔除重复数据,得到有效数据之后,进行后续逻辑处理;不存在则说明是有效数据,缓存当前数据到redis,下次继续基于缓存的数据进行去重判断。
设备数据去重处理
2.1 获取缓存的设备某天的数据
// 查询:设备 某天 数据
Set<String> deviceDayDataSet = recordService.queryDeviceDayData(devId, dataTimestamp);
同样查询我们走redis缓存。避免每次都去查询DB带来开销过大,如果reids中没有数据则查询DB保存到redis。设备某天的数据是缓存到redis中的set集合中。
Set<String> deviceDayDataSet = redisUtils.setMembers(deviceDayDataKey);
if(!Objects.isNull(deviceDayDataSet)) {
return deviceDayDataSet;
}
// redis中没有,则从DB中查询
RecordPageReq recordPageReq = new RecordPageReq();
recordPageReq.setDevId(devId);
recordPageReq.setDataTimeStamp(dataTimestamp);
String[] deviceDayDataFromDB = queryDeviceDayDataFromDB(recordPageReq);
// 然后缓存到Redis并设置过期时间
redisUtils.sAdd(deviceDayDataKey, deviceDayDataFromDB);
redisUtils.expire(deviceDayDataKey, deviceDayBatchDataTimestampExpTime, TimeUnit.DAYS); // 缓存18天(因为设备端历史数据最多保存15天)
// 使用流将数组转换为Set
Set<String> deviceDayDataFromDBSet = Arrays.stream(deviceDayDataFromDB).collect(Collectors.toSet());
return deviceDayDataFromDBSet;
2.2 对设备某天的数据去重
// 设备 某天 数据 进行去重处理:
// 根据当前设备上报数据的开始时间戳、时长 剔除重复数据(不在deviceDayDataSet集合中),得到有效数据
List<Record> removedDuplicateDataRecordList = removeDuplicateData(recordList, deviceDayDataSet);
去除设备某天的重复数据,实际就是判断当前上报的设备数据不在当前缓存的设备数据集合中,剩下的数据就是去除重复数据之后的有效数据。
// 如果查询到的设备数据为空,则不用去重
if(Objects.isNull(deviceDayDataSet) || deviceDayDataSet.isEmpty()) {
log.info("查询到的设备数据为空,则不用去重");
return recordList;
}
// 根据当前设备上报数据的开始、持续时长,剔除重复数据(不在deviceDayDataSet集合中),得到有效数据
List<Record> removedRecordList = recordList.stream()
.filter(r->!deviceDayDataSet.contains(r.getStartTimestamp() + "#" + r.getDuration()))
.collect(Collectors.toList());
return removedRecordList;
2.3 设备 某天 数据 缓存到redis
// 设备 某天 数据 缓存到redis中
String[] deviceDayDataFromDB = recordListAfterSplit.stream()
.map(s->s.getStartTimestamp() + "#" + s.getDuration())
.collect(Collectors.toList()).toArray(new String[0]);
if(!Objects.isNull(deviceDayDataFromDB) && deviceDayDataFromDB.length > 0) {
redisUtils.sAdd(deviceDayDataKey, deviceDayDataFromDB);
redisUtils.expire(deviceDayDataKey, deviceDayBatchDataTimestampExpTime, TimeUnit.DAYS); // 缓存18天(因为设备端历史数据最多保存15天)
}
// 缓存: 设备 某天 批次数据的时间戳到redis
if(!CollUtil.isEmpty(recordListAfterSplit)) {
...
String timestampStrOfDeviceDayBatchData = String.valueOf(recordListAfterSplit.get(0).getTimestamp());
redisUtils.setEx(deviceDayBatchDataTimestampKey, timestampStrOfDeviceDayBatchData, deviceDayBatchDataTimestampExpTime, TimeUnit.DAYS); // 缓存18天(因为设备端历史数据最多保存15天)
}
3. 利用RocketMQ延迟消息刷新历史数据所属某天的汇总报告
因为设备不可能一直连接到物联网平台,设备会掉电,导致数据不会实时上报。这是设备会存储数据,当设备上线或者上电之后再一起上报。因此就会造成,今天会上报昨天,前天,一星期前的数据。这些数据就是历史数据。
假如上报昨天的数据,那么此时需要更新昨天的报告数据,就需要重新刷新昨天的报告。但是每次收到历史数据就刷新更新历史数据所属某天的汇总统计数据,这样会有如下问题:
由于设备上报某天的历史数据是分批上报,如果每次收到就刷新某天的汇总数据,会导致大量查询DB以及写入刷新操作,给DB带来压力。同时最终结果是以最后汇总数据为准,中间刷新的结果其实没啥作用。
解决办法:由于我们的月报中的每天汇总数据不需要实时性,我们只需要定时刷新某天的汇总报告数据就可以了,即收到历史数据之后,延迟5分钟刷新汇总报告。所以,我们借助RocketMQ的延迟消息,来处理。
基于RocketMQ延迟刷新某天汇总数据
3.1 解析历史数据
解析历史数据所属哪天、哪个设备、哪个用户、是什么类型的数据,方便后面重新刷新某天的汇总数据。
// 记录:用户 设备 某天 某类型
Set<UserDeviceDayTypeDTO> userDeviceDayTypeSet = new HashSet<>();
recordListAfterSplit.forEach(record -> {
UserDeviceDayTypeDTO userDeviceDayTypeDTO = new UserDeviceDayTypeDTO(record.getUserId(), devId, record.getDataTime(), dataType);
userDeviceDayTypeSet.add(userDeviceDayTypeDTO);
});
最初的解决办法是:每次收到历史数据,就直接根据上面的条件,重新查询DB得到统计数据写入刷新到每天的汇总统计表中,如下:
userDeviceDayTypeSet.forEach(userDeviceDayType->{
historyDayReportService.parseAndSaveDayDeviceTypeReport(
userDeviceDayType.getDay(),
userDeviceDayType.getDevId(),
userDeviceDayType.getUserId(),
dataObj.getInteger("UserDateType"));
});
# 根据条件查询统计数据
QueryWrapper<Record> queryWrapper = new QueryWrapper<>();
queryWrapper
.select("user_id as userId, dev_id as devId, data_time as dataTime, sum(duration) as duration")
.eq("dev_id", devId)
.eq("user_id", userId)
.eq("data_time", thisDay)
.eq("data_type", 1);
List<Map<String, Object>> userDayDurationList = recordMapper.selectMaps(queryWrapper);
重新写入更新每天数据统计表
statisticsMapper.batchInsertOrUpdateDuration(userDayDurationList);
后期改为利用RocketMQ延迟消息来刷新历史某天汇总数据。
3.2 发送到RocketMQ延迟消息
发送之前判断:距离上次发送是否超过了5分钟,超过5分钟则发送;没有超过不发送;
// 发送RocketMQ延迟消息(历史数据所属哪天信息)
userDeviceDayTypeSet.forEach(userDeviceDayType->{
HistoryDataDayDTO historyDataDayDTO = new HistoryDataDayDTO();
historyDataDayDTO.setUserId(userDeviceDayType.getUserId());
historyDataDayDTO.setDevId(userDeviceDayType.getDevId());
historyDataDayDTO.setDay(userDeviceDayType.getDay());
historyDataDayDTO.setUserDataType(dataObj.getInteger("UserDateType"));
// 发送之前判断:距离上次发送是否超过了5分钟,超过5分钟则发送;没有超过不发送;
// 查询redis
String userDeviceDayTypeDataSendMQTimestampKey = RedisKeyConstant
.getUserDeviceDayTypeDataSendMQTimestampKey(
userDeviceDayType.getUserId(),
userDeviceDayType.getDevId(),
userDeviceDayType.getDay(),
dataObj.getInteger("UserDateType"));
String userDeviceDayTypeDataSendMQTimestamp = redisUtils.get(userDeviceDayTypeDataSendMQTimestampKey);
if(StrUtil.isEmpty(userDeviceDayTypeDataSendMQTimestamp)) {
// 为空,表明距离上次发送数据超过5分钟,需要发送延迟消息。因为这个key有5分钟的有效期
sendHistoryBelongToWhichDayDelayMsg(historyDataDayDTO);
// 缓存发送时间, 过期时间5分钟
redisUtils.setEx(userDeviceDayTypeDataSendMQTimestampKey,
DateUtil.formatDateTime(new Date()), 5, TimeUnit.MINUTES);
log.info("发送历史数据所属哪天,historyDataDayDTO:{}", JSON.toJSONString(historyDataDayDTO));
}
});
发送延迟消息(即历史数据所属信息)
/**
* 发送RocketMQ延迟消息(历史数据所属哪天)
*
* 然后消费端消费处理:刷新历史数据所属哪天报告
*/
public void sendHistoryBelongToWhichDayDelayMsg(HistoryDataDayDTO historyDataDayDTO) {
// 发送RocketMQ延迟消息,延迟级别9表示延时5分钟消费
SendResult result = MQClient.custom().syncSend(
historyBelongToWhichDayTopic,
JSON.toJSONString(historyDataDayDTO),
9);
log.info("发送RocketMQ延迟消息(历史数据所属哪天)historyDataDayDTO:{}, messageTopic:{}, result:{}",
JSON.toJSONString(historyDataDayDTO),
historyBelongToWhichDayTopic,
JSON.toJSONString(result));
}
3.3 消费端延迟5分钟消费到消息,然后刷新历史某天汇总数据
消费的订阅历史数据所属信息,然后 刷新历史数据所属那天的汇总数据。
/**
* 订阅历史数据所属信息
*/
@Slf4j
@Component
@RocketMQMessageListener(topic = "HISTORY_BELONG_TO_WHICH_DAY", consumerGroup = "HISTORY_GROUP")
public class HistoryDataListener implements InitializingBean, RocketMQListener<MessageExt> {
@Resource
private HistoryDayReportService historyDayReportService;
@Override
public void onMessage(MessageExt messageExt) {
String msgId = messageExt.getMsgId();
log.info("HistoryDataListener.onMessage,msgId:{}",msgId);
String msg = new String(messageExt.getBody());
HistoryDataDayDTO historyDataBelongToInfo = JSON.parseObject(msg, HistoryDataDayDTO.class);
log.info("HistoryDataListener.onMessage, historyDataBelongToInfo:{}",historyDataBelongToInfo);
// 刷新历史数据所属那天的汇总数据
try {
historyDayReportService.parseAndSaveDayDeviceTypeReport(
historyDataBelongToInfo.getDay(),
historyDataBelongToInfo.getDevId(),
historyDataBelongToInfo.getUserId(),
historyDataBelongToInfo.getUserDataType());
} catch (Exception e) {
log.error("刷新历史数据所属那天的汇总数据异常,e:{}", e);
}
}