Redis集群架构

主从架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 复制一份redis.conf文件
port 6380
pidfile /var/run/redis_6380.pid # 把pid进程号写入pidfile配置的文件
logfile "6380.log"
dir /usr/local/redis-5.0.3/data/6380 # 指定数据存放目录
# 需要注释掉bind
# bind 127.0.0.1 绑定机器网卡ip,多块网卡可配多个ip,代表允许客户端通过机器的哪些网卡ip去访问,内网一般可不配置bind

# 配置主从复制
replicaof 192.168.0.60 6379 # 从本机6379的redis实例复制数据,Redis 5.0之前使用slaveof
replica-read-only yes # 配置从节点只读

# 启动从节点
redis-server redis.conf

# 连接从节点
redis-cli -p 6380
# 测试在6379实例上写数据,6380实例是否能及时同步新修改数据
# 可以自己再配置一个6381的从节点

若为master主节点配置了一个slave从节点不管该slave从节点是否是第一次连接上Master主节点,都会发送一个PSYNC命令给master请求复制数据。

master主节点收到PSYNC命令后,会在后台进行数据持久化通过bgsave生成最新的rdb快照文件持久化期间master会继续接收客户端请求,且把这些可能修改数据集的请求缓存在内存中。当持久化进行完毕以后,master主节点会把这份rdb文件数据集发送给slave从节点,slave会把接收到的数据进行持久化生成rdb,然后再加载到内存中master主节点再将之前缓存在内存中的命令发送给slave从节点

当master主节点与slave从节点之间的连接由于某些原因而断开时,slave从节点能够自动重连Master主节点,若master收到了多个slave从节点并发连接请求,它只会进行一次持久化,然后把这一份持久化的数据发送给多个并发连接的slave从节点

Redis主从全量复制流程

当master主节点和slave从节点断开重连后,一般都会对整份数据进行复制。但从Redis 2.8开始,PSYNC命令支持部分数据复制去master同步数据,slave从节点与master主节点能够在网络连接断开重连后只进行部分数据复制即断点续传

master会在其内存中创建一个复制数据用的缓存队列,缓存最近一段时间的数据,master主节点和它所有slave从节点都维护了复制数据下标offsetmaster进程id,当网络连接断开后,slave从节点会请求master主节点从所记录的数据下标开始继续进行未完成的复制。若master主节点进程id变化,或slave从节点数据下标offset太旧超出master主节点缓存队列,则将进行一次全量数据复制

Redis主从部分复制流程

若有很多从节点多个从节点同时复制主节点导致主节点压力过大,为了缓解主从复制风暴,可让部分从节点与从节点同步数据

哨兵模式

sentinel哨兵是特殊的redis服务,不提供读写服务,主要用来监控redis实例节点,哨兵架构下client端第一次从哨兵找出redis的主节点,后续直接访问redis主节点不会每次都通过sentinel哨兵代理访问redis的主节点,当redis的主节点发生变化,哨兵会第一时间感知到,并且将新的redis主节点通知给client,redis的client端一般都实现了订阅功能,订阅sentinel哨兵发布的节点变动消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 复制一份sentinel.conf文件
cp sentinel.conf sentinel-26379.conf

port 26379
daemonize yes
pidfile "/var/run/redis-sentinel-26379.pid"
logfile "26379.log"
dir "/usr/local/redis-5.0.3/data"
# sentinel monitor <master-redis-name> <master-redis-ip> <master-redis-port> <quorum>
# quorum是一个数字,指明当有多少个sentinel认为一个master失效时(值一般为:sentinel总数/2 + 1),master才算真正失效
sentinel monitor mmaster 192.168.0.60 6379 2 # mmaster名字随便取,客户端访问时会用到

# 启动sentinel哨兵实例
src/redis-sentinel sentinel-26379.conf

# 查看sentinel的info信息
src/redis-cli -p 26379
127.0.0.1:26379>info # 可以看到Sentinel的info里已经识别出了redis的主从
# 可再配置两个sentinel,端口26380和26381,注意上述配置文件里的对应数字都要修改

sentinel集群都启动完毕后,会将哨兵集群的元数据信息写入所有sentinel配置文件中,追加在文件的最下面:

1
2
3
4
sentinel known-replica mmaster 192.168.0.60 6380 #代表redis主节点的从节点信息
sentinel known-replica mmaster 192.168.0.60 6381 #代表redis主节点的从节点信息
sentinel known-sentinel mmaster 192.168.0.60 26380 52d0a5d70c1f90475b4fc03b6ce7c3c56935760f # 感知到的其它哨兵节点
sentinel known-sentinel mmaster 192.168.0.60 26381 e9f530d3882f8043f76ebb8e1686438ba8bd5ca6 # 感知到的其它哨兵节点

当redis主节点如果挂了,哨兵集群会重新选举出新的redis主节点,同时修改所有sentinel节点配置文件的集群元数据信息,如6379的redis挂了,假设选举出的新主节点是6380:

1
2
3
4
sentinel known-replica mmaster 192.168.0.60 6379 # 主节点的从节点信息
sentinel known-replica mmaster 192.168.0.60 6381 # 主节点的从节点信息
sentinel known-sentinel mmaster 192.168.0.60 26380 52d0a5d70c1f90475b4fc03b6ce7c3c56935760f # 感知到的其它哨兵节点
sentinel known-sentinel mmaster 192.168.0.60 26381 e9f530d3882f8043f76ebb8e1686438ba8bd5ca6 # 感知到的其它哨兵节点

同时修改sentinel文件里之前配置的mmaster对应的6379端口,改为6380,当6379的redis实例再次启动时,哨兵集群根据集群元数据信息就可以将6379端口的redis节点作为从节点加入集群

1
sentinel monitor mmaster 192.168.0.60 6380 2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class JedisSentinelTest {
public static void main(String[] args) throws IOException {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(20);
config.setMaxIdle(10);
config.setMinIdle(5);
String masterName = "mmaster";
Set<String> sentinels = new HashSet<String>();
sentinels.add(new HostAndPort("172.16.20.53", 26379).toString());
sentinels.add(new HostAndPort("172.16.20.53", 26380).toString());
sentinels.add(new HostAndPort("172.16.20.53", 26381).toString());
// JedisSentinelPool其实本质跟JedisPool类似,都是与redis主节点建立的连接池
// JedisSentinelPool并不是说与sentinel建立的连接池,而是通过sentinel发现redis主节点并与其建立连接
JedisSentinelPool jedisSentinelPool = new JedisSentinelPool(masterName, sentinels, config, 3000, null);
Jedis jedis = null;
try {
jedis = jedisSentinelPool.getResource();
System.out.println(jedis.set("sentinel", "eleven"));
System.out.println(jedis.get("sentinel"));
} catch (Exception e) {
e.printStackTrace();
} finally {
// 注意这里不是关闭连接,在JedisPool模式下,Jedis会被归还给资源池。
if (jedis != null) {
jedis.close();
}
}
}
}

Spring Boot整合Redis哨兵模式,只需要引入如下依赖,并将哨兵的节点信息配置到配置文件中,即可通过自动注入的方式引入StringRedisTemplateRedisTemplate进行使用,

1
2
3
4
5
6
7
8
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
spring:
redis:
database: 0
timeout: 3000
sentinel: # 哨兵模式
master: mmaster # 主服务器所在集群名称
nodes: 192.168.0.60:26379,192.168.0.60:26380,192.168.0.60:26381
lettuce:
pool:
max-idle: 50
min-idle: 10
max-active: 100
max-wait: 1000
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@RestController
public class IndexController {
private static final Logger logger = LoggerFactory.getLogger(IndexController.class);
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 测试节点挂了哨兵重新选举新的master节点,客户端是否能动态感知到
* 新的master选举出来后,哨兵会把消息发布出去,客户端实际上是实现了一个消息监听机制,
* 当哨兵把新master的消息发布出去,客户端会立马感知到新master的信息,从而动态切换访问的masterip
*/
@RequestMapping("/test_sentinel")
public void testSentinel() throws InterruptedException {
int i = 1;
while (true){
try {
stringRedisTemplate.opsForValue().set("zhuge"+i, i+"");
System.out.println("设置key:"+ "zhuge" + i);
i++;
Thread.sleep(1000);
}catch (Exception e){
logger.error("错误:", e);
}
}
}
}

Redis 3.0以前的版本要实现集群一般是借助哨兵sentinel工具来监控master节点的状态,若master节点异常则会做主从切换,将某一台slave作为master,哨兵的配置略微复杂,且性能高可用性等各方面表现一般,且在主从切换瞬间存在访问瞬断情况,且哨兵模式只有一个主节点对外提供服务无法支持很高的并发,且单个主节点内存也不宜设置得过大否则会导致持久化文件过大影响数据恢复或主从同步的效率

集群模式

Redis高可用集群模式

Redis集群是一个由多个主从节点群组成的分布式服务器群,具有复制高可用分片特性,Redis集群不需要sentinel哨兵也能完成节点移除故障转移的功能。只需要将每个节点设置成集群模式,这种集群模式没有中心节点可水平扩展,据官方文档称可以线性扩展到上万个节点,官方推荐不超过1000个节点Redis集群的性能高可用性优于之前版本的哨兵模式,且集群配置非常简单Redis集群需要至少三个master主节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# 第一步:在第一台机器的/usr/local下创建文件夹redis-cluster,然后在其下面分别创建2个文件夾如下
mkdir -p /usr/local/redis-cluster
mkdir 8001 8004

# 把之前的redis.conf配置文件copy到8001下,修改如下内容:
daemonize yes
port 8001 # 分别对每个机器的端口号进行设置
pidfile /var/run/redis_8001.pid # 把pid进程号写入pidfile配置的文件
dir /usr/local/redis-cluster/8001/(指定数据文件存放位置,必须要指定不同的目录位置,不然会丢失数据)
cluster-enabled yes(启动集群模式)
cluster-config-file nodes-8001.conf(集群节点信息文件,这里800x最好和port对应上)
cluster-node-timeout 10000
# bind 127.0.0.1 绑定机器网卡ip,若有多块网卡可配多个ip,代表允许客户端通过机器的哪些网卡ip去访问
protected-mode no # 关闭保护模式
appendonly yes
# 如果要设置密码需要增加如下配置:
requirepass eleven # 设置redis访问密码
masterauth eleven # 设置集群节点间访问密码,跟上面一致

# 分别启动redis实例,然后检查是否启动成功
src/redis-server redis.conf
ps -ef | grep redis # 查看是否启动成功

# 首先需要确认集群机器之间redis实例能相互访问,可先把所有机器防火墙关掉,若不关闭防火墙则需打开redis服务端口和集群节点gossip通信端口16379,默认是在redis端口号上加1W
# systemctl stop firewalld # 临时关闭防火墙
# systemctl disable firewalld # 禁止开机启动


# 用redis-cli创建整个redis集群,redis5以前版本集群依靠ruby脚本redis-trib.rb实现
# 命令中的1代表为每个创建的主服务器节点创建一个从服务器节点
src/redis-cli -a zhuge --cluster create --cluster-replicas 1 192.168.0.61:8001 192.168.0.62:8002 192.168.0.63:8003 192.168.0.61:8004 192.168.0.62:8005 192.168.0.63:8006

# 验证集群, -a访问服务端密码,-c表示集群模式,指定ip地址和端口号
src/redis-cli -a eleven -c -h 192.168.0.61 -p 8001
cluster info # 查看集群信息
cluster nodes # 查看节点列表
# 关闭集群则需要逐个进行关闭,使用命令:
src/redis-cli -a eleven -c -h 192.168.0.60 -p 8001 shutdown

Redis集群Cluster将所有数据划分为16384个slots槽位每个节点负责其中一部分槽位。槽位的信息存储于每个节点中。当Redis Cluster的客户端来连接集群时,它也会得到一份集群的槽位配置信息并将其缓存在客户端本地。当客户端要查找某个key时,可直接定位到目标节点。Cluster默认会对key值使用crc16算法进行hash得到一个整数值,然后用这个整数值对16384进行取模来得到具体槽位

槽位的信息可能存在客户端与服务器不一致的情况,还需要纠正机制来实现槽位信息的校验调整,即跳转重定向。当客户端向节点发出指令,若发现指令的key所在槽位不在本节点,则向客户端发送一个携带目标操作的节点地址的特殊跳转指令,告诉客户端连该节点获取数据。客户端收到指令后除了跳转到正确的节点上去操作,还会同步更新纠正本地槽位映射表缓存

增加集群实例

首先启动实例,然后使用add-node命令新增一个主节点8007(master),前面的ip:port新增节点,后面的ip:port已知存在节点。当添加节点成功后,新增的节点不会有任何数据,因为它还没有分配任何的slothash需要为新节点手工分配hash。找到集群中的任意一个主节点,对其进行重新分片工作。

1
2
src/redis-cli -a eleven --cluster add-node 192.168.0.61:8007 192.168.0.61:8001 # 新增实例
src/redis-cli -a eleven --cluster reshard 192.168.0.61:8001 # 重新分片

添加从节点,同样先启动实例,然后通过add-node命令将实例添加到集群中,然后执行replicate命令来指定当前节点的主节点id,首先需要连接新加的当前节点8008的客户端,然后使用集群命令进行操作,把当前的8008节点指定到一个主节点下

1
cluster replicate 2728a594a0498e98e4b83a537e19f9a0a3790f38  # 后面这串id为8007的节点id

删除集群实例

对于从节点的删除直接使用del-node命令删除即可

1
src/redis-cli -a eleven --cluster del-node 192.168.0.61:8008 a1cfe35722d151cf70585cee21275565393c0956

若删除主节点,由于主节点中有分配的hash槽,必须先把hash槽放入其他可用主节点中,然后再进行移除操作,不然会丢失数据

1
2
src/redis-cli -a eleven --cluster reshard 192.168.0.61:8007 # 释放hash槽
src/redis-cli -a eleven --cluster del-node 192.168.0.61:8007 2728a594a0498e98e4b83a537e19f9a0a3790f38

集群节点通信机制

redis cluster节点间采取gossip协议进行通信;维护集群的元数据有集中式gossip;元数据包括集群节点信息主从角色节点数量各节点共享的数据

集中式优点在于元数据更新和读取时效性非常好,一旦元数据出现变更立即就会更新到集中式的存储中,其他节点读取时立即就可感知到;但压力全部集中在一个地方,可能导致元数据的存储压力。很多中间件都会借助zookeeper集中式存储元数据。

gossip协议包含多种消息,优点在于元数据不集中在一个地方更新比较分散,更新请求会陆陆续续,打到所有节点上去更新,有一定的延时降低了压力;缺点在于元数据更新有延时可能导致集群的一些操作会有一些滞后。

  • meet:某个节点发送meet给新加入的节点,让新节点加入集群中,然后新节点就会开始与其他节点进行通信;
  • ping每个节点都会频繁给其他节点发送ping,其中包含本节点状态以及本节点维护的集群元数据,互相通过ping交换元数据
  • pongpingmeet消息的返回,包含本节点的状态和其他信息,也可用于信息广播和更新
  • fail:某个节点判断另一个节点fail之后,就发送fail给其他节点,通知其他节点,指定的节点宕机了。

每个节点都有一个专门用于节点间gossip通信的端口,为本节点提供服务的端口号+10000每个节点每隔一段时间都会往另外几个节点发送ping消息,同时其他几点收到ping消息之后返回pong消息

网络并非是一直稳定的,网络抖动就是非常常见的一种现象,突然之间部分连接变得不可访问,然后很快又恢复正常。为解决这种问题,Redis Cluster提供了一种选项cluster-node-timeout,表示当某个节点持续timeout的时间失联时,才认定该节点出现故障,需要进行主从切换。若没有该选项网络抖动会导致主从频繁切换以及数据的重新复制。

集群选举

slave从节点发现master主节点变为FAIL状态时,便尝试进行Failover,以期成为新的master。由于挂掉的master可能会有多个slave,从而存在多个slave竞争成为master节点的过程。

首选slave发现自己的master变为FAIL,将自己记录的集群currentEpoch1,并广播FAILOVER_AUTH_REQUEST信息,其他节点收到该信息,只有master响应,判断请求者的合法性,并发送FAILOVER_AUTH_ACK,对每一个epoch只发送一次ack,尝试failover的slave收集master返回的FAILOVER_AUTH_ACK,slave收到超过半数master的ack后变成新Master,最后slave广播Pong消息通知其他集群节点。

从节点并不是在主节点一进入FAIL状态就马上尝试发起选举,而是有一定延迟,一定的延迟确保等待FAIL状态在集群中传播,若slave立即尝试选举,其它masters或许尚未意识到FAIL状态可能会拒绝投票

延迟计算公式DELAY = 500ms + random(0 ~ 500ms) + SLAVE_RANK * 1000msSLAVE_RANK表示此slave已经从master复制数据的总量的rankRank越小代表已复制的数据越新。故持有最新数据的slave将会首先发起选举。

脑裂数据丢失问题

Redis集群没有过半机制会有脑裂问题,网络分区导致脑裂后多个主节点对外提供写服务,一旦网络分区恢复会将其中一个主节点变为从节点,从而导致大量数据丢失。规避方法可以在redis配置里加上min-replicas-to-write配置写数据成功最少同步的slave数量,该数量可模仿大于半数机制配置。但该配置在一定程度上会影响集群的可用性,如slave要是少于1个,该集群就算leader正常也不能提供服务,需要具体场景权衡选择。

1
min-replicas-to-write 1  # 写数据成功最少同步的slave数量

当redis.conf的配置cluster-require-full-coverageno,表示当负责一个插槽的主库下线且没有相应的从库进行故障恢复时集群仍然可用,若为yes则集群不可用

新master的选举需要大于半数的集群master节点同意才能选举成功,若只有两个master节点,当其中一个挂了,是达不到选举新master的条件的。奇数个master节点可以在满足选举该条件的基础上节省一个节点,如三个master节点和四个master节点的集群相比,若都挂一个master节点都能选举新master节点,若都挂两个master节点都没法选举新master节点,故奇数的master节点更多的是从节省机器资源角度出发说的。

集群批量操作问题

对于类似msetmget这样的多个key的原生批量操作命令,redis集群只支持所有key落在同一slot的情况,若有多个key一定要用mset命令在redis集群上操作,则可在key的前面加上{XX},则数据分片hash计算只会是大括号里的值,这样能确保不同的key能落到同一slot中。

1
mset {user1}:1:name zhuge {user1}:1:age 18

集群使用

借助redis的java客户端jedis可以操作以上集群,引用jedis版本的maven坐标如下

1
2
3
4
5
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class JedisClusterTest {
public static void main(String[] args) throws IOException {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(20);
config.setMaxIdle(10);
config.setMinIdle(5);

Set<HostAndPort> jedisClusterNode = new HashSet<HostAndPort>();
jedisClusterNode.add(new HostAndPort("192.168.0.61", 8001));
jedisClusterNode.add(new HostAndPort("192.168.0.62", 8002));
jedisClusterNode.add(new HostAndPort("192.168.0.63", 8003));
jedisClusterNode.add(new HostAndPort("192.168.0.61", 8004));
jedisClusterNode.add(new HostAndPort("192.168.0.62", 8005));
jedisClusterNode.add(new HostAndPort("192.168.0.63", 8006));

JedisCluster jedisCluster = null;
try {
// connectionTimeout:指的是连接一个url的连接等待时间
// soTimeout:指的是连接上一个url,获取response的返回等待时间
jedisCluster = new JedisCluster(jedisClusterNode, 6000, 5000, 10, "eleven", config);
System.out.println(jedisCluster.set("cluster", "eleven"));
System.out.println(jedisCluster.get("cluster"));
} catch (Exception e) {
e.printStackTrace();
} finally {
if (jedisCluster != null) {
jedisCluster.close();
}
}
}
}

集群的Spring Boot整合Redis连接

1
2
3
4
5
6
7
8
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
spring:
redis:
database: 0
timeout: 3000
password: eleven
cluster:
nodes: 192.168.0.61:8001,192.168.0.62:8002,192.168.0.63:8003,192.168.0.61:8004,192.168.0.62:8005,192.168.0.63:8006
lettuce:
pool:
max-idle: 50
min-idle: 10
max-active: 100
max-wait: 1000
1
2
3
4
5
6
7
8
9
10
11
@RestController
public class IndexController {
@Autowired
private StringRedisTemplate stringRedisTemplate;

@RequestMapping("/test_cluster")
public void testCluster() throws InterruptedException {
stringRedisTemplate.opsForValue().set("eleven", "666");
System.out.println(stringRedisTemplate.opsForValue().get("eleven"));
}
}