分布式缓存问题(缓存雪崩,缓存穿透,缓存击穿)
参考地址:
《缓存穿透,缓存击穿,缓存雪崩解决方案分析》
《缓存穿透、缓存击穿、缓存雪崩区别和解决方案》
好的,把缓存逻辑从业务代码逻辑揪了出来,后一个问题就解决了,现在解决前一个问题:将集群中所有服务的缓存从本地缓存转为分布式缓存,降低缓存在服务中占用的资源。
由于业务组只有 Memcache 缓存集群,并没有搭起来 Redis,所以笔者还是选了 Memcache 作为分布式缓存工具。笔者用了一天时间封装了我们服务自己用的 MemcacheService,把初始化、常用的 get, set 方法封装完毕,测试也没有问题。由于具体过程只是对 Memcache 的 API 进行简单封装,故具体过程不表。但是进行到这里,笔者也只是简单的封装完毕,仍然有可以优化的空间。
集群服务的缓存,有三大问题:缓存雪崩、缓存穿透、缓存击穿。在并发量高的时候,这三个缓存问题很容易引起服务与数据库的宕机。虽然我们的小服务并不存在高并发的场景,但既然要做性能优化,就要尽量做到最好,所以笔者还是在我这小小的服务上事先了这几个缓存问题并加以解决。
(1) 缓存雪崩
缓存雪崩和缓存击穿都和分布式缓存的缓存过期时间有关。
缓存雪崩,指的是对于某些热点缓存,如果都设置了相同的过期时间,在过期时间范围之内是正常的。但等到经过了这个过期时间之后,大量并发再访问这些缓存内容,会因为缓存内容已经过期而失效,从而大量并发短时间内涌向数据库,很容易造成数据库的崩溃。
这样的情况发生的主要原因,在于热点数据设置了相同的过期时间。解决的方案是对这些热点数据设置随机的过期时间即可。比如笔者在封装 Memcache 接口的参数中有过期时间 int expireTime,并设置了默认的过期时间为 30min,这样的缓存策略确实容易产生缓存雪崩现象。此后笔者在传入的 expireTime 值的基础上,由加上了一个 0~300 秒的随机值。这样所有缓存的过期时间都有了一定的随机性,从而避免了缓存雪崩现象。
(2) 缓存击穿
假设有某个热点数据,该数据在数据库中存在该值,但缓存中不存在,那么如果同一时间大量并发查询该缓存,则会由于缓存中不存在该数据,从而将大量并发释放,大量并发涌向数据库,容易引起数据库的宕机。
看到这里也可以体会到,前面的缓存雪崩与缓存击穿有很大的相似性。缓存雪崩针对的是对一批在数据库中存在,但在缓存中不存在的数据;而缓存击穿针对的是一个数据。
在《缓存穿透,缓存击穿,缓存雪崩解决方案分析》一文中提到了四种方式,笔者采用了类似于第一种方式的解决方法:使用互斥锁。由于这里的环境是分布式环境,所以这里的互斥锁指的其实是分布式锁。笔者又按照《缓存穿透、缓存击穿、缓存雪崩区别和解决方案》一文中的思路,以业务组的 Zookeeer 集群为基础实现了分布式锁,解决了缓存击穿的问题。伪代码如下:
public Object getData(String key) {
// 1. 从缓存中读取数据
Object result = getDataFromMemcache(key);
// 2. 如果缓存中不存在数据,则从数据库中 (或者计算) 获取
if (result == null) {
InterProcessMutex lock = new InterProcessMutex(client, "/service/lock/test1");
// 2.1 尝试获取锁
try {
if (lock.acquire(10, TimeUnit.SECONDS)) {
// ※ 2.1.1 尝试再次获取缓存,如果获取值不为空,则直接返回
result = getDataFromMemcache(key);
if (result != null) {
log.info("获取锁后再次尝试获取缓存,缓存命中,直接返回");
return result;
}
// 2.1.2 从数据库中获取原始数据 (或者计算获取得到数据)
result = queryData(key);
// 2.1.3 将结果存入缓存
setDataToMemcache(key, result);
}
// 2.2 获取锁失败,暂停短暂时间,尝试再次重新获取缓存信息
else {
TimeUnit.MILLISECONDS.sleep(100);
result = getData(key);
}
} catch (Exception e) {
e.printStackTrace();
}
// 2.3 退出方法前释放分布式锁
finally {
if (lock != null && lock.isAcquiredInThisProcess()) {
lock.release();
}
}
}
return result;
}
笔者解决缓存击穿的思路,是集群中服务如果同时处理大量并发,且尝试获取同一数据时,所有并发都会尝试获取 InterProcessMutex 的分布式锁。这里的 InterProcessMutex,是 Curator 自带的一个分布式锁,它基于 Zookeeper 的 Znode 实现了分布式锁的功能。在 InterProcessMutex 的传参中,需要传入一个 ZNode 路径,当大量并发都尝试获取这个分布式锁时,只有一个锁可以获得该锁,其他锁需要等待一定时间 (acquire 方法中传入的时间)。如果经过这段时间仍然没有获得该锁,则 acquire 方法返回 false。
笔者解决缓存击穿的逻辑伪代码如上所示。逻辑比较简单,但其中值得一提的是,在 2.1.1 中,对于已经获取了分布式锁的请求,笔者又重新尝试获取一次缓存。这是因为 Memcache 缓存的存入与读取可能会不同步的情况。假想一种情况:对于尝试获取分布式锁的请求 req1, req2,如果 req1 首先获取到了锁,且将计算的结果存入了 Memcache,然后 req2 在等待时间内又重新获取到了该锁,如果直接继续执行,也就会重新从数据库中获取一次 req1 已经获取且存入缓存的数据,这样就造成了重复数据的读取。所以需要在获取了分布式锁之后重新再获取一次缓存,判断在争抢分布式锁的过程中,缓存是否已经处理完毕。
(3) 缓存穿透
缓存穿透,指的是当数据库与缓存中都没有某数据时,该条数据就会成为漏洞,如果有人蓄意短时间内大量查询这条数据,大量连接就很容易穿透缓存涌向数据库,会造成数据库的宕机。针对这种情况,比较普遍的应对方法是使用**布隆过滤器 (Bloom Filter)**进行防护。
布隆过滤器和弗雷尔卓德之心有一些相似的地方,它的防御不是完全抵挡的,是不准确的。换句话说,针对某条数据,布隆过滤器只保证在数据库中一定没有该数据,不能保证一定有这条数据。
布隆过滤器的最大的好处是,判断简单,消耗空间少。通常如果直接使用 Map 访问结果来判断是否存在数据是否存在,虽然可以实现,但 Map 通常的内存利用率不会太高,对于几百万甚至几亿的大数据集,太浪费空间。而布隆过滤器本身是一个 bitmap 的结构(笔者个人理解基本是一个很大很大的 0-1 数组),初始状态下全部为 0。当有值存入缓存时,使用多个 Hash 函数分别计算对应 Key 值的结果,结果转换为 bitmap 指定的位数,对应位上置 1。这样,越来越多的值存入,bitmap 上也填充了越来越多的 1。
这样如果有请求查询某个数据是否存在,则依旧利用相同的 Hash 函数计算结果,并在 bitmap 上查找计算结果的位置上是否全部为 1。只要有一个位置不为 1,缓存中就必然没有该数据。但是如果所有位置都为 1,那么也不能说明缓存中一定有这条数据。因为随着越来越多的数据存入缓存,布隆过滤器 bitmap 中的 1 值也越来越多,所以即使计算结果中所有位数的值都为 1,也有可能是其他若干计算结果将这些位置上的 1 给占据了。布隆过滤器虽然有误判率,但是有文章指出布隆过滤器的误判率在合适的参数设置之下会变得很低。具体可以见文章《使用BloomFilter布隆过滤器解决缓存击穿、垃圾邮件识别、集合判重》。
除了不能判断数据库中一定存在某条数据之外,布隆过滤器还有一个问题,在于它不能删除某个值填充在 bitmap 中的结果。
笔者本来想用 guava 包中自带的 BloomFilter 来实现 Memcache 的缓存穿透防护,本来都已经研究好该怎么加入布隆的大盾牌了,但是后来一想,布隆过滤器应该是在 Memcache 端做的事情,而不是在我集群服务这里该做的。如果每个服务都建一个 BloomFilter,这几个过滤器的值肯定是不同步的,而且会造成大量的空间浪费,所以最后并没有付诸实践。
转发
《服务假死问题解决过程实记(一)——问题发现篇》
《服务假死问题解决过程实记(二)——C3P0 数据库连接池配置引发的血案》
《服务假死问题解决过程实记(三)——缓存问题优化》