小X上一次面试勉强过关了,今天来进行了第二轮的面试,于是便有了如下对话。
面试官:我现在有个场景需求,比如我们的APP想做个用户签到功能,我想统计用户今年的签到次数,但是我有个特殊要求,你要使用redis来实现这个功能,你有什么想法呢?
面试者:这个简单啊,我可以使用简单的string来实现,只是在设计key的时候,用usercode+日期的格式,value随便都行,如果用户进行了点赞的动作,那么就往redis中缓存一条记录就行,比如set zhangsan20191225 1,然后当我按月统计或者按年统计用户签到次数的时候,就按照规则组装好key,然后依次去redis中获取对应的value,然后对value做一些统计就可以了。
面试官:嗯,你这个确实也是个好办法,但是你这个还是有点问题,我一个用户比如签到一年,那么同一个用户你就需要给他建365个key-value,内存占用太严重了,如果我们系统的用户上亿了,机器内存能存的下吗?还有没有什么其它能够省内存的方法呢?
面试者:额?这个嘛。。。
接下来就要引出我们今天的主角了,redis的位图,位图相信很多人都没有听过,这又是redis支持的新的数据结构?非也。其实位图就是我们上期文章介绍的string,通过上节的介绍,相信大家都知道了,string的底层数据结构存储其实是一个字节数组,既然是字节数组,那么我们可以不可以通过操作字节的位(bit)的方式去操作呢?答案当然是肯定的,redis就为我们提供了这样的命令,对应的就是位图。
回到上面的面试题,其实我们可以使用位图来进行处理,其实用户每次签到只占用一个位(bit),这样即使用户签到365天也只需占用365个位(bit),算下来也就46个字符,一个普通的字符串完全就可以容纳下来,这样就能节省很多内存了。
上面我们提到位图不是特殊的数据结构,就是redis的普通string,所以我们可以使用普通的get/set命令直接获取或者设置位图的内容,也可以使用位图操作的命令getbit/setbit命令将byte数组看成位数组进行操作,redis的位数组是自动扩容的,所以即使我们设置的某个偏移位置超出了现有内容的范围,redis也会自动将位数组进行零扩充。
接下来我们就通过位图操作来设置字符串"hello",不是使用set命令,首先我们需要知道"hello"字符串里面的每个字符的ascii码数的二进制数。
h:01101000 高位->低位
e:01100101
l:01101100
o:01101111
在设置之前,首先我们要明确一点,我们普通字符编码对应数字的二进制数的高位在左,低位在右,而位数组是从最左边开始的,所以低位在左,高位在右。例如下图上面一行是我们字符的二进制数的高低位位置,而下面一行是位数组的高低位的顺序。所以位数组存储的时候是从左到右,然后在每个字节或者字符内还是按照从右到左的顺序存储字符对应编码的二进制值。
接下来我们就先通过位图的命令将'h'和'e'这两个字符设置到位数组中,也就是位数组的头8位和第8位到第16位,但是在设置的时候,我们只需要设置对应位置为1的位就行了,为0的位置,redis会自动帮助我们填充0,所以h需要设置1,2,4位置,而e需要设置9,10,13,15位置,其他的依次类推设置就行。
上面我们这种方式是"按位存值按字符串取值"的方式,其实还有"按照字符串存值按位取值"或者"按位存值按位取值"等方式,按位存值就是使用redis的setbit命令依次进行位设置,而按字符串存值就是使用set命令直接设置一个字符串来填充整个位数组然后将整个旧值全部覆盖掉。
按位存值按位取值:
按照字符串设值按位取值:
其中如果对应的字节是不可打印的字符,那么redis就会返回其十六进制的数值
其实通过上面的讲解,我们已经完成我们想要的统计功能了,但是有个缺陷就是我们必须要把所有的值获取到程序中,然后用程序去统计,要是redis能够给我提供一些统计的命令,我们只需要使用这些命令就可以获取到我们想要的结果那该多爽,那么恭喜你,这个懒可以偷了,redis为我们提供了两个指令供我们使用。
bitcount:位图统计指令,统计指定范围内1出现的次数。
bitpos:用来查找指定范围内出现的第一个0或者1。
那么回到最开始的面试题,我们可以通过bitcount来统计用户当年的签到次数,使用bitpos命令来查找用户第一次签到的时间。并且我们在使用上面两个指令的时候还可以指定一个范围[start,end],用来统计某个范围内用户的签到次数,或者是从哪一天开始签到。但是有个缺陷是,start和end参数是字节索引,所以我们指定的范围必须是8的倍数,而不能任意的指定。所以我们想统计某个月,这个可能就不能实现了,因为那个月的数据可能分散在三个字节内,我们就必须将三个字节的数据全部取出来,然后取子串,在内存进行统计了。
最后上面我们的setbit和getbit都是只能操作单个值,如果我们想一次操作多个值,那么我们只能使用管道了,好在redis3以后给我们提供了一个批量操作的命令bitfield,这个指令有三个子指令,分别是set/get/incrby,可以对指定的位片段进行读写,但是一次只能连续处理64个位,如果超过64需要使用多个子指令,并且bitfield可以一次性使用多个不同的子指令。
一次性使用多个子指令
接下来我们使用set子命令将第一个字母h替换成a,其中a的ascii码位97,命令将会返回旧值。
最后我们来看看incrby子命令,incrby子命令用来对指定范围内的位进行自增操作,但是因为是自增操作,有可能就会超出二进制能够表示的范围,例如增加整数,就会向上溢出,而增加的是负数,就会出现向下溢出,redis默认采取的策略是折返,如果出现溢出,就将符号位直接丢弃,例如无符号的255自增1以后就溢出了,就全部变成了0,丢弃最高位的符号位之后,就是0,再比如8位无符号整数127自增1以后,就溢出了,我们丢弃最高的符号位之后,就是-128。redis除了折返wrap之外还为我们提供了饱和截断sat以及fail报错不执行,我们可以通过overflow子指令来手动选择某种方式。
默认为折返wrap的例子:
手动设置为饱和截断sat的例子:
最后再看一下手动设置为报错fail不执行的例子: