• Redis(六) - Redis企业实战之商户查询缓存


    一、什么是缓存

    • 缓存就是数据交换的缓冲区(称作Cache [ kæʃ ] ),是存储数据的临时地方,一般读写性能较高。
      在这里插入图片描述

    1. 缓存的作用

    • 降低后端负载
    • 提高读写效率,降低响应时间

    2. 缓存的成本

    • 数据一致性成本
    • 代码维护成本
    • 运维成本

    二、添加redis缓存

    • 可以将更新不频繁的数据存入redis缓存中,这样每次从redis缓存中获取数据,提高了查询效率
      在这里插入图片描述
    • ShopController
    @RestController
    @RequestMapping("/shop")
    public class ShopController {
    
        @Resource
        public IShopService shopService;
    
        /**
         * 根据id查询商铺信息
         * @param id 商铺id
         * @return 商铺详情数据
         */
        @GetMapping("/{id}")
        public Result queryShopById(@PathVariable("id") Long id) {
            return shopService.queryById(id);
        }
    }    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • ShopServiceImpl
    @Service
    public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    
        @Resource
        private StringRedisTemplate stringRedisTemplate;
    
    
        @Override
        public Result queryById(Long id) {
         
            // 1.从redis查询商铺缓存
            String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
    
            // 2.判断是否存在
            if (StrUtil.isNotBlank(shopJson)) {
                // 3.存在,则返回
                Shop shop = JSONUtil.toBean(shopJson, Shop.class);
                return Result.ok(shop);
            }
    
            // 4.不存在,则根据id查数据库
            Shop shop = getById(id);
            if (shop == null) {
                // 5.商户不存在,返回错误
                return Result.fail("店铺不存在!");
            }
    
            // 6.商户存在,写入redis
            stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop));
    
            //7.返回shop
            return Result.ok(shop);
        }
    }    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    三、缓存更新策略

    • 缓存更新策略是为了解决缓存和数据库数据不一致的问题
      在这里插入图片描述

    1. 主动更新策略的三种模式

    (1)Cache Aside Pattern(旁路缓存模式)

    • 由缓存的调用者,在更新数据库的同时更新缓存
    • 适用于读请求多的场景

    (2)Read/Write Through Pattern(读写穿透模式)

    • 缓存和数据库整合为一个服务,由服务来维护一致性
    • 调用者调用该服务,无需关心缓存一致性问题
    • 维护成本高,很少使用

    (3)Write Behind Caching Pattern(异步写回缓存模式)

    • 调用者只操作缓存,由其他线程异步的将缓存数据持久化到数据库,保证最终一致
    • 适用于数据经常变化又对数据一致性要求不高的场景,比如浏览量、点赞量

    2. 选择Cache Aside Pattern(旁路缓存模式)

    • 本章节选用的是Cache Aside Pattern(旁路缓存模式),接下来分析该模式需要考虑的问题
      在这里插入图片描述

    (1)分析先删除缓存,再操作数据库

    • 由于更新操作会花费一定时间,所以在并发场景下会出现缓存与数据库不一致的情况
      在这里插入图片描述

    (2)分析先操作数据库,再删除缓存

    • 使用这种方式,出现缓存与数据库不一致的情况需要满足多个条件:
      • 并发场景下,线程1查缓存时,恰好缓存失效
      • 线程1还没写入缓存时,线程2就执行完更新数据库、删除缓存这两个操作
    • 以上条件出现的概率很低,所以先操作数据库,再删除缓存的方式相对好一些
      在这里插入图片描述

    3. 给查询商铺的缓存添加超时剔除和主动更新的策略

    修改ShopController、ShopServiceImpl中的业务逻辑,满足下面的需求:

    • 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
    • 根据id修改店铺时,先修改数据库,再删除缓存
    @RestController
    @RequestMapping("/shop")
    public class ShopController {
    
        @Resource
        public IShopService shopService;
    
        /**
         * 根据id查询商铺信息
         * @param id 商铺id
         * @return 商铺详情数据
         */
        @GetMapping("/{id}")
        public Result queryShopById(@PathVariable("id") Long id) {
            return shopService.queryById(id);
        }
    
    
        /**
         * 更新商铺信息
         * @param shop 商铺数据
         * @return 无
         */
        @PutMapping
        public Result updateShop(@RequestBody Shop shop) {
            // 更新商铺信息,并写入数据库
            return shopService.update(shop);
        }
    }    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    @Service
    public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    
        @Resource
        private StringRedisTemplate stringRedisTemplate;
    
    
        @Override
        public Result queryById(Long id) {
    
            // 1.从redis查询商铺缓存
            String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
    
            // 2.判断是否存在
            if (StrUtil.isNotBlank(shopJson)) {
                // 3.存在,则返回
                Shop shop = JSONUtil.toBean(shopJson, Shop.class);
                return Result.ok(shop);
            }
    
            // 4.不存在,则根据id查数据库
            Shop shop = getById(id);
            if (shop == null) {
                // 5.商户不存在,返回错误
                return Result.fail("店铺不存在!");
            }
    
            // 6.商户存在,写入redis
            stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
    
            //7.返回shop
            return Result.ok(shop);
        }
    
        @Override
        @Transactional
        public Result update(Shop shop) {
            Long id = shop.getId();
            if (id == null) {
                return Result.fail("店铺id不能为空");
            }
            // 1.更新数据库
            updateById(shop);
            // 2.删除缓存
            stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + id);
            return Result.ok();
        }
    
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50

    4.小结

    缓存更新策略的最佳实践方案:

    (1)低一致性需求:使用Redis自带的内存淘汰机制
    (2)高一致性需求:主动更新,并以超时剔除作为兜底方案

    • 读操作:
      缓存命中则直接返回
      缓存未命中则查询数据库,并写入缓存,设定超时时间
    • 写操作:
      先写数据库,然后再删除缓存
      要确保数据库与缓存操作的原子性

    四、缓存穿透

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

    1. 常见的解决方案:

    (1)缓存空对象

    • 优点:实现简单,维护方便
    • 缺点:额外的内存消耗;可能造成短期的不一致
      在这里插入图片描述

    (2)布隆过滤

    • 优点:内存占用较少,没有多余key
    • 缺点:实现复杂;存在误判可能
      在这里插入图片描述

    (3)增强id的复杂度,避免被猜测id规律

    (4)做好数据的基础格式校验

    (5)加强用户权限校验

    (6)做好热点参数的限流

    2. 使用缓存空对象解决缓存穿透问题

    在这里插入图片描述

    修改ShopServiceImpl中queryById方法,解决缓存穿透问题:

    @Service
    public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    
        @Resource
        private StringRedisTemplate stringRedisTemplate;
    
    
        @Override
        public Result queryById(Long id) {
    
            // 1.从redis查询商铺缓存
            String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
    
            // 2.判断是否存在
            if (StrUtil.isNotBlank(shopJson)) {
                // 3.存在,则返回
                Shop shop = JSONUtil.toBean(shopJson, Shop.class);
                return Result.ok(shop);
            }
    
            // 如果命中的是空值,则返回错误信息,防止缓存穿透
            if ("".equals(shopJson)) {
                return Result.fail("店铺不存在!");
            }
    
            // 4.不存在,则根据id查数据库
            Shop shop = getById(id);
            if (shop == null) {
                // 5.商户不存在,返回错误
                // 将空值写入redis,并设置短暂过期时间,解决缓存穿透问题
                stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
                return Result.fail("店铺不存在!");
            }
    
            // 6.商户存在,写入redis
            // 设置过期时间
            stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
    
            //7.返回shop
            return Result.ok(shop);
        }
    }    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42

    五、缓存雪崩

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

    解决方案:

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

    六、缓存击穿

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

    1. 常见的两种解决方案

    (1)互斥锁

    在这里插入图片描述

    (2)逻辑过期

    • 需要多维护一个逻辑过期时间expire,会占用一定内存
      在这里插入图片描述

    (3)互斥锁 与 逻辑过期 对比

    在这里插入图片描述

    2. 基于互斥锁方式解决缓存击穿问题

    在这里插入图片描述
    修改ShopServiceImpl中queryById方法,获取互斥锁、释放互斥锁,解决缓存击穿问题:

    @Service
    public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    
        @Resource
        private StringRedisTemplate stringRedisTemplate;
    
        @Override
        public Result queryById(Long id) {
    
           // 互斥锁解决缓存击穿
            Shop shop = queryWithMutex(id);
            if (shop == null) {
                return Result.fail("店铺不存在!");
            }
    
            return Result.ok(shop);
       }
    
    	public Shop queryWithMutex(Long id)  {
            String key = RedisConstants.CACHE_SHOP_KEY + id;
            // 1、从redis中查询商铺缓存
            String shopJson = stringRedisTemplate.opsForValue().get(key);
            // 2、判断是否存在
            if (StrUtil.isNotBlank(shopJson)) {
                // 3、存在,直接返回
                return JSONUtil.toBean(shopJson, Shop.class);
            }
            // 如果命中的是空值,则返回错误信息,防止缓存穿透
            if ("".equals(shopJson)) {
                return null;
            }
            // 4.实现缓存重构
            // 4.1 获取互斥锁
            String lockKey = "lock:shop:" + id;
            Shop shop = null;
            try {
                boolean isLock = tryLock(lockKey);
                // 4.2 判断否获取成功
                if (!isLock){
                    // 4.3 失败,则休眠重试
                    Thread.sleep(50);
                    // 递归重试
                    return queryWithMutex(id);
                }
    
                // 4.4 成功,根据id查询数据库
                shop = getById(id);
    
                // 模拟业务延时
                Thread.sleep(200);
    
                // 5.不存在,返回错误
                if(shop == null){
                    // 将空值写入redis,并设置短暂过期时间,解决缓存穿透问题
                    stringRedisTemplate.opsForValue().set(key,"", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
                    // 返回错误信息
                    return null;
                }
    
                // 6.写入redis
                stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
    
            }catch (Exception e){
                throw new RuntimeException(e);
            }finally {
                // 7.释放互斥锁
                unlock(lockKey);
            }
            return shop;
        }
    
        /**
         * 自定义互斥锁,利用redis的setnx方法来表示获取锁
         * @param key
         * @return
         */
        private boolean tryLock(String key) {
            return Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(key, "1", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS));
        }
    
        /**
         * 释放互斥锁
         * @param key
         */
        private void unlock(String key) {
            stringRedisTemplate.delete(key);
        }
        
    }    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89

    使用Jmeter工具压测:

    压测时需要注意:

    1. 注释掉拦截器,以免token问题影响压测
    2. 清掉redis缓存
      在这里插入图片描述在这里插入图片描述
      压测结果:
      最终只会有一个线程获取互斥锁成功,并查询数据库,其他线程都通过redis缓存查询数据

    3. 基于逻辑过期方式解决缓存击穿问题

    • 逻辑过期是指额外维护一个字段expire作为过期时间,实际上由于没有设置TTL,则TTL默认为-1表示永久有效,从而缓存一直会命中,不会被击穿
    • 获取互斥锁成功的线程,直接返回缓存中旧数据,另外开启一个新线程去数据库查最新数据重新存入redis缓存
      在这里插入图片描述

    (1)创建缓存预热对象

    • 将热点数据构造为缓存预热对象,提前存入缓存
    @Data
    public class RedisData {
    	// 逻辑过期时间
        private LocalDateTime expireTime;
        // 缓存预热对象
        private Object data;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    (2)编写测试方法,将热点数据写入缓存

    在ShopServiceImpl中新增该方法:

    /**
     * 重建缓存
     * @param id
     * @param expireSeconds
     */
    public void saveShop2Redis(Long id, Long expireSeconds)throws InterruptedException  {
        // 1.查询店铺数据
        Shop shop = getById(id);
        // 模拟业务延时
        Thread.sleep(200);
        // 2.封装缓存预热对象
        RedisData redisData = new RedisData();
        redisData.setData(shop);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        // 3.写入redis
        stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    测试方法:

    @SpringBootTest
    class HmDianPingApplicationTests {
    
        @Autowired
        private ShopServiceImpl shopService;
    
        @Test
        void testSaveShop() throws InterruptedException {
            // 将id为1的对象作为热点数据,逻辑过期时间设置20s
            shopService.saveShop2Redis(1L, 20L);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    热点数据存入成功:

    在这里插入图片描述

    (3)新增逻辑过期时间,解决缓存击穿问题

    修改ShopServiceImpl中queryById方法:

    @Override
    public Result queryById(Long id) {
    
        // 互斥锁解决缓存击穿
    //        Shop shop = queryWithMutex(id);
    
        // 逻辑过期解决缓存击穿
        Shop shop = queryWithLogicalExpire(id);
    
        if (shop == null) {
            return Result.fail("店铺不存在!");
        }
    
        return Result.ok(shop);
    }
    
    /**
     * 逻辑过期解决缓存击穿
     * @param id
     * @return
     */
    public Shop queryWithLogicalExpire(Long id) {
        String key = RedisConstants.CACHE_SHOP_KEY + id;
        // 1.从redis查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isBlank(json)) {
            // 3.存在,直接返回
            return null;
        }
        // 4.命中,需要先把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        // 先转化为JSONObject,再将JSONObject转化为需要的对象
        JSONObject JSONData = (JSONObject) redisData.getData();
        Shop shop = JSONUtil.toBean(JSONData, Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        // 5.判断是否过期
        if(expireTime.isAfter(LocalDateTime.now())) {
            // 5.1 未过期,直接返回店铺信息
            return shop;
        }
        // 5.2 已过期,需要缓存重建
        // 6. 缓存重建
        // 6.1 获取互斥锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        // 6.2 判断是否获取锁成功
        if (isLock){
            // 6.3 成功,开启新线程,重建缓存
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try{
                    //  重建缓存
                    this.saveShop2Redis(id,20L);
                }catch (Exception e){
                    throw new RuntimeException(e);
                }finally {
                    unlock(lockKey);
                }
            });
        }
        // 6.4 返回过期的商铺信息
        return shop;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63

    (4)Jmeter压测

    • 压测时先修改数据库数据,例如:将“105茶餐厅”修改为“106茶餐厅”
      在这里插入图片描述
    • 模拟1s内开启100个线程测试
      在这里插入图片描述

    测试结果:

    • 前面的请求查询的还是过期的旧数据,后面的请求查询的是最新数据
    • 说明使用逻辑过期解决缓存击穿问题时,会出现数据不一致的情况
      在这里插入图片描述
      在这里插入图片描述
  • 相关阅读:
    mysql数据库表之间关系,一对一、一对多、多对多,多表查询
    Android 系统功耗分析工具
    多种异构数据的分析设计方案3:聊聊策略模式+函数式接口+MAP
    GIL全局解释器锁
    【面试经典150 | 栈】最小栈
    Android 获取手机通讯录信息 — 头像、姓名和A-Z的快速查询
    python是垃圾?
    基于go语音开源的跨平台端口扫描工具Naabu
    EOCR电机保护器的日常维护与保养技巧
    ELK极简上手
  • 原文地址:https://blog.csdn.net/qq_36602071/article/details/125876461