《玩转Redis》系列文章主要讲述Redis的基础及中高级应用。本文是《玩转Redis》系列第【10】篇,最新系列文章请前往公众号“zxiaofan”查看,或百度搜索“玩转Redis zxiaofan”即可。
本文关键字:玩转Redis、HyperLogLog原理、基数缓存、密集存储结构和稀疏存储结构;
大纲
名词解释:
1、基数:集合中不重复元素的个数;
2、HLL:HyperLogLog 的简写;
概要
上文《玩转Redis-HyperLogLog统计微博日活月活》介绍了牛逼哄哄的HyperLogLog,传入元素数量或体积非常大时,HLL所需空间固定且很小。12kb内存可计算接近 2^64 个不同元素的基数。如此厉害,怎能不继续深入探索呢?
PS:看完这篇文章,你会发现HyperLogLog能统计的基数值实际并不是 2^64 。
介绍HyperLogLog底层原理前,我们先了解下伯努利试验(援引百度百科)。
伯努利试验(Bernoulli experiment)是在同样的条件下重复地、相互独立地进行的一种随机试验。
其特点是该随机试验只有两种可能结果:发生或者不发生。我们假设该项试验独立重复地进行了n次,那么就称这一系列重复独立的随机试验为n重伯努利试验,或称为伯努利概型。
以抛硬币为例,每次抛硬币出现正面的概率都是50%。假设一直抛硬币,直到出现正面,则一个伯努利试验结束;
假设第1次伯努利试验抛硬币的次数为K1,第n次伯努利试验抛硬币的次数为Kn。我们记录这n次伯努利试验的最大抛硬币次数为K_max;
结合极大似然估算法,我们能得到估算关系 n = 2^(K_max)。当然,用这种方式计算的结果会有较大的偏差,HyperLogLog内部运用了 分桶平均、调和平均、偏差修正 等一系列数学优化方案,涉及较复杂的数学算法,此处暂不深入研究。
下图的计算公式引自网络:
HyperLogLog总体分为2大部分:对象头、寄存器(桶)。
HYLL | E | N/U | Cardin. |
---|---|---|---|
4 字节 | 1 字节 | 3 字节 | 8 字节 |
HLL源码中结构体定义如下:
struct hllhdr {
char magic[4]; /* "HYLL",对应源码注释中的HYLL*/
uint8_t encoding; /* HLL_DENSE or HLL_SPARSE.稀疏/密集存储结构标记,对应源码注释中的E */
uint8_t notused[3]; /* 保留3字节备用,目前未使用,值为0,对应源码注释中的N/U */
uint8_t card[8]; /* Cached cardinality(基数缓存), little endian. 对应源码注释中的Cardin,cardinality<基数> */
uint8_t registers[]; /* Data bytes. */
};
HyperLogLog底层结构有 dense(密集存储结构) 和 sparse(稀疏存储结构) 2种,无论哪种存储结构,都有一个 16 byte 的对象头(header);
从定义看,magic占用 4 byte ,存储的是“HYLL”标记,那么它究竟有什么用呢?使用过HyperLogLog的同学肯定对下面这个异常不陌生;
127.0.0.1:6379> set key1 e1
OK
127.0.0.1:6379> pfadd key1
(error) WRONGTYPE Key is not a valid HyperLogLog string value.
127.0.0.1:6379> pfadd hll1 a
(integer) 1
127.0.0.1:6379> get hll1
"HYLL\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x80q\xa6\x84NW"
当我们对一个普通字符串使用pfadd指令时,会提示string value 不是HyperLogLog类型;
由于HyperLogLog底层结构也是string,那么Redis如何区分一个string仅仅是基础的字符串还是HyperLogLog呢?
原来在执行pf相关指令前,会调用方法 isHLLObjectOrReply() 检查 value对象是否是 HyperLogLog 结构,如果不是则说明不是HyperLogLog结构,就会返回上述WRONGTYPE异常。
isHLLObjectOrReply() 其中一项校验就是检查 value 的magic魔术字符串是否是"HYLL",不是则说明不是HyperLogLog结构。
细心的同学一定发现了,使用get指令获取HyperLogLog对象的值时,对象头是以“HYLL”开头的。
注意:
如果pfcount 一个 不存在的 key,返回结果是0;
encoding:单字节编码,HyperLogLog数据类型时值为0或1:
先前提到的isHLLObjectOrReply()方法在检查value对象是否是 HyperLogLog 结构时,也会检查 encoding值:
# HyperLogLog源码-校验encoding值
# HLL_MAX_ENCODING定义值为1,有趣的是此处不是用的( encoding !=0 || encoding != 1),而是直接(encoding > 1);
if (hdr->encoding > HLL_MAX_ENCODING) goto invalid;
notused:未使用的3个字节,目前未使用,值为0 。
Cardin:The “Cardin.” field is a 64 bit integer。8字节的 基数缓存,正因为有了基数缓存,才让pfcount更加高效;
在执行pfcount指令时,会返回HLL对象的基数,那么这个基数是如何存储及返回的呢?让我们一探究竟:
HLL的对象头中有个Cardin基数缓存,存储着HLL的基数,便于快速返回基数值。
HyperLogLog 的基数值是由 16384 个寄存器(寄存器又可叫做桶,每个桶6bit)的基数值进行调和平均并修正而来。如果有桶位基数值改变,则将基数缓存标记置为过期,需要特别注意的是,此时并不会重新计算基数值,需等执行pfcount指令时才重新计算并刷新缓存;
// 1左移14位(2^14),值为 16384;
#define HLL_P 14 /* The greater is P, the smaller the error. */
#define HLL_REGISTERS (1</* With P=14, 16384 registers. */
执行pfcount指令时,先判断基数缓存是否过期,未过期则直接返回缓存值,已过期则重新计算并缓存后再返回;
但是这个基数并不一定是最新的,如果card最高位是0,则说明缓存有效。card共8位即64字节,1bit标记缓存是否有效,63bit存储基数值;
// pfcount 核心逻辑 @zxiaofan
void pfcountCommand(client *c) {
if (多key) {
// 合并多个HLL后计算基数并返回(注意:不是基数值之和);
}
if (单key且value不存在) {
// 单key且value不存在返回0;
} else { // 单key且value存在
if (HLL_VALID_CACHE(hdr)) {// 缓存有效
// 直接返回基数缓存值;
} else {// 缓存无效
// 计算基数值;
// 更新基数缓存标记及基数缓存值;
}
}
}
// HLL_INVALIDATE_CACHE:将基数缓存置为失效状态(最高位设置为1);
#define HLL_INVALIDATE_CACHE(hdr) (hdr)->card[7] |= (1<<7)
// HLL_VALID_CACHE:校验缓存是否有效;
// 最高位如果为0,表示缓存有效,pfcount查询基数时直接返回缓存值;
#define HLL_VALID_CACHE(hdr) (((hdr)->card[7] & (1<<7)) == 0)
pfadd key value
HLL有2^14 = 16384个桶,每个桶有 6 bit;
pfadd时需要计算2个值:桶位(寄存器编号) regnum、桶值(寄存器计数值)count;
让我们看个实际的例子:
假设hash值是 :{此处省略45位}01100 00000000000101
前文我们提到,HLL底层有2种存储结构:稀疏和密集。
密集存储结构相对很简单,由连续的16384个寄存器(桶)拼接而成,每个桶6 bit。
但由于一个桶只有6 bit,在计算一个桶的计数值时,可能需要 2个桶拼接计算,即会涉及到2个字节。
需要注意的是,HHL的字节都是左低位右高位,我们平时计算使用的字节都是左高位右低位(如二进制 0101 表示 十进制 5),所以需要倒置后进行计算。我们来个简单的图理解下,寄存器registers表示HLL的存储单元,字节buffer是底层存储结构。
以上图例仅为方便理解,HLL实际存储计算方式更加复杂,有兴趣的同学可以看看以下源码:
/* Store the value of the register at position 'regnum' into variable 'target'.
* 'p' is an array of unsigned bytes. */
/* 获取密集存储结构指定寄存器的值
target:变量,用户存放指定寄存器编号regnum目前的计数值;
p:寄存器;
regnum:寄存器编号;
*/
#define HLL_BITS 6 /* Enough to count up to 63 leading zeroes. */
#define HLL_REGISTER_MAX ((1<
#define HLL_DENSE_GET_REGISTER(target,p,regnum) do { \
uint8_t *_p = (uint8_t*) p; \
unsigned long _byte = regnum*HLL_BITS/8; \
unsigned long _fb = regnum*HLL_BITS&7; \
unsigned long _fb8 = 8 - _fb; \
unsigned long b0 = _p[_byte]; \
unsigned long b1 = _p[_byte+1]; \
target = ((b0 >> _fb) | (b1 << _fb8)) & HLL_REGISTER_MAX; \
} while(0)
/* Set the value of the register at position 'regnum' to 'val'.
* 'p' is an array of unsigned bytes. */
/* 密集存储结构 设置寄存器指定编号(桶位)的值
p:寄存器;
regnum:寄存器编号;
val:待设置的计数值;
*/
#define HLL_DENSE_SET_REGISTER(p,regnum,val) do { \
uint8_t *_p = (uint8_t*) p; \
unsigned long _byte = regnum*HLL_BITS/8; \
unsigned long _fb = regnum*HLL_BITS&7; \
unsigned long _fb8 = 8 - _fb; \
unsigned long _v = val; \
_p[_byte] &= ~(HLL_REGISTER_MAX << _fb); \
_p[_byte] |= _v << _fb; \
_p[_byte+1] &= ~(HLL_REGISTER_MAX >> _fb8); \
_p[_byte+1] |= _v >> _fb8; \
} while(0)
为什么会有稀疏存储结构呢?试想一下,在HLL初始化后,仅少量数据存入,此时大量的寄存器register(桶)的值都是0,大量的6bit寄存器空间也就完全浪费了。
Redis采用了如下opcode(指令)极致优化空间占用:
现在回过头来看看先前的HLL结构图是不是就更清晰了呢。
转换条件有2个,满足任意一个就会立即转换:
注意:
# this limit, it is converted into the dense representation.
#
# A value greater than 16000 is totally useless, since at that point the
# dense representation is more memory efficient.
#
# The suggested value is ~ 3000 in order to have the benefits of
# the space efficient encoding without slowing down too much PFADD,
# which is O(N) with the sparse encoding. The value can be raised to
# ~ 10000 when CPU is not a concern, but space is, and the data set is
# composed of many HyperLogLogs with cardinality in the 0 - 15000 range.
hll-sparse-max-bytes 3000
首先补充一个非常实用的网站,可以在线动态观察HyperLogLog算法。
http://content.research.neustar.biz/blog/hll.html
需要注意的是,此网站 value hash后的值是24位,不是HyperLogLog MurmurHash64A()后的64位,不过也不影响观察学习其原理了。
HyperLogLog实际使用了 2^14 = 16384 个桶,每个桶 6bit,最大占用内存即 2^14 * 6 / 8 = 12288 Byte = 12 KB(不多不少刚好12KB)。
补充1:HyperLogLog实际最大存储空间
HyperLogLog实际占用的空间为 【header】 + 【寄存器所占用的空间】,所以HyperLogLog实际占用空间会比12KB略多一点儿。
补充2:HyperLogLog实际最小存储空间
HyperLogLog最小存储空间是多少呢?当HHL基数为1时,1字节表示基数,XZERO占用2字节表示余下所有的零值计数器,所以 HyperLogLog最小存储空间是3字节略多一点儿(算上header占用)。
MurmurHash64A算法对value进行hash,结果为64bit的hash值,64位二进制最大表示的十进制是 2^64 ,所以最大可统计基数值就是 2^64 了吗?
但还需要注意的是,header中8字节的 基数缓存Cardin,1位表示缓存是否有效,63位表示基数值,所以HyperLogLog实际能统计的最大数量是 2^63。
ZERO:表示连续多少个桶计数为0(桶数最大64);
XZERO:表示连续多少个桶计数为0(桶数最大16384);
因为1个寄存器6bit,最多可表示 2^6 = 64 个 零值计数器,所以需要XZERO来表示更多的零值计数器。
【需要才计算】
在计算基数缓存时,HLL并没有在每次寄存器更新时就计算,而是执行pfcount指令时才计算基数值并缓存。
HLL不立即计算本质上是因为pfadd的执行频率远高于pfcount,所以我们也不能一味的采用此种思想。
适合的才是最好的。
【存储结构转换】
为了最大化节省内存空间,也算是煞费苦心了。
学原汁原味的读音,可前往YouTube的 Redis University 频道拜听《Redis HyperLogLog Explained》
读音:[ˈhaɪpərlɔːɡlɔːɡ]。
恭喜你读完本篇文章了,给你赞一个,HyperLogLog的底层原理你get到了吗。
HyperLogLog为什么有如此大的魅力,本质是数学的魅力。
基础科学任重而道远。
搞芯片光砸钱不行还要砸数学家物理学家化学家。(任正非)
【玩转Redis系列文章 往期精选 @zxiaofan】
《玩转Redis-京东签到领京豆如何实现》
《玩转Redis-老板带你深入理解分布式锁》
《玩转Redis-如何高效访问Redis中的海量数据》
《玩转Redis-高级程序员必知的Key命令》
《玩转Redis-研发也应该知道的Connection命令》
《玩转Redis-Redis高级数据结构及核心命令-ZSet》
祝君好运!
Life is all about choices!
将来的你一定会感激现在拼命的自己!
【CSDN】【GitHub】【OSCHINA】【掘金】【语雀】【微信公众号】