面试遇到一个问题:
现在有百万的数据,要对用户答题做一个排行榜,展示前20的排名信息,用户可以重复进行答题,更新分数排名。
一. 导数据入缓存
要实时展示一个用户排行榜,如果每次都重数据库查询数据,效率肯定不行。这是考虑到使用Redis缓存。
Redis的缓存类型主要有String,Hash,List,Set,ZSet这5种。对于要有序不重复的排行场景,采用ZSet,其中以分数作为score。
从数据库导入缓存这里有个点要考虑: 数据量很大无法一次性完成操作?可以根据用户id分区间,每次取一批次的数据导入缓存中。
大体伪代码如下:
// 用户区间步长
int step=10000;
// 起始用户id
int startUserId=1;
whille(true){
// 查询用户分数数据
List dataList=selectUserScore(startUserId);
if(dataList.size==0){
break;
}
// 插入缓存中
redis.add(dataList);
// 更新起始用户id
startUserId+=step;
}
二. 查询排名前20的数据
查询前多少的排名,redisTemplate有现成的方法:range。默认情况下是排序是从小到大的,要输出分数最高的可用倒叙输出。
这里要注意一点: ZSet的排名默认是0开始的,故而返回的排名要+1
public Set<Object> listRank() {
BoundZSetOperations zSetOps = redisTemplate.boundZSetOps(RANK_KEY);
return zSetOps.reverseRange(0, 19);
}
三. 更新用户分数
更新用户分数主要考虑两点:
- 同用户的分数如何实时更新
- 不同用户分数一致的情况下,如何定义排名
1. 同用户的分数如何实时更新?
- 首先将新的用户分数数据入数据库
- 获取缓存当前用户的排行信息
- 如果缓存中不存在或者分数低于最新的分数则,更新缓存
2. 不同用户分数一致的情况下,如何定义排名?
不同用户分数相同的情况,应以先达到该分数的用户排名靠前。考虑到Zset中score是double类型的,可以在小数上做文章。
用户分数数据入数据库之后能获取到对应的id,先插入的id较小,可以Integer.MAX_VALUE-id的方式设置小数。最终存入redis中的score格式: 用户分数.(Integer.MAX_VALUE-该数据id)
public void updateScore(String userCode,int score) {
// 模拟存入数据库
Random random = new Random();
int i = random.nextInt(1000);
BoundZSetOperations zSetOps = redisTemplate.boundZSetOps(RANK_KEY);
// 当前分数, 分数.Integer.MAX_VALUE-数据库id 用于相同分数下排名;分数相同的用户,先获取到该分数的用户排名更高
BigDecimal currentScore = new BigDecimal(score+"."+(Integer.MAX_VALUE-i));
// 获取历史的分数
Double scoreHistory = zSetOps.score(userCode);
// 不存在该用户记录或者 历史分数比当前分数小,则更新缓存数据
if (scoreHistory == null || BigDecimal.valueOf(scoreHistory).compareTo(currentScore) <=0) {
zSetOps.add(userCode, currentScore.doubleValue());
}
}
四. 优化点
如果只需要的前20排名,不需要后面的的排名数据,在redis中存储全部的排行还是有所浪费内存的。
在更新时,获取分数排行20的数据分数,如果当前数据小于该数不更新缓存,否则更新缓存。
之后在判断缓存中总数是否达到50条,如果达到则删除小于排行20分数的数据。
为啥取存50条数据?50只是一个估计值,也可以40,100等,主要要大于20个数。如果只存20条数据,必然要加全局锁,来保证数据的准确。这里让数据冗余了一部分,既可以避免频繁的移除数据,也可以避免加锁的问题。
最后奉上 项目demo地址