Redis缓存
缓存就是数据交换的缓冲区(Cache),是存储数据的临时地方,一般读写性能较高。使用缓存可以降低后端负载、提高读写效率、降低响应时间,但是增加了数据一致性成本、代码维护成本、运维成本。
Redis缓存的模型及流程如下图所示:
模拟查询商户的过程,将商户的数据存储在缓存中。
商户缓存1.0
根据以上流程,编写代码
public static final Long CACHE_SHOP_TTL = 30L;
public static final String CACHE_SHOP_KEY = "cache:shop:";
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public Result queryById(Long id) {
//从redis查询商户信息
String shopJson = redisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//存在对应信息,则直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//不存在则根据id查询数据库
Shop shop = getById(id);
//数据库中也不存在
if(shop == null){
return Result.fail("用户不存在");
}
//数据库中存在则写入Redis
redisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop));
return Result.ok(shop);
}
商户缓存2.0
在1.0版本已经实现了将商户数据存储在缓存中,但是缓存数据并不是一成不变的,如果商户信息发生改变,那么缓存数据也要改变,这就涉及到了缓存的更新策略。
内存淘汰 | 超时删除 | 主动更新 | |
---|---|---|---|
说明 | 不需要自己维护,利用Redis的内存淘汰机制,当内存不足自动淘汰部分数据。下次查询时更新缓存。 | 给缓存数据添加TTL时间,到期后自动删除缓存。下次查询时更新缓存。 | 编写业务逻辑,在修改数据库的同时,更新缓存。 |
一致性 | 差 | 一般 | 好 |
维护成本 | 无 | 低 | 高 |
更新策略选择根据业务场景进行选择
- 低一致性需求:使用内存淘汰机制,例如店铺类型的查询缓存。
- 高一致性需求:主动更新,并以超时剔除作为兜底方案,例如店铺详情查询缓存。
主动更新策略:主动更新策略有多种,常用的是缓存调用者在更新数据库的同时更新缓存。而操作缓存和数据库又存在以下问题:
-
删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都更新缓存,无效的写操作较多。(不推荐)
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存。(推荐)
-
如何保证缓存与数据库操作同时成功或者失败?
- 单体系统可以将缓存与数据库操作放在一个事务内,保证同时成功或失败
- 分布式系统利用TCC等分布式事务方案
-
先操作缓存还是先操作数据库?
- 方案一:先删除缓存,再操作数据库
- 方案二:先操作数据库,再删除缓存
首先方案一和方案二在多线程情况下都有可能发生问题,但是方案二发生问题的可能性更低,因为缓存的操作速度是比数据库快的,而方案二发生的前提就是在更新缓存的几毫秒过程中,有另一个线程完成了更新数据库操作+删除缓存操作才会导致存入旧数据,但是这种情况发生的概率非常低。
因此最佳的方案是
- 读操作:
- 缓存命中直接返回
- 缓存未命中查询数据库,并写入缓存设定超时时间
- 写操作:
- 先写数据库,再删除缓存
- 确保数据库与缓存操作的原子性
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public Result queryById(Long id) {
//从redis查询商户信息
String shopJson = redisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//存在对应信息,则直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//不存在则根据id查询数据库
Shop shop = getById(id);
//数据库中也不存在
if(shop == null){
return Result.fail("用户不存在");
}
//数据库中存在则写入Redis并设置有效期30分钟
redisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
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.删除缓存
redisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}
缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远都不会生效,这些请求都会直接到数据库,当不断地有这种请求就会给数据库带来巨大压力。
常见的解决方案分为两种:
-
缓存空对象
不存在的时候直接缓存null空对象(“”),这样再次请求就不会访问数据库。
- 优点:实现简单,维护方便
- 缺点:额外的内存消耗、可能造成短期不一致
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public Result queryById(Long id) {
//从redis查询商户信息
String shopJson = redisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//判断Redis是否存在商户信息,如果为设置的空值这里无法判断,在下方再写一个if判断
if(StrUtil.isNotBlank(shopJson)){
//存在对应信息,则直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//判断命中的是否为空值,如果为"",那么shopJson就不等于null,""和null是不一样的
if(shopJson != null){
return Result.fail("店铺信息不存在");
}
//不存在则根据id查询数据库
Shop shop = getById(id);
//数据库中也不存在
if(shop == null){
//不存在则在Redis存储空值并设置有效期2min
redisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return Result.fail("商户不存在");
}
//数据库中存在则写入Redis,并设置有效期30min
redisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
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.删除缓存
redisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}
}
-
布隆过滤
在访问缓存之前通过布隆过滤器来判断数据是否存在,如果不存在则直接拒绝访问,避免一直访问缓存和数据库
- 优点:内存占用小,没有多余key
- 缺点:实现复杂,存在误判可能
缓存雪崩
缓存雪崩是指同一时间段内,大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同key的TTL添加随机值,避免大量key同时失效
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂(失效之后的重新构建数据)的key突然失效了,无数的请求访问会瞬间给数据库带来巨大的冲击。
常见的解决方案有两种
-
互斥锁
避免大量的请求都尝试重建数据,使用互斥锁,只有一个线程能够进行重建。也就是当一个请求查询不到缓存,就获取互斥锁,然后写入缓存之后再释放锁。其余线程只能不断地休眠重试,处于等待状态。
- 优点:相对于逻辑过期没有额外的内存消耗、保证一致性、实现简单、
- 缺点:线程需要等待,性能受影响、可能有死锁风险
public Shop queryWithMutex(Long id){
//从redis查询商户信息
String shopJson = redisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//判断Redis是否存在商户信息,如果为设置的空值这里无法判断,在下方再写一个if判断
if(StrUtil.isNotBlank(shopJson)){
//存在对应信息,则直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
//判断命中的是否为空值,如果为"",那么shopJson就不等于null,""和null是不一样的
if(shopJson != null){
return null;
}
Shop shop = null;
//实现缓存重建
//获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
try {
boolean isLock = tryLock(lockKey);
//判断是否获取成功
//如果获取锁不成功
if(!isLock){
//失败则休眠并重试
Thread.sleep(1000);
return queryWithMutex(id);
}
//如果获取锁成功,则查询数据库进行缓存重建
shop = getById(id);
//模拟重建的延迟
Thread.sleep(200);
//数据库中也不存在
if(shop == null){
//不存在则在Redis存储空值并设置有效期2min
redisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//数据库中存在则写入Redis,并设置有效期30min
redisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//释放锁
unLock(lockKey);
}
return shop;
}
//获取锁
private boolean tryLock(String key){
Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//释放锁
public void unLock(String key){
redisTemplate.delete(key);
}
-
逻辑过期
不给热点数据设置一个真正的过期时间,而是逻辑上的过期,在存储数据的时候,增加一个字段存储过期时间(当前时间+期望存活的时间)这样key永远都不会过期。
- 优点:线程无需等待,性能较好
- 缺点:不保证一致性、有额外内存消耗、实现复杂
首先为了在Redis中存储商户信息同时,还多出一个过期时间的字段,需要重新建一个类,来存储
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
创建重建代码
public void saveShop2Redis(Long id,Long expireSeconds) throws InterruptedException {
//查询店铺信息
Shop shop = getById(id);
//模拟缓存重建的时间
Thread.sleep(200);
//封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//写入redis
redisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}
编写单元测试,查看重建方法是否正确的同时,导入热点数据,这样后面进行查看才不会出错,因为之前的商户缓存是没有过期时间字段的
@Test
void test() throws InterruptedException {
shopService.saveShop2Redis(1L,20L);
}
测试成功则编写具体的逻辑过期代码
public Shop queryWithLogicalExpire(Long id){
//1.从redis查询商户信息
String shopJson = redisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//2.如果为空,为空则返回
if(StrUtil.isBlank(shopJson)){
return null;
}
//3.如果存在则把json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//4.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())){
//4.1未过期返回店铺信息
return shop;
}
//4.2已过期,则缓存重建
//4.2.1 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean tryLock = tryLock(lockKey);
//4.2.2 判断是否获取锁成功
if(tryLock){
//获取锁成功,需要再次检查是否过期
// 因为可能线程a发现缓存过期,发起重建请求获取锁的时候,线程b刚刚开始判断缓存是否过期,此时线程b同样认为缓存过期需要重建
//在b获取锁之前,线程a刚好重建完成释放锁,那么b就顺利拿到锁,再次进行重建,可是此时a已经重建过了,不需要再次重建
//从redis查询商户信息
shopJson = redisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//如果为空,为空则返回
if(StrUtil.isBlank(shopJson)){
return null;
}
//如果存在则把json反序列化为对象
redisData = JSONUtil.toBean(shopJson, RedisData.class);
expireTime = redisData.getExpireTime();
//判断是否过期
if (expireTime.isAfter(LocalDateTime.now())){
//未过期返回店铺信息
return shop;
}
//4.2.3 过期则开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(()->{
//重建
try {
this.saveShop2Redis(id,20L);
}catch (Exception e){
throw new RuntimeException();
}finally {
//释放锁
unLock(lockKey);
}
});
}
//4.2.4 如果拿不到锁就直接返回过期的商户信息
return shop;
}
缓存工具封装
方法一:将任意Java对象序列化为json并存储在String类型的key中,并且可以设置TTL过期时间
方法二:将任意Java对象序列化为json并存储在String类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
方法三:根据指定key查询缓存,并反序列化为指定类型,利用缓存空值方式,解决缓存穿透问题
方法四:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
@Component
@Slf4j
public class CacheClient {
private final StringRedisTemplate redisTemplate;
public CacheClient(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 将任意Java对象序列化为json并存储在String类型的key中,并且可以设置TTL过期时间
* @param key key值
* @param value value值
* @param time 过期时间
* @param timeUnit 过期时间单位
*/
public void set(String key, Object value, Long time, TimeUnit timeUnit){
redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,timeUnit);
}
/**
* 将任意Java对象序列化为json并存储在String类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
* @param key key值
* @param value value值
* @param time 过期时间
* @param timeUnit 过期时间单位
*/
public void setWithLogicalExpire(String key, Object value,Long time,TimeUnit timeUnit){
//设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(time)));
//写入redis
redisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
}
/**
* 根据指定key查询缓存,并反序列化为指定类型,利用缓存空值方式,解决缓存穿透问题
* @param keyPrefix String类型key值的前缀
* @param id 唯一编码id(不限类型)
* @param type 返回值类型
* @param dbFallback 获取数据库内容函数
* @param time 过期时间
* @param timeUnit 过期时间单位
* @return
* @param <R> 返回值泛型
* @param <ID> 返回值泛型
*/
public <R,ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback
,Long time,TimeUnit timeUnit){
String key = keyPrefix + id;
//从redis查询商户信息
String json = redisTemplate.opsForValue().get(key);
//判断Redis是否存在商户信息,如果为设置的空值这里无法判断,在下方再写一个if判断
if(StrUtil.isNotBlank(json)){
//存在对应信息,则直接返回
return JSONUtil.toBean(json, type);
}
//判断命中的是否为空值,如果为"",那么shopJson就不等于null,""和null是不一样的
if(json != null){
return null;
}
//不存在则根据id查询数据库
R r = dbFallback.apply(id);
//数据库中也不存在
if(r == null){
//不存在则在Redis存储空值并设置有效期2min
redisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//数据库中存在则写入Redis,并设置有效期
this.set(key,r,time,timeUnit);
return r;
}
//线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
//获取锁
private boolean tryLock(String key){
Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//释放锁
public void unLock(String key){
redisTemplate.delete(key);
}
/**
* 根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
* @param keyPrefix Stiring类型key的前缀
* @param id 唯一标识可以为任意类型
* @param type 返回值类型
* @param dbFallback 获取数据库内容函数
* @param time 过期时间
* @param timeUnit 过期时间单位
* @return
* @param <R> 返回值类型
* @param <ID> 返回值类型
*/
public <R,ID> R queryWithLogicalExpire(String keyPrefix,ID id,Class<R> type,Function<ID,R> dbFallback
,Long time,TimeUnit timeUnit){
String key = keyPrefix +id;
//1.从redis查询商户信息
String json = redisTemplate.opsForValue().get(key);
//2.如果为空,为空则返回
if(StrUtil.isBlank(json)){
return null;
}
//3.如果存在则把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
//4.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())){
//4.1未过期返回店铺信息
return r;
}
//4.2已过期,则缓存重建
//4.2.1 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean tryLock = tryLock(lockKey);
//4.2.2 判断是否获取锁成功
if(tryLock){
//获取锁成功,需要再次检查是否过期
// 因为可能线程a发现缓存过期,发起重建请求获取锁的时候,线程b刚刚开始判断缓存是否过期,此时线程b同样认为缓存过期需要重建
//在b获取锁之前,线程a刚好重建完成释放锁,那么b就顺利拿到锁,再次进行重建,可是此时a已经重建过了,不需要再次重建
//从redis查询商户信息
json = redisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//如果为空,为空则返回
if(StrUtil.isBlank(json)){
return null;
}
//如果存在则把json反序列化为对象
redisData = JSONUtil.toBean(json, RedisData.class);
expireTime = redisData.getExpireTime();
//判断是否过期
if (expireTime.isAfter(LocalDateTime.now())){
//未过期返回店铺信息
return r;
}
//4.2.3 过期则开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(()->{
//重建
try {
//查询数据库
R r1 = dbFallback.apply(id);
this.setWithLogicalExpire(key,r1,time,timeUnit);
}catch (Exception e){
throw new RuntimeException();
}finally {
//释放锁
unLock(lockKey);
}
});
}
//4.2.4 如果拿不到锁就直接返回过期的商户信息
return r;
}
}
方法的调用方式:
//使用工具类解决缓存穿透
Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, dataId -> getById(dataId), CACHE_SHOP_TTL, TimeUnit.MINUTES);
//使用工具类逻辑过期解决缓存击穿
Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, dataId -> getById(dataId), CACHE_SHOP_TTL, TimeUnit.MINUTES);