redis使用之bitmap

说清楚几个问题:
1.bitmap的原理、用法。
2.bitmap的优势、限制。
3.bitmap空间、时间粗略计算方式。
4.bitmap的使用场景。
5.使用bitmap过程中可能会遇到的坑。
6.bitmap进阶用法(思考)。

一、bitmap的原理、用法

8bit = 1b = 0.001kb

bitmap就是通过最小的单位bit来进行0或者1的设置,表示某个元素对应的值或者状态。
一个bit的值,或者是0,或者是1;也就是说一个bit能存储的最多信息是2。

Redis提供了以下几个指令用于操作BitMap:

命令 说明 可用版本 时间复杂度
SETBIT 对 key 所储存的字符串值,设置或清除指定偏移量上的位(bit)。 >= 2.2.0 O(1)
GETBIT 对 key 所储存的字符串值,获取指定偏移量上的位(bit)。 >= 2.2.0 O(1)
BITCOUNT 计算给定字符串中,被设置为 1 的比特位的数量。 >= 2.6.0 O(N)
BITPOS 返回位图中第一个值为 bit 的二进制位的位置。 >= 2.8.7 O(N)
BITOP 对一个或多个保存二进制位的字符串 key 进行位元操作。 >= 2.6.0 O(N)
BITFIELD BITFIELD 命令可以在一次调用中同时对多个位范围进行操作。 >= 3.2.0 O(1)

常用的几个方法:
setBit

说明:给一个指定key的值得第offset位 赋值为value。

参数:key offset value: bool or int (1 or 0)

返回值:LONG: 0 or 1

getBit

说明:返回一个指定key的二进制信息

参数:key offset

返回值:LONG

bitCount

说明:返回一个指定key中位的值为1的个数(是以byte为单位不是bit)

参数:key start offset

返回值:LONG

bitOp

说明:对不同的二进制存储数据进行位运算(AND、OR、NOT、XOR)

参数:operation destkey key [key …]

返回值:LONG

二、bitmap的优势、限制

优势
1.基于最小的单位bit进行存储,所以非常省空间。
2.设置时候时间复杂度O(1)、读取时候时间复杂度O(n),操作是非常快的。
3.二进制数据的存储,进行相关计算的时候非常快。
4.方便扩容

限制
redis中bit映射被限制在512MB之内,所以最大是2^32位。建议每个key的位数都控制下,因为读取时候时间复杂度O(n),越大的串读的时间花销越多。

bitmap空间、时间粗略计算方式
在一台2010MacBook Pro上,offset为232-1(分配512MB)需要~300ms,offset为230-1(分配128MB)需要~80ms,offset为228-1(分配32MB)需要~30ms,offset为226-1(分配8MB)需要8ms。<来自官方文档>

大概的空间占用计算公式是:($offset/8/1024/1024)MB

三、bitmap的使用场景

使用方式很多,根据不同的业务需求来,但是总的来说就两种,以用户为例子:

1.一种是某一用户的横向扩展,即此个key值中记录这当前用户的各种状态值,允许无限扩展(2^32内)

点评:这种用法基本上是很少用的,因为每个key携带uid信息,如果存储的key的空间大于value,从空间角度看有一定的优化空间,如果是记录长尾的则可以考虑。

2.一种是某一用户的纵向扩展,即每个key只记录当前业务属性的状态,每个uid当作bit位来记录信息(用户超过2^32内需要分片存储)

点评:基本上项目使用的场景都是基于这种方式的,按业务区分方便回收资源,key值就一个,将uid的存储转为了位的存储,十分巧妙的通过uid即可找到相应的值,主要存储量在value上,符合预期。

案例说明:

1.视频属性的无限延伸
需求分析:

一个拥有亿级数据量的短视频app,视频存在各种属性(是否加锁、是否特效等等),需要做各种标记。

可能想到的解决方案:

1.存储在mysql中,肯定不行,一个是随着业务增长属性一直增加,并且存在有时间限制的属性,直接对数据库进行加减字段是非常不合理的做法。即使是存在一个字段中用json等压缩技术存储也存在读效率的问题,并且对于大几亿的数据来说,废弃的字段回收起来非常麻烦。

2.直接记录在redis中,根据业务属性+uid为key来存储。读写效率角度没毛病,但是存储的角度来说key的数据量都大于value了,太耗费空间了。即使是用json等压缩技术来存储。也存在问题,解压需要时间,并且大几亿的数据回收也是难题。

设计方案:

使用redis的bitmap进行存储
key由属性id+视频分片id组成。value按照视频id对分片范围取模来决定偏移量offset。10亿视频一个属性约120m还是挺划算的。

伪代码:

function set($business_id , $media_id , $switch_status=1){
    $switch_status = $switch_status ? 1 : 0;
    $key = $this->_getKey($business_id, $media_id);
    $offset = $this->_getOffset($media_id);
    return $this->redis->setBit($key, $offse, $switch_status);
}

function get($business_id , $media_id){
    $key = $this->_getKey($business_id,$media_id);
    $offset = $this->_getOffset($media_id);
    return $this->redis->getBit($key , $offset);
}

function _getKey($business_id, $media_id){
    return 'm:'.$business_id.':'.intval($media_id/10000);
}

function _getOffset($media_id){
    return $media_id % 10000;
}

 

这样基本实现了属性的存储,后续增加新属性也只是business_id再增加一个值。

至于为什么分片呢?分片的粒度怎么衡量?

分片有两个原因:1.读取的时候时间复杂度是O(n)存储越长读取时间越多 2.bitmap有长度限制2^32。

分片粒度怎么衡量:1.如果主键id存在的断层那么请尽可能选择的粒度可以避开此段id范围,防止空间浪费,因为来一个00000…9999个0…01,那么因为存一个属性而存了全部的,就浪费了。2.分片粒度可参考某一单位时间的增长值来判断,这样也有利于预算占了多少空间,虽然空间不会占很多。

2.用户在线状态
需求分析:

需要对子项目提供一个接口,来提供某用户是否在线?

设计方案:

使用bitmap是一个节约空间效率又高的一种方法,只需要一个key,然后用户id为偏移量offset,如果在线就设置为1,不在线就设置为0,3亿用户只需要36MB的空间。

伪代码:

$status = 1;
$redis->setBit('online', $uid, $status);
$redis->getBit('online', $uid);

需要加上如例子1一样分片的方式。10亿真的太多了。10w分一片。

3.统计活跃用户
需求分析:

需要计算活跃用户的数据情况。

设计方案:

使用时间作为缓存的key,然后用户id为offset,如果当日活跃过就设置为1。之后通过bitOp进行二进制计算算出在某段时间内用户的活跃情况。

伪代码:

$status = 1;
$redis->setBit('active_20170708', $uid, $status);
$redis->setBit('active_20170709', $uid, $status);

$redis->bitOp('AND', 'active', 'active_20170708', 'active_20170709'); 

上亿用户需要加上如例子1一样分片的方式。几十万或者以下,可无需分片省的业务变复杂。

4.用户签到
需求分析:

用户需要进行签到,对于签到的数据需要进行分析与相应的运运营策略。

设计方案:

使用redis的bitmap,由于是长尾的记录,所以key主要由uid组成,设定一个初始时间,往后没加一天即对应value中的offset的位置。

伪代码:

$start_date = '20170708';
$end_date = '20170709';
$offset = floor((strtotime($start_date) - strtotime($end_date)) / 86400);
$redis->setBit('sign_123456', $offset, 1);

//算活跃天数
$redis->bitCount('sign_123456', 0, -1)

无需分片,一年365天,3亿用户约占300000000*365/8/1000/1000/1000=13.68g。存储成本是不是很低。

再例如:考虑到每月要重置连续签到次数,最简单的方式是按用户每月存一条签到数据。Key的格式为 u:sign:{uid}:{yyyMM},而Value则采用长度为4个字节的(32位)的BitMap(最大月份只有31天)。BitMap的每一位代表一天的签到,1表示已签,0表示未签。

例如 u:sign:1225:202101 表示ID=1225的用户在2021年1月的签到记录

# 用户1月6号签到
SETBIT u:sign:1225:202101 5 1 # 偏移量是从0开始,所以要把6减1

# 检查1月6号是否签到
GETBIT u:sign:1225:202101 5 # 偏移量是从0开始,所以要把6减1

# 统计1月份的签到次数
BITCOUNT u:sign:1225:202101

# 获取1月份前31天的签到数据
BITFIELD u:sign:1225:202101 get u31 0

# 获取1月份首次签到的日期
BITPOS u:sign:1225:202101 1 # 返回的首次签到的偏移量,加上1即为当月的某一天

上面都需要增加过期时间,redisTemplate.expireAt(key, ttl)

使用bitmap过程中可能会遇到的坑
1.bitcout的陷阱
如果你有仔细看前文的用法,会发现有这么一个备注“返回一个指定key中位的值为1的个数(是以byte为单位不是bit)”,这就是坑的所在。

有图有真相:

所以bitcount 0 0 那么就应该是第一个字节中1的数量的,注意是字节,第一个字节也就是1,2,3,4,5,6,7,8这八个位置上。

bitmap进阶用法(思考)
以下内容来自此文的笔记:http://www.infoq.com/cn/articles/the-secret-of-bitmap/

1.空间
redis的bitmap已经是最小单位的存储了,有没有办法对二进制存储的信息再进行压缩呢?进一步省空间?

答案是有的。
可以对记录的二进制数据进行压缩。常见的二进制压缩技术都是基于RLE(Run Length Encoding,详见http://en.wikipedia.org/wiki/Run-length_encoding)。

RLE编码很简单,比较适合有很多连续字符的数据,比如以下边的Bitmap为例:

可以编码为0,8,2,11,1,2,3,11

其意思是:第一位为0,连续有8个,接下来是2个1,11个0,1个1,2个0,3个1,最后是11个0(当然此处只是对RLE的基本原理解释,实际应用中的编码并不完全是这样的)。

可以预见,对于一个很大的Bitmap,如果里边的数据分布很稀疏(说明有很多大片连续的0),采用RLE编码后,占用的空间会比原始的Bitmap小很多。

2.时间

redis虽然是在内存操作,但是超过redis指定存储在内存的阀值之后,会被搞到磁盘中。要是进行大范围的计算还需要从磁盘中取出到内存在计算比较耗时,效率也不高,有没有办法尽可能内存中多放一些数据,缩短时间?

答案是有的。

基于第一点同时引入一些对齐的技术,可以让采用RLE编码的Bitmap不需要进行解压缩,就可以直接进行AND/OR/XOR等各类计算;因此采用这类压缩技术的Bitmap,加载到内存后还是以压缩的方式存在,从而可以保证计算时候的低内存消耗;而采用word(计算机的字长,64位系统就是64bit)对齐等技术又保证了对CPU资源的高效利用。因此采用这类压缩技术的Bitmap,保持了Bitmap数据结构最重要的一个特性,就是高效的针对每个bit的逻辑运算。

常见的压缩技术包括BBC(有专利保护,WAH(http://code.google.com/p/compressedbitset/)和EWAH(http://code.google.com/p/javaewah/)


原文链接:https://blog.csdn.net/u011957758/article/details/74783347

https://blog.csdn.net/Ainanaya/article/details/115324205

posted on 2021-04-25 16:53  duanxz  阅读(7788)  评论(0编辑  收藏  举报