文章目录
- 程序设计
- score 设计 (相同积分的排序)
- 缓存数据定时刷新
- 当心缓存击穿
之前有做到一个需求, 需要做一个小的排行榜的功能. 然后发现里面涉及到的东西挺多的, 记录一下. 主要包括 zset 使用, 缓存的定时刷新保证数据准确性, 预防缓存击穿.
大概需求就是: 排行榜上显示前n个积分最高的用户. 并且相同积分先完成的排在前面. 并且还要能看到自己当前的积分.
看到这个需求的时候就想到可以用redis zset来实现. 但是还是假装分析一下:
- 首先排行榜明显是一个热点数据, 访问频率大, 且计算复杂. 肯定不能直接从数据库中读取计算排名, 否则服务很容易挂掉.
- 然后就只能用缓存了. 于是看一下redis 的 zset 有序列表.
- zset 是一个有序列表, 满足排序需求
- redis 数据存在内存中, 存取效率高
- zset 使用ziplist 或 skiplist+map实现. 直接查询用户积分时间复杂度低.
大概就是元素少于128且长度小于64字节时用ziplist, 否则使用 skiplist+map, skiplist 存储score-value, map=value-score
程序设计
对于整个排行榜, 我们用 zset 保存排行榜数据, key 为排行榜信息, member 为用户id, score 存储用户积分.
用户信息(排行榜需要的头像昵称) 再用string或hash结构存储. 这个没啥说的, 就是查用户信息的时候别一个一个查就好了.
score 设计 (相同积分的排序)
zset对于score相同的排序是按照key的字典序排的.
所以我们需要在score里面加入时间信息.
比较简单的一种方式是积分乘以10的n次方, 后面n位用于存储时间信息.
由于时间小的排在前面, 所以可以取一个最大时间减去当前时间.
score = 积分 * 1E10 + 最大时间 - 当前时间
(其实也考虑过把时间信息放在小数位, 但是发现会丢失精度就放弃了)
需要注意两点
- score支持的最大值为
9007199254740992
, 所以这个n需要结合具体的业务场景决定. 主要考虑积分可能的最大值生成的score不会越界. - 最大时间的取值.
- 如果排行榜是临时的(超过某个时间就不存在或不保证数据的准确性), 那最大时间直接取排行榜的截止有效时间就可,这种方式时间信息占用的位数较少. (推荐)
- 如果排行榜一直存在就取一个固定值, 2050年? 这种时间占用位数较多, 但是可以降低时间精度缓解一点压力, 精确到秒? 或者占用几位小数(最好自己测一下精度有没有影响)
然后我这里根据实际业务, 使用的固定的最大时间2050年, 时间精确到秒, 所以n取的10, 此时支持的最大积分为90w, 满足实际业务场景.
这里贴一下相关代码
private static final double STEP = 1E10;
private static final long SECOND_20500101 = 2524579200L;
public static Double createScoreWithTimeAsc(Integer value, long timeSecond) {
if (value == null) {
return 0D;
}
return value * STEP + SECOND_20500101 - timeSecond;
}
/**
* 返回的是incrScore, 用于 redisTemplate.opsForZSet().incrementScore
*/
public static Double incrScoreWithTimeAsc(Integer increment, Double originScore) {
if (increment == null) {
return 0D;
}
if (originScore == null || originScore < 1.0) {
return createScoreWithTimeAsc(delta);
}
double last = originScore % STEP; //上次的时间
long now = System.currentTimeMillis() / 1000;
return increment * STEP - last + SECOND_20500101 - now;
}
public static long getValueFromTimeScore(Double score) {
return (long) (score / STEP);
}
然后在加减积分的代码就不贴了, 但是需要注意并发的情况, 对于这种排行榜类的数据也是比较容易出现并发的.
缓存数据定时刷新
虽然我们将排行榜数据存入zset中了, 但这个只是提高了我们的访问效率, 并不能完全保证数据的准确性. 可能会因为各种原因(并发, 网络异常)导致缓存数据不准确, 因此需要定时刷新缓存数据.
然后缓存刷新策略大概有一下几种:
- 全量刷新: 把所有缓存数据都重新计算一遍, 比如每天刷一遍
- 增量刷新: 把一定时间内变化的缓存数据刷新, 每个小时刷一次
- 根据数据变化频率动态刷新: 类似redis持久化策略. 一定时间内变化的频率到达一个阈值就刷新.
具体采取哪种策略也是看具体的需求, 对数据的准确性实时性需求、性能需求等. 可以同时结合多种策略. 以及时间间隔也取决于产品需求和刷新耗时.
大概就是使用一个定时任务, 查询数据库最近一段时间内积分变化的用户, 对这些用户的积分进行重算, 刷新缓存.
还有就是到了缓存过期的时候, 就别再刷了. 除非打算这个排行榜一直存在缓存中.
当心缓存击穿
缓存肯定是要设置过期时间的, 过期时间肯定是在缓存数据不经常访问的时候. 那如果缓存过期后用户访问排行榜, 这个时候就需要从数据库中查询相关数据, 重新计算排行榜前n位(没必要全部重算), 显然重算排行榜是一个比较费事费力的操作.
但是假如这个时候是大量用户并发访问, 然后查询排行榜缓存, 发现没有数据, 于是都去查询数据库重算. 这个时候数据库压力就会很大, 很容易挂掉. 即 缓存击穿
.
然后解决方式大概有几种
- 不设置过期时间, 不过期就不会失效.
- 加锁, 重新加载缓存的时候加锁, 防止所有的请求都去数据库查询重算.
然后结合具体场景, 这里加载缓存时不一定时缓存过期了, 可能还没构建缓存, 就有大量用户访问该排行榜. 并且该排行榜缓存没有必要一直存在, 浪费空间.
然后记得用双重锁检测
// 伪代码
// redis 里查排行榜
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().reverseRangeWithScores(zSetKey, 0, endIndex);
// 判断是否为空
if (CollectionUtils.isEmpty(typedTuples)) {
// 如果缓存为空, 则加锁加载缓存, 防止缓存击穿
lock();
try {
// 再查一次
typedTuples = stringRedisTemplate.opsForZSet().reverseRangeWithScores(zSetKey, 0, endIndex);
if (CollectionUtils.isEmpty(typedTuples)) { // 还是空, 重新加载缓存
result = computeDataFromDB();
// empty return
if (CollectionUtils.isEmpty(result)) {
return Collections.emptyList();
}
typedTuples = loadDataToCache(result);
}
} finally {
unlock();
}
}