Redis高级数据结构原理解析-bitmap,hyperloglog

Redis 位图

  • 开发过程中,我们可能遇到这种场景记录用户的打卡情况,签到情况,这些场景只有两种结果,有或者没有,加入记录的数据量比较大,比如用一年的数据,如果用Redis中普通key/value,每个用户要记录365个,当用户上亿时候,需要的存储就比较多了。Redis为解决这种勤快提供了位图的数据结构,这样一条数据在位图中只需要占用1位,365天就是365位,一个字节8位,你们就是46个字节左右,这样大大节省空间,位图的最小单位是bit,每个bit只能取1或者0.
    在这里插入图片描述
  • 位图在Redis中就是普通字符串,也就是byte数组。可以通过Redis命令get/set直接获取,和设置整个位图的内容,也可以使用getbit/setbit 等将byte看出位数组操作某一个位。
基本用法
  • Redis中提供位图的统计指令bitcount和位图查找指令bitpos, bitcount统计指定范围内1 个数,bitpos查找指定范围内出现的第一个0或者1。
  • 还是签到的案例,我们通过bitcount统计用户一共签到多少天,通过bitpos查找从那一天开始签到的。还可以指定范围参数[start, end],可以统计某个时间范围内用户签到多少天。
  • 注意此处的start,end是字节的索引,也就是说指定的范围必须是8的倍数(1字节=8位),不能任意指定。如下案例
新docker-redis:0>get w
"hello"
新docker-redis:0>bitcount w
"21"
新docker-redis:0>bitcount w 0 0 // 第一个字符中1 的位数
"3"
新docker-redis:0>bitcount w 0 1//前两个字符中1 的位数
"7"
新docker-redis:0>bitpos w 0 //第一个0 位
"0"
新docker-redis:0>bitpos w 1//第一个1 位
"1"
特殊的bitfield
  • Redis中setbit, getbit指定的值都是单个位的,如果要一次操作多个位,就必须使用管道处理。Redis3.2 之后增加了一个强大的指令bitfield,可以一次性处理多个位操作。
  • bitfield 有三个基本指令,get,set, incrby,可以对指定位片段进行读写,但最多只能处理64个连续位,如果超过64个,就得使用多个子指令,bitfield可以一次执行多个指令。如下是h,e两个字符的bit位示意图:
    Redis高级数据结构原理解析-bitmap,hyperloglog_第1张图片
新docker-redis:0>set w hello
"OK"
新docker-redis:0>bitfield w get u4 0 //从第一位开始取4个位,结果是无符号数u
 1)  "6"
新docker-redis:0>bitfield w get u3 2 //从第三个位开始取3个位,结果是无符号数u
 1)  "5"
新docker-redis:0>bitfield w get i4 0 //从第一位开始取4个位,结果有符号数i (0-正 1-负)
 1)  "6"
新docker-redis:0>bitfield w get i3 2 //从第三位开始取3个位,结果是有符号i
 1)  "-3"
  • 如上结果中,有符号以上是第一个是按符号位算,其他的才是值,符号为0-正 1-负,无符号标识非负数,没有符合为,获取到的位全是数值。有符号数最多获取64位,无符号只能获取63位,因为Redis协议中的integer是有符号数,最大64位,符号位占用一位,所以只有63位数据位,如果超出限制,会提示参数错误。一次性执行多个指令:
127.0.0.1:6379> bitfield w get u4 0 get u3 2 get i4 0 get i3 2
1) (integer) 6
2) (integer) 5
3) (integer) 6
4) (integer) -3
  • bitfielt提供单个字节替换的功能,因为1字节=8为,我们可以替换其中的一个8 位bit符来达到操作字节的目的,如下面这个案例我们将hello中的第二个字符替换成hallo:
127.0.0.1:6379> get w
"hello"
127.0.0.1:6379> bitfield w set u8 8 97 // 从第九位开始替换一个8bit的字节,用97替换
1) (integer) 101
127.0.0.1:6379> get w
"hallo"
  • 同样的bitmap中也有自增命令incrby,用来对指定范围的位进行自增操作,因为自增的时候就可能出现数据溢出的风险,Redis中默认是折返处理,比如溢出后将溢出符号为丢弃,如果8位无符号255,加1 溢出变成0 也就是向上近位 1111 1111 变成1 0000 0000但是只有8 位所以第一位1 被舍弃,变成最后的0 。
  • bitfield指令提供溢出的操作行为默认是折返(wrap),还可以失败(fail)就是报错不执行,饱和截断(sat)超过范围停留在最大值比如255 +1 = 255,如下三个案例:
//越界情况
127.0.0.1:6379> set w hello
OK
127.0.0.1:6379> bitfield w incrby u4 2 2 //从第三位开始,对接下来4位无符号数+1
1) (integer) 12
127.0.0.1:6379> bitfield w incrby u4 2 2
1) (integer) 14
127.0.0.1:6379> bitfield w incrby u4 2 1
1) (integer) 15
127.0.0.1:6379> bitfield w incrby u4 2 1 // 越界,变为0 
1) (integer) 0

//饱和截断情况
127.0.0.1:6379> set w hello
OK
127.0.0.1:6379> bitfield w overflow sat incrby u4 2 2
1) (integer) 12
127.0.0.1:6379> bitfield w overflow sat incrby u4 2 2
1) (integer) 14
127.0.0.1:6379> bitfield w overflow sat incrby u4 2 1
1) (integer) 15
127.0.0.1:6379> bitfield w overflow sat incrby u4 2 1
1) (integer) 15

//失败情况:
127.0.0.1:6379> set w hello
OK
127.0.0.1:6379> bitfield w overflow fail incrby u4 2 3
1) (integer) 13
127.0.0.1:6379> bitfield w overflow fail incrby u4 2 2
1) (integer) 15
127.0.0.1:6379> bitfield w overflow fail incrby u4 2 1
1) (nil)

Redis HyperLogLog

-Java web开发过程中经常需要统计网页的UV,这个我们怎么实现,之前我们统计PV只需要给每个网页分配一个Redis计数器就可以,将key后缀加上日期,每天一个,每次请求incrby就行。但是UV需要区分不同的用户,最笨的办法是每个也没分配一个set集合,用来存储访问的用户ID,但是这个方式是非常消耗内存的,当有热点也没达到百万兵法时候,当有N个热点也没时候,那内存消耗将会是一个非常大的数量,Redis中HyperLogLog引入来一个新的解决方案,提供来不精确的去重计数方案,虽然不精确但是标准误差也就0.81%而已,对于统计UV的这种业务场景是完全能够接受的。

HyperLogLog使用方式
127.0.0.1:6379> pfadd code userq
(integer) 1
127.0.0.1:6379> pfadd code user1
(integer) 1
127.0.0.1:6379> pfcount code
(integer) 2
  • 如上,Redis中HyperLogLog提供pfadd,pfcount,一个增加,一个统计,我们试了一把,统计没有误差,我们执行100000 次add操作来测试他的误差以及性能
public class HypLogLogDemo {
    public static final String key = "code";
    public static void main(String[] args) {
        Jedis jedis = JedisUtils.getJedis();
        for (int i = 0; i < 100000; i++) {
            jedis.pfadd(key, "user" + i);
        }
        long total = jedis.pfcount(key);
        System.out.println(total);
    }
}
//输出:99715
  • 如上结果相差285个按照0.285%的误差,对于UV的统计需求误差可以忽略,我们可以多次重复执行,其实还是得到近似的结果,说明他有去重的功能。
pfmerge
  • HypLogLog除了提供上面的两个命令还有一个merge功能,用于将多个计数器累加成一个新的pf值
  • 比如多个内容相似的网页,运营需要合并统计,就可以用这个来统计。
  • HyperLogLog这个数据结构占用内存异常的小,只需要占用12KB内存,如果统计的计数有亿级别,那么对空间的节省是非常令人惊讶的
存储原理
  • HypLogLog有以下几个特点:

    • 实现比较困难
    • 能够用极少的内存来统计巨量的数据,在Redis中实现的HyperLogLog,只需要12k内存就能统计2^64个数据
    • 计数存在一定的误差0.81%
    • 误差可以被设置辅助计算因子进行降低
  • 12K是一个什么概念,1byte=8bit,long类型是8字节64bit,对应2^63-1 个数,那假设有这么多个数,从0 ~ 2 ^ 63-1,按照long以及1k=1026字节内存总数应该是((2 ^ 63-1) * 8.1024)K,这个远远超过来12k。

伯努力试验
  • HyperLogLog用极少内存完成巨量数据计算,我们先需要了解他的数学原理:伯努利试验(概率论)
  • 抛硬币一次得到正反概率都是50%,假设一直抛硬币,一直到得到一次正面,记这位一次完整试验,期间记录抛硬币的次数,这个试验就是伯努利试验;
  • 对于多次伯努利试验,假设多次为n次,每次试验抛次数为K(抛K次得到正面),因此第一次k1, 依次类推,第n次kn
  • 期间必然在n次试验中有一个最大抛次数k,我们称为k_max。
  • 伯努利试验容易得出如下结论
    • n次伯努利过程抛次数不大于k_max
    • n次伯努利过程至少依次k_max
  • 结合极大似然估算方法,可以发现n和k_max的估算关联: n = 2^(k_max),这种估算方法需要用概率论和数理统计方法推导证明,此处略过。
第一次试验: 抛了3次才出现正面,此时 k=3,n=1
第二次试验: 抛了2次才出现正面,此时 k=2,n=2
第三次试验: 抛了6次才出现正面,此时 k=6,n=3
第n 次试验:抛了12次才出现正面,此时我们估算, n = 2^12
  • 如上试验,共三组,k_max=6,n=3,代如公式3 != 2^6。此处只能说明样本太少,估算误差大。
估算优化
  • 我们假设上面3次试验算一轮,当n足够大,估算误差才小,例如我们进行100 轮,然后每一轮取出k_max,在取出平均数即:k_max/100,在估算数n,下面是LogLog的估算公式:
    在这里插入图片描述
  • 上面公式中DVLL对于的是n,contant是修正因子,具体值不定,可以根据时间情况分钟设置,m代表试验轮次,头上有一横的R就是平均数:(k_max_1+…+k_max_n)/m。
  • 这种通过增加试验次数在取k_max平均数的算法优化就是LogLog的做法,而HyperLogLog和LogLoe有一点区别,不是用平均数,而是调和平均数,相对平均数更不容易受到大数的影响。如下案例
A月薪1000,B月薪30000
平均数:(1000+30000)/2=15500
调和平均数:2/(1/1000 + 1/30000) ~ 1935.484
  • 显然,调和平均数比平均数更准确表达事实情况,下面是调和平均数的计算方式,∑ 是累加符号:
    Redis高级数据结构原理解析-bitmap,hyperloglog_第2张图片
  • 以上其实就是他的数学原理。
带入实际案例讲解
  • 上面的内容中通过抛硬币的案例解释了伯努利实验通过k_max来估算n,那么这种估算方法和我们UV统计方式有什么关联,我们需求是统计APP或者网页的一个页面,每天有多少个不同的用户点击进入的次数。同一个用户的反复点击进入记为1。HyperLogLog是按如下几个步骤操作:
  1. 获取比特串
  • 通过Hash函数,将数据转为比特串,比如,输入一个用户id=5,我们转成101,通过这种方式将访问用户数据和上面的抛硬币对应上,比特串中,0 代表反面,1 代表正面,如果一个数据最终被转成10010000,那么按照从右到左,从低位到高位看,我们认为首次出现1 的时候代表正面。
  • 基于上面的估算结论,我们可以通过多次试验的最大抛到正面的次数来预估总共进行了多少次实验,同样更具存储的数据中,转化后出现了1 的最大值k_max来估算出总量n。
  1. 分桶存储
  • 分桶就是分开进行多轮次。抽象到计算机中存储,可以看成是一个以单位是bit,长度是L的大的数组S,将S平均分成M组,这里的M其实就是对应多少轮,然后每组锁占有的比特个数是平均的,设为P,我们可以得出以下公式
    • L = S.length
    • L = M * P
    • 以K为单位,S占用内存= L / 8 / 1024
  • 在Redis中,HyperLogLog设置为: m=16834,p=6,L=168346。占用内存为168346/8/1024 = 12K
  • 如下示意来标识他存储的结构:
0组     第1组      第2组     第3....16833[000 000] [000 000] [000 000] [000 000] .... [000 000]
  1. 对应存储规则
  • 我们回到原始APP页面统计的具体问题中,设APP主页的key为:main, 用户id:idn, n->0,1,2,3 …
  • 在这个统计问题中,不同用户id标识了一个用户,你们我们可以把用户id作为被hash的输入:hash(id) = 比特串
  • 不同的用户id必然有不同比特串,每一个比特串,也必然至少出现一次1 的位置,我们类比每一个比特串就是一次伯努利试验
  • 现在要分轮次,即分桶存储,设定每个比特串钱w位转为10进制后,其值就对应于所在的桶的下标(1~16833)。假设比特串的低两位用来计算桶下标,此时有一个用户id的比特串是:1001 0110 0001 1,他的下标为 11(二进制) = 12 ^ 0 + 12 ^ 1 = 3,处于第三个桶。即第三轮
  • 上面案例中,计算桶号后,剩下的比特串: 1001 0110 000, 从低位到高位看,第一次出现1 的位置是5 ,也就是说,此时第三个桶,第三轮试验中k_max = 5, 5 对应二进制是 101,又因为每个通有p个比特位,单 p >= 3 时候,便可以将101存进去(三个比特位)。
  • 模拟上面流程,多个不同用户id就被分配到不同桶中区了,并且每个桶有其k_max,然后统计出main页面有多少用户点击量,就是一次估算。最终结合所有桶中的k_max,带入估算公式,便得出我们的估算值。
  • 更具上面调和平均数的公式,得出下面HyperLogLog的估算公式,变量的意义和LogLog的一样:
    Redis高级数据结构原理解析-bitmap,hyperloglog_第3张图片
  • 其中以下表示每个通的估计值,对每个通估计值进行调和平均数求职
    - 其中
Redis中HyperLogLog的原理
  • 首先Redis中HyperLogLog是他的意志高级数据结构,他的实现有16384个桶,即 2^14 = 16384,每个桶有6位,每个桶可以表达的最大数字是 :2 ^ 5 + 2 ^ 4 + 2 ^ 3+ 2 ^ 2 + 2 ^ 1 = 63,二进制是111 111。
  • 对应pfadd key value命令,存储时候,value被hash成64 位,即64bit位,前14位用来分桶,前14位的二进制转成10进制就是通标号。之所以选14位是因为2^14 = 16384 ,刚好最大的时候可以把桶利用完,不造成浪费,假设一个字符串的钱14位是00 0000 0000 0010,其十进制是2 ^ 1 = 2,你们就会被放到编号是2 的桶
14位全是12 ^ 13 + 2 ^ 12 + ......+ 2 ^ 5 + 2 ^ 4 + 2 ^ 3+ 2 ^ 2 + 2 ^ 1 + 2 ^ 0 = 16383 < 16384
  • Index的转化规则:
    • 因为完整value是64位,减去低位14位,剩下高位50 位,极端情况,出现1 的位置是第50位,次数index = 50,转二进制是 110010,不超过6 bit数,所以刚好可以被设置到第2 号桶中去,因为50是最坏的情况,类比第五十次才抛出正面,最坏情况,其他情况必然都能被存储到桶中,
    • 如果不同value有相同的前面 14 位,也就是两个用户hash(userid) 得到的64位低位14 位相同,但是后面出现1 的位置不同。那么比较原来的index是否比新的index打,是则替换,否则不变,存储更大的数字
    • 最终一个key对应的16384 个桶都设置了很多的value,每个桶有一个k_max,此时调用pfcount 时候,按前面介绍估算方法,计算key的设置了多少次的value,就得到对应统计值
  • value被转为64 位比特串,最终被按照上做法记录到每个桶中,64位转十进制2 ^ 64 ,HyperLogLog仅仅用了16384 * 6 /8/1024= 12K存储空间就能够完成2 ^64 数量基数的数据统计。

布隆过滤器

  • 上面HyperLogLog中,如果我想知道某个userId是不是已经存储了,发现找不到对应Redis命令来查找,因为HyperLogLog只提供了add,count的功能,没有提供contant功能。
  • 如果有这样的业务,比如,我们做用户推荐,新闻推荐的时候,他会给我们不停的推荐新内容,怎么去掉已经看过的内容。推送去重这是一个问题
  • 用set存储历史记录,太费内存,在Redis高级数据结构中布隆过滤器(Bloom Filter),专门用来解决去重问题。并且空间上节省90%以上,但是他会有一点不精确,可能导致微小的误判。
Redis中的布隆过滤器
  • Redis4.0 才提供了bloomfilter,但是他是作为一个插件加载到Redis server中,给Redis提供了强大的布隆过滤功能,
  • 布隆过滤器有两个基本指令
    • bf.add添加元素, 一次只能添加一个元素
    • bf.madd 添加元素,一次可以添加多个
    • bf.exists 判断是否存在,一次判断一个
    • bf.mexists 判断多个元素是否存在
布隆过滤器原理
  • 每个bloomFilter 对应到Redis的数据结构就是一个大小的bitmap加上杰哥不一样的无偏hash函数,如下面图,f,g,h就是这样的hash函数。所谓的无偏就是能把元素值算的比较均匀,让元素被hash后映射到位数字中的位置比较随机。
    Redis高级数据结构原理解析-bitmap,hyperloglog_第4张图片
  • 想bloomfilter中添加key时候,会使用多个hash函数对key进行hash,算得一个整数值,然后对维数组长度进行取模运算得到这个整数在位数组中的位置,每个hash函数都会算得一个不同的位置,再把这个位数组的这几个位置都设置为1,就完成add操作。所以其实一个key是会标记多个bit位,这取决于他hash算法的个数,每个hash都出来一个位置。(减少重复率)
  • 向bloomFilter询问key是否存在是,也和add一样,吧hash的几个位置算出,看bitmao里面是不是都是1,有一个是0 就一定不存在 。但是都是1 并不100%保证一定存在,只是极有可能存在,因为这些被设置为1 ,可能是其他key存在的hash位,如果数组比较稀疏,判断的正确率就高,如果数组拥挤,误判就高。
  • bloomFilter使用之前可以指定其参数,Redis提供了自定义参数的BloomFilter,我们需要在add之前用bf.reserve显示的创建,如果对于key已经存在,你们bf.reserve会报错。bf.reserve有三个参数key, error_rate(错误率), initial_size(预计数据大小)
    • error_rate越低,需要空间越大
    • initial_size表示预计放入的元素数量,当实际数量超过这个数,误判就会上升,所以需要提前设置一个较大的数值避免超过导致误判率升高
    • 如果不使用bf.reserver,默认的error_rate是0.01,默认的initial_size是100.
空间占用估计
  • bloomFilter空间占用有一个简单计算公式,不推导了,直接给吧,有两个参数,第一个语句元素数量n,第二个错误率f, 公式根据两个输入得到两个输出,第一个输出是位数组的长度1,也就是需要的存储空间大小bit,第二个输出是hash函数的最佳数量k。hash函数的数量也直接影响错误率,最佳数量会有最低错误率。
k = 0.7 *(1/n)  //约等于
f = 0.6185^(1/n) 
  • 如上公式得出:
    • 位数越长(1/n),错误率发越低
    • 位数月长(1/n),hash函数需要的最佳数量也越多,影响计算效率
    • 当一个元素评价需要1字节8bit的指纹空间(槽空间)时候(1/n = 8),错误率大概2%
    • 错误率0.1% 时候,一个元素平均指纹空间14.377bit,大约15bit
  • 即使15bit也比set强,因为这边是15位,set存储取决于他存储的字符串大小,不是一个量级。
元素超出误判率变化
  • 当实际元素超过预计元素,错误率会有多大变化,这个也有公司,引入参数t 表示实际元素和预计元素的倍数
f = (1-0.5^t) ^k // 极限近似,k是hash函数最佳数量

-暂时就这些

上一篇Redis基础数据结构内部实现简单介绍
下一篇Redis流量控制策略

你可能感兴趣的:(redis,分布式)