1. 缓存雪崩、击穿、穿透⚓
1.1 缓存雪崩⚓
当 1. 大量缓存数据同时过期(失效) 或 2. Redis 宕机 时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库压力骤增,严重时会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩
。
不同的诱因,应对的策略也会不同。
1.1.1 大量数据同时过期⚓
常见的应对方法有下面这几种:
- 均匀设置过期时间;
- 互斥锁;
- 双 key 策略;
- 后台更新缓存;
1.1.1.1 均匀设置过期时间⚓
避免对大量数据设置同一个过期时间,在设置过期时间时给这些数据的过期时间加上一个随机数,保证不在同一时间过期。
1.1.1.2 互斥锁⚓
当业务线程在处理用户请求时,如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存(从数据库读取数据,再将数据更新到 Redis 里),当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
实现互斥锁的时候,最好设置超时时间,不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞,一直不释放锁,这时其他请求也一直拿不到锁,整个系统就会出现无响应的现象。
golang 中有 singleflight 机制,和这个思路相同。
1.1.1.3 双 key 策略⚓
二级缓存:A1为原始缓存,A2为拷贝缓存。A1失效时,可以访问A2,其中A1的缓存失效时间设置为短期(比如5min),A2的缓存失效时间设置为长期(比如1天)。如果缓存value很大,此方案的缓存空间利用率低。
双key:思路和二级缓存类似,不同的是双key分别缓存过期时间(key-time)和缓存数据(key-data),其中(key-time)的缓存失效时间设置为短期(比如5min),(key-data)的缓存失效时间设置为长期(比如1天)。当第一个线程发现 key-time 过期不存在时,则先更新key-time,然后去查询数据库并更新key-data 的值;当其他线程来获取数据时,虽然第一个线程还没有从数据库查询完毕并更新缓存,但发现key-time存在,会直接读取缓存的旧数据返回。和二级缓存的方案对比,该方案的缓存空间利用率高。
1.1.1.4 后台更新缓存⚓
业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新。
事实上,缓存数据不设置有效期,并不是意味着数据一直能在内存里,因为当系统内存紧张的时候,有些缓存数据会被“淘汰”,而在缓存被“淘汰”到下一次后台定时更新缓存的这段时间内,业务线程读取缓存失败就返回空值,业务的视角就以为是数据丢失了。
解决上面的问题的方式有两种。
第一种方式,后台线程不仅负责定时更新缓存,而且也负责频繁地检测缓存是否有效,检测到缓存失效了,原因可能是系统紧张而被淘汰的,于是就要马上从数据库读取数据,并更新到缓存。 检测的间隔最好是毫秒级的,但是总归是有个间隔时间,用户体验一般。
第二种方式,在业务线程发现缓存数据失效后(缓存数据被淘汰),通过消息队列发送一条消息通知后台线程更新缓存,后台线程收到消息后,在更新缓存前可以判断缓存是否存在,存在就不执行更新缓存操作;不存在就读取数据库数据,并将数据加载到缓存。这种方式相比第一种方式缓存的更新会更及时,用户体验也比较好。
在业务刚上线的时候,我们最好提前把数据缓起来,而不是等待用户访问才来触发缓存构建,这就是所谓的缓存预热,后台更新缓存的机制刚好也适合干这个事情。
1.1.2 Redis 宕机⚓
常见的应对方法有下面这几种:
- 服务熔断或请求限流机制;
- 构建 Redis 缓存高可靠集群;
1.1.2.1 服务熔断或请求限流机制⚓
服务熔断
机制,暂停业务应用对缓存服务的访问,直接返回错误,不用再继续访问数据库,从而降低对数据库的访问压力,保证数据库系统的正常运行,然后等到 Redis 恢复正常后,再允许业务应用访问缓存服务。
为了减少对业务的影响,我们可以启用请求限流
机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等到 Redis 恢复正常并把缓存预热完后,再解除请求限流的机制。
1.1.2.2 构建 Redis 缓存高可靠集群⚓
服务熔断或请求限流机制是缓存雪崩发生后的应对方案,我们最好通过主从节点的方式构建 Redis 缓存高可靠集群。
1.2 缓存击穿⚓
如果缓存中的某个热点数据过期了,此时大量的请求就会直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿
。
缓存击穿跟缓存雪崩很相似,应对缓存击穿可以采取前面说到两种方案:
- 互斥锁方案,保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
- 不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间;
1.3 缓存穿透⚓
如果访问的数据既不在缓存也不在数据库中,无法构建缓存数据,那么大量的请求到来时也会造成数据库的压力陡增,这就是缓存穿透。
缓存穿透的发生一般有这两种情况:
- 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;
- 黑客恶意攻击,故意大量访问某些读取不存在数据的业务;
常见的方案有三种。
- 限制非法请求;
- 缓存空值或者默认值;
- 使用布隆过滤器快速判断数据是否存在;
1.3.1 限制非法请求⚓
在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。
1.3.2 布隆过滤器⚓
在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。
即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。
下面说下布隆过滤器的工作原理。
布隆过滤器由初始值都为 0 的位图数组和N 个哈希函数两部分组成。
布隆过滤器会通过 3 个操作完成标记:
- 使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值;
- 将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置。
- 将每个哈希值在位图数组的对应位置的值设置为 1;
当要查询数据 x 是否存在时,通过布隆过滤器只要查找 N 个哈希值对应位图数组的 N 个位置的值是否全为 1,只要有一个为 0,就认为数据 x 不存在。
布隆过滤器由于是基于哈希函数实现查找的,高效查找的同时存在哈希冲突的可能性。
所以,查询布隆过滤器说数据存在,并不一定证明存在这个数据,但是查询到数据不存在则一定为真。