赞
踩
Redis为什么需要作为缓存使用?
在高并发的业务场景下,数据库往往是用户并发访问最薄弱的环节,所以,这个时候就需要做一个缓存操作,如果是单机服务,当然也可以使用ConcurrentHashMap
作为简单缓存使用,但遇到分布式服务时,就需要使用一个公共的缓存进行缓存操作,此时就可以使用Redis作为缓存,这样就可以大大的环节数据库的压力。那么如果使用Redis作为缓存就需要考虑一些失效问题。
高并发使用Redis作为缓存出现的失效问题
缓存雪崩概述
当大量缓存数据在同一时刻过期(即缓存数据失效)或者Redis故障宕机时,此时如果有大量的请求访问Redis缓存数据则会导致大量请求会直接访问数据库(即直接压入数据库),导致数据库压力剧增,严重的情况会导致数据库直接宕机,致使整个系统崩溃,这就是缓存雪崩。
出现缓存雪崩主要是因为:① Redis中大量缓存数据同一时间过期; ② Redis故障宕机
解决方案
给缓存数据设置随机值过期时间,使得数据的过期时间可以均匀开来,避免大量数据在同一时间全部过期(实用)
当请求访问Redis时,发现Redis缓存数据不存在,则加互斥锁,保证相同请求过来时因为加了互斥锁只能等当前请求访问完数据库并将数据缓存到Redis后释放互斥锁才允许其他相同的请求访问,这样就不会导致大量相同请求直接访问数据库,当然其他请求可以根据业务需求是继续等待还是返回空值。
可以给缓存数据同时准备两个key,主key
设置过期时间,副key
不设置过期时间,两个key保存的值value是相同的,相当于副key
是缓存数据的副本,当主key
过期时可以直接返回副key
的value(即缓存数据),而在主key
更新时同时要更新主key
和副key
的缓存数据value
针对Redis故障宕机的处理方式
缓存穿透概述
用户访问的数据压根不存在Redis缓存中,也不存在数据库中,导致用户发送请求访问缓存时,缓存失效,便直接访问数据库也没有拿到数据,这样的话当有大量这样的请求打入时,数据库的压力会剧增,同样可能会导致数据库崩溃从而导致系统崩溃,这就是缓存穿透。
一般导致缓存穿透的情况有两种:① 业务误操作(数据意外丢失) ② 黑客恶意攻击(故意访问一些不存在的数据)
解决方案
当业务应用查询不到缓存和数据库的数据时,可以最后在缓存中设置一个对应的空值或者默认值,这样后续的请求就可以从缓存中读取到空值或者默认值,返回给业务应用,这样就可以避免大量请求压入数据库导致缓存穿透。
在请求打入缓存前即在接口拿到数据前就对请求参数进行判断,查看是否合理,是否是非法值,请求字段是否存在,对非法数据直接拦截,这样就可以避免后续的缓存穿透问题了。
使用Redis中的bitmaps
类型定义一个可以访问的名单,名单id作为bitmaps的偏移量(数组的下标),每次访问和bitmap里面的id进行比较,如果访问id不在bitmaps里面,进行拦截,不允许访问。
在数据库写入数据之后,使用布隆过滤器对数据进行标记(上述3的方式类似),当请求访问缓存时,缓存失效(数据不存在),此时可以先使用布隆过滤器检查数据是否存在数据库,不存在直接拦截即可。(布隆过滤器会导致一定误判,即检查到存在的数据不一定真的存在数据库,但检查到不存在的数据一定不存在数据库)
缓存击穿概述
大量用户请求同一个数据(即热点数据被频繁访问),刚好缓存中的热点数据过期,此时就会导致大量直接访问数据库,这样数据库很容易被高并发搞崩溃,击穿顾名思义针对一点不断打击,这就是缓存击穿。
解决方案
通过加互斥锁保证大量请求在缓存失效的情况下只有一个业务线程对数据库进行访问,其余业务线程等待锁释放后重新获取缓存数据,这样就避免了大量请求直接访问数据库导致数据库压力过大崩溃。
针对热点数据不在缓存中设置过期时间,这样访问热点数据就不会导致缓存失效,或者设置热点数据准备过期前更新缓存延长过期时间。
缓存污染概述
缓存污染指的是缓存中一些只会被访问一次或者几次的的数据,被访问完后,再也不会被访问到,但这部分数据依然留存在缓存中,消耗缓存空间。缓存污染会随着数据的持续增加而逐渐显露,随着服务的不断运行,缓存中会存在大量的永远不会再次被访问的数据。缓存空间是有限的,如果缓存空间满了,再往缓存里写数据时就会有额外开销,影响Redis性能。 这部分额外开销主要是指写的时候判断淘汰策略,根据淘汰策略去选择要淘汰的数据,然后进行删除操作。
解决方案
缓存写满是不可避免的,可以通过调整最大缓存值来避免缓存内存太小导致影响Redis性能,但同时又要兼顾内存空间开销(缓存内存过大会导致内存空间开销太大,成本过高,缓存内存太小对应的访问缓存的速度也变小,影响Redis性能),可以把缓存容量设置为总数据量的15%到30%。
可以通过CONFIG SET maxmemory 内存大小
来指定缓存内存大小。
为什么要使用分布式锁?
可以看到上述的缓存失效问题中的解决方案里有加互斥锁的解决方案,对于单机服务来说,直接使用(Java API中有许多加锁的方式)就可以轻松的完成加互斥锁,但是当单机服务需要演化为分布式集群服务时,原先直接加锁就变了味了
就像上图这样,分布式集群服务下,每个服务都进行加锁,当然如果是本机自己的服务可以实现只有一个线程访问数据库,但是如图有六台业务服务器,所以即使每个本机都加锁了,但是依旧最大会有六个线程可以同时访问数据库,这样就说明使用Java API并不具备真正的实现分布式锁的能力。缓存击穿等导致的缓存失效问题希望的是加锁后有同一时间只允许一台服务器下的一个线程访问数据库。这就是为什么要使用分布式锁的原因,也是分布式锁的应用场景之一。
分布式锁主流实现方式
下面介绍两种缓存实现方式
- 使用SpringBoot整合Redis实现分布式锁(Redis实现)
- 使用Redisson实现分布式锁
使用Redis作为缓存实现分布式锁存在如下几个问题
情况一:怎么使用Redis保存锁?
setnx key value
实现(相当于set key value nx
),setnx
表示只有key不存在时,才将key和value保存进缓存中,否则不进行操作,返回结果为当操作成功时返回1,失败返回0,利用这一特性可以实现对锁对象的保存。比如可以设置一个setnx lock XXX
根据不同的返回结果进行等待操作还是执行业务操作。情况二:设置的锁在执行业务时出现异常无法释放如何解决?
expire
对锁设置过期时间,但是如果先执行setnx key value
再执行expire
设置过期时间可以能碰上这样的情况:当设置好锁时还没来得及设置过期时间就因为异常终止了,这样一样无法释放锁,所以要求这两步必须是原子操作,而Redis支持set key value ex 时间 nx
的方式直接设置过期时间,同时这也是一个原子操作,使用该命令即可解决当前问题。情况三:由于业务时间过久导致锁过期,此时另外的线程拿到锁正在执行业务,而当前业务完成并删除锁,这里删除的已经不是自己的锁了(锁误删)
key
时将UUID生成的值设置为value
,然后在要删除锁前对锁进行判断,是自己的才可以删除。情况四:在删除锁判断时,值还是自己的,但是判断成功后锁过期了,此时执行删除锁的行为时,锁已经过期了已经不是自己的了,这样也会造成情况三的锁误删
通过以上的四种情况总结为以下流程图
代码
- /**
- * 通过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();
- }
- }
- 复制代码
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的可重入锁实现分布式锁
- <dependency>
- <groupId>org.redisson</groupId>
- <artifactId>redisson</artifactId>
- <version>3.12.0</version>
- </dependency>
- 复制代码
此处配置类还可以配置更多信息,具体参考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,并且该方法不存在更新过期时间的操作,一般实用的还是自己设置过期时间,并且不需要更新过期时间的操作。
上面介绍了Redis作为缓存时读取数据时遇到的缓存失效问题,当然,Redis作为缓存,除了读取数据操作存在问题外,更新数据同样存在问题,接下来浅浅看看究竟是什么问题?
如果只是串行执行更新数据操作的话,当然不存在任何问题,但是如果是并发执行更新数据便会带来数据在缓存和数据库中的不一致性问题。
在并发更新操作下,可以使用双写模式(即同时更新数据库和缓存),那么是先更新缓存,再更新数据库 还是 先更新数据库,再更新缓存呢?为什么会带来数据在缓存和数据库的不一致性呢?
下面依次介绍两种情况以及出现的问题
先更新缓存,再更新数据库 图示问题
线程1和线程2并发的进行更新数据操作,由于线程1在执行过程中存在卡顿情况,导致线程2执行完操作,才到线程1执行后面的操作。
通过上图可以看到执行到最后的过程得到的结果是 数据库中的数据是数据1,缓存中的数据是数据2。 出现了缓存和数据库不一致的问题。
先更新数据库,再更新缓存 图示问题
线程1和线程2并发的进行更新数据操作,同样由于线程1在执行过程中存在卡顿情况,导致线程2执行完操作,才到线程1执行后面的操作。
通过上图可以看到执行到最后的过程得到的结果是 数据库中的数据是数据2,缓存中的数据是数据1。 出现了缓存和数据库不一致的问题。
通过上面的分析可以看到同时更新缓存和更新数据库会存在并发问题,导致数据的不一致性。如何解决缓存不一致性问题呢?
分析
从上面的两种情况看,会发现数据库与缓存存在数据不一致的问题,但是数据不一致性其实是短暂的,因为如果缓存中对数据都进行了过期时间的设置,那么总会在缓存中数据过期后,重新更新数据库的数据到缓存中,所以上述的两种情况存在的是暂时性的脏数据问题,数据具备着最终一致性。
如何解决呢?
了解一下缓存更新的几种 Desgin Pattern
具体可以参考缓存更新的套路
根据上述的 Desgin Pattern,介绍最常用的缓存更新的模式:Cache aside Pattern
两个关键点:
Cache aside Pattern的逻辑为:
抛出问题一:为什么不能先删除缓存再更新数据库呢?
线程1执行更新操作,线程2执行读取操作,线程1和线程2并发执行,此时线程1由于原因卡顿了,线程2执行完才执行剩余的线程1操作
通过上图可以看到,最后的结果会使线程2读取到的数据是旧的数据(数据1),缓存里存着的是数据1,数据库存着的是数据2,造成了数据不一致性。(但是随着缓存中旧数据过期,最终总会拿到新的数据(数据2),依旧存在数据延迟一致性的问题,无法实时同步数据)
补充:如果是两个并发更新操作的话,已经不会存在缓存与数据库不一致的问题了,因为删除缓存,缓存失效总会从数据库中同步数据。
抛出问题二:难道先更新数据库再删除缓存就不存在不一致性问题了吗?
可以看到上述这种情况最后拿到的结果是 数据库的数据是数据2,而缓存的数据是数据1,依旧存在数据不一致性问题
但是在实际的业务中,发生这种情况的概率是很小很小的,因为数据库的写入通常要比缓存的写入要旧的多了,所以很难出现上述的情况,所以概率很小的条件下,先更新数据库再删除缓存是可以接受的。
小结一下失效模式下的问题
经过上面的分析,对于更新数据操作来说,选用先更新数据库再删除缓存是较好的方案
接下来解决原子性问题(删除缓存失败问题)
执行流程如下:
注意点:
执行流程概述:当MySQL数据库产生新的insert、update、delete等操作时,产生binlog操作日志(类似于MySQL的主从复制中使用binlog实现数据一致性),将日志的信息交给非业务代码,非业务代码根据日志提取的信息可以对缓存进行删除数据操作,删除失败则发送相关数据给消息队列,重复消费消息队列达到重试的效果。
使用该方案的优点:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。