Redis-Day3实战篇-商户查询缓存(缓存的添加和更新, 缓存穿透/雪崩/击穿, 缓存工具封装)

Redis-Day3实战篇-商户查询缓存

什么是缓存

  • 缓存(cache): 数据交换的缓冲区, 贮存数据的临时地方, 一般读写性能较高
  • 作用:
    • 降低后端负载
    • 提高读写效率, 降低响应时间
  • 成本:
    • 数据一致性成本
    • 代码维护成本
    • 运维成本

添加Redis缓存

业务流程

项目实现

java 复制代码
public Result queryShopById(Long id) {
    String shopKey = CACHE_SHOP_KEY + id;
    // 1. 从Redis查询商铺缓存
    String cacheShop = stringRedisTemplate.opsForValue().get(shopKey);
    // 2. 判断缓存是否命中
    if(StringUtils.isNotBlank(cacheShop)){
        // 3. 命中, 返回商铺信息
        Shop shop = JSON.parseObject(cacheShop, Shop.class);
        return Result.ok(shop);
    }
    // 4. 未命中, 根据id查询数据库
    Shop shop = getById(id);
    // 5. 判断商铺是否存在
    if(shop == null){
        // 6. 不存在, 返回404
        return Result.fail("商铺不存在");
    }
    // 7. 存在, 将商铺数据写入Redis并返回
    stringRedisTemplate.opsForValue().set(shopKey, JSON.toJSONString(shop));
    return Result.ok(shop);
}

练习 - 给店铺类型查询业务添加缓存

java 复制代码
public Result queryTypeList() {
    String shopTypeKey = CACHE_SHOP_TYPE_KEY;
    // 1. 从Redis查询商户类型缓存
    String shopTypeJson = stringRedisTemplate.opsForValue().get(shopTypeKey);
    // 2. 判断缓存是否命中
    if(StringUtils.isNotBlank(shopTypeJson)){
        // 3. 命中, 返回商户类型
        List<ShopType> shopTypes = JSON.parseArray(shopTypeJson, ShopType.class);
        return Result.ok(shopTypes);
    }
    // 4. 未命中, 从数据库查询商户类型
    List<ShopType> shopTypes = query().orderByAsc("sort").list();
    // 5. 将商户类型数据写入Redis并返回
    stringRedisTemplate.opsForValue().set(shopTypeKey, JSON.toJSONString(shopTypes));
    stringRedisTemplate.expire(shopTypeKey, CACHE_SHOP_TYPE_TTL, TimeUnit.MINUTES);
    return Result.ok(shopTypes);
}

缓存更新策略

最佳实践方案

  • 低一致性需求: 使用Redis自带的内存淘汰机制
  • 高一致性需求: 主动更新, 并以超时剔除作为兜底方案
    • 读操作
      • 缓存命中则直接返回
      • 缓存未命中则查询数据库, 并写入缓存, 设定超时时间
    • 写操作
      • 先写数据库, 然后在删除缓存
      • 要确保数据库与缓存操作的原子性

案例 - 给查询商铺的缓存添加超时剔除和主动更新

java 复制代码
@Override
public Result queryShopById(Long id) {
    ...
    // 8. 超时剔除
    stringRedisTemplate.expire(shopKey, CACHE_SHOP_TTL, TimeUnit.MINUTES);
    return Result.ok(shop);
}

// 主动更新
@Override
@Transactional
public Result updateShop(Shop shop) {
    Long id = shop.getId();
    if(id == null){
        return Result.fail("店铺id不能为空!");
    }
    String shopKey = CACHE_SHOP_KEY + id;
    // 1. 修改数据库
    updateById(shop);
    // 2. 删除缓存
    stringRedisTemplate.delete(shopKey);
    // 3. 返回ok
    return Result.ok();
}

缓存穿透/雪崩/击穿

缓存穿透

概述

  • 缓存穿透: 指客户端请求的数据在缓存中和数据库中都不存在, 这样缓存永远不会生效, 这些请求都会打到数据库
  • 解决方案
    • 缓存空对象
      • 优点
        • 实现简单, 维护方便
      • 缺点
        • 额外的内存消耗
        • 可能造成短期的不一致
    • 布隆过滤
      • 优点
        • 内存占用较少, 没有多余key
      • 缺点
        • 实现复杂
        • 存在误判可能
    • 增强id的复杂度, 并做好数据的基础格式校验
    • 加强用户权限校验
    • 做好热点参数的限流

项目实现 - 商铺查询缓存

java 复制代码
public Result queryShopById(Long id) {
    String shopKey = CACHE_SHOP_KEY + id;
    // 1. 从Redis查询商铺缓存
    String cacheShop = stringRedisTemplate.opsForValue().get(shopKey);
    // 2. 判断缓存是否命中
    if(StringUtils.isNotBlank(cacheShop)){
        // 3. 命中, 返回商铺信息
        Shop shop = JSON.parseObject(cacheShop, Shop.class);
        return Result.ok(shop);
    }
    // 缓存穿透: 判断是否是空对象, 健不存在的话需要去数据库查, 键存在但是是空对象则直接返回
    if(cacheShop != null){
        return Result.fail("商铺不存在");
    }
    // 4. 未命中, 根据id查询数据库
    Shop shop = getById(id);
    // 5. 判断商铺是否存在
    if(shop == null){
        // 6. 缓存穿透, 缓存空对象
        stringRedisTemplate.opsForValue().set(shopKey, "");
        stringRedisTemplate.expire(shopKey, CACHE_NULL_TTL, TimeUnit.MINUTES);
        return Result.fail("商铺不存在");
    }
    // 7. 存在, 将商铺数据写入Redis并返回
    stringRedisTemplate.opsForValue().set(shopKey, JSON.toJSONString(shop));
    stringRedisTemplate.expire(shopKey, CACHE_SHOP_TTL, TimeUnit.MINUTES);
    return Result.ok(shop);
}

缓存雪崩

  • 缓存雪崩: 指在同一时段大量的缓存key同时失效或者redis服务宕机, 导致大量请求到达数据库, 带来巨大压力
  • 解决方案
    • 给不同的key的ttl添加随机值
    • 利用redis集群提高服务的可用性
    • 给缓存业务添加降级限流策略
    • 给业务添加多级缓存

缓存击穿

概述

  • 缓存击穿(热点key问题): 指一个被高并发访问 并且缓存重建业务较复杂的key突然失效了, 无数的请求访问会在瞬间给数据库带来巨大的冲击
  • 解决方案
    • 互斥锁
      • 优点
        • 没有额外的内存消耗
        • 保证一致性
        • 实现简单
      • 缺点
        • 线程需要等待, 性能受影响
        • 可能有死锁风险
    • 逻辑过期
      • 优点
        • 线程无需等待, 性能较好
      • 缺点
        • 不保证一致性
        • 有额外的内存消耗
        • 实现复杂
  • 热点数据是提前存储的

互斥锁

java 复制代码
public Shop queryWithMutex(Long id){
    String shopKey = CACHE_SHOP_KEY + id;
    // 1. 从Redis查询商铺缓存
    String cacheShop = stringRedisTemplate.opsForValue().get(shopKey);
    // 2. 判断缓存是否命中
    if(StringUtils.isNotBlank(cacheShop)){
        // 3. 命中, 返回商铺信息
        Shop shop = JSON.parseObject(cacheShop, Shop.class);
        return shop;
    }
    // 缓存穿透: 判断是否是空对象, 健不存在的话需要去数据库查, 键存在但是是空对象则直接返回
    if(cacheShop != null){
        return null;
    }

    // 4. 未命中, 缓存重建
    // 4.1 尝试获取互斥锁

    String lockKey = LOCK_SHOP_KEY+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);
        // 5. 判断商铺是否存在
        if(shop == null){
            // 6. 缓存穿透, 缓存空对象
            stringRedisTemplate.opsForValue().set(shopKey, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        // 7. 存在, 将商铺数据写入Redis并返回
        stringRedisTemplate.opsForValue().set(shopKey, JSON.toJSONString(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } finally {
        // 8. 释放锁
        unLock(lockKey);
    }

    // 9. 返回
    return shop;
}

private boolean tryLock(String key){
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(flag);
}

private void unLock(String key){
    stringRedisTemplate.delete(key);
}

逻辑过期

java 复制代码
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    // 线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    @Override
    public Result queryShopById(Long id) {
        // // 缓存穿透
        // Shop shop = queryWithPassThrough(id);

        // // 缓存击穿 - 互斥锁
        // Shop shop = queryWithMutex(id);

        // 缓存击穿 - 逻辑过期
        Shop shop = queryWithLogicalExpire(id);

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

        return Result.ok(shop);
    }

    // 缓存击穿 - 逻辑过期
    public Shop queryWithLogicalExpire(Long id){
        String shopKey = CACHE_SHOP_KEY + id;
        // 1. 从Redis查询商铺缓存
        String cacheShop = stringRedisTemplate.opsForValue().get(shopKey);
        // 2. 判断缓存是否命中
        if(StringUtils.isBlank(cacheShop)){
            // 3. 未命中, 返回空
            return null;
        }
        // 4. 命中, 取出商铺数据和过期时间
        RedisData redisData = JSON.parseObject(cacheShop, RedisData.class);
        JSONObject data = (JSONObject)redisData.getData();
        Shop shop = JSON.parseObject(data.toJSONString(), Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();


        // 5. 判断缓存是否过期
        if(expireTime.isAfter(LocalDateTime.now())){
            // 6. 未过期
            return shop;
        }
        // 7. 过期, 尝试获取互斥锁
        String lockKey = LOCK_SHOP_KEY+id;
        boolean isLock = tryLock(lockKey);
        if(isLock){
            // 8. 获取成功, 开启独立线程, 缓存数据
            CACHE_REBUILD_EXECUTOR.submit(()->{
                try {
                    // 重建缓存
                    saveShopToRedis(id, 20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // 释放锁
                    unLock(lockKey);
                }
            });
        }
        // 9. 获取失败, 返回商铺信息
        return shop;
    }

    private boolean tryLock(String key){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(flag);
    }

    private void unLock(String key){
        stringRedisTemplate.delete(key);
    }

    public void saveShopToRedis(Long id, Long expireSeconds) throws InterruptedException {
        // 1. 查询商铺数据
        Shop shop = getById(id);
        // 1.1 模拟复杂查询
        Thread.sleep(200);
        // 2. 创建缓存数据
        RedisData redisData = new RedisData();
        redisData.setData(shop);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        // 3. 将数据缓存导redis
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id, JSON.toJSONString(redisData));
    }
}
  • 获取锁成功后需要再次检测缓存是否过期(懒得写, 没写)

练习 - 缓存工具封装

java 复制代码
@Slf4j
@Component
public class CacheClient {

    private final StringRedisTemplate stringRedisTemplate;

    // 线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    public CacheClient(StringRedisTemplate stringRedisTemplate){
        this.stringRedisTemplate = stringRedisTemplate;
    }

    // 将任意java对象序列化为json并存储在string类型的key中, 并且可以设置TTL过期时间
    public void set(String key, Object value, Long time, TimeUnit unit){
        String valueJson = JSON.toJSONString(value);
        stringRedisTemplate.opsForValue().set(key, valueJson, time, unit);
    }

    // 将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
    public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
        RedisData redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));

        String redisJson = JSON.toJSONString(redisData);
        stringRedisTemplate.opsForValue().set(key, redisJson);
    }

    // 根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
    public <R, ID>  R queryWithPassThrough(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
        String key = keyPrefix + id;
        // 1. 从Redis查询缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2. 判断缓存是否命中
        if(StringUtils.isNotBlank(json)){
            // 3. 命中, 返回
            return JSON.parseObject(json, type);
        }
        // 缓存穿透: 判断是否是空对象, 健不存在的话需要去数据库查, 键存在但是是空对象则直接返回
        if(json != null){
            return null;
        }
        // 4. 未命中, 根据id查询数据库
        R r = dbFallback.apply(id);
        // 5. 判断数据是否存在
        if(r == null){
            // 6. 缓存穿透, 缓存空对象
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        // 7. 存在, 将数据写入Redis并返回
        set(key, r, time, unit);
        return r;
    }

    // 根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
    public <R, ID> R queryWithLogicalExpire(
            String cachePrefix, String lockPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
        String key = cachePrefix + id;
        // 1. 从Redis查询缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2. 判断缓存是否命中
        if(StringUtils.isBlank(json)){
            // 3. 未命中, 返回空
            return null;
        }
        // 4. 命中, 取出数据和过期时间
        RedisData redisData = JSON.parseObject(json, RedisData.class);
        JSONObject data = (JSONObject)redisData.getData();
        R r = JSON.parseObject(data.toJSONString(), type);
        LocalDateTime expireTime = redisData.getExpireTime();


        // 5. 判断缓存是否过期
        if(expireTime.isAfter(LocalDateTime.now())){
            // 6. 未过期
            return r;
        }
        // 7. 过期, 尝试获取互斥锁
        String lockKey = lockPrefix+id;
        boolean isLock = tryLock(lockKey);
        if(isLock){
            // 8. 获取成功, 开启独立线程, 缓存数据
            CACHE_REBUILD_EXECUTOR.submit(()->{
                try {
                    // 查询数据库
                    R newR = dbFallback.apply(id);
                    // 写入缓存
                    setWithLogicalExpire(key, newR, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // 释放锁
                    unLock(lockKey);
                }
            });
        }
        // 9. 获取失败, 返回旧信息
        return r;
    }

    private boolean tryLock(String key){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(flag);
    }

    private void unLock(String key){
        stringRedisTemplate.delete(key);
    }
}

使用示例

java 复制代码
public Result queryShopById(Long id) {
    // 缓存穿透
    // Shop shop = cacheClient.
    //         queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, id2->getById(id2), CACHE_SHOP_TTL, TimeUnit.MINUTES);

    // 缓存击穿 - 互斥锁
    // Shop shop = queryWithMutex(id);

    // 缓存击穿 - 逻辑过期
    Shop shop = cacheClient.
            queryWithLogicalExpire(CACHE_SHOP_KEY, LOCK_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);

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

    return Result.ok(shop);
}

来源

黑马程序员. Redis入门到实战教程

Gitee地址

https://gitee.com/Y_cen/redis

相关推荐
m0_741585357 分钟前
网站框架
数据库
编程充电站pro29 分钟前
SQL 子查询与多表 JOIN 用法大全(速查版)
数据库·sql
祈祷苍天赐我java之术35 分钟前
Redis 有序集合解析
java·前端·windows·redis·缓存·bootstrap·html
Dersun40 分钟前
mysql数据库学习之常用函数(五)
数据库·sql·学习·mysql·ai编程
TDengine (老段)1 小时前
TDengine 时序函数 MAVG 用户手册
大数据·数据库·物联网·性能优化·时序数据库·iot·tdengine
hqwest1 小时前
QT肝8天16--加载动态菜单
开发语言·数据库·qt·ui·sqlite
Mr.Ja2 小时前
【LeetCode热题100】No.1——两数之和(Java)
java·算法·leetcode
编啊编程啊程2 小时前
gRPC从0到1系列【20】
java·rpc·kafka·dubbo·nio
数据知道3 小时前
Go基础:用Go语言操作MySQL详解
开发语言·数据库·后端·mysql·golang·go语言
种时光的人3 小时前
无状态HTTP的“记忆”方案:Spring Boot中Cookie&Session全栈实战
服务器·spring boot·后端·http