缓存穿透、缓存击穿、缓存雪崩及解决方案
一、为什么要使用缓存?
- 提升应用从程序性能
- 降低数据库成本
- 减少后端负载
- 可预测的性能
- 清除数据库热点
- 提高读取吞吐量(IOPS)
二、缓存的带来的问题
常见的缓存问题有:缓存与数据库数据不一致;缓存穿透、缓存击穿、缓存雪崩;缓存并发竞争等;
三、缓存的设计
insert,新增数据至数据库;update,删除缓存中的对应的数据,修改数据库的数据;delete,删除缓存中对应数据,删除数据库的数据;query,查询缓存中的数据,存在数据即返回查询的数据,缓存中不存在数据则查询数据库,数据库存在数据则返回数据库中数据并同步更新到缓存,缓存和数据库都不存在数据返回null。
四、缓存穿透
缓存穿透指用户请求数据,系统缓存中不存在该数据,直接请求了数据库,并且数据库也没有该数据。当大量该类请求发送是,系统在缓存中都没有获取到数据,直接请求了数据库,这会给数据库造成很大压力,甚至直接宕机。
解决方案:
- 缓存空对象
当请求从缓存和数据都获取不到数据时,将返回的空对象缓存起来,同时设置一个过期时间。但是这种方法会存在两个问题:如果空值能够被缓存起来,这就意味着缓存需要更多的空间存储更多的键,因为这当中可能会有很多的空值的键;即使对空值设置了过期时间,还是会存在缓存层和存储层的数据会有一段时间窗口的不一致,这对于需要保持一致性的业务会有影响。 - 接口层增加校验(布隆过滤器Bloom Filter)
2.1 Bloom Filter概念
Bloom Filter是一种空间效率很高的随机数据结构,它利用位数组很简洁地表示一个集合,并能判断一个元素是否属于这个集合。Bloom Filter的这种高效是有一定代价的:在判断一个元素是否属于某个集合时,有可能会把不属于这个集合的元素误认为属于这个集合(false positive)。因此,Bloom Filter不适合那些“零错误”的应用场合。而在能容忍低错误率的应用场合下,Bloom Filter通过极少的错误换取了存储空间的极大节省。
2.2 Bloom Filter原理(不存在漏报,存在误报)
布隆过滤器底层通过bit数组进行存储(bit数组空间占用少、能支持快速的查找),并且通过hash函数(hash函数能保证任意输入固定输出,并且多次计算结果一致)来计算元素在数组中的下标位置。由于不同元素值可能存在相同的hash值(Hash碰撞),所以布隆过滤器存在一定的误判率。
布隆公式:
n:存储元素的个数
p:希望达到的误判率
m:bit数组的长度m
k:hash函数的个数
2.3 Java 手写 Bloom Filter
Java中没有bit类型的数据类型,但是可以通过基础数据类型来转换为bit数组,如int[10]相当于bit[32 * 10],long[10]相当于bit[64 * 10]。
byte:1byte = 8bit 1个字节是8个bit
short:2byte
int:4byte
long:8byte
float:4byte
double:8byte
boolean:1byte
char:2byte
代码如下:
import java.util.BitSet;
/**
* 缓存穿透解决方案:bloomFilter
*/
public class MyBloomFilter {
// 需要存储的元素个数
private int n;
// 希望达到的误判率
private double p;
// bit数组的长度
private int m;
// hash函数的个数
private int k;
// 存储元素的数组
BitSet bitSet;
public MyBloomFilter(int n, double p) {
this.n = n;
this.p = p;
}
/**
* 添加元素
* @param ele
*/
public void addElement(String ele) {
if (this.bitSet == null) {
// 初始化bitSet及相关参数
init();
}
// 获取元素ele 在k个哈希函数中计算的下标值(数组)
int[] indexArray = getIndexs(ele);
// 将BitSet中下标设置为true
for (int index : indexArray) {
this.bitSet.set(index);
}
}
/**
* 判断ele 元素是否存在集合中
* @param ele
* @return
*/
public boolean isExist(String ele) {
if (this.bitSet == null || this.bitSet.isEmpty()) {
return false;
}
boolean flag = true;
// 获取元素ele 在k个哈希函数中计算的下标值(数组)
int[] indexArray = getIndexs(ele);
for (int index : indexArray) {
flag = flag && this.bitSet.get(index);
}
return flag;
}
/**
* 元素ele 在k个哈希函数中计算的下标值(数组)
* @param ele
* @return
*/
private int[] getIndexs(String ele) {
int[] indexArray = new int[this.k];
for (int i = 0; i < k; i++) {
indexArray[i] = HashUtil.md5Hash(ele + "-" + k);
}
return indexArray;
}
/**
* 初始化bitSet及相关参数
*/
private void init() {
// 获取 bit数组的长度
this.m = (int) ((-this.n * Math.log(this.p)) / (Math.log(2) * Math.log(2)));
// 获取 hash函数的个数
this.k = (int) ((this.m / this.n) * Math.log(2));
bitSet = new BitSet(this.m);
}
public static void main(String[] args) {
int n = 1000000;
double p = 0.0003;
// 测试
MyBloomFilter bloomFilter = new MyBloomFilter(n, p);
// 添加元素
for (int i = 1; i <= n; i++) {
bloomFilter.addElement("key" + i);
}
int num = n * 2;
// 测试:误判率
int count = 0;
for (int i = 1; i <= num; i++) {
if (bloomFilter.isExist("key" + i)) {
count++;
}
}
System.out.println("进行判断的key个数:" + num);
System.out.println("系统判断存在的key:" + count);
System.out.println("期望误判个数:" + n * p);
System.out.println("实际误判个数:" + (count - n));
}
}
import java.math.BigInteger;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class HashUtil {
/**
* md5 hash函数
* @param str
* @return
*/
public static int md5Hash(String str) {
if (str == null) {
str = "";
}
MessageDigest md5 = null;
try {
md5 = MessageDigest.getInstance("md5");
md5.update(str.getBytes());
BigInteger bigInteger = new BigInteger(md5.digest());
return Math.abs(bigInteger.intValue());
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
return -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()) {
// 获取锁,获取成功,去数据库获取数据,并更新缓存
if (reenLock.tryLock()) {
try {
// 从数据库查询数据
result = getDataFromDB(key);
// 将查询到的数据写入缓存
setDataToCache(key, result);
} finally {
reenLock.unlock();// 释放锁
}
} else {
result = getDataFromCache(key);// 先查一下缓存
if (result.isEmpty()) {
Thread.sleep(100);
return getData(key);// 重试
}
}
}
return result;
}
六、缓存雪崩
缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
解决方案:
- 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
- 如果缓存数据库是分布式部署,将热点数据均匀分布在不同搞得缓存数据库中。
- 数据预热:数据预热的含义就是在正式部署之前,先把可能的数据预先加载到缓存中。
- 限流(加锁,分布式锁)、缓存降级(设置备用缓存,可能存在数据不一致问题)。
第三方Bloom Filter
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>18.0</version>
</dependency>
创建 Bloom Filter
BloomFilter.create(Funnels.stringFunnel(Charset.forName("UTF-8")), 1000000, 0.00003);