最近项目里需要集成签到和统计功能,连续签到后会给用户发放一些优惠券和奖品,以此来吸引用户持续在该品台进行活跃。下面我们一些来聊一聊目前主流的实现方案。
因为签到和统计的功能涉及的数据量比较大,所以在如此大的数据下利用传统的关系型数据库进行计算和统计是非常耗费性能的,所以目前市面上主要依赖于高性能缓存RedisBitMap 功能来实现。
先看看利用Mysql实现以上功能会有哪些缺陷和短板。
首先我们需要一个签到表
DROP TABLE IF EXISTS `tb_sign`;
CREATE TABLE `tb_sign` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int(11) NOT NULL COMMENT '用户Id',
`year` year(4) NOT NULL COMMENT '签到的年',
`month` tinyint(2) NOT NULL COMMENT '签到的月',
`date` date NOT NULL COMMENT '签到日期',
`is_backup` tinyint(1) NOT NULL COMMENT '是否补签',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
用户一次签到,就是一条记录,假如有1000万用户,平均每人每年签到次数为10次,则这张表一年的数据量为 1亿条
每签到一次需要使用(8 + 8 + 1 + 1 + 3 + 1)共22 字节的内存,一个月则最多需要600多字节
这样的坏处,占用内存太大了,极大的消耗内存空间!
我们可以根据 Redis中 提供的 BitMap 位图功能来实现,每次签到与未签到用0 或1 来标识 ,一次存31个数字,只用了2字节 这样我们就用极小的空间实现了签到功能
利用SETBIT新增key 进行存储
SETBIT bm1 0 1
看不懂上面的指令?没关系,我们可以通过help指令查看提示
help SETBIT
通过这个指令可以看出Redis SETBIT 命令用于对 key 所储存的字符串值,设置或清除指定偏移量上的位(bit)。位的设置或清除取决于 value,可以是 0 或者是 1 。
当 key 不存在时,自动生成一个新的字符串值。字符串会进行伸展以确保它可以将 value 保存在指定的偏移量上。当字符串值进行伸展时,空白位置以 0 填充。 offset 参数必须大于或等于 0 ,小于 2^32 (bit 被限制在 512 MB 之内)。
提示:如果 offset 偏移量的值较大,计算机进行内存分配时可能会造成 Redis 服务器被阻塞。
这样的话我们就可以通过偏移量设置每一天的签到情况:
下面我们只需要通过GETBIT命令就可以查看每一天的签到情况
GETBIT bm1 2
同样,我们可以通过BITCOUNT可以统计出该用户签到了多少天
BITCOUNT bm1
通过BITFIELD以原子方式操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
BITFIELD复杂度是O(n),其中n是访问的计数器数。
语法:
BITFIELD key [GET type offset] [SET type offset value] [INCRBY type offset increment] [OVERFLOW WRAP|SAT|FAIL]
参数说明:
使用方法
举个例子,我们有一个整数值10,它的二进制位是00001010,我们想获取它的第3位到第5位的值,即001。那么可以这样使用:
127.0.0.1:6379> BITFIELD myint GET u3 3 u1 6
1) (integer) 1
2) (integer) 0
u3:表示3位无符号整数
指定myint键值,通过GET命令获取它的第3位到第5位,结果返回一个二进制数001,也就是十进制的1。第二个返回值是0,表示其他未指定的位都是0。
127.0.0.1:6379> BITFIELD myint SET u3 3 5
(integer) 10
执行成功后,该键值指定的整数值变为13(二进制为00001101)。
所以统计每个用户的签到情况和积分,可以使用 Redis BITFIELD 命令记录每个用户每天的签到情况。
每个用户一年365天,需要使用整数类型的BITFIELD信息记录,每个用户需要365个二进制位来表示签到情况(已签到为1,未签到为0),再需要一个整数位去表示用户的总积分,就可以方便地统计用户签到情况并进行排名。
当然该命令的功能远不止于此,在某些系统中,需要对某些数据做计数,比如对每个IP地址访问次数的计数。可以使用字符串类型的计数器来达到这个目的,首先在Redis中创建一个字符串类型的计数器(值为0),通过INCRBY命令执行增减操作,每次给IP地址所代表的计数器加1,最后获取到的长度即为IP地址对应的访问次数。如果访问量过大,可以使用整数类型的BITFIELD存储计数器,通过INCRBY命令执行增减操作。这样可以优化性能并减少内存占用。
考虑到每月初需要重置连续签到次数,我们可以把 年和月 作为BitMap的key,然后保存到一个BitMap中,每次签到就到对应的位上把数字从0 变为1,只要是1,就代表是这一天签到了,反之咋没有签到。
key的格式为:“USER_SIGN_KEY:” + userId + keySuffix
Value则采用长度为4个字节(32位)的位图(最大月份只有31天)。位图的每一位代表一天的签到,1表示已签,0表示未签。
例如:USER_SIGN_KEY:122101:202309:表示ID=122101的用户在2023年9月的签到记录
# 用户9月17号签到
SETBIT USER_SIGN_KEY:122101:202309 16 1 # 偏移量是从0开始,所以要把17减1
# 检查9月17号是否签到
GETBIT USER_SIGN_KEY:122101:202309 16 # 偏移量是从0开始,所以要把17减1
# 统计9月份的签到次数
BITCOUNT USER_SIGN_KEY:122101:202309
# 获取9月份前30天的签到数据,u30表示取0-29位的数据
BITFIELD USER_SIGN_KEY:122101:202309 get u30 0
# 获取9月份首次签到的日期
BITPOS USER_SIGN_KEY:122101:202309 1 # 返回的首次签到的偏移量,加上1即为当月的某一天
有了上面的理论支撑,下面我开始在项目里去集成,为了节约篇幅,只展示核心代码,项目地址在文章末尾,完整代码可下载之后自行观看
首先需要我们项目里集成redis,可以参考:《springBoot集成redis(jedis)详解》
签到功能实现方式如下:
@GetMapping("sign")
public Object sign() {
//1. 获取登录用户
Long userId = 122101L;
//2.获取签到使用的key
String key = RedisKeyUtils.createSignKey(userId);
//3. 获取今天是本月的第几天设置偏移量
LocalDateTime now = LocalDateTime.now();
int offset = now.getDayOfMonth() - 1;
System.out.println(String.format("入参:key:%s,offset:%s", JSON.toJSONString(key), JSON.toJSONString(offset)));
//5. 写入redis setbit key offset 1
Boolean res = jedis.setbit(key, offset, true);
System.out.println(String.format("出参:%s", JSON.toJSONString(res)));
return String.format("%s签到成功,签到结果:%s", JSON.toJSONString(now.format(DateTimeFormatter.ofPattern("yyyyMM"))), JSON.toJSONString(res));
}
签到功能我们已经实现,只需要将对应该月的某天设置为1则表示签到成功
检查用户是否签到
@GetMapping("checkSign")
public Object checkSign(@RequestParam(value = "sigDate") String sigDate) {
//1. 获取登录用户
Long userId = 122101L;
//2.获取签到使用的key
LocalDateTime time = TimeUtils.str2LocalDateTime(TimeUtils.PATTERN.YYYYMMDD, sigDate);
if (Objects.isNull(time)) {
return false;
}
String key = RedisKeyUtils.createSignKey(time, userId);
//3. 获取今天是本月的第几天设置偏移量
int offset = time.getDayOfMonth() - 1;
System.out.println(String.format("入参:key:%s,offset:%s", JSON.toJSONString(key), JSON.toJSONString(offset)));
Boolean res = jedis.getbit(key, offset);
System.out.println(String.format("出参:%s", JSON.toJSONString(res)));
return res;
}
访问:http://127.0.0.1:8080/checkSign?sigDate=2023-09-07
下面我们继续看看签到统计功能
Q1:什么叫连续签到天数?
从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。
所以我们统计的方式很简单:
❝获得当前这个月的最后一次签到数据,定义一个计数器,然后不停的向前统计,直到获得第一个非0的数字即可,每得到一个非0的数字计数器+1,直到遍历完所有的数据,就可以获得当前月的签到总天数了❞
Q2:如何得到本月到今天为止的所有签到数据?
那我们则需要借助BITFIELD指令来进行实现
BITFIELD key GET u[dayOfMonth-1] 0
假设今天是5号,那么我们就可以从当前月的第一天开始,获得到当前这一天的位数,是5号,那么就是5位,去拿这段时间的数据,就能拿到所有的数据了,那么这5天里边签到了多少次呢?统计有多少个1即可。
Q3:如何从后向前遍历每个Bit位?
值得我们注意的是:❝bitMap返回的数据是10进制,哪假如说返回一个数字8,那么我哪儿知道到底哪些是0,哪些是1呢?❞
这是一道很简单的位运算算法题
我们只需要让得到的10进制数字和1做与运算就可以了,因为1只有遇见1 才是1,其他数字都是0 ,我们把签到结果和1进行与操作,每与一次,就把签到结果向右移动一位,依次类推,我们就能完成逐个遍历的效果了。
通过上面的几个方法就可以很容易统计用户签到的情况。
下面看看代码的具体实现
为了方便测试,我的当前时间为:2023-09-07,所以我对1,3,5,6,7五天的签到设置为1
那么当前缓存里的值则为:1010111,对应的10进制数为:87,最大连续1出现的个数为3,所以签到的天数为3
代码如下:
@GetMapping("signCount")
public Object signCount() {
//1. 获取登录用户
Long userId = 122101L;
//2. 获取日期
LocalDateTime now = LocalDateTime.now();
//3. 拼接key
String key = RedisKeyUtils.createSignKey(now, userId);
//4. 获取今天是本月的第几天
int offset = now.getDayOfMonth();
//5. 获取本月截至今天为止的所有的签到记录,返回的是一个十进制的数字 BITFIELD USER_SIGN_KEY1:5:202309 GET u5 0
String type = String.format("u%d", offset);
System.out.println(String.format("入参:key:%s,operationType:%s,type:%s", JSON.toJSONString(key), JSON.toJSONString(RedisHelper.OPERATION_TYPE.GET.name()), JSON.toJSONString(type)));
List<Long> result = RedisHelper.bitField(key, RedisHelper.OPERATION_TYPE.GET, type, "0");
//没有任务签到结果
if (result == null || result.isEmpty()) {
return 0;
}
Long num = result.get(0);
if (num == null || num == 0) {
return 0;
}
//6. 循环遍历
int count = 0;
while (true) {
//6.1 让这个数字与1 做与运算,得到数字的最后一个bit位 判断这个数字是否为0
if ((num & 1) == 0) {
//如果为0,签到结束
break;
} else {
count++;
}
num >>>= 1;
}
System.out.println(String.format("缓存值:%s,签到天数:%d", JSON.toJSONString(result), count));
return count;
}
@GetMapping("getFirstSignDate")
public Object getFirstSignDate(@RequestParam(value = "time") String time) {
//1. 获取登录用户
Long userId = 122101L;
//2. 获取日期
LocalDateTime dateTime = TimeUtils.str2LocalDateTime(TimeUtils.PATTERN.YYYYMMDD, time);
//3. 拼接key
String key = RedisKeyUtils.createSignKey(dateTime, userId);
//4. 获取首次出现的位置索引
long pos = jedis.bitpos(key, true);
//5. 转换为日期
String res = pos < 0 ? "无签到记录" : TimeUtils.localDateTime2String(TimeUtils.PATTERN.YYYYMMDD, dateTime.withDayOfMonth((int) (pos + 1)));
return res;
}
@GetMapping("getSignInfo")
public Object getSignInfo(@RequestParam(value = "time") String time) {
//1. 获取登录用户
Long userId = 122101L;
//2. 获取日期
LocalDateTime dateTime = TimeUtils.str2LocalDateTime(TimeUtils.PATTERN.YYYYMMDD, time);
//当月天数
int monthOfDays = TimeUtils.getDayNumsOfMonth(dateTime);
Map<String, Boolean> signMap = new HashMap<>(dateTime.getDayOfMonth());
//3. 拼接key
String key = RedisKeyUtils.createSignKey(dateTime, userId);
//4. 设置位数
String type = String.format("u%d", monthOfDays);
System.out.println(String.format("入参:key:%s,operationType:%s,type:%s", JSON.toJSONString(key), JSON.toJSONString(RedisHelper.OPERATION_TYPE.GET.name()), JSON.toJSONString(type)));
List<Long> list = RedisHelper.bitField(key, RedisHelper.OPERATION_TYPE.GET, type, "0");
if (!CollectionUtils.isEmpty(list)) {
Long num = list.get(0);
int i = monthOfDays;
while (i > 0) {
String d = TimeUtils.localDateTime2String(TimeUtils.PATTERN.YYYYMMDD, dateTime.withDayOfMonth(i--));
if ((num & 1) == 0) {
signMap.put(d, false);
} else {
signMap.put(d, true);
}
num >>>= 1;
}
}
//按照日期排序
TreeMap sortedMap = new TreeMap(signMap);
System.out.println("出参:" + JSON.toJSONString(sortedMap));
return sortedMap;
}
至此签到所需要的功能我们就全部实现了
本文的核心在于bitMap的使用,但是任何技术都需要有具体的落地,所以借着签到的场景带着大家具体使用一下。
当然bitmap的落地场景远不止我上文中介绍的签到统计等业务层面的方案,针对缓存穿透的场景,bitMap也是一个极佳的选择。
描述: 缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。
这种可以认为是系统漏洞,一旦发生这种情况一般都来自于恶意请求。
常见解决方案:
第一种解决方案:遇到的问题是如果用户访问的是id不存在的数据,则此时就无法生效,如id远大于某个值,虽然不小于0,但是也无法进行过滤。
第二种解决方案:遇到的问题是:如果是不同的id那就可以防止下次过来直击数据
所以我们如何解决呢?
我们可以将数据库的数据,所对应的id写入到一个list集合中,当用户过来访问的时候,我们直接去判断list中是否包含当前的要查询的数据,如果说用户要查询的id数据并不在list集合中,则直接返回,如果list中包含对应查询的id数据,则说明不是一次缓存穿透数据,则直接放行。
现在的问题是这个主键其实并没有那么短,有的可能是通过各种拼接生成的Id,是很长的一个主键,所以如果采用以上方案,这个list也会很大,所以我们可以 使用bitmap来减少list的存储空间
我们可以把list数据抽象成一个非常大的bitmap,我们不再使用list,而是将db中的id数据利用哈希思想,比如:
id 求余bitmap长度 : i n d e x = i d % b i t m a p . s i z e index=id\%bitmap.size index=id%bitmap.size算出当前这个id对应应该落在bitmap的哪个索引上,然后将这个值从0变成1,然后当用户来查询数据时,此时已经没有了list,让用户用他查询的id去用相同的哈希算法, 算出来当前这个id应当落在bitmap的哪一位,然后判断这一位是0,还是1,如果是0则表明这一位上的数据一定不存在,采用这种方式来处理,需要重点考虑一个事情,就是误差率, 所谓的误差率就是指当发生哈希冲突的时候,产生的误差 。
项目地址:https://gitee.com/ninesuntec/redisBitMapSign