签到功能对应的逻辑很常见,主要有以下几种场景
- 签到 1 天送 10 积分,连续签到 2 天送 20 积分,3 天送 30 积分,4 天以上均送 50 积分等
- 如果连续签到中断,则重置计数,每月初重置计数
- 在日历控件上展示用户每月签到情况,可以切换年月显示
最简单的就是使用数据库保存,假设数据签到表设计如下
create table user_sign(
id int primary key AUTO_INCREMENT,
user_id int not null comment '用户id',
sign_date datetime not null comment '签到日期',
amount int not null comment '连续签到次数'
);
如果这样存数据,对于用户量大的应用,db可能扛不住,比如 1000W 用户,一天一条,那么一个月就是 3 亿数据,非常庞大。
使用bitmap
就是通过一个bit位来表示某个元素对应的值或者状态,其中的key就是对应元素本身。我们知道8个bit可以组成一个Byte,所以bitmap本身会极大的节省储存空间。
Redis从2.2.0版本开始新增了setbit,getbit,bitcount等几个bitmap相关命令。虽然是新命令,但是并没有新增新的数据类型,因为setbit等命令只不过是在set上的扩展。
内存开销小、效率高且操作简单,很适合用于签到这类场景。比如按月进行存储,一个月最多 31 天,那么我们将该月用户的签到缓存二进制就是 00000000000000000000000000000000,当某天签到将 0 改成 1 即可,而且 Redis 提供对 bitmap 的很多操作比如存储、获取、统计等指令,使用起来非常方便。
常用命令
命令 | 功能 | 参数 | 示例 |
---|---|---|---|
setbit | 指定偏移量 bit 位置设置值 | key offset value【0=< offset< 2^32】 | setbit user:sign:202105 0 1 |
getbit | 查询指定偏移位置的 bit 值 | key offset | getbit user:sign:202105 0 |
bitcount | 统计指定字节区间 bit 为 1 的数量 | key [start end] | bitcount user:sign:202105 0 31 |
bitfield | 操作多字节位域 | key [GET type offset] [SET type offset value] [INCRBY type offset increment] [OVERFLOW WRAP/SAT/FAIL] | user:sign:202105 get u31 0 |
设置签到
假设需要设置2021年5月份的签到数据
setbit user:sign:202105 0 1 # key: user:sign:202105, 0 代表1号,1 代表已签到
setbit user:sign:202105 1 1
setbit user:sign:202105 2 1
# 第三天断签了
setbit user:sign:202105 4 1
setbit user:sign:202105 5 1
Redis中数据为
11101100
统计这个月签到天数
bitcount user:sign:202105 0 31
范围指定从0-31,一个月最多就31天
连续签到次数
bitfield user:sign:202105 get u31 0 # 1979711488
从 user:sign:202105
这个key 中获取31
位,u代表无符号,1979711488
代表获取到的十进制数字
位运算判断是否签到
假设获取到连续31天签到次数为 : 1979711488
,如何判断某天是否已经签到
1979711488
的二进制为
1110110000000000000000000000000
# 先右移一位
0111011000000000000000000000000
# 再左移一位, 和原数字一样
1110110000000000000000000000000
# 假设当天最后一天签到了,则最后一位会是1
1110110000000000000000000000001
# 先右移一位
0111011000000000000000000000000
# 再左移一位,和原数字不一样
1110110000000000000000000000000
所以,如果右移再左移等于之前的数字,则代表没有签到。如果右移再左移不等于之前的数字,则代表已经签到了。
按照上面逻辑,可以计算用户这个月连续签到的次数
Java代码实现计算用户这个月连续签到的次数
private int continuousSignIn(long userId, LocalDate date) {
// 统计连续签到次数, 假设今天是31号
int dayOfMonth = date.getDayOfMonth();
ValueOperations<String, Object> opsForValue = redisTemplate.opsForValue();
// bitfield user:sign:202105 get u31 0
BitFieldSubCommands bitFieldSubCommands = BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
.valueAt(0);
String signKey = "user:sign:202105";
List<Long> bitFields = opsForValue.bitField(signKey, bitFieldSubCommands);
if (CollectionUtils.isEmpty(bitFields)) {
return 0;
}
AtomicInteger continuousSignInCount = new AtomicInteger();
bitFields.stream()
.findFirst()
.ifPresent(v -> {
// i: 操作多少次位移, 今天是多少号,则有多少次
for (int i = dayOfMonth; i > 0; i--) {
// 右移再左移等于自己, 代表移动的是0,表示未签到。也有可能今天是第一天签到,也要排除
if ((v >> 1 << 1 == v) && (i != dayOfMonth)) {
// 低位是0且非当天签到
break;
} else {
continuousSignInCount.getAndIncrement();
}
// 右移一位,开始前一天的判断
v >>= 1;
}
});
return continuousSignInCount.get();
}