Bitmaps
- Bitmaps 并不是实际的数据类型,而是定义在String类型上的一个面向字节操作的集合。因为字符串是二进制安全的块,他们的最大长度是512M,最适合设置成2^32个不同字节。
- bitmaps的位操作分成两类:
1.固定时间的单个位操作,比如把String的某个位设置为1或者0,或者获取某个位上的值
2.对于一组位的操作,对给定的bit范围内,统计设定值为1的数目(比如人口统计)。
- bitmaps最大的优势是在存储数据时可以极大的节省空间,比如在一个项目中采用自增长的id来标识用户,就可以仅用512M的内存来记录40亿用户的信息(比如用户是否希望收到新的通知,用1和0标识)
简单来说bitmaps就是一个长度可变的bit数组。每个位只能存储0或1。
命令
SETBIT key offset value
时间复杂度:O(1);
返回值:在offset处原来的bit值;
该命令的作用:设置key的value(字符串)在offset处的bit值(0/1)。
对于一个不存在的key,首次使用setbit命令在offset=2^32-1的位置(最后一个bit位)设置value时,或者对于一个存在的key,并非首次使用setbit命令在 offset=2^32-1 的位置设置value但之前设置offset的位置比较小时, Redis需要立即分配所有内存,这有可能会导致服务阻塞一会。一次分配之后,后续相同的 key 不会再有分配开销
比如存储2020-07-01该平台的用户活跃情况,key=active:2020-07-01;
127.0.0.1:6379> setbit active:2020-07-01 666 1
#userId=666的用户登陆,这是今天登陆的第一个用户。
(integer) 0
127.0.0.1:6379> setbit active:2020-07-01 100000000 1
#userId=100000000的用户登陆,这是今天第二个登陆的用户。
(integer) 0
127.0.0.1:6379> setbit active:2020-07-01 33 1
(integer) 0
127.0.0.1:6379> setbit active:2020-07-01 666 1
(integer) 0
127.0.0.1:6379> setbit active:2020-07-01 100000 1
(integer) 0
第一个用户登陆时,会新建一个String,这个String被分配的空间时667bit,这是offset为0到665的用户未登录,值为0;
第二个用户登陆时,这个String已经存在,这时会被分配666到100000000的字节空间,这个时候就会出现短时间阻塞。
GETBIT key offset
返回值:在offset处的bit值;
该命令的作用:返回key对应的string在offset处的bit值。
当offset超出了字符串长度的时候,这个字符串就被假定为由0比特填充的连续空间。当key不存在的时候,它就认为是一个空字符串,所以offset总是超出范围,然后value也被认为是由0比特填充的连续空间。
127.0.0.1:6379> setbit active:2020-07-01 666
(integer) 1
BITCOUNT key [start end]
时间复杂度:O(N);
该命令的作用:统计字符串被设置为1的bit数;
返回值:被设置为 1 的位的数量。
start 和 end 参数指的是 Byte,不是 bit,官网介绍在 7.0 版本之后才可以指定 Byte 或 bit。
一般情况下,给定的整个字符串都会被进行计数,通过指定额外的 start 或 end 参数,可以让计数只在特定的位上进行。start 和 end 参数的设置和 GETRANGE 命令类似,都可以使用负数值:比如 -1 表示最后一个位,而 -2 表示倒数第二个位,以此类推。不存在的 key 被当成是空字符串来处理,因此对一个不存在的 key 进行 BITCOUNT 操作,结果为 0 。
比如,统计2020-07-01该平台有多少用户活跃(登陆):
127.0.0.1:6379> bitcount active:2020-07-01 0 -1
(integer) 342342326
有342342326个用户登陆过。
BITOP
时间复杂度: O(n)
BITOP operation destkey key [key ...]
在多个键(包含字符串值)之间执行按位运算并将结果存储在目标键中
其中 operation 有 :AND,OR,XOR 和 NOT
destkey 是指目标 key,将后面的多个 key 进行按位操作后,储存在 destkey 中
127.0.0.1:6379> bitop and dk1 k1 k2 k3
(integer) 3
127.0.0.1:6379> get dk1
"a\x00\x00"
将 k1 ,k2,k3,进行按位与之后结果储存在 dk1 中,dk1 后面的 \x00 是十六进制, a\x00\x00 转换成二进制就是:0110 0001 0000 0000 0000 0000。
BITPOS
时间复杂度: O(N)
BITPOS key bit [start [end [BYTE|BIT]]]
返回字符串中设置为 1 或 0 的第一位的位置。
示例
127.0.0.1:6379> setbit k1 4 1
(integer) 0
127.0.0.1:6379> setbit k1 13 1
(integer) 0
127.0.0.1:6379> bitpos k1 1
(integer) 4
127.0.0.1:6379> bitpos k1 1 0 0
(integer) 4
127.0.0.1:6379> bitpos k1 1 1 1
(integer) 13
1、这里的 start 、end 参数指的是 Byte,在 7.0 版本后可以指定 Byte 或 bit。
2、查询 1 时,不存在的 key 或者 对应范围的字符串全是 0 ,返回 -1。
3、查询 0 时,有三种特殊情况:
k2 = 1111 1111 , k3 不存在
---------------------------
// 不指定范围或仅指定 start,且值全是1,这时候会查出来最右侧的1的位置 + 1,可以视为右侧填充了0
127.0.0.1:6379> BITPOS k2 0
(integer) 8
---------------------------
// 不指定范围或仅指定 start,且key不存在,返回0
127.0.0.1:6379> BITPOS k3 0
(integer) 0
--------------------------
// 指定范围,且范围内没有0,返回 -1
127.0.0.1:6379> BITPOS k2 1 1
(integer) -1
布隆过滤器
之前的布隆过滤器可以使用Redis中的位图操作实现,直到Redis4.0版本提供了插件功能,Redis官方提供的布隆过滤器才正式登场。布隆过滤器作为一个插件加载到Redis Server中,就会给Redis提供了强大的布隆去重功能。
在Redis中,布隆过滤器有两个基本命令,分别是:
bf.add
:添加元素到布隆过滤器中,类似于集合的sadd
命令,不过bf.add
命令只能一次添加一个元素,如果想一次添加多个元素,可以使用bf.madd
命令。
bf.exists
:判断某个元素是否在过滤器中,类似于集合的sismember
命令,不过bf.exists
命令只能一次查询一个元素,如果想一次查询多个元素,可以使用bf.mexists
命令。
> bf.add one-more-filter fans1
(integer) 1
> bf.add one-more-filter fans2
(integer) 1
> bf.add one-more-filter fans3
(integer) 1
> bf.exists one-more-filter fans1
(integer) 1
> bf.exists one-more-filter fans2
(integer) 1
> bf.exists one-more-filter fans3
(integer) 1
> bf.exists one-more-filter fans4
(integer) 0
> bf.madd one-more-filter fans4 fans5 fans6
1) (integer) 1
2) (integer) 1
3) (integer) 1
> bf.mexists one-more-filter fans4 fans5 fans6 fans7
1) (integer) 1
2) (integer) 1
3) (integer) 1
4) (integer) 0
在使用bf.add
命令添加元素之前,使用bf.reserve
命令创建一个自定义的布隆过滤器。bf.reserve
命令有三个参数,分别是:
key
:键
error_rate
:期望错误率,期望错误率越低,需要的空间就越大。
capacity
:初始容量,当实际元素的数量超过这个初始化容量时,误判率上升。
> bf.reserve one-more-filter 0.0001 1000000
OK
如果对应的key已经存在时,在执行bf.reserve
命令就会报错。如果不使用bf.reserve
命令创建,而是使用Redis自动创建的布隆过滤器,默认的error_rate
是 0.01,capacity
是 100。
布隆过滤器的error_rate
越小,需要的存储空间就越大,对于不需要过于精确的场景,error_rate
设置稍大一点也可以。布隆过滤器的capacity
设置的过大,会浪费存储空间,设置的过小,就会影响准确率,所以在使用之前一定要尽可能地精确估计好元素数量,还需要加上一定的冗余空间以避免实际元素可能会意外高出设置值很多。
假设已经有3个元素a、b和c,分别通过3个hash算法h1()、h2()和h2()计算,算出一个整数索引值,然后对位数组长度进行取模运算得到一个位置,然后对一个bit进行赋值,接下来假设需要判断d是否已经存在,那么也需要使用3个hash算法h1()、h2()和h2()对d进行计算,然后得到3个bit的值,恰好这3个bit的值为1,这就能够说明:d可能存在集合中。再判断e,由于h1(e)算出来的bit之前的值是0,那么说明:e一定不存在集合中:
GEO
Redis GEO
并不是一种新的数据结构,而是基于Sorted Set
实现的。我们在之前的文章中学过Sorted Set
结构,该结构保存的数据形式是key-score
,即一个元素对应一个分值,默认是根据分值排序的,且可以进行范围查询。
经度的范围是[-180,180],纬度的范围是[-90,90],当我们对经纬度进行编码时,先对经度和纬度分别进行GeoHash编码,然后再合并为一个编码值。
GeoHash的编码方法
对于经度或纬度来说,GeoHash会将其编码为一个N为的二进制值,其实就是通过N次的分区得到的,N可以自定义。下面是具体的逻辑:
- 第一次分区:我们把经度范围[-180,180]分为两个区间[-180,0) 和[0,180],简称为左右区间。看当前的经度值落在哪个区间中,如果在左区间,记为一次0,否则记为1,这样我们就得到一位编码值了。
- 第二次分区:假设第一次落在了[0,180]区间内,我们再把该区间分为两个区间[0,90) 和[90,180],然后再根据落在左右区间,得到一个0或者1的编码值。
......
- 重复N次之后,我们就得到了N个编码值。纬度也是一样的逻辑,可以得到N个编码值。
举个具体的例子,给定经纬度[120,40],N=5。
经度120:
无法复制加载中的内容
纬度40:
无法复制加载中的内容
分别得到了经度和纬度的N位编码值后,是如何合并为一个编码值的呢?
规则就是:最终编码值的长度是2N,其中偶数位上依次是经度的编码值,奇数位上依次是纬度的编码值(从0开始计数,0为偶数),示意图如下:
使用了GeoHash编码后,经纬度[120,40]就被编码成了1110011101,这个值就可以作为key对应的score值。
最后使用用0-9、b-z(去掉a, i, l, o)这32个字母进行base32编码,首先将11100 11101 00100 01111转成十进制,对应着28、29、4、15,十进制对应的编码就是wx4g。同理,将编码转换成经纬度的解码算法与之相反。
将二进制编码的结果填写到空间中,当将空间划分为四块时候,编码的顺序分别是左下角00,左上角01,右下脚10,右上角11,也就是类似于Z的曲线,当我们递归的将各个块分解成更小的子块时,编码的顺序是自相似的(分形),每一个子块也形成Z曲线,这种类型的曲线被称为Peano空间填充曲线。
由于GeoHash是将区域划分为一个个规则矩形,并对每个矩形进行编码,这样在查询附近POI信息时会导致以下问题,比如红色的点是我们的位置,绿色的两个点分别是附近的两个餐馆,但是在查询的时候会发现距离较远餐馆的GeoHash编码与我们一样(因为在同一个GeoHash区域块上),而较近餐馆的GeoHash编码与我们不一致。这个问题往往产生在边界处。
解决的思路很简单,我们查询时,除了使用定位点的GeoHash编码进行匹配外,还使用周围8个区域的GeoHash编码,这样可以避免这个问题。
现有的GeoHash算法使用的是Peano空间填充曲线,这种曲线会产生突变,造成了编码虽然相似但距离可能相差很大的问题,因此在查询附近餐馆时候,首先筛选GeoHash编码相似的POI点,然后进行实际距离计算。
相关命令
GEOADD
可用版本:>= 3.2.0
时间复杂度:对每个要添加的元素,时间复杂度为O(log(N)),N为已存在的元素数量
版本变化:6.2.0版本后新增了NX、XX、CH参数
命令格式
GEOADD key [NX|XX] [CH] longitude latitude member [longitude latitude member ...]
命令描述
- 将指定的空间元素(经度、纬度、元素名)添加到key对应的Sorted Set中,GeoHash编码会将经纬度转化为52位比特值
- 该命令的参数格式是固定的,即(longitude latitude member),经度要在纬度之前
-
GEOADD
坐标是有限的:非常接近两极的区域是无法被索引的。坐标被 EPSG:900913 / EPSG:3785 / OSGEO:41001 规范限制, 合法值如下:- 有效的经度介于 -180 度至 180 度之间
- 有效的纬度介于 -85.05112878 度至 85.05112878 度之间
- 当给定的经纬度超出上述合法范围时,会返回error
- Redis GEO 没有删除命令 GEODEL,因为底层使用的是Sorted Set,所以完全可以使用 ZREM 命令删除
可选参数
- XX: 只更新已存在的元素,不添加新元素
- NX: 只添加新元素,不更新已存在元素
- CH: 改变命令返回值的逻辑(CH是change的缩写)。命令默认返回添加新元素的个数,不包含更新已存在的元素;但是如果使用了CH 参数,命令返回被改变的元素数量,包括 添加新元素的数量 + 已存在元素被更新的数量
返回值
默认:返回新增元素的数量
CH参数:被改变的元素数量
示例
127.0.0.1:6379> geoadd city 116.41667 39.91667 beijing 121.43333 34.50000 shanghai 117.20000 39.13333 tianjin
(integer) 3
127.0.0.1:6379> geoadd city NX 116.41667 39.91667 beijing
(integer) 0
GEOPOS
可用版本:>= 3.2.0
时间复杂度:O(N),N为给定的元素个数
命令格式
GEOPOS key member [member ...]
命令描述
- 返回给定元素对应的经纬度
- 使用GEOADD添加的元素,会被GeoHash转化为52位比特值,因此使用GEOPOS取出值并转为经纬度时,可能与添加的经纬度值有少许差异
- 命令接收多个可变参数,返回值始终是数组形式
返回值
数组:存在的元素返回经纬度,不存在的元素返回nil
示例
127.0.0.1:6379> geoadd city 116.41667 39.91667 beijing 121.43333 34.50000 shanghai 117.20000 39.13333 tianjin
(integer) 3
127.0.0.1:6379> geopos city beijing nanjing
1) 1) "116.41667157411575317"
2) "39.91667095273589183"
2) (nil)
GEODIST
可用版本:>= 3.2.0
时间复杂度:O(log(N))
命令格式
GEODIST key member1 member2 [m|km|ft|mi]
命令描述
- 返回两个给定元素之间的距离
-
距离度量支持如下参数:
- m: 米(默认值)
- km: 千米
- ft: 英尺
- mi:英里
- 在计算距离时会假设地球为完美的球形,在极限情况下最大会造成 0.5% 的误差
- 如果给定的元素中,有元素不存在,返回nil
返回值
字符串:双精度的距离,以字符串形式返回;如果其中一个元素不存在,返回nil
127.0.0.1:6379> geoadd city 116.41667 39.91667 beijing 121.43333 34.50000 shanghai 117.20000 39.13333 tianjin
(integer) 3
127.0.0.1:6379> geodist city beijing shanghai
"748346.9287"
127.0.0.1:6379> geodist city beijing shanghai km
"748.3469"
GEOHASH
可用版本:>= 3.2.0
时间复杂度:对每个元素,时间复杂度为O(log(N)),N为已存在的元素数量
命令格式
GEOHASH key member [member ...]
命令描述
- GEOADD命令会将经纬度编码为52bit,该命令返回GeoHash编码转换后的
11
位字符串表示形式,该字符串形式,与 维基百科描述一致,兼容 geohash.org规范
- 使用该URL:geohash.org/可以反向解析出命令返回…
127.0.0.1:6379> geoadd city 15.08723 37.50265 test
(integer) 1
127.0.0.1:6379> geohash city test
1) "sqdtr74hvb0"
- GEORADIUS:返回以指定经纬度为圆心,给定半径范围内的元素
- GEORADIUSBYMEMBER:返回以指定元素为圆心,给定半径范围内的元素
- GEOSEARCH:GEORADIUS 和 GEORADIUSBYMEMBER的整合,提供了长方形区域范围查询
- GEORADIUSBYMEMBER:与GEOSEARCH功能一致,提供了保存结果功能
Hyperloglogs
基数就是指一个集合中不同值的数目,比如 a, b, c, d 的基数就是 4,a, b, c, d, a 的基数还是 4。虽然 a 出现两次,只会被计算一次。
使用 Redis 统计集合的基数一般有三种方法,分别是使用 Redis 的 HashMap,BitMap 和 HyperLogLog。前两个数据结构在集合的数量级增长时,所消耗的内存会大大增加,但是 HyperLogLog 则不会。
Redis 的 HyperLogLog 通过牺牲准确率来减少内存空间的消耗,只需要12K内存,在标准误差0.81%的前提下,能够统计2^64个数据。所以 HyperLogLog 是否适合在比如统计日活月活此类的对精度要不不高的场景。
Redis 提供了 PFADD
、 PFCOUNT
和 PFMERGE
三个命令来供用户使用 HyperLogLog。
PFADD
用于向 HyperLogLog 添加元素。如果 HyperLogLog 估计的近似基数在 PFADD
命令执行之后出现了变化, 那么命令返回 1 , 否则返回 0 。 如果命令执行时给定的键不存在, 那么程序将先创建一个空的 HyperLogLog 结构, 然后再执行命令。
PFCOUNT
****命令会给出 HyperLogLog 包含的近似基数。在计算出基数后, PFCOUNT
会将值存储在 HyperLogLog 中进行缓存,知道下次 PFADD
执行成功前,就都不需要再次进行基数的计算。
PFMERGE
将多个 HyperLogLog 合并为一个 HyperLogLog , 合并后的 HyperLogLog 的基数接近于所有输入 HyperLogLog 的并集基数。
redis> PFADD hll1 foo bar zap a
(integer) 1
redis> PFADD hll2 a b c foo
(integer) 1
redis> PFMERGE hll3 hll1 hll2
OK
redis> PFCOUNT hll3
(integer) 6
redis>
基本原理
HyperLogLog 是一种概率数据结构,它使用概率算法来统计集合的近似基数。而它算法的最本源则是伯努利过程。
伯努利过程就是一个抛硬币实验的过程。抛一枚正常硬币,落地可能是正面,也可能是反面,二者的概率都是 1/2 。伯努利过程就是一直抛硬币,直到落地时出现正面位置,并记录下抛掷次数k。比如说,抛一次硬币就出现正面了,此时 k 为 1; 第一次抛硬币是反面,则继续抛,直到第三次才出现正面,此时 k 为 3。
对于 n 次伯努利过程,我们会得到 n 个出现正面的投掷次数值 k1, k2 ... kn , 其中这里的最大值是k_max。
根据一顿数学推导,我们可以得出一个结论: 2^{k_ max} 来作为n的估计值。也就是说你可以根据最大投掷次数近似的推算出进行了几次伯努利过程。
HyperLogLog 是如何模拟伯努利过程
HyperLogLog 在添加元素时,会通过Hash函数,将元素转为64位比特串,例如输入5,便转为101(省略前面的0,下同)。这些比特串就类似于一次抛硬币的伯努利过程。比特串中,0 代表了抛硬币落地是反面,1 代表抛硬币落地是正面,如果一个数据最终被转化了 10010000,那么从低位往高位看,我们可以认为,这串比特串可以代表一次伯努利过程,首次出现 1 的位数为5,就是抛了5次才出现正面。
所以 HyperLogLog 的基本思想是利用集合中数字的比特串第一个 1 出现位置的最大值来预估整体基数,但是这种预估方法存在较大误差,为了改善误差情况,HyperLogLog中引入分桶平均的概念,计算 m 个桶的调和平均值。
Redis 中 HyperLogLog 一共分了 2^14 个桶,也就是 16384 个桶。每个桶中是一个 6 bit 的数组
HyperLogLog 将上文所说的 64 位比特串的低 14 位单独拿出,它的值就对应桶的序号,然后将剩下 50 位中第一次出现 1 的位置值设置到桶中。50位中出现1的位置值最大为50,所以每个桶中的 6 位数组正好可以表示该值。
在设置前,要设置进桶的值是否大于桶中的旧值,如果大于才进行设置,否则不进行设置。
此时为了性能考虑,是不会去统计当前的基数的,而是将 HyperLogLog 头的 card 属性中的标志位置为 1,表示下次进行 pfcount 操作的时候,当前的缓存值已经失效了,需要重新统计缓存值。在后面 pfcount 流程的时候,发现这个标记为失效,就会去重新统计新的基数,放入基数缓存。
在计算近似基数时,就分别计算每个桶中的值,带入到上文将的 DV 公式中,进行调和平均和结果修正,就能得到估算的基数值。
HyperLogLog 对象的定义
struct hllhdr {
char magic[4]; /* 魔法值 "HYLL" */
uint8_t encoding; /* 密集结构或者稀疏结构 HLL_DENSE or HLL_SPARSE. */
uint8_t notused[3]; /* 保留位, 全为0. */
uint8_t card[8]; /* 基数大小的缓存 */
uint8_t registers[]; /* 数据字节数组 */
};
HyperLogLog 对象中的 registers
数组就是桶,它有两种存储结构,分别为密集存储结构和稀疏存储结构,两种结构只涉及存储和桶的表现形式
密集存储结构
它是十分的简单明了,既然要有 2^14 个 6 bit的桶,那么我就真使用足够多的 uint8_t
字节去表示,只是此时会涉及到字节位置和桶的转换,因为字节有 8 位,而桶只需要 6 位。
所以我们需要将桶的序号转换成对应的字节偏移量 offsetbytes 和其内部的位数偏移量 offsetbits。需要注意的是小端字节序,高位在右侧,需要进行倒转。
当 offset_bits 小于等于2时,说明一个桶就在该字节内,只需要进行倒转就能得到桶的值。
如果 offset_bits 大于 2 ,则说明一个桶分布在两个字节内,此时需要将两个字节的内容都进行倒置,然后再进行拼接得到桶的值,如下图所示。
HyperLogLog 的稀疏存储结构
是为了节约内存消耗,它不像密集存储模式一样,真正找了那么多个字节数组来表示2^14 个桶,而是使用特殊的字节结构来表达。
Redis 为了方便表达稀疏存储,它将上面三种字节表示形式分别赋予了一条指令。
- ZERO : 一字节,表示连续多少个桶计数为0,前两位为标志00,后6位表示有多少个桶,最大为64。
- XZERO : 两个字节,表示连续多少个桶计数为0,前两位为标志01,后14位表示有多少个桶,最大为16384。
- VAL : 一字节,表示连续多少个桶的计数为多少,前一位为标志1,四位表示连桶内计数,所以最大表示桶的计数为32。后两位表示连续多少个桶。
所以,一个初始状态的 HyperLogLog 对象只需要2 字节,也就是一个 XZERO 来存储其数据,而不需要消耗12K 内存。当 HyperLogLog 插入了少数元素时,可以只使用少量的 XZERO、VAL 和 ZERO 进行表示,如下图所示。
Redis从稀疏存储转换到密集存储的条件是:
- 任意一个计数值从 32 变成 33,因为 VAL 指令已经无法容纳,它能表示的计数值最大为 32
- 稀疏存储占用的总字节数超过 3000 字节,这个阈值可以通过 hllsparsemax_bytes 参数进行调整。
主从复制
「复制」也叫「同步」,在Redis使用的是「PSYNC」命令进行同步,该命令有两种模型:完全重同步和部分重同步
如果是第一次「同步」,从服务器没有复制过任何的主服务器,或者从服务器要复制的主服务器跟上次复制的主服务器不一样,那就会采用「完全重同步」模式进行复制。如果只是由于网络中断,只是**「短时间」断连**,那就会采用「部分重同步」模式进行复制(假如主从服务器的数据差距实在是过大了,还是会采用「完全重同步」模式进行复制)
主服务器要复制数据到从服务器,首先是建立Socket「连接」,这个过程会干一些信息校验啊、身份校验等,然后从服务器就会发「PSYNC」命令给主服务器,要求同步(这时会带「服务器ID」RUNID和「复制进度」offset参数,如果从服务器是新的,那就没有)主服务器发现这是一个新的从服务器(因为参数没带上来),就会采用「完全重同步」模式,并把「服务器ID」(runId) 和「复制进度」(offset)发给从服务器,从服务器就会记下这些信息。
随后,主服务器会在后台生成RDB文件,通过前面建立好的连接发给从服务器,从服务器收到RDB文件后,首先把自己的数据清空,然后对RDB文件进行加载恢复(非阻塞)
主服务器把生成RDB文件「之后修改的命令」会用「buffer」记录下来,等到从服务器加载完RDB之后,主服务器会把「buffer」记录下的命令都发给从服务器,这样一来,主从服务器就达到了数据一致性了(复制过程是异步的,所以数据是『最终一致性』)
「部分重同步」
靠「offset」来进行部分重同步。每次主服务器传播命令的时候,都会把「offset」给到从服务器,主服务器和从服务器都会将「offset」保存起来(如果两边的offset存在差异,那么说明主从服务器数据未完全同步)
从服务器断连之后,就会发「PSYNC」命令给主服务器,同样也会带着RUNID和offset(重连之后,这些信息还是存在的)
主服务器收到命令之后,看RUNID是否能对得上,对得上,说明这可能以前就复制过一部分了,接着检查该「offset」是否在主服务器记录的offset还存在(因为主服务器记录offset使用的是一个环形buffer,如果该buffer满了,会覆盖以前的记录)。
如果找到了,那就把从缺失的一部分offer开始,把对应的修改命令发给从服务器,如果从环形buffer没找到,那只能使用「完全重同步」模式再次进行主从复制了
哨兵
监控(监控主服务器的状态)、选主(主服务器挂了,在从服务器选出一个作为主服务器)、通知(故障发送消息给管理员)和配置(作为配置中心,提供当前主服务器的信息)
可以把「哨兵」当做是运行在「特殊」模式下的Redis服务器,为了「高可用」,哨兵也是集群架构的。
首先它需要跟Redis主从服务器创建对应的连接(获取它们的信息),每个哨兵不断地用ping命令看主服务器有没有下线,如果主服务器在「配置时间」内没有正常响应,那当前哨兵就「主观」认为该主服务器下线了,其他「哨兵」同样也会ping该主服务器,如果「足够多」(还是看配置)的哨兵认为该主服务器已经下线,那就认为「客观下线」,这时就要对主服务器执行故障转移操作。
「哨兵」之间会选出一个「领头」,选出领头的规则也比较多,总的来说就是先到先得(哪个快,就选哪个),由「领头哨兵」对已下线的主服务器进行故障转移
首先要在「从服务器」上挑选出一个,来作为主服务器(这里也挑选讲究,比如:从库的配置优先级、要判断哪个从服务器的复制offset最大、RunID大小、跟master断开连接的时长)然后,以前的从服务器都需要跟新的主服务器进行「主从复制」,已经下线的主服务器,再次重连的时候,需要让他成为新的主服务器的从服务器
分片集群
用多个Redis实例来组成一个集群,按照一定的规则把数据「分发」到不同的Redis实例上。当集群所有的Redis实例的数据加起来,那这份数据就是全的
要「分布式存储」,就肯定避免不了对数据进行「分发」(也是路由的意思),对于Redis Cluster,它的「路由」是做在客户端的(SDK已经集成了路由转发的功能)
Redis Cluster默认一个集群有16384个哈希槽,这些哈希槽会分配到不同的Redis实例中
至于怎么「瓜分」,可以直接均分,也可以「手动」设置每个Redis实例的哈希槽,但不能有剩余
当客户端有数据进行写入的时候,首先会对key按照CRC16算法计算出16bit的值(可以理解为就是做hash),然后得到的值对16384进行取模,取模之后,自然就得到其中一个哈希槽,然后就可以将数据插入到分配至该哈希槽的Redis实例中
在集群的中每个Redis实例都会向其他实例「传播」自己所负责的哈希槽有哪些。这样一来,每台Redis实例就可以记录着「所有哈希槽与实例」的关系了,客户端也会「缓存」一份到自己的本地上。
当集群删除或者新增Redis实例时,那总会有某Redis实例所负责的哈希槽关系会发生变化,发生变化的信息会通过消息发送至整个集群中,所有的Redis实例都会知道该变化,然后更新自己所保存的映射关系,但这时候,客户端其实是不感知的。
当客户端请求时某Key时,还是会请求到「原来」的Redis实例上。而原来的Redis实例会返回「moved」命令,告诉客户端应该要去新的Redis实例上去请求,客户端接收到「moved」命令之后,就知道去新的Redis实例请求了,并且更新「缓存哈希槽与实例之间的映射关系」,如果数据还没完全迁移完,那这时候会返回客户端「ask」命令。也是让客户端去请求新的Redis实例,但客户端这时候不会更新本地缓存
Redis实例之间「通讯」会相互交换「槽信息」,那如果槽过多(意味着网络包会变大),网络包变大,那就意味着会「过度占用」网络的带宽,另外一块是,Redis作者认为集群在一般情况下是不会超过1000个实例,那就取了16384个,即可以将数据合理打散至Redis集群中的不同实例,又不会在交换数据时导致带宽占用过多
为什么不用一致性哈希算法
一致性哈希算法就是有个「哈希环」,当客户端请求时,会对Key进行hash,确定在哈希环上的位置,然后顺时针往后找,找到的第一个真实节点
一致性哈希算法比「传统固定取模」的好处就是:如果集群中需要新增或删除某实例,只会影响一小部分的数据
但如果在集群中新增或者删除实例,在一致性哈希算法下,就得知道是「哪一部分数据」受到影响了,需要进行对受影响的数据进行迁移
而哈希槽的方式,在集群中的每个实例都能拿到槽位相关的信息,当客户端对key进行hash运算之后,如果发现请求的实例没有相关的数据,实例会返回「重定向」命令告诉客户端应该去哪儿请求
集群的扩容、缩容都是以「哈希槽」作为基本单位进行操作,总的来说就是「实现」会更加简单(简洁,高效,有弹性)。过程大概就是把部分槽进行重新分配,然后迁移槽中的数据即可,不会影响到集群中某个实例的所有数据。