• 博客园logo
  • 会员
  • 众包
  • 新闻
  • 博问
  • 闪存
  • 赞助商
  • HarmonyOS
  • Chat2DB
    • 搜索
      所有博客
    • 搜索
      当前博客
  • 写随笔 我的博客 短消息 简洁模式
    用户头像
    我的博客 我的园子 账号设置 会员中心 简洁模式 ... 退出登录
    注册 登录

千里之行,始于足下

  • 博客园
  • 联系
  • 订阅
  • 管理

公告

View Post

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,或者认为是第一次连接,就采用全量复制

全量复制

全量复制流程如下:

image


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节点执行。流程如下:

image

哨兵模式

Redis做好主从复制之后,当master节点遇到故障,并不会自动切换slave节点继续运行,需要人工将从节点晋升为主节点,同时还要通知应用方更新主节点地址,达不到高可用的情况。

Redis从2.8开始正式提供了Redis Sentinel(哨兵)架构来解决这个问题:当master宕机后,哨兵可以根据投票选举机制,在众多slave节点中选出一个节点作为master继续执行。

哨兵的原理

哨兵模式,由一个或多个Sentinel实例组成的Sentinel系统,它可以监视所有的Redis主节点和从节点,并在被监视的主节点出现故障,进入下线状态时,通过投票选举机制自动将下线的master属下的某个从节点升级为新的master。但是呢,一个哨兵进程对Redis节点进行监控,就可能会出现问题(单点问题),因此,可以使用多个哨兵来进行监控Redis节点,并且各个哨兵之间还会进行监控。

image

哨兵的工作步骤

哨兵对数据节点集群进行监控的步骤

①首先主节点的信息是配置在哨兵的配置文件中(几个哨兵配置几次)。

②哨兵节点会和配置的主节点建立两个连接,分别为:命令连接和订阅连接。

  • 命令连接: 为了让哨兵节点和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集群模式

分布式锁

分布式锁的特征

image

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底层原理图吧:
image

只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用Redisson解决了「锁过期释放,业务没执行完」问题。

方案七:多机实现的分布式锁Redlock+Redisson

前面六种方案都只是基于单机版的讨论,还不是很完美。其实Redis一般都是集群部署的:

image

如果线程一在Redis的master节点上拿到了锁,但是加锁的key还没同步到slave节点。恰好这时,master节点发生故障,一个slave节点就会升级为master节点。线程二就可以获取同个key的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。

为了解决这个问题,提出一种高级的分布式锁算法:Redlock。Redlock核心思想是这样的:

搞多个Redis master部署,以保证它们不会同时宕掉。并且这些master节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个master实例上,是与在Redis单实例,使用相同方法来获取和释放锁。

我们假设当前有5个Redis master节点,在5台服务器上面运行这些Redis实例。

image

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、如果获取锁失败,解锁!

posted on 2023-03-20 17:56  我隔壁是老王  阅读(116)  评论(0)    收藏  举报

刷新页面返回顶部
 
博客园  ©  2004-2025
浙公网安备 33010602011771号 浙ICP备2021040463号-3