(转载)
一:前言
设计一个缓存系统,不得不要考虑的问题就是:缓存穿透、缓存击穿与失效时的雪崩效应。

二:缓存穿透
缓存穿透是指查询一个一定不存在的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。请求的数据大量的没有获取到缓存,导致走数据库,有可能搞垮数据库,使整个服务瘫痪。在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。
比如文章表,一般我们的主键ID都是无符号的自增类型,有些人想要搞垮你的数据库,每次请求都用负数ID,而ID为负数的记录在数据库根本就没有。

解决方案
有很多种方法可以有效地解决缓存穿透问题,最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被 这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。另外也有一个更为简单粗暴的方法(我们采用的就是这种),如果一个查询返回的数据为空(不管是数 据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。
1、对于像ID为负数的非法请求直接过滤掉,采用布隆过滤器(Bloom Filter)。
布隆过滤器:

<?php
class Bloom {
 
    // 哈希函数的数量
    protected $hashNum = 3;
 
    // 位数组的大小
    protected $bitArrayCount = 1024*10;
 
    // 位数组
    protected $bitArray = [];
 
    public function __construct()
    {
        // 构建默认的位数组,全部置为 false
        $this->bitArray = array_pad([], $this->bitArrayCount, false);
    }
 
    /**
     * 获取 hash 函数;也就是在位数组中,需要改为 true 的索引
     * @param string $key 元素
     * @return array
     */
    protected function getIndexes($key)
    {
        $indexes = [];
        for ($i = 0; $i < $this->hashNum; $i ++) {
            $index = sprintf('%u', crc32($key . $i));  // 使用 crc32 散列
            $index = $index % $this->bitArrayCount;       // 获取 在位数组中的 位置
            $indexes[] = $index;
        }
 
        return $indexes;
    }
 
    /**
     * 向过滤器中添加元素
     * @param string $key 要添加的元素
     */
    public function addItem($key)
    {
        $indexes = $this->getIndexes($key);
 
        // 将 hash 结果对应的位修改为 true
        foreach ($indexes as $index) {
            $this->bitArray[$index] = true;
        }
    }
 
    /**
     * 过滤器中是否存在这个元素; true 表示很可能存在,false 表示一定不存在
     * @param string $key 元素
     * @return array
     */
    public function mightExist($key)
    {
        $indexes = $this->getIndexes($key);
 
        foreach ($indexes as $index) {
            if (! $this->bitArray[$index]) {
                return false;
            }
        }
        return true;
    }
}
 
class Test
{
    public function run()
    {
        $bloom = new Bloom();
 
        // 向过滤器中添加 1000 个元素
        for ($i = 0; $i < 100; $i ++) {
            $bloom->addItem($i);
        }
 
        // 测试 过滤器判断结果
        for ($i = 90; $i < 110; $i ++) {
            
            $mightExist = $bloom->mightExist($i);
 
            if ($mightExist) {
                echo "might exist ", $i, PHP_EOL;
            } else {
                echo "not exist ", $i, PHP_EOL;
            }
        }
    }
}
 
(new Test())->run();

2、针对在数据库中找不到记录的,我们仍然将该空数据存入缓存中,当然一般会设置一个较短的过期时间。

//设置文章ID为-10000的缓存为空
$id = -10000;
$redis->set('article_content_' . $id, '', 60);
 
var_dump($redis->get('article_content_' . $id));

三:缓存雪崩
缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。
使缓存集中失效的原因:

1.redis服务器挂掉了。
2.对缓存数据设置了相同的过期时间,导致某时间段内缓存集中失效。
解决方案
缓存失效时的雪崩效应对底层系统的冲击非常可怕。大多数系统设计者考虑用加锁或者队列的方式保证缓存的单线 程(进程)写,从而避免失效时大量的并发请求落到底层存储系统上。这里分享一个简单方案就时讲缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
如何解决缓存集中失效:

  1. 针对原因1,可以实现redis的高可用,Redis Cluster 或者 Redis Sentinel(哨兵) 等方案。
  2. 针对原因2,设置缓存过期时间时加上一个随机值,避免缓存在同一时间过期。
<?php
 
$redis = new Redis();
$redis->connect('127.0.0.1', 6379, 60);
$redis->auth('');
 
//设置过期时间加上一个随机值
$redis->set('article_content_1', '文章内容', 60 + mt_rand(1, 60));
$redis->set('article_content_2', '文章内容', 60 + mt_rand(1, 60));
  1. 使用双缓存策略,设置两个缓存,原始缓存和备用缓存,原始缓存失效时,访问备用缓存,备用缓存失效时间设置长点。
//原始缓存
$redis->set('article_content_2', '文章内容', 60);
//设置备用缓存,失效时间设置长点
$redis->set('article_content_backup_2', '文章内容', 1800);

四:缓存击穿
对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是很多key。
缓存击穿与缓存雪崩的区别是这里针对的是某一热门key缓存,而雪崩针对的是大量缓存的集中失效。

缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。

解决方案
1、让该热门key的缓存永不过期。
这里的“永远不过期”包含两层意思:

(1) 从redis上看,确实没有设置过期时间,这就保证了,不会出现热点key过期问题,也就是“物理”不过期。
(2) 从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期

从实战看,这种方法对于性能非常友好,唯一不足的就是构建缓存时候,其余线程(非构建缓存的线程)可能访问的是老数据,但是对于一般的互联网功能来说这个还是可以忍受。
2、使用互斥锁,通过redis的setnx实现互斥锁。
业界比较常用的做法,是使用mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。

SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果。

<?php
 
function getRedis()
{
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379, 60);
    return $redis;
}
 
//加锁
function lock($key, $random)
{
    $redis = getRedis();
    //设置锁的超时时间,避免释放锁失败,del()操作失败,产生死锁。
    $ret = $redis->set($key, $random, ['nx', 'ex' => 3 * 60]);
    return $ret;
}
 
//解锁
function unLock($key, $random)
{
    $redis = getRedis();
    //这里的随机数作用是,防止更新缓存操作时间过长,超过了锁的有效时间,导致其他请求拿到了锁。
    //但上一个请求更新缓存完毕后,如果不加判断直接删除锁,就会误删其他请求创建的锁。
    if ($redis->get($key) == $random) {
        $redis->del($key);
    }
}
 
//从缓存中获取文章数据
function getArticleInCache($id)
{
    $redis = getRedis();
    $key = 'article_content_' . $id;
    $ret = $redis->get($key);
    if ($ret === false) {
        //生成锁的key
        $lockKey = $key . '_lock';
        //生成随机数,用于设置锁的值,后面释放锁时会用到
        $random = mt_rand();
        //拿到互斥锁
        if (lock($lockKey, $random)) {
            //这里是伪代码,表示从数据库中获取文章数据
            $value = $db->getArticle($id);
            //更新缓存,过期时间可以根据情况自已调整
            $redis->set($key, $value, 2 * 60);
            //释放锁
            unLock($lockKey, $random);
        } else {
            //等待200毫秒,然后重新获取缓存值,让其他获取到锁的进程取得数据并设置缓存
            usleep(200);
            getArticleInCache($id);
        }
    } else {
        return $ret;
    }
}

3、"提前"使用互斥锁(mutex key):
在value内部设置1个超时值(timeout1), timeout1比实际的memcache timeout(timeout2)小。当从cache读取到timeout1发现它已经过期时候,马上延长timeout1并重新设置到cache。然后再从数据库加载数据并设置到cache中。

4、资源保护:
采用netflix的hystrix,可以做资源的隔离保护主线程池,如果把这个应用到缓存的构建也未尝不可。

四种解决方案:没有最佳只有最合适

八:总结

针对业务系统,永远都是具体情况具体分析,没有最好,只有最合适。最后,对于缓存系统常见的缓存满了和数据丢失问题,需要根据具体业务分析,通常我们采用LRU策略处理溢出,Redis的RDB和AOF持久化策略来保证一定情况下的数据安全。

最后修改:2021 年 12 月 04 日 11 : 35 AM
如果觉得我的文章对你有用,请随意赞赏