1 缓存和数据库的数据一致性分析
1.1 Redis 中如何保证缓存和数据库双写时的数据一致性?
无论先操作db还是cache,都会有各自的问题,根本原因是cache和db的更新不是一个原子操作,因此总会有不一致的问题。想要彻底解决这种问题必须将cache和db的更新操作归在一个事务之下(例如使用一些分布式事务,或者强一致性的分布式协议)。或者采用串行化,可以保证强一致性。
1.1.1 写请求为什么更新数据库后是删除缓存而不是更新缓存?
注意看上面的图片,当有两个写请求的线程,线程一比线程二先执行,反而是线程二先执行完。这时候,缓存保存的是A的数据(老数据),数据库保存的是B的数据(新数据),数据不一致了。
1.1.2 写请求时,为什么更新数据库,然后再删除缓存?
如果采用写请求,先删除缓存,再更新数据库就会出现如上图的情况,线程B读到的是老的数据,并且缓存中也保存的是老的数据。
1.1.3 写请求时,先更新数据,后删除缓存一定没有问题吗?
可以看到一个读请求和一个写请求,读请求可能会读取到旧的数据,或者当写请求删除缓存失败,读请求会一直读取的是旧的缓存数据。只不过是这种情况,相对于其他的实现方式概率要低很多。
1.2 三种方案保证数据库与缓存的一致性
1.2.1 缓存延时双删
第二次删除缓存一般会采用延时的操作,主要是用来删除读请求产生的缓存数据
1.2.2 删除缓存重试机制
延时双删和普通写操作的删除操作都有可能会操作失败,导致数据不一致,删除重试机制就是为了保证删除可靠性。(删除失败的key放到消息队列中)这种机制会造成大量的业务代码入侵。
1.2.3 读取biglog异步删除缓存
通过binlog日志,将要删除的key发送到消息队列中。
1.3 如何使用 Redis 做异步队列和延时队列?
1.3.1 延时队列
将需要延时执行的任务放到 Redis 中的 Zset 类型中,Zset会根据 score 自动进行数据排序(score使用时间戳),定义一个延时任务检测器,检测器使用 zrangebysocre 命令查询 Redis 中符合执行条件的任务执行.
1.3.2 异步队列
Redis的队列list是有序的且可以重复的,作为消息队列使用时可使用rpush/lpush操作入队,使用lpop/rpop操作出队。当发布消息是执行lpush命令,将消息从列表左侧加入队列。消息接收方执行rpop命令从列表右侧弹出消息。
如果队列空了,消费者会陷入pop死循环,即使没有数据也不会停止。空轮询不但消耗消费者的CPU资源还会影响Redis的性能。并且需要不停的调用rpop查看列表中是否有待处理的消息。每调用一次都会发起一次连接,势必造成不必要的资源浪费。
入队的速度大于出队的速度,消息队列长度会一直增大,时间长了会占用大量的空间。
针对上面的 rpop 命令会一直阻塞队列,Redis提供了一种更优的 brpop命令,brpop可以设置一个超时时间,
1.4 Redis 中的过期策略
Redis 中的过期策略共有三种:
- 定时删除
- 定期删除
- 惰性删除
Redis 采用的过期策略是 定期 惰性 删除。
1.4.1 定时删除
在设置key的过期时间的同时为该key创建一个定时器,让定时器在key的过期时间来临时,对key进行删除
优点: 保证内存被尽快释放
缺点: 过期的key太多,删除这些key会占用很多的CPU时间。设置了过多的定时器,会对redis 的性能造成影响。
1.4.2 定期删除
默认一段时间就去随机部分扫描redis中的设置了过期时间的key,检查是否过期,过期的话就移除key。
1.4.2.1 为什么定期删除只扫描部分设置了过期时间的key
因为扫描全部的key会非常多,很影响性能。
1.4.3 惰性删除
惰性删除就是等到有查询key的请求过来的时候,我看看这个key有没有过期,过期的话就删除这个key。
缺点: 可能会造成内存泄漏
1.5 Redis 中的内存淘汰机制
设置方式: config set maxmemory-policy volatile-lru
- no-eviction: 禁止驱逐数据(当内存达到限制时,就报错)
- allkeys-lru: 从redis 中回收最近使用最少的键
- volatile-lru: 从设置了过期时间的键中,回收最近使用最少的键
- allkeys-random:随机回收redis中的键
- volitile-random:从设置了过期时间的键中,随机回收
- volitile-ttl:从设置了过期时间的键中,回收存活时间较少的键
关于volatile-lru:LRU 算法实现:1.通过双向链表来实现,新数据插入到链表头部;2.每当缓存命中(即缓 存数据被访问),则将数据移到链表头部;3.当链表满的时候,将链表尾部的数据丢弃。
指定redis 的淘汰策略
代码语言:javascript复制# maxmemory-policy noeviction
2 缓存更新机制
当执行写操作后,需要保证从缓存读取到的数据与数据库中的数据是一致的,因此需要对缓存进行更新。因为涉及到数据库和缓存两步操作,难以保证更新的原子性。在设计更新策略时,我们需要考虑多个方面的问题,对系统吞吐量的影响、并发安全性、更新失败的影响。
更新缓存有两种方式:
- 删除失效缓存: 读取时会因为未命中缓存而从数据库中读取新的数据并更新到缓存中
- 更新缓存: 直接将新的数据写入缓存覆盖过期数据
更新缓存和更新数据库有两种顺序:
- 先数据库后缓存
- 先缓存后数据库
两两组合共有四种更新策略,现在我们逐一进行分析。
2.1 更新策略分析
先做一个说明,从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。这种方案下,我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存。
2.1.1 先更新数据库,再删除缓存(推荐)
若数据库更新成功,删除缓存操作失败,则此后读到的都是缓存中过期的数据,造成不一致问题。
假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生:
(1)缓存刚好失效;
(2)请求A查询数据库,得一个旧值;
(3)请求B将新值写入数据库;
(4)请求B删除缓存;
(5)请求A将查到的旧值写入缓存;
假设,有人非要抬杠,有强迫症,一定要解决怎么办?
如何解决上述并发问题?首先,给缓存设置有效时间是一种方案。其次,采用异步延时删除策略,redis自己起一个线程,异步删除保证读请求完成以后,再进行删除操作。
2.1.2 先更新数据库,再更新缓存(反对)
同删除缓存策略一样,若数据库更新成功缓存更新失败则会造成数据不一致问题。反对此方案
原因一(线程安全角度)
同时有请求A和请求B进行更新操作,那么会出现:
(1)线程A更新了数据库
(2)线程B更新了数据库
(3)线程B更新了缓存
(4)线程A更新了缓存
这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。
原因二(业务场景角度)
有如下两点:
(1)如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。
(2)如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。
接下来讨论的就是争议最大的,先删缓存,再更新数据库。还是先更新数据库,再删缓存的问题。
2.1.3 先删除缓存,再更新数据库
该方案会导致不一致的原因是。同时有一个请求A进行操作,另一个请求B进行查询操作。那么会出现如下情形:
(1)请求A进行写操作,删除缓存;
(2)请求B查询发现缓存不存在;
(3)请求B去数据库查询得到旧值;
(4)请求B将旧值写入缓存;
(5)请求A将新值写入数据库;
上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。
那么,如何解决呢?
采用延时双删策略:
(1)先淘汰删除缓存;
(2)再写数据库(这两步和原来一样);
(3)休眠1秒,再次淘汰缓存;
这么做,可以将1秒内所造成的缓存脏数据,再次删除。那么,这个1秒怎么确定的,具体该休眠多久呢?这确实需要根据实际情况而定:
如果你用了MySQL的读写分离架构怎么办?还是使用延时双删策略。
采用这种同步淘汰策略,吞吐量降低怎么办?ok,那就将第二次删除作为异步的。自己起一个线程,异步删除。
第二次删除,如果删除失败怎么办?这是个非常好的问题,因为第二次删除失败,就会出现如下情形。还是有两个请求,一个请求A进行更新操作,另一个请求B进行查询操作,为了方便,假设是单库:
(1)请求A进行写操作,删除缓存;
(2)请求B查询发现缓存不存在;
(3)请求B去数据库查询得到旧值;
(4)请求B将旧值写入缓存;
(5)请求A将新值写入数据库;
(6)请求A试图去删除请求B写入对缓存值,结果失败了。
ok,这也就是说。如果第二次删除缓存失败,会再次出现缓存和数据库不一致的问题。
如何解决呢?
2.1.4 先更新缓存,再更新数据库(反对)
若缓存更新成功数据库更新失败, 则此后读到的都是未持久化的数据。因为缓存中的数据是易失的,这种状态非常危险。
2.2 缓存雪崩/击穿/穿透
正常情况下的流程是这样的,先查缓存,缓存无就查数据库。
2.2.1 缓存雪崩
缓存雪崩是指缓存中的数据大批量的过期 ,而查询量巨大,造成数据库压力过大而崩溃。
解决方法:
- 缓存的过期时间随机设置,防止大量数据同时过期。
- 尽量保证redis集群的高可用性,当发现机器坠机时尽快补上。
- 选择合适的缓存淘汰策略。
2.2.2 缓存击穿
缓存击穿是指缓存中没有数据,而数据库中有数据,一般是缓存中的数据过期了,然后很多用户并发查询该数据,同时在缓存中读取该数据没读取到,就同时去数据库中查,造成数据库压力过大。缓存击穿强调的是一个数据过期,同时并发地去数据库访问该数据;而缓存雪崩是强调大量的数据过期。
解决方法:
- 设置热点数据永不过期。
- 加互斥锁。逻辑如下:从缓存中获取当前数据,如果缓存中没有,则尝试去获取锁,如果获取成功则查询数据库,然后写进缓存,然后释放锁。
2.2.3 缓存穿透
缓存穿透是指缓存中没有该数据,数据库中也没有该数据。而用户不断地发请求,比如不断发出一些id=-1或者是根本就很不合理的数据来发生请求。这种一般是别人想攻击你。攻击会导致数据库压力过大。
对于这种情况很好解决,我们可以在redis缓存一个空字符串或者特殊字符串,比如&&,下次我们去redis中查询的时候,当取到的值是空或者&&,我们就知道这个值在数据库中是没有的,就不会再去数据库中查询,ps:这里缓存不存在key的时候一定要设置过期时间,不然当数据库已经新增了这一条记录的时候,这样会导致缓存和数据库不一致的情况。
上面这个只是重复查询同一个不存在的值的情况,如果每次查询的不存在的值是不一样的呢?那怎么办,难道自己手动缓存许多特殊字符串吗?别人想攻击你,即使你每次缓存很多特殊字符串也没用,太有概率性了,这时候数据库的压力是相当大,怎么办呢,布隆过滤器就登场了。
布隆过滤器使用场景:
①、原本有10亿个数,现在又来了10万个数,要快速准确判断这10万个数是否在10亿个数库中?
- 办法一:将10亿个数存入数据库,再数据库查询,查出值为null,代表不存在,准确性有了,但是速度会比较慢。
- 办法二:将10亿数放入内存中,比如Redis中,这里我们算一下占用内存大小:10亿*8字节=8GB,通过内存查询,准确性和速度都有了,但是大约8GB的内存空间,挺浪费内存空间的。
那么对于类似这种,大数据量集合,如何准确快速的判断某个数据是否在大数据量集合中,并且不占用内存,布隆过滤器应运而生了。
布隆过滤器:使用位图实现,是由一串很长的二进制向量组成,数组中只存在0.1
当要向布隆过滤器中添加一个元素key时,我们通过多个hash函数,算出一个值,然后将这个值所在的方格置为1。 如下图:
如何查询是否存在呢?
我们只需要将这个新的数据通过上面自定义的几个哈希函数,分别算出各个值,然后看其对应的地方是否都是1,如果存在一个不是1的情况,那么我们可以说,该新数据一定不存在于这个布隆过滤器中。
反过来说,如果通过哈希函数算出来的值,对应的地方都是1,那么我们能够肯定的得出:这个数据一定存在于这个布隆过滤器中吗?
答案是否定的,因为多个不同的数据通过hash函数算出来的结果是会有重复的,所以会存在某个位置是别的数据通过hash函数置为的1。比如这个d,通过三次计算发现得到的结果也都是1,那么我们能说d在布隆过滤器中是存在的吗,显然是不行的,我们仔细看d得到的三个1其实是f1(a),f1(b),f2©存进去的,并不是d自己存进去的,这个还是哈希碰撞导致的。
结论:布隆过滤器可以判断某个数据一定不存在,但是无法判断一定存在。
参考链接
Redis的缓存更新策略和缓存问题_redis更新缓存数据_〖雪月清〗的博客-CSDN博客
Redis-基本概念_redis定义_SeaDhdhdhdhdh的博客-CSDN博客
一文搞懂 Redis 架构演化之路
Redis设计与实现
redis架构_剑八-的博客-CSDN博客
Redis高可用方案—主从(masterslave)架构
Redis高可用架构—哨兵(sentinel)机制详细介绍
Redis高可用架构—Redis集群(Redis Cluster)详细介绍
Redis基本概念知识_redis 基本概念_Gatsby_codeLife的博客-CSDN博客
03 Redis 网络IO模型简介_redis的io模型_天秤座的架构师的博客-CSDN博客
Redis 详解_王叮咚的博客-CSDN博客