一、主从同步
1. 分布式原理
网络分区定义:分布式系统节点往往分布在不同的机器上,这意味着必然会有网络断开的风险,网络断开的场景称之为网络分区
cap原理:cap是分布式存储的理论基石,含义如下:
- C:Consisten,一致性。写操作之后进行读操作,必须可以读到修改的最新数据
- A:Availablity,可用性。只要接收到用户的信息,服务器就必须响应
- P:Partition tolerance,分区容忍性。例子:M1和M2是两台服务器,系统设计时,应当考虑到M1消息有可能M2接收不到,因此分布式系统中,P总成立
- 网络分区发生后,分布式节点间无法通信,一个节点的数据变更,无法同步到另一个节点,此时一致性无法满足(数据出现不一致),除非牺牲可用性,即网络分区发生时,不提供修改数据的功能,直到网络状况完全恢复后再对外提供服务。
- 总而言之,网络分区发生时,一致性和可用性两难全。
2. redis分布式策略
a. 最终一致
redis分布式系统最终一致性原则:
- redis主从数据是异步同步的,所以redis分布式系统并不满足C(一致性)要求。
- redis分布式系统满足AP原则,在网络分区的情况下,主节点依旧可以正常对外提供服务
- redis满足最终一致性:一旦网络恢复,从节点会采用多种策略努力追赶主节点,尽量与主节点数据保持一致。
b. 主从同步与从从同步
redis同步支持主从同步与从从同步,从从同步是redis后续版本新增的功能,减轻主节点的同步负担
c. 增量同步
增量同步过程:
- redis同步的是指令流,主节点会把对数据状态产生影响的指令记录在本地buffer中,然后异步将buffer中的指令同步到从节点
- 从节点一边执行同步的指令流来达到和主节点一样的状态,一边向主节点反馈自己同步到哪儿了
- redis的复制内存buffer是一个定长的环形数组,如果数组内容满了,就会从头开始覆盖前面的内容。因此当网络情况不好时,redis主节点中没有同步的指令在buffer中可能会被覆盖,从节点无法通过指令流来进行同步,因此需要使用快照同步
d. 快照同步
同步时机:
- 当从节点刚刚加入主节点时,必须进行一次快照同步,同步完后再进行增量同步
- 若增量同步的指令在主节点复制buffer中被覆盖,无法进行增量复制,则会发起快照同步
同步流程:
- 首先在主节点上执行bgsave,将当前内存中数据全部快照到磁盘中,将快照文件的内容全部传送给从节点
- 从节点将快照接收完毕,立即执行一次全量加载,加载前将内存数据清空,加载完毕后通知主节点继续进行增量同步
- 整个同步过程中,主节点的复制buffer不断往前移动,若快照同步时间过长,会导致同步期间增量指令在buffer中被覆盖,导致快照同步后无法进行增量复制,会再次发起快照同步,极有可能陷入快照同步的死循环,因此需要设置一个合适的复制buffer大小参数
无盘复制:
快照同步的问题:主节点在进行快照同步时,会执行十分耗时的IO操作,对系统的负载产生较大的影响;特别是若系统正在执行AOF的fsync操作,若发生快照同步,fsync会被推迟执行,严重影响主节点的服务效率。
无盘复制流程:无盘复制在redis2.8.18后被提出,流程如下:
- 主节点直接通过套接字将快照内容发给从节点,生成快照是一个遍历的过程,主节点会一边遍历内存,一边将序列化的内容发送给从节点
- 从节点流程不变,先将接受到的内容存储到磁盘,再进行一次性加载
e. wait指令
wait指令可以让redis同步复制指令,确保系统的强一致性
127.0.0.1:6379> set key value
OK
127.0.0.1:6379> wait 1 0
wait提供两个参数,第一个是等待N个从节点将数据同步完成;第二个是最多等待时间T(t=0,表示无限等待,若主从同步无法继续进行,wait会永远阻塞,redis服务器丧失可用性)
二、sentinel
1. 概述
定义:sentinel类似zookeeper集群,是集群高可用的心脏,负责监控主从节点的健康。当主节点挂了,会自动选择一个最优从节点进行切换。
流程:
- 客户端连接集群时,会首先连接sentinel,通过sentinel查询主节点地址,然后再连接主节点进行数据交互;
- 当主节点发生故障,客户端会重新向sentinel获取地址,sentinel会将最新主节点地址告诉客户端,如此应用程序无需重启也可以自动完成节点切换
从上图可以看到,主节点挂了,原来的主从复制也断开,客户端和损坏的主节点也断开了。一个从节点被提升为新的主节点,其他从节点开始和新的主节点建立复制关系。客户端通过新的主节点进行交互,sentinel集群会持续监控已经挂了的主节点,若恢复,则调整集群结构如下
消息丢失:redis采用主从异步复制,这意味着若主节点挂点,从节点可能没有收到全部的消息,这部分数据会丢失;如果主从同步延迟很大,丢失的数据则很多。sentinel集群无法保证消息完全不丢失,但可以尽量保证消息丢失少,其策略如下:
- min-salves-to-write K:表示主节点必须至少K个从节点在进行正常复制,否则就停止对外写服务,丧失可用性
- min-slaves-max-log N:表示Ns内若没有收到从节点反馈,则意味着从节点同步不正常
通过这两个选项可以限制主从延迟过大问题
2. sentinel基本用法
a. 搭建简单的redis-sentinel集群
单台服务器搭建sentinel集群:
wget http://download.redis.io/releases/redis-3.0.7.tar.gz
tar -zxvf redis-3.0.7.tar.gz
cd redis-3.0.7
make
make PREFIX=/usr/local/software/redis/redis-3.0.7 install
ln -s /usr/local/software/redis/redis-3.0.7 /usr/local/redis
mkdir -p /usr/local/redis/conf
# 复制配置文件
cp sentinel.conf /usr/local/redis/conf/sentinel-26379.conf
cp sentinel.conf /usr/local/redis/conf/sentinel-26380.conf
cp sentinel.conf /usr/local/redis/conf/sentinel-26381.conf
# 使用vim修改配置文件的port即可,此步骤省略
nohup ./redis-sentinel ../conf/sentinel-26379.conf > redis_sentinel1.log 2>&1 &
nohup ./redis-sentinel ../conf/sentinel-26380.conf > redis_sentinel2.log 2>&1 &
nohup ./redis-sentinel ../conf/sentinel-26381.conf > redis_sentinel3.log 2>&1 &
单台服务器部署redis主从:
nohup ./redis-server --port 6379 > redis1.log 2>&1 &
nohup ./redis-server --port 6380 --slaveof 127.0.0.1 6379 > redis2.log 2>&1 &
nohup ./redis-server --port 6381 --slaveof 127.0.0.1 6379 > redis3.log 2>&1 &
查看日志可见,redis哨兵模式已经搭建成功
kill掉主节点,发现sentinel重新选举了master
b. 使用程序操作sentinel集群
使用方法:
- sentinel对象的discover_xxx(master/slaves)可以发现主从地址,主地址只有一个,从地址可以有多个
- xxx_for(master/slave)方法可以从连接池中拿出一个连接,因为从地址有多个,redis客户端对从地址采用轮询方案
>>> from redis.sentinel import Sentinel
>>> sentinel = Sentinel([('localhost',26379)],socket_timeout=0.1)
>>> sentinel.discover_master('mymaster')
('127.0.0.1', 6380)
>>> sentinel.discover_master('mymaster')
('127.0.0.1', 6380)
>>> master=sentinel.master_for('mymaster',socket_timeout=0.1)
>>> salve=sentinel.slave_for('mymaster',socket_timeout=0.1)
>>> master.set("name","zzh")
True
>>> salve.get("name")
b'zzh'
redis-py原理:
- python连接池在建立新连接时,会查询主节点地址,然后跟内存中主节点地址进行对比,若发生变更,则断开所有连接,重新使用新地址建立连接
- 如果旧的主节点挂掉了:所有正在使用的连接都会断开,重连时使用新的地址
- 若sentinel主动进行主从切换,主节点没有挂掉,原先的主节点变成只读:redis-py在处理命令时捕获一个特殊的异常(ReadOnlyError),在这个异常将所有旧连接关闭,后续指令会进行重连。主从切换后,之前的主节点会降级成从节点,所有修改性指令会抛出ReadOnlyError,如果程序没有修改性指令,即使不切换,程序也不会出现问题
三、codis
1. 概述
问题:在大数据高并发场景下,单个redis实例往往捉襟见肘。问题如下:
- 单个redis实例rdb不宜过大,否则会导致主从同步时间过长,实例重启恢复时间长
- 单个redis实例只能利用单核CPU,单核CPU要完成海量数据存储,压力较大
codis定义:
- codis使用go语言开发,是代理中间件,和redis一样使用redis协议对外提供服务。当客户端向codis发送指令时,codis负责将指令转发到后面的redis实例来执行,并将结果返回客户端
- codis上挂接所有redis实例,构成一个redis集群,当集群空间不足时,可以动态增加redis实例实现扩容
- 客户端操作codis和操作redis没有区别,甚至可以使用redis客户端sdk
- codis是无状态的,只是一个转发代理中间件,因此可以启动多个codis实例,供客户端使用,每个codis节点是对等的。通过启动多个codis代理可以显著增加整体的QPS,还能起容灾功能,挂掉一个codis没关系,还有多个codis可以提供服务
2. 原理
a. codis分片原理
分片原理:
- codis默认将所有的key划分为1024个槽(slot),首先对客户端传过来的key进行crc32运算计算hash值,再将hash后的整数值对1024取模得到余数,这个余数就是对应的key的槽位
- 每个槽位会唯一映射到后面多个redis实例之一,codis会在内存中维护槽位和redis实例的映射关系。如此以来,有了上面key对应的槽位,数据该往哪个redis实例转发就十分明显了
- 槽位默认数量是1024,当集群节点较多时,可以将改值设置大一些
伪代码:
hash = crc32(command.key)
slot_hash = hash % 1024
redis = slots[slot_hash].redis
redis.do(command)
b. 不同codis实例之间槽位信息同步
问题:如果codis的槽位关系只存储在内存中,那么不同redis实例之间的槽位关系就无法同步。codis同步方案:codis使用zookeeper(etcd也支持)进行数据同步:
- codis将槽位关系存储在zookeeper中,并提供dashboard观察和修改槽位信息
- 当槽位关系变化时,codis proxy会监听到变化并同步槽位关系,从而实现多个codis proxy之间共享槽位信息
c. 扩容
场景:codis扩容时,需要对槽位关系进行调整,将部分槽位划分到新节点上,这意味着需要对这部分槽位对应的key进行迁移,迁移到新的redis节点
迁移key流程:
- codis对redis进行改造,增加了slotsscan指令,可以遍历slot下所有key,然后挨个迁移每个key到新的redis节点
- 迁移过程中,codis会接收到新的请求打到正在迁移的slot上。当codis接收到位于正在迁移的slot中的key后,会立即强制对当前key进行迁移,迁移完成后,再将请求转发到新的redis实例
伪代码:
slot_index = crc32(command.key) % 1024
if slot_index in migrating_slots:
do_migrating_key(command.key)
redis = slots[slot_index].new_redis
else:
redis = slots[slot_index].redis
redis.do(command)
其他:redis支持的所有scan指令都无法避免重复,同样道理,slotsscan也无法避免重复,不过这部影响迁移,因为单个key被迁移后,旧实例就会将其彻底删除,也就不会再次被扫描出来
d. 自动均衡
codis提供了自动均衡功能,自动均衡会在系统比较空闲时,观察每个redis实例对应的slot数量,如果不平衡,就自动进行均衡
e. mget指令原理
mget可以批量获取多个key的值,这些key可能发布在不同slot上,codis的策略是将key按照所分配的实例打散分组,然后依次对每个实例调用mget,最后将结果汇总成一个,返回客户端
3. codis优缺点
a. codis代价
- codis中所有的key分布在不同redis实例上,不能再支持事务了,事务只能在单个实例完成;rename操作也很危险,它的参数是两个key,如果两个key(根据hash值计算得到的slot值)在不同的redis实例中,rename操作无法正确完成
- 为了支持扩容,单个key对应的value值不宜太大,因为集群迁移的最小单位是key,对应hash,会一次性使用hgetall拉取所有内容,然后用hmset放置到另一个节点,如果hash内部key/value太多,会导致迁移卡顿(官方建议不要超过1M)
- codis增加了proxy作为中转层,网络开销比单个redis大,相当于数据包多走了一个网络节点,整体性能比单个redis有所下降(可通过增加proxy数量来弥补性能不足)
- codis集群配置中心使用zookeeper来实现,增加了zookeeper运维成本
b. codis的优点
- 设计上比redis cluser简单
- 有强大的dashboard功能,能便捷的对redis集群进行管理。Codis-fe可以同时对多个codis集群进行管理
四、cluster
1. 概述
特点:
- redis cluster是去中心化的,集群由多个几点组成,每个节点负责的数据多少可能不一样
- 节点间通过一种特殊的二进制协议交互集群信息
- cluster将所有数据划分成16384个槽,每个节点负责一部分槽位,槽位信息存储于每个节点中
- 当redis cluster的客户端连接集群时,会得到一份集群槽位信息,当客户端要查询某个key时,可以直接定位到目标节点(不同于cluster,不需要使用proxy来定位目标节点)
- 客户端缓存了cluster槽位信息,以准确快速的定位到相应节点。同时存在客户端与服务器存储槽位的信息不一致的问题,需要使用纠正机制来实现槽位信息的校验调整
- cluster每个节点会将集群的配置信息持久化到配置文件中,因此必须确保配置文件可写
2. cluster原理
a. 槽位定位算法
算法流程:
- cluster会使用crc16算法对key进行hash,再对16384取模,确定具体的槽(定位槽方法与codis类似)
- cluster允许用户强制将某个key挂到特定槽位上。通过在key字符串里面嵌入tag标志,可以强制key所挂的槽位等于tag所在槽位(redis在计算槽编号的时候只会获取{}之间的字符串进行槽编号计算,若{}里面的字符串是相同的,则这些key可以被计算出相同的槽)
模拟代码:
package redis.clusterLearn;
import sun.misc.CRC16;
public class slotAlgorithm {
public static void main(String[] args) {
System.out.println(hashSlot("sdjk{wsl}}gft"));
System.out.println(hashSlot("dsdf{wsl}}gfk"));
System.out.println(hashSlot("sdfsa{wsl}d}ddgf"));
System.out.println(hashSlot("dfkjljsd"));
}
public static int hashSlot(String key) {
// 定位tag字符
int loc = key.indexOf("{");
if (loc >= 0) {
int locEnd = key.indexOf("}", loc + 1);
if (locEnd >= 0 && locEnd != loc) {
key = key.substring(loc + 1, locEnd);
}
}
// 定位槽
CRC16 crc16 = new CRC16();
for (byte byt : key.getBytes()) {
crc16.update(byt);
}
return crc16.value & 0x3FFF;
}
}
运行结果:
由结果可见,若在key中使用{}指定tag,则redis cluster只会使用tag中的值定位slot
b. 跳转
流程:
- 当客户端向一个错误节点发出指令后,该节点会发现指令的key所在的槽位不归自己管理,此时节点会向客户端发送一个特殊的跳转指令,携带目标操作的节点地址,告诉客户端去连接这个节点以获取数据
- 客户端接收到消息后,要立即纠正本地槽位映射表,后续所有key的访问将使用新的槽位映射表
MOVED指令:
GET x
-MOVED 3999 127.0.0.1:6381
MOVED指令第一个参数是key对应的槽号,后面是目标节点的ip地址。MOVED指令前有一个-,代表返回的消息是一条错误消息(参照redis协议)
c. 迁移
概述:redis cluster提供redis-trib以让运维工作人员手动调整槽位分配情况
迁移特点:
- redis的迁移单位是槽,一个槽一个槽的进行迁移
- 当一个槽进行迁移时,这个槽就处于中间状态(在源节点状态为 migrating,目标节点状态为importing)
- 迁移工具 redis-trib 首先会在源节点和目标节点之间设置好中间过渡状态,然后一次性获取源节点槽位中所有key列表(keysinslot指令,可以部分获取),再挨个key遍历
- 迁移过程是同步的,目标节点执行restore指令到源节点删除key之间,源节点主线程处于阻塞状态,直到key被成功删除
- 迁移过程中,如果每个key很小,migrate会执行得很快,如果key内容很大,由于migrate是阻塞指令,会同时导致源节点和目标节点卡顿(在集群环境下,业务逻辑要尽可能避免产生大key)
单个key迁移过程:
- 每个key迁移过程是将源节点作为“客户端”,源节点对当前的key执行dump指令得到序列化内容;
- 通过“客户端”向目标节点发送restore指令携带序列化内容作为参数;
- 目标节点再进行反序列化将内容恢复到目标节点的内存中,然后给“客户端”返回OK
- 源节点收到ok后,将当前节点的key删除掉,即可完成单个key 的迁移过程
- 迁移过程中,若出现网络故障,整个槽只迁移了一半,这时两个节点依旧处于中间状态,等下次迁移工具重新连上时,会提示用户继续进行迁移
迁移过程中,客户端访问流程:
- 由于新旧节点对应的槽位都存在部分key,客户端可以先尝试访问旧节点,若对应的数据还在,则正常返回;
- 若数据不存在于旧节点,要么数据在新节点,要么数据根本不存在。旧节点不清楚是那种情况,于是会放回 -ASK targetNodeAddr重定向指令
- 客户端收到重定向指令后,先去目标节点执行不带参数的ASKING指令,然后在目标节点重新执行原来的指令
发送ASKING指令原因:
- 在迁移没有完成之前,该槽位不归属新节点管理,如果这时候直接向目标节点发送请求该槽位的指令,目标节点不接收,会向客户端返回 -MOVED 指令指向旧节点,进而形成重定向循环。
- ASKING指令旨在告诉目标节点,即使槽位不归属其管理,也要进行处理
迁移对效率的影响:
正常指令一个ttl可以完成,在迁移时,需要3个ttl
d. 容错
redis cluster可以为每个主节点分配若干从节点,主节点故障后,集群会自动选择某个从节点升级为主节点。若没有配置从节点,当主节点故障后,集群会处于不可用状态(redis提供cluster-require-full-coverage参数以允许部分节点故障时,仍可以继续对外提供服务)
e. 网络抖动
redis cluster处理网络抖动的参数:
- redis cluster提供cluster-node-timeout参数,表示某个节点持续timeout的时间失联后,才可认定该节点故障,需要进行主从切换(若没有该选项,网络抖动会导致主从频繁切换)
- redis cluster还提供了cluster-slave-validity-factor参数,作为倍数系数放大超时时间来宽松容错的紧急程度(0表示主从切换不会抗拒网络抖动,系数大于1,该系数为主从切换松弛系数)
f. 可能下线与确定下线
场景:redis cluster是去中心化的,一个节点认为某个节点失联,并不代表所有节点任务该节点失联。
流程:
- redis使用Gossip协议来广播自己的状态,该表整个集群的认知
- 一个节点发现某个节点失联了(PFail),则会将改信息广播到整个集群,其他节点可以收到这条信息
- 若定义某个节点失联的节点数量(PFail Count)达到了集群的大多数,则可以标志该节点下线,并对该节点进行主从切换
g. 槽位迁移感知
槽位迁移状态感知方法:
- MOVED指令:纠正槽位
- ASKING指令:临时纠正槽位,客户端收到ASKING error指令后,会根据指令携带的目标节点地址尝试拿数据,此时,客户端不会刷新槽位关系映射表(只是临时纠正,不会影响后续指令槽位与节点的映射)
槽位迁移指令重试次数:
- 重试一次:MOVED指令和ASKING指令会导致重试一次
- 重试两次:一条指令被发送到错误节点,执行MOVED指令(重试一次);客户端去MOVED指令指向的节点重试,刚好这个节点在迁移,于是向客户端返回ASKING指令,让客户端使用asking指令访问新节点(重试两次)
- 重试多次:存在这种可能,因此在编程时,需要在客户端设置最大重试次数的参数
h. 集群变更感知
场景:服务器节点变更时,客户端应该立即得到通知并更新槽位-节点映射表
客户端更新映射表方法:
- 目标节点挂掉了,客户端会抛出ConnectionError的异常,紧接着会任意选择一个节点来重试,重试的节点会通过MOVED指令告知目标槽位所在的新的节点地址
- 运维手段修改了集群信息,将主节点切换为其他节点,并将旧的主节点迁移出集群。此时,打在旧节点上的指令会收到一个clusterDown错误,告知当前节点所在集群不可用(当前节点已经孤立)。这是,客户端会关闭所有连接,清空槽位映射关系表,抛出一个异常。待下一条指令过来时,再重新尝试初始化节点信息
3. cluster使用
>>> from rediscluster import RedisCluster
>>> startup_nodes=[{"host":"127.0.0.1","port":"6379"}]
>>> rc=RedisCluster(startup_nodes=startup_nodes,decode_responses=True)
cluster使用特点:cluster是去中心化的,由多个节点组成,构造cluster实例时,我们可以只用一个节点地址,其他地址由改地址发现(用多地址会比较安全)
与单机版redis的区别:cluster不支持事务,mget方法比redis要慢很多(被拆分成多个get指令);rename方法不再是原子性的,它需要将数据从源节点转移到目标节点