缓存应用之缓存穿透、缓存雪崩、缓存击穿
缓存已是系统架构中非常重要的组件,特别是在高并发的系统中几乎是不可或缺的。
由于缓存的特性和功能,在某些场景上会存在一些问题,主要是 缓存穿透、缓存雪崩 和 缓存击穿。
缓存穿透
概念
当查询缓存的 Key 不存在时执行数据库查询,高并发情况下给数据库带来压力,会十分消耗性能,甚至导致数据库瘫痪,也就失去了缓存的作用,这就是缓存穿透。
即查询缓不存在,大量的数据查询请求打到了数据库,当高并发情况下,瞬时数据库压力会很大。
解决方案
主要解决方案有:
把数据库查询为空的 Key 缓存起来(添加NULL值到缓存),设置一个较短的有效期,例如 60 秒。
对单一的不存在的数据效果较好;弊端是当请求的是大量不同的 Key 时,此方案会使得内存占用率升高,甚至导致 Redis 根据自己的缓存淘汰策略将热点数据淘汰掉。
对缓存的 Key 使用规则认证,若认证不通过返回默认值。这样即不查询缓存,也不查询数据库,能解决部分问题,但使用场景较少。
利用布隆过滤器来实现对缓存 Key 的检验,需要将所有可能缓存的数据 Hash 到一个 足够大的 BitSet,在缓存之前先从布隆过滤器中判断这个 Key 是否存在,然后做对应的操作。
布隆过滤器
Bloom Filter 概念
Bloom 过滤器是 HASH 很好的一个应用。Bloom 过滤器用于检测某个元素是否在集合中,能够返回的是 绝对不存 和 可能存在。
Bloom 算法是一种概率型数据结构,类似于 Hash Set,只存键不存值,底层是基于位图实现的,也称为位数组或位向量。
Bloom 过滤器的优点是空间效率和查询时间都比一般的算法要好的多(高效、占用空间少),缺点是有一定的识别率且删除困难(可以对 Bloom Filter 进行扩展实现计数过滤器来支持删除)。
Bloom Filter 应用场景
一个典型应用场景是通过 Bloom Filter 来减少对不存在的 Key 进行昴贵的磁盘或网络查询。
Bloom 过滤器的应用场景很多,除了防止缓存穿透外,还可以用于垃圾邮件检测、识别恶意链接、用于爬虫抓取的 URL 进行去重检查 等。
Guava Bloom Filter
Google Guava 是个强大的万能库,封装了很多实用的功能,其 Hashing 库构建了 Bloom Filter 的实现,可直接使用。
使用 BloomFilter.create(Funnel funnel, int expectedInsertions, double falsePositiveProbability) 创建一个新的过滤器,expectedInsertions 是预期插入的数据量,falsePositiveProbability 表示匹配错误率,必须是正数,小于 1,默认是 0.03,该值越少错误率越低,同时存储空间会越大。
创建的过滤器 BloomFilter<T>
提供了 void put(T) 方法用于插入数据,mightContain(T) 方法用于匹配数据。
利用布隆过滤器,预先将缓存的 Key 存到过滤器中,例如用户ID,产品ID。当根据 Key 查询数据时,先从过滤器中判断是否存在,存在的话进入缓存查询,不存在的话直接返回空即可,认为这是一个非法请求。
Guava Hashing Bloom Filter 使用示例:
1 |
|
缓存穿透并不能完全解决,只能将其控制在一个可以接受的范围内。
Redis Bloom Filter
参考 RedisBloom/RedisBloom,Redis Modules ,RedisBloom/JReBloom
缓存雪崩
概念
指大量的 Key 设置了相同的过期时间,导致在同一时刻同时失效,造成瞬时大量数据库请求,压力骤增,数据库崩溃。更致命的是某个 Redis 节点宕机或断网,导致所有请求走数据库,可能瞬间压垮数据库。
例如抢购的商品缓存失效,大量数据请求都落到数据库,会导致数据库压力瞬间增大。
解决方案
缓存存储层高可用:比如 Redis 集群,主成备份自动切换,这样可以防止某台 Redis 挂掉后所有缓存失效导致雪崩的问题。
缓存失效时间均匀分布:优化缓存失效时间,不同的数据有不同的有效期,使每个 Key 失效时间均匀分布(设置过期时间时加上一个随机因子(随机值时间)),不会集中在同一时刻失效。
例如,热门类目商品缓存时间长一些,冷门类目的商口缓存时间短一些。缓存预热:对一些热点数据采用定时更新的方式来刷新缓存,避免自动失效。
服务限流接口限流:如果服务和接口都有限流机制,即失缓存失效,请求的总量是有限的,只正常响应某些请求,用户体验会差些,但不会导致数据库崩溃而影响整个系统。
加锁控制查询数据库的并发量:从数据库获取缓存需要的数据时加锁控制,本地锁或分布式锁都可以,加锁执行排队请求,而不是大并发请求到数据库,保证存储服务不会崩溃。
给执行数据库查询的代码块添加 synchronized 关键字来同步代码块,或使用 Lock 加锁,加锁意味着要损失一部分性能。
或采用信号量方式来限制并发数,信号量可以限制同时操作的线程数,只要在数据库能抗住的范围内,充分发挥数据库性,又不会引起崩溃。示例如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38/**
* Semaphore也是一个线程同步的辅助类,可以维护当前访问自身的线程个数,并提供了同步机制。
* 使用Semaphore可以控制同时访问资源的线程个数。
* acquire()尝试获取许可证,没有获得的线程会阻塞
* release()操作完之后释放资源到资源池中
* @param args
* @throws InterruptedException
*/
public static void main(String[] args) throws InterruptedException {
// 线程池
ExecutorService exec = Executors.newCachedThreadPool();
// 只能5个线程同时访问
final Semaphore semp = new Semaphore(5);
final CountDownLatch latch = new CountDownLatch(20);
// 模拟20个客户端访问
for (int i = 0; i < 20; i++) {
exec.execute(new Runnable() {
public void run() {
try {
semp.acquire();
synchronized (latch) {
Thread.currentThread().setName("Thread--" + latch.getCount());
latch.countDown();
}
Thread.sleep(4000);
System.out.println(Thread.currentThread().getName() + "is working");
semp.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
}
exec.shutdown();
latch.await();
System.out.println("---work is done----");
}
缓存击穿
概念
一个非常热点的 Key,不定的扛着大并发,大并发集中对这个点进行访问,当这个 Key 失效的瞬间,持续的大并发穿破缓存,直接请求数据库,就像在一个屏障上 凿了个洞。
缓存击穿与缓存穿透极其相似,但缓存击穿主要集中在某一个 Key 的高并发时瞬间失效。
解决方案
- 可设置永不过期,后续手动淘汰或更新。
- 使用互斥锁,分布式环境下可使用分布式锁,其他线程拿不到锁时进入等待。
分布式锁实现:Redis SETNX 命令,或使用 Redisson 库。