Redis 高级数据结构
Bitmaps
现代计算机用二进制(位)作为信息的基础单位,1 个字节等于 8 位,例如“big” 字符串是由 3 个字节组成,但实际在计算机存储时将其用二进制表示,“big”分 别对应的 ASCII 码分别是 98、105、103,对应的二进制分别是 01100010、01101001 和 01100111。
许多开发语言都提供了操作位的功能,合理地使用位能够有效地提高内存使 用率和开发效率。Redis 提供了 Bitmaps 这个“数据结构”可以实现对位的操作。 把数据结构加上引号主要因为:
Bitmaps 本身不是一种数据结构,实际上它就是字符串,但是它可以对字符串的位进行操作。
Bitmaps 单独提供了一套命令,所以在 Redis 中使用 Bitmaps 和使用字符串的 方法不太相同。可以把 Bitmaps 想象成一个以位为单位的数组,数组的每个单元只能存储 0 和 1,数组的下标在 Bitmaps 中叫做偏移量。
操作命令
setbit 设置值
setbit key offset value
getbit 获取值
getbit key offset
offset 是不存在的,也会返回 0。
bitcount 获取 Bitmaps 指定范围值为 1 的个数
bitcount [start] [end]
bitop Bitmaps 间的运算
bitop operation destkey key [key ...]
bitop 是一个复合操作,它可以做多个 Bitmaps 的 and(交集)or(并 集)not(非)xor(异或)操作并将结果保存在 destkey 中。
bitpos 计算 Bitmaps 中第一个值为 targetBit 的偏移量
bitpos key bit [start] [end]
Bitmaps 优势
假设网站有 1 亿用户,每天独立访问的用户有 5 千万,如果每天用集合类型 和 Bitmaps 分别存储活跃用户,很明显,假如用户 id 是 Long 型,64 位,则集合 类型占据的空间为 64 位 x50 000 000= 400MB,而 Bitmaps 则需要 1 位×100 000 000=12.5MB,可见 Bitmaps 能节省很多的内存空间。
布隆过滤器
面试题和场景
1、目前有 10 亿数量的自然数,乱序排列,需要对其排序。限制条件-在 32 位机器上面完成,内存限制为 2G。如何完成?
2、如何快速在亿级黑名单中快速定位 URL 地址是否在黑名单中?(每条 URL 平均 64 字节) (大概6g)
3、需要进行用户登陆行为分析,来确定用户的活跃情况?
4、网络爬虫-如何判断 URL 是否被爬过?
5、快速定位用户属性(黑名单、白名单等)
6、数据存储在磁盘中,如何避免大量的无效 IO?
传统数据结构的不足
当然有人会想,我直接将网页 URL 存入数据库进行查找不就好了,或者建立 一个哈希表进行查找不就 OK 了。
当数据量小的时候,这么思考是对的,
确实可以将值映射到 HashMap 的 Key,然后可以在 O(1) 的时间复杂度内 返回结果,效率奇高。但是 HashMap 的实现也有缺点,例如存储容量占比高, 考虑到负载因子的存在,通常空间是不能被用满的,举个例子如果一个 1000 万 HashMap,Key=String(长度不超过 16 字符,且重复性极小),Value=Integer, 会占据多少空间呢?1.2 个 G。实际上,1000 万个 int 型,只需要 40M 左右空间, 占比 3%,1000 万个 String,需要 161M 左右空间,占比 13.3%。
可见一旦你的值很多例如上亿的时候,那 HashMap 占据的内存大小就变得 很可观了。
但如果整个网页黑名单系统包含 100 亿个网页 URL,在数据库查找是很费时 的,并且如果每个 URL 空间为 64B,那么需要内存为 640GB,一般的服务器很难 达到这个需求。
位图法
位图法就是 bitmap 的缩写。所谓 bitmap,就是用每一位来存放某种状态, 适用于大规模数据,但数据状态又不是很多的情况。通常是用来判断某个数据存 不存在的。
布隆过滤器详解
本质上布隆过滤器是一种数据结构,比较巧妙的概率型数据结构 (probabilistic data structure),特点是高效地插入和查询,可以用来告诉你 “某 样东西一定不存在或者可能存在”。
相比于传统的 List、Set、Map 等数据结构,它更高效、占用空间更少,但 是缺点是其返回的结果是概率性的,而不是确切的。
实际上,布隆过滤器广泛应用于网页黑名单系统、垃圾邮件过滤系统、爬虫 网址判重系统等,Google 著名的分布式数据库 Bigtable 使用了布隆过滤器来查 找不存在的行或列,以减少磁盘查找的 IO 次数,Google Chrome 浏览器使用了布 隆过滤器加速安全浏览服务。
在很多 Key-Value 系统中也使用了布隆过滤器来加快查询过程,如 Hbase, Accumulo,Leveldb,一般而言,Value 保存在磁盘中,访问磁盘需要花费大量时 间,然而使用布隆过滤器可以快速判断某个 Key 对应的 Value 是否存在,因此可 以避免很多不必要的磁盘 IO 操作。
通过一个 Hash 函数将一个元素映射成一个位阵列(Bit Array)中的一个点。 这样一来,我们只要看看这个点是不是 1 就知道可以集合中有没有它了。这就 是布隆过滤器的基本思想。
Hash 面临的问题就是冲突。假设 Hash 函数是良好的,如果我们的位阵列 长度为 m 个点,那么如果我们想将冲突率降低到例如 1%, 这个散列表就只能 容纳 m/100 个元素。显然这就不叫空间有效了(Space-efficient)。解决方法也 简单,就是使用多个 Hash,如果它们有一个说元素不在集合中,那肯定就不在。
但是布隆过滤器的缺点是什么?
因为随着增加的值越来越多,被置为 1 的 bit 位也会越来越多,这样某个 值 即使没有被存储过,但是万一哈希函数返回的三个 bit 位都被其他值置位了 1 ,那么程序还是会判断 这个值存在。比如此时来一个不存在的 URL1000,他 经过哈希计算后。发现 bit 位为下:
但是这些 bit 位已经被 url1,url2,url3 置为 1,程序就会判断 URL1000 值存在。 这就是布隆过滤器的误判现象。所以:布隆过滤器判断存在的不一定存在,但是判断不存在的一定不存在。False is always false. True is maybe true.
布隆过滤器可精确的代表一个集合,可精确判断某一元素是否在此集合中, 精确程度由用户的具体设计决定,达到 100%的正确是不可能的。但是布隆过滤 器的优势在于,利用很少的空间可以达到较高的精确率。
很显然,过小的布隆过滤器很快所有的 bit 位均为 1,那么查询任何值都会 返回“可能存在”,起不到过滤的目的了。布隆过滤器的长度会直接影响误报率, 布隆过滤器越长其误报率越小。
另外,哈希函数的个数也需要权衡,个数越多则布隆过滤器 bit 位置位 1 的 速度越快,且布隆过滤器的效率越低;但是如果太少的话,那我们的误报率会变 高。
布隆过滤器的位数组的大小大小如何确定?
设 bitarray 大小为 m,样本数量为 n,允许失误率为 p,则
10亿数据,误报1%的m = 87.5M
如何使得错误率最小,对于给定的 m 和 n,m 指位数组的大小,n 指插入元 素的个数,Hash 函数的个数为 k,错误率的计算公式为:
希望错误率最小,对于给定的 m 和 n,那么合适的 Hash 函数的个数为 k 计 算公式为:
Redis 中的布隆过滤器
1、下载 redisbloom 插件
wget https://github.com/RedisLabsModules/rebloom/archive/v1.1.1.tar.gz
2、解压并安装,生成.so 文件:
tar -zxvf v1.1.1.tar.gz
cd redisbloom-1.1.1/
make
3、在 redis 配置文件(redis.conf)中加入该模块
vim redis.conf
loadmodule /usr/local/redis/redisbloom-1.1.1/rebloom.so
4、相关的操作命令
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 批量判断
5、目前 jedis 不支持布隆过滤器,需要 JRedisBloom
https://github.com/RedisLabs/JReBloom
<dependency>
<groupId>com.redislabs</groupId>
<artifactId>jrebloom</artifactId>
<version>2.1.0</version>
</dependency>
Redisson
Redisson 底层基于位图实现了一个布隆过滤器,范例代码:
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.14.104:6379"); config.useSingleServer().setPassword("123"); //构造
Redisson RedissonClient redisson = Redisson.create(config);
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("phoneList"); //初始化布隆过滤器:预计元素为 100000000L,误差率为 3%
bloomFilter.tryInit(100000000L,0.03); //将号码 10086 插入到布隆过滤器中
bloomFilter.add("10086"); //判断下面号码是否在布隆过滤器中 System.out.println(bloomFilter.contains("123456"));//false System.out.println(bloomFilter.contains("10086"));//true
自行实现
就是利用 Redis 的 bitmaps 来实现。
// RedisBloomFilter
/*仿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 RedisBloomFilter
*/
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 +
'}';
}
}
单机下无 Redis 的布隆过滤器
使用 Google 的 Guava 的 BloomFilter。
HyperLogLog
HyperLogLog(Hyper[ˈhaɪpə®])并不是一种新的数据结构(实际类型为字符串类 型),而是一种基数算法,通过 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 统计需求了。
UV(Unique visitor) : 独立访客
是指通过互联网访问、浏览这个网页的自然人。访问您网站的一台电脑客户端为一个访客。00:00-24:00内相同的客户端只被计算一次。
一天内同个访客多次访问仅计算一个UV。
IP(Internet Protocol)
独立IP是指访问过某站点的IP总数,以用户的IP地址作为统计依据。00:00-24:00内相同IP地址被计算一次。
UV与IP区别:
如:你和你的家人用各自的账号在同一台电脑上登录新浪微博,则IP数+1,UV数+2。由于使用的是同一台电脑,所以IP不变,但使用的不同账号,所以UV+2
PV(Page View):
即页面浏览量或点击量,用户每1次对网站中的每个网页访问均被记录1个PV。用户对同一页面的多次访问,访问量累计,用以衡量网站用户访问的网页数量。
VV(Visit View):
用以统计所有访客1天内访问网站的次数。当访客完成所有浏览并最终关掉该网站的所有页面时便完成了一次访问,同一访客1天内可能有多次访问行为,访问次数累计。
PV与VV区别:
如:你今天10点钟打开了百度,访问了它的三个页面;11点钟又打开了百度,访问了它的两个页面,则PV数+5,VV数+2.
PV是指页面的浏览次数,VV是指你访问网站的次数。
操作命令
HyperLogLog 提供了 3 个命令: pfadd、pfcount、pfmerge。
例如 08-15 的访问用户是 u1、u2、u3、u4,08-16 的访问用户是 u-4、u-5、 u-6、u-7
pfadd
pfadd key element [element …]
pfadd 用于向 HyperLogLog 添加元素,如果添加成功返回 1:
pfadd 08-15:u:id “u1” “u2” “u3” “u4”
pfcount
pfcount key [key …]
pfcount 用于计算一个或多个 HyperLogLog 的独立总数,例如 08-15:u:id 的独 立总数为 4:
pfcount 08-15:u:id
如果此时向插入 u1、u2、u3、u90,结果是 5:
pfadd 08-15:u:id “u1” “u2” “u3” “u90”
pfcount 08-15:u:id
如果我们继续往里面插入数据,比如插入 100 万条用户记录。内存增加非常 少,但是 pfcount 的统计结果会出现误差。
以使用集合类型和 HperLogLog 统计百万级用户访问次数的占用空间对比:
可以看到,HyperLogLog 内存占用量小得惊人,但是用如此小空间来估算如 此巨大的数据,必然不是 100%的正确,其中一定存在误差率。前面说过,Redis 官方给出的数字是 0.81%的失误率。
pfmerge
fmerge destkey sourcekey [sourcekey … ]
pfmerge 可以求出多个 HyperLogLog 的并集并赋值给 destkey
原理概述
基本原理
HyperLogLog 基于概率论中伯努利试验并结合了极大似然估算方法,并做了 分桶优化。
实际上目前还没有发现更好的在大数据场景中准确计算基数的高效算法,因此在不追求绝对准确的情况下,使用概率算法算是一个不错的解决方案。概率算 法不直接存储数据集合本身,通过一定的概率统计方法预估值,这种方法可以大 大节省内存,同时保证误差控制在一定范围内。目前用于基数计数的概率算法包 括:
Linear Counting(LC):早期的基数估计算法,LC 在空间复杂度方面并不算优 秀;
LogLog Counting(LLC):LogLog Counting 相比于 LC 更加节省内存,空间复杂 度更低;
HyperLogLog Counting(HLL):HyperLogLog Counting 是基于 LLC 的优化和改进, 在同样空间复杂度情况下,能够比 LLC 的基数估计误差更小
分桶平均的基本原理是将统计数据划分为 m 个桶,每个桶分别统计各自的 k max, 并能得到各自的基数预估值,最终对这些基数预估值求平均得到整体 的基数估计值。LLC 中使用几何平均数预估整体的基数值,但是当统计数据量较 小时误差较大;HLL 在 LLC 基础上做了改进,采用调和平均数过滤掉不健康的统 计值。
什么叫调和平均数呢?举个例子
求平均工资:A 的是 1000/月,B 的 30000/月。采用平均数的方式就是:(1000 + 30000) / 2 = 15500
采用调和平均数的方式就是: 2/(1/1000 + 1/30000) ≈ 1935.484
可见调和平均数比平均数的好处就是不容易受到大的数值的影响,比平均数 的效果是要更好的。
value 被转为 64 位的比特串,最终被按照上面的做法记录到每个桶中去。 64 位转为十进制就是:2^64,HyperLogLog 仅用了:16384 * 6 /8 / 1024 =12K 存 储空间就能统计多达 2^64 个数。
同时,在具体的算法实现上,HLL 还有一个分阶段偏差修正算法。我们就不 做更深入的了解了。
GEO
Redis 3.2 版本提供了 GEO(地理信息定位)功能,支持存储地理位置信息用来 实现诸如附近位置、摇一摇这类依赖于地理位置信息的功能。
地图元素的位置数据使用二维的经纬度表示,经度范围 (-180, 180],纬度范围 (-90, 90],纬度正负以赤道为界,北正南负,经度正负以本初子午线 (英国格林尼治 天文台) 为界,东正西负。
业界比较通用的地理位置距离排序算法是 GeoHash 算法,Redis 也使用 GeoHash 算法。GeoHash 算法将二维的经纬度数据映射到一维的整数,这样所有的 元素都将在挂载到一条线上,距离靠近的二维坐标映射到一维后的点之间距离也会 很接近。当我们想要计算「附近的人时」,首先将目标位置映射到这条线上,然后 在这个一维的线上获取附近的点就行了。
在 Redis 里面,经纬度使用 52 位的整数进行编码,放进了 zset 里面,zset 的 value 是元素的 key,score 是 GeoHash 的 52 位整数值
操作命令
增加地理位置信息
geoadd key longitude latitude member [longitude latitude member …J
longitude、latitude、member 分别是该地理位置的经度、纬度、成员,例如 下面有 5 个城市的经纬度。
cities:locations 是上面 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 key member [member …]下面操作会获取天津的经维度:
geopos cities:locations tianjin1)1)“117.12000042200088501”
获取两个地理位置的距离
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 是必需参数,指定了半径(带单位)。
删除地理位置信息
zrem key member
GEO 没有提供删除成员的命令,但是因为 GEO 的底层实现是 zset,所以可 以借用 zrem 命令实现对地理位置信息的删除。