01 背景
大家好,我是阿沐!你的收获便是我的喜欢,你的点赞便是对我的认可。
多年前刚毕业出来工作的时候,那个时候刚毕业对缓存的使用基本上可以说很少涉及,在大学做课件设计或者小型项目也都是用不到缓存,再者说了我大学是做嵌入式写汇编语言和c语言的。
当时出实习去找工作并不顺利,面试官问了知道redis和memcached区别嘛?额,我当时虽然也做了一些功课,就是恶补redis基础以及应用,但是并不是很熟悉;就支支吾吾地回答个一知半解。然后又问我,假如公司现在做一个app,app有签到功能,你该怎么做?
“这很难嘛?”,直接对着面试官说用mysql存储就可以啦。
“目前我们有20多万的活跃用户,你确定只用mysql存储嘛?你就不怕把我们数据库搞炸了?”,面试官很费解的看着我,心里好像在对我说,真是个菜鸟。这么简单就不会?
正好最近有小伙伴问了这关于签到方面的知识点,这里就来说一说!
02 位图是什么梗?
官网说:位图并不是一个真实的数据类型,而是定义在字符串类型上的面向位的操作集合。位图的最小单位是比特(bit),每一个bit的值只能是0或者1。redis中字符串限制最大为512M,所以位图中最大可容纳2^32(42亿)个不同的位。
可以将位图看做是一个bit数组,数组的下标就是偏移量
它的优点:内存开销小,效率高且操作简单。

03 我们用位图能做什么?
- 统计用户每日签到(最最最常用的)
- 统计日活/越活活跃用户(扩展:精确数据:用
hive或spark统计;非精确数据,用HyperLogLog) - 用户在线状态实时统计(1亿用户大概:需要12MB的存储空间)
- 数据双写去重
- 视频、文章等等的已读或未读状态
04 位图有哪些指令可以使用?
1、查找select指令操作:
1getbit指令:getbit key offset 获取指定偏移量上的位(bit);时间复杂度O(1)。 2 3注意: 4 5当key不存在或者offset比字符串值的长度大时,则返回0。 6 7bitcount指令:bitcount key [start] [end]获取指定范围内比特位的数量;时间复杂度O(n)。 8 9注意: 10 11当key不存在时会被当成是空字符串来处理,所以返回值为0。 12 13bitpos指令:bittops key bit [start] [end] 获取位图中第一个值为bit的二进制位的位置;时间复杂度: O(n),其中n为位图包含的二进制位数量。
2、添加insert指令操作:
1setbit指令:setbit key offset value 设置key所储存的字符串值,或清除指定偏移量上的位(bit);时间复杂度:O(1)。 2 3注意: 4 51、位的设置或清除取决于value参数,0或者1。 6 72、当key不存在时,自动生成一个新的字符串值。 8 93、位数组会自动伸展扩充,offet偏移量设置超出现现有的内容范围,为确保value值在指定偏移量上,会通过扩容,空白位置用0填充补上, 10 114、offet参数值必须大于或者等于0,小于2^32(字符串最大值是512M)
下面是我整理哈希类型命令的时间复杂度,大家可以参考此表:
| 指令 | 时间复杂度 |
|---|---|
| getbit key offset | O(1) |
| bitcount key [start] [end] | O(n),n是位数量的大小 |
| setbit key offset value | O(1) |
| bittops key bit [start] [end] | O(n),n是二进制位数量 |
05 位图实践系列
我们要实现的功能是最最最常用的签到,实现功能如下:
- 1、签到打卡
- 2、检测某一天是否打卡(因为大部分app只会存在当日是否签到按钮)
- 3、获取用户某月打卡记录列表
- 4、统计用户某月打卡总次数
- 5、获取用户某月连续打卡次数
- 6、用户补签
我们首先创建一个关系型的用户打卡信息数据表,存储用户的打卡信息,这里强调一点:
看过网络上很多人只用redis才存储用户打卡信息,并不实际落地处理,最奇怪的是别人问,假如redis挂了或者怎样,运营或者产品想要获取数据分析,我们该怎么办?
有一位博主仁兄回答是:“redis高可用、redis持久化、后台写一个查询缓存接口,这些你不会嘛?”,下面评价:博主是认真回答的嘛?来逗我们玩呢?我也是笑了笑.....
说一说建表记录的原因:
1、在大数据时代,任何有价值的信息都要收集起来,跟用户活跃度、DAU相关的都是比较重要的
2、跟用户相关联的,产品与营运必定会需要这些数据来分析用户行为,认为签到送礼带来的收益
3、不能过度的去依赖缓存,一旦缓存出问题或者崩盘,数据丢失都是一个大问题,用户的反馈投诉极具增加
4、用户存在质疑时,可以快速通过落地数据进行排查问题
5、缓存出现问题时,可以通过数据库的记录进行数据回源,保证数据一致
1CREATE TABLE `mumu_sign_202105` ( 2 `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键自增ID', 3 `user_id` varchar(255) NOT NULL DEFAULT '' COMMENT '用户昵称', 4 `sign_date` date NOT NULL DEFAULT '0000-00-00' COMMENT '签到时间', 5 `create_at` int(10) NOT NULL DEFAULT '0' COMMENT '创建时间', 6 PRIMARY KEY (`id`), 7 UNIQUE KEY `uniq_uid_date` (`user_id`,`sign_date`) 8) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户信息基础表';
大家看到这个表名是不是很奇怪为啥带上_202105呢,主要是这里用了按月分表的原理,因为用户量大,尽可能的保证一张表的数据在500w一下,当然有的性能比较好1000以上优化之后速度也是杠杠的。
不过建议还是按月分表,每个月度结束之后,数据可以同步给hive、es、sphinx都可以,我们只需要保证半年内的表数据,其余可以用完删除即可。
所以每当我们设计表或者做一项功能,一定要考虑预估量以及为什么要这样做,能带来的效益是什么?这样你会变的越来越优秀,思维逻辑越来越缜密。
1insert into `mumu_user` (`user_id`, `sign_date`,`create_at`) VALUES 2('10001','2021-04-22', unix_timestamp()), 3('10001','2021-04-24', unix_timestamp()), 4('10001','2021-04-25', unix_timestamp()), 5('10001','2021-04-26', unix_timestamp()), 6('10001','2021-04-30', unix_timestamp());
那么下面我们开始代码逻辑梳理:
① 签到打卡系列
1/** 2 * @desc 签到 3 * @param string $date 4 * @return int 5 */ 6public function signIn($date = '') 7{ 8 //获取当月用户签到的缓存key 9 $key = $this->getKey($date); 10 11 // this->getCurrentDay 获取当日是本月的第几天并且减去1就是设置位图的下标 12 return $this->redis->setBit($key, $this->getCurrentDay($date), 1); 13} 14 15require_once './Bitmap.php'; //引入位图类 16 17$user_id = 1001; //传入用户ID 18 19$date = "2021-04-24"; //传入指定的签到日期 20 21$bitmap = new Bitmap($user_id); //实例化位图类 22 23echo $bitmap->signIn($date); //输出1 24 25-- 终端操作 26localhost:6379> SETBIT user:sign:1001:202104 23 1 27(integer) 0
是不是看起来超级简单,无非就是使用setbit指令来给用户存储签到状态为1。记住这里写入缓存之前一定要先插入数据表保证数据库落地成功,聪明的你可能在表中看到了设置的唯一键,目的是保证一个用户每天的只有一次签到记录。
② 检测某一天是否已打卡
1/** 2 * @desc 判断用户在某一天是否签到 3 * @param string $date 4 * @return int 5 */ 6public function judgeUserSign($date = '') 7{ 8 $key = $this->getKey($date); 9 10 return $this->redis->getBit($key, $this->getCurrentDay($date)); 11} 12 13require_once './Bitmap.php'; 14 15$user_id = 1001; 16 17$date = "2021-04-24"; 18 19$bitmap = new Bitmap($user_id); 20 21echo $bitmap->judgeUserSign($date);// 输出值为1 22 23-- 终端操作 24localhost:6379> GETBIT user:sign:1001:202104 23 //这里23是指位图下标,因为是从0开始,所以存储时减一操作了 变成了23 25(integer) 1 26localhost:6379> GETBIT user:sign:1001:202104 24 //这里实际查询的是4月25号是否签到 27(integer) 0
③ 获取用户某月打卡记录列表
1/** 2 * @desc 获取用户本月签到的记录列表 3 * @param string $date 4 * @return mixed 5 */ 6public function getUserAllSign($date = '') 7{ 8 // 获取本月或者指定月的签到缓存key 9 $key = $this->getKey($date); 10 11 // 很遗憾 本地reddi并没有支持这个函数 12 $result = $this->redis->bitField($key); // 正常这里应该返回的是数组 我这里使用不了 相当于模拟 13 14 // 存储本月的签到结果集 15 $list = []; 16 17 // 获取指定月的月数 18 $days = $this->getMonthDays($date); 19 20 // 从低位到高位遍历,0表示未签到;1表示已签到 21 for ($i = $days; $i > 0; $i--) { 22 // 本月已经循环完直接退出 23 if ($i < 0) break; 24 // 定义当前的日期是多少 25 $local_date = date('Y-m') . '-' . $i; 26 27 // 右移再左移,如果不等于自己说明最低位是 1,表示已签到 28 $flag = ($result >> 1 << 1) != $result ? true : false; 29 30 // 如果已签到,添加标记为1,否则为0 31 $list[$local_date] = $flag ? 1 : 0; 32 33 // 然后右移一位重新赋值计算 34 $result >>= 1; 35 } 36 37 return $list; 38} 39 40require_once './Bitmap.php'; 41 42$user_id = 1001; 43 44$date = "2021-04-24"; 45 46$bitmap = new Bitmap($user_id); 47 48$result = $bitmap->getUserAllSign($date); 49 50var_dump($result); 51 52//执行结果集 53array(30) { 54 ["2021-04-30"]=> 55 int(0) 56 ["2021-04-29"]=> 57 int(0) 58 ["2021-04-28"]=> 59 int(0) 60 ["2021-04-27"]=> 61 int(0) 62 ["2021-04-26"]=> 63 int(0) 64 ["2021-04-25"]=> 65 int(0) 66 ["2021-04-24"]=> 67 int(1) 68 ["2021-04-23"]=> 69 int(0) 70 ["2021-04-22"]=> 71 int(0) 72 ["2021-04-21"]=> 73 int(0) 74 ["2021-04-20"]=> 75 int(0) 76 ["2021-04-19"]=> 77 int(0) 78 ["2021-04-18"]=> 79 int(0) 80 ["2021-04-17"]=> 81 int(0) 82 ["2021-04-16"]=> 83 int(0) 84 ["2021-04-15"]=> 85 int(0) 86 ["2021-04-14"]=> 87 int(0) 88 ["2021-04-13"]=> 89 int(0) 90 ["2021-04-12"]=> 91 int(0) 92 ["2021-04-11"]=> 93 int(0) 94 ["2021-04-10"]=> 95 int(0) 96 ["2021-04-9"]=> 97 int(0) 98 ["2021-04-8"]=> 99 int(0) 100 ["2021-04-7"]=> 101 int(0) 102 ["2021-04-6"]=> 103 int(0) 104 ["2021-04-5"]=> 105 int(0) 106 ["2021-04-4"]=> 107 int(0) 108 ["2021-04-3"]=> 109 int(0) 110 ["2021-04-2"]=> 111 int(0) 112 ["2021-04-1"]=> 113 int(0) 114} 115//我们再看下终端执行的结果 116localhost:6379> BITFIELD user:sign:1001:202104 get u30 0 1171) (integer) 64
注意2021-04-24这日期是我们上面设置签到了,返回的64这个是二进制数据:0100 0000 这样是不是就很清晰了,表示24号签到了
大家是不是有点疑问,为啥没有支持bitField指令,redis在3.2版本之后就新增了这个强大的指令bitfield。r如果没有这个指令的出现,估计上面的代码就要缓存通过管道命令批量获取几十天的数据了。但是有了它就完全不是一个概念,它一条命令就可以完成所有值获取。普及下bitfield命令:
1官方文档: 2BITFIELD key [GET type offset] [SET type offset value] [INCRBY type offset increment] [OVERFLOW WRAP|SAT|FAIL] 意思是:中括号的意思是指支持它的子命令;get、set、incrby 3 4时间复杂度:O(1) 5 6举个例子解析: 7bitfield key get u8 0 81、key指我们需要操作的缓存key 92、get是bitfield的子命令 用来select 103、u8表示无符号数+30位整形位数(i8表示有符号数) 114、0表示返回指定的位偏移量
科普一下:所谓的无符号数是指非负数,没有符号位置,获取的位数组全部都是值;无符号数是指一个负数,获取到的值的第一位是符号位,剩下的才是可用的值。大家想了解的话,可以看看计算机组成原理在bibi就可以看的。
④ 获取用户当月打卡总数
1/** 2 * @Desc 获取用户当月打卡总数 3 * @param string $date 4 * @return int 5 */ 6public function getSumSignCount($date = '') 7{ 8 $key = $this->getKey($date); 9 10 return $this->redis->bitCount($key); 11} 12$bitmap = new Bitmap($user_id); 13 14$result = $bitmap->getSumSignCount($date); 15 16var_dump($result); //结果输出1 因为我们4月份就打卡了一天 17 18//终端执行 19localhost:6379> BITCOUNT user:sign:1001:202104 20(integer) 1
⑤ 获取用户连续签到次数
1/** 2 * @desc 获取用户连续签到的次数 3 * @param string $date 4 * @return int 5 */ 6public function getContinuousSignCount($date = '') 7{ 8 $key = $this->getKey($date); 9 10 // 获取今天天数 11 $days = $this->getCurrentDay($date); 12 13 //// 获取用户从当前日期开始到 1 号的所有签到状态 不过很遗憾 本地reddi并没有支持这个函数 14 $result = $this->redis->bitField($key, 'u' . $days, 0); // 正常这里应该返回的是数组 15 16 // 连续签到计数器总数 17 $signCount = 0; 18 19 $value = isset($result[0]) ? $result[0] : 0; 20 21 // 通过位移计算连续签到次数 22 for ($i = $days; $i > 0; $i--) // i 表示位移操作次数 23 { 24 if ($i < 0) break; //超出则终止循环 25 26 // 先右移再左移,如果等于自己说明最低位是 0,表示未签到 27 if ($value >> 1 << 1 == $value) { //存在用户当天还未签到,所以要排除掉 28 // 低位 0 且非当天说明连续签到中断了 29 if ($i != $days) break; 30 } else { 31 // 如果不等于自己说明最低位是1,表示已经签到 32 $signCount++; 33 } 34 35 // 右移一位并重新赋值,相当于把最低位丢弃一位然后重新计算 36 $value >>= 1; 37 } 38 return $signCount; 39} 40 41$bitmap = new Bitmap($user_id); 42 43$result = $bitmap->getContinuousSignCount($date); 44 45var_dump($result); //执行结果 只有2次连续签到 后面我设置了 20号 24号签到了 46//终端执行 47localhost:6379> BITFIELD user:sign:1001:202104 get u25 0 481) (integer) 22
这个连续签到次数主要是考验大家对二进制的位运算的熟练程度,知道如何进行位运算,这样就能更好地知道签到如何使用。
科普下左移右移:
$a << $b (左移) 将 $a 中的位向左移动 $b 次(每一次移动都表示“乘以 2”);左移时右侧以零填充,符号位被移走意味着正负号不被保留。
$a >> $b (右移) 将 $a 中的位向右移动 $b 次(每一次移动都表示“除以 2”);右移时左侧以符号位填充,意味着正负号被保留。
⑥ 用户补签
1/** 2 * @desc 用户补签 3 * @param string $date 4 * @return bool|int 5 */ 6public function rebuildSign($date = '') 7{ 8 $key = $this->getKey($date); 9 10 // 先检测当前用户这一天是否已经签到 11 if ($this->judgeUserSign($date)) return false; 12 13 return $this->signIn($date); 14} 15//这个就很简单了,大家可以自己操作一下
位图实战代码仓库地址:https://github.com/woshiamu/amu/tree/master/redis
最后总结
本文主要是通过实际的应用场景才讲解redis的位图使用。如何应用,如何实践,通过一个个的代码案例执行你就能更加了解位图到底是怎么回事。
位图是一个占用内存且能存储大量数据的一个字符串。给大家一个小小的建议,在看文章或者看书籍时,一定要看完之后动手实践,因为实践才是检验真理的唯一标准;如果还在使用set hash simember来做签到功能,可以尝试改换然后对比性能,也提高下我们的技术水准以及接口访问速度。
看完文章小伙伴们对位图的使用是否有进一步的了解?如果阿沐的文章感觉有帮助或者有不足之处,请在评论下面留言。
最后,欢迎关注我的个人公众号「我是阿沐」,会不定期的更新后端知识点和学习笔记。也欢迎直接公众号私信或者邮箱联系我,我们可以一起学习,一起进步。
好了,我是阿沐,一个不想30岁就被淘汰的打工人 ⛽️ ⛽️ ⛽️ 。
