本文记录一下ZSet的使用心得,使用实例在【叫号系统】中,是一个简单的小系统。
【叫号系统】:用户来到考试现场进行签到,签到成功的用户进入“等待队列”。
考试科目分为:学科一、学科二、学科三、学科四。
考试场地分为:多个教室,多台电脑(有不同型号,每个型号对应不同科目,一个科目可能有两种型号)。
此时将电脑分为 两个队列:空闲队列、已分配队列。
叫号系统自动从队列中第一个用户进行分配电脑(电脑有空闲情况),用户考完试结束使用设备重新进入等待队列,直到四个学科考完。
由于叫号系统的数据频繁更新,在此使用redis对数据进行管理和缓存,根据需求需要存储的数据分为:用户等待、电脑空闲或已分配;
以下通过详细介绍数据结构的具体设计与实现,完整代码放于文末;
一、ZSet介绍
ZSet 是有序的 集合,存储格式为 ( key , value, score)
- key: redis的关键字
- value: 值,由于是集合,所以重复的数据会被覆盖。
- score:正常理解是分数,按照分数大小进行排序, 可以使用 当前时间戳 System.currentTimeMillis(),按照进入队列时间进行排序。
value直接存储bean实例,根据值删除会找不到此对象。也许是redis序列化的问题 。可以使用JSON字符串格式存储, 同时取出时使用JSON.parse()转化为实体对象。
二、等待队列
用户签到后先进入等待队列,等待队列使用redis的zset数据结构,根据进入的时间顺序进行排列。
前戏:
1、将等待队列封装为 ExamWaitListUtils.java 工具类,方便使用 。
使用 @Component 注解 使 springboot 自动注入到容器中。
再注入redisTemplate 对象。
2、redis的key值使用 “EXAM_WAIT_LIST” 与 “schoolId ” 进行拼接。
- EXAM_WAIT_LIST : 自定义的字符串,封装再SocketCommon.java类中
- schoolId: 考场学校的id,用来区分在哪所学校进行考试。
1、入队
通过 redisTemplate.opsForZSet() 获取ZSet对象。
调用add() 方法添加数据。
2、获取列表
1)、获取数据:
ZSet有几种获取集合数据的方法,在此使用其中两种,正序 和 逆序 获取。
- 正序:range()
- 逆序:reserserange()
range(key, start, end) 方法获取redis 数据拥有三个参数,start是数据列表的起始位置,end是数据列表的结束位置。用数学的区间表示就是:[start, end] 包含。
2)、实现分页:
通过[ start, end] 区间,可以计算分页。
分页规则: 输入当前页 pageCount 和 页面大小 pageSize, 获取到当前页的pageSize条数据。如果pageSize<=0 ,则获取列表所有数据。
将pageCount 和 pageSize 转化为start 和 end 。计算方式如下。
int pageCount = page.getPageCount() <= 1 ? 1 : page.getPageCount();
int pageSize = page.getPageSize() <= 0 ? 0 : page.getPageSize();
//开始位置: 从1开始
int start = (pageCount - 1) * pageSize;
// 结束位置: 如果页面大小 = 0 时 获取所有数据, 包含当前位置
int end = pageSize == 0 ? -1 : pageCount * page.getPageSize() - 1;
如果需要实现 输入查询条件筛选数据,只能将所有数据获取出来,筛选结束再进行分页。(redis没有实现跟mysql一样可以where查找数据)
3、删除列表数据
redis的zset结构也有几种删除数据方法
-
remove(key, value): 根据值vlue删除数据,这里由于之前使用JSON字符串存储bean对象,所以删除时也要先把bean对象转化为JSON字符串。
-
removeRange(key, start, end) : 根据区间[ statr, end] 范围删除数据,可以队列删除首个数据 [0,0]
4、 获取队列大小
可以通过redis 的size(key) 方法,获取队列中有多少条数据。
/**
* 等待队列大小
*
* @return 返回 ExamWaitQueue
*/
public Long size(Long schoolId) {
ZSetOperations<String, Object> zSetRedis = redisTemplate.opsForZSet();
return zSetRedis.size(SocketCommon.EXAM_WAIT_LIST + schoolId);
}
二、空闲与已分配队列
设计思路,
- 设备初始化: 将可用的设备都放入空闲队列。
- 分配设备: 从等待队列中随机分配设备
具体使用与zset的息息相关,需要注意的就是,拿出空闲队列设备放入已分配设备中时,需要删除空闲设备中的旧数据。
三、完整代码
封装等待队列工具类(ExamWaitListUtils.java):
/**
* 考试 排队列表 Redis 工具类
*
* @author Admin
*/
@Component
@Slf4j
public class ExamWaitListUtils {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 获取列表数据
*
* @param sort 排序 0-按请求时间正序(早-晚) 1-按请求时间逆序(晚-早)
* @param page 分页数据
* @return 返回1
*/
public Page<ExamWaitQueue> list(Integer sort, Long schoolId, CommonParam page) {
int pageCount = page.getPageCount() <= 1 ? 1 : page.getPageCount();
int pageSize = page.getPageSize() <= 0 ? 0 : page.getPageSize();
//开始位置: 从1开始
int start = (pageCount - 1) * pageSize;
// 结束位置: 如果页面大小 = 0 时 获取所有数据, 包含当前位置
int end = pageSize == 0 ? -1 : pageCount * page.getPageSize() - 1;
ZSetOperations<String, Object> zSetRedis = redisTemplate.opsForZSet();
Set<Object> typedTuples = null;
if (sort == 0) {
typedTuples = zSetRedis.range(SocketCommon.EXAM_WAIT_LIST + schoolId, start, end);
} else {
typedTuples = zSetRedis.reverseRange(SocketCommon.EXAM_WAIT_LIST + schoolId, start, end);
}
typedTuples = Optional.ofNullable(typedTuples).orElse(new HashSet<>());
List<ExamWaitQueue> collect = typedTuples.stream().map(v -> JSON.parseObject(v.toString(), ExamWaitQueue.class)).collect(Collectors.toList());
Page<ExamWaitQueue> examWaitQueuePage = new Page<>();
examWaitQueuePage.setRecords(collect); //数据
examWaitQueuePage.setCurrent(pageCount); //当前页
//总数
long total = Optional.ofNullable(zSetRedis.size(SocketCommon.EXAM_WAIT_LIST + schoolId)).orElse(0L);
examWaitQueuePage.setTotal(total);
examWaitQueuePage.setSize(pageSize == 0 ? total : pageSize);//页面大小
return examWaitQueuePage;
}
/**
* 弹出队列第一条数据
*
* @return 如果(没有数据或弹出失败)返回null
*/
public ExamWaitQueue popFirst(Long schoolId) {
ZSetOperations<String, Object> zSetRedis = redisTemplate.opsForZSet();
Set<Object> objects = Optional.ofNullable(zSetRedis.range(SocketCommon.EXAM_WAIT_LIST + schoolId, 0, 0))
.orElse(new HashSet<>());
if (CollectionUtils.isEmpty(objects)) {
return null;
}
Object o = objects.stream().findFirst().orElse(null);
if (o != null ) {
ExamWaitQueue examWaitQueue = JSON.parseObject(o.toString(), ExamWaitQueue.class);
this.removeFirst(examWaitQueue.getExamSchoolId());
return examWaitQueue;
}
return null;
}
/**
* 等待队列大小
*
* @return 返回 ExamWaitQueue
*/
public Long size(Long schoolId) {
ZSetOperations<String, Object> zSetRedis = redisTemplate.opsForZSet();
return zSetRedis.size(SocketCommon.EXAM_WAIT_LIST + schoolId);
}
/**
* 判断队列是否为空
*
* @return 返回 true - 为空, false - 不为空
*/
public boolean isEmpty(Long schoolId) {
ZSetOperations<String, Object> zSetRedis = redisTemplate.opsForZSet();
Long size = Optional.ofNullable(zSetRedis.size(SocketCommon.EXAM_WAIT_LIST + schoolId))
.orElse(0L);
return size <= 0;
}
/**
* 判断队列是否不为空
*
* @return 返回 true - 不为空, false - 为空
*/
public boolean isNotEmpty(Long schoolId) {
return !isEmpty(schoolId);
}
/**
* 获取等待考试用户(最先进入等待队列的用户)
* @param schoolId 考场学校id
* @param index 索引(为空则第一个)
* @return ex
*/
public ExamWaitQueue get(Long schoolId, Integer index) {
index = Optional.ofNullable(index).orElse(0);
ZSetOperations<String, Object> zSetRedis = redisTemplate.opsForZSet();
Set<Object> objects = Optional.ofNullable(zSetRedis.range(SocketCommon.EXAM_WAIT_LIST + schoolId, index, index))
.orElse(new HashSet<>());
return objects.stream().map(v -> JSON.parseObject(v.toString(), ExamWaitQueue.class)).findFirst().orElse(null);
}
/**
* 添加列表数据
*
* @param examWaitQueue 添加参数
* @return 返回1
*/
public int add(ExamWaitQueue examWaitQueue) {
ZSetOperations<String, Object> zSetRedis = redisTemplate.opsForZSet();
examWaitQueue.setInWaitTime(new Date());
zSetRedis.add(SocketCommon.EXAM_WAIT_LIST + examWaitQueue.getExamSchoolId(), JSON.toJSONString(examWaitQueue), System.currentTimeMillis());
return 1;
}
/**
* 移除第一个元素(最先进入队列的)
*
* @return 返回
*/
public boolean removeFirst(Long schoolId) {
ZSetOperations<String, Object> zSetRedis = redisTemplate.opsForZSet();
Long remove = zSetRedis.removeRange(SocketCommon.EXAM_WAIT_LIST + schoolId, 0, 0);
return remove != null && remove > 0;
}
/**
* 移除第一个元素(最先进入队列的)
*
* @return 返回
*/
public boolean remove(ExamWaitQueue object) {
ZSetOperations<String, Object> zSetRedis = redisTemplate.opsForZSet();
Long remove = zSetRedis.remove(SocketCommon.EXAM_WAIT_LIST + object.getExamSchoolId(), JSON.toJSONString(object));
return remove != null && remove > 0;
}
}
封装空闲与已分配队列工具类:
@Slf4j
@Component
public class LaboratoryDistributionUtils {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ExamWaitListUtils waitListUtils;
/**
* 考试空闲设备标识符
*/
public final static String MACHINE_IDLE = "MACHINE_IDLE:";
/**
* 考试已分配设备标识符
*/
public final static String MACHINE_USE = "MACHINE_USE:";
/**
*
* 初始化方法
*/
public void __init__(List<ExamMachineQueue> machinesList){
// Map<String, List<ExamMachineQueue>> subjectNumberMap = machinesList.stream()
// .collect(Collectors.groupingBy(ExamMachineQueue::getSubjectNumber));
for (ExamMachineQueue machineQueue : machinesList) {
this.addIdle(machineQueue);
}
}
/**
* 给等待队列所有学员 分配空闲设备(若无空闲则不分配)
* @param schoolId 考试学校id
* @return bean 如果无空闲设备返回为null
*/
public Boolean distributionBatch(Long schoolId){
Integer index = 0;
while(waitListUtils.isNotEmpty(schoolId) && index < waitListUtils.size(schoolId)){
ExamWaitQueue examWaitQueue = waitListUtils.get(schoolId, index);
if(this.distributionMachine(examWaitQueue)!=null){
waitListUtils.remove(examWaitQueue);
}else{
index++;
}
}
return true;
}
/**
* 给学员分配空闲设备
* @param waitQueue 学员
* @return bean 如果无空闲设备返回为null
*/
public ExamMachineQueue distributionMachine(ExamWaitQueue waitQueue){
ExamMachineQueue machineQueue = null;
if(waitQueue==null) return null;
//遍历学生考试科目
for (Integer subjectNumber : waitQueue.getProcess()) {
//获取可用设备
machineQueue = this.getIdle(waitQueue.getExamSchoolId(), subjectNumber);
if(BeanUtil.isNotEmpty(machineQueue)){
break;
}
}
if(BeanUtil.isNotEmpty(machineQueue)){
BeanUtils.copyProperties(waitQueue, machineQueue);
this.addUse(machineQueue, true);
return machineQueue;
}
return null;
}
/**
* 给空闲设备分配考试学员
* @param machineQueue 空闲设备
* @return bean 如果无空闲设备返回为null
*/
public boolean distributionMember(ExamMachineQueue machineQueue){
if(BeanUtil.isNotEmpty(machineQueue)){
CommonParam commonParam = new CommonParam();
commonParam.setPageSize(-1);
List<ExamWaitQueue> waitQueues = waitListUtils.list(0, machineQueue.getExamSchoolId(), commonParam).getRecords();
for (ExamWaitQueue waitQueue : waitQueues) {
if(waitQueue.getProcess().contains(machineQueue.getSubjectNumber())){
//先移除之前在使用队列保存的旧数据
this.remove(machineQueue);
BeanUtil.copyProperties(waitQueue, machineQueue);
//添加使用队列
this.addUse(machineQueue, false);
//移除等待队列
waitListUtils.remove(waitQueue);
return true;
}
}
}
return false;
}
/**
* 结束使用设备
* @param schoolId 学校id
* @param subjectNumber 设备所属科目
* @param machineId 设备id
* @return 设备信息
*/
public ExamWaitQueue endUse(Long schoolId, Integer subjectNumber, Long machineId){
List<ExamMachineQueue> examMachineQueues = this.useList(schoolId, String.valueOf(subjectNumber));
Optional.ofNullable(examMachineQueues).orElseThrow(()->new ApiException("没有使用中的设备"));
List<ExamMachineQueue> collect = examMachineQueues.stream()
.filter(v -> v.getMachineId().equals(machineId)).collect(Collectors.toList());
if(CollectionUtils.isNotEmpty(collect)){
ExamMachineQueue machineQueue = collect.get(0);
//如果学员process还有考试科目未完成,进入等待队列
ExamWaitQueue examWaitQueue = BeanUtil.copyProperties(machineQueue, ExamWaitQueue.class);
examWaitQueue.getProcess().remove(subjectNumber);
if(CollectionUtils.isNotEmpty(examWaitQueue.getProcess())){
waitListUtils.add(examWaitQueue);
}
//重新从等待队列分配设备 (若等待队列无数据,则进入空闲队列)
if(!this.distributionMember(machineQueue)){
this.addIdle(machineQueue);
}
return examWaitQueue;
}
return null;
}
/**
* 获取使用中设备列表
* @param schoolId 学校id
* @return bean 没有设备返回为null
*/
public List<ExamMachineQueue> useList(Long schoolId, String subjectNumber){
ZSetOperations<String, Object> zSetRedis = redisTemplate.opsForZSet();
Set<Object> uses = new HashSet<>();
if("*".equals(subjectNumber)){
Set<String> keys = redisTemplate.keys(this.keyGroup(MACHINE_USE, String.valueOf(schoolId), "*"));
keys = Optional.ofNullable(keys).orElse(new HashSet<>());
for (String key : keys) {
Set<Object> range = zSetRedis.range(key, 0, -1);
range = Optional.ofNullable(range).orElse(new HashSet<>());
uses.addAll(range);
}
}else{
uses = zSetRedis.range(this.keyGroup(MACHINE_USE, String.valueOf(schoolId), subjectNumber), 0, -1);
}
uses = Optional.ofNullable(uses).orElse(new HashSet<>());
return uses.stream().map(v->JSON.parseObject(v.toString(), ExamMachineQueue.class))
.collect(Collectors.toList());
}
/**
* redis键组合
* @param key 常量
* @param schoolId 学校id
* @param subjectNumber 所属科目
* @return 例如: MACHINE_IDLE:1:1
*/
public String keyGroup(String key, String schoolId, String subjectNumber){
return key + schoolId + ":" + subjectNumber;
}
/**
* 获取第一台空闲设备(队列中的第一台)
* @param schoolId 学校id
* @param subjectNumber 设备所属科目
* @return bean
*/
public ExamMachineQueue getIdle(Long schoolId, Integer subjectNumber){
return this.get(MACHINE_IDLE, schoolId, subjectNumber);
}
/**
* 获取已使用设备(队列中的第一台)
* @param schoolId 学校id
* @param subjectNumber 设备所属科目
* @return bean
*/
public ExamMachineQueue getUse(Long schoolId, Integer subjectNumber){
return this.get(MACHINE_USE, schoolId, subjectNumber);
}
/**
* 获取队列第一个设备
* @param key MACHINE_IDLE:空闲队列, MACHINE_USE:使用中的队列
* @param schoolId 学校id
* @param subjectNumber 设备所属科目
* @return ExamMachineQueue 如果队列为空返回null
*/
private ExamMachineQueue get(String key, Long schoolId, Integer subjectNumber){
ZSetOperations<String, Object> zSetRedis = redisTemplate.opsForZSet();
Set<Object> objects = zSetRedis.range(this.keyGroup(key, String.valueOf(schoolId), String.valueOf(subjectNumber)), 0, 0);
ExamMachineQueue machineQueue = null;
if(CollectionUtils.isNotEmpty(objects)){
machineQueue = objects.stream().map(v->JSON.parseObject(v.toString(), ExamMachineQueue.class))
.findFirst().orElse(null);
}
return machineQueue;
}
/**
* 添加空闲设备列表数据
* @param addParam 添加参数
* @return 返回1
*/
public Boolean addIdle(ExamMachineQueue addParam){
ZSetOperations<String, Object> zSetRedis = redisTemplate.opsForZSet();
//添加空闲列表
Boolean add = zSetRedis.add(this.keyGroup(MACHINE_IDLE
, String.valueOf(addParam.getExamSchoolId())
, String.valueOf(addParam.getSubjectNumber())) , JSON.toJSONString(addParam), System.currentTimeMillis());
//移除使用中队列
if(Boolean.TRUE.equals(add)){
this.remove(addParam);
}
return add;
}
/**
* 添加使用中设备列表数据
* @param addParam 添加参数
* @param isRemove 需要添加的设备是否存在 “空闲队列”,true-存在:同时移除空闲队列
* @return 返回1
*/
public Boolean addUse( ExamMachineQueue addParam, boolean isRemove){
ZSetOperations<String, Object> zSetRedis = redisTemplate.opsForZSet();
//防止修改值不能移除等待队列
ExamMachineQueue machineQueue = BeanUtil.copyProperties(addParam, ExamMachineQueue.class);
machineQueue.setInUseTime(new Date());
//计算用户到使用设备的等待时间
machineQueue.getWaitTimeList().add(addParam.getSubjectNumber(), DateUtil.betweenMs(machineQueue.getInWaitTime(), new Date()));
String useKey = this.keyGroup(MACHINE_USE, String.valueOf(machineQueue.getExamSchoolId()), String.valueOf(machineQueue.getSubjectNumber()));
String idleKey = this.keyGroup(MACHINE_IDLE, String.valueOf(machineQueue.getExamSchoolId()), String.valueOf(machineQueue.getSubjectNumber()));
Boolean add = zSetRedis.add(useKey , JSON.toJSONString(machineQueue), System.currentTimeMillis());
if(Boolean.TRUE.equals(add) && isRemove){
Long aLong = zSetRedis.removeRange(idleKey,0, 0);
}
return add;
}
/**
* 移除使用队列 中指定元素
* @return 返回
*/
public boolean remove(ExamMachineQueue removeParam){
ZSetOperations<String, Object> zSetRedis = redisTemplate.opsForZSet();
String key = this.keyGroup(MACHINE_USE, String.valueOf(removeParam.getExamSchoolId()), String.valueOf(removeParam.getSubjectNumber()));
Long remove = zSetRedis.remove(key, JSON.toJSONString(removeParam));
return remove!=null && remove>0;
}
/**
* 空闲设备总数
* @return 返回
*/
public Long idleAllNum(Long schoolId){
ZSetOperations<String, Object> zSetRedis = redisTemplate.opsForZSet();
Set<String> keys = redisTemplate.keys(this.keyGroup(MACHINE_IDLE, String.valueOf(schoolId), "*"));
keys = Optional.ofNullable(keys).orElse(new HashSet<>());
Long sum = 0L;
for (String key : keys) {
Long size = zSetRedis.size(key);
sum += size;
}
return sum;
}
/**
* 空闲设备总数
* @return 返回
*/
public Map<String, Long> statics(Long schoolId){
ZSetOperations<String, Object> zSetRedis = redisTemplate.opsForZSet();
Set<String> keys = redisTemplate.keys(this.keyGroup(MACHINE_IDLE, String.valueOf(schoolId), "*"));
keys = Optional.ofNullable(keys).orElse(new HashSet<>());
HashMap<String, Long> map = new HashMap<>(16);
Set<Object> uses = new HashSet<>();
for (String key : keys) {
Set<Object> range = zSetRedis.range(key, 0, -1);
range = Optional.ofNullable(range).orElse(new HashSet<>());
uses.addAll(range);
}
Map<String, List<ExamMachineQueue>> queueMap = uses.stream()
.map(v -> JSON.parseObject(v.toString(), ExamMachineQueue.class))
.collect(Collectors.groupingBy(ExamMachineQueue::getType));
queueMap.forEach((key, value)->{
map.put(key, (long) value.size());
});
return map;
}
/**
* 判断队列是否为空
*
* @return 返回 true - 为空, false - 不为空
*/
public boolean isEmpty(Long schoolId) {
ZSetOperations<String, Object> zSetRedis = redisTemplate.opsForZSet();
Long size = Optional.ofNullable(zSetRedis.size(SocketCommon.EXAM_WAIT_LIST + schoolId))
.orElse(0L);
return size <= 0;
}
/**
* 判断队列是否不为空
*
* @return 返回 true - 不为空, false - 为空
*/
public boolean isNotEmpty(Long schoolId) {
return !isEmpty(schoolId);
}
}