背景
在我的项目一:Online学习平台中,有这么一个场景;积分的获取,积分的高低又形成了排行榜。
用户获取积分的途径有5种:
- 签到:在个人积分页面可以每日签到,每次签到得1分,连续签到有额外积分奖励。
- 学习:也就是看视频
- 写回答:就是给其他学员提问的问题回答,给回答做评论是没有积分的。
- 写笔记:就是学习的过程中记录公开的学习笔记,分享给所有人看。或者你的笔记被人点赞。
- 写评价:对你学习过的课程评价,可以获取积分。但课程只能评价一次。
我们重点要说的就是签到
分析
使用数据库记录签到记录
我们设计出如下表
缺点
占用空间大:
- 这张表中的一条记录是一个用户一次的签到记录。假如一个用户
1
年签到100
次,而网站有100万
用户,就会产生1亿条
记录。 - 随着用户量增多、时间的推移,这张表中的数据只会越来越多,占用的空间也会越来越大。
- 计算一下大小 id 8个字节,user_id 8个字节,year 1个字节,month 1个字节,date 3个字节 , is_backup 1 个字节 ------> (8+8+1+1+3+1 = 22 Byte)
22Byte*10^8/1024/1024 = 2098MB
(其实也不是很大哈哈哈)
如何解决
使用Redis的BitMap结构
如果有对这个不熟悉的小伙伴可以看我这篇文章哦:Redis之BitMap的使用场景
我们从问题本质出发,其实签到就两种情况:签到
,未签到
;
没错,就是 0
或者1
如果我们按月来统计用户签到信息,签到记录为1
,未签到则记录为0
,就可以用一个长度为31位
的二级制数组
来表示一个用户一个月的签到情况。最终效果如下:
内存分析
一个月最多也就 31
天,因此一个月的签到记录最多也就使用 31 bit
就能保存了,还不到 4 个字节
。
而如果用到我们前面讲的数据库方式
来保存相同数据,则要使用数百字节
,是这种方式的上百倍都不止。
使用这种方式是非常高效的
使用
修改某个bit位上的数据:
offset
:要修改第几个bit位的数据value
:0或1
怎么签到
如果要签到就可以利用上面的这个命令,例如这个月的第1、2、3、6、7、8几天签到了,就可以这样:
# 第1天签到
SETBIT bm 0 1
# 第2天签到
SETBIT bm 1 1
# 第3天签到
SETBIT bm 2 1
# 第6天签到
SETBIT bm 5 1
# 第7天签到
SETBIT bm 6 1
# 第8天签到
SETBIT bm 7 1
查询签到记录怎么办?
BITFIELD key GET encoding offset
- key:就是
BitMap的key
- GET:代表查询
- encoding:返回结果的编码方式,BitMap中是二进制保存,而返回结果会转为10进制,但需要一个转换规则,也就是这里的编码方式
- u:无符号整数,例如
u2
,代表读2个bit
位,转为无符号整数返回 - i:又符号整数,例如
i2
,代表读2个bit
位,转为有符号整数返回
- u:无符号整数,例如
offset
:从第几个bit位开始读取,例如0:代表从第一个bit位开始
例如,我想查询从第1天到第3天
的签到记录,可以这样:
可以看到,返回的结果是7
. 为什么是7呢?
签到记录是 11100111, 从0开始,取3个bit位 ,刚好是111
,转无符号整数,刚好是7
代码实现
签到接口:
@Override
public SignResultVO addSignRecords() {
// 1.签到
// 1.1.获取登录用户
Long userId = UserContext.getUser();
// 1.2.获取日期
LocalDate now = LocalDate.now();
// 1.3.拼接key
String key = RedisConstants.SIGN_RECORD_KEY_PREFIX
+ userId
+ now.format(DateUtils.SIGN_DATE_SUFFIX_FORMATTER);
// 1.4.计算offset
int offset = now.getDayOfMonth() - 1;
// 1.5.保存签到信息
Boolean exists = redisTemplate.opsForValue().setBit(key, offset, true);
if (BooleanUtils.isTrue(exists)) {
throw new BizIllegalException("不允许重复签到!");
}
// 2.计算连续签到天数
int signDays = countSignDays(key, now.getDayOfMonth());
// 3.计算签到得分
int rewardPoints = 0;
switch (signDays) {
case 7:
rewardPoints = 10;
break;
case 14:
rewardPoints = 20;
break;
case 28:
rewardPoints = 40;
break;
}
// 4.保存积分明细记录
mqHelper.send(
MqConstants.Exchange.LEARNING_EXCHANGE,
MqConstants.Key.SIGN_IN,
SignInMessage.of(userId, rewardPoints + 1));
// 5.封装返回
SignResultVO vo = new SignResultVO();
vo.setSignDays(signDays);
vo.setRewardPoints(rewardPoints);
return vo;
}
监听由于签到而导致的积分变动信息:
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "sign.points.queue", durable = "true"),
exchange = @Exchange(name = MqConstants.Exchange.LEARNING_EXCHANGE, type = ExchangeTypes.TOPIC),
key = MqConstants.Key.SIGN_IN
))
public void listenSignInMessage(SignInMessage message){
recordService.addPointsRecord(message.getUserId(), message.getPoints(), PointsRecordType.SIGN);
}
更新积分:
@Override
public void addPointsRecord(Long userId, int points, PointsRecordType type) {
LocalDateTime now = LocalDateTime.now();
int maxPoints = type.getMaxPoints();
// 1.判断当前方式有没有积分上限
int realPoints = points;
if(maxPoints > 0) {
// 2.有,则需要判断是否超过上限
LocalDateTime begin = DateUtils.getDayStartTime(now);
LocalDateTime end = DateUtils.getDayEndTime(now);
// 2.1.查询今日已得积分
int currentPoints = queryUserPointsByTypeAndDate(userId, type, begin, end);
// 2.2.判断是否超过上限
if(currentPoints >= maxPoints) {
// 2.3.超过,直接结束
return;
}
// 2.4.没超过,保存积分记录
if(currentPoints + points > maxPoints){
realPoints = maxPoints - currentPoints;
}
}
// 3.没有,直接保存积分记录
PointsRecord p = new PointsRecord();
p.setPoints(realPoints);
p.setUserId(userId);
p.setType(type);
save(p);
// 4.更新总积分到Redis
String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + now.format(DateUtils.POINTS_BOARD_SUFFIX_FORMATTER);
redisTemplate.opsForZSet().incrementScore(key, userId.toString(), realPoints);
}
MQ的topic方式实现不同渠道的积分变动
前面说到用户获取积分的途径有5种:签到、看视频、写回答、写评论、写笔记
那么我是如何根据不同的方式,增加对应的积分记录呢?答案是RabbitMQ的topic
方式
首先实现不同方式的routingKey
以写回答为例子:当评论或者回答入库后我们就发送积分记录消息
分别实现各自渠道的消息监听器,进而添加积分记录
@Component
@RequiredArgsConstructor
public class LearningPointsListener {
private final IPointsRecordService recordService;
/**
* 用户点赞而导致的积分变更
*/
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "qa.points.queue", durable = "true"),
exchange = @Exchange(name = MqConstants.Exchange.LEARNING_EXCHANGE, type = ExchangeTypes.TOPIC),
key = MqConstants.Key.WRITE_REPLY
))
public void listenWriteReplyMessage(Long userId){
recordService.addPointsRecord(userId, 5, PointsRecordType.QA);
}
/**
* 用户签到而导致的积分变更
*/
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "sign.points.queue", durable = "true"),
exchange = @Exchange(name = MqConstants.Exchange.LEARNING_EXCHANGE, type = ExchangeTypes.TOPIC),
key = MqConstants.Key.SIGN_IN
))
public void listenSignInMessage(SignInMessage message){
recordService.addPointsRecord(message.getUserId(), message.getPoints(), PointsRecordType.SIGN);
}
/**
* 用户看视频而导致的积分变更
*/
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "learning.points.queue", durable = "true"),
exchange = @Exchange(name = MqConstants.Exchange.LEARNING_EXCHANGE, type = ExchangeTypes.TOPIC),
key = MqConstants.Key.LEARN_SECTION
))
public void listenLearnSectionMessage(Long userId){
recordService.addPointsRecord(userId, 10, PointsRecordType.LEARNING);
}
/**
* 用户写笔记而导致的积分变更
*/
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "note.new.points.queue", durable = "true"),
exchange = @Exchange(name = MqConstants.Exchange.LEARNING_EXCHANGE, type = ExchangeTypes.TOPIC),
key = MqConstants.Key.WRITE_NOTE
))
public void listenWriteNodeMessage(Long userId){
recordService.addPointsRecord(userId, 3, PointsRecordType.NOTE);
}
/**
* 用户笔记被采集而导致的积分变更
*/
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "note.gathered.points.queue", durable = "true"),
exchange = @Exchange(name = MqConstants.Exchange.LEARNING_EXCHANGE, type = ExchangeTypes.TOPIC),
key = MqConstants.Key.NOTE_GATHERED
))
public void listenNodeGatheredMessage(Long userId){
recordService.addPointsRecord(userId, 2, PointsRecordType.NOTE);
}
}
总结:
模拟面试:
你项目中使用过Redis的那些数据结构啊?能不能具体说说使用的场景?
答:比如很多的缓存,我们就使用了String结构来存储。还有点赞功能,我们用了Set结构和SortedSet结构。签到功能,我们用了BitMap结构。
就拿签到来说吧。因为签到数据量非常大嘛,而BitMap则是用bit位来表示签到数据,31bit位就能表示1个月的签到记录,非常节省空间,而且查询效率也比较高。
你使用Redis保存签到记录,那如果Redis宕机怎么办?
对于Redis的高可用数据安全问题,有很多种方案。
比如:我们可以给Redis添加数据持久化机制,比如使用AOF持久化。这样宕机后也丢失的数据量不多,可以接受。
或者呢,我们可以搭建Redis主从集群,再结合Redis哨兵。主节点会把数据持续的同步给从节点,宕机后也会有哨兵重新选主,基本不用担心数据丢失问题。
当然,如果对于数据的安全性要求非常高。肯定还是要用传统数据库来实现的。但是为了解决签到数据量较大的问题,我们可能就需要对数据做分表处理了。或者及时将历史数据存档。
总的来说,签到数据使用Redis的BitMap无论是安全性还是数据内存占用情况,都是可以接受的。但是具体是选择Redis还是数据库方案,最终还是要看公司的要求来选择。
评论( 0 )