redis学习(五):常见问题及解决方案

一、如何保证缓存与数据库的双写一致性

1.1、最经典的缓存+数据库读写的模式


读的时候:先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应
更新的时候:先更新数据库,然后再删除缓存
为什么是删除缓存,而不是更新缓存:
复杂点的缓存场景缓存不只是数据库中直接取出来的值,比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据并进行运算,才能计算出缓存最新的值的;更新缓存的代价可能很高,有的场景可能是每次修改数据库的时候,都一定要将其对应的缓存更新一份,但是这个缓存可能不会频繁被访问到。比如一个缓存涉及到的表字段1分钟更新100次缓存更新100次但是只被读了1次,有大量冷数据,如果只是删除缓存1分钟内该缓存只不过重新计算一次而已,用到缓存才去算缓存(懒加载思想)


1.2、初级的缓存不一致问题及解决方案(先更新数据库,再删除缓存)


问题:先更新数据库后删除缓存,删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据就出现了不一致
解决方案:先删除缓存,再更新数据库。如果数据库更新失败了,那么数据库中是旧数据,缓存中是空的,那么数据不会不一致,因为读的时候缓存没有,所以去读了数据库中的旧数据,然后更新到缓存中


1.3、复杂的缓存不一致问题及解决方案(先删除缓存,再更新数据库)


问题:先删除缓存,再修改数据库,此时还没修改,一个请求过来,去读缓存发现缓存空了,去查询数据库查到了修改前的旧数据并放到了缓存中。随后数据变更的程序完成了数据库的修改,但是数据库和缓存中的数据不一致了(并发读写才会出现这个不一致问题)
解决方案:更新数据的时候,根据数据的唯一标识将操作路由之后,发送到一个 jvm 内部队列中,读取数据时发现数据不在缓存中,重新执行“读取数据+更新缓存”的操作,根据唯一标识路由之后,也发送到同一个 jvm 内部队列中。一个队列对应一个工作线程,每个工作线程串行拿到对应的操作,然后一条一条的执行。
如果一个读请求过来,没有读到缓存,可以先将缓存更新的请求发送到队列中,此时会在队列中积压,然后同步等待缓存更新完成。待那个队列对应的工作线程完成了上一个操作的数据库的修改之后,才会去执行下一个缓存更新的操作,此时会从数据库中读取最新的值,然后写入缓存中。
如果请求还在等待时间范围内,不断轮询发现可以取到值了,那么就直接返回;如果请求等待的时间超过一定时长,那么这一次直接从数据库中读取当前的旧值。


1.4、高并发的场景下,该复杂缓存解决方案要注意的问题


(1)读请求长时阻塞(读超时):
该方案可能数据更新很频繁,导致队列中积压了大量更新操作在里面,然后读请求会发生大量的超时,最后导致大量的请求直接走数据库,所以要模拟真实的测试(压力测试,和模拟线上环境);
比如:如果一个内存队列里挤压 100 个商品的库存修改操作,每个库存修改操作要耗费 10ms 去完成,那么最后一个商品的读请求,可能等待 10 * 100 = 1000ms = 1s 后,才能得到数据,这个时候就导致 读请求的长时阻塞。
解决方法:部署多个服务(加机器),每个服务分摊一些数据的更新操作;压力测试,和模拟线上环境;
(2)读请求并发量过高
做好压力测试
(3)多服务实例部署的请求路由
部署了多个实例条件下,执行数据和缓存更新操作都要通过 Nginx 服务器路由到相同的服务实例上
比如:对同一个商品的读写请求,全部路由到同一台机器上,做服务间的按照某个请求参数的 hash 路由,也可以用 Nginx 的 hash 路由功能

二、redis如何解决并发竞争问题

《阿里开发手册》中规定并发修改同一记录时,避免更新丢失,需要加锁:

要么在应用层加锁,要么在缓存加锁,要么在数据库层使用乐观锁,使用 version 作为更新依据。
说明:如果每次访问冲突概率小于 20%,推荐使用乐观锁,否则使用悲观锁。乐观锁的重试次
数不得小于 3 次

问题
redis中的这个问题;多客户端同时并发一个 key,可能本来应该先到的数据后到了,导致数据版本错了;或者是多客户端同时一个 key,修改值之后再写回去,只要顺序错了,数据就错了。
而且 redis 自己就有天然解决这个问题的 CAS 类的乐观锁方案(redis 事务的 CAS 方案)
解决方案:
假如某个时刻,多个系统实例都去更新某个 key。可以基于 zookeeper 实现分布式锁。每个系统通过 zookeeper 获取分布式锁,确保同一时间,只能有一个系统实例在操作某个 key,别人都不允许读和写。
要写入缓存的数据,都是从 sql里查出来的,都得写入sql中,写入sql中的时候必须保存一个时间戳,从 mysql 查出来的时候,时间戳也查出来。每次要写之前,先判断一下当前这个 value 的时间戳是否比缓存里的 value 的时间戳要新。如果是的话,那么可以写,否则,就不能用旧的数据覆盖新的数据。

三、缓存穿透和缓存雪崩

3.1、缓存雪崩

(1)缓存雪崩产生的原因

由于原有缓存失效,也就是redis所有key在同一时间失效,所有原本应该访问缓存的请求都去查数据库了,对数据库和CPU造成巨大压力,严重甚至会宕机,造成系统崩溃。

(2)解决缓存雪崩方案

第一:使用分布式锁或者本地锁,可以保证不会有大量线程对数据库一次性进行读写,但是会降低吞吐量(加锁 不推荐);

第二:如redis主备,但是双缓存又会涉及到更新事务的问题,但是可能读到脏数据(不推荐)

第三:均摊分配redis的key的失效时间

第四:做二级缓存Redis+Ehcache;

第五:用消息中间件方式如果大量请求访问的时候,如果redis没有值得情况下,这个会将该消息存放在MQ中(MQ默认一步一,但是 这里要用同步),消费者根据参数查询,得到结果(比如消费者最多能承受50个消息,生产者发送了1000个消息,有950个请求还在等待,所以这种方式最好);

(3)具体解决方式

1:在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待

@RequestMapping("/getUsers")
	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;
	}

2:均摊分配失效时间。不同的key设置不同的过期时间,让缓存失效时间点尽量均匀(比较靠谱)。

3:做二级缓存

3.2、缓存穿透

(1)缓存穿透产生原因

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

(2)解决缓存穿透方案

第一种:网关判断客户端传入对应key规则,如果不符合规则,直接返回空;

第一种:如果查询数据库为空,直接设置一个默认值放入缓存中,这样第二次缓冲中获取就有值了,而不会继续访问数据库。也就是把空结果也给缓存起来,这样下次请求就可以直接返回空了,即可以避免当查询的值为空时引起缓存穿透;

第三种:也可以单独设置一个 缓存区域 存储控制,对要查询的key进行预先校验,然后再放行给后面的正常缓存处理逻辑。

(3)具体操作如下

public String getByUsers2(Long id) {
		// 1.先查询redis
		String key = this.getClass().getName() + "-" + Thread.currentThread().getStackTrace()[1].getMethodName()
				+ "-id:" + id;
        //1.先查询redis
		String userName = redisService.getString(key);
		if (!StringUtils.isEmpty(userName)) {
			return userName;
		}
		System.out.println("######开始发送数据库DB请求########");
		Users user = userMapper.getUser(id);
		String value = null;
        //2.如果数据库中没有对应的数据信息的时候
		if (user == null) {
			// 标识为一个值,防止黑客攻击
			value = SIGN_KEY;
		} else {
			value = user.getName();
		}
		redisService.setString(key, value);
        //3.直接返回
		return value;
	}

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

四、热点key

4.1、什么是热点key

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

4.2、热点key解决方案

(1)使用锁,单机使用synchronized、lock等,分布式使用分布式锁

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

(3)在value设置一个比过期时间t0小的过期时间t1,当t1过期时候,延长t1并做更新缓存操作

发布了52 篇原创文章 · 获赞 116 · 访问量 5万+

猜你喜欢

转载自blog.csdn.net/RuiKe1400360107/article/details/103706472