Redis实战二:基于Redis实现缓存(黑马点评)

在做商户缓存时,一个商户实体类可以作为String进行Redis存储,我们只需要做Json到String直接的转化。

@Override
    public Result queryById(Long id) {

        // 1. 从redis中查询
        String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
        if(StrUtil.isNotBlank(shopJson)){
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }

        Shop shop = getById(id);
        if(shop == null){
            return Result.fail("店铺不存在");

        }

        stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop));

        // 2,判断是否存在

        return Result.ok(shop);
    }

当要做商品类型查询时,涉及到多个商品类型,这种可以做List保存,也可以做String进行存储,这里主要演示List,因为List方便随时扩充和删除。

@Override
    public Result queryTypeList() {

        ListOperations<String, String> listOps = stringRedisTemplate.opsForList();

        Long size = listOps.size(RedisConstants.CACHE_SHOP_KEY+"typeList");
        List<ShopType> shopTypes = new ArrayList<>();


        if(size > 0){

            for (long i = 0; i < size; i++) {
                String shopTypeJson = listOps.index(RedisConstants.CACHE_SHOP_KEY+"typeList", i);
                if (shopTypeJson != null && !shopTypeJson.isEmpty()) {
                    try {
                        // 使用 Jackson 将 JSON 字符串转换为 ShopType 对象
                        ShopType shopType = JSONUtil.toBean(shopTypeJson, ShopType.class);
                        shopTypes.add(shopType);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }
            return Result.ok(shopTypes);

        }

        List<ShopType> types = query().orderByAsc("sort").list();
        if(types.isEmpty()){
            return Result.fail("失败查询类型");
        }
        for(ShopType shopType: types){
            stringRedisTemplate.opsForList().rightPush(RedisConstants.CACHE_SHOP_KEY+"typeList",JSONUtil.toJsonStr(shopType) );
        }
        return  Result.ok(types);
    }

缓存更新

image-20240809202509933

对于缓存如何保证内存不被占满?超时剔除

设置超时重传TTL

 stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES );

对于缓存来说 ,如何保证缓存和数据?

在数据更新的时候作为整体事务,先更新数据库,再删除Redis的缓存

 @Override
    @Transactional
    public Result update(Shop shop) {

        Long id  =  shop.getId();
        if(id == null){
            return  Result.fail("店铺ID不能为空");
        }
        updateById(shop);

        stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY+id);
        return Result.ok();
    }

缓存穿透

缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

解决方法

  1. 缓存null对象: 将大量不存在的数据也放入redis中,保证下一次访问的时候一定是redis,而不是直接访问Mysql
  2. 布隆过滤:采用布隆过滤器判断数据是否存在,若存在则放行到Redis中。

缓存null对象,并设置超时剔除

 if(shopJson != null){
            return Result.fail("店铺不存在");
        }
 Shop shop = getById(id);
 if(shop == null){
            stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "",RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES );
            return Result.fail("店铺不存在");
        }

布隆过滤

  • 在应用启动时,将所有可能有效的ID添加到布隆过滤器中
  • 在处理请求时,
    • 当接收到查询请求时,首先检查布隆过滤器。
    • 如果布隆过滤器判断该请求的 ID 存在,继续查询缓存
    • 如果缓存中没有数据,再去查询数据库。
    • 如果布隆过滤器判断该 ID 不存在,直接返回一个空或错误响应,不再查询缓存和数据库。

手动实现布隆过滤器

package com.hmdp.utils;

import org.springframework.data.redis.core.StringRedisTemplate;

import java.nio.charset.StandardCharsets;
import java.util.BitSet;

public class BloomFiler {


        private static final int DEFAULT_SIZE = 1 << 24;
        private static final int[] SEEDS = new int[]{31, 41, 59, 93, 97, 3, 5, 7, 11}; // 哈希函数种子
        private BitSet bitSet;

        private StringRedisTemplate stringRedisTemplate;


        public BloomFiler(StringRedisTemplate stringRedisTemplate){
            this.stringRedisTemplate = stringRedisTemplate;
            this.bitSet = new BitSet(DEFAULT_SIZE);
        }

        public void add(String value) {

            for(int seed : SEEDS){
                int hash  = hash(value, seed);
                bitSet.set(hash);
            }
        }

        public boolean contains(String value) {
            for (int seed : SEEDS) {
                int hash = hash(value, seed);
                if (!bitSet.get(hash)) {
                    return false;
                }
            }
            return true;
        }

        public int hash(String value, int seed) {
            byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
            int result = 0;
            for (byte b : bytes) {
                result = seed * result + b;
            }
            return Math.abs(result) % DEFAULT_SIZE;
        }

}

在ShopServiceImpl中进行初始化,系统第一次加载的时候,将从数据库里面加载所有商品ID

private BloomFiler bloomFilter;

    @PostConstruct
    public void init(){
        this.bloomFilter  = new BloomFiler(stringRedisTemplate);
        List<Shop> list = list();
        for(Shop shop: list){
            String id = String.valueOf(shop.getId());
            bloomFilter.add(id);
        }
    }


在通过ID查询时候首先检测ID是否存在

 @Override
    public Result queryById(Long id) {

        // 为了ID安全,通过设置为String类型,但黑马为数字,这里做一次类型转换
        String value = String.valueOf(id);
        if(!bloomFilter.contains(value)){
            return Result.fail("店铺不存在");
        }

        // 1. 从redis中查询
        String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
        if(StrUtil.isNotBlank(shopJson)){
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }

        if(shopJson != null){
            return Result.fail("店铺不存在");
        }
        Shop shop = getById(id);
        if(shop == null){
            stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "",RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES );
            return Result.fail("店铺不存在");
        }


        stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES );

        // 2,判断是否存在

        return Result.ok(shop);
    }

关于布隆过滤器实现,最好还是采用Redis来存储BitSet信息,这样能保证数据一致性。这里只是单机实现。

直接防止缓存穿透的发生,1. 在API涉及时增加IP的复杂度,并进行数据的基础格式校验 2. 热点参数的限流

后续进行介绍

缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

解决方案:

  • 给不同的Key的TTL添加随机值
  • 利用Redis集群提高服务的可用性
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存

具体解决在SpringCloud微服务里面

缓存击穿

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

互斥锁(牺牲可用性)

逻辑过期(牺牲一致性)

互斥锁实现

Redis实现互斥锁主要是利用了Setnx这个操作,如果数据存在则不能设置,如果数据不存在才能设置。将获取的ID作为Value. 对于通过业务设置同样的变量, 每次处理就进行Setnx, 对变量进行赋值,成功则进行数据库重建等操作,失败则重试。

   //    缓存击穿,互斥锁
    public Shop queryWithMutex(Long id){
        // 为了ID安全,通过设置为String类型,但黑马为数字,这里做一次类型转换
        String value = String.valueOf(id);
        if(!bloomFilter.contains(value)){
            return null;
        }

        String key  = RedisConstants.CACHE_SHOP_KEY + id;
        // 1. 从redis中查询
        String shopJson = stringRedisTemplate.opsForValue().get(key);

        if(StrUtil.isNotBlank(shopJson)){
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return shop;
        }

        if(shopJson != null){
            return null;
        }

        //开始缓存重建
        // 获取互斥锁
        String localKey = RedisConstants.LOCK_SHOP_KEY + id;
        Shop shop = null;
        try {
            boolean isLock = tryLock(localKey);
            // 判断是否获取成功
            if(!isLock){
                // 失败,休眠并重试
                Thread.sleep(50);
                return queryWithMutex(id);
            }
            
            shop = getById(id);
            if(shop == null){
                stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "",RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES );
                return null;
            }
            stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES );
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            unlock(localKey);

        }

        return shop;
    }

逻辑过期实现

原理就是,在数据过期时任然返回过期数据,另开线程来进行缓存重建。引入数据不一致性,解决击穿。需要进行数据预热,不然不会访问到任何数据。具体就是对于热点数据,不使用Redis的过期时间,手动让数据携带时间字段判断过期。

 private static  final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
//缓存击穿 逻辑过期
    public Shop queryWithLogicalExpire(Long id){

        // 为了ID安全,通过设置为String类型,但黑马为数字,这里做一次类型转换
        // 布隆过滤
        String value = String.valueOf(id);
        if(!bloomFilter.contains(value)){
            return null;
        }

        // 1. 从redis中查询店铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
        if(StrUtil.isNotBlank(shopJson)){
            return null;
        }
        // 将店铺信息 反序列化
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        JSONObject data = (JSONObject) redisData.getData();
        Shop shop = JSONUtil.toBean(data, Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
//        判断是否过期
        if(expireTime.isAfter(LocalDateTime.now())){
//           未过期
            return shop;
        }
        // 已经过期进行重建
        String localKey  = RedisConstants.LOCK_SHOP_KEY + id;

        boolean isLock = tryLock(localKey);

        if(isLock){
//            新开线程进行访问
            CACHE_REBUILD_EXECUTOR.submit(()->{
                try {
                    
                    //需要进行二次过期判断, 可能别的线程完成了重建
                    
                    //重建缓存
                    this.savaShop2Redis(id, RedisConstants.CACHE_SHOP_TTL);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }finally {

                    //释放锁
                    unlock(localKey);
                }

            });
        }

//       返回过期的商品信息
        return shop;
    }

封装工具类

主要是泛型修改,将代码变成通用代码。

总结

缓存作为Redis中间件的主要功能,也存在相应问题,没有绝对好的操作,只能在一致性和速度直接权衡选择合适的措施。