高并发场景下使用Redis,常常遇到的缓存问题

高并发场景下使用Redis,常常遇到的问题

最近重新系统的学习一下redis,看了尚硅谷雷丰阳老师讲解的视频,在高并发场景下使用Redis,常常遇到会遇到一些问题,而且面试也经常会问到,好记性不如烂笔头,遂记录如下:

1)、缓存穿透

缓存穿透

2)、缓存雪崩

缓存雪崩

3)、缓存击穿

缓存击穿

代码演示

初始代码:

只是最基础的,查缓存,有就存,没有就查db。复现“缓存穿透、缓存雪崩、缓存击穿”场景代码:

@Slf4j
@Service("categoryService")
public class CategoryServiceImpl extends ServiceImpl<CategoryDao, CategoryEntity> implements CategoryService {
    @Autowired
    StringRedisTemplate stringRedisTemplate;

    @Override
    public Map<String, List<Catelog2Vo>> getCatelogJson() {
        String catelogkey = "CatelogJson";
        //拿缓存
        ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
        String reidsJson = ops.get(catelogkey);

        //缓存中没值
        if (StringUtils.isBlank(reidsJson)) {
            //从db获取 ,方法略
            Map<String, List<Catelog2Vo>> catelogJsonFromDb = this.getCatelogJsonFromDb();
            //返回db中查询到的数据
            ops.set(catelogkey, JSON.toJSONString(catelogJsonFromDb));
            return catelogJsonFromDb;
        } else {
            //返回缓存获取到的数据
            return JSON.parseObject(reidsJson, new TypeReference<Map<String, List<Catelog2Vo>>>() {
            });
        }
    }
    
}

优化1代码:

为了解决上述常见的“3种缓存问题”,优化如下:

  1. 解决【缓存穿透】:null 结果缓存,并加入短暂的过期时间
  2. 解决【缓存雪崩】:原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
  3. 解决【缓存击穿】:
    加“本地进程锁” (synchronized关键字、JUC包下的各种Lock锁类),大量并发时只让一个线程去查,其他线程等待,查到以后释放锁,其他线程获取到锁,先查缓存,就会有数据,不用去db查。
@Slf4j
@Service("categoryService")
public class CategoryServiceImpl extends ServiceImpl<CategoryDao, CategoryEntity> implements CategoryService {
    @Autowired
    StringRedisTemplate stringRedisTemplate;

    @Override
    public Map<String, List<Catelog2Vo>> getCatelogJson() {
        //拿缓存
        ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
        String reidsJson = ops.get(CATELOG_KEY);

        //缓存中没值
        if (StringUtils.isBlank(reidsJson)) {
            log.info("缓存不命中....查询数据库");
            //从 本地获取
            Map<String, List<Catelog2Vo>> catelogJsonFromDb = this.getCatelogJsonByLock();
            String s = JSON.toJSONString(catelogJsonFromDb);
           //等概率 生成 [1-1024]的随机数 ,方法略
            int i = RandomUtils.sumBinary(RandomUtils::getZeroOrOne, 9) + 1;
            //db数据放入缓存中 ,设置过期时间: 3600秒 + [1-1024]的随机数 
            ops.set(CATELOG_KEY, s, 30 * 60 + i, TimeUnit.SECONDS);
            //返回db中查询到的数据
            return catelogJsonFromDb;
        } else {
            log.info("缓存命中....直接返回...");
            //返回缓存获取到的数据
            return JSON.parseObject(reidsJson, new TypeReference<Map<String, List<Catelog2Vo>>>() {
            });
        }
    }
    
    /**
     * 从本地进程锁中获取 数据
     *
     * @return 菜单分类数据
     */
    private synchronized Map<String, List<Catelog2Vo>> getCatelogJsonByLock() {
        /*
        只要是同一把锁,就能锁住需要这个锁的所有线程
        1、使用 synchronized 方法 、synchronized(this)、JUC(Lock),SpirngBoot所有的组件在容器中都是单例的,所以单服务的情况下可以锁住。
        2、synchronized、JUC(Lock),都是本地锁,只能锁住本地线程。但是在分布式场景下,想要锁住所有,则必须使用分布式锁。TODO
         */
        ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
        String reidsJson = ops.get(CATELOG_KEY);

        //缓存中没值
        if (StringUtils.isBlank(reidsJson)) {
            return this.getCatelogJsonFromDb();
        } else {
            //返回缓存获取到的数据
            return JSON.parseObject(reidsJson, new TypeReference<Map<String, List<Catelog2Vo>>>() {
            });
        }
    }
    
    /**
     * 从数据库获取 菜单json数据
     *
     * @return 菜单分类数据
     */
    private Map<String, List<Catelog2Vo>> getCatelogJsonFromDb() {
        log.info("查询数据库...");
      //db 查询略。   
    }
    
}

优化1代码的问题:

并发50 ,压测一段时间,结果看控制台打印如下图:

​ “查询数据库”的操作,执行了2次。没有锁住,为什么?

查询数据库执行了2次

原因: 锁的时序问题

  • 缓存的保存时,需要建立连接,中间花费的时间虽然很短暂,但毕竟需要时间。

  • 在“db查到数据”,还没来得及将结果存入缓存中,就释放掉了锁;

  • 在高并发时,下一个线程拿到锁就会进来重新判断一遍缓存是否有数据,没有,然后又进来“db查数据”,因此”打印了2次db查询数据库“。

锁-时序问题

解决办法:

“确认缓存有没有”、“db操作” 与 “存redis”的操作 放在一把锁内,保证 原子性。

锁-时序问题-解决

优化2代码:

“确认缓存有没有”、“db操作” 与 “存redis”的操作 放在一把锁内,保证 原子性。

@Slf4j
@Service("categoryService")
public class CategoryServiceImpl extends ServiceImpl<CategoryDao, CategoryEntity> implements CategoryService {
    @Autowired
    StringRedisTemplate stringRedisTemplate;

    @Override
    public Map<String, List<Catelog2Vo>> getCatelogJson() {
        //拿缓存
        ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
        String reidsJson = ops.get(CATELOG_KEY);

        //缓存中没值
        if (StringUtils.isBlank(reidsJson)) {
            log.info("缓存不命中....查询数据库");
            //返回db中查询到的数据,并保存到缓存
            return this.getCatelogJsonByLock();
        } else {
            log.info("缓存命中....直接返回...");
            //返回缓存获取到的数据
            return JSON.parseObject(reidsJson, new TypeReference<Map<String, List<Catelog2Vo>>>() {
            });
        }
    }
    
    /**
     * 从本地进程锁中获取 数据
     *
     * @return 菜单分类数据
     */
    private synchronized Map<String, List<Catelog2Vo>> getCatelogJsonByLock() {
        /*
        只要是同一把锁,就能锁住需要这个锁的所有线程
        1、使用 synchronized 方法 、synchronized(this)、JUC(Lock),SpirngBoot所有的组件在容器中都是单例的,所以单服务的情况下可以锁住。
        2、synchronized、JUC(Lock),都是本地锁,只能锁住本地线程。但是在分布式场景下,想要锁住所有,则必须使用分布式锁。TODO
         */
        ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
        String reidsJson = ops.get(CATELOG_KEY);

        //缓存中没值
        if (StringUtils.isBlank(reidsJson)) {
            Map<String, List<Catelog2Vo>> catelogJsonFromDb = this.getCatelogJsonFromDb();
            String s = JSON.toJSONString(catelogJsonFromDb);
            //等概率 生成 [1-1024]的随机数
            int i = RandomUtils.sumBinary(RandomUtils::getZeroOrOne, 9) + 1;

            //db数据放入缓存中 ,设置过期时间: 3600秒 + [1-1024]的随机数
            ops.set(CATELOG_KEY, s, 30 * 60 + i, TimeUnit.SECONDS);
            return catelogJsonFromDb;
        } else {
            //返回缓存获取到的数据
            return JSON.parseObject(reidsJson, new TypeReference<Map<String, List<Catelog2Vo>>>() {
            });
        }
    }
    
    /**
     * 从数据库获取 菜单json数据
     *
     * @return 菜单分类数据
     */
    private Map<String, List<Catelog2Vo>> getCatelogJsonFromDb() {
        log.info("查询数据库...");
      //db 查询略。   
    }
    
}

单实例压测

​ 压测(并发50 ),控制台部分截图如下: “查询数据库”的操作只执行了一次。锁住了。

压测-正常

多实例压测

  1. 启动3个一样的服务,利用--Server.port=xxx,分别设置3个不同的端口号
  2. 3个服务,均设置 VM 参数 -Xmx100m,避免占用本地过多空间。
  3. 启动网关服务,端口为:88
idea 本地多实例启动

idea 多实例启动服务

压测
  • 压测某个业务接口,走网关,通过网关分发到集群(3个实例)

  • redis缓存数据清空,确认还没存入缓存

  • 设置,并发100,持续10次

多实例压测参数

多实例压测结果示意图

结果:3个实例,分别命中一次“查询数据库”

总结:

只要是同一把锁,就能锁住需要这个锁的所有线程:

  • ​ 1、使用“ synchronized 标注方法 、synchronized(this){}代码块、JUC(Lock)” 这些锁 ,因为 SpirngBoot所有的组件在容器中都是单例的,所以单服务的情况下可以锁住。
  • ​ 2、“ synchronized 关键字、JUC(Lock)” 这些锁 ,都是本地锁,只能锁住本地线程。但是在分布式场景下,想要锁住所有,则必须使用"分布式锁"。

本地锁多实例下运行示意图

  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值