Redis BitMap 实现签到

546 阅读5分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第6天,点击查看活动详情

业务背景

关于签到的场景很透明,网上关于签到的实现也有很多文章。这里就不过多的解释签到的业务场景。简单的来说就是记录用户签到行为。

基于 MySQL 的实现

  1. 建立用户签到表
CREATE TABLE `award` ( 
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id', 
`user_id` bigint(20) NOT NULL COMMENT '用户主键', 
`checkin_time` timestamp DEFAULT NULL COMMENT '签到时间', 
`create_time` timestamp NOT NULL DEFAULT current_timestamp() COMMENT '创建时间', 
`update_time` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() COMMENT '更新时间', 
PRIMARY KEY (`id`) USING BTREE ) 
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户签到表';
  1. 统计用户签到信息
  • 需求1: 用户连续签到天数
  • 需求2: 用户某一时间段内连续签到数据
  • 需求3: 用户最长连续签到时间、用户当前最长签到时间
  • ......

我相信这些签到的统计数据对于能够获取到签到明细数据的程序来说,实现只是简单的增删改查。

  1. 问题

如上对于数据量百万以至于千万的时候依旧能轻松应付。但是随着用户量的增加、签到数据的积累。这张表的数据会达到上亿条,那个时候 MySql 的性能会急剧下降。(假设用户用户数量 100000,理想情况下一年的数据会达到 36500000)这个时候就要考虑分库分表。分库分表又会带来新的问题,比如如何统计今日签到人数等。

关于 BitMap 的实现

以上分析如果使用传统的 MySQL 带来的问题。其实这些问题主要是源于数据量的问题,我们只要解决数据量的问题就可以解决性能的问题。

因为签到的数据只需记录时间与是否签到。恰好符合 BitMap 的存储,我们只需记录开始时间 + BitMap 就可以完美记录。

image.png

BitMap

位图(Bitmap),即位(Bit)的集合,是一种数据结构,可用于记录大量的0-1状态,在很多地方都会用到,比如Linux内核(如inode,磁盘块)、Bloom Filter算法等,其优势是可以在一个非常高的空间利用率下保存大量0-1状态。

Redis BitMap 实现

Redis 中没有单纯的 BitMap 数据结构的实现。Redis 利用 String 类型数据结构实现 BitMap,因此最大上限是512M,转换为 bit 则是 2^32 个 bit 位。

BitMap常用操作命令

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

举个例子

假设用户8月的签到数据如下:

+----+---------+---------------------+---------------------+---------------------+
| id | user_id | checkin_time        | create_time         | update_time         |
+----+---------+---------------------+---------------------+---------------------+
|  1 |       1 | 2022-08-01 00:00:00 | 2022-08-14 18:42:40 | 2022-08-14 18:42:40 |
|  2 |       1 | 2022-08-02 00:00:00 | 2022-08-14 18:42:50 | 2022-08-14 18:42:50 |
|  3 |       1 | 2022-08-03 00:00:00 | 2022-08-14 18:42:54 | 2022-08-14 18:42:54 |
|  4 |       1 | 2022-08-04 00:00:00 | 2022-08-14 18:42:57 | 2022-08-14 18:42:57 |
|  5 |       1 | 2022-08-05 00:00:00 | 2022-08-14 18:42:59 | 2022-08-14 18:42:59 |
|  6 |       1 | 2022-08-06 00:00:00 | 2022-08-14 18:43:02 | 2022-08-14 18:43:02 |
|  7 |       1 | 2022-08-10 00:00:00 | 2022-08-14 18:43:05 | 2022-08-14 18:43:05 |
|  8 |       1 | 2022-08-11 00:00:00 | 2022-08-14 18:43:08 | 2022-08-14 18:43:08 |
|  9 |       1 | 2022-08-15 00:00:00 | 2022-08-14 18:43:12 | 2022-08-14 18:43:12 |
| 10 |       1 | 2022-08-28 00:00:00 | 2022-08-14 18:43:16 | 2022-08-14 18:43:16 |
| 11 |       1 | 2022-08-29 00:00:00 | 2022-08-14 18:43:20 | 2022-08-14 18:43:20 |
+----+---------+---------------------+---------------------+---------------------+

那么对于 BitMap 的数据存储为:

start_time = 2022-8-1
key = user:1 
value = 11111100011000100000000000011

# redis 统计结果
# 1. 签到总天数 11
BITCOUNT user:1 

# 2. 统计累计签到多少天
# MySQL 新增累计签到字段,每次更新只需判断当日签到的前一天是否签到即可

# 3. 获取 8月13日签到数据 0
GETBIT user:1 13

扩展

最开始我是想用 Redis 的 BitMap 作为缓存。最终持久化数据到 My SQL,但是这里却遇到了问题: 在我将数据序列化到 Java 程序中会出现 底层 的字节数组会被序列化以及反序列化,从而改变 Redis str 底层的二进制数据存储。

如图如果使用 RedisTemplate 并配置 StringRedisSerializer。

image.png

问题:

假设 Redis 中存储的 Bit 为 100000000 对应十进制为 128。

那么对应 Java 程序中的 byte[] 为 [-128] 对应二进制位 -100000000

带来的问题就是 Redis 中的二进制数据与程序中读到的二进制数据不相同。

参考文档

Redis 对象的类型与编码