目录
Bitmaps
在移动应用的业务场景中,我们需要保存这样的信息:一个 key 关联了一个数据集合。
例如:
- 根据用户id判断登陆状态
- 用户的签到情况
通常情况下,我们面临的用户数量以及访问量都是巨大的,比如百万、千万级别的用户数量,或者千万级别、甚至亿级别的访问信息。
所以,我们必须要选择能够非常高效地统计大量数据(例如亿级)的集合类型。
如何选择集合类型
假如我们在判断用户是否登陆的场景中使用 Redis 的 String 类型实现(key -> userId,value -> 0 表示下线,1 - 登陆),假如存储 100 万个用户的登陆状态,如果以字符串的形式存储,就需要存储 100 万个字符串了,内存开销太大。
String 类型除了记录实际数据以外,还需要额外的内存记录数据长度、空间使用等信息。所以除了记录实际数据以外,其他都是额外开销。
合理地使用操作位能够有效地提高内存使用率和开发效率。java中位与操作是最快的,在Redis中同样道理,Redis提供了Bitmaps这个“数据类型”可以实现对位的操作:
- Bitmaps本身不是一 种数据类型,实际上它就是字符串( key-value ),但是它可以对字符串的位进行操作。
- Bitmaps单独提供了一套命令,所以在Redis中使用Bitmaps和使用字符串的方法不太相同。可以把 Bitmaps想象成一个以位为单位的数组 ,数组的每个元只能存储0和1,数组的下标在Bitmaps中叫做偏移量。
- 8 个 bit 组成一个 Byte,所以 Bitmap 会极大地节省存储空间。 这就是 Bitmap 的优势。
判断用户登录状态
Bitmap 提供了 GETBIT、SETBIT
操作,通过一个偏移值 offset 对 bit 数组的 offset 位置的 bit 位进行读写操作,需要注意的是 offset 从 0 开始。
只需要一个 key为login 表示存储用户登陆状态集合数据, 将用户 ID 作为 offset,在线就设置为 1,下线设置 0。通过 GETBIT
判断对应的用户是否在线。50000 万 用户只需要 6 MB 的空间。
setbit <key> <offset>设置对应KEY值的偏移量的值
第一步:1代表用户已经登陆
SETBIT login 10001 1
第二步:检查该用户是否登陆,返回值 1 表示已登录
GETBIT login 10001
第三步:退出,将 offset 对应的 value 设置成 0
SETBIT login 10001 0
小结
只需要一个 bit 位就能表示 0 和 1。在统计海量数据的时候将大大减少内存占用。
HyperLogLog
在工作当中,我们经常会遇到与统计相关的功能需求,比如统计网站PV( PageView页面访问量) ,可以使用Redis的incr、incrby 轻松实现。
但像UV ( UniqueVisitor ,独立访客)、独立IP数、搜索记录数等需要去重和计数的问题如何解决?这种求集合中不重复元素个数的问题称为基数问题。
解决技术问题的方案有很多种:
- 数据存储在MYSQL表中,使用distinct count计算不重复个数
- 使用Redis提供的hash、set、bitmaps等数据结构来梳理
以上的方案结果精确,但随着数据不断增加,导致空间占用越来越大,对于非常大的数据集是不切实际的。
Redis HyperLogLog 是用来做基数统计的算法,HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。
在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基数。这和计算基数时,元素越多耗费内存就越多的集合形成鲜明对比。
但是,因为 HyperLogLog 只会根据输入元素来计算基数,而不会储存输入元素本身,所以 HyperLogLog 不能像集合那样,返回输入的各个元素。
什么是基数
比如数据集 {1, 3, 5, 7, 5, 7, 8}, 那么这个数据集的基数集为 {1, 3, 5 ,7, 8}, 基数(不重复元素)为5。 基数估计就是在误差可接受的范围内,快速计算基数。
应用场景
HyperLogLog 主要的应用场景就是进行基数统计。这个问题的应用场景其实是十分广泛的。例如:对于 Google 主页面而言,同一个账户可能会访问 Google 主页面多次。于是,在诸多的访问流水中,如何计算出 Google 主页面每天被多少个不同的账户访问过就是一个重要的问题。那么对于 Google 这种访问量巨大的网页而言,其实统计出有十亿 的访问量或者十亿零十万的访问量其实是没有太多的区别的,因此,在这种业务场景下,为了节省成本,其实可以只计算出一个大概的值,而没有必要计算出精准的值。
对于上面的场景,可以使用HashMap
、BitMap
和HyperLogLog
来解决。对于这三种解决方案,这边做下对比:
HashMap
:算法简单,统计精度高,对于少量数据建议使用,但是对于大量的数据会占用很大内存空间;BitMap
:位图算法,具体内容可以参考我的这篇文章,统计精度高,虽然内存占用要比HashMap
少,但是对于大量数据还是会占用较大内存;HyperLogLog
:存在一定误差,占用内存少,稳定占用 12k 左右内存,可以统计 2^64 个元素,对于上面举例的应用场景,建议使用。
命令
pfadd <key> <element> [element ...] 添加指定元素到 HyperLogLog中,将所有元素添加到指定HyperLogLog数据结构中。如果执行命令后HLL估计的近似基数发生变化,则返回1,否则返回0。
pfcount<key> [key ..]计算HLL的近似基数,可以计算多个HLL ,比如用HLL存储每天的UV ,计算一周的UV可以使用7天的UV合并计算即可
pfmerge<destkey> <sourcekey> [sourcekey ..]将一个或多 个HLL合并后的结果存储在另一个HLL中,比如每月活跃用户可以使用每天的活跃用户来给并计算可得
比如在网站中我们有两个内容差不多的页面,运营说需要这两个页面的数据进行合并。其中页面的 UV 访问量也需要合并,那这个时候 PFMERGE
就可以派上用场了,也就是同样的用户访问这两个页面则只算做一次。
Geospatial
Redis 3.2中增加了对GEO类型的支持。GEO , Geographic ,地理信息的缩写。该类型,就是元素的2维坐标,在地图上就是经纬度。redis基于该类型,提供了经纬度设置,查询,范围查询,距离查询,经纬度Hash等常见操作。
如何实现定位
说到定位,很多人第一反应应该是,实时上报经纬度,数据库中提前存储好所有的经纬度,然后用上报的经纬度和数据库中的经纬度进行比较,计算出附近的人或共享单车。这种做法需要循环遍历,数据库中的数据量大,查询慢,效率低。
那么,这些app是如何做到既能够精确定位,又能够实时查询的呢?答案就是使用geohash。redis的"数据类型"geospatial就能计算出geohash。redis使用geohash技术将实时上报的精度和纬度,通过一定的算法转化成最长12个字符的字符串,两个位置的经纬度计算的字符串的前缀越相同,则两个位置离得越近。这样一来就可以通过数据库的like加上geohash的前几位模糊查询数据库的数据了。比如ofo共享单车,数据库中用一张表t_bike专门存储ofo的每一辆车的编号no、经度longitude 、纬度latitude、geohash等字段,当每一辆车上报自己的经纬度时,同时计算一个geohash存到表中;当用户要用车时,上报用户的实时位置的经纬度,并计算一个hash值,比如hash=efgrtv98fjng,那么可以使用:
select * from t_bike where geohash like 'efgrtv98%'
就可以找到附近有多少车了。like后面使用的hash位数越多,查找的范围越准确。
Geohash技术
geohash技术就是将经纬度转换成最长12个字符的字符串,同时两个位置越近,生成的字符串的前缀越一致。这是如何实现的呢?
例如,东方明珠的经纬度,东经121.506377,北纬31.245105。
下面就以东方明珠为例,简单说一下如何将这两个经纬度计算成一个hash字符串的。
geohash的计算
1.使用二分法生成二进制
将纬度(-90,90)分成两个区间,(-90,0)和(0,90),如果目标纬度落在左边区间则记为0,否则记为1;再将目标纬度所在的那个区间在通过二分法分成两个相等的区间,如果目标纬度落在左边区间则记为0,否则记为1,以此类推。
同样的,将经度(-180,180)也通过这种方式计算。
最终,经度和纬度计算后,分别得到一个由0和1组成的二进制。
假如,东方明珠的经纬度计算后,得到两个二进制位:
经度:110101100101001110111100011010
纬度:101011000101010000110101100101
2.合并二进制
将上面的两个二进制按照“偶数位放经度,奇数位放纬度”的原则,从0位开始数起,合并成一个二进制。
可以理解成将纬度向后移动一位,然后将两行压成一行。
结果: 111001100111100000110011000110101000111110110001011010011001
3.二进制转换成十进制
把上面合并后的60位二进制,按照从左往右,每5位划分成1个组,如果最后一组如果不足5位就用0补齐到5位。分组后所示:
分组结果: 11100 11001 11100 00011 00110 00110 10100 01111 10110 00101 10100 11001
将上面的每组二进制分别转成十进制:
十进制结果: 28 25 28 3 6 6 20 15 22 5 20 25
4.十进制转base32字符串
使用base32编码表,将每个十进制数替换成编码表中的字符,获得一个字符串。
转化后的字符串:
base32字符串:4Z4CGGUPWFUZ
这就是模拟东方明珠的经纬度生产的geohash的值(不是真实值)。
geohash的精度
hash的字符串长1位-12位,对应精度的级别1-12级。字符串越长,位置越精确。
geohash的边界问题
比如现在是y1,根据geohash算法定位到y1所在的矩形块,返回附近的点就会得到y2。但是我们发现实际情况是,x, z虽然不在我们所在的那个矩形区域,但是x, z显然离我们更近。这就是geohash的边界问题,就是如果我们刚好处在矩形的边界处,那么离我们最近的点不一定是和我们在同一个矩形框中的点,很可能是旁边的矩形框中的某些点离我们更近。
其实,就是将该区块上下左右以及四个对角的8个区块的hash都计算一遍,分别计算和自己之间的距离,找到最近的一个。因为这是的数据量已经非常小了,计算周边的8个块也很快。
Redis对地理位置的支持
可以通过编程的方式实现上面的计算过程,当然也可以直接使用redis计算geohash。
redis中的geospatial本质是使用sorted set存储的。使用的也是geohash技术。经度二进制和纬度二进制,通过上面介绍的“偶数位放经度,奇数位放纬度”的原则,形成一个独特的52位二进制。sorted set存储每一个成员时,会给每一个成员一个分数用于排序,分数值是一个双精度的64位浮点型数字字符串,它能包括的整数范围是-(2^53)
到 +(2^53)
。所以使用sorted set存储52位的二进制不会丢失精度。同时,这种格式的数据通过半径查询时,支持查询中间的1个区块和周边的8个区块,并丢弃半径意外的元素,可以解决geohash的边界问题。
实际使用时,可以提前将一份各地区的经纬度表格,通过redis命令导入到redis内存中,然后可以通过相关的redis命令计算每个地区的geohash,并且可以搜索指定范围内,redis中存在的地区有哪些,同时也可以计算两个经纬度之间的距离。
指令操作
1、geoadd<key> < longitude> <latitude> <member> [longitude latitude member..]添加地理位置(经度,纬度,名称)。
2、geopos <key><name> 获取制定城市的坐标
3、geodist <key> <member1> <member2> [m]|km|ft|mi ]获取两个位置 之间的直线距离,不指定单位参数,默认用米
4、georadius <key>< longitude> <latitude>radius m|km|ft|mi 以给定的经纬度为中心 ,找出某一半径内的元素