缓存击穿问题

1.说明

高并发情况下的缓存穿透:如果某个数据项不存在于缓存中(例如数据库中没有),但是多个请求几乎同时访问此数据项,数据库的查询压力会瞬间增大,造成性能瓶颈。(比如某文章)

用分布式锁来解决

分布式锁:就是当有一个节点获取到锁后,其他节点是不可以获取锁的

为了解决这个文章的缓存击穿问题,我用的是Redis分布式锁,理由如下Redis分布式锁:它追求的高可用性和分区容错性。Redis在追求高可用性时会在Redis写入主节点数据后,立即返回成功,不关心异步主节点同步从节点数据是否成功。Redis是基于内存的,性能极高,官方给的指标是每秒可达到10W的吞吐量。 Zookeeper分布式锁:它追求的是强一致性和分区容错性。Zookeeper在写入主节点数据后会等到从节点同步完数据成后才会返回成功。为了数据的强一致性牺牲了一部分可用性。

两者综合对比,编程客栈为了追求用户体验度,采用了Redis分布式锁来实现

2.背景

论坛项目使用Redis分布式锁的背景是,用户根据articleId查询文章详情,查询出结果返回。

如果并发量很高,若缓存中没有数据,则大量请求会到达MySQL,就会导致缓存击穿问题,所以要用到Redis分布式锁

3.redis分布式锁的实现方式

3.1,第一种setIfAbsent(key,value,time)
redisTemplate.opsForValue().setIfAbsent(key, value, time, TimeUnit.SECONDS);

对应的Redis命令是

set key value EX time NX

set key value EX time NX

是一个符合操作,setNx + setEx,底层采用的是lua脚本来保证其原子性,要么全部成功,否则加锁失败。

redisTemplate.opsForValue().setIfAbsent(key, value, time, TimeUnit.SECONDS);

含义是:如果key不存在则加锁成功,返回true;负责加锁失败,返回false;

主要逻辑:当缓存中没有数据时候,开始加锁,加锁成功则允许访问数据库,加锁失败则自旋重新访问

/**
 * Redis分布式锁第一种方法
 *
 * @param  articleId
 * @return ArticleDTO
 */
private ArticleDTO checkArticleByDBone(Long articleId) {
    String redisLockKey =
            RedisConstant.REDIS_PAI + RedisConstant.REDIS_PRE_ARTICLE + RedisConstant.REDIS_LOCK + articleId;
    ArticleDTO article = null;
    //加分布式锁:此时value为null,时间为90s(结合自己场景设置合适过期时间,这里随便设置的时间)
    Boolean isLockSuccess = redisUtil.setIfAbsent(redisLockKey, null, 90L);

    if(isLockSuccess) {
        //加锁成功可以访问数据库
        article = articleDao.queryArticleDetail(articleId);
    } else{
        try {
            //短暂睡眠,为了让拿到锁的线程有时间访问数据库拿到数据后set进缓存,
            //这样在自旋时就能从缓存中拿到数据
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //加锁失败,采用自旋方式重新拿取数据
        this.queryDetailArticleInfo(articleId);
    }
    return article;
}

问题:虽然我设置了过期时间,但是会出现一种情况:当业务执行之后,锁还被持有着,这是比较消耗Redis缓存资源,正确的做法应该是当业务执行完之后,立即释放锁

3.2第二种setIfAbsent(key,value,time)
/**
 * Redis分布式锁第二种方法
 * @param articleId
 * @return ArticleDTO
 */
private ArticleDTO checkArticleByDBTwo(Long articleId) {

    String redisLockKey =
            RedisConstant.REDIS_PAI + RedisConstant.REDIS_PRE_ARTICLE + RedisConstant.REDIS_LOCK + articleId;

    ArticleDTO article = null;

    Boolean isLockSuccess = redisUtil.setIfAbsent(redisLockKey, null, 90L);
    try {
        if (isLockSuccess) {
            article = articleDao.queryArticleDetail(articleId);
        } else {
            Thread.sleep(200);
            this.queryDetailArticleInfo(articleId);
        }
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        //和第一种方式相比增加了finally删除key
        RedisClient.del(redisLockKey);
    }
    return article;
}

针对第一种问题,锁不能即使释放,我们将其进行优化为当业务执行完成后立即释放锁,则这种方法在业务执行完毕之后,立即删除了key值。

但是会有一个问题,释放别人的锁

释放别人的锁线程A已经获取到锁,正在执行业务,但是没有执行完成,结果过期时间到了,该锁被释放了;此时线程B可以获取该锁了,且执行业务逻辑,但是此时线程A执行完成需要释放锁,释放的锁是线程B的,也就是释放了别人的锁

3.3,第三种setIfAbsent(key,value,time)

针对第二种加锁方式中存在误释放他,人锁的情况,我们可以采用加锁的时候设置个value值,然后在释放锁前判断给key的value是否和前面设置的value值相等,相等则说明是自己的锁可以删除,否则是别人的锁不能删除。

/**
     * Redis分布式锁第三种方法
     * @param articleId
     * @return ArticleDTO
     */
    private ArticleDTO checkArticleByDBThree(Long articleId) {
        String redisLockKey =
                RedisConstant.REDIS_PAI + RedisConstant.REDIS_PRE_ARTICLE + RedisConstant.REDIS_LOCK + articleId;

        //设置value值,保证不误删除他人锁
        String value = RandomUtil.randomString(6);
        Boolean isLockSuccess = redisUtil.setIfAbsent(redisLockKey, value, 90L);
        ArticleDTO article = null;
        try {
            if(isLockSuccess) {
                article = articleDao.queryArticleDetail(articleId);
            }else{
                Thread.sleep(200);
                this.queryDetailArticleInfo(articleId);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            //这种先get出value,然后再比较删除;这种无法保证原子性,为了保证原子性,采用了lua脚本
           /*
            String redisLockValue = RedisClient.getStr(redisLockKey);
            if(!ObjectUtils.isEmpty(redisLockValue) && StringUtils.equals(value,redisLockValue)) {
                RedisClient.del(redisLockKey);
            }*/
            //采用lua脚本来进行判断,在删除;和上面的这种方式相比保证了原子性
            Long cad =  redisLuaUtil.cad("pai_" + redisLockKey, value);
            log.info("lua 脚本删除结果" + cad);
        }
        return article;
    }

第三种方式解决了误删他人锁的问题,但是还存在一个问题---过期时间的值到底如何设置? 时间设置过短:可能业务还没有执行完毕,过期时间已经到了,锁被释放,其他线程可以拿到锁去访问DB违背了我们的初心。 时间设置过长:过长的话,可能在我们加锁成功后,还没有执行到释放锁,在这一段过程中节点宕机了,那么在锁未过期的这段时间,其他线程是不能获取锁的,这样也不好。 针对这个问题,可以写一个守护线程,然后每隔固定时间去查看业务是否执行完毕,如果没有的话就演唱其过期时间,也就是为锁续期

3.2 Redission实现分布式锁

/**
 * Redis分布式锁第四种方法
 * @param articleId
 * @return ArticleDTO
 */
private ArticleDTO checkArticleByDBFour(Long articleId) {
    ArticleDTO article = null;
    String redisLockKey =
            RedisConstant.REDIS_PAI + RedisConstant.REDIS_PRE_ARTICLE + RedisConstant.REDIS_LOCK + articleId;
    //获取分布式锁
    RLock lock = redissonClient.getLock(redisLockKey);
    try {
        //尝试加锁,最大等待时间3秒,上锁30秒自动解锁
        if (lock.tryLock(3, 30, TimeUnit.SECONDS)) {
            //3秒是最大等待时间:如果锁被其他线程持有,当前线程会等待最多3秒来获取锁
            //30秒是锁持有时间:当前线程获取锁后,锁将在30秒后自动释放,防止死锁
            article = articleDao.queryArticleDetail(articleId);
        } else {
            // 未获得分布式锁线程睡眠一下;然后再去获取数据
            Thread.sleep(200);
            this.queryDetailArticleInfo(articleId);
        }
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        //判断该lock是否已经锁 并且 锁是否是自己的
        if (lock.isLocked() && lock.isHeldByCurrentThread()) {
            lock.unlock();
        }

    }
    return article;

}

redission首先获取锁( get lock()),然后尝试加锁,加锁成功后可以执行下面的业务逻辑,执行完毕之后,会释放该分布式锁。 redission解决了redis实现分布式锁中出现的锁过期问题,还有释放他人锁的问题 另外,它还是可重入锁:内部机制是默认锁过期时间是30s,然后会有一个定时任务在每10s去扫描一下该锁是否被释放,如果没有释放那么就延长至30s,这个机制就是看门狗机制。 如果请求没有获取到锁,那么它将 while循环继续尝试加锁。

你可能感兴趣的:(缓存)