11.7 哨兵监控故障转移监控
演示故障转移
#查看主节点所在端口6379进程的PID [root@localhost src]# lsof -i:6379 #杀死主节点的redis服务模拟故障 [root@localhost src]# kill -9 PID
查看哨兵节点信息
[root@localhost src]# ./redis-cli -p 26379 127.0.0.1:26379> info sentinel # Sentinel sentinel_masters:1 sentinel_tilt:0 sentinel_running_scripts:0 sentinel_scripts_queue_length:0 sentinel_simulate_failure_flags:0 master0:name=mymaster,status=ok,address=127.0.0.1:6381,slaves=5,sentinels=3
注意:
会发现主节点还没有切换过来,因为哨兵发现主节点故障并转移,需要一段时间。
重启6379节点
[root@localhost src]# ./redis-cli info replication # Replication role:slave master_host:127.0.0.1 master_port:6381 master_link_status:down
该节点变为了从节点
6379节点的配置文件被改写
故障转移阶段,哨兵和主从节点的配置文件都会被改写
include /usr/local/redis/redis.conf pidfile "/var/run/redis_6379.pid" port 6379 dbfilename "dump6379.rdb" # Generated by CONFIG REWRITE daemonize yes protected-mode no appendonly yes slowlog-max-len 1200 slowlog-log-slower-than 1000 save 5 1 user default on nopass ~* &* +@all dir "/usr/local/redis" replicaof 127.0.0.1 6381
结论
- 哨兵系统中的主从节点,与普通的主从节点并没有什么区别,故障发现和转移是由哨兵来控制和完成的。
- 哨兵节点本质上是redis节点,是redis节点的不同进程。
- 每个哨兵节点,只需要配置监控主节点,便可以自动发现其他的哨兵节点和从节点。因为主节点中包含从节点的信息。
- 在哨兵节点启动和故障转移阶段,各个节点的配置文件会被重写(config rewrite)。
11.8 Cluster模式概述
Redis有三种集群模式
- 主从模式
- Sentinel模式
- Cluster模式
哨兵模式的缺点
缺点:
- 当master挂掉的时候,sentinel 会选举出来一个 master,选举的时候是没有办法去访问Redis的,会存在访问瞬断的情况;
- 哨兵模式,对外只有master节点可以写,slave节点只能用于读。尽管Redis单节点最多支持10W的QPS,但是在电商大促的时候,写数据的压力全部在master上。
- Redis的单节点内存不能设置过大,若数据过大在主从同步将会很慢;在节点启动的时候,时间特别长;
Cluster模式概述
Redis集群是一个由多个主从节点群组成的分布式服务集群,它具有复制、高可用和分片特性。当缓存的数据特别大的时候建议使用Cluster模式。数据不大的情况下使用哨兵模式即可。
Redis集群的优点
- Redis集群有多个master,可以减小访问瞬断问题的影响
- Redis集群有多个master,可以提供更高的并发量
- Redis集群可以分片存储,这样就可以存储更多的数据
11.9 Cluster模式搭建
Redis的集群搭建最少需要3个master节点,我们这里搭建3个master,每个下面挂一个slave节点,总共6个Redis节点;
环境准备
第1台机器(纯净): 192.168.66.100 8001端口 8002端口 第2台机器(redis-2): 192.168.66.101 8001端口 8002端口 第3台机器(redis-3): 192.168.66.102 8001端口 8002端口
关闭三台虚拟机的防火墙
systemctl stop firewalld.service
将纯净虚拟机中的redis压缩文件上传到redis-2和redis-3虚拟机中
scp -r redis-6.2.6.tar.gz/ 192.168.66.101:$PWD scp -r redis-6.2.6.tar.gz/ 192.168.66.102:$PWD
将redis-2和redis-3的压缩文件分别解压到/user/local下
tar -zxvf redis-6.2.6.tar.gz -C /usr/local
将redis-2和redis-3中解压后的文件进行编译
[root@localhost redis-6.2.6]# make
注意:文件的编译需要C语言环境。
#检查当前的虚拟机是否安装了C语言环境 gcc --version #安装gcc yum install -y gcc
将编译完的文件进行安装
[root@localhost redis-6.2.6]# make install
分别在三台机器的redis安装目录下创建文件夹redis-cluster
[root@localhost redis-6.2.6]# mkdir redis-cluster #在文件夹下创建用于存放配置文件的文件夹 [root@localhost redis-cluster]# mkdir 8001 [root@localhost redis-cluster]# madir 8002
在纯净虚拟机中将redis的核心配置文件redis.conf拷贝到/redis-cluster/8001下
[root@localhost redis-6.2.6]# cp redis.conf redis-cluster/8001
修改8001下的redis.conf配置文件
#修改配置文件中的端口号为8001 port 8001 #开启守护线程(后台运行) daemonize yes #修改pidfile,pidfile参数用于指定一个文件路径,用于存储Redis服务器进程的PID(进程ID) pidfile /var/run/redis_8001.pid #指定数据文件存放位置,必须要指定不同的目录位置,不然会丢失数据 dir /usr/local/redis-6.2.6/redis-cluster/8001/ #启动集群模式 cluster-enabled yes #集群节点信息文件,这里800x最好和port对应上 cluster-config-file nodes-8001.conf # 节点离线的超时时间 cluster-node-timeout 5000 #去掉bind绑定访问ip信息 #bind 127.0.0.1 #关闭保护模式 protected-mode no #启动AOF文件 appendonly yes
将该配置文件复制到8002目录下
[root@localhost 8001]# cp redis.conf ../8002 #将8001改为8002 :%s/8001/8002/g
将本机机器上的文件拷贝到另外两台机器上
# 第二台机器 [root@localhost 8001]# scp -r redis.conf 192.168.66.101:$PWD [root@localhost 8002]# scp -r redis.conf 192.168.66.101:$PWD #第三台机器 [root@localhost 8001]# scp -r redis.conf 192.168.66.102:$PWD [root@localhost 8002]# scp -r redis.conf 192.168.66.102:$PWD
分别启动这6个redis实例
[root@localhost src]# ./redis-server ../redis-cluster/8001/redis.conf [root@localhost src]# ./redis-server ../redis-cluster/8002/redis.conf
检查是否启动成功
ps -ef |grep redis
在纯净虚拟机使用redis-cli创建整个redis集群
[root@localhost src]# ./redis-cli --cluster create --cluster-replicas 1 192.168.66.100:8001 192.168.66.100:8002 192.168.66.101:8001 192.168.66.101:8002 192.168.66.102:8001 192.168.66.102:8002
- --cluster-replicas 1:表示1个master下挂1个slave; --cluster-replicas 2:表示1个master下挂2个slave。
查看帮助命令
src/redis‐cli --cluster help
参数:
- create:创建一个集群环境host1:port1 ... hostN:portN
- call:可以执行redis命令
- add-node:将一个节点添加到集群里,第一个参数为新节点的ip:port,第二个参数为集群中任意一个已经存在的节点的ip:port
- del-node:移除一个节点
- reshard:重新分片
- check:检查集群状态
验证集群 在纯净虚拟机连接任意一个客户端
[root@localhost src]# ./redis-cli -c -h 192.168.66.101 -p 8001 192.168.66.101:8001>
参数:
- ‐c表示集群模式
- -h指定ip地址
- -p表示端口号
查看集群的信息
cluster info
11.10 Cluster模式原理
Redis Cluster将所有数据划分为16384个slots(槽位),每个节点负责其中一部分槽位。槽位的信息存储于每个节点中。只有master节点会被分配槽位,slave节点不会分配槽位。通过槽位就能直接定位到数据的存储的位置。当客户端连接到redis集群的时候,集群会返回给客户端一个槽位表并缓存到客户端本地,该表明确划分了各redis服务器数据的槽位范围,存储数据时通过槽位定位算法决定数据保存在哪个服务器上。
槽位定位算法: k1 = 127001
Cluster 默认会对 key 值使用 crc16 算法进行 hash 得到一个整数值,然后用这个整数值对 16384 进行取模来得到具体槽位。
HASH_SLOT = CRC16(key) % 16384
192.168.66.101:8001> set k1 v1 -> Redirected to slot [12706] located at 192.168.66.102:8001 OK 192.168.66.102:8001>
在101机器主节点上存储数据,返回槽位为12706,当前主节点切换为102表示该数据存储到了102机器上。
注意:
根据k1计算出的槽值进行切换节点,并存入数据。不在一个slot下的键值,是不能使用mget、mset等多建操作。
可以通过{}来定义组的概念,从而使key中{}内相同内容的键值对放到同一个slot中。
192.168.66.102:8001> mset k1{test} v1 k2{test} v2 k3{test} v3 -> Redirected to slot [6918] located at 192.168.66.101:8001 OK 192.168.66.101:8001> get k2{test} "v2"
查看节点的信息
192.168.66.101:8001> cluster nodes
杀死Master节点
lsof -i:8001 kill -9 pid
当一个master死往后会重新选举产生一个master。
11.11 Java操作Redis集群
Jedis整合Redis
<dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>3.6.0</version> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.12</version> <scope>test</scope> </dependency>
public class TestJedis { @Test public void testCluster() { //Set集合保存节点数据 Set<HostAndPort> redisNodes = new HashSet<HostAndPort>(); redisNodes.add(new HostAndPort("192.168.66.100",8001)); redisNodes.add(new HostAndPort("192.168.66.100",8002)); redisNodes.add(new HostAndPort("192.168.66.101",8001)); redisNodes.add(new HostAndPort("192.168.66.101",8002)); redisNodes.add(new HostAndPort("192.168.66.102",8001)); redisNodes.add(new HostAndPort("192.168.66.102",8002)); //构建redis集群实例,建立连接 JedisCluster jedisCluster = new JedisCluster(redisNodes); //添加元素 jedisCluster.set("name","zhangsan"); //获取元素 System.out.println(jedisCluster.get("name")); } }
SpringBoot 整合 Redis
pom依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis-reactive</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>
配置文件
##服务器 spring.redis.cluster.nodes=192.168.66.100:8001,192.168.66.100:8002,192.168.66.101:8001,192.168.66.101:8002,192.168.66.102:8001,192.168.66.102:8002 ## 连接池最大连接数(使用负值表示没有限制) spring.redis.pool.max-active=300 ## Redis数据库索引(默认为0) spring.redis.database=0 ## 连接池最大阻塞等待时间(使用负值表示没有限制) spring.redis.pool.max-wait=-1 ## 连接池中的最大空闲连接 spring.redis.pool.max-idle=100 ## 连接池中的最小空闲连接 spring.redis.pool.min-idle=20 ## 连接超时时间(毫秒) spring.redis.timeout=60000
@SpringBootTest class RedisApplicationTests { @Resource private StringRedisTemplate stringRedisTemplate; @Test void test(){ //添加值 stringRedisTemplate.opsForValue().set("age","23"); //获取值 String age = stringRedisTemplate.opsForValue().get("age"); System.out.println(age); } }
十二、Redis企业级解决方案
12.1 Redis脑裂
什么是Redis的集群脑裂
Redis的集群脑裂是指因为网络问题,导致Redis Master节点跟Redis slave节点和Sentinel集群处于不同的网络分区,此时因为sentinel集群无法感知到master的存在,所以将slave节点提升为master节点。说白了就是sentinel向主节点“喊话”的时候,主节点因为网络的问题没有及时恢复,让sentinel误认为主节点已经挂了。又重新选举产生了新的主节点。
注意:
此时存在两个不同的master节点,就像一个大脑分裂成了两个。集群脑裂问题中,如果客户端还在基于原来的master节点继续写入数据,那么新的Master节点将无法同步这些数据,当网络问题解决之后,sentinel集群将原先的Master节点降为slave节点,此时再从新的master中同步数据,将会造成大量的数据丢失。
解决方案
redis.conf配置参数:
min-replicas-to-write 1 min-replicas-max-lag 5
参数:
- 第一个参数表示最少的slave节点为1个
- 第二个参数表示数据复制和同步的延迟不能超过5秒
配置了这两个参数:如果发生脑裂原Master会在客户端写入操作的时候拒绝请求。这样可以避免大量数据丢失。
12.2 缓存预热
缓存冷启动
因为浏览器请求数据时先到redis缓存中查找数据,假如缓存中没有数据就会访问数据库获取数据,那么并发量上来Mysql就裸奔挂掉了。
缓存冷启动场景
新启动的系统没有任何缓存数据,在缓存重建数据的过程中,系统性能和数据库负载都不太好,所以最好是在系统上线之前就把要缓存的热点数据加载到缓存中,这种缓存预加载手段就是缓存预热。
解决思路
- 提前给redis中灌入部分数据,再提供服务
- 如果数据量非常大,就不可能将所有数据都写入redis,因为数据量太大了,第一是因为耗费的时间太长了,第二根本redis容纳不下所有的数据
- 需要根据当天的具体访问情况,实时统计出访问频率较高的热数据
- 然后将访问频率较高的热数据写入redis中,肯定是热数据也比较多,我们也得多个服务并行读取数据去写,并行的分布式的缓存预热
12.3 缓存穿透
概念
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。
解释:
缓存穿透是指用户查询数据,在数据库没有,自然在缓存中也不会有。这样就导致用户查询的时候,在缓存中找不到,每次都要去数据库再查询一遍,然后返回空(相当于进行了两次无用的查询)。这样请求就绕过缓存直接查数据库,这也是经常提的缓存命中率问题。
解决方案
- 对空值缓存:如果一个查询返回的数据为空(不管数据是否存在),我们仍然把这个空结果缓存,设置空结果的过期时间会很短,最长不超过5分钟。
- 布隆过滤器:如果想判断一个元素是不是在一个集合里,一般想到的是将集合中所有元素保存起来,然后通过比较确定。
布隆过滤器
布隆过滤器是一种数据结构,比较巧妙的概率型数据结构(probabilistic data structure),特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”。
注意:
布隆说不存在一定不存在,布隆说存在你要小心了,它有可能不存在。
代码实现布隆过滤器
<dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.7.17</version> </dependency>
// 初始化 注意 构造方法的参数大小10 决定了布隆过滤器BitMap的大小 BitMapBloomFilter filter = new BitMapBloomFilter(10); filter.add("123"); filter.add("abc"); filter.add("ddd"); boolean abc = filter.contains("abc"); System.out.println(abc);
12.4 缓存击穿
概念
某一个热点 key,在缓存过期的一瞬间,同时有大量的请求打进来,由于此时缓存过期了,所以请求最终都会走到数据库,造成瞬时数据库请求量大、压力骤增,甚至可能打垮数据库。
解决方案
- 互斥锁:在并发的多个请求中,只有第一个请求线程能拿到锁并执行数据库查询操作,其他的线程拿不到锁就阻塞等着,等到第一个线程将数据写入缓存后,其他线程直接查询缓存。
- 热点数据不过期:直接将缓存设置为不过期,然后由定时任务去异步加载数据,更新缓存。
public String get(String key) throws InterruptedException { String value = jedis.get(key); // 缓存过期 if (value == null){ // 设置3分钟超时,防止删除操作失败的时候 下一次缓存不能load db Long setnx = jedis.setnx(key + "mutex", "1"); jedis.pexpire(key + "mutex", 3 * 60); // 代表设置成功 if (setnx == 1){ // 数据库查询 //value = db.get(key); //保存缓存 jedis.setex(key,3*60,""); jedis.del(key + "mutex"); return value; }else { // 这个时候代表同时操作的其他线程已经load db并设置缓存了。 需要重新重新获取缓存 Thread.sleep(50); // 重试 return get(key); } }else { return value; } }
12.5 缓存雪崩
概念
缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。
缓存正常从Redis中获取,示意图如下:
缓存失效瞬间示意图如下:
解决方案
- 过期时间打散:既然是大量缓存集中失效,那最容易想到就是让他们不集中生效。可以给缓存的过期时间时加上一个随机值时间,使得每个 key 的过期时间分布开来,不会集中在同一时刻失效。
- 热点数据不过期:该方式和缓存击穿一样,也是要着重考虑刷新的时间间隔和数据异常如何处理的情况。
- 加互斥锁: 该方式和缓存击穿一样,按 key 维度加锁,对于同一个 key,只允许一个线程去计算,其他线程原地阻塞等待第一个线程的计算结果,然后直接走缓存即可。
public Object GetProductListNew(String cacheKey) { int cacheTime = 30; String lockKey = cacheKey; // 获取key的缓存 String cacheValue = jedis.get(cacheKey); // 缓存未失效返回缓存 if (cacheValue != null) { return cacheValue; } else { // 枷锁 synchronized(lockKey) { // 获取key的value值 cacheValue = jedis.get(cacheKey); if (cacheValue != null) { return cacheValue; } else { //这里一般是sql查询数据 // db.set(key) // 添加缓存 jedis.set(cacheKey,""); } } return cacheValue; } }
注意:
加锁排队只是为了减轻数据库的压力,并没有提高系统吞吐量。
12.6 Redis开发规范
key设计技巧
- 1、把表名转换为key前缀,如
tag:
- 2、把第二段放置用于区分key的字段,对应msyql中主键的列名,如
user_id
- 3、第三段放置主键值,如
2,3,4
- 4、第四段写存储的列名
user_id | name | age |
1 | 张三 | 18 |
2 | lisi | 20 |
# 表名 主键 主键值 存储列名字 set user:user_id:1:name 张三 set user:user_id:1:age 20 #查询这个用户 keys user:user_id:9*
value设计
拒绝bigkey
防止网卡流量、慢查询,string类型控制在10KB以内,hash、list、set、zset元素个数不要超过5000。
命令使用
1、禁用命令
禁止线上使用keys、flushall、flushdb等,通过redis的rename机制禁掉命令,或者使用scan的方式渐进式处理。
2、合理使用select
redis的多数据库较弱,使用数字进行区分,很多客户端支持较差,同时多业务用多数据库实际还是单线程处理,会有干扰。
3、使用批量操作提高效率
- 原生命令:例如mget、mset。
- 非原生命令:可以使用pipeline提高效率。
注意:
但要注意控制一次批量操作的元素个数(例如500以内,实际也和元素字节数有关)。
4、不建议过多使用Redis事务功能
Redis的事务功能较弱(不支持回滚),而且集群版本(自研和官方)要求一次事务操作的key必须在一个slot上。
客户端使用
- Jedis :https://github.com/xetorthio/jedis 重点推荐
- Spring Data redis :https://github.com/spring-projects/spring-data-redis 使用Spring框架时推荐
- Redisson :https://github.com/mrniko/redisson 分布式锁、阻塞队列的时重点推荐
1、避免多个应用使用一个Redis实例
不相干的业务拆分,公共数据做服务化。
2、使用连接池
可以有效控制连接,同时提高效率,标准使用方式:
执行命令如下: Jedis jedis = null; try { jedis = jedisPool.getResource(); //具体的命令 jedis.executeCommand() } catch (Exception e) { logger.error("op key {} error: " + e.getMessage(), key, e); } finally { //注意这里不是关闭连接,在JedisPool模式下,Jedis会被归还给资源池。 if (jedis != null) jedis.close(); }
12.7 数据一致性
缓存已经在项目中被广泛使用,在读取缓存方面,大家没啥疑问,都是按照下图的流程来进行业务操作。
缓存说明:
从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。缓存过期的时间越短,越能保证数据的一致性。
三种更新策略
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
先更新数据库,再更新缓存
这套方案,大家是普遍反对的。为什么呢?
线程安全角度
同时有请求A和请求B进行更新操作,那么会出现
(1)线程A更新了数据库
(2)线程B更新了数据库
(3)线程B更新了缓存
(4)线程A更新了缓存
这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。
先删缓存,再更新数据库
该方案会导致不一致的原因是。同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:
(1)请求A进行写操作,删除缓存 (2)请求B查询发现缓存不存在 (3)请求B去数据库查询得到旧值 (4)请求B将旧值写入缓存 (5)请求A将新值写入数据库
注意:
该数据永远都是脏数据。
这种情况存在并发问题吗?
(1)缓存刚好失效 (2)请求A查询数据库,得一个旧值 (3)请求B将新值写入数据库 (4)请求B删除缓存 (5)请求A将查到的旧值写入缓存
发生这种情况的概率又有多少?
发生上述情况有一个先天性条件,就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。可是,大家想想,数据库的读操作的速度远快于写操作的,因此步骤(3)耗时比步骤(2)更短,这一情形很难出现。