4Redis高级数据结构(BitmapsHyperLogLogGEO)

Posted *King*

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了4Redis高级数据结构(BitmapsHyperLogLogGEO)相关的知识,希望对你有一定的参考价值。

一、Bitmaps

1、Bitmaps

Bitmaps 本身不是一种数据结构,实际上它就是字符串,但是它可以对字符 串的位进行操作。

Bitmaps 单独提供了一套命令,所以在 Redis中使用 Bitmaps和使用字符串的 方法不太相同。可以把 Bitmaps 想象成一个以位为单位的数组,数组的每个单元只能存储 0 和 1,数组的下标在 Bitmaps 中叫做偏移量。

(1)setbit设置值

设置键的第offset个位的值(从0算起)

setbit key offset value

假设现在有20个用户,userid=0,2,5,6,10的用户对网站进行了访问,存储键名为u:v:日期

(2)getbit获取值

获取键的第offset位的值(从0开始算)

getbit key offset

比如获取第7位用户是否在09-21这天访问过,返回0说明没有访问,当offset不存在时,也会返回0

(3)bitcount获取Bitmaps指定范围值为1的个数

bitcount [start] [end]

比如计算09-21这天的独立访问用户数量

[start]和[end]代表起始和结束字节数

(4)bitop Bitmaps 间的运算

bitop 是一个复合操作,它可以做多个 Bitmaps 的 and(交集)or(并 集)not(非)xor(异或)操作并将结果保存在 destkey 中。

bitops 有两个选项[start]和[end],分别代表起始字节和结束字节。

bitop op destkey key [key . ...]

比如计划09-21当天访问网站的最小用户id

Bitmaps优势:

假设网站有 1 亿用户,每天独立访问的用户有 5 千万,如果每天用集合类型 和 Bitmaps 分别存储活跃用户,很明显,假如用户 id 是 Long 型,64 位,则集合类型占据的空间为 64 位 x50 000 000= 400MB,而 Bitmaps 则需要 1 位×100 000 000=12.5MB,使用 Bitmaps 能节省很多的内存空间。

2、布隆过滤器

使用场景:

1、目前有10亿数量的自然数,乱序排列,需要对其排序

2、快速在亿级黑名单中定位URL地址是否在黑名单内

3、需要进行用户登录行为分析,来确定用户的活跃情况

4、网络爬虫-如何判断 URL 是否被爬过

5、快速定位用户属性(黑名单、白名单等)

6、数据存储在磁盘中,如何避免大量的无效 IO

位图法:

位图法就是bitmap的缩写,就是用每一位来存放某种状态,适用于大规模数据,但数据状态又不是很多的情况,通常是用来判断某个数据存不存在的

布隆过滤器详解:

bitmap相比于传统的List、Set、Map等数据结构,它更高效、占用空间更少,但是缺点是其返回的结果是概率性的,而不是确切的。

通过一个 Hash 函数将一个元素映射成一个位阵列(Bit Array)中的一个点。这样一来,我们只要看看这个点是不是 1 就知道可以集合中有没有它了。这就是布隆过滤器的基本思想。

Hash 面临的问题就是冲突。假设 Hash 函数是良好的,如果我们的位阵列 长度为 m 个点,那么如果我们想将冲突率降低到例如 1%, 这个散列表就只能 容纳 m/100 个元素。显然这就不叫空间有效了(Space-efficient)。解决方法也简单,就是使用多个 Hash,如果它们有一个说元素不在集合中,那肯定就不在。

布隆过滤器的缺点

为随着增加的值越来越多,被置为 1 的 bit 位也会越来越多,这样某个值 即使没有被存储过,但是万一哈希函数返回的三个 bit 位都被其他值置位了1 ,那么程序还是会判断 这个值存在。

布隆过滤器判断存在的不一定存在,但是判断不存在的一定不存在。

布隆过滤器的实现

(1)Redis 中的布隆过滤器

插件形式:

下载redisbloom插件:

wget https://github.com/RedisLabsModules/rebloom/archive/v1.1.1.tar.gz 

解压并安装,生成.so文件:

tar -zxvf v1.1.1.tar.gz 
cd redisbloom-1.1.1/ 
make

在redis配置文件(redis.conf)中加入该模块:

vim redis.conf
loadmodule /usr/local/redis/redisbloom-1.1.1/rebloom.so

相关的操作命令:

bf.reserve 创建 Filter,其有 3 个参数,key,error_rate, initial_size,错误 率越低,需要的空间越大,error_rate 表示预计错误率,initial_size 参数表示预计 放入的元素数量,当实际数量超过这个值时,误判率会上升,所以需要提前设置 一个较大的数值来避免超出。默认的 error_rate 是 0.01,initial_size 是 100 bf.add 增加 
bf.exists 判断是否存在 
bf.madd 批量增加 
bf.mexists 批量判断

目前 jedis 不支持布隆过滤器,需要 JRedisBloom

https://github.com/RedisLabs/JReBloom

<dependency> 
	<groupId>com.redislabs</groupId> 
	<artifactId>jrebloom</artifactId> 
	<version>2.1.0</version> 
</dependency>

(2)访Google的布隆过滤器实现

加入maven依赖

 <dependency>
 	<groupId>com.google.guava</groupId>
 	<artifactId>guava</artifactId>
 	<version>30.1.1-jre</version>
 </dependency>

RedisBloomFilter

import com.google.common.hash.Funnels;
import com.google.common.hash.Hashing;
import com.google.common.primitives.Longs;
import org.springframework.beans.factory.annotation.Autowired;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.Pipeline;

import java.nio.charset.Charset;

/*仿Google的布隆过滤器实现,基于redis支持分布式*/
public class RedisBloomFilter 

    public final static String RS_BF_NS = "rbf:";
    private int numApproxElements; /*预估元素数量*/
    private double fpp; /*可接受的最大误差*/
    private int numHashFunctions; /*自动计算的hash函数个数*/
    private int bitmapLength; /*自动计算的最优Bitmap长度*/

    @Autowired
    private JedisPool jedisPool;

    /**
     * 构造布隆过滤器
     * @param numApproxElements 预估元素数量
     * @param fpp 可接受的最大误差
     * @return
     */
    public RedisBloomFilter init(int numApproxElements,double fpp)
        this.numApproxElements = numApproxElements;
        this.fpp = fpp;
        /*位数组的长度*/
        this.bitmapLength = (int) (-numApproxElements*Math.log(fpp)/(Math.log(2)*Math.log(2)));
        /*算hash函数个数*/
        this.numHashFunctions = Math.max(1, (int) Math.round((double) bitmapLength / numApproxElements * Math.log(2)));
        return  this;
    

    /**
     * 计算一个元素值哈希后映射到Bitmap的哪些bit上
     * 用两个hash函数来模拟多个hash函数的情况
     *     * @param element 元素值
     * @return bit下标的数组
     */
    private long[] getBitIndices(String element)
        long[] indices = new long[numHashFunctions];
        /*会把传入的字符串转为一个128位的hash值,并且转化为一个byte数组*/
        byte[] bytes = Hashing.murmur3_128().
                hashObject(element, Funnels.stringFunnel(Charset.forName("UTF-8"))).
                asBytes();

        long hash1 = Longs.fromBytes(bytes[7],bytes[6],bytes[5],bytes[4],bytes[3],bytes[2],bytes[1],bytes[0]);
        long hash2 = Longs.fromBytes(bytes[15],bytes[14],bytes[13],bytes[12],bytes[11],bytes[10],bytes[9],bytes[8]);

        /*用这两个hash值来模拟多个函数产生的值*/
        long combinedHash = hash1;
        for(int i=0;i<numHashFunctions;i++)
            indices[i]=(combinedHash&Long.MAX_VALUE) % bitmapLength;
            combinedHash = combinedHash + hash2;
        

        System.out.print(element+"数组下标");
        for(long index:indices)
            System.out.print(index+",");
        
        System.out.println(" ");
        return indices;
    

    /**
     * 插入元素
     *
     * @param key       原始Redis键,会自动加上前缀
     * @param element   元素值,字符串类型
     * @param expireSec 过期时间(秒)
     */
    public void insert(String key, String element, int expireSec) 
        if (key == null || element == null) 
            throw new RuntimeException("键值均不能为空");
        
        String actualKey = RS_BF_NS.concat(key);

        try (Jedis jedis = jedisPool.getResource()) 
            try (Pipeline pipeline = jedis.pipelined()) 
                for (long index : getBitIndices(element)) 
                    pipeline.setbit(actualKey, index, true);
                
                pipeline.syncAndReturnAll();
             catch (Exception ex) 
                ex.printStackTrace();
            
            jedis.expire(actualKey, expireSec);
        
    

    /**
     * 检查元素在集合中是否(可能)存在
     *
     * @param key     原始Redis键,会自动加上前缀
     * @param element 元素值,字符串类型
     */
    public boolean mayExist(String key, String element) 
        if (key == null || element == null) 
            throw new RuntimeException("键值均不能为空");
        
        String actualKey = RS_BF_NS.concat(key);
        boolean result = false;

        try (Jedis jedis = jedisPool.getResource()) 
            try (Pipeline pipeline = jedis.pipelined()) 
                for (long index : getBitIndices(element)) 
                    pipeline.getbit(actualKey, index);
                
                result = !pipeline.syncAndReturnAll().contains(false);
             catch (Exception ex) 
                ex.printStackTrace();
            
        
        return result;
    

    @Override
    public String toString() 
        return "RedisBloomFilter" +
                "numApproxElements=" + numApproxElements +
                ", fpp=" + fpp +
                ", numHashFunctions=" + numHashFunctions +
                ", bitmapLength=" + bitmapLength +
                '';
    

测试类

import cn.enjoyedu.redis.redisbase.advtypes.RedisBloomFilter;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class TestRedisBloomFilter 

    private static final int DAY_SEC = 60 * 60 * 24;

    @Autowired
    private RedisBloomFilter redisBloomFilter;

    @Test
    public void testInsert() throws Exception 
        System.out.println(redisBloomFilter);
        redisBloomFilter.insert("topic_read:8839540:20210810", "76930242", DAY_SEC);
        redisBloomFilter.insert("topic_read:8839540:20210810", "76930243", DAY_SEC);
        redisBloomFilter.insert("topic_read:8839540:20210810", "76930244", DAY_SEC);
        redisBloomFilter.insert("topic_read:8839540:20210810", "76930245", DAY_SEC);
        redisBloomFilter.insert("topic_read:8839540:20210810", "76930246", DAY_SEC);
    

    @Test
    public void testMayExist() throws Exception 
        System.out.println(redisBloomFilter.mayExist("topic_read:8839540:20210810", "76930242"));
        System.out.println(redisBloomFilter.mayExist("topic_read:8839540:20210810", "76930244"));
        System.out.println(redisBloomFilter.mayExist("topic_read:8839540:20210810", "76930246"));
        System.out.println(redisBloomFilter.mayExist("topic_read:8839540:20210810", "86930250"));
    


测试结果:

二、HyperLogLog

HyperLogLog并不是一种数据结构,而是一种基数算法,通达HyperLogLog可以利用极小的内存空间完成独立总数的统计,数据集可以是IP、Email、ID等。

HyperLogLog 提供不精确的去重计数方案,虽然不精确但是也不是非常不精确,Redis 官方给出标准误差是 0.81%。

1、操作命令

HyperLogLog提供了3个命令:pfadd、pfcount、pfmerge

pfadd

pfadd 用于向 HyperLogLog 添加元素,如果添加成功返回 1:

pfadd key element [element …]

例如09-21的访问用户是u1、u2、u3、u4,09-22的访问用户是u4、u5、u6、u7

pfcount

pfcount 用于计算一个或多个 HyperLogLog 的独立总数

pfcount key [key ...]

例如09-21:u:id的独立总数为4,如果继续往里插入数据,比如插入 100 万条用户记录。内存增加非常少,但是 pfcount 的统计结果会出现误差


pfmerge

pfmerge 可以求出多个 HyperLogLog 的并集并赋值给 destkey

pfmerge destkey sourcekey [sourcekey ... ]

2、原理概述

HyperLogLog 基于概率论中伯努利试验并结合了极大似然估算方法,并做了分桶优化。

概率算法不直接存储数据集合本身,通过一定的概率统计方法预估值,这种方法可以大大节省内存,同时保证误差控制在一定范围内。

实例理解

统计网页每天的 UV 数据

1、转为比特串 :通过 hash 函数,将数据转为比特串,例如输入 5,便转为:101。

2、分桶: 分桶就是分多少轮。抽象到计算机存储中去,就是存储的是一个以单位是比特(bit),长度为 L 的大数组 S ,将 S 平均分为 m 组,注意这个 m 组,就是对应多少轮,然后每组所占有的比特个数是平均的,设为 P。容易得出下面的关系:

L = S.length

L = m * p

以 K 为单位,S 占用的内存 = L / 8 / 1024

3、对应:不同的用户 id 标识了一个用户,那么我们可以把用户的id 作为被 hash 的输入。即: hash(id) = 比特串,不同的用户 id,拥有不同的比特串。每一个比特串,也必然会至少出现一次1 的位置。我们类比每一个比特串为一次伯努利试验。

假设比特串的低两位用来计算桶下标志,总共有 4 个桶,此时有一个用户的 id 的比特串是:1001011000011。它的所在桶下标为:12^1 + 12^0 = 3,处于第 3 个桶,即第 3 轮中。计算出桶号后,剩下的比特是:10010110000,从低位到高 位看,第一次出现 1 的位置是 5 。也就是说,此时第 3 个桶中,k_max = 5。5对应的二进制是:101,将 101存入第 3 个桶。模仿上面的流程,多个不同的用户 id,就被分散到不同的桶中去了,且每个桶有其k_max。最终结合所有桶中的 k_max,代入估算公式,便能得出估算值。

Redis 中的 HyperLogLog 实现

Redis 的实现中,HyperLogLog 占据 12KB 的大小,共设有 16384 个桶,即:2^14 = 16384,每个桶有 6 位,每个桶可以表达的最大数字是:25+24+…+1 = 63 ,二进制为: 111 111 。

对于命令:pfadd key value

在存入时,value 会被 hash 成 64 位,即 64 bit 的比特字符串,前 14 位用来分桶,剩下 50 位用来记录第一个 1 出现的位置。

之所以选 14 位 来表达桶编号是因为,分了 16384 个桶,而 2^14 = 16384, 刚好最大的时候可以把桶利用完,不造成浪费。假设一个字符串的前 14 位是:00 0000 0000 0010 (从右往左看) ,其十进制值为 2。那么 value 对应转化后的值放到编号为 2 的桶。

index 的转化规则

因为完整的

value 比特字符串是 64 位形式,减去 14 后,剩下 50 位,

假设极端情况,出现 1 的位置,是在第 50 位,即位置是 50。此时 index = 50。此时先将 index转为 2 进制,它是:110010 。

因为 16384 个桶中,每个桶是 6 bit 组成的。于是 110010 就被设置到了第 2 号桶中去了。50 已经是最坏的情况,且它都被容纳进去了。那么其他的不用想也肯定能被容纳进去。

不同的 value,会被设置到不同桶中去,如果出现了在同一个桶的,即前 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 个数。

三、GEO

Redis 3.2 版本提供了 GEO(地理信息定位)功能,支持存储地理位置信息用来 实现诸如附近位置、摇一摇这类依赖于地理位置信息的功能。

地图元素的位置数据使用二维的经纬度表示,经度范围 (-180, 180],纬度范围(-90, 90],纬度正负以赤道为界,北正南负,经度正负以本初子午线 (英国格林尼治天文台) 为界,东正西负。

在 Redis 里面,经纬度使用 52 位的整数进行编码,放进了 zset 里面,zset 的value 是元素的 key,score 是 GeoHash 的 52 位整数值。

1、操作命令

增加地理位置信息

geoadd key longitude latitude member [longitude latitude member ...J

longitude、latitude、member 分别是该地理位置的经度、纬度、成员

例如:添加北京的地理位置:北京的经度是116.28,纬度是39.55

geoadd cities:locations 116.28 39.55 beijing


获取地理位置信息

geopos key member [member ...]

例如:获取天津的经纬度

geopos cities:locations tianjin

获取两个地理位置的距离

geodist key member1 member2 [unit]

unit 代表返回结果的单位,包含以下四种:

  • m (meters)代表米。
  • km (kilometers)代表公里。
  • mi (miles)代表英里。
  • ft(feet)代表尺。

例如:计算天津到北京的距离,并以公里为单位

geodist cities:locations tianjin beijing km

获取指定位置范围内的地理信息位置集合

georadius key longitude latitude radius m|km|ft|mi [withcoord][withdist] [withhash][COUNT count] [ascldesc] [store key] [storedist key] 
georadiusbymember key member radius m|km|ft|mi [withcoord][withdist] [withhash] [COUNT count][ascldesc] [store key] [storedist key]

georadius 和 georadiusbymember 两个命令的作用是一样的,都是以一个地理位置为中心算出指定半径内的其他地理信息位置,不同的是 georadius 命令的中心位置给出了具体的经纬度,georadiusbymember 只需给出成员即可。其中radius m | km |ft |mi 是必需参数,指定了半径(带单位)。

withcoord:返回结果中包含经纬度。

withdist:返回结果中包含离中心节点位置的距离。

withhash:返回结果中包含 geohash,有关 geohash 后面介绍。

COUNT count:指定返回结果的数量。

asc l desc:返回结果按照离中心节点的距离做升序或者降序。

store key:将返回结果的地理位置信息保存到指定键。

storedist key:将返回结果离中心节点的距离保存到指定键。

例如:计算五座城市中,距离北京150公里以内的城市

georadiusbymember cities:locations beijing 150 km

获取 geohash

geohash key member [member ...]

Redis 使用 geohash 将二维经纬度转换为一维字符串,

例如:返回 beijing的 geohash 值。

geohash cities:locations beijing

删除地理位置信息

GEO 没有提供删除成员的命令,但是因为 GEO 的底层实现是 zset,所以可以借用 zrem 命令实现对地理位置信息的删除。

zrem key member

以上是关于4Redis高级数据结构(BitmapsHyperLogLogGEO)的主要内容,如果未能解决你的问题,请参考以下文章

4redis 分布式锁

Redis系列--4Redis命令

4redis.conf中replication配置项说明

七天玩转Redis | Day4Redis持久化机制

七天玩转Redis | Day4Redis事务操作

七天玩转Redis | Day4Redis事务操作