欢迎大家关注 github.com/hsfxuebao ,希望对大家有所帮助,要是觉得可以的话麻烦给点一下Star哈
1. 前言
1.1 为什么引入这3个类型?
面试题:
- 手机App中的每天的⽤⼾登录信息:1天对应1系列⽤⼾ID或移动设备ID;
- 电商⽹站上商品的⽤⼾评论列表:1个商品对应了1系列的评论;
- ⽤⼾在⼿机App上的签到打卡信息:1天对应1系列⽤⼾的签到记录;
- 应⽤⽹站上的⽹⻚访问信息:1个⽹⻚对应1系列的访问点击。
- 记录对集合中的数据进行统计
- 在移动应用中,需要统计每天的新增用户数和第2天的留存用户数;
- 在电商网站的商品评论中,需要统计评论列表中的最新评论;
- 在签到打卡中,需要统计一个月内连续打卡的用户数;
- 在网页访问记录中,需要统计独立访客(UniqueVisitor,UV)量。 痛点:用户访问级别都是亿级的,数据量这么大如何处理?
- 亿级数据的收集+统计
存的进,取得快,多统计- 真正有价值的是统计
1.2 统计的类型
我们介绍一下亿级系统中常用的四种统计:
-
聚合统计:统计多个集合元素的聚合结果,
交差并等集合统计,交差并集和聚合函数的使用 -
排序统计:
- 最新评论留言的场景,请你设计一个展现列表(考察数据结构和设计思路)
- 需要按照时间+分页显示
- 在面对需要展示最新列表、排行榜等场景时,如果数据更新频繁或者需要分页展示,建议使用zset
-
二值统计:集合元素的取值就只有0和1两种,比如签到打卡,签到1 没签到0,详见bitmap
-
计数统计:指统计一个集合中
不重复的元素个数,详见hyperloglog
2. 位图bitmap(精确去重计数版本)
2.1 概述
Bitmap(即Bitset),Bitmap是一串连续的2进制数字(0或1),每一位所在的位置为偏移(offset), 在bitmap上可执行AND(与) OR(或) NOT(非) XOR(异或)以及其它位操作。如下图:
位图本质是数组,它是基于String数据类型的按位的操作。该数组由多个二进制位组成,每个二进制位都对应一个偏移量(我们可以称之为一个索引或者位格)。Bitmap支持的最大位数是2^32位,它可以极大的节约存储空间,使用512M内存就可以存储多大42.9亿的字节信息(2^32 = 4294967296)
底层编码说明: 用String类型作为底层数据结构实现的一种统计二值状态的数据类型 ,实质是二进制的ASCII编码对应,如下图:
两个setbit命令对k1进行设置后,对应的二进制串就是
0100 0001;二进制串就是0100 0001对应的10进制就是65,所以见下图:
2.2 常用命令
setbit命令setbit key offset value偏移量是从零开始算的
- 对 key 所储存的字符串值,设置或清除指定偏移量上的位(bit)。
- 位的设置或清除取决于 value 参数,必须是 0 或 1 。
- offset 参数必须大于或等于 0 ,小于 2^32 (bit 映射被限制在 512 MB 之内)。
- offset 较大的操作,内存分配可能造成 Redis 服务器被阻塞。
# 命令示例
127.0.0.1:26379> setbit k 2 0
(integer) 0
127.0.0.1:26379> setbit k 3 1
(integer) 0
getbit命令
- 命令:GETBIT key offset
- 对 key 所储存的字符串值,获取指定偏移量上的位(bit)。
- 当 offset 比字符串值的长度大,或者 key 不存在时,返回 0 。
# 命令示例
# 对已存在的 offset 进行 GETBIT, 返回 1
127.0.0.1:26379> exists k
(integer) 1
127.0.0.1:26379> getbit k 3
(integer) 1
# 对不存在的 offset 进行 GETBIT, 返回 0
127.0.0.1:26379> exists k1
(integer) 0
127.0.0.1:26379> getbit k 99
(integer) 0
bitcount命令
- 命令:BITCOUNT key [start] [end]
- 计算给定字符串中,被设置为 1 的比特位的数量。
- 一般情况下,给定的整个字符串都会被进行计数,通过指定额外的 start 或 end 参数,可以让计数只在特定的位上进行。start 和 end 参数的设置和 GETRANGE 命令类似,都可以使用负数值:比如 -1 表示最后一个位,而 -2 表示倒数第二个位,以此类推。
# 命令示例
127.0.0.1:26379> setbit k1 120 1
(integer) 0
127.0.0.1:26379> setbit k1 122 1
(integer) 0
127.0.0.1:26379> setbit k1 121 0
(integer) 0
127.0.0.1:26379> bitcount k1
(integer) 2
127.0.0.1:26379> bitcount k1 0 1
(integer) 0
127.0.0.1:26379> bitcount k1 15 16
(integer) 2
127.0.0.1:26379> bitcount k1 14 15
(integer) 2
strlen命令
- 命令:stlen key
- 统计字节数占用多少,不是字符串长度而是占据几个字节,超过8位后自己按照8位一组
一byte 再扩容
# 命令示例
127.0.0.1:26379> setbit k2 0 1
(integer) 0
127.0.0.1:26379> setbit k2 7 1
(integer) 0
127.0.0.1:26379> strlen k2
1
127.0.0.1:26379> setbit k2 8 1
(integer) 0
127.0.0.1:26379> strlen k2
2
bitpos命令
- 命令:bittops key bit [start] [end]
- 返回位图中第一个值为bit的二进制位的位置
- 在默认情况下,命令将检测到的整个位图,但用户也可以通过可选的start参数和end参数指定要检测的范围
- start和end指的单位是字节[byte]
- 如果我们查找设置位(位参数为1)并且字符串为空或仅由零字节组成,则返回-1。
# 命令示例
127.0.0.1:26379> setbit k 2 0
(integer) 0
127.0.0.1:26379> setbit k 3 1
(integer) 0
127.0.0.1:26379> setbit k 5 1
(integer) 0
127.0.0.1:26379> bitpos k 0
(integer) 0
#返回位图中第一个值为 1 的二进制位的位置
127.0.0.1:26379> bitpos k 1
(integer) 3
#返回位图中[0-10字节中]第一个值为 1 的二进制位的位置
127.0.0.1:26379> bitpos k 1 0 10
(integer) 3
#返回位图中[1-10字节中]第一个值为 1 的二进制位的位置
127.0.0.1:26379> bitpos k 1 1 10
(integer) -1
bitop命令
- 命令:BITOP operation destkey key [key ...]
- 对一个或多个保存二进制位的字符串 key 进行位元操作,并将结果保存到 destkey 上。
- operation 可以是 AND 、 OR 、 NOT 、 XOR 这四种操作中的任意一种:
BITOP AND destkey key [key ...],对一个或多个 key 求逻辑并,并将结果保存到 destkey 。BITOP OR destkey key [key ...],对一个或多个 key 求逻辑或,并将结果保存到 destkey 。BITOP XOR destkey key [key ...],对一个或多个 key 求逻辑异或,并将结果保存到 destkey 。BITOP NOT destkey key,对给定 key 求逻辑非,并将结果保存到 destkey 。
- 除了 NOT 操作之外,其他操作都可以接受一个或多个 key 作为输入。
- 处理不同长度的字符串
- 当 BITOP 处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作 0 。
- 空的 key 也被看作是包含 0 的字符串序列。
- 总结 |命令|作用|时间复杂度| |--|--|--| |setbit key offset val|给指定的key的值第offset赋值val|O(1)| |getit key offset|获取指定key的第offset位|O(1)| |bitcount key start end|返回指定key中[start end]中为1的数量|O(n)| |bittop operation destkey key|对不同二进制存储数据进行位运算(AND、OR、NOT、XOR)|O(n)|
2.3 应用场景
- JD签到领取京豆
- 在签到统计时,每个用户一天的签到用1个bit位就能表示, 一个月(假设是31天)的签到情况用31个bit位就可以,一年的签到也只需要用365个bit位,根本不用太复杂的集合类型
- 钉钉打卡上下班,签到统计
- 日活统计(精确)
- 统计指定用户一年之中的登录天数
- 某一用户按照一年365天,哪几天登录过?哪几天没有登录?全年中登录的天数共计多少?
3. hyperloglog(模糊去重计数版本)
3.1 概述
3.1.1 大厂常用术语
- UV:Unique Visitor 独立访客,一般理解为客户端IP,
去重考虑 - PV:Page View 页面浏览量 不用去重
- DAU: Daily Active User 日常活跃用户量(登录或者使用产品的用户数,去重复登录),常用语反应网站、互联网应用和网络游戏的运营情况
- MAU: Monthly Active User 月活跃用户
3.1.2 概念
需求:
- 统计某个网站的UV,统计某个文章的UV
- 用户搜索网站关键词的数量
- 统计用户每天搜索不同词条个数
基数统计:用户统计一个集合中不重复的元素个数,就是对集合去重复后剩余元素的计算
HyperLogLog 是用来做基数统计的算法,HyperLogLog 的优点是,在输入元素的数量或者体积非常大时,计算基数所需的空间总是固定的、并且是很小的。即 去重复统计功能的基数估计计算法。
在 Redis 中每个键占用的内容都是 12K,理论存储近似接近 2^64 个值,不管存储的内容是什么。这是一个基于基数估计的算法,只能比较准确的估算出基数,可以使用少量固定的内存去存储并识别集合中的唯一元素。但是这个估算的基数并不一定准确,是一个带有 0.81% 标准误(standard error)的近似值。但是,也正是因为只有 12K 的存储空间,所以,它并不实际存储数据的内容。
3.1.3 演进历程
- 去重统计想到的方式:HashSet,bitmap(精确统计,数据量大占用内存大)
- 概率算法:
通过牺牲准确率来换取空间,对于不要求绝对准确率的场景下可以使用,因为概率算法不直接存储数据本身, 通过一定的概率统计方法预估基数值,同时保证误差在一定范围内,由于又不储存数据故此可以大大节约内存。HyperLogLog就是一种概率算法的实现。 - 原理说明:
- 只是进行不重复的基数统计,不是集合也不保存数据,只记录数量而不是具体内容
- 非精确统计,牺牲准确率来换取空间,误差仅仅只是 0.81% 左右
- 误差出处和论文
3.2 常用命令
- PFADD
- 将任意数量的元素添加到指定的
HyperLogLog里面。 - 时间复杂度: 每添加一个元素的复杂度为 O(1) 。
- 如果 HyperLogLog 估计的近似基数(
approximated cardinality)在命令执行之后出现了变化, 那么命令返回 1 , 否则返回 0 。 - 如果命令执行时给定的键不存在, 那么程序将先创建一个空的
HyperLogLog结构, 然后再执行命令。
- PFCOUNT
- 当 PFCOUNT key [key …] 命令作用于单个键时,返回储存在给定键的 HyperLogLog 的近似基数,如果键不存在,那么返回 0,复杂度为 O(1),并且具有非常低的平均常数时间;
- 当 PFCOUNT key [key …] 命令作用于多个键时,返回所有给定 HyperLogLog 的并集的近似基数,
这个近似基数是通过将所有给定
HyperLogLog合并至一个临时 HyperLogLog 来计算得出的,复杂度为 O(N),常数时间也比处理单个HyperLogLog时要大得多。
- PFMERGE
- 将多个 HyperLogLog 合并(merge)为一个 HyperLogLog,合并后的 HyperLogLog 的基数接近于所有输入 HyperLogLog 的可见集合(observed set)的并集。
- 时间复杂度是 O(N),其中 N 为被合并的 HyperLogLog 数量,不过这个命令的常数复杂度比较高。
- 命令格式:PFMERGE destkey sourcekey [sourcekey …] 合并得出的 HyperLogLog 会被储存在 destkey 键里面,如果该键并不存在,那么命令在执行之前,会先为该键创建一个空的 HyperLogLog。
示例:
127.0.0.1:26379> pfadd alipay20200205 001 002 003
(integer) 1
127.0.0.1:26379> pfadd alipay20200206 001 004 005
(integer) 1
127.0.0.1:26379> pfmerge alipay202002 alipay20200205 alipay20200206
OK
127.0.0.1:26379> pfcount alipay20200205
(integer) 3 // 这里计数某一天的数据
127.0.0.1:26379> pfcount alipay202002
(integer) 5 // 这里计算2月份数据时, 进行了去重计数
127.0.0.1:26379>
3.3 应用场景
鉴于 HyperLogLog 不保存数据内容的特性,所以,它只适用于一些特定的场景。比如: 计算日活、7日活、月活数据。微信公众号文章的阅读数,网页的 UV 统计(可利用cookie)。
为啥采用这种方式呢?
如果我们通过解析日志,把 ip 信息(或用户 id)放到集合中,例如:HashSet。如果数量不多则还好,但是假如每天访问的用户有几百万。无疑会占用大量的存储空间。且计算月活时,还需要将一个整月的数据放到一个 Set 中,这随时可能导致我们的程序 OOM。
前面说过 HyperLogLog 占用的内容都是 12K?为啥是12k?
redis集群有16384个槽, 每个桶取6位,16384*6÷8 = 12kb,每个桶有6位,最大全部都是1,值就是63。
4. 咆哮位图 (精确去重计数版本--省内存版本)
4.1 概述
- 如果要统计一篇文章的阅读量,可以直接使用 Redis 的 incr 指令来完成。
- 如果要求阅读量必须按用户去重,那就可以使用 set 来记录阅读了这篇文章的所有用户 id,获取 set 集合的长度就是去重阅读量。
- 但是如果爆款文章阅读量太大,set 会浪费太多存储空间。
- 此时可以使用 Redis 提供的 HyperLogLog 数据结构来代替 set,它只会占用最多 12k 的存储空间就可以完成海量的去重统计。但是它牺牲了准确度,它是模糊计数,误差率约为 0.81%。
疑问:那么有没有一种不怎么浪费空间的精确计数方法呢?
我们首先想到的就是位图,可以使用位图的一个位来表示一个用户id。如果一个用户id是32字节,那么使用位图就只需要占用 1/256 的空间就可以完成精确计数。但是如何将用户id映射到位图的位置呢?
如果用户id是连续的整数这很好办,但是通常用户系统的用户id并不是整数,而是字符串或者是有一定随机性的大整数。我们可以强行给每个用户id赋予一个整数序列,然后将用户id和整数的对应关系存在redis中。
[
$next_user_id = incr user_id_seq
set user_id_xxx $next_user_id
$next_user_id = incr user_id_seq
set user_id_yyy $next_user_id
$next_user_id = incr user_id_seq
set user_id_zzz $next_user_id
]
提出疑问,为了节省空间,这里存储用户id和整数的映射关系就不浪费空间了么?
-
这个问题提的很好,但是同时我们也要看到这个映射关系是可以复用的,它可以统计所有文章的阅读量,还可以统计签到用户的日活、月活,还可以用在很多其它的需要用户去重的统计场合中。有了这个映射关系,我们就很容易构造出每一篇文章的阅读打点位图,来一个用户,就将相应位图中相应的位置为一。如果位从0变成1,那么就可以给阅读数加1。这样就可以很方便的获得文章的阅读数。
-
而且我们还可以动态计算阅读了两篇文章的公共用户量有多少?将两个位图做一下 AND 计算,然后统计位图中位 1 的个数。同样,还可以有 OR 计算、XOR 计算等等都是可行的。
问题又来了!Redis 的位图是密集位图,什么意思呢?
- 如果有一个很大的位图,它只有最后一个位是 1,其它都是零,这个位图还是会占用全部的内存空间,这就不是一般的浪费了。你可以想象大部分文章的阅读量都不大,但是它们的占用空间却是很接近的,和哪些爆款文章占据的内存差不多。看来这个方案行不通,我们需要想想其它方案!这时咆哮位图(RoaringBitmap)来了。
4.2 原理
咆哮位图(RoaringBitmap)将整个大位图进行了分块,如果整个块都是零,那么这整个块就不用存了。但是如果位1比较分散,每个块里面都有1,虽然单个块里的1很少,这样只进行分块还是不够的,那该怎么办呢?
我们再想想,对于单个块,是不是可以继续优化?
如果单个块内部位 1 个数量很少,我们可以只存储所有位1的块内偏移量(整数),也就是存一个整数列表,那么块内的存储也可以降下来。这就是单个块位图的稀疏存储形式 —— 存储偏移量整数列表。
只有单块内的位1超过了一个阈值,才会一次性将稀疏存储转换为密集存储。咆哮位图除了可以大幅节约空间之外,还会降低 AND、OR 等位运算的计算效率。以前需要计算整个位图,现在只需要计算部分块。如果块内非常稀疏,那么只需要对这些小整数列表进行集合的 AND、OR 运算,如是计算量还能继续减轻。
这里既不是用空间换时间,也没有用时间换空间,而是用逻辑的复杂度同时换取了空间和时间。咆哮位图的位长最大为 2^32,对应的空间为 512M(普通位图),位偏移被分割成高 16 位和低 16 位,高 16 位表示块偏移,低16位表示块内位置,单个块可以表达 64k 的位长,也就是 8K 字节。最多会有64k个块。
现代处理器的 L1 缓存普遍要大于 8K,这样可以保证单个块都可以全部放入 L1 Cache,可以显著提升性能。
如果单个块所有的位全是零,那么它就不需要存储。具体某个块是否存在也可以是用位图来表达,当块很少时,用整数列表表示,当块多了就可以转换成普通位图。整数列表占用的空间少,它还有类似于 ArrayList 的动态扩容机制避免反复扩容复制数组内容。当列表中的数字超出4096个时,会立即转变成普通位图。用来表达块是否存在的数据结构和表达单个块数据的结构可以是同一个,因为块是否存在本质上也是 0 和 1,就是普通的位标志。
但是 Redis 并没有原生支持咆哮位图这个数据结构啊? Redis 确实没有原生的,但是咆哮位图的 Redis Module 有。 github.com/aviggiano/r… (Redis Module 咆哮位图)
5. GEO
5.1 概述
自Redis 3.2开始,Redis基于geohash和有序集合提供了地理位置相关功能。
GeoHash核心原理解析
原理:核心思想就是将球体转换为平面,区块转换为一点
- 将三维的地球变为二维的坐标
- 在将二维的坐标转换为一维的点块
- 最后将一维的点块转换为二进制再通过base32编码
5.2 常用命令
- GEOADD: 将给定的位置对象(纬度、经度、名字)添加到指定的key;
- GEOPOS: 从key里面返回所有给定位置对象的位置(经度和纬度);
- GEODIST: 返回两个给定位置之间的距离;
- GEOHASH: 返回一个或多个位置对象的Geohash表示;
- GEORADIUS: 以给定的经纬度为中心,返回目标集合中与中心的距离不超过给定最大距离的所有位置对象;
- GEORADIUSBYMEMBER: 以给定的位置对象为中心,返回与其距离不超过给定最大距离的所有位置对象。
获取某个地址的经纬度:jingweidu.bmcx.com/
命令示例:
127.0.0.1:26379> GEOADD city 116.403963 39.915119 "天安门" 116.403414 39.924091 "故宫" 116.024067 40.362639 "长城"
(integer) 3
127.0.0.1:26379> type city
zset
// 解决中文乱码
[root@hsfxuebao ~] redis-cli --raw
127.0.0.1:26379> zrange city 0 -1
天安门
故宫
长城
127.0.0.1:26379> geopos city 天安门
116.40396326780319214
39.91511970338637383
127.0.0.1:26379> geohash city 天安门
wx4g0f6f2v0
// km m ft英尺 mi英里
127.0.0.1:26379> geodist city 天安门 长城 km
59.3390
5.3 应用场景
-
组合使用GEOADD和GEORADIUS可实现“附近的人”中“增”和“查”的基本功能。
-
要实现微信中“附近的人”功能,也可直接使用GEORADIUSBYMEMBER命令。其中“给定的位置对象”即为用户本人,搜索的对象为其他用户。
-
不过本质上,GEORADIUSBYMEMBER = GEOPOS + GEORADIUS,即先查找用户位置再通过该位置搜索附近满足位置相互距离条件的其他用户对象。
6. 经典面试题:redis集群的最大槽数是16384个?
Redis集群并没有使用一致性hash而是引入了哈希槽的概念。 Redis 集群有16384个哈希槽 ,每个key通过CRC16校验后对16384取模来决定放置哪个槽,集群的每个节点负责一部分hash槽。但为什么哈希槽的数量是16384(2^14)个呢? github问题地址
CRC16算法产生的hash值有16bit,该算法可以产生2^16=65536个值。 换句话说值是分布在0~65535之间。那作者在做mod运算的时候,为什么不mod65536,而选择mod16384?
说明1:
-
正常的心跳数据包带有节点的完整配置,可以用幂等方式用旧的节点替换旧节点,以便更新旧的配置。这意味着它们包含原始节点的插槽配置,该节点使用2k的空间和16k的插槽,但是会使用8k的空间(使用65k的插槽)。
-
同时,由于其他设计折衷,Redis集群不太可能扩展到1000个以上的主节点。 因此16k处于正确的范围内,以确保每个主机具有足够的插槽,最多可容纳1000个矩阵,但数量足够少,可以轻松地将插槽配置作为原始位图传播。请注意,在小型群集中,位图将难以压缩,因为当N较小时,位图将设置的slot / N位占设置位的很大百分比。
说明2:
-
如果槽位为65536,发送心跳信息的消息头达8k,发送的心跳包过于庞大。
- 在消息头中最占空间的是myslots[CLUSTER_SLOTS/8]。 当槽位为65536时,这块的大小是: 65536÷8÷1024=8kb
- 因为每秒钟,redis节点需要发送一定数量的ping消息作为心跳包,如果槽位为65536,这个ping消息的消息头太大了,浪费带宽。
-
redis的集群主节点数量基本不可能超过1000个。
- 集群节点越多,心跳包的消息体内携带的数据越多。如果节点过1000个,也会导致网络拥堵。因此redis作者不建议redis cluster节点数量超过1000个。 那么,对于节点数在1000以内的redis cluster集群,16384个槽位够用了。没有必要拓展到65536个。
-
槽位越小,节点少的情况下,压缩比高,容易传输
- Redis主节点的配置信息中它所负责的哈希槽是通过一张bitmap的形式来保存的,在传输过程中会对bitmap进行压缩,但是如果bitmap的填充率slots / N很高的话(N表示节点数),bitmap的压缩率就很低。 如果节点数很少,而哈希槽数量很多的话,bitmap的压缩率就很低。