Redis作为缓存出现的问题及解决方案
Redis为什么需要作为缓存使用?
在高并发的业务场景下,数据库往往是用户并发访问最薄弱的环节,所以,这个时候就需要做一个缓存操作,如果是单机服务,当然也可以使用ConcurrentHashMap
作为简单缓存使用,但遇到分布式服务时,就需要使用一个公共的缓存进行缓存操作,此时就可以使用Redis作为缓存,这样就可以大大的环节数据库的压力。那么如果使用Redis作为缓存就需要考虑一些失效问题。
高并发使用Redis作为缓存出现的失效问题
- 缓存雪崩、缓存穿透、缓存击穿、缓存污染
- 缓存雪崩:大量缓存数据同一时刻过期或Redis故障宕机
- 缓存穿透:数据压根不存在于Redis和数据库
- 缓存击穿:热点缓存数据过期
- 缓存污染:大量缓存数据只被访问一次或几次却占用缓存空间
- 缓存与数据库的一致性问题(如何保持一致性?)
- 双写模式下存在的问题
- 失效模式下存在的问题
1. 缓存雪崩
缓存雪崩概述
当大量缓存数据在同一时刻过期(即缓存数据失效)或者Redis故障宕机时,此时如果有大量的请求访问Redis缓存数据则会导致大量请求会直接访问数据库(即直接压入数据库),导致数据库压力剧增,严重的情况会导致数据库直接宕机,致使整个系统崩溃,这就是缓存雪崩。
出现缓存雪崩主要是因为:① Redis中大量缓存数据同一时间过期; ② Redis故障宕机
解决方案
- 过期时间设置随机值 (最常用)
给缓存数据设置随机值过期时间,使得数据的过期时间可以均匀开来,避免大量数据在同一时间全部过期(实用)
- 加互斥锁(不适用高并发情况) (注意分布式系统下,要加分布式锁,后面说明)
当请求访问Redis时,发现Redis缓存数据不存在,则加互斥锁,保证相同请求过来时因为加了互斥锁只能等当前请求访问完数据库并将数据缓存到Redis后释放互斥锁才允许其他相同的请求访问,这样就不会导致大量相同请求直接访问数据库,当然其他请求可以根据业务需求是继续等待还是返回空值。
- 双key策略
可以给缓存数据同时准备两个key,主key
设置过期时间,副key
不设置过期时间,两个key保存的值value是相同的,相当于副key
是缓存数据的副本,当主key
过期时可以直接返回副key
的value(即缓存数据),而在主key
更新时同时要更新主key
和副key
的缓存数据value
-
针对Redis故障宕机的处理方式
- 使用服务熔断机制(阿里的sentinel),暂停业务应用对Redis的访问,直接返回错误,不再继续将请求压入数据库,保证数据库系统能正常运行,这样整个系统就不会崩溃,等Redis恢复后再允许业务应用访问Redis。
- 可以构建高可用集群Redis服务器,实现Redis高可用,当Redis主节点宕机后,可以切换节点继续提供缓存服务,从根源上解决宕机问题。
2. 缓存穿透
缓存穿透概述
用户访问的数据压根不存在Redis缓存中,也不存在数据库中,导致用户发送请求访问缓存时,缓存失效,便直接访问数据库也没有拿到数据,这样的话当有大量这样的请求打入时,数据库的压力会剧增,同样可能会导致数据库崩溃从而导致系统崩溃,这就是缓存穿透。
一般导致缓存穿透的情况有两种:① 业务误操作(数据意外丢失) ② 黑客恶意攻击(故意访问一些不存在的数据)
解决方案
- 设置缓存空值或者默认值(常用)
当业务应用查询不到缓存和数据库的数据时,可以最后在缓存中设置一个对应的空值或者默认值,这样后续的请求就可以从缓存中读取到空值或者默认值,返回给业务应用,这样就可以避免大量请求压入数据库导致缓存穿透。
- 非法请求的限制
在请求打入缓存前即在接口拿到数据前就对请求参数进行判断,查看是否合理,是否是非法值,请求字段是否存在,对非法数据直接拦截,这样就可以避免后续的缓存穿透问题了。
- 设置可访问的名单(白名单)
使用Redis中的bitmaps
类型定义一个可以访问的名单,名单id作为bitmaps的偏移量(数组的下标),每次访问和bitmap里面的id进行比较,如果访问id不在bitmaps里面,进行拦截,不允许访问。
- 使用布隆过滤器
在数据库写入数据之后,使用布隆过滤器对数据进行标记(上述3的方式类似),当请求访问缓存时,缓存失效(数据不存在),此时可以先使用布隆过滤器检查数据是否存在数据库,不存在直接拦截即可。(布隆过滤器会导致一定误判,即检查到存在的数据不一定真的存在数据库,但检查到不存在的数据一定不存在数据库)
3. 缓存击穿
缓存击穿概述
大量用户请求同一个数据(即热点数据被频繁访问),刚好缓存中的热点数据过期,此时就会导致大量直接访问数据库,这样数据库很容易被高并发搞崩溃,击穿顾名思义针对一点不断打击,这就是缓存击穿。
解决方案
- 加互斥锁 (分布式下要加分布式锁)
通过加互斥锁保证大量请求在缓存失效的情况下只有一个业务线程对数据库进行访问,其余业务线程等待锁释放后重新获取缓存数据,这样就避免了大量请求直接访问数据库导致数据库压力过大崩溃。
- 热点数据不设置过期时间或者热点数据要过期前更新缓存延长过期时间
针对热点数据不在缓存中设置过期时间,这样访问热点数据就不会导致缓存失效,或者设置热点数据准备过期前更新缓存延长过期时间。
4. 缓存污染
缓存污染概述
缓存污染指的是缓存中一些只会被访问一次或者几次的的数据,被访问完后,再也不会被访问到,但这部分数据依然留存在缓存中,消耗缓存空间。缓存污染会随着数据的持续增加而逐渐显露,随着服务的不断运行,缓存中会存在大量的永远不会再次被访问的数据。缓存空间是有限的,如果缓存空间满了,再往缓存里写数据时就会有额外开销,影响Redis性能。 这部分额外开销主要是指写的时候判断淘汰策略,根据淘汰策略去选择要淘汰的数据,然后进行删除操作。
解决方案
缓存写满是不可避免的,可以通过调整最大缓存值来避免缓存内存太小导致影响Redis性能,但同时又要兼顾内存空间开销(缓存内存过大会导致内存空间开销太大,成本过高,缓存内存太小对应的访问缓存的速度也变小,影响Redis性能),可以把缓存容量设置为总数据量的15%到30%。
可以通过CONFIG SET maxmemory 内存大小
来指定缓存内存大小。
5. 基于缓存实现的分布式锁
为什么要使用分布式锁?
可以看到上述的缓存失效问题中的解决方案里有加互斥锁的解决方案,对于单机服务来说,直接使用(Java API中有许多加锁的方式)就可以轻松的完成加互斥锁,但是当单机服务需要演化为分布式集群服务时,原先直接加锁就变了味了
就像上图这样,分布式集群服务下,每个服务都进行加锁,当然如果是本机自己的服务可以实现只有一个线程访问数据库,但是如图有六台业务服务器,所以即使每个本机都加锁了,但是依旧最大会有六个线程可以同时访问数据库,这样就说明使用Java API并不具备真正的实现分布式锁的能力。缓存击穿等导致的缓存失效问题希望的是加锁后有同一时间只允许一台服务器下的一个线程访问数据库。这就是为什么要使用分布式锁的原因,也是分布式锁的应用场景之一。
分布式锁主流实现方式
- 基于数据库实现分布式锁
- 基于缓存实现分布式锁(redis实现分布式锁性能较高)
- 基于zookeeper实现分布式锁(可靠性高)
下面介绍两种缓存实现方式
- 使用SpringBoot整合Redis实现分布式锁(Redis实现)
- 使用Redisson实现分布式锁
5.1 Redis实现方式
使用Redis作为缓存实现分布式锁存在如下几个问题
-
情况一:怎么使用Redis保存锁?
- 解决方案:可以通过Redis的
setnx key value
实现(相当于set key value nx
),setnx
表示只有key不存在时,才将key和value保存进缓存中,否则不进行操作,返回结果为当操作成功时返回1,失败返回0,利用这一特性可以实现对锁对象的保存。比如可以设置一个setnx lock XXX
根据不同的返回结果进行等待操作还是执行业务操作。
- 解决方案:可以通过Redis的
-
情况二:设置的锁在执行业务时出现异常无法释放如何解决?
- 解决方案:可以使用Redis的
expire
对锁设置过期时间,但是如果先执行setnx key value
再执行expire
设置过期时间可以能碰上这样的情况:当设置好锁时还没来得及设置过期时间就因为异常终止了,这样一样无法释放锁,所以要求这两步必须是原子操作,而Redis支持set key value ex 时间 nx
的方式直接设置过期时间,同时这也是一个原子操作,使用该命令即可解决当前问题。
- 解决方案:可以使用Redis的
-
情况三:由于业务时间过久导致锁过期,此时另外的线程拿到锁正在执行业务,而当前业务完成并删除锁,这里删除的已经不是自己的锁了(锁误删)
- 解决方案:使用UUID等方式在设置锁
key
时将UUID生成的值设置为value
,然后在要删除锁前对锁进行判断,是自己的才可以删除。
- 解决方案:使用UUID等方式在设置锁
-
情况四:在删除锁判断时,值还是自己的,但是判断成功后锁过期了,此时执行删除锁的行为时,锁已经过期了已经不是自己的了,这样也会造成情况三的锁误删
- 解决方案:情况四要求的是判断锁操作和删除锁操作必须是原子操作,否则会产生情况四的问题,而Redis中并不包含这样的原子操作,此时就要使用LUA脚本保证删除原子性(LUA脚本会一次性提交给Redis运行,相当于Redis的事务,可以保证原子性)
通过以上的四种情况总结为以下流程图
代码
/**
* 通过Redis实现分布式锁实现加锁执行业务
*/
public List<Catelog> getCatalog() {
// 获取uuid
String uuid = UUID.randomUUID().toString();
// 尝试加锁,通过返回的布尔值判断执行业务还是自旋等待
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS);
if (lock) {
// 加锁成功
List<Catelog> data = null;
try {
// 执行业务
data = getData();
} finally {
// 通过LUA脚本实现判断锁的uuid值和删除锁的原子操作
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
// 将Lua脚本交给redis一次性处理
stringRedisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Collections.singletonList("lock"), uuid);
}
return data;
} else {
// 加锁失败,等待重试
// 休眠一百毫秒
try { TimeUnit.MILLISECONDS.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); }
// 通过递归自旋的方式
return getCatalogJsonFromDbWithRedisLock();
}
}
5.2 Redisson实现方式
Redisson概述
使用Redisson可以方便地实现分布式锁,Redisson实现了很多JUC相关的锁
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。其中包括(BitSet
, Set
, Multimap
, SortedSet
, Map
, List
, Queue
, BlockingQueue
, Deque
, BlockingDeque
, Semaphore
, Lock
, AtomicLong
, CountDownLatch
, Publish / Subscribe
, Bloom filter
, Remote service
, Spring cache
, Executor service
, Live Object service
, Scheduler service
) Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。
Redisson的详细使用可以参考官网:目录 · redisson/redisson Wiki · GitHub
这里简单使用Redisson的可重入锁实现分布式锁
- 引入maven坐标
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.12.0</version>
</dependency>
- 使用配置类的方式将Redisson加入Spring容器中(官网也提供了其他方法)
此处配置类还可以配置更多信息,具体参考2. 配置方法 · redisson/redisson Wiki · GitHub
/**
* Redisson配置类
* @author 兴趣使然的L
**/
@Configuration
public class MyRedissonConfig {
/**
* 所有对Redisson的使用都是通过RedissonClient
*/
@Bean(destroyMethod="shutdown")
public RedissonClient redisson() throws IOException {
// 创建配置
Config config = new Config();
// 这里的的配置参数可以参考https://github.com/redisson/redisson/wiki/2.-%E9%85%8D%E7%BD%AE%E6%96%B9%E6%B3%95#26-%E5%8D%95redis%E8%8A%82%E7%82%B9%E6%A8%A1%E5%BC%8F
config.useSingleServer().setAddress("redis://192.168.77.130:6379");
// 根据Config创建出RedissonClient实例
return Redisson.create(config);
}
}
- 实现分布式锁
/**
* 通过Redisson实现分布式锁实现加锁执行业务
*/
public List<Catelog> getCatalog() {
// 获取可重入锁
RLock lock = redisson.getLock("lock");
// 加锁
lock.lock();
List<Catelog> data = null;
try {
// 执行业务
data = getData();
} finally {
// 解锁
lock.unlock();
}
return data;
}
补充
使用lock.lock()
进行加锁,通过底层源码分析,可知默认过期时间为30s,并且过去1/3的时间(即10s)后,会自动检查是否还持有锁,持有的话,会延长锁时间(重置为30s)
使用lock.lock(10, TimeUint.SECONDS)
加锁,该方法指定过期时间为10s,并且该方法不存在更新过期时间的操作,一般实用的还是自己设置过期时间,并且不需要更新过期时间的操作。
6. 缓存一致性问题
上面介绍了Redis作为缓存时读取数据时遇到的缓存失效问题,当然,Redis作为缓存,除了读取数据操作存在问题外,更新数据同样存在问题,接下来浅浅看看究竟是什么问题?
如果只是串行执行更新数据操作的话,当然不存在任何问题,但是如果是并发执行更新数据便会带来数据在缓存和数据库中的不一致性问题。
6.1 双写模式(更新数据库和缓存)下的问题
在并发更新操作下,可以使用双写模式(即同时更新数据库和缓存),那么是先更新缓存,再更新数据库 还是 先更新数据库,再更新缓存呢?为什么会带来数据在缓存和数据库的不一致性呢?
下面依次介绍两种情况以及出现的问题
先更新缓存,再更新数据库 图示问题
线程1和线程2并发的进行更新数据操作,由于线程1在执行过程中存在卡顿情况,导致线程2执行完操作,才到线程1执行后面的操作。
通过上图可以看到执行到最后的过程得到的结果是 数据库中的数据是数据1,缓存中的数据是数据2。 出现了缓存和数据库不一致的问题。
先更新数据库,再更新缓存 图示问题
线程1和线程2并发的进行更新数据操作,同样由于线程1在执行过程中存在卡顿情况,导致线程2执行完操作,才到线程1执行后面的操作。
通过上图可以看到执行到最后的过程得到的结果是 数据库中的数据是数据2,缓存中的数据是数据1。 出现了缓存和数据库不一致的问题。
通过上面的分析可以看到同时更新缓存和更新数据库会存在并发问题,导致数据的不一致性。如何解决缓存不一致性问题呢?
分析
从上面的两种情况看,会发现数据库与缓存存在数据不一致的问题,但是数据不一致性其实是短暂的,因为如果缓存中对数据都进行了过期时间的设置,那么总会在缓存中数据过期后,重新更新数据库的数据到缓存中,所以上述的两种情况存在的是暂时性的脏数据问题,数据具备着最终一致性。
如何解决呢?
- 如果不要求数据具备实时性,能有一定的延迟的话,只需要在缓存中加上过期时间,这样数据最终总会一致。(主要看业务的要求)
- 如果对数据的一致性要求很高的话,则在更新缓存前加上分布式锁,保证同一时刻只允许一个线程进行操作,这样会避免了并发导致的问题(但是同样加锁意味着性能肯定会下降,可能还不如不要缓存,直接访问数据库的效率)
了解一下缓存更新的几种 Desgin Pattern
- Cache aside Pattern
- Read/Write Through Pattern
- Write Beahind Caching Pattern
具体可以参考缓存更新的套路
6.2 失效模式(更新数据库删除缓存)的问题
根据上述的 Desgin Pattern,介绍最常用的缓存更新的模式:Cache aside Pattern
两个关键点:
- 读取数据操作:先读取缓存中的数据,缓存没有数据时,读取数据库的数据并将数据放入缓存中(更新缓存)
- 更新数据操作:先更新数据库的数据,再把缓存的数据删除。
Cache aside Pattern的逻辑为:
- 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
- 命中:应用程序从cache中取数据,取到后返回。
- 更新:先把数据存到数据库中,成功后,再让缓存失效。
抛出问题一:为什么不能先删除缓存再更新数据库呢?
线程1执行更新操作,线程2执行读取操作,线程1和线程2并发执行,此时线程1由于原因卡顿了,线程2执行完才执行剩余的线程1操作
通过上图可以看到,最后的结果会使线程2读取到的数据是旧的数据(数据1),缓存里存着的是数据1,数据库存着的是数据2,造成了数据不一致性。(但是随着缓存中旧数据过期,最终总会拿到新的数据(数据2),依旧存在数据延迟一致性的问题,无法实时同步数据)
补充:如果是两个并发更新操作的话,已经不会存在缓存与数据库不一致的问题了,因为删除缓存,缓存失效总会从数据库中同步数据。
抛出问题二:难道先更新数据库再删除缓存就不存在不一致性问题了吗?
可以看到上述这种情况最后拿到的结果是 数据库的数据是数据2,而缓存的数据是数据1,依旧存在数据不一致性问题
但是在实际的业务中,发生这种情况的概率是很小很小的,因为数据库的写入通常要比缓存的写入要旧的多了,所以很难出现上述的情况,所以概率很小的条件下,先更新数据库再删除缓存是可以接受的。
小结一下失效模式下的问题
- 如果选用先删除缓存再更新数据库的方案,那么在读+写的并发操作下,依旧存在数据的不一致性(数据最终一致性),可以通过延迟双删的方式进行优化(延迟双删指先删除一次缓存,等更新完数据库后,延迟一会,再删除一次缓存),优化难点:这延迟一会就很控制。
- 选用先更新数据库再删除缓存的方案,在所有方案中更新数据时数据在缓存和数据库中的一致性效果是最好的,推荐使用该方案(先更新数据库再删除缓存)
- 但是先更新数据库再删除缓存的方案同样存在一些别的问题:如果更新完数据库后删除缓存的过程中出现了问题,此时便会导致缓存中的数据依旧是旧数据,数据库又是新数据,便会带来不一致性,说白了就是这两个操作不是原子操作,依旧会带来问题。
6.3 数据不一致性的最终解决方案
经过上面的分析,对于更新数据操作来说,选用先更新数据库再删除缓存是较好的方案
接下来解决原子性问题(删除缓存失败问题)
- 方案一:队列 + 重试机制
执行流程如下:
- 更新数据库的数据
- 因为某些原因删除缓存失败
- 将需要删除的key发送到消息队列
- 业务应用在自己消费消息,获取到删除的key
- 继续重试删除操作
注意点:
- 可以设置一定重试次数,超过次数则向业务应用发送报错信息,终止操作。
- 删除缓存成功后,注意检查将消息队列的数据移除,避免重复删除。
- 方案二:异步更新缓存(基于订阅binlog的同步机制)
执行流程概述:当MySQL数据库产生新的insert、update、delete等操作时,产生binlog操作日志(类似于MySQL的主从复制中使用binlog实现数据一致性),将日志的信息交给非业务代码,非业务代码根据日志提取的信息可以对缓存进行删除数据操作,删除失败则发送相关数据给消息队列,重复消费消息队列达到重试的效果。
使用该方案的优点:
- 读取数据:基本都在缓存中得到
- 更新数据:增删改操作都在数据库操作,通过binlog同步更新缓存
转载自:https://juejin.cn/post/7190400432294854711