Redis缓存问题
参考博客:
https://www.cnblogs.com/dalianpai/p/12678795.html
https://www.cnblogs.com/boye169/p/17039601.html
https://www.cnblogs.com/bandaoyu/p/16752497.html
https://www.cnblogs.com/123456789SI/p/16953027.html
https://www.cnblogs.com/wangyingshuo/p/14510524.html
Redis常见缓存问题
缓存穿透
一般读请求来了,先查下缓存,缓存有值命中,就直接返回;缓存没命中,就去查数据库,然后把数据库的值更新到缓存,再返回。
缓存穿透:指查询一个一定不存在的数据,由于缓存是不命中时需要从数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,进而给数据库带来压力。
通俗点说,读请求访问时,缓存和数据库都没有某个值,这样就会导致每次对这个值的查询请求都会穿透到数据库,这就是缓存穿透。
如何避免缓存穿透呢? 一般有3种方法。
- 1、缓存空对象:当存储层不命中后,可以给缓存设置个空值,或者默认值,同时会设置一个过期时间,之后再访问这个数据将会从缓存中获取,保护了后端数据源;
- 2、如果是非法请求,我们在API入口,对参数进行校验,过滤非法值。
- 3、使用布隆过滤器快速判断数据是否存在。即一个查询请求过来时,先通过布隆过滤器判断值是否存在,存在才继续往下查。
缓存雪崩
缓存雪崩是指,由于缓存层承载着大量请求,有效的保护了存储层,但是如果缓存层由于某些原因整体不能提供服务,于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会挂掉的情况。
解决方法:主要是保证缓存层服务高可用性,比如 Redis Sentinel 和 Redis Cluster 都实现了高可用,即使个别节点、个别机器、甚至是机房宕掉,依然可以提供服务
缓存击穿
缓存击穿:指热点key在某个时间点过期的时候,而恰好在这个时间点对这个Key有大量的并发请求过来,从而大量的请求打到db。
解决方案就有两种:
- 1.使用互斥锁方案。缓存失效时,不是立即去加载db数据,而是先使用某些带成功返回的原子操作命令,如(Redis的setnx)去操作,成功的时候,再去加载db数据库数据和设置缓存。否则就去重试获取缓存。
- 2.“永不过期”,是指没有设置过期时间,但是热点数据快要过期时,异步线程去更新和设置过期时间。
Redis 过期策略和内存淘汰策略
过期策略
往Redis里添加key-value的数据时,会有个选填参数——过期时间。如果设置了这个参数的值,Redis到过期时间后会自行把过期的数据给清除掉。是怎么清除的呢?
先来介绍几种过期策略:
定时过期
- 每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即对key进行清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。
惰性过期
- 只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。
定期过期
- 每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。
- expires字典会保存所有设置了过期时间的key的过期时间数据,其中,key是指向键空间中的某个键的指针,value是该键的毫秒精度的UNIX时间戳表示的过期时间。键空间是指该Redis集群中保存的所有键。
淘汰策略
在Redis可能没有需要过期的数据的情况下,还是会把我们的内存都占满。比如每个key设置的过期时间都很长或不过期,一直添加就有可能把内存给塞满。那么Redis又是怎么解决这个问题的呢?——那就是“淘汰策略”。
- volatile-lru:当内存不足以容纳新写入数据时,从设置了过期时间的key中使用LRU(最近最少使用)算法进行淘汰;
- allkeys-lru:当内存不足以容纳新写入数据时,从所有key中使用LRU(最近最少使用)算法进行淘汰。
- volatile-lfu:4.0版本新增,当内存不足以容纳新写入数据时,在过期的key中,使用LFU算法进行删除key。
- allkeys-lfu:4.0版本新增,当内存不足以容纳新写入数据时,从所有key中使用LFU算法进行淘汰;
- volatile-random:当内存不足以容纳新写入数据时,从设置了过期时间的key中,随机淘汰数据;。
- allkeys-random:当内存不足以容纳新写入数据时,从所有key中随机淘汰数据。
- volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的key中,根据过期时间进行淘汰,越早过期的优先被淘汰;
- noeviction:默认策略,当内存不足以容纳新写入数据时,新写入操作会报错
Redis高可用
为了实现高可用,通常的做法是,将数据库复制多个副本以部署在不同的服务器上,其中一台挂了也可以继续提供服务。Redis 实现高可用有三种部署模式:主从模式,哨兵模式,集群模式。
主从模式
主从模式中,Redis部署了多台机器,有主节点(master),负责写操作,有从节点(slaver),只负责读操作。主从复制即将master中的数据即时、有效的复制到slave中。
特征:一个master可以拥有多个slave,一个slave只对应一个master
主从复制包括全量复制,增量复制两种。一般当slave第一次启动连接master,或者认为是第一次连接,就采用全量复制
全量复制
全量复制流程如下:
1.slave发送sync命令到master。
2.master接收到SYNC命令后,fork子进程执行bgsave命令,生成RDB全量文件。
3.master使用缓冲区,记录RDB快照生成期间的所有写命令。
4.master执行完bgsave后,向所有slave发送RDB快照文件。
5.slave收到RDB快照文件后,载入、解析收到的快照。
6.master使用缓冲区replication buffer,记录RDB同步期间生成的所有写的命令。
7.master快照发送完毕后,开始向slave发送缓冲区中的写命令;
8.salve接受命令请求,并执行来自master缓冲区的写命令
redis2.8版本之后,已经使用psync来替代sync,因为sync命令非常消耗系统资源,psync的效率更高。
增量同步
slave与master全量同步之后,master上的数据,如果再次发生更新,就会触发增量复制。
当master节点发生数据增减时,就会触发replicationFeedSalves()函数,接下来在 Master节点上调用的每一个命令会使用replicationFeedSlaves()来同步到Slave节点。执行此函数之前呢,master节点会判断用户执行的命令是否有数据更新,如果有数据更新的话,并且slave节点不为空,就会执行此函数。这个函数作用就是:把用户执行的命令发送到所有的slave节点,让slave节点执行。流程如下:
哨兵模式
Redis做好主从复制之后,当master节点遇到故障,并不会自动切换slave节点继续运行,需要人工将从节点晋升为主节点,同时还要通知应用方更新主节点地址,达不到高可用的情况。
Redis从2.8开始正式提供了Redis Sentinel(哨兵)架构来解决这个问题:当master宕机后,哨兵可以根据投票选举机制,在众多slave节点中选出一个节点作为master继续执行。
哨兵的原理
哨兵模式,由一个或多个Sentinel实例组成的Sentinel系统,它可以监视所有的Redis主节点和从节点,并在被监视的主节点出现故障,进入下线状态时,通过投票选举机制自动将下线的master属下的某个从节点升级为新的master。但是呢,一个哨兵进程对Redis节点进行监控,就可能会出现问题(单点问题),因此,可以使用多个哨兵来进行监控Redis节点,并且各个哨兵之间还会进行监控。
哨兵的工作步骤
哨兵对数据节点集群进行监控的步骤
①首先主节点的信息是配置在哨兵的配置文件中(几个哨兵配置几次)。
②哨兵节点会和配置的主节点建立两个连接,分别为:命令连接和订阅连接。
-
命令连接: 为了让哨兵节点和master建立连接关系。
-
订阅连接: 哨兵会通过命令连接后,每10s发送一次info命令,通过info命令,主节点会返回自己的run_id 和自己的从节点信息。
③哨兵通过订阅连接获取到了从节点的信息,便也会向这些从节点建立两条连接,命令连接和订阅连接。
-
命令连接: 为了让哨兵和slave建立连接关系
-
订阅连接: 哨兵通过命令连接向从节点发送info命令,获取到从节点的一些信息(比如:run_id、role(职能)、从服务器的复制偏移量offset)。
哨兵与哨兵之间的监控步骤
①通过命令连接向服务器的setinel:hello频道发送一条消息,内容包括自己的ip端口、run_id、配置等信息。
②通过订阅连接对服务器的sentinel:hello 频道做了监听,所以所由向该频道发送的哨兵的消息都能被接收到。
③解析监听到的消息,进行分析提取。就可以指导还有哪些别的哨兵服务节点也在监听这些主从节点了,更新结构体将这些哨兵节点记录下来。
④向观察到的其它的哨兵节点建立命令连接,达到批次监控的目的。
简化流程:
哨兵集群之间向hello频道(哨兵之间共享数据的位置)发送自己的数据
哨兵从hello节点获取到其它的节点的位置
哨兵模式下的故障迁移
每个哨兵节点每1秒会向主节点、从节点及其它哨兵节点发送一次ping命令做一次心跳检测。
如果主节点在一定时间范围不回复或回复一个错误消息,那么这个哨兵就会认为这个主节点主观下线了(单方面)
当超过半数哨兵节点认为该节点主观下线了,这样就客观下面。(可以设置多少数量主观下线)
当主节点出现故障,此时哨兵节点会通过 Ratf算法(选举算法) 实现选举机制,共同选举出一个哨兵 节点为leader(领导者),来处理主节点的故障转移和通知。
所以整个运行的哨兵的集群数量不得少于3个节点。
由leader哨兵节点执行故障转移过程
将某一个节点升级为新的主节点,让其它从节点指向新的主节点。
Cluster集群模式
分布式锁
分布式锁的特征
redis实现分布式锁
方案一:SETNX + EXPIRE
先用setnx来抢锁,如果抢到之后,再用expire给锁设置一个过期时间,防止锁忘记了释放。
Redis Setnx(SET if Not eXists) 命令在指定的 key 不存在时,为 key 设置指定的值。设置成功,返回 1 。 设置失败,返回 0 。
Redis Expire 命令用于设置 key 的过期时间,key 过期后将不再可用。单位以秒计。
假设某电商网站的某商品做秒杀活动,key可以设置为key_resource_id,value设置任意值,伪代码如下:
if(jedis.setnx(key_resource_id,lock_value) == 1){ //加锁
expire(key_resource_id,100); //设置过期时间
try {
do something //业务请求
}catch(){
}
finally {
jedis.del(key_resource_id); //释放锁
}
}
但是这个方案中,setnx和expire两个命令分开了,「不是原子操作」。如果执行完setnx加锁,正要执行expire设置过期时间时,发生异常,那么这个锁就“长生不老”了,「别的线程永远获取不到锁啦」。
方案二:SETNX + value值是(系统时间+过期时间)
为了解决方案一,「发生异常锁得不到释放的场景」,可以把过期时间放到setnx的value值里面。如果加锁失败,再拿出value值校验一下即可。加锁代码如下:
long expires = System.currentTimeMillis() + expireTime; //系统时间+设置的过期时间
String expiresStr = String.valueOf(expires);
// 如果当前锁不存在,返回加锁成功
if (jedis.setnx(key_resource_id, expiresStr) == 1) {
return true;
}
// 如果锁已经存在,获取锁的过期时间
String currentValueStr = jedis.get(key_resource_id);
// 如果获取到的过期时间,小于系统当前时间,表示已经过期
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
// 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间(不了解redis的getSet命令的小伙伴,可以去官网看下哈)
String oldValueStr = jedis.getSet(key_resource_id, expiresStr);
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
// 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才可以加锁
return true;
}
}
//其他情况,均返回加锁失败
return false;
}
方案缺点:
- 过期时间是客户端自己生成的(System.currentTimeMillis()是当前系统的时间),必须要求分布式环境下,每个客户端的时间必须同步。
- 如果锁过期的时候,并发多个客户端同时请求过来,都执行jedis.getSet(),最终只能有一个客户端加锁成功,但是该客户端锁的过期时间,可能被别的客户端覆盖
- 该锁没有保存持有者的唯一标识,可能被别的客户端释放/解锁。
方案三:使用Lua脚本(包含SETNX + EXPIRE两条指令)
实际上,我们还可以使用Lua脚本来保证原子性(包含setnx和expire两条指令),lua脚本如下:
if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then
redis.call('expire',KEYS[1],ARGV[2])
else
return 0
end;
加锁代码如下:
String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" +
" redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";
Object result = jedis.eval(lua_scripts, Collections.singletonList(key_resource_id), Collections.singletonList(values));
//判断是否成功
return result.equals(1L);
方案四:SET的扩展命令
保证SETNX + EXPIRE两条指令的原子性,还可以巧用Redis的SET指令扩展参数,它也是原子性的!
SET key value[EX seconds][PX milliseconds][NX|XX]
NX :表示key不存在的时候,才能set成功,也即保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁,才能获取。
EX seconds :设定key的过期时间,时间单位是秒。
PX milliseconds: 设定key的过期时间,单位为毫秒
XX: 仅当key存在时设置值
if(jedis.set(key_resource_id, lock_value, "NX", "EX", 100s) == 1){ //加锁
try {
do something //业务处理
}catch(){
}
finally {
jedis.del(key_resource_id); //释放锁
}
}
存在问题:
问题一:「锁过期释放了,业务还没执行完」。
问题二:「锁被别的线程误删」。假设线程a执行完后,去释放锁。但是它不知道当前的锁可能是线程b持有的(线程a去释放锁时,有可能过期时间已经到了,此时线程b进来占有了锁)。那线程a就把线程b的锁释放掉了,但是线程b临界区业务代码可能都还没执行完呢。
方案五:SET EX PX NX + 校验唯一随机值,再删除
既然锁可能被别的线程误删,那我们给value值设置一个标记当前线程唯一的随机数,在删除的时候,校验一下
if(jedis.set(key_resource_id, uni_request_id, "NX", "EX", 100s) == 1){ //加锁
try {
do something //业务处理
}catch(){
}
finally {
//判断是不是当前线程加的锁,是才释放
if (uni_request_id.equals(jedis.get(key_resource_id))) {
jedis.del(lockKey); //释放锁
}
}
}
「判断是不是当前线程加的锁」和「释放锁」 不是一个原子操作。如果调用jedis.del()释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁。
为了更严谨,一般也是用lua脚本代替。lua脚本如下:
if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end;
可能存在「锁过期释放,业务没执行完」的问题
方案六:Redisson框架
设想一下,是否可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。
当前开源框架Redisson解决了这个问题。我们一起来看下Redisson底层原理图吧:
只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用Redisson解决了「锁过期释放,业务没执行完」问题。
方案七:多机实现的分布式锁Redlock+Redisson
前面六种方案都只是基于单机版的讨论,还不是很完美。其实Redis一般都是集群部署的:
如果线程一在Redis的master节点上拿到了锁,但是加锁的key还没同步到slave节点。恰好这时,master节点发生故障,一个slave节点就会升级为master节点。线程二就可以获取同个key的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。
为了解决这个问题,提出一种高级的分布式锁算法:Redlock。Redlock核心思想是这样的:
搞多个Redis master部署,以保证它们不会同时宕掉。并且这些master节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个master实例上,是与在Redis单实例,使用相同方法来获取和释放锁。
我们假设当前有5个Redis master节点,在5台服务器上面运行这些Redis实例。
RedLock的实现步骤:如下
1.获取当前时间,以毫秒为单位。
2.按顺序向5个master节点请求加锁。客户端设置网络连接和响应超时时间,并且超时时间要小于锁的失效时间。(假设锁自动失效时间为10秒,则超时时间一般在5-50毫秒之间,我们就假设超时时间是50ms吧)。如果超时,跳过该master节点,尽快去尝试下一个master节点。
3.客户端使用当前时间减去开始获取锁时间(即步骤1记录的时间),得到获取锁使用的时间。当且仅当超过一半(N/2+1,这里是5/2+1=3个节点)的Redis master节点都获得锁,并且使用的时间小于锁失效时间时,锁才算获取成功。(如上图,10s> 30ms+40ms+50ms+4m0s+50ms)
如果取到了锁,key的真正有效时间就变啦,需要减去获取锁所使用的时间。
如果获取锁失败(没有在至少N/2+1个master实例取到锁,有或者获取锁时间已经超过了有效时间),客户端要在所有的master节点上解锁(即便有些master节点根本就没有加锁成功,也需要解锁,以防止有些漏网之鱼)。
简化下步骤就是:
1、按顺序向5个master节点请求加锁
2、根据设置的超时时间来判断,是不是要跳过该master节点。
3、如果大于等于3个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功啦。
4、如果获取锁失败,解锁!