Redis学习笔记(5)

Redis学习笔记(5)

1.Redis高端面试-缓存穿透,缓存击穿,缓存雪 崩问题

1.1缓存的概念

什么是缓存?

广义的缓存就是在第一次加载某些可能会复用数据的时候,在加载数据的同时,将数据放到一个指定的地点做保 存。再下次加载的时候,从这个指定地点去取数据。这里加缓存是有一个前提的,就是从这个地方取数据,比从数 据源取数据要快的多。

java狭义一些的缓存,主要是指三大类

  1. 虚拟机缓存(ehcache,JBoss Cache)
  2. 分布式缓存(redis,memcache)
  3. 数据库缓存

正常来说,速度由上到下依次减慢

缓存取值图:

在这里插入图片描述

1.2 缓存雪崩

缓存雪崩产生的原因

缓存雪崩通俗简单的理解就是:由于原有缓存失效(或者数据未加载到缓存中),新缓存未到期间(缓存正常从 Redis中获取,如下图)所有原本应该访问缓存的请求都去查询数据库了,而对数据库CPU和内存造成巨大压力, 严重的会造成数据库宕机,造成系统的崩溃。

在这里插入图片描述

缓存失效的时候如下图:

在这里插入图片描述

解决方案:

1:在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据 和写缓存,其他线程等待。虽然能够在一定的程度上缓解了数据库的压力但是与此同时又降低了系统的吞吐量。

public Users getByUsers(Long id) {
// 1.先查询redis
String key = this.getClass().getName() + "-" + Thread.currentThread().getStackTrace()
[1].getMethodName()
+ "-id:" + id;
String userJson = redisService.getString(key);
if (!StringUtils.isEmpty(userJson)) {
Users users = JSONObject.parseObject(userJson, Users.class);
return users;
}
Users user = null;
try {
lock.lock();
// 查询db
user = userMapper.getUser(id);
redisService.setSet(key, JSONObject.toJSONString(user));
} catch (Exception e) {
} finally {
lock.unlock(); // 释放锁
}
return user;
}

注意:加锁排队只是为了减轻数据库的压力,并没有提高系统吞吐量。假设在高并发下,缓存重建期间key是锁着 的,这是过来1000个请求999个都在阻塞的。同样会导致用户等待超时,这是个治标不治本的方法。

2: 分析用户的行为,不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀。

1.3 缓存穿透

缓存穿透是指用户查询数据,在数据库没有,自然在缓存中也不会有。这样就导致用户查询的时候,在缓存中找 不到,每次都要去数据库再查询一遍,然后返回空。这样请求就绕过缓存直接查数据库,这也是经常提的缓存命中 率问题。

解决方案:

1.如果查询数据库也为空,直接设置一个默认值存放到缓存,这样第二次到缓冲中获取就有值了,而不会继续访问 数据库,这种办法最简单粗暴。

2.把空结果,也给缓存起来,这样下次同样的请求就可以直接返回空了,既可以避免当查询的值为空时引起的缓存 穿透。同时也可以单独设置个缓存区域存储空值,对要查询的key进行预先校验,然后再放行给后面的正常缓存处 理逻辑。

public String getByUsers2(Long id) {
// 1.先查询redis
String key = this.getClass().getName() + "-" + Thread.currentThread().getStackTrace()
[1].getMethodName()+ "-id:" + id;
String userName = redisService.getString(key);
if (!StringUtils.isEmpty(userName)) {
	return userName;
}
System.out.println("######开始发送数据库DB请求########");
Users user = userMapper.getUser(id);
String value = null;
if (user == null) {
		// 标识为null
		value = "";
} else {
		value = user.getName();
}
		redisService.setString(key, value);
		return value;
}

注意:再给对应的ip存放真值的时候,需要先清除对应的之前的空缓存。

1.4 缓存击穿

对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。 这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是 很多key。

热点key:

某个key访问非常频繁,当key失效的时候有大量线程来构建缓存,导致负载增加,系统崩溃。

解决办法:

①使用锁,单机用synchronized,lock等,分布式用分布式锁。

②缓存过期时间不设置,而是设置在key对应的value里。如果检测到存的时间超过过期时间则异步更新缓存。

2.Redis高端面试-分布式锁

2.1 使用分布式锁要满足的几个条件:

  1. 系统是一个分布式系统(关键是分布式,单机的可以使用ReentrantLock或者synchronized代码块来实现)
  2. 共享资源(各个系统访问同一个资源,资源的载体可能是传统关系型数据库或者NoSQL)
  3. 同步访问(即有很多个进程同时访问同一个共享资源。)

2.2 什么是分布式锁?

线程锁:主要用来给方法、代码块加锁。当某个方法或代码使用锁,在同一时刻仅有一个线程执行该方法或该代码 段。线程锁只在同一JVM中有效果,因为线程锁的实现在根本上是依靠线程之间共享内存实现的,比如 synchronized是共享对象头,显示锁Lock是共享某个变量(state)。

进程锁:为了控制同一操作系统中多个进程访问某个共享资源,因为进程具有独立性,各个进程无法访问其他进程 的资源,因此无法通过synchronized等线程锁实现进程锁。

分布式锁:当多个进程不在同一个系统中,用分布式锁控制多个进程对资源的访问。

2.3 应用的场景

线程间并发问题和进程间并发问题都是可以通过分布式锁解决的,但是强烈不建议这样做!因为采用分布式锁解决 这些小问题是非常消耗资源的!分布式锁应该用来解决分布式情况下的多进程并发问题才是最合适的。

有这样一个情境,线程A和线程B都共享某个变量X。

如果是单机情况下(单JVM),线程之间共享内存,只要使用线程锁就可以解决并发问题。

如果是分布式情况下(多JVM),线程A和线程B很可能不是在同一JVM中,这样线程锁就无法起到作用了,这时候 就要用到分布式锁来解决。

分布式锁可以基于很多种方式实现,比如zookeeper、redis…。不管哪种方式,他的基本原理是不变的:用一 个状态值表示锁,对锁的占用和释放通过状态值来标识。

这里主要讲如何用redis实现分布式锁。

2.4 使用redis的setNX命令实现分布式锁

实现的原理

Redis为单进程单线程模式,采用队列模式将并发访问变成串行访问,且多客户端对Redis的连接并不存在竞争 关系。redis的SETNX命令可以方便的实现分布式锁。

基本命令解析

1)setNX(SET if Not eXists)

语法:

SETNX key value

将 key 的值设为 value ,当且仅当 key 不存在。

若给定的 key 已经存在,则 SETNX 不做任何动作。

SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写

返回值:

​ 设置成功,返回 1 。

​ 设置失败,返回 0 。

例子:

redis> EXISTS job # job 不存在
(integer) 0
redis> SETNX job "programmer" # job 设置成功
(integer) 1
redis> SETNX job "code-farmer" # 尝试覆盖 job ,失败
(integer) 0
redis> GET job # 没有被覆盖
"programmer"

所以我们使用执行下面的命令SETNX可以用作加锁原语(locking primitive)。比如说,要对关键字(key) foo 加锁, 客户端可以尝试以下方式:

SETNX lock.foo <current Unix time + lock timeout + 1>

如果 SETNX返回 1 ,说明客户端已经获得了锁,SETNX将键 lock.foo 的值设置为锁的超时时间(当前时间 + 锁 的有效时间)。 之后客户端可以通过 DEL lock.foo 来释放锁。

如果 SETNX返回 0 ,说明 key 已经被其他客户端上锁了。如果锁是非阻塞(non blocking lock)的,我们可以选 择返回调用,或者进入一个重试循环,直到成功获得锁或重试超时(timeout)。

2)getSET

先获取key对应的value值。若不存在则返回nil,然后将旧的value更新为新的value。

语法:

GETSET key value

​ 将给定 key 的值设为 value ,并返回 key 的旧值(old value)。

​ 当 key 存在但不是字符串类型时,返回一个错误。

返回值:

​ 返回给定 key 的旧值[之前的值]。

​ 当 key 没有旧值时,也即是, key 不存在时,返回 nil 。

注意的关键点:(回答面试的核心点)

1、同一时刻只能有一个进程获取到锁。setnx

2、释放锁:锁信息必须是会过期超时的,不能让一个线程长期占有一个锁而导致死锁; (最简单的方式就是del, 如果在删除之前死锁了。)

在这里插入图片描述

死锁情况是在判断超时后,直接操作业务,设置过期时间,执行业务,然后删除释放锁。其他进程再次通过setnx 来抢锁。
解决死锁

上面的锁定逻辑有一个问题:如果一个持有锁的客户端失败或崩溃了不能释放锁,该怎么解决?

我们可以通过锁的键对应的时间戳来判断这种情况是否发生了,如果当前的时间已经大于lock.foo的值,说明该锁 已失效,可以被重新使用。

发生这种情况时,可不能简单的通过DEL来删除锁,然后再SETNX一次(讲道理,删除锁的操作应该是锁拥有 者执行的,这里只需要等它超时即可),当多个客户端检测到锁超时后都会尝试去释放它,这里就可能出现一个竞 态条件,让我们模拟一下这个场景:

C0操作超时了,但它还持有着锁,C1和C2读取lock.foo检查时间戳,先后发现超时了。 C1 发送DEL lock.foo C1 发送SETNX lock.foo 并且成功了。 C2 发送DEL lock.foo C2 发送SETNX lock.foo 并且成功了。 这样一来,C1,C2 都拿到了锁!问题大了!

幸好这种问题是可以避免的,让我们来看看C3这个客户端是怎样做的:

C3发送SETNX lock.foo 想要获得锁,由于C0还持有锁,所以Redis返回给C3一个0 C3发送GET lock.foo 以检查锁 是否超时了

如果没超时,则等待或重试。 反之,如果已超时,C3通过下面的操作来尝试获得锁: GETSET lock.foo 通过 GETSET,C3拿到的时间戳如果仍然是超时的,那就说明,C3如愿以偿拿到锁了。 如果在C3之前,有个叫C4的客 户端比C3快一步执行了上面的操作,那么C3拿到的时间戳是个未超时的值,这时,C3没有如期获得锁,需要再次 等待或重试。留意一下,尽管C3没拿到锁,但它改写了C4设置的锁的超时值,不过这一点非常微小的误差带来的 影响可以忽略不计。

注意:为了让分布式锁的算法更稳键些,持有锁的客户端在解锁之前应该再检查一次自己的锁是否已经超时, 再去做DEL操作,因为可能客户端因为某个耗时的操作而挂起,操作完的时候锁因为超时已经被别人获得,这时就 不必解锁了。

伪代码:

public static boolean lock(String lockName) {
	Jedis jedis = RedisPool.getJedis();
	//lockName可以为共享变量名,也可以为方法名,主要是用于模拟锁信息
	System.out.println(Thread.currentThread() + "开始尝试加锁!");
	Long result = jedis.setnx(lockName, String.valueOf(System.currentTimeMillis() + 		5000));
	if (result != null && result.intValue() == 1){
		System.out.println(Thread.currentThread() + "加锁成功!");
		jedis.expire(lockName, 5);
		System.out.println(Thread.currentThread() + "执行业务逻辑!");
		jedis.del(lockName);
		return true;
	} else {//判断是否死锁
		String lockValueA = jedis.get(lockName);
//得到锁的过期时间,判断小于当前时间,说明已超时但是没释放锁,通过下面的操作来尝试获得锁。下面逻辑防止死锁
[已经过期但是没有释放锁的情况]
	if (lockValueA != null && Long.parseLong(lockValueA) < System.currentTimeMillis()){
		String lockValueB = jedis.getSet(lockName,
String.valueOf(System.currentTimeMillis() + 5000));
//这里返回的值是旧值,如果有的话。之前没有值就返回null,设置的是新超时。
			if (lockValueB == null || lockValueB.equals(lockValueA)){
				System.out.println(Thread.currentThread() + "加锁成功!");
				jedis.expire(lockName, 5);
				System.out.println(Thread.currentThread() + "执行业务逻辑!");
				jedis.del(lockName);
				return true;
			} else {
				return false;
			}
		} else {
			return false;
		}
	}
}

){
System.out.println(Thread.currentThread() + “加锁成功!”);
jedis.expire(lockName, 5);
System.out.println(Thread.currentThread() + “执行业务逻辑!”);
jedis.del(lockName);
return true;
} else {
return false;
}
} else {
return false;
}
}
}


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值