降本增效浪潮下的Redis存储优化--BitMap

716 阅读5分钟

引言

今年真是,各大中小厂都在如火如荼的降本增效的进行中,我司也不是例外,服务器成本的控制,数量的减少,不得不让我们对数据的存储方式进行优化,在面对大数据的应用下更是如此,笔者将分享下我们接近亿级用户相关Redis存储的优化过程。

背景

目前我们用户标签与风控系统会给每个用户打标签以及计算每个用户在不同业务方风控场景下的风险分数和风险等级,即每个用户会对应多个标签,以及多个场景的风险分数和风险等级。我们每晚的全量服务是要跑9000多万个用户的信息,而这些用户的标签和场景下的风险分数和风险等级我们都需要存储到Redis中,当随着Redis内存占用的越来越多,为了能够存储更多的数据,我们就需要对它进行瘦身了,接下来一起看看标签的压缩存储吧。

优化前的存储

基于上面的背景,我们一开始的设计是这样的:每个用户的相关信息使用一个Hash结构来存储,其中每个场景的分数对应一个Hash的属性键值,每个场景的等级也是如此,而用户的标签集合则是对应一个属性键值,Redis中的存储结构如下: image.png 先我们来看下标签信息的存储占用的空间(关于风控的场景分数和等级压缩我们下文再说):

标签的结构:

  • key为tag,占用3byte
  • value为标签code的集合,一个标签code占用6byte,目前总共有100多个

我们就按平均10个标签来计算,也就是说一个用户标签占用为:3 + 6 * 10= 63byte,所用用户占的总内存为:63 * 90000000 / 1000 / 1024 / 1024=5.4G

优化

思路

由于我们标签code的设计是连续的,而且结构都是Tag+code,所以我们只需要记下用户有哪些code就可以了。因此我们想到了使用BitMap的结构来存储,把用户拥有的标签code对应的BitMap的索引位置为1即可,

比如用户有Tag001,Tag005这两个标签,

那么在Redis的存储就是:01000100,占用一个字节,

但是由于我们是Hash存储,而Redis原生是不支持Hash结构使用BitMap结构的,所以我们需要在内存使用BitMap存储然后使用byte数组存储Redis,当然为了保持Redis输出结果的友好性,转byte数组的时候,我们可以按BitMap的格式来转换。

压缩代码如下(其中省略了将标签code转换为BitSet的业务逻辑):

编码:

这是将BitSet转换为byte数组然后存储Redis

public static byte[] convertBitArrayToByteArray(BitSet bitArray) {
    int numOfBytes = bitArray.length() / 8;
    if (bitArray.length() % 8 != 0) {
        numOfBytes++;
    }

    byte[] byteArray = new byte[numOfBytes];

    int byteIndex = 0;
    int bitIndex = 0;

    for (int i = 0; i < bitArray.length(); i++) {
        if (bitArray.get(i)) {
            byteArray[byteIndex] |= (byte) (1 << (7 - bitIndex));
        }

        bitIndex++;

        if (bitIndex == 8) {
            byteIndex++;
            bitIndex = 0;
        }
    }

    return byteArray;
}
解码

将从Redis获取到的byte数组转换为BitMap方便操作

public static BitSet convertByteArrayToBitArray(byte[] byteArray) {
    BitSet bitSet = new BitSet(byteArray.length * 8);

    for (int byteIndex = 0; byteIndex < byteArray.length; byteIndex++) {
        byte currentByte = byteArray[byteIndex];

        for (int bitIndex = 0; bitIndex < 8; bitIndex++) {
            boolean bit = ((currentByte >> (7 - bitIndex)) & 1) == 1;
            bitSet.set(byteIndex * 8 + bitIndex, bit);
        }
    }

    return bitSet;
}

压缩的成效

由于使用BitMap存储且标签code的数值是连续生成的,所以BitMap占用的空间取决于标签code的最大值tagMax,即(tagMax + 7) / 8 字节,在当前100多个标签数的情况下,最大占用13byte能存下所有的数据,最少占用1byte存储,那么最少情况就是所有用户只有一个标签,而最坏情况就是所用户都有所有的标签,两种情况的优化前后对比如下:

优化前优化后
最少占用0.5G0.09G
最多占用51.5G1.11G

优化结果展示:

image.png

不足与思考

上面的优化还是存在着不足的,不知道你想到了没有:那就是当随着标签量的增多,如果当用户拥有的标签是很稀疏的,比如在一个很大的是Tag160,这样就会即使用户只有一个标签也会使用20byte来存储,而不压缩存储只需要6byte,会有很多空间的浪费,对于这种的不足,我们又有什么样的解决方案呢?游程编码(Run-Length Encoding)是一种常用的解决方案,其中做的较好的是RoaringBitMap兼容了逻辑操作和存储的多方面高性能,关于稀疏位图这一问题的解决我们后续再细细道来。

笔者本文仅仅只讲述标签的编码压缩,但你观察文章开头的图片中场景的分数和等级的结构其实占用了更多的空间,我们又该怎么来压缩它呢?下文我们一起来探索场景code、风险分数、风险等级的压缩,它会更加的精彩,一起期待吧。

🙏 感谢您花时间阅读这篇文章!如果觉得有所收获,请关注我的更新,给个喜欢和分享,如果有任何疑惑,可以评论区交流。您的支持是我写作的最大动力!✍️🌟