Redis的复杂数据类型的使用——Bitmap、HyperLogLog(极大似然估值法)、Geospatial

435 阅读3分钟

简述

本篇文章给掘友们介绍一下 Redis 的 3 个复杂数据类型的使用。分别是:Bitmap(位映射)、HyperLogLog(超对数日志)、GeoSpacial(地理空间)。

简单来讲,Bitmap 用于操作字符串,并且使用二进制数来存储字符。HyperLogLog 可以利用极小的空间完成独立总数的统计。GeoSpacial 可以实现地理位置的数据结构的操作,可以计算两点之间的距离以及在附近限定范围的地点。

Bitmap

现代计算机用二进制(位)作为信息的基础单位,1 个字节等于 8 位,例如“big”字符串是由 3 个字节组成,但实际在计算机存储时将其用二进制表示,“big”分别对应的 ASCII 码分别是 98、105、103,对应的二进制分别是 011000100110001001101001011010010110011101100111

许多开发语言都提供了操作位的功能,合理地使用位能够有效地提高内存使用率和开发效率。Redis 提供了Bitmap 这个“数据结构”可以实现对位的操作。把数据结构加上引号主要因为:Bitmap 本身不是一种数据结构,实际上它就是字符串,但是它可以对字符串的位进行操作。

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

操作命令

setbit

setbit 命令用于设置键的第 offset 个位的值。

setbit ${key} offset ${value}

假设现在有 20 个用户,userId=02468userId = 0 | 2 | 4 | 6 | 8 的用户对网站进行了访问,存 key 名为日期。这个 key 记录了网站的当日的 UV 数据。

docker-local:db0> setbit uv-2024-08-01 0 1
0
docker-local:db0> setbit uv-2024-08-01 2 1
0
docker-local:db0> setbit uv-2024-08-01 4 1
0
docker-local:db0> setbit uv-2024-08-01 6 1
0
docker-local:db0> setbit uv-2024-08-01 8 1
0

可以通过 Tiny RDM 工具查看 key 的值,查看方式选择 binary:

image.png

可以清楚地看到对应的位置被设置成了 1。

getbit

getbit 命令可以获取键的第 offset 位的值(从 0 开始算)。比如获取 userId=8userId = 8 的用户是否在这天访问过,返回 0 说明没有访问过。

getbit ${key} offset
docker-local:db0> getbit uv-2024-08-01 8
1

当然 offset 是不存在的,也会返回 0。

bitcount

bitcount 可以获取 bitmap 指定范围值为 1 的个数:

bitcount ${key} [${start} ${end}]

[] 表示可选项。startend 可以省略,但是必须同时存在。

计算当天的访问量:

docker-local:db0> bitcount uv-2024-08-01
5

docker-local:db0> bitcount uv-2024-08-01 0
ERR syntax error

docker-local:db0> bitcount uv-2024-08-01 0 8
5

bitop

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

bitop ${operation} ${destkey} ${key1} [keys...]

取交集:

docker-local:db0> bitop and uv-2024-08-03 uv-2024-08-01 uv-2024-08-02
2

bitpos

bitpos 用于计算 bitmap 中第一个值为 targetBit 的偏移量:

bitpos key ${targetBit} [${start}] [${end}]

计算 8 月 1 号第一个访问网站的 id:

docker-local:db0> bitpos uv-2024-08-01 1
1
docker-local:db0> bitpos uv-2024-08-01 1 0 16
1

image.png

Bitmap优势

假设网站有 1 亿用户,每天独立访问的用户有 5 千万。如果每天用集合类型和 Bitmap 分别存储活跃用户,很明显,假如用户 id 是 Long 型,64 位。则集合类型占据的空间为 64/850,000,000B=400MB64 / 8 * 50,000,000 B = 400 MB,而 Bitmap 则需要 1/850,000,000B=6.25MB1 / 8 * 50,000,000 B = 6.25MB,可见 Bitmap 能节省很多的内存空间。

布隆过滤器

1970 年布隆提出了一种布隆过滤器的算法,用来判断一个元素是否在一个集合中。这种算法由一个二进制数组和一个 Hash 算法组成。

本质上布隆过滤器是一种数据结构,比较巧妙的概率型数据结构(probabilistic data structure),特点是高效地插入和查询,可以用来告诉你“某样东西一定不存在或者可能存在”。

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

实际上,布隆过滤器广泛应用于网页黑名单系统、垃圾邮件过滤系统、爬虫网址判重系统等,Google 著名的分布式数据库 Bigtable 使用了布隆过滤器来查找不存在的行或列,以减少磁盘查找的 IO 次数,Google Chrome 浏览器使用了布隆过滤器加速安全浏览服务。

801f60ff2d28436faaaab3007dd7d893.png

布隆过滤器误判问题:

  • 通过 Hash 计算在数组上不一定在集合。
  • 本质是 Hash 冲突。
  • 通过 Hash 计算不在数组的一定不在集合(误判)。

优化方案:

  • 增大数组(预估适合值)。
  • 增加 Hash 函数。

84b1457186f44856b6ad56561ba64229.png

Redis中的布隆过滤器

Redisson内置

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.12.3</version>
</dependency>
public class RedissonBF {

    public static void main(String[] args) {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");

        // 构造Redisson
        RedissonClient redisson = Redisson.create(config);

        RBloomFilter<String> bloomFilter = redisson.getBloomFilter("phoneList");
        // 初始化布隆过滤器:预计元素为 100,000,000L,误差率为 3%
        bloomFilter.tryInit(100000000L, 0.03);
        // 将号码10081~10086插入到布隆过滤器中
        bloomFilter.add("10081");
        bloomFilter.add("10082");
        bloomFilter.add("10083");
        bloomFilter.add("10084");
        bloomFilter.add("10085");
        bloomFilter.add("10086");

        // 判断下面号码是否在布隆过滤器中
        System.out.println("123456:BF--" + bloomFilter.contains("123456"));//false
        System.out.println("10086:BF--" + bloomFilter.contains("10086"));//true
        System.out.println("10084:BF--" + bloomFilter.contains("10084"));//true
    }
}

自行实现

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支持分布式*/
@Component
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)));
        this.bitmapLength = 128;
        /*算hash函数个数*/
        //this.numHashFunctions = Math.max(1, (int) Math.round((double) bitmapLength / numApproxElements * Math.log(2)));
        this.numHashFunctions = 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 + '}';
    }
}

单机式

单机的情况下可以使用 Google 的 Guava:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>30.1.1-jre</version>
</dependency>
public class GuavaBF {

    public static void main(String[] args) {
        long expectedInsertions = 100000;
        double fpp = 0.00005;

        BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), expectedInsertions, fpp);

        bloomFilter.put("10081");
        bloomFilter.put("10082");
        bloomFilter.put("10083");
        bloomFilter.put("10084");
        bloomFilter.put("10085");
        bloomFilter.put("10086");

        System.out.println("123456:BF--" + bloomFilter.mightContain("123456")); // false
        System.out.println("10086:BF--" + bloomFilter.mightContain("10086")); // true
        System.out.println("10084:BF--" + bloomFilter.mightContain("10084")); // true
    }
}

HyperLogLog

HyperLogLog(Hyper[ˈhaɪpə(r)])并不是一种新的数据结构(实际类型为字符串类型),而是一种基数算法,通过 HyperLogLog 可以利用极小的内存空间完成独立总数的统计,数据集可以是 IP、Email、ID 等。

如果你负责开发维护一个大型的网站,有一天产品经理要网站每个网页每天的 UV 数据,然后让你来开发这个统计模块,你会如何实现?

如果统计 PV 那非常好办,给每个网页一个独立的 Redis 计数器就可以了,这个计数器的 key 后缀加上当天的日期。这样来一个请求,incrby 一次,最终就可以统计出所有的 PV 数据。

但是 UV 不一样,它要去重,同一个用户一天之内的多次访问请求只能计数一次。这就要求每一个网页请求都需要带上用户的 ID,无论是登陆用户还是未登陆用户都需要一个唯一 ID 来标识。

一个简单的方案,那就是为每一个页面一个独立的 Set 集合来存储所有当天访问过此页面的用户 ID。当一个请求过来时,我们使用 sadd 将用户 ID 塞进去就可以了。通过 scard 可以取出这个集合的大小,这个数字就是这个页面的 UV 数据。

但是,如果你的页面访问量非常大,比如一个爆款页面几千万的 UV,你需要一个很大的 Set 集合来统计,这就非常浪费空间。如果这样的页面很多,那所需要的存储空间是惊人的。为这样一个去重功能就耗费这样多的存储空间,值得么?其实需要的数据又不需要太精确,105w 和 106w 这两个数字对于老板们来说并没有多大区别,So,有没有更好的解决方案呢?

这就是 HyperLogLog 的用武之地,Redis 提供了 HyperLogLog 数据结构就是用来解决这种统计问题的。HyperLogLog 提供不精确的去重计数方案,虽然不精确但是也不是非常不精确,Redis 官方给出标准误差是0.81%,这样的精确度已经可以满足上面的 UV 统计需求了。

百万级用户访问网站:

494d8e7c3cbc464db90935208fa20d44.png

操作命令

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

pfadd

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

pfadd ${key} ${element} [${elements}...]
docker-local:db0> pfadd uv-08-01 u1 u2 u3 u4 u5 u6 u7 u8
1

pfcount

pfcount 用于计算一个或多个 HyperLogLog 的独立总数,例如 uv-08-01 的独立总数为 8:

pfcount ${key} [${keys}...]
docker-local:db0> pfcount uv-08-01
8

如果我们继续往里面插入数据,比如插入 100 万条用户记录。内存增加非常少,但是 pfcount 的统计结果会出现误差。

pfmerge

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

pfmerge ${destkey} ${sourcekey} [${sourcekeys}...]

HyperLogLog 内存占用量小得惊人,但是用如此小空间来估算如此巨大的数据,必然不是 100% 的正确,其中一定存在误差率。前面说过,Redis 官方给出的数字是 0.81% 的失误率。

实现原理

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

实际上目前还没有发现更好的在大数据场景中准确计算基数的高效算法,因此在不追求绝对准确的情况下,使用概率算法算是一个不错的解决方案。概率算法不直接存储数据集合本身,通过一定的概率统计方法预估值,这种方法可以大大节省内存,同时保证误差控制在一定范围内。

以抛硬币游戏举例:抛硬币游戏,每次抛的硬币可能正面,可能反面,没回合一直抛,直到每当抛到正面回合结束。

b908ff160cdd4eed858e76b248fd880e.png

进行了 n 次实验,比如上图:

第 1 次试验:抛了 3 次才出现正面,此时 k=3n=1k=3,n=1

第 2 次试验:抛了 2 次才出现正面,此时 k=2n=2k=2,n=2

第 3 次试验:抛了 4 次才出现正面,此时 k=4n=3k=4,n=3

…………

第 n 次试验:抛了 7 次才出现正面,此时我们估算,k=7k=7

kk 是每回合抛到 11 所用的次数,我们已知的是最大的 kk 值,可以用 kmaxk_{max} 表示。由于每次抛硬币的结果只有 0011 两种情况,因此,能够推测出 kmaxk_{max} 在任意回合出现的概率,并由 kmaxk_{max} 结合极大似然估算的方法推测出 nn 的次数 n=2kmaxn = 2^{k_{max}}。概率学把这种问题叫做伯努利实验。

但是问题是,这种本身就是概率的问题,可能用了超过了 7 次。

所以这种预估方法存在较大误差,为了改善误差情况,HLL 中引入分桶平均的概念。

同样举抛硬币的例子,如果只有一组抛硬币实验,显然根据公式推导得到的实验次数的估计误差较大;如果 100100 个组同时进行抛硬币实验,受运气影响的概率就很低了,每组分别进行多次抛硬币实验,并上报各自实验过程中抛到正面的抛掷次数的最大值,就能根据 100100 组的平均值预估整体的实验次数了。

分桶平均的基本原理是将统计数据划分为 mm 个桶,每个桶分别统计各自的 kmaxk_{max},并能得到各自的基数预估值,最终对这些基数预估值求平均得到整体的基数估计值。LLC 中使用几何平均数预估整体的基数值,但是当统计数据量较小时误差较大;HLL 在 LLC 基础上做了改进,采用调和平均数过滤掉不健康的统计值

什么叫调和平均数呢?举个例子:

求平均工资:A 的是 1000/月,B 的30000/月。采用平均数的方式就是:

1000+300002=15500\frac{1000 + 30000}{2} = 15500

采用调和平均数的方式就是:

211000+1300001935.484\frac{2}{\frac{1}{1000} + \frac{1}{30000}} ≈ 1935.484

可见调和平均数算术平均数的好处就是不容易受到大的数值的影响,比平均数的效果是要更好的。

Redis中的HyperLogLog实现

执行 pfadd 操作时,元素会抓换成 64 bit 的二进制字符串。

然后在 Redis 中要分到 16384 个桶中(为什么是这么多桶:第一降低误判,第二,用到了 14 位二进制:214=163842^{14} = 16384)。

怎么分?根据得到的比特串的后 1414 位来做判断即可。

bd71287e85294b14b95e3fcb82243fab.png

根据上述的规则,我们知道这个数据要分到 1 号桶,同时从左往右(低位到高位)计算第 1 个出现的 1 的位置,假如是第 4 位,那么就往这个 1 号桶插入 4 的数据(转成二进制)。

如果有第二个数据来了,按照上述的规则进行计算。

那么问题来了,如果分到桶的数据有重复了(这里比大小,大的替换小的):

规则如下,比大小(比出现位置的大小),比如有个数据是最高位才出现 1,那么这个位置算出来就是 50。50比 4 大,则进行替换。1 号桶的数据就变成了 50(二进制是 110010)。

所以这里可以看到,每个桶的数据一般情况下 6 位存储即可。

所以我们这里可以推算一下一个 key 的 HyperLogLog 只占据多少的存储。

163846/8/1024=12KB16384 * 6 / 8 / 1024 = 12KB。并且这里最多可以存储多少数据,因为是 64 位吗,所以就是 2642^{64} 的数据,这个存储的数据非常非常大的,一般用户用 long 来定义,最大值也只有这么多。

进行统计的时候,就是把 16384 桶,把每个桶的值拿出来,比如取出是 n,那么访问次数就是 2n2^n

然后把每个桶的值做调和平均数,就可以算出一个算法值。同时,在具体的算法实现上,HLL 还有一个分阶段偏差修正算法。我们就不做更深入的了解了。

50bed8f5a0394a93aa8033ee9f847672 (1).png

constconstmm 都是 Redis 里面根据数据做的调和平均数。

GeoSpatial

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

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

业界比较通用的地理位置距离排序算法是 GeoHash 算法,Redis 也使用 GeoHash 算法。GeoHash 算法将二维的经纬度数据映射到一维的整数,这样所有的元素都将在挂载到一条线上,距离靠近的二维坐标映射到一维后的点之间距离也会很接近。当我们想要计算“附近的人时”,首先将目标位置映射到这条线上,然后在这个一维的线上获取附近的点就行了。

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

操作命令

geoadd

geoadd 用于增加地理位置信息:

geoadd ${key} ${longitude} ${latitude} ${member} [${longitude} ${latitude} ${member}...]

longitudelatitudemember 分别是该地理位置的经度、纬度、成员,例如下面有 5 个城市的经纬度:

CityLongitudelatitudeMember
北京116.2839.55beijing
天津117.1239.08tianjin
石家庄114.2938.02shijiazhuang
唐山118.0139.38tangshan
保定115.2938.51baoding

cities:locations 这个 key 是上面 5 个城市地理位置信息的集合,现向其添加北京的地理位置信息:

geoadd cities:locations 116.28 39.55 beijing

返回结果代表添加成功的个数,如果 cities:locations 没有包含 beijing,那么返回结果为 1,如果已经存在则返回 0。

如果需要更新地理位置信息,仍然可以使用 geoadd 命令,虽然返回结果为 0。geoadd 命令可以同时添加多个地理位置信息:

geoadd cities:locations 117.12 39.08 tianjin \
114.29 38.02 shijiazhuang \
118.01 39.38 tangshan \
115.29 38.51 baoding

geopos

geopos 用于获取地理位置信息:

geopos ${key} ${member} [${members}...]

获取天津的经纬度:

geopos cities:locations tianjin

geodist

geodist 用于获取两个地理位置的距离:

geodist ${key} ${member1} ${member2} [${unit}]

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

  • m(meter):米。
  • km(kilometer):公里。
  • mi(mile):英里。
  • ft(feet):尺。

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

geodist cities:locations tianjin beijing km

georadius georadiusbymember

这两个命令用于获取指定位置范围内的地理信息位置集合:

georadius ${key} ${longitude} ${latitude} ${radius} m | km | ft | mi 
[withcoord] [withdist] [withhash] [COUNT count] [asc | desc]
[store key] [storedist key]

georadiusbymember ${key} ${member} ${radius} m | km | ft | mi 
[withcoord] [withdist] [withhash] [COUNT count] [asc | desc] [store key]
[storedist key]

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

这两个命令有很多可选参数,如下所示:

  • withcoord:返回结果中包含经纬度。
  • withdist:返回结果中包含离中心节点位置的距离。
  • withhash:返回结果中包含 geohash,有关 geohash 后面介绍。
  • COUNT count:指定返回结果的数量。
  • asc | desc:返回结果按照离中心节点的距离做升序或者降序。
  • store key:将返回结果的地理位置信息保存到指定键。
  • storedist key:将返回结果离中心节点的距离保存到指定键。

下面操作计算五座城市中,距离北京 150 公里以内的城市:

georadiusbymember cities:locations beijing 150 km

geohash

geohash 用于将二维经纬度转换为一维字符串,下面操作会返回 beijing 的 geohash 值。

geohash cities:locations beijing

字符串越长,表示的位置更精确,geohash 长度为 9 时,精度在 2 米左右,geohash 长度为 8 时,精度在 20 米左右。

两个字符串越相似,它们之间的距离越近,Redis 利用字符串前缀匹配算法实现相关的命令。

geohash 编码和经纬度是可以相互转换的。

zrem

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

zrem key member