(redis缓存) 缓存是存储数据的临时地方,一般读写性能高

1. 给商铺添加缓存

先实现基本思路

用店铺id先查询redis,命中直接返回店铺信息,不命中则查询数据库,并将查到的信息写入redis,数据库也查不到就返回错误信息 思路: image.png 在对应的serviceImpl里写逻辑

@Override
public Result queryById(Long id) {
    String key = CACHE_SHOP_KEY + id;
    //1.从redis查询商铺缓存
    String shopJSON = stringRedisTemplate.opsForValue().get(key);

    if (StrUtil.isNotEmpty(shopJSON)) {
        Shop shop = BeanUtil.toBean(shopJSON, Shop.class);
        return Result.ok(shop);
    }
    //2. 未查到,查询数据库
    Shop shop2 = shopMapper.selectById(id);
    //3.判断商铺是否存在
    if (shop2 == null) {
        //4.不存在,返回404
        Result.fail("商铺不存在!");
    }
    //5. 存在,将商铺数据写入redis
    String jsonStr = JSONUtil.toJsonStr(shop2);
    stringRedisTemplate.opsForValue().set(key,jsonStr);

    //6. 返回商铺信息
    return Result.ok(shop2);
}

可以看到查询的时间比原来小了很多 image.png

2. 给商铺类型添加缓存

在对应的serviceImpl中写处理逻辑

@Override
public Result queryType() {
    //1. redis中查询商铺类型
    Long size = stringRedisTemplate.opsForList().size(CACHE_SHOP_TYPE);
    List<String> range = stringRedisTemplate.opsForList().range(CACHE_SHOP_TYPE, 0, size);
    ArrayList<ShopType> list = new ArrayList<>();
    if (range.size() > 0) {
        //2.查到直接返回
        for (String typeJSON : range) {
            if (StrUtil.isNotEmpty(typeJSON)) {
                ShopType shopType = JSONUtil.toBean(typeJSON, ShopType.class);
                list.add(shopType);
            }
        }
        return Result.ok(list);
    }

    //3. 未查到,查询数据库
    List<ShopType> shopTypes = shopTypeMapper.selectList(null);
    if (shopTypes == null) {
        Result.fail("未找到商铺类型");
    }
    //4. 存在,将商铺类型数据写入redis

    for (ShopType shopType : shopTypes) {
        String jsonStr = JSONUtil.toJsonStr(shopType);
        stringRedisTemplate.opsForList().rightPush(CACHE_SHOP_TYPE, jsonStr);
    }

    return Result.ok(shopTypes);
}

加入缓存前后时间对比: image.png image.png

3. 缓存更新策略

image.png 业务场景:

  • 低一致性需求:使用内存淘汰机制,如店铺类型的查询缓存
  • 高一致性需求:主动更新,并以超时剔除作为兜底方案,如店铺详情查询的缓存

3.1 更新策略

有三种策略,推荐第一种: 也就是由缓存的调用者,在更新数据库的同时更新缓存

image.png

3.2 问题

有三个问题需要考虑:

1. 删除缓存还是更新缓存?

  • 更新缓存:每次更新数据库都更新缓存,无效写操作较多
  • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存

推荐删除缓存

2. 如何保证缓存与数据库的操作的同时成功或失败?

  • 单体系统,将缓存与数据库操作放在一个事务
  • 分布式系统,利用TCC等分布式事务方案

3. 先操作缓存还是先操作数据库?

  • 先删除缓存,再操作数据库
  • 先操作数据库,再删除缓存

第一种

正常情况:

image.png 出现错误,即删除完缓存而数据库没更新之前就查询·生成缓存: image.png

第二种:

正常情况:

image.png 出现错误,即缓存因未知原因被删除,查询数据库的时候数据库更新,因为没有生成缓存而无法删除缓存,用先前查到的数据库旧数据生成了缓存,导致缓存和数据库数据不匹配 image.png

第一种方案可能会导致缓存和数据库数据不匹配的情况,第二种在低概率的情况下也可能出现(发生这种情况可以加超时时间解决),所以推荐第二种

3.3 总结

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

3.4 实现商铺缓存与数据库的双写一致

超时删除,在查询商铺的逻辑里添加了定时删除商铺缓存的功能

@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJSON = stringRedisTemplate.opsForValue().get(key);

if (StrUtil.isNotEmpty(shopJSON)) {
    Shop shop = JSONUtil.toBean(shopJSON, Shop.class);
    System.err.println(shop);
    return Result.ok(shop);
}
//2. 未查到,查询数据库
Shop shop2 = shopMapper.selectById(id);
//3.判断商铺是否存在
if (shop2 == null) {
    //4.不存在,返回404
    Result.fail("商铺不存在!");
}
//5. 存在,将商铺数据写入redis
String jsonStr = JSONUtil.toJsonStr(shop2);
stringRedisTemplate.opsForValue().set(key,jsonStr,CACHE_SHOP_TTL, TimeUnit.MINUTES);

//6. 返回商铺信息
return Result.ok(shop2);
}

修改商铺时先操作数据库,再删除缓存

@Override
@Transactional
public Result updateShop(Shop shop) {
Long id = shop.getId();
if (id == null) {
    return Result.fail("商铺id不能为空!");
}
//1.操作数据库
shopMapper.updateById(shop);
//2.删除缓存
String key = CACHE_SHOP_KEY + id;
stringRedisTemplate.delete(key);
return Result.ok();
}

效果: 更新商铺信息后 image.png 可以看到,原来id为1的102茶餐厅在被我改为下北泽茶餐厅后,因为数据库更新而删除了缓存 image.png image.png 网页刷新后,我们实现了缓存与数据库的双写一致 image.png image.png

4. 缓存穿透

缓存穿透是指客户端请求的数据在缓存和数据库中都不存在,所以每次查询都会查询数据库,增加数据库的压力

4.1 解决方案

4.1.1 设置空对象

当客户端请求的id对象在缓存和数据库中都不存在,会在缓存中缓存一个该id的空对象,并设置一个ttl时间,以减少内存消耗,下次客户端再次请求该id时,直接在缓存中查询,但是如果在生成空对象且并未删除之后,数据库有了该id的对象,可能会造成数据短期的不一致 image.png

  • 优点:实现简单,维护方便
  • 缺点:额外的内存消耗,并且可能会造成数据短期的不一致

4.1.2 布隆过滤

在查询缓存之前,设置一个布隆过滤器,如果请求的id不存在,直接拒绝访问。布隆过滤器中的数据是基于哈希算法求出的二进制数据,并不是把数据库中的数据全存储在里面,相对来说内存消耗较少,但是存在误判的可能 image.png

  • 优点:内存占用较少,没有多余key
  • 缺点:实现复杂,存在误判可能

推荐缓存空对象的方式

4.2 编写实现

需要改两个地方:

  1. 在数据库中查不到,要将空值写入redis
  2. 在缓存中命中,要判断是否为空,空的话直接结束,不为空再返回商铺信息

image.png

@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJSON = stringRedisTemplate.opsForValue().get(key);

if (StrUtil.isNotBlank(shopJSON)) {
    Shop shop = JSONUtil.toBean(shopJSON, Shop.class);

    return Result.ok(shop);
}

//1.2 判断商铺数据是否为空
if (Objects.equals(shopJSON, "")) {
    return Result.fail("商铺不存在!");
}
//2. 未查到,查询数据库
Shop shop2 = shopMapper.selectById(id);
System.err.println(shop2);
//3.判断商铺是否存在
if (shop2 == null) {
    //4.在缓存中设置空值
    stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
    //5.返回错误信息
    Result.fail("商铺不存在!");
}
//5. 存在,将商铺数据写入redis
String jsonStr = JSONUtil.toJsonStr(shop2);
stringRedisTemplate.opsForValue().set(key, jsonStr, CACHE_SHOP_TTL, TimeUnit.MINUTES);

//6. 返回商铺信息
return Result.ok(shop2);
}

当我们传入一个不存在的id,缓存中会储存一个空对象 image.pngimage.png

4.3 总结

缓存穿透的解决方案

  1. 缓存空值
  2. 布隆过滤
  3. 增强id的复杂度,避免被猜到id规律
  4. 做好数据的基础格式校验
  5. 加强用户权限校验
  6. 做好热点参数的限流

5.缓存雪崩

缓存雪崩是指同一时间大量的key同时失效或者redis宕机,导致大量的请求到达数据库,带来巨大的压力 image.png 解决方案:

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

6.缓存击穿

缓存击穿也叫热点key问题,与缓存雪崩不同,缓存击穿是一个高并发访问或者缓存业务复杂的key突然失效,导致无数的请求对数据库造成了巨大的冲击

解决方案:

  1. 互斥锁

image.png 查询缓存未命中的情况下,获取互斥锁,然后查询数据库重建缓存数据,再写入缓存之前如果又有请求来查询缓存,就会获取互斥锁失败,为了防止他一直重试,会让他休眠一会再重试,直到写入缓存成功释放了锁,才能缓存命中

  1. 逻辑删除

image.png 在存入缓存时不设置ttl,而是假如一个字段表示过期时间 查询缓存发现逻辑时间已经过期时。获取互斥锁并开启一个全新的线程,去查询数据库并写入缓存和重置过期时间,原线程直接返回过期数据,再写入缓存之前来查询的的其他线程,发现获取互斥锁失败会直接返回过期数据,写入缓存并释放锁之后,来查询的线程才能命中

优缺点: image.png

互斥锁,大量的请求会一直等待,影响性能,并且有死锁的风险,牺牲可用性

逻辑过期,无需等待直接返回过期时间,并且有额外的内存消耗,牺牲一致性

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

image.png

按照流程图,缓存未命中时尝试获取互斥锁,获取不到就休眠并重新查询,能获取到就查数据库,写缓存,最后释放互斥锁

private Shop queryWithMutex(Long id) {
Shop shop2 = null;

String key = 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;
}

//1.2 判断商铺数据是否为空
if (Objects.equals(shopJSON, "")) {
    return null;
}

//2.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;

try {
    boolean flag = tryLock(lockKey);
    //3.判断是否获取锁
    if (!flag) {
        //没获取到就休眠
        Thread.sleep(50);
        //递归,直到获取到锁
        return queryWithMutex(id);
    }
    //4. 未查到,查询数据库
    shop2 = shopMapper.selectById(id);
    //模拟重建的延时
    Thread.sleep(200);
    //5.判断商铺是否存在
    if (shop2 == null) {
        //5.1 在缓存中设置空值
        stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        //5.2 返回错误信息
        return null;
    }
    //6. 存在,将商铺数据写入redis
    String jsonStr = JSONUtil.toJsonStr(shop2);
    stringRedisTemplate.opsForValue().set(key, jsonStr, CACHE_SHOP_TTL, TimeUnit.MINUTES);


} catch (InterruptedException e) {
    throw new RuntimeException(e);
} finally {
    //7.释放互斥锁
    destroyLock(lockKey);

}
//8. 返回商铺信息
return shop2;

}

使用jmeter测试工具测试,分别对商铺1和商铺2请求查询1000次: image.png 可以看到请求全部通过了 image.png 吞吐量 image.png 只执行了两次查询数据库的操作,商铺1一次,商铺2一次 image.png redis中有对应的数据

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

image.png 如果缓存命中,要判断缓存是否过期,不过期直接返回数据,过期要尝试获取互斥锁,没获取到返回旧数据,获取到要开启独立线程,在新线程中写入缓存并设置逻辑过期时间,释放互斥锁,最后返回商铺信息

private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);



private Shop queryWithLogicalExpire(Long id) {

String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJSON = stringRedisTemplate.opsForValue().get(key);

if (StrUtil.isBlank(shopJSON)) {

    return null;
}

//2.命中,需要把json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJSON, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//3.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
    //3.1 未过期,直接返回商铺信息
    return shop;
}

//5.2 已过期,需要缓存重建
//6.缓存重建
//6.1 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean flag = tryLock(lockKey);
//6.2 判断是否获取锁成功
if (flag) {
    if (expireTime.isAfter(LocalDateTime.now())) {
        //3.1 未过期,直接返回商铺信息
        return shop;
    }
    //6.3 商铺信息过期,获取锁成功,开启独立线程,实现缓存重建
    CACHE_REBUILD_EXECUTOR.submit(() -> {
        try {
            //重建缓存
            this.saveShop2Redis(id, 20L);
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            //释放锁
            destroyLock(lockKey);
        }

    });
}
//6.4 返回过期的商铺信息
return shop;
}

事先将数据库中的id为1的商铺名字改为下北泽2餐厅 使用jmeter测试工具测试,对商铺1请求查询100次: image.png image.png 可以看到,刚开始查的还是旧数据,后面才变成新数据 image.png 只发送了一次请求

7.封装redis工具类

image.png 难点:使用了java中的函数式编程思想

package com.hmdp.utils;

import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;

import static com.hmdp.utils.RedisConstants.CACHE_NULL_TTL;
import static com.hmdp.utils.RedisConstants.LOCK_SHOP_KEY;

@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;
    }

    /**
     * 加入缓存并设置ttl过期时间
     * 
     * @param key
     * @param value
     * @param time
     * @param unit
     */
    public void set(String key, Object value, Long time, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }


    /**
     * 热点key缓存重建
     * 
     * @param key
     * @param value
     * @param time
     * @param unit
     */
    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)));
        // 写入Redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    /**
     * 解决缓存穿透
     * @param keyPrefix
     * @param id
     * @param type
     * @param dbFallback
     * @param time
     * @param unit
     * @return
     * @param <R>
     * @param <ID>
     */
    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 (StrUtil.isNotBlank(json)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(json, type);
        }
        // 判断命中的是否是空值
        if (json != null) {
            // 返回一个错误信息
            return null;
        }

        // 4.不存在,根据id查询数据库
        R r = dbFallback.apply(id);
        // 5.不存在,返回错误
        if (r == null) {
            // 将空值写入redis
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回错误信息
            return null;
        }
        // 6.存在,写入redis
        this.set(key, r, time, unit);
        return r;
    }

    /**
     * 逻辑过期解决缓存击穿
     * @param keyPrefix
     * @param id
     * @param type
     * @param dbFallback
     * @param time
     * @param unit
     * @return
     * @param <R>
     * @param <ID>
     */
    public <R, ID> R queryWithLogicalExpire(
            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 (StrUtil.isBlank(json)) {
            // 3.存在,直接返回
            return null;
        }
        // 4.命中,需要先把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        
        LocalDateTime expireTime = redisData.getExpireTime();
        // 5.判断是否过期
        if(expireTime.isAfter(LocalDateTime.now())) {
            // 5.1.未过期,直接返回店铺信息
            return r;
        }
        // 5.2.已过期,需要缓存重建
        // 6.缓存重建
        // 6.1.获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        // 6.2.判断是否获取锁成功
        if (isLock){
            // 6.3.成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 查询数据库
                    R newR = dbFallback.apply(id);
                    // 重建缓存
                    this.setWithLogicalExpire(key, newR, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }finally {
                    // 释放锁
                    unlock(lockKey);
                }
            });
        }
        // 6.4.返回过期的商铺信息
        return r;
    }

    /**
     * 互斥锁解决缓存击穿
     * 
     * @param keyPrefix
     * @param id
     * @param type
     * @param dbFallback
     * @param time
     * @param unit
     * @return
     * @param <R>
     * @param <ID>
     */
    public <R, ID> R queryWithMutex(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(shopJson, type);
        }
        // 判断命中的是否是空值
        if (shopJson != null) {
            // 返回一个错误信息
            return null;
        }

        // 4.实现缓存重建
        // 4.1.获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        R r = null;
        try {
            boolean isLock = tryLock(lockKey);
            // 4.2.判断是否获取成功
            if (!isLock) {
                // 4.3.获取锁失败,休眠并重试
                Thread.sleep(50);
                return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
            }
            // 4.4.获取锁成功,根据id查询数据库
            r = dbFallback.apply(id);
            // 5.不存在,返回错误
            if (r == null) {
                // 将空值写入redis
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                // 返回错误信息
                return null;
            }
            // 6.存在,写入redis
            this.set(key, r, time, unit);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            // 7.释放锁
            unlock(lockKey);
        }
        // 8.返回
        return r;
    }

    /**
     * 获取互斥锁
     * @param key
     * @return
     */
    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

    /**
     * 释放互斥锁
     * @param key
     */
    private void unlock(String key) {
        stringRedisTemplate.delete(key);
    }
}