缓存的收益和成本
1、缓存带来的回报
- 高速读写
- 降低后端负载(业务端使用redis降低后端mysql负载)
2、缓存带来的代价
- 数据的不一致性
缓存层和数据层有时间窗口不一致,和更新策略有关 - 代码维护成本,增加代码的复杂度
- 堆内缓存(缓存占用的内存是从jvn里面分配的)可能带来内存溢出的风险影响用户进程
堆内缓存和远程服务器缓存redis的选择
- 堆内缓存一般性能更好,远程缓存需要套接字传输
- 用户级别缓存尽量采用远程缓存
- 大数据量尽量采用远程缓存,服务节点化原则
缓存处理流程
使用缓存通常的操作是,请求先访问缓存数据,如果缓存中不存在的话,就会回源到数据库中然后将数据写入到缓存中;如果存在的话就直接返回数据。
缓存穿透
现象: 每次请求直接穿透缓存层,直接回源到数据库中,给数据库带来了巨大访问压力,甚至宕机。(宕机:操作系统无法从一个严重系统错误中恢复过来,或系统硬件层面出问题,以致系统长时间无响应,而不得不重新启动计算机的现象)
原因: 访问数据会先访问缓存,如果数据不存在缓存中才会查询数据库,但是如果查询数据库也查询不出来数据,也是说当前访问数据永远不会写入缓存中。这样就导致了,访问一定不存在的数据,就相当于缓存层形同虚设,每次请求都会到db层,造成数据库负担过大。
产生原因: 自身业务代码或者数据出现问题;一些恶意攻击、爬虫等造成大量空命中
解决方案核心 ==》缓存穿透强调是获取本不存在的缓存数据,请求必然会越过缓存层直接到达到存储层,很明显这是利用业务规则的漏洞对系统发起攻击,解决方案的核心原则是 过滤这些非法业务请求 ,与是否是热点数据、缓存失效时间等因素没有关系。
解决方案:
- 1、 采用 bloom filter 保存缓存过的key,在访问请求到来时可以过滤掉不存在的key,防止这些请求到db层;
布隆过滤器(Bloom Filter) 是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是 空间效率和查询时间都远远超过一般的算法 ,缺点是 有一定的误识别率和删除困难 。
布隆过滤器非常高效同时占空间非常少, 它判断一个元素不存在那肯定就是不存在,它判断存在的时候有一定误差,是有可能不存在的
布隆过滤器的原理:
当一个元素被加入集合时,通过K个Hash函数将这个元素映射成一个位数组中的K个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的基本思想。
简单的说就是:通过将一个key的hash值分布到一个大的bit数组上面,判断一个key是否存在时只需判断该的hash对应的bit位 是否都是1 ,如果 全是1则表示存在,否则不存在 。
优点:性能很高主要在hash算法上面,空间占用小,能够极大的缩小存储空间。
缺点:存在误判。既对应的bit位刚好被其他的key置为1了。
具体实现: ===》使用Guava提供的相关类库
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.1-jre</version>
</dependency>
//判断一个元素是否在集合中
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
/**
* @Description: 判断一个元素是否在集合中
*/
public class BloomFilterTest {
private static int size = 1000000;
private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), size);
public static void main(String[] args) {
for (int i = 0; i < size; i++) {
bloomFilter.put(i);
}
// 获取开始时间
long startTime = System.nanoTime();
//判断这一百万个数中是否包含29999这个数
if (bloomFilter.mightContain(29999)) {
System.out.println("命中了");
}
// 获取结束时间
long endTime = System.nanoTime();
System.out.println("程序运行时间: " + (endTime - startTime) + "纳秒");
}
}
==》 命中了 程序运行时间: 441616纳秒
//自定义错误率
public class Test2 {
private static int size = 1000000;
private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), size, 0.01);
public static void main(String[] args) {
for (int i = 0; i < size; i++) {
bloomFilter.put(i);
}
List<Integer> list = new ArrayList<Integer>(1000);
// 故意取10000个不在过滤器里的值,看看有多少个会被认为在过滤器里
for (int i = size + 10000; i < size + 20000; i++) {
if (bloomFilter.mightContain(i)) {
list.add(i);
}
}
System.out.println("误判的数量:" + list.size());
}
}
===》误判的数量:94
- 2、如果db查询不到数据,保存空对象到缓存层,设置较短的失效时间
缓存空对象会有两个问题:
空值做了缓存,意味着缓存层中存了更多的键, 需要更多的内存空间 (如果是攻击,问题更严重),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。
缓存层和存储层的数据会有一段时间窗口的不一致, 可能会对业务有一定影响。例如过期时间设置为5分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。
String get(String key){
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if(StringUtils.isBlank(cacheValue)){
// 从存储中获取
String storageValue = storage.get(key);
// 如果存储数据为空,需要设置一个过期时间
if(storageValue == null){
cache.expire(key,60);
}
return storageValue;
}else{
// 直接返回缓存中的值
return cacheValue;
}
}
- 3、针对业务场景对请求的参数进行有效性校验,防止非法请求击垮db
比如我们查询商品信息,我们把商品信息存储在 Mongodb 中,Mongodb 有一个 _id 是自动生成的,它有一定的生成规则,如果是直接根据 id 查询商品,在查询之前我们可以对这个 id 做认证,看是不是符合规范,当不符合的时候就直接返回默认的值, 既不用去缓存中查询,也不用操作数据库了 。这种方案可以解决一部分问题,使用场景比较少。
采用布隆过滤器BloomFilter
将所有可能存在的数据哈 希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力
缓存空值
如果一个查询返回的数据为空(不管是数据不 存在,还是系统故障)我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。 通过这个直接设置的默认值存放到缓存,这样第二次到缓冲中获取就有值了,而不会继续访问数据库
缓存击穿
现象: 当某一key失效时,造成大量请求到db层,击垮存储层。
原因: 为了保证缓存数据的时效性,通常会设置一个失效时间,如果是热点key,高并发时会有海量请求直接越过缓存层到数据库,这样就会给数据库造成的负担增大,甚至宕机。
解决方案的核心原则 ===》规避数据库的并发操作。
解决方案:
- 1、使用互斥锁,当缓存数据失效时,保证一个请求能够访问到数据库,并更新缓存,其他线程等待并重试;
static Lock reenLock = new ReentrantLock();
public List<String> getData(String key) throws InterruptedException {
List<String> result = new ArrayList<String>();
// 从缓存读取数据
result = getDataFromCache(key);
// 缓存中数据不存在
if (result.isEmpty(key)) {
//获取锁获取成功,去数据库取数据
if (reenLock.tryLock()) {
try {
System.out.println("我拿到锁了,从DB获取数据库后写入缓存");
// 从数据库查询数据
result = getDataFromDB(key);
// 将查询到的数据写入缓存
setDataToCache(key,result);
} finally {
// 释放锁
reenLock.unlock();
}
} else {//获取锁失败
result = getDataFromCache();// 先查一下缓存
if (result.isEmpty()) {
System.out.println("我没拿到锁,缓存也没数据,先小憩一下");
Thread.sleep(100);// 小憩一会儿
return getData(key);// 重试
}
}
}
return result;
}
- 2、缓存数据“永远不过期”,如果缓存数据不设置失效时间的话,就不会存在热点key过期造成了大量请求到数据库。 但是,缓存数据就变成“静态数据”,因此当缓存数据快要过期时, 采用异步线程的方式提前进行更新缓存数据 。
String get(final String key) {
V v = redis.get(key);
String value = v.getValue();
long timeout = v.getTimeout();
if (v.timeout <= System.currentTimeMillis()) {
// 异步更新后台异常执行
threadPool.execute(new Runnable() {
public void run() {
String keyMutex = "mutex:" + key;
if (redis.setnx(keyMutex, "1")) {
//3分钟超时,以避免互斥锁持有者崩溃
redis.expire(keyMutex, 3 * 60);
String dbValue = db.get(key);
redis.set(key, dbValue);
redis.delete(keyMutex);
}
}
});
}
return value;
}
缓存雪崩
现象: 多个key失效,造成大量请求到db层,导致db层负担过重甚至宕机。
如果缓存集中在一段时间内失效,发生大量的缓存穿透,所有的查询都落在数据库上,造成了缓存雪崩。 由于原有缓存失效,新缓存未到期间所有原本应该访问缓存的请求都去查询数据库了,而对数据库CPU 和内存造成巨大压力,严重的会造成数据库宕机
原因: 缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到数据库,最终导致数据库瞬时压力过大而崩溃。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
解决方案核心 ===》缓存雪崩强调的多个key的集体失效,与key是否是热点数据并不是必然的因素,解决方案的核心原则则 让key之间的失效时间分布更加均匀,避免集体失效的情况
解决方案:
-
1、使用互斥锁的方式,保证只有单个线程进行请求能够达到db;
-
2、给每个key的失效时间在基础时间上再加上一个1~5分钟的随机值, 这样就能保证大规模key集体失效的概率,并且需要尽量让多个key的失效时间能够均匀分布;
解决方案二:
加锁排队
key: whiltList value:1000w个uid 指定setNx whiltList value nullValue mutex互斥锁解决,Redis的SETNX去set一个mutex key, 当操作返回成功时,再进行load db的操作并回设缓存; 否则,就重试整个get缓存的方法
数据预热
缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!可以通过缓存reload机制,预先去更新缓存,再即将发生大并发访问前手动触发加载缓存不同的key
双层缓存策略
C1为原始缓存,C2为拷贝缓存,C1失效时,可以访问C2,C1缓存失效时间设置为短期,C2设置为长期。
定时更新缓存策略
失效性要求不高的缓存,容器启动初始化加载,采用定时任务更新或移除缓存
设置不同的过期时间,让缓存失效的时间点尽量均匀
响应速度不给力?解锁正确缓存姿势 https://mp.weixin.qq.com/s/QidAD9OuVdEXFqxRMPx5lQ