Redis系列(3) Bloom/BitMap/Geo

https://gitlab.com/zhangxin1932/java-tools.git (java-tools for redis5.0)

全文代码及安装均基于 Redis5.0

1.Redis中的布隆过滤器 (验证某X是否在某Y中, 防缓存穿透)
2.Redis去重计数 (大批量数据)
3.Redis实现分布式计数器 (限流 & 接口请求次数统计)
4.Redis GEO (附近的人, 商店)

1.Redis中的布隆过滤器 (验证某X是否在某Y中, 防缓存穿透)

https://www.jianshu.com/p/28b97568299b (参考此文即可)

2.Redis去重计数 (大批量数据)

2.1 HyperLogLog (模糊去重计数版本)

2.1.1 概述

在说明 HyperLogLog 之前,我们需要先了解一个概念:基数统计。维基百科中的解释是:
[cardinality of a set is a measure of the “number of elements“ of the set.]
它的意思是:
一个集合(注意:这里集合的含义是 Object 的聚合,可以包含重复元素)中不重复元素的个数。
例如集合 {1,2,3,1,2},它有5个元素,但它的基数/Distinct 数为3。

Redis 最常用的数据结构有字符串、列表、字典、集合和有序集合。
后来,由于 Redis 的广泛应用,Redis 自身也做了很多补充,其中就有 HyperLogLog(2.8.9 版本添加)结构。
HyperLogLog 是用来做基数统计的算法,HyperLogLog 的优点是,
在输入元素的数量或者体积非常大时,计算基数所需的空间总是固定的、并且是很小的。

2.1.2 Redis HyperLogLog 结构

http://www.rainybowe.com/blog/2017/07/13/%E7%A5%9E%E5%A5%87%E7%9A%84HyperLogLog%E7%AE%97%E6%B3%95/index.html (关于HyperLogLog算法)

在 Redis 中每个键占用的内容都是 12K,理论存储近似接近 2^64 个值,不管存储的内容是什么。
这是一个基于基数估计的算法,只能比较准确的估算出基数,
可以使用少量固定的内存去存储并识别集合中的唯一元素。
但是这个估算的基数并不一定准确,是一个带有 0.81% 标准误(standard error)的近似值。

但是,也正是因为只有 12K 的存储空间,所以,它并不实际存储数据的内容。

2.1.3 Redis HyperLogLog 应用场景

鉴于 HyperLogLog 不保存数据内容的特性,所以,它只适用于一些特定的场景。
比如:  计算日活、7日活、月活数据。
微信公众号文章的阅读数,网页的 UV 统计(可利用cookie)。


// 为啥采用这种方式呢?
如果我们通过解析日志,把 ip 信息(或用户 id)放到集合中,例如:HashSet。
如果数量不多则还好,但是假如每天访问的用户有几百万。无疑会占用大量的存储空间。
且计算月活时,还需要将一个整月的数据放到一个 Set 中,这随时可能导致我们的程序 OOM。

2.1.4 Redis HyperLogLog 命令

Redis 为 HyperLogLog提供了三个命令:PFADD、PFCOUNT、PFMERGE。

1.PFADD
将任意数量的元素添加到指定的 HyperLogLog 里面。
时间复杂度: 每添加一个元素的复杂度为 O(1) 。
>> 如果 HyperLogLog 估计的近似基数(approximated cardinality)在命令执行之后出现了变化, 
那么命令返回 1 , 否则返回 0 。 
>> 如果命令执行时给定的键不存在, 那么程序将先创建一个空的 HyperLogLog 结构, 然后再执行命令。 

2.PFCOUNT
>> 当 PFCOUNT key [key …] 命令作用于单个键时,返回储存在给定键的 HyperLogLog 的近似基数,
如果键不存在,那么返回 0,复杂度为 O(1),并且具有非常低的平均常数时间;
>> 当 PFCOUNT key [key …] 命令作用于多个键时,返回所有给定 HyperLogLog 的并集的近似基数,
这个近似基数是通过将所有给定 HyperLogLog 合并至一个临时 HyperLogLog 来计算得出的,
复杂度为 O(N),常数时间也比处理单个 HyperLogLog 时要大得多。 

3.PFMERGE
>> 将多个 HyperLogLog 合并(merge)为一个 HyperLogLog,
合并后的 HyperLogLog 的基数接近于所有输入 HyperLogLog 的可见集合(observed set)的并集。
时间复杂度是 O(N),其中 N 为被合并的 HyperLogLog 数量,不过这个命令的常数复杂度比较高。
>> 命令格式:PFMERGE destkey sourcekey [sourcekey …]
合并得出的 HyperLogLog 会被储存在 destkey 键里面,
如果该键并不存在,那么命令在执行之前,会先为该键创建一个空的 HyperLogLog。
[zhangxin@JD install-prefix-redis]$ bin/redis-cli -p 26379
127.0.0.1:26379> pfadd alipay20200205 001 002 003
(integer) 1
127.0.0.1:26379> pfadd alipay20200206 001 004 005
(integer) 1
127.0.0.1:26379> pfmerge alipay202002 alipay20200205 alipay20200206
OK
127.0.0.1:26379> pfcount alipay20200205
(integer) 3 // 这里计数某一天的数据
127.0.0.1:26379> pfcount alipay202002
(integer) 5 // 这里计算2月份数据时, 进行了去重计数
127.0.0.1:26379> 

2.1.5 java代码实现

java实现去重计数.png

2.2 位图 (精确去重计数版本)

2.2.1 概述

#Bitmap(即Bitset)
Bitmap是一串连续的2进制数字(0或1),每一位所在的位置为偏移(offset),
在bitmap上可执行AND(与) OR(或) NOT(非) XOR(异或)以及其它位操作。


#位图计数(Population Count)
位图计数统计的是bitmap中值为1的位的个数。位图计数的效率很高,
例如,一个bitmap包含10亿个位,90%的位都置为1,
在一台MacBook Pro上对其做位图计数需要 21.1 ms。
SSE4甚至有对整形(integer)做位图计数的硬件指令。

2.2.2 实用命令

setbit命令

>> 语法:SETBIT key offset value 
>> 对 key 所储存的字符串值,设置或清除指定偏移量上的位(bit)。
>> 位的设置或清除取决于 value 参数,必须是 0 或 1 。
>> offset 参数必须大于或等于 0 ,小于 2^32 (bit 映射被限制在 512 MB 之内)。
>> offset 较大的操作,内存分配可能造成 Redis 服务器被阻塞。

# 命令示例
[zhangxin@JD install-prefix-redis]$ bin/redis-cli -p 26379
127.0.0.1:26379> setbit k 2 0
(integer) 0
127.0.0.1:26379> setbit k 3 1
(integer) 0

getbit命令

>> 语法:GETBIT key offset
>> 对 key 所储存的字符串值,获取指定偏移量上的位(bit)。
>> 当 offset 比字符串值的长度大,或者 key 不存在时,返回 0 。

# 命令示例
[zhangxin@JD install-prefix-redis]$ bin/redis-cli -p 26379
# 对已存在的 offset 进行 GETBIT, 返回 1
127.0.0.1:26379> exists k
(integer) 1
127.0.0.1:26379> getbit k 3
(integer) 1
# 对不存在的 offset 进行 GETBIT, 返回 0
127.0.0.1:26379> exists k1
(integer) 0
127.0.0.1:26379> getbit k 99
(integer) 0

bitcount命令

>> BITCOUNT key [start] [end]
>> 计算给定字符串中,被设置为 1 的比特位的数量。
>> 一般情况下,给定的整个字符串都会被进行计数,
通过指定额外的 start 或 end 参数,可以让计数只在特定的位上进行。
start 和 end 参数的设置和 GETRANGE 命令类似,都可以使用负数值:
比如 -1 表示最后一个位,而 -2 表示倒数第二个位,以此类推。

# 命令示例
[zhangxin@JD install-prefix-redis]$ bin/redis-cli -p 26379
127.0.0.1:26379> setbit k1 120 1
(integer) 0
127.0.0.1:26379> setbit k1 122 1
(integer) 0
127.0.0.1:26379> setbit k1 121 0
(integer) 0
127.0.0.1:26379> bitcount k1
(integer) 2
127.0.0.1:26379> bitcount k1 0 1
(integer) 0
127.0.0.1:26379> bitcount k1 15 16
(integer) 2
127.0.0.1:26379> bitcount k1 14 15
(integer) 2

bitpos命令

>> 语法:bittops key bit [start] [end]
>> 返回位图中第一个值为bit的二进制位的位置
>> 在默认情况下,命令将检测到的整个位图,但用户也可以通过可选的start参数和end参数指定要检测的范围
>> start和end指的单位是字节[byte]
>> 如果我们查找设置位(位参数为1)并且字符串为空或仅由零字节组成,则返回-1。

# 命令示例
[zhangxin@JD install-prefix-redis]$ bin/redis-cli -p 26379
127.0.0.1:26379> setbit k 2 0
(integer) 0
127.0.0.1:26379> setbit k 3 1
(integer) 0
127.0.0.1:26379> setbit k 5 1
(integer) 0
127.0.0.1:26379> bitpos k 0
(integer) 0
#返回位图中第一个值为 1 的二进制位的位置
127.0.0.1:26379> bitpos k 1
(integer) 3
#返回位图中[0-10字节中]第一个值为 1 的二进制位的位置
127.0.0.1:26379> bitpos k 1 0 10
(integer) 3
#返回位图中[1-10字节中]第一个值为 1 的二进制位的位置
127.0.0.1:26379> bitpos k 1 1 10
(integer) -1

bitop命令

>> BITOP operation destkey key [key ...]
>> 对一个或多个保存二进制位的字符串 key 进行位元操作,并将结果保存到 destkey 上。
>> operation 可以是 AND 、 OR 、 NOT 、 XOR 这四种操作中的任意一种:
   -- BITOP AND destkey key [key ...] ,对一个或多个 key 求逻辑并,并将结果保存到 destkey 。
   -- BITOP OR destkey key [key ...] ,对一个或多个 key 求逻辑或,并将结果保存到 destkey 。
   -- BITOP XOR destkey key [key ...] ,对一个或多个 key 求逻辑异或,并将结果保存到 destkey 。
   -- BITOP NOT destkey key ,对给定 key 求逻辑非,并将结果保存到 destkey 。
>> 除了 NOT 操作之外,其他操作都可以接受一个或多个 key 作为输入。
>> 处理不同长度的字符串
>> 当 BITOP 处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作 0 。
>> 空的 key 也被看作是包含 0 的字符串序列。

2.2.3 应用场景

场景一: 统计活跃用户数

Redis支持对String类型的value进行基于二进制位的置位操作。
通过将一个用户的id对应value上的一位,通过对活跃用户对应的位进行置位,
就能够用一个value记录所有活跃用户的信息。
如下图所未,下图中的bitmap有9个位被置为1,表示这9个位上对应的用户是今天的活跃用户。
其中第15位表示uid为15的用户,第一位表示uid为0的用户。

如果你的uid不是从1开始的,比如从100000开始,
实际上你也可以相应的用uid减去初始值来表示其位数,
比如1000000用户对应到bitmap的第一位。
图片.png
具体的代码类似下面这样:
[
  redis.setbit(play:yyyy-mm-dd, user_id, 1)
]


这样一次记录的复杂度是O(1),在Redis中速度非常快。
而我们通过每天换用一个不同的key来将每天的活跃用户状态记录分开存。
并且可以通过一些与或运算计算出N天活跃用户,和连接N天活跃用户这样的统计数据。
如下图,第一行表示星期一的活跃用户情况,第二行表示周二的,以此类推。
为样我们通过对N天的活跃用户记录取并集操作,就能得出在N天内活跃过的用户列表。
图片.png

https://www.cnblogs.com/hzjjames/p/redis_bit.html (Redis位图)
https://blog.csdn.net/weixin_34292959/article/details/93850749 (Redis位图)
https://blog.csdn.net/sen7747/article/details/84991871 (Redis位图的一些问题)

2.3 咆哮位图 (精确去重计数版本--省内存版本)

>> 如果要统计一篇文章的阅读量,可以直接使用 Redis 的 incr 指令来完成。
>> 如果要求阅读量必须按用户去重,那就可以使用 set 来记录阅读了这篇文章的所有用户 id,
获取 set 集合的长度就是去重阅读量。
>> 但是如果爆款文章阅读量太大,set 会浪费太多存储空间。
此时可以使用 Redis 提供的 HyperLogLog 数据结构来代替 set,
它只会占用最多 12k 的存储空间就可以完成海量的去重统计。
但是它牺牲了准确度,它是模糊计数,误差率约为 0.81%。

那么有没有一种不怎么浪费空间的精确计数方法呢?

我们首先想到的就是位图,可以使用位图的一个位来表示一个用户id。
如果一个用户id是32字节,那么使用位图就只需要占用 1/256 的空间就可以完成精确计数。
但是如何将用户id映射到位图的位置呢?
如果用户id是连续的整数这很好办,但是通常用户系统的用户id并不是整数,
而是字符串或者是有一定随机性的大整数。
我们可以强行给每个用户id赋予一个整数序列,然后将用户id和整数的对应关系存在redis中。
[
  $next_user_id = incr user_id_seq
  set user_id_xxx   $next_user_id
  $next_user_id = incr user_id_seq
  set user_id_yyy  $next_user_id
  $next_user_id = incr user_id_seq
  set user_id_zzz   $next_user_id
]

#这里你也许会提出疑问,你说是为了节省空间,这里存储用户id和整数的映射关系就不浪费空间了么?
{
这个问题提的很好,但是同时我们也要看到这个映射关系是可以复用的,
它可以统计所有文章的阅读量,还可以统计签到用户的日活、月活,
还可以用在很多其它的需要用户去重的统计场合中。
有了这个映射关系,我们就很容易构造出每一篇文章的阅读打点位图,
来一个用户,就将相应位图中相应的位置为一。
如果位从0变成1,那么就可以给阅读数加1。这样就可以很方便的获得文章的阅读数。

而且我们还可以动态计算阅读了两篇文章的公共用户量有多少?
将两个位图做一下 AND 计算,然后统计位图中位 1 的个数。
同样,还可以有 OR 计算、XOR 计算等等都是可行的。
}

#问题又来了!Redis 的位图是密集位图,什么意思呢?
如果有一个很大的位图,它只有最后一个位是 1,其它都是零,
这个位图还是会占用全部的内存空间,这就不是一般的浪费了。
你可以想象大部分文章的阅读量都不大,但是它们的占用空间却是很接近的,
和哪些爆款文章占据的内存差不多。
看来这个方案行不通,我们需要想想其它方案!这时咆哮位图(RoaringBitmap)来了。

它将整个大位图进行了分块,如果整个块都是零,那么这整个块就不用存了。
但是如果位1比较分散,每个块里面都有1,虽然单个块里的1很少,
这样只进行分块还是不够的,那该怎么办呢?
我们再想想,对于单个块,是不是可以继续优化?
如果单个块内部位 1 个数量很少,我们可以只存储所有位1的块内偏移量(整数),
也就是存一个整数列表,那么块内的存储也可以降下来。
这就是单个块位图的稀疏存储形式 —— 存储偏移量整数列表。
只有单块内的位1超过了一个阈值,才会一次性将稀疏存储转换为密集存储。
咆哮位图除了可以大幅节约空间之外,还会降低 AND、OR 等位运算的计算效率。
以前需要计算整个位图,现在只需要计算部分块。
如果块内非常稀疏,那么只需要对这些小整数列表进行集合的 AND、OR 运算,如是计算量还能继续减轻。
这里既不是用空间换时间,也没有用时间换空间,而是用逻辑的复杂度同时换取了空间和时间。
咆哮位图的位长最大为 2^32,对应的空间为 512M(普通位图),
位偏移被分割成高 16 位和低 16 位,高 16 位表示块偏移,
低16位表示块内位置,单个块可以表达 64k 的位长,也就是 8K 字节。最多会有64k个块。
现代处理器的 L1 缓存普遍要大于 8K,这样可以保证单个块都可以全部放入 L1 Cache,可以显著提升性能。
如果单个块所有的位全是零,那么它就不需要存储。
具体某个块是否存在也可以是用位图来表达,当块很少时,
用整数列表表示,当块多了就可以转换成普通位图。
整数列表占用的空间少,它还有类似于 ArrayList 的动态扩容机制避免反复扩容复制数组内容。
当列表中的数字超出4096个时,会立即转变成普通位图。
用来表达块是否存在的数据结构和表达单个块数据的结构可以是同一个,
因为块是否存在本质上也是 0 和 1,就是普通的位标志。

#但是 Redis 并没有原生支持咆哮位图这个数据结构啊?
Redis 确实没有原生的,但是咆哮位图的 Redis Module 有。

https://github.com/aviggiano/redis-roaring (Redis Module 咆哮位图)

3.Redis实现分布式计数器 (限流 & 接口请求次数统计)

https://www.jianshu.com/p/f6189078514e (参考此文即可)

譬如一个手机号一天限制发送5条短信、一个接口一分钟限制多少请求、一个接口一天限制调用多少次等等。
使用Redis的Incr自增命令可以轻松实现以上需求。

4.Redis GEO (附近的人)

4.1 概述

自Redis 3.2开始,Redis基于geohash和有序集合提供了地理位置相关功能。
Redis Geo模块包含了以下6个命令:
>> GEOADD: 将给定的位置对象(纬度、经度、名字)添加到指定的key;
>> GEOPOS: 从key里面返回所有给定位置对象的位置(经度和纬度);
>> GEODIST: 返回两个给定位置之间的距离;
>> GEOHASH: 返回一个或多个位置对象的Geohash表示;
>> GEORADIUS: 
以给定的经纬度为中心,返回目标集合中与中心的距离不超过给定最大距离的所有位置对象;
>> GEORADIUSBYMEMBER: 
以给定的位置对象为中心,返回与其距离不超过给定最大距离的所有位置对象。

4.2 应用场景

应用场景1

组合使用GEOADD和GEORADIUS可实现“附近的人”中“增”和“查”的基本功能。

要实现微信中“附近的人”功能,也可直接使用GEORADIUSBYMEMBER命令。
其中“给定的位置对象”即为用户本人,搜索的对象为其他用户。

不过本质上,GEORADIUSBYMEMBER = GEOPOS + GEORADIUS,
即先查找用户位置再通过该位置搜索附近满足位置相互距离条件的其他用户对象。

其他场景

这个功能在做摇一摇或者周边餐饮、车辆时非常有用。

http://www.gpsspg.com/maps.htm (查看某地点的经纬度工具网站)
https://blog.csdn.net/ruanhao1203/article/details/88742179

参考资源
https://mp.weixin.qq.com/s/oMdmM8Lfc5EsswQ5GifEOg
https://zhuanlan.zhihu.com/p/58358264 (Redis-HyperLogLog)
https://juejin.im/post/5cf5c817e51d454fbf5409b0 (Redis精确去重计数)
https://mp.weixin.qq.com/s/2uSr2YOjtLbUdHI01qc4rw (附近的人实现原理)

https://blog.csdn.net/tx542009/article/details/87970254 (核心参考)

你可能感兴趣的:(Redis系列(3) Bloom/BitMap/Geo)