一、背景介绍
项目是公司一个未验收的智慧园区演示项目,项目大屏上之前都是demo静态数据,现在通过mqtt接收物联网设备实时传感器数据并在大屏页面上进行展示,大屏上有一个长度为10的列表动态刷新展示实时物联设备传感器数据。因为项目并没有正式验收还处在一个demo状态,并且需求简单仅作简单展示并没有统计、图表等需求,物联设备实时传感器数据量大为了不占用过多存储资源,因此物联设备传感器数据没有使用mysql或者mongo等进行存储。
因为不需要考虑历史数据存储问题,仅需保留近10条数据即可,因此考虑使用一个定长队列来实现该需求,将新收到的传感器数据入队列,老数据出队列随即丢失。
二、实现思路
物联设备数量较多,而且传感器数据推送频率较高约为3s/次/设备,因此传感器数据存储队列需要考虑并发问题。
内存中维护长度为10的队列。内存中维护队列的较为简单的方式是使用ConcurrentLinkedQueue来实现。
redis中维护长度为10的队列。redis中没有队列这种数据结构,实际通过list数据结构和操作就可以实现队列。
相关命令介绍:
LLEN key
计算List的长度
时间复杂度:O(1)
RPOP key [count]
从List的右侧移除元素
时间复杂度:O(N),N为移除元素的个数。
LPOP key [count]
从List的左侧移除元素
时间复杂度:O(N),N为移除元素的个数。
RPUSH key element [element ...]
从List的右侧保存元素
时间复杂度:O(N),N为保存元素的个数。
LPUSH key element [element ...]
从List的左侧保存元素
时间复杂度:O(N),N为保存元素的个数。
LTRIM key startInclusive endInclusive
对列表进行修剪,只保留startInclusive-endInclusive区间的元素(前后都包括),不在指定区间内的元素都将被删除。
三、实现方案
方案一:lua脚本
传感器数据入队列和出队列无法通过单条redis命令实现,因此需要考虑多条命令间的原子性,可以使用lua脚本进行操作。最新的传感器数据通过LPUSH添加到队列头部,再对list进行LTRIM操作,只保留前10个元素,其他内容丢弃。如此就实现了一个前入后弃的定长为10的队列。而且由redis执行lua脚本保证原子性。(只能保证执行lua脚本中多条命令时不会有其他命令进行执行)
springboot项目中使用redisTemplate执行lua脚本代码示例:
@Repository
public class DeviceRepository {
private static final Logger logger = LoggerFactory.getLogger(DeviceRepository.class);
public static final String DEVICE_REG_INFO_KEY = "sxrjy:device_reg_infos";
@Resource
private RedisTemplate<String, String> redisTemplate;
/**
* redis lua脚本:维护一个定长队列(长度10),每次新数据加到队列头部,超过长度部分丢弃
*/
private static final String FIXED_LENGTH_QUEUE_LUA_SCRIPT =
"local key = KEYS[1] " +
"redis.call('lpush',key,unpack(ARGV))" +
"redis.call('ltrim',key,0,9)";
/**
* 推送数据到redis
*
* @param regInfos 设备寄存器信息list
* @date 2022/8/3 10:03
*/
public void pushDeviceRegInfo(List<DeviceRegInfo> regInfos) {
// lua脚本
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(FIXED_LENGTH_QUEUE_LUA_SCRIPT);
String[] regInfoJsons = regInfos.stream().map(JSON::toJSONString).toArray(String[]::new);
// 执行lua脚本
redisTemplate.execute(redisScript, Collections.singletonList(DEVICE_REG_INFO_KEY), regInfoJsons);
}
/**
* 获取设备寄存器数据列表
*
* @date 2022/8/3 12:17
*/
public List<DeviceRegInfoVO> getDeviceRegInfoList() {
return Optional.ofNullable(redisTemplate.opsForList().range(DEVICE_REG_INFO_KEY, 0, -1))
.orElseGet(ArrayList::new)
.stream()
// str转实体
.map(s -> JSON.parseObject(s, DeviceRegInfo.class))
// 时间倒叙排序列表
.sorted(Comparator.comparing(DeviceRegInfo::getCreateTime).reversed())
// 只取10条
.limit(10)
// po转vo
.map(DeviceUtil::deviceRegInfoPoToVo)
.collect(Collectors.toList());
}
}
方案二:multi+exec
redis通过MULTI、EXEC、WATCH等命令来实现事务(transaction)功能。redis的事务提供了一种将多个命令请求打包、一次性、按顺序地执行多个命令的机制。在事务执行期间,服务器不会中断事务而去执行其他客户端的命令请求,它会将事务中的所有命令都执行完毕,然后才去处理其他客户端的命令请求。redis事务不支持回滚机制,如果事务中的某条命令在执行期间出现了错误,事务后续其他命令都不会执行,已经执行的命令都不会收到任何影响,不进行回滚。
@Repository
public class DeviceRepository {
private static final Logger logger = LoggerFactory.getLogger(DeviceRepository.class);
public static final String DEVICE_REG_INFO_KEY = "sxrjy:device_reg_infos";
@Resource
private RedisTemplate<String, String> redisTemplate;
/**
* redis lua脚本:维护一个定长队列(长度10),每次新数据加到队列头部,超过长度部分丢弃
*/
private static final String FIXED_LENGTH_QUEUE_LUA_SCRIPT =
"local key = KEYS[1] " +
"redis.call('lpush',key,unpack(ARGV))" +
"redis.call('ltrim',key,0,9)";
/**
* 推送数据到redis
*
* @param regInfos 设备寄存器信息list
* @author liurong
* @date 2022/8/3 10:03
*/
public void pushDeviceRegInfo(List<DeviceRegInfo> regInfos) {
SessionCallback sessionCallback = new SessionCallback() {
@Override
public Object execute(RedisOperations redisOperations) throws DataAccessException {
redisOperations.multi();
String[] regInfoJsons = regInfos.stream().map(JSON::toJSONString).toArray(String[]::new);
ListOperations listOperations = redisOperations.opsForList();
listOperations.leftPushAll(DEVICE_REG_INFO_KEY, regInfoJsons);
listOperations.trim(DEVICE_REG_INFO_KEY, 0, 9);
return redisOperations.exec();
}
};
redisTemplate.execute(sessionCallback);
}
}
参考文献: