1. 什么是 bitmap
bitmap,即位图。bitmap就是通过最小的单位bit来进行0或者1的设置,表示某个元素对应的值或者状态。一个bit的值,或者是0,或者是1;也就是说一个bit能存储的最多信息是2。
- 位(bit):是计算机 内部数据储存的最小单位,11001100是一个八位二进制数。
- 字节(byte):是计算机中数据处理的基本单位,习惯上用大写 B 来表示,1B(byte,字节)= 8bit
2. 重要 API
命令 | 含义 |
---|---|
getbit key offset | 用于获取Redis中指定key对应的值,中对应offset的bit |
setbit key key offset value | 用于修改指定key对应的值,中对应offset的bit |
bitcount key [start end] | 获取位图指定范围中位值为1的个数,如果不指定start与end,则取所有 |
bitop op destKey key1 [key2...] | 做多个BitMap的and(交集)、or(并集)、not(非)、xor(异或)操作并将结果保存在destKey中 |
bitpos key tartgetBit [start end] | 计算位图指定范围第一个偏移量对应的的值等于targetBit的位置 1. 找不到返回-1 2. start与end没有设置,则取全部 3. targetBit只能取0或者1 |
3. bitmap的优点和限制
3.1 优点
- 基于最小的单位bit进行存储,所以非常省空间。
- 设置时候时间复杂度O(1)、读取时候时间复杂度O(n),操作是非常快的。
- 二进制数据的存储,进行相关计算的时候非常快。
- 方便扩容
3.2 限制
bitmap的value实际是一个string结构。Redis中string value限制在512MB之内,所以最大是2^32位。建议每个key的位数都控制下,因为读取时候时间复杂度O(n),越大的串读的时间花销越多。
3.3 空间、时间粗略计算方式
在一台2010 MacBook 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。
4. 使用场景
使用方式很多,根据不同的业务需求来,但是总的来说就两种,以用户为例子:
1. 一种是某一用户的横向扩展,即此个key值中记录这当前用户的各种状态值,允许无限扩展(2^32内)
点评:这种用法基本上是很少用的,因为每个key携带uid信息,如果存储的key的空间大于value,从空间角度看有一定的优化空间,如果是记录长尾的则可以考虑。
2. 一种是某一用户的纵向扩展,即每个key只记录当前业务属性的状态,每个uid当作bit位来记录信息(用户超过2^32内需要分片存储)
点评:基本上项目使用的场景都是基于这种方式的,按业务区分方便回收资源,key值就一个,将uid的存储转为了位的存储,十分巧妙的通过uid即可找到相应的值,主要存储量在value上,符合预期。
4.1 用户在线状态
使用bitmap是一个节约空间效率又高的一种方法,只需要一个key,然后用户id为偏移量offset,如果在线就设置为1,不在线就设置为0,3亿用户只需要36MB的空间。
当数据量大时key需要进行分片处理,大概10w用户分为一个key,减少位移耗费时间。
$status = 1;
$redis->setBit('online', $uid, $status);
$redis->getBit('online', $uid);
4.2 统计活跃用户
使用时间作为缓存的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');
上亿用户需要加上分片的方式。几十万或者以下,可无需分片省的业务变复杂。
4.3 用户签到
使用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。存储成本是不是很低。
4.4 布隆过滤器
布隆过滤器主要是用于应对缓存穿透问题。
比如爬虫服务器在爬取电商网站的商品信息时,首先经过缓存,如果缓存查不到,再去数据库获取信息,因为爬虫的效率很高,且sku很有可能是不存在或者已下架的,就会造成缓存穿透,大量请求被发送到数据库,导致服务器受到影响。
此时,可以在缓存层之前,添加一个布隆过滤器,布隆 过滤器看作是一个bitmap,sku作为offset值,如果商品真实存在,bit值设为1。首先将商品数据初始化,当有请求时,通过getbit判断sku是否有效。如果布隆过滤器认为商品不存在,就拒绝访问,这样就可以保护存储层。
4.5 视频属性的无限延伸
一个拥有亿级数据量的短视频app,视频存在各种属性(是否加锁、是否特效等等),需要做各种标记。
我们可以使用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再增加一个值。
分片有两个原因:
- 读取的时候时间复杂度是O(n)存储越长读取时间越多
- bitmap有长度限制2^32。
分片粒度怎么衡量:
- 如果主键id存在的断层那么请尽可能选择的粒度可以避开此段id范围,防止空间浪费,因为来一个00000…9999个0…01,那么因为存一个属性而存了全部的,就浪费了。
- 分片粒度可参考某一单位时间的增长值来判断,这样也有利于预算占了多少空间,虽然空间不会占很多。
5. 可能会遇到的坑
5.1 bitcout的陷阱
如果你有仔细看前文的用法,会发现有这么一个备注“返回一个指定key中位的值为1的个数(是以byte为单位不是bit)”,这就是坑的所在。
所以bitcount 0 0 那么就应该是第一个字节中1的数量的,注意是字节,第一个字节也就是1,2,3,4,5,6,7,8这八个位置上。
6. bitmap进阶用法
6.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小很多。
6.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/)