讨论Redis缓存下可能出现的缓存穿透、缓存击穿、缓存雪崩等问题。
另外记录一下缓存预热、缓存更新模式、缓存降级等等。
涉及秒杀、抢购、页面瞬间大量访问的情况使用SQL数据库会因为磁盘读写效率问题导致严重的性能弊端。
这时候就需要引入NoSQL技术,比如Redis,它是一种基于内存的数据库,并且提供一定持久化功能。
这里我们讨论Redis的缓存穿透、缓存击穿、缓存雪崩这三个问题。
缓存穿透
Redis是一个Key-Value的缓存,key对应的数据在缓存中不存在,则会去请求数据库。
如果大量请求从缓存获取不到,都会请求SQL数据库,从而可能压垮数据库。
比如用一个不存在的用户id来获取用户信息,此时不论是缓存还是数据库都查询不到,若黑客利用此漏洞进行攻击可能压垮我们的数据库。
由于缓存是不命中时被动写的,并且出于容错考虑,如果从数据库查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,失去了缓存的意义。
解决方案:
1. 布隆过滤器
布隆过滤器可以用于检索一个元素是否在一个集合中。
它的优点是空间效率和查询效率极高,缺点是有一定的误识别率和删除困难,但是没有识别错误的情形。
误识别的情况可能是:布隆过滤器报告元素在集合中,但实际上集合中没有。
但是如果布隆过滤器报告元素不在集合中,那么集合中一定没有。
应用举例:Google 著名的分布式数据库 Bigtable 使用了布隆过滤器来查找不存在的行或列,以减少磁盘查找的IO次数。
另外提一句,有一个布谷过滤器(Cuckoo Filter)可以解决布隆过滤器无法删除的缺点。
2. 写一个无效值到缓存中
如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存起来。
但它的过期时间会很短,最长不超过五分钟。
这样下次查询依旧会返回一个空值。
缓存击穿
设置了过期时间的key,它可能会在某些时间点被超高并发访问,这种就属于热点数据。
对应的情况可能是这样:缓存在某个时间点过期,恰好此时针对这个key有大量的请求过来,然后缓存查询不到,这些请求就打在了数据库上,最终将数据库压垮。
解决方案:
1. 使用互斥锁
简单来讲就是缓存查询不到的情况下,不会直接去请求数据库,而是先使用Redis的SETNX(或者Memcache的ADD)设置一个mutex key,当操作返回成功的情况下才请求数据库,否则就重试get缓存方法。
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public String get(key) { String value = redis.get(key); if (value == null) { //代表缓存值过期 //设置3min的超时,防止del操作失败的时候,下次缓存过期一直不能请求数据库 if (redis.setnx(key_mutex, 1, 3 * 60) == 1) { //设置成功, 请求数据库 value = db.get(key); redis.set(key, value, expire_secs); redis.del(key_mutex); } else { //这个时候代表同时候的其他线程已经请求数据库并回设到缓存了,这时候重试获取缓存值即可 sleep(50); get(key); //重试 } } else { return value; } } |
存在的问题是,一个线程去请求数据,其他线程因为拿不到mutex而阻塞,性能会大大降低。
2. 设置永不过期
从Redis来说就是功能上的修改,以前有过期时间,改成没有过期时间。
3. 异步更新
给数据手动加上一个过期时间,当我们请求数据时,判断数据是否已经过期。
如果数据过期了,则拉起一个线程(协程)异步更新数据。
此时其他线程获取的还是过期的老数据,不过对数据实时性要求不高的数据来说…也是可以的。
缓存雪崩
我们给缓存的key设置了相同的过期时间,导致在某一时间它们同时失效,请求就全部打到数据库,导致数据库压力过大崩溃。
解决方案
1. 随机过期时间
给热点key加上随机的过期时间,让它们不会同时失效。
2. 永不过期
还是那句话,不过期就没这个问题了嘛。
3. 速率限制
这里主要是针对数据库,不让大量的请求直接打上去,但是实际上治标不治本。
缓存预热
当系统上线时,缓存内是没有数据的,如果直接使用的话,大量请求就会直接打在数据库上,说不定上线就宕机。
所以一种可行的操作是:上线前先将数据库内的热点数据缓存到Redis中。
比较通用的方式是:写一个批处理任务,在启动项目时或定时触发将底层数据库内热点数据加载到缓存中。
缓存更新模式
缓存服务(Redis)和数据服务(MySQL)是相互独立的系统,在更新缓存时或更新数据时无法做到原子性的同时更新两边的数据。因此在并发读写或第二步操作异常时会遇到各种数据不一致的问题。
这里讨论如何解决并发场景下更新操作的一致。
更新缓存的模式:
1. Cache Aside 模式:
查询操作:先查询缓存,缓存内没有则查询数据库然后加载到缓存中。
更新操作:先更新数据库,然后让缓存失效或者更新缓存。
更新缓存可能导致的脏数据问题:
a,b两个线程,线程执行顺序:
a更新db,b更新db,b线程更新cache,a线程更新cache。
此时数据库存储的是b线程数据,cache存储是a线程的数据,所以缓存存储的是脏数据。
让缓存失效可能导致的脏数据问题:
a,b线程,线程执行顺序:
- a读取cache,发现没有数据,读db数据(且成功)。
- b线程更新db,并让cache失效。
- a线程已经读取到数据(b更新前的数据),此时a线程把数据同步到cache中。
此时数据库中的数据是b现场数据,缓存中数据是a线程读取的旧数据,所以也是脏数据。
不过让缓存失效出现脏数据的概率非常低:
- 这个条件需要发生在读缓存失效,且并发写操作的情况下。
- 数据库的写操作比读操作慢得多,而且还要锁表。
- 读操作必须在写操作之前进入数据库操作,又要晚于写操作更新缓存。
以上条件发生的概率并不大,但是最好还是为缓存设置一个过期时间。
Facebook就使用了让缓存失效的方法。
2. Read/Write Through 模式:
Read Through: 在查询操作中更新缓存。
当缓存失效时(缓存过期或LRU换出),Read Through则用缓存服务器自己加载数据到缓存中。
Write Through: 在更新操作中更新缓存。
没有命中缓存,则直接更新数据库,然后返回。
命中缓存,则更新缓存,再由Cache自己更新数据库(同步操作)
3. Write Behind Caching 模式:
又叫Write Back,就是Linux文件系统的Page Cache的算法。
更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。
好处是数据的I/O操作飞快无比(因为直接操作内存 ),因为异步,还可以合并对同一个数据的多次操作,所以性能大大提高。
带来的问题:
- 数据不是强一致性,而且可能会导致数据丢失
- Write Back实现逻辑复杂,因为它要知道哪些数据是需要写到数据库的。
操作系统的write back会在仅当这个cache需要失效的时候,才会被真正持久起来,比如,内存不够了,或是进程退出了等情况,这又叫lazy write。
缓存本身就是通过牺牲强一致性来提高性能,因此使用缓存提升性能,就会有数据更新的延迟性。
需要我们在评估需求和设计阶段根据实际场景去做权衡了。
缓存降级
缓存降级指的是当访问量剧增、服务出现问题(比如响应过慢或不响应)或非核心业务影响到核心业务的性能时,即使有损部分其他服务,仍要保证主服务可用。
可以将其他次要服务的数据进行缓存降级,从而提升主服务的稳定性。
降级的目的是保证核心服务即使有损依旧可用。
降级可以根据实时监控数据自动降级,也可以配置开关人工降级。
是否需要降级、哪些服务需要降级、是什么情况下降级都取决于系统功能的取舍。
举个例子:阿里双十一淘宝购物车无法修改地址(只能使用默认地址),就是被降级了,保证了下单可以提交和付款。