使用BitMap实现用户签到功能

2.4k 词

什么是BitMap

所谓 BitMap 就是用一个 bit 位来标记某个元素对应的 value,而 key 即是这个元素。由于采用bit为单位来存储数据,因此在可以大大的节省存储空间。

优点:

  • 效率高,不许进行比较和移位
  • 占用内存少,比如N=10000000;只需占用内存为N/8 = 1250000Bytes = 1.2M,如果采用int数组存储,则需要38M多

缺点:

  • 无法对存在重复的数据进行排序和查找

为什么选择BitMap

假如我们用下面这样一张表来存储用户的签到信息

1
2
3
4
5
6
7
8
9
CREATE TABLE `tb_sign`  (
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` bigint(20) UNSIGNED NOT NULL COMMENT '用户id',
`year` year NOT NULL COMMENT '签到的年',
`month` tinyint(2) NOT NULL COMMENT '签到的月',
`date` date NOT NULL COMMENT '签到的日期',
`is_backup` tinyint(1) UNSIGNED NULL DEFAULT NULL COMMENT '是否补签',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Compact

每签到一次需要使用(8 + 8 + 1 + 1 + 3 + 1)共22字节的内存,一个月则最多需要600多字节

那么,如果用BitMap呢?
我们按越来统计用户签到信息,签到记为1,未签到则记为0
把每一个bit位对应当月的每一天,形成映射关系。
每个用户每个月仅消耗4字节不到

如何实现BitMap

Redis是利用string类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是2 ^ 32 个bit位。
常用的指令有如下:

  • SETBIT:向指定位置(offset)存入一个0或1
  • GETBIT :获取指定位置(offset)的bit值
  • BITCOUNT :统计BitMap中值为1的bit位的数量
  • BITFIELD :操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
  • BITFIELD_RO :获取BitMap中bit数组,并以十进制形式返回
  • BITOP :将多个BitMap的结果做位运算(与 、或、异或)
  • BITPOS :查找bit数组中指定范围内第一个0或1出现的位置

怎么用BitMap实现签到

实现签到功能时,业务流程如下:

  1. 获取当前登录用户
  2. 获取日期
  3. 拼接key
  4. 获取今天是本月第几天
  5. 写入redis SETBIT key offset 1|0

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public void sign() {
// 获取当前用户
Long userId = UserHolder.getUser().getId();
// 获取日期
LocalDateTime curTime = LocalDateTime.now();
// 拼接key
String key = RedisConstants.USER_SIGN_KEY +
userId +
curTime.format(DateTimeFormatter.ofPattern("yyyy:MM"));
// 获取是第几天
int offsetDay = curTime.getDayOfMonth() - 1;
// 写入Redis SETBIT key offset 1
stringRedisTemplate.opsForValue().setBit(key, offsetDay, true);
}

怎么统计连续签到天数

先获取这个月的签到数据,然后从前往后统计
实现很简单,直接上代码了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Override
public Integer signCount() {
// 获取本月今天为止的所有签到记录
// 获取当前用户
Long userId = UserHolder.getUser().getId();
// 获取日期
LocalDateTime curTime = LocalDateTime.now();
// 拼接key
String key = RedisConstants.USER_SIGN_KEY +
userId +
curTime.format(DateTimeFormatter.ofPattern("yyyy:MM"));
// 获取是第几天
int offsetDay = curTime.getDayOfMonth();
List<Long> result = stringRedisTemplate.opsForValue().bitField(
key,
BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(offsetDay)).valueAt(0)
);
// 得到最后有多少连续0
if (result == null || result.isEmpty()) return 0;
Long num = result.get(0);
if (num == null || num == 0) return 0;
int cnt = 0;
while ((num & 1) == 1) {
num >>>= 1; // 无论正负,高位都是补0
cnt++;
}
return cnt;
}

这个功能有个bug,无法统计这个月之前的签到天数

于是,签到功能就写完啦