Redis高级数据结构HyperLogLog
HyperLogLog(Hyper [ˈhaɪpə(r)] ) 并不是一种新的数据结构 ( 实际类型为字符串类 型) ,而是一种基数算法 , 通过 HyperLogLog 可以利用极小的内存空间完成独立总数的统计,数据集可以是 IP 、 Email 、 ID 等。
如果你负责开发维护一个大型的网站,有一天产品经理要网站每个网页每天 的 UV 数据,然后让你来开发这个统计模块,你会如何实现?
如果统计 PV 那非常好办,给每个网页一个独立的 Redis 计数器就可以了, 这个计数器的 key 后缀加上当天的日期。这样来一个请求, incrby 一次,最终 就可以统计出所有的 PV 数据。
但是 UV 不一样,它要去重,同一个用户一天之内的多次访问请求只能计 数一次。这就要求每一个网页请求都需要带上用户的 ID ,无论是登陆用户还是 未登陆用户都需要一个唯一 ID 来标识。
一个简单的方案,那就是为每一个页面一个独立的 set 集合来存储所有当 天访问过此页面的用户 ID 。当一个请求过来时,我们使用 sadd 将用户 ID 塞 进去就可以了。通过 scard 可以取出这个集合的大小,这个数字就是这个页面 的 UV 数据。
但是,如果你的页面访问量非常大,比如一个爆款页面几千万的 UV ,你需 要一个很大的 set 集合来统计,这就非常浪费空间。如果这样的页面很多,那 所需要的存储空间是惊人的。为这样一个去重功能就耗费这样多的存储空间,值 得么?其实需要的数据又不需要太精确,1050w 和 1060w 这两个数字对于老板 们来说并没有多大区别,So ,有没有更好的解决方案呢?
这就是 HyperLogLog 的用武之地, Redis 提供了 HyperLogLog 数据结构就是 用来解决这种统计问题的。HyperLogLog 提供不精确的去重计数方案,虽然不精 确但是也不是非常不精确, Redis 官方给出标准误差是 0.81% ,这样的精确度已经 可以满足上面的 UV 统计需求了。
操作命令
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 统计百万级用户访问次数的占用空间对比:
数据类型 1 天 1 个月 1 年
集合类型 80M 2.4G 28G
HyperLogLog 15k 450k 5M
可以看到, HyperLogLog 内存占用量小得惊人,但是用如此小空间来估算如
此巨大的数据,必然不是 100% 的正确,其中一定存在误差率。前面说过, Redis
官方给出的数字是 0.81% 的失误率。
pfmerge
pfmerge destkey sourcekey [sourcekey ... ]
pfmerge 可以求出多个 HyperLogLog 的并集并赋值给 destkey ,请自行测试。
原理概述
数学原理
HyperLogLog 基于概率论中伯努利试验并结合了极大似然估算方法,并做了
分桶优化。
实际上目前还没有发现更好的在大数据场景中准确计算基数的高效算法,因
此在不追求绝对准确的情况下,使用概率算法算是一个不错的解决方案。概率算
法不直接存储数据集合本身,通过一定的概率统计方法预估值,这种方法可以大
大节省内存,同时保证误差控制在一定范围内。目前用于基数计数的概率算法包
括 :
Linear Counting(LC) :早期的基数估计算法, LC 在空间复杂度方面并不算优
秀;
LogLog Counting(LLC) : LogLog Counting 相比于 LC 更加节省内存,空间复杂
度更低;
HyperLogLog Counting(HLL) : HyperLogLog Counting 是基于 LLC 的优化和改进,
在同样空间复杂度情况下,能够比 LLC 的基数估计误差更小。 举个例子来理解 HyperLogLog 算法,有一天 Fox 老师和 Mark 老师玩抛硬币
的游戏,规则是 Mark 老师负责抛硬币,每次抛的硬币可能正面,可能反面,每
当抛到正面为一回合, Mark 老师可以自己决定进行几个回合。最后需要告诉 Fox
老师最长的那个回合抛了多少次以后出现了正面,再由 Fox 老师来猜 Mark 老师
一共进行了几个回合。
进行了 n 次,比如上图:
第一次 : 抛了 3 次才出现正面,此时 k=3 , n=1
第二次试验 : 抛了 2 次才出现正面,此时 k=2 , n=2
第三次试验 : 抛了 4 次才出现正面,此时 k=4 , n=3
............
第 n 次试验:抛了 7 次才出现正面,此时我们估算, k=7 , n=n
k 是每回合抛到 1 (硬币的正面)所用的次数,我们已知的是最大的 k 值,
也就是 Mark 老师告诉 Fox 老师的数,可以用 k_max 表示。由于每次抛硬币的结
果只有 0 和 1 两种情况,因此,能够推测出 k_max 在任意回合出现的概率 ,并
由 kmax 结合极大似然估算的方法推测出 n 的次数 n = 2^(k_max) 。概率学把这
种问题叫做伯努利实验。
现在 Mark 老师已经完成了 n 个回合,并且告诉 Fox 老师最长的一次抛了 4
次, Fox 老师此时也胸有成竹,马上说出他的答案 16 ,最后的结果是: Mark 老
师只抛了 3 回合,
这三个回合中 k_max=4 ,放到公式中, Fox 老师算出 n=2^4 ,于是推测 Mark
老师抛了 16 个回合,但是 Fox 老师输了,要负责买奶茶一个星期。
所以这种预估方法存在较大误差,为了改善误差情况, HLL 中引入分桶平均
的概念。
同样举抛硬币的例子,如果只有一组抛硬币实验,显然根据公式推导得到的
实验次数的估计误差较大;如果 100 个组同时进行抛硬币实验,样本数变大,受
运气影响的概率就很低了,每组分别进行多次抛硬币实验,并上报各自实验过程
中抛到正面的抛掷次数的最大值,就能根据 100 组的平均值预估整体的实验次数
了。 分桶平均的基本原理是将统计数据划分为 m 个桶,每个桶分别统计各自的
k_max, 并能得到各自的基数预估值,最终对这些基数预估值求平均得到整体的
基数估计值。 LLC 中使用几何平均数预估整体的基数值,但是当统计数据量较小
时误差较大; HLL 在 LLC 基础上做了改进,采用调和平均数过滤掉不健康的统计
值。
什么叫调和平均数呢?举个例子
求平均工资:
A 的是 1000/ 月, B 的 30000/ 月。采用平均数的方式就是: (1000
-
- / 2 = 15500
采用调和平均数的方式就是: 2/(1/1000 + 1/30000) ≈ 1935.484
可见调和平均数比平均数的好处就是不容易受到大的数值的影响,比平均数
的效果是要更好的。
- / 2 = 15500
结合实例理解实现原理
现在我们和前面的业务场景进行挂钩:统计网页每天的 UV 数据。
1.转为比特串
通过 hash 函数,将数据转为比特串,例如输入 5 ,便转为: 101 ,字符串也
是一样。为什么要这样转化呢?
是因为要和抛硬币对应上,比特串中,0 代表了反面, 1 代表了正面,如果
一个数据最终被转化了 10010000 ,那么从右往左,从低位往高位看,我们可以
认为,首次出现 1 的时候,就是正面。
那么基于上面的估算结论,我们可以通过多次抛硬币实验的最大抛到正面的
次数来预估总共进行了多少次实验,同样也就可以根据存入数据中,转化后的出
现了 1 的最大的位置 k_max 来估算存入了多少数据。
2.分桶
分桶就是分多少轮。抽象到计算机存储中去,存储的是一个长度为 L 的位
(bit) 大数组 S ,将 S 平均分为 m 组,这个 m 组,就是对应多少轮,然后每
组所占有的比特个数是平均的,设为 P 。容易得出下面的关系:
L = S.length
L = m * p
以 K 为单位, S 占用的内存 = L / 8 / 1024
3、对应
假设访问用户 id 为: idn , n->0,1,2,3....
在这个统计问题中,不同的用户 id 标识了一个用户,那么我们可以把用户
的 id 作为被 hash 的输入。即:
hash(id) = 比特串
不同的用户 id ,拥有不同的比特串。每一个比特串,也必然会至少出现一
次 1 的位置。我们类比每一个比特串为一次伯努利试验。
现在要分轮,也就是分桶。所以我们可以设定,每个比特串的前多少位转为
10 进制后,其值就对应于所在桶的标号。假设比特串的低两位用来计算桶下标 志,总共有 4 个桶,此时有一个用户的 id 的比特串是: 10010110000 11 。它的所
在桶下标为: 1*2^1 + 1*2^0 = 3 ,处于第 3 个桶,即第 3 轮中。
上面例子中,计算出桶号后,剩下的比特串是: 100101 1 0000 ,从低位到高
位看,第一次出现 1 的位置是 5 。也就是说,此时第 3 个桶中, k_max = 5 。 5
对应的二进制是: 101 ,将 101 存入第 3 个桶。
模仿上面的流程,多个不同的用户 id ,就被分散到不同的桶中去了,且每
个桶有其 k_max 。然后当要统计出某个页面有多少用户点击量的时候,就是一
次估算。最终结合所有桶中的 k_max ,代入估算公式,便能得出估算值。
Redis 中的 HyperLogLog 实现
Redis 的实现中, HyperLogLog 占据 12KB( 占用内存为 =16834 * 6 / 8 / 1024 =
12K) 的大小,共设有 16384 个桶,即: 2^14 = 16384 ,每个桶有 6 位,每个桶
可以表达的最大数字是: 25+24+...+1 = 63 ,二进制为: 111 111 。
对于命令: pfadd key value
在存入时, value 会被 hash 成 64 位,即 64 bit 的比特字符串,前 14 位
用来分桶,剩下 50 位用来记录第一个 1 出现的位置。
之所以选 14 位 来表达桶编号是因为分了 16384 个桶,而 2^14 = 16384 ,
刚好地,最大的时候可以把桶利用完,不造成浪费。假设一个字符串的前 14 位
是: 00 0000 0000 0010 ( 从右往左看 ) ,其十进制值为 2 。那么 value 对应转化
后的值放到编号为 2 的桶。
index 的转化规则:
首先因为完整的 value 比特字符串是 64 位形式,减去 14 后,剩下 50 位,
假设极端情况,出现 1 的位置,是在第 50 位,即位置是 50 。此时 index = 50 。
此时先将 index 转为 2 进制,它是: 110010 。
因为 16384 个桶中,每个桶是 6 bit 组成的。于是 110010 就被设置到了
第 2 号桶中去了。请注意, 50 已经是最坏的情况,且它都被容纳进去了。那么
其他的不用想也肯定能被容纳进去。
因为 fpadd 的 key 可以设置多个 value 。例如下面的例子:
pfadd lgh golang
pfadd lgh python
pfadd lgh java
根据上面的做法,不同的 value ,会被设置到不同桶中去,如果出现了在同
一个桶的,即前 14 位值是一样的,但是后面出现 1 的位置不一样。那么比较
原来的 index 是否比新 index 大。是,则替换。否,则不变。
最终地,一个 key 所对应的 16384 个桶都设置了很多的 value 了,每个
桶有一个 k_max 。此时调用 pfcount 时,按照调和平均数进行估算,同时加以偏
差修正,便可以计算出 key 的设置了多少次 value ,也就是统计值,具体的估算
公式如下:
value 被转为 64 位的比特串,最终被按照上面的做法记录到每个桶中去。
64 位转为十进制就是: 2^64 , HyperLogLog 仅用了: 16384 * 6 /8 / 1024 =12K 存
储空间就能统计多达 2^64 个数。
同时,在具体的算法实现上, HLL 还有一个分阶段偏差修正算法。我们就不
做更深入的了解了。
事务
Redis 事务
大家应该对事务比较了解,简单地说,事务表示一组动作,要么全部执行,
要么全部不执行。例如在社交网站上用户 A 关注了用户 B ,那么需要在用户 A 的
关注表中加入用户 B ,并且在用户 B 的粉丝表中添加用户 A ,这两个行为要么全
部执行,要么全部不执行 , 否则会出现数据不一致的情况。
Redis 提供了简单的事务功能,将一组需要一起执行的命令放到 multi 和 exec
两个命令之间。 multi( ['mʌlti]) 命令代表事务开始, exec( 美 [ɪɡˈzek] ) 命令代表事务结
束,如果要停止事务的执行,可以使用 discard 命令代替 exec 命令即可。
它们之间的命令是原子顺序执行的, 例如下面操作实现了上述用户关注问题。
可以看到 sadd 命令此时的返回结果是 QUEUED ,代表命令并没有真正执行,
而是暂时保存在 Redis 中的一个缓存队列(所以 discard 也只是丢弃这个缓存队
列中的未执行命令,并不会回滚已经操作过的数据,这一点要和关系型数据库的
Rollback 操作区分开)。如果此时另一个客户端执行 sismember u:a:follow ub 返
回结果应该为 0 。
只有当 exec 执行后,用户 A 关注用户 B 的行为才算完成,如下所示 exec 返
回的两个结果对应 sadd 命令。
另一个客户端:
如果事务中的命令出现错误 ,Redis 的处理机制也不尽相同。
1 、命令错误
例如下面操作错将 set 写成了 sett ,属于语法错误,会造成整个事务无法执
行, key 和 counter 的值未发生变化 :
2. 运行时错误
例如用户 B 在添加粉丝列表时,误把 sadd 命令 ( 针对集合 ) 写成了 zadd 命令 ( 针
对有序集合 ) ,这种就是运行时命令,因为语法是正确的 :
可以看到 Redis 并不支持回滚功能, sadd u:c:follow ub 命令已经执行成功 , 开发人员需要自己修复这类问题。
有些应用场景需要在事务之前,确保事务中的 key 没有被其他客户端修改过,
才执行事务,否则不执行 ( 类似乐观锁 ) 。 Redis 提供了 watch 命令来解决这类问
题。
可以看到"客户端 -1 "在执行 multi 之前执行了 watch 命令,"客户端 -2 "
在"客户端 -1 "执行 exec 之前修改了 key 值,造成客户端 -1 事务没有执行 (exec
结果为 nil) 。
Redis 客户端中的事务使用代码参见:
cn.tuling.redis.adv.RedisTransaction
Pipeline 和事务的区别
简单来说,
1、 pipeline 是客户端的行为,对于服务器来说是透明的,可以认为服务器无
法区分客户端发送来的查询命令是以普通命令的形式还是以 pipeline 的形式发送
到服务器的;
2 而事务则是实现在服务器端的行为,用户执行 MULTI 命令时,服务器会将
对应这个用户的客户端对象设置为一个特殊的状态,在这个状态下后续用户执行
的查询命令不会被真的执行,而是被服务器缓存起来,直到用户执行 EXEC 命令
为止,服务器会将这个用户对应的客户端对象中缓存的命令按照提交的顺序依次
执行。
3、应用 pipeline 可以提服务器的吞吐能力,并提高 Redis 处理查询请求的能
力。
但是这里存在一个问题,当通过 pipeline 提交的查询命令数据较少,可以被
内核缓冲区所容纳时, Redis 可以保证这些命令执行的原子性。然而一旦数据量
过大,超过了内核缓冲区的接收大小,那么命令的执行将会被打断,原子性也就
无法得到保证。因此 pipeline 只是一种提升服务器吞吐能力的机制,如果想要命
令以事务的方式原子性的被执行,还是需要事务机制,或者使用更高级的脚本功
能以及模块功能。
4、可以将事务和 pipeline 结合起来使用,减少事务的命令在网络上的传输
时间,将多次网络 IO 缩减为一次网络 IO 。
Redis 提供了简单的事务,之所以说它简单,主要是因为它不支持事务中的
回滚特性 , 同时无法实现命令之间的逻辑关系计算,当然也体现了 Redis 的" keep
it simple "的特性。
Redis 7.0****前瞻
2022 年 2 月初, Redis 7.0 迎来了首个候选发布( RC )版本。这款内存键值
数据库迎来了"重大的性能优化"和其它功能改进,性能优化包括降低写入时复
制内存的开销、提升内存效率,改进 fsync 来避免大量的磁盘写入和优化延迟表
现。
Redis 7.0-rc1 的其它一些变动,包括将" Redis 函数"作为新的服务器端脚
本功能,细粒度 / 基于键的权限、改进子命令处理 / Lua 脚本 / 各种新命令。
此外也提供了一些安全改进。
我们从分析 Redis 主从复制中的内存消耗过多和堵塞问题,以及 Redis 7.0
( 尚未发布 ) 的共享复制缓冲区方案是如何解决这些问题的。
Redis 主从复制原理
我们先简单回顾一下 Redis 主从复制的基本原理。 Redis 的主从复制主要分
为两种情况:
全量同步
主库通过 fork 子进程产生内存快照,然后将数据序列化为 RDB 格式同步
到从库,使从库的数据与主库某一时刻的数据一致。
命令传播
当从库与主库完成全量同步后,进入命令传播阶段,主库将变更数据的命令
发送到从库,从库将执行相应命令,使从库与主库数据持续保持一致。
Redis****复制缓存区相关问题分析
多从库时主库内存占用过多
如上图所示,对于 Redis 主库,当用户的写请求到达时,主库会将变更命
令分别写入所有从库复制缓冲区( OutputBuffer) ,以及复制积压区
(ReplicationBacklog) 。全量同步时依然会执行该逻辑,所以在全量同步阶段经常
会触发 client-output-buffer-limit ,主库断开与从库的连接,导致主从同步失败,
甚至出现循环持续失败的情况。
该实现一个明显的问题是内存占用过多,所有从库的连接在主库上是独立的,
也就是说每个从库 OutputBuffer 占用的内存空间也是独立的,那么主从复制消
耗的内存就是所有从库缓冲区内存大小之和。如果我们设定从库的
client-output-buffer-limit 为 1GB ,如果有三个从库,则在主库上可能会消耗 3GB
的内存用于主从复制。另外,真实环境中从库的数量不是确定的,这也导致 Redis
实例的内存消耗不可控。
OutputBuffer 拷贝和释放的堵塞问题
Redis 为了提升多从库全量复制的效率和减少 fork 产生 RDB 的次数,会尽
可能的让多个从库共用一个 RDB ,从代码 (replication.c) 上看: 当已经有一个从库触发 RDB BGSAVE 时,后续需要全量同步的从库会共享
这次 BGSAVE 的 RDB ,为了从库复制数据的完整性,会将之前从库的
OutputBuffer 拷贝到请求全量同步从库的 OutputBuffer 中。
其中的 copyClientOutputBuffer 可能存在堵塞问题,因为 OutputBuffer 链表
上的数据可达数百 MB 甚至数 GB 之多,对其拷贝可能使用百毫秒甚至秒级的
时间,而且该堵塞问题没法通过日志或者 latency 观察到,但对 Redis 性能影响
却很大。
同样地,当 OutputBuffer 大小触发 limit 限制时, Redis 就是关闭该从库链
接,而在释放 OutputBuffer 时,也需要释放数百 MB 甚至数 GB 的数据,其耗
时对 Redis 而言也很长。
ReplicationBacklog 的限制
我们知道复制积压缓冲区 ReplicationBacklog 是 Redis 实现部分重同步的
基础,如果从库可以进行增量同步,则主库会从 ReplicationBacklog 中拷贝从库
缺失的数据到其 OutputBuffer 。拷贝的数据量最大当然是 ReplicationBacklog 的
大小,为了避免拷贝数据过多的问题,通常不会让该值过大,一般百兆左右。但
在大容量实例中,为了避免由于主从网络中断导致的全量同步,又希望该值大一
些,这就存在矛盾了。
而且如果重新设置 ReplicationBacklog 大小时,会导致 ReplicationBacklog
中的内容全部清空,所以如果在变更该配置期间发生主从断链重连,则很有可能
导致全量同步。
Redis7.0 共享复制缓存区的设计与实现
简述
每个从库在主库上单独拥有自己的 OutputBuffer ,但其存储的内容却是一样
的,一个最直观的想法就是主库在命令传播时,将这些命令放在一个全局的复制
数据缓冲区中,多个从库共享这份数据,不同的从库对引用复制数据缓冲区中不
同的内容,这就是『共享复制缓存区』方案的核心思想。实际上,复制积压缓冲
区( ReplicationBacklog )中的内容与从库 OutputBuffer 中的数据也是一样的,所 以该方案中, ReplicationBacklog 和从库一样共享一份复制缓冲区的数据,也避
免了 ReplicationBacklog 的内存开销。
『共享复制缓存区』方案中复制缓冲区 (ReplicationBuffer) 的表示采用链表
的表示方法,将 ReplicationBuffer 数据切割为多个 16KB 的数据块
(replBufBlock) ,然后使用链表来维护起来。为了维护不同从库的对
ReplicationBuffer 的使用信息,在 replBufBlock 中存在字段:
refcount : block 的引用计数
id : block 的唯一标识,单调递增的数值
repl_offset : block 开始的复制偏移
ReplicationBuffer 由多个 replBufBlock 组成链表,当 复制积压区 或从库对
某个 block 使用时,便对正在使用的 replBufBlock 增加引用计数,上图中可以
看到,复制积压区正在使用的 replBufBlock refcount 是 1 ,从库 A 和 B 正在使
用的 replBufBlock refcount 是 2 。当从库使用完当前的 replBufBlock (已经将数
据发送给从库)时,就会对其 refcount 减 1 而且移动到下一个 replBufBlock ,
并对其 refcount 加 1 。
堵塞问题和限制问题的解决
多从库消耗内存过多的问题通过共享复制缓存区方案得到了解决,对于
OutputBuffer 拷贝和释放的堵塞问题和 ReplicationBacklog 的限制问题是否解决
了呢?
首先来看 OutputBuffer 拷贝和释放的堵塞问题问题, 这个问题很好解决,
因为 ReplicationBuffer 是个链表实现,当前从库的 OutputBuffer 只需要维护共
享 ReplicationBuffer 的引用信息即可。所以无需进行数据深拷贝,只需要更新引
用信息,即对正在使用的 replBufBlock refcount 加 1 ,这仅仅是一条简单的赋值
操作,非常轻量。 OutputBuffer 释放问题呢?在当前的方案中释放从库
OutputBuffer 就变成了对其正在使用的 replBufBlock refcount 减 1 ,也是一条赋
值操作,不会有任何阻塞。
对于 ReplicationBacklog 的限制问题也很容易解决了,因为
ReplicatonBacklog 也只是记录了对 ReplicationBuffer 的引用信息,对
ReplicatonBacklog 的拷贝也仅仅成了找到正确的 replBufBlock ,然后对其
refcount 加 1 。这样的话就不用担心 ReplicatonBacklog 过大导致的拷贝堵塞问
题。而且对 ReplicatonBacklog 大小的变更也仅仅是配置的变更,不会清掉数据。 ReplicationBuffer 的裁剪和释放
ReplicationBuffer 不可能无限增长, Redis 有相应的逻辑对其进行裁剪,简
单来说, Redis 会从头访问 replBufBlock 链表,如果发现 replBufBlock refcount
为 0 ,则会释放它,直到迭代到第一个 replBufBlock refcount 不为 0 才停止。
所以想要释放 ReplicationBuffer ,只需要减少相应 ReplBufBlock 的 refcount ,会
减少 refcount 的主要情况有:
1、当从库使用完当前的 replBufBlock 会对其 refcount 减 1 ;
2、当从库断开链接时会对正在引用的 replBufBlock refcount 减 1 ,无论是
因为超过 client-output-buffer-limit 导致的断开还是网络原因导致的断开;
3、当 ReplicationBacklog 引用的 replBufBlock 数据量超过设置的该值大小
时,会对正在引用的 replBufBlock refcount 减 1 ,以尝试释放内存;
不过当一个从库引用的 replBufBlock 过多,它断开时释放的 replBufBlock
可能很多,也可能造成堵塞问题,所以 Redis7 里会限制一次释放的个数,未及
时释放的内存在系统的定时任务中渐进式释放。
数据结构的选择
当从库尝试与主库进行增量重同步时,会发送自己的 repl_offset ,主库在每
个 replBufBlock 中记录了该其第一个字节对应的 repl_offset ,但如何高效地从
数万个 replBufBlock 的链表中找到特定的那个?
从链表的性质我们知道,链表只能直接从头到位遍历链表查找对应的
replBufBlock ,这个操作必然会耗费较多时间而堵塞服务。有什么改进的思路?
可以额外使用一个链表用于索引固定区间间隔的 replBufBlock ,每 1000 个
replBufBlock 记录一个索引信息,当查找 repl_offset 时,会先从索引链表中查
起,然后再查找 replBufBlock 链表,这个就类似于跳表的查找实现。 Redis 的 zset
就是跳表的实现:
在极端场景下可能会查找超过千次,有 10 毫秒以上的延迟,所以 Redis 7
没有使用这种数据结构。
最终使用 rax 树实现了对 replBufBlock 固定区间间隔的索引,每 64 个记
录一个索引点。一方面, rax 索引占用的内存较少;另一方面,查询效率也是非
常高,理论上查找比较次数不会超过 100 ,耗时在 1 毫秒以内。 rax 树
Redis 中还有其他地方使用了 Rax 树,比如我们前面学习过的 streams 这个
类型里面的 consumer group( 消费者组 ) 的名称还有和 Redis 集群名称存储。
RAX 叫做基数树(前缀压缩树),就是有相同前缀的字符串,其前缀可以作
为一个公共的父节点,什么又叫前缀树?
Trie 树
即字典树,也有的称为前缀树,是一种树形结构。广泛应用于统计和排序大
量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。
它的优点是最大限度地减少无谓的字符串比较,查询效率比较高。
Trie 的核心思想是空间换时间,利用字符串的公共前缀来降低查询时间的开
销以达到提高效率的目的。
先看一下几个场景问题:
1.我们输入 n 个单词,每次查询一个单词,需要回答出这个单词是否在之前
输入的 n 单词中出现过。
答:当然是用 map 来实现。
2.我们输入 n 个单词,每次查询一个单词的前缀,需要回答出这个前缀是之
前输入的 n 单词中多少个单词的前缀?
答:还是可以用 map 做,把输入 n 个单词中的每一个单词的前缀分别存入
map 中,然后计数,这样的话复杂度会非常的高。若有 n 个单词,平均每个单词
的长度为 c,那么复杂度就会达到 nc。
因此我们需要更加高效的数据结构,这时候就是 Trie 树的用武之地了。现在
我们通过例子来理解什么是 Trie 树。现在我们对 cat 、 cash 、 apple 、 aply 、 ok 这
几个单词建立一颗 Trie 树。
从图中可以看出:
- 每一个节点代表一个字符
- 有相同前缀的单词在树中就有公共的前缀节点。
- 整棵树的根节点是空的。
- 每个节点结束的时候用一个特殊的标记来表示,这里我们用 -1 来表示结束, 从根节点到-1 所经过的所有的节点对应一个英文单词。
- 查询和插入的时间复杂度为 O(k) , k 为字符串长度,当然如果大量字符串 没有共同前缀时还是很耗内存的。
所以,总的来说,Trie 树把很多的公共前缀独立出来共享了。这样避免了很
多重复的存储。想想字典集的方式,一个个的 key 被单独的存储,即使他们都有
公共的前缀也要单独存储。相比字典集的方式, Trie 树显然节省更多的空间。
Trie 树其实依然比较浪费空间,比如我们前面所说的"然如果大量字符串没
有共同前缀时"。
比如这个字符串列表:"deck", "did", "doe", "dog", "doge" , "dogs" 。 "deck" 这
一个分支,有没有必要一直往下来拆分吗?还是 "did" ,存在着一样的问题。像这
样的不可分叉的单支分支,其实完全可以合并,也就是压缩。
Radix 树:压缩后的 Trie 树
所以 Radix 树就是压缩后的 Trie 树,因此也叫压缩 Trie 树。比如上面的字符
串列表完全可以这样存储:
同时在具体存储上, Radix 树的处理是以 bit (或二进制数字)来读取的。一
次被对比 r 个 bit 。
比如 "dog", "doge" , "dogs" ,按照人类可读的形式, dog 是 dogs 和 doge 的子
串。
但是如果按照计算机的二进制比对:
dog: 01100100 01101111 01100111
doge: 01100100 01101111 01100111 011 0 0101
dogs: 01100100 01101111 01100111 011 1 0011
可以发现 dog 和 doge 是在第二十五位的时候不一样的。 dogs 和 doge 是在
第二十八位不一样的,按照位的比对的结果, doge 是 dogs 二进制子串,这样在
存储时可以进一步压缩空间。