springboot项目redis缓存异常实战篇!

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

缓存异常实践案例

redis基本上是高并发场景上会用到的一个高性能的key-value数据库属于nosql类型一般用作于缓存一般是结合数据库一块使用的但是在使用的过程中可能会出现异常的问题就是面试常常唠嗑的缓存异常问题
分别是缓存击穿缓存穿透和雪崩简单解释如下:

缓存穿透:
就是当用户查询数据时缓存和数据库该数据都是不存在的此时如果用户不断的请求就会不断的查询缓存和数据库对数据库造成很大压力

缓存击穿:
当热点key过期或者丢失时大量的请求访问该数据缓存不存在就会将大量的请求直接访问数据库造成数据库有大量连接存在崩溃风险

缓存雪崩:
是指大量请求在缓存中没有查到数据直接访问数据库导致数据库压力增大最终导致数据库崩溃从而波及整个系统不可用好像雪崩一样。

下面就讲讲案例并提供解决方案

常规写法

平常的写法未考虑异常时
现在是有一个查询商户的接口
这个是正常的结合了redis的逻辑
image.png

public TbShopEntity rawQuery(Long id) {
        TbShopEntity shop = null;
        //1.是否命中缓存
        String shopJson = redisClient.get(CACHE_SHOP_KEY + id);
        //1.1 如果命中且数据非空则直接返回
        if (!StrUtil.isEmpty(shopJson)) {
            return JSONObject.parseObject(shopJson, TbShopEntity.class);
        }
        //1.2 查询数据库
        shop = getById(id);
        //1.2.1 如果数据库不存在则直接返回
        if (shop == null) return null;
        //1.2.2 如果存在则设置到redis中
        redisClient.set(CACHE_SHOP_KEY + id, JSON.toJSONString(shop));
        //2.返回数据
        return shop;
    }

现在使用一个不存在的商品id然后使用jmeter进行压测例如使用id=0的商品然后使用200个线程共发起200个请求进行压测
image.png
image.png

结果:
发现有大量的请求直接请求数据库如果时大量的请求有可能会把数据库搞崩,也就是我们的缓存穿透问题
image.png

缓存穿透问题

分析:
如果是这种情况当用户并发测试访问一个不存在的key时会有大量的不存在的key访问数据导致数据库压力剧增也就是缓存穿透问题

改进方式

缓存穿透一般有两种解决方式分别是:

  • 设置带有过期时间的空值
  • 布隆过滤器

设置带有过期时间的空值

逻辑图如下:主要是改进这里
image.png

/**
     * 缓存穿透解决方案, 1、设置一个空值且带有较短的过期时间 2、布隆过滤器
     * <p>
     * 但是目前还是没有解决穿透的问题的因为该key不存在所以会有多个请求去直接请求数据库(并发问题)从而需要进行优化解决缓存穿透问题
     */
    private TbShopEntity queryWithPassThrough(Long id) {
        //1.是否命中缓存
        String shopJson = redisClient.get(CACHE_SHOP_KEY + id);

        //1.1 如果命中缓存则直接返回
        //这里不能使用Strutil.isEmpty去判断因为在下边会使用”“去存空值
        if (shopJson != null) {
            //如果返回的值是""则直接返回null
            if (StrUtil.isEmpty(shopJson)) return null;
            //否则则返回结果
            return JSONObject.parseObject(shopJson, TbShopEntity.class);
        }

        //1.2 如果没有命中,查询数据库
        TbShopEntity shop = getById(id);

        //1.2.1 如果数据库查询数据不存在则直设置值为空以及过期时间,现在是设置为10s如果10s已经过再重新查询
        if (shop == null) {
            redisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, StrUtil.EMPTY, SECKILL_SECONDS, TimeUnit.SECONDS);
            return null;
        }

        //1.2.2 如果数据库存在则设置到redis中
        redisClient.set(CACHE_SHOP_KEY + id, JSON.toJSONString(shop));
        //并转成shop返回

        //2.返回数据
        return JSONObject.parseObject(shopJson, TbShopEntity.class);
    }

测试一:直接调用接口
之后进行测试首先是使用链接直接访问一个不存在的商品
image.png
可以发现在第一次访问的时候会去查找数据库然后在之后的10s内都不会进行数据库的查询了然后当数据过期之后才会进行查询从这个结果来看是没有问题的也就是平常我们可以这样子使用的

测试二:使用并发测试进行
但是如果使用200个并发去测试结果又是如何呢?
image.png
可以发现还是有大部分请求直接去查询数据库
image.png
原因是由于线程的并发问题大部分请求都执行到这一步这个时候redis的数据还没有补充上去所以导致了大量请求还是重新去查数据库其实也类似于缓存穿透问题当某个key失效时大量请求会去查询数据库那么这个问题应该如何解决呢?
image.png

缓存击穿问题其中也解决了穿透问题

当有一个访问量较高的key在失效时会导致大量的请求发向数据库导致数据库崩溃
解决方式主要有:

  • 加互斥锁
  • 逻辑过期

加互斥锁

image.png

public TbShopEntity queryWithMutexLock(Long id) {
        TbShopEntity shop = null;
        //是否命中缓存
        String shopJson = redisClient.get(CACHE_SHOP_KEY + id);

        //如果命中缓存则先判断缓存是否为”“如果是返回null否则返回结果
        if (shopJson != null) {
            //如果缓存为空的话则直接返回结果
            if (StrUtil.isEmpty(shopJson)) return null;
            return JSONObject.parseObject(shopJson, TbShopEntity.class);
        }


        //获取锁锁的粒度需要精确到id不能太大
        RLock lock = redissonClient.getLock(LOCK_SHOP + id);
        try {
            //加锁
            boolean isLock = lock.tryLock(10,TimeUnit.SECONDS);

            //如果没有获取到锁则休眠50ms然后重试
            if (!isLock) {
                Thread.sleep(50);
                queryWithMutexLock(id);
            }

            //这里需要做doubleCheck需要重新查询缓存的数据是否存在否则还会出现重复查询数据库的情况
            shopJson = redisClient.get(CACHE_SHOP_KEY + id);

            if (shopJson != null) {
                if (StrUtil.isEmpty(shopJson)) return null;
                return JSONObject.parseObject(shopJson, TbShopEntity.class);
            }

            //如果缓存不存在则查询数据库
            shop = getById(id);
            //如果数据库查询数据不存在则直设置值为空以及过期时间直接返回null
            if (shop == null) {
                redisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, StrUtil.EMPTY, SECKILL_SECONDS, TimeUnit.SECONDS);
                return null;
            }
            //如果数据库数据存在则设置到redis中
            redisClient.set(CACHE_SHOP_KEY + id, JSON.toJSONString(shop));

        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }

        //2.返回数据
        return shop;
    }

压测:
使用200个线程测试结果是只查询了一次是ok的
image.png

逻辑过期

逻辑过期的原理指的是不使用redis自带的expire进行存储而是在存储的数据中添加一个过期字段然后在获取数据的时候进行该字段的判断如果已经过期了则返回旧的数据启动一个线程去更新新的数据
数据结构如下:
image.png
data用来存储数据expired存储过期时间所以我们只要比较如果expired小于当前时间的话就代表该数据是过期的了

逻辑图如下:
这个逻辑图稍微比较复杂基本将空值和互斥锁都加进去了

image.png
queryWithLogicExpire

 public TbShopEntity queryWithLogicExpire(Long id) {
        TbShopEntity shop = null;

        //查询是否命中缓存
        String shopJson = redisClient.get(CACHE_SHOP_KEY + id);
        //如果命中,则判断结果是空置还是过期的值或者没过期的值
        if (shopJson != null) {
            //这里的代码往下滑查看
             return redisDTO2Entity(id, shopJson);
        }

        //获取锁粒度具体到商户
        RLock lock = redissonClient.getLock("lock:shop:" + id);
        try {
            //加锁,10s过期
            boolean isLock = lock.tryLock(10,TimeUnit.SECONDS);

            // 如果没有获取到锁则休眠50ms然后重试
            if (!isLock) {
                Thread.sleep(50);
                queryWithLogicExpire(id);
            }

            //这里需要做doubleCheck否则还会出现重复查询数据库的情况
            //这里先不做判断是否逻辑过期的逻辑
            shopJson = redisClient.get(CACHE_SHOP_KEY + id);
            if (shopJson != null) {
                redisDTO2Entity(id,shopJson);
            }

            shop = getById(id);
            //1.2 如果数据库查询数据不存在则直设置值为空以及过期时间直接返回null
            if (shop == null) {
                redisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", SECKILL_SECONDS, TimeUnit.MINUTES);
                return null;
            }
            //1.3 如果存在则设置到redis中
            redisClient.set(CACHE_SHOP_KEY + id, JSON.toJSONString(shop));

        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }

        //2.返回数据
        return shop;
    }

redisDTO2Entity

private TbShopEntity redisDTO2Entity(Long id,String shopJson){
        //如果是空值则直接返回null
        if (StrUtil.isEmpty(shopJson)) return null;
        //或者则转换成redis实体类
        RedisDTO redisDTO = JSONObject.parseObject(shopJson, RedisDTO.class);
        //获取逻辑过期时间
        LocalDateTime expired = redisDTO.getExpired();
        // 判断时间是否过期如果过期则启动线程更新数据其他直接返回
        if (expired.isBefore(LocalDateTime.now())) {
            //使用异步线程,更新数据
            saveShop(id);
        }
        return JSONObject.parseObject(JSON.toJSONString(redisDTO.getData()), TbShopEntity.class);
    }

使用异步线程进行数据的更新
saveShop

    @Async
    public void saveShop(Long id){
        TbShopEntity entity = getById(id);
        if(entity==null) return;
        redisClient.setLogicExpired(RedisConstant.CACHE_SHOP_KEY+id,entity,RedisConstant.SECKILL_SECONDS, TimeUnit.SECONDS);
        log.info("线程{},更新商户信息",Thread.currentThread().getName());
    }

测试

  • 需要先添加一条数据

执行下面的test方法进行添加数据添加成功后
数据如下:
image.png

package com.walker.dianping;

import com.walker.dianping.common.constants.RedisConstant;
import com.walker.dianping.common.utils.RedisClient;
import com.walker.dianping.model.TbShopEntity;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.concurrent.TimeUnit;

@SpringBootTest
public class RedisTest {

    @Autowired
    private RedisClient redisClient;

    @Test
    void addShop() {
        TbShopEntity tbShopEntity = new TbShopEntity();
        tbShopEntity.setId(1L);
        tbShopEntity.setName("逻辑过期测试商户");
        //该方法可以查看大纲放在了完整代码中
        redisClient.setLogicExpired(RedisConstant.CACHE_SHOP_KEY + 1L, tbShopEntity, 20, TimeUnit.MINUTES);
    }
}

  • 调用接口发起测试

image.png
因为一开始是有的所以可以直接拿到数据

  • 当过期时间expired小于当前时间时这个时候重新去调用接口可以直接更改redis的数据

这个时候就会去更新商户的数据了这个时候就能拿到新的数据了
image.png
image.png

完整代码

controller

package com.walker.dianping.controller;


import com.walker.dianping.model.R;
import com.walker.dianping.service.TbShopService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;

import org.springframework.web.bind.annotation.RestController;

/**
 * <p>
 *  前端控制器
 * </p>
 *
 * @author walker
 * @since 2023-01-18
 */
@RestController
@RequestMapping("/tb-shop-entity")
public class TbShopController {

    @Autowired
    private TbShopService tbShopService;

    /**
    * 获取商铺
    */
    @GetMapping("/shop/{id}")
    public R getShop(@PathVariable(value = "id") Long id){
        return tbShopService.getShop(id);
    }
}

service

package com.walker.dianping.service;


import com.baomidou.mybatisplus.extension.service.IService;
import com.walker.dianping.model.R;
import com.walker.dianping.model.TbShopEntity;

/**
 * <p>
 *  服务类
 * </p>
 *
 * @author walker
 * @since 2023-01-18
 */
public interface TbShopService extends IService<TbShopEntity> {

    R getShop(Long id);
}

TbShopServiceImpl

package com.walker.dianping.service.impl;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.walker.dianping.common.constants.RedisConstant;
import com.walker.dianping.common.utils.RedisClient;
import com.walker.dianping.mapper.TbShopMapper;
import com.walker.dianping.model.R;
import com.walker.dianping.model.TbShopEntity;
import com.walker.dianping.model.dto.RedisDTO;
import com.walker.dianping.service.TbShopService;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;

import static com.walker.dianping.common.constants.RedisConstant.*;

@Slf4j
@Service
public class TbShopServiceImpl extends ServiceImpl<TbShopMapper, TbShopEntity> implements TbShopService {


    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private RedisClient redisClient;

    @Autowired
    private RedissonClient redissonClient;


    @Override
    public R getShop(Long id) {
        //初始查询
//        TbShopEntity shop = rawQuery(id);
        //缓存穿透
//        TbShopEntity shop = queryWithPassThrough(id);

        //解决缓存击穿问题
//        TbShopEntity shop = queryWithMutexLock(id);

        //解决缓存击穿+穿透问题使用逻辑过期
        TbShopEntity shop = queryWithLogicExpire(id);

        if (shop == null) {
            return R.fail("店铺不存在");
        }
        return R.ok(shop);
    }


    /**
     * 最初始的版本
     */
    public TbShopEntity rawQuery(Long id) {
        TbShopEntity shop = null;
        //1.是否命中缓存
        String shopJson = redisClient.get(CACHE_SHOP_KEY + id);
        //1.1 如果命中且数据非空则直接返回
        if (!StrUtil.isEmpty(shopJson)) {
            return JSONObject.parseObject(shopJson, TbShopEntity.class);
        }

        //1.2 查询数据库
        shop = getById(id);
        //1.2.1 如果数据库不存在则直接返回
        if (shop == null) return null;
        //1.2.2 如果存在则设置到redis中
        redisClient.set(CACHE_SHOP_KEY + id, JSON.toJSONString(shop));
        //2.返回数据
        return shop;
    }


    /**
     * 缓存穿透解决方案, 1、设置一个空值且带有较短的过期时间 2、布隆过滤器
     * <p>
     * 但是目前还是没有解决穿透的问题的因为该key不存在所以会有多个请求去直接请求数据库(并发问题)从而需要进行优化解决缓存穿透问题
     */
    private TbShopEntity queryWithPassThrough(Long id) {
        //1.是否命中缓存
        String shopJson = redisClient.get(CACHE_SHOP_KEY + id);

        //1.1 如果命中缓存则直接返回
        //这里不能使用Strutil.isEmpty去判断因为在下边会使用”“去存空值
        if (shopJson != null) {
            //如果返回的值是""则直接返回null
            if (StrUtil.isEmpty(shopJson)) return null;
            //否则则返回结果
            return JSONObject.parseObject(shopJson, TbShopEntity.class);
        }

        //1.2 如果没有命中,查询数据库
        TbShopEntity shop = getById(id);

        //1.2.1 如果数据库查询数据不存在则直设置值为空以及过期时间,现在是设置为10s如果10s已经过再重新查询
        if (shop == null) {
            redisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, StrUtil.EMPTY, SECKILL_SECONDS, TimeUnit.SECONDS);
            return null;
        }

        //1.2.2 如果数据库存在则设置到redis中
        redisClient.set(CACHE_SHOP_KEY + id, JSON.toJSONString(shop));
        //并转成shop返回

        //2.返回数据
        return JSONObject.parseObject(shopJson, TbShopEntity.class);
    }


    /**
     * 缓存击穿key失效导致高并发的请求
     * 加锁:可以使用redisson
     */
    public TbShopEntity queryWithMutexLock(Long id) {
        TbShopEntity shop = null;
        //是否命中缓存
        String shopJson = redisClient.get(CACHE_SHOP_KEY + id);

        //如果命中缓存则先判断缓存是否为”“如果是返回null否则返回结果
        if (shopJson != null) {
            //如果缓存为空的话则直接返回结果
            if (StrUtil.isEmpty(shopJson)) return null;
            return JSONObject.parseObject(shopJson, TbShopEntity.class);
        }


        //获取锁锁的粒度需要精确到id不能太大
        RLock lock = redissonClient.getLock(LOCK_SHOP + id);
        try {
            //加锁
            boolean isLock = lock.tryLock(10,TimeUnit.SECONDS);

            //如果没有获取到锁则休眠50ms然后重试
            if (!isLock) {
                Thread.sleep(50);
                queryWithMutexLock(id);
            }

            //这里需要做doubleCheck需要重新查询缓存的数据是否存在否则还会出现重复查询数据库的情况
            shopJson = redisClient.get(CACHE_SHOP_KEY + id);

            if (shopJson != null) {
                if (StrUtil.isEmpty(shopJson)) return null;
                return JSONObject.parseObject(shopJson, TbShopEntity.class);
            }

            //如果缓存不存在则查询数据库
            shop = getById(id);
            //如果数据库查询数据不存在则直设置值为空以及过期时间直接返回null
            if (shop == null) {
                redisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, StrUtil.EMPTY, SECKILL_SECONDS, TimeUnit.SECONDS);
                return null;
            }
            //如果数据库数据存在则设置到redis中
            redisClient.set(CACHE_SHOP_KEY + id, JSON.toJSONString(shop));

        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }

        //2.返回数据
        return shop;
    }


    private TbShopEntity redisDTO2Entity(Long id,String shopJson){
        //如果是空值则直接返回null
        if (StrUtil.isEmpty(shopJson)) return null;
        //或者则转换成redis实体类
        RedisDTO redisDTO = JSONObject.parseObject(shopJson, RedisDTO.class);
        //获取逻辑过期时间
        LocalDateTime expired = redisDTO.getExpired();
        // 判断时间是否过期如果过期则启动线程更新数据其他直接返回
        if (expired.isBefore(LocalDateTime.now())) {
            //使用异步线程,更新数据
            saveShop(id);
        }
        return JSONObject.parseObject(JSON.toJSONString(redisDTO.getData()), TbShopEntity.class);
    }

    /**
     * 缓存击穿解决方式二:逻辑过期
     */
    public TbShopEntity queryWithLogicExpire(Long id) {
        TbShopEntity shop = null;

        //查询是否命中缓存
        String shopJson = redisClient.get(CACHE_SHOP_KEY + id);
        //如果命中,则判断结果是空置还是过期的值或者没过期的值
        if (shopJson != null) {
             return redisDTO2Entity(id, shopJson);
        }

        //获取锁粒度具体到商户
        RLock lock = redissonClient.getLock("lock:shop:" + id);
        try {
            //加锁,10s过期
            boolean isLock = lock.tryLock(10,TimeUnit.SECONDS);

            // 如果没有获取到锁则休眠50ms然后重试
            if (!isLock) {
                Thread.sleep(50);
                queryWithLogicExpire(id);
            }

            //这里需要做doubleCheck否则还会出现重复查询数据库的情况
            //这里先不做判断是否逻辑过期的逻辑
            shopJson = redisClient.get(CACHE_SHOP_KEY + id);
            if (shopJson != null) {
                redisDTO2Entity(id,shopJson);
            }

            shop = getById(id);
            //1.2 如果数据库查询数据不存在则直设置值为空以及过期时间直接返回null
            if (shop == null) {
                redisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", SECKILL_SECONDS, TimeUnit.MINUTES);
                return null;
            }
            //1.3 如果存在则设置到redis中
            redisClient.set(CACHE_SHOP_KEY + id, JSON.toJSONString(shop));

        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }

        //2.返回数据
        return shop;
    }

    @Async
    public void saveShop(Long id) {
        //查询数据
        TbShopEntity entity = getById(id);
        if (entity == null) return;
        redisClient.setLogicExpired(RedisConstant.CACHE_SHOP_KEY + id, entity, RedisConstant.SECKILL_SECONDS, TimeUnit.SECONDS);
        log.info("线程{},更新商户信息", Thread.currentThread().getName());
    }
}

RedisClient代码

package com.walker.dianping.common.utils;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSON;
import com.walker.dianping.model.dto.RedisDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.convert.RedisData;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;

@Slf4j
@Component
public class RedisClient {

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
    * 定义set方法
    */
    public void set(String key,Object value){
        redisTemplate.opsForValue().set(key, JSON.toJSONString(value));
    }

    /**
     * 定义set方法
     */
    public void setLogicExpired(String key,Object value,long timeout, TimeUnit unit){
        RedisDTO dto = new RedisDTO();
        dto.setData(value);
        dto.setExpired(LocalDateTime.now().plusSeconds(unit.toSeconds(timeout)));
        redisTemplate.opsForValue().set(key, JSON.toJSONString(dto));
    }



    /**
    * get方法
    */
    public String get(String key){
        String s = redisTemplate.opsForValue().get(key);
        return s;
    }

}

RedisConstant

package com.walker.dianping.common.constants;

public interface RedisConstant {

    String SECKILL_LUA_SCRIPT="seckill.lua";
    String CACHE_SHOP_KEY= "cache:shop:";
    Integer SECKILL_SECONDS=10;
    String LOCK_SHOP="lock:shop:";
}

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: Springredis