(redis缓存) 缓存是存储数据的临时地方,一般读写性能高
1. 给商铺添加缓存
先实现基本思路
用店铺id先查询redis,命中直接返回店铺信息,不命中则查询数据库,并将查到的信息写入redis,数据库也查不到就返回错误信息 思路: 在对应的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);
}
可以看到查询的时间比原来小了很多
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);
}
加入缓存前后时间对比:
3. 缓存更新策略
业务场景:
- 低一致性需求:使用内存淘汰机制,如店铺类型的查询缓存
- 高一致性需求:主动更新,并以超时剔除作为兜底方案,如店铺详情查询的缓存
3.1 更新策略
有三种策略,推荐第一种: 也就是由缓存的调用者,在更新数据库的同时更新缓存
3.2 问题
有三个问题需要考虑:
1. 删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
推荐删除缓存
2. 如何保证缓存与数据库的操作的同时成功或失败?
- 单体系统,将缓存与数据库操作放在一个事务
- 分布式系统,利用TCC等分布式事务方案
3. 先操作缓存还是先操作数据库?
- 先删除缓存,再操作数据库
- 先操作数据库,再删除缓存
第一种
正常情况:
出现错误,即删除完缓存而数据库没更新之前就查询·生成缓存:
第二种:
正常情况:
出现错误,即缓存因未知原因被删除,查询数据库的时候数据库更新,因为没有生成缓存而无法删除缓存,用先前查到的数据库旧数据生成了缓存,导致缓存和数据库数据不匹配
第一种方案可能会导致缓存和数据库数据不匹配的情况,第二种在低概率的情况下也可能出现(发生这种情况可以加超时时间解决),所以推荐第二种
3.3 总结
缓存更新策略的最佳实践方案:
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();
}
效果: 更新商铺信息后 可以看到,原来id为1的102茶餐厅在被我改为下北泽茶餐厅后,因为数据库更新而删除了缓存 网页刷新后,我们实现了缓存与数据库的双写一致
4. 缓存穿透
缓存穿透是指客户端请求的数据在缓存和数据库中都不存在,所以每次查询都会查询数据库,增加数据库的压力
4.1 解决方案
4.1.1 设置空对象
当客户端请求的id对象在缓存和数据库中都不存在,会在缓存中缓存一个该id的空对象,并设置一个ttl时间,以减少内存消耗,下次客户端再次请求该id时,直接在缓存中查询,但是如果在生成空对象且并未删除之后,数据库有了该id的对象,可能会造成数据短期的不一致
- 优点:实现简单,维护方便
- 缺点:额外的内存消耗,并且可能会造成数据短期的不一致
4.1.2 布隆过滤
在查询缓存之前,设置一个布隆过滤器,如果请求的id不存在,直接拒绝访问。布隆过滤器中的数据是基于哈希算法求出的二进制数据,并不是把数据库中的数据全存储在里面,相对来说内存消耗较少,但是存在误判的可能
- 优点:内存占用较少,没有多余key
- 缺点:实现复杂,存在误判可能
推荐缓存空对象的方式
4.2 编写实现
需要改两个地方:
- 在数据库中查不到,要将空值写入redis
- 在缓存中命中,要判断是否为空,空的话直接结束,不为空再返回商铺信息
@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,缓存中会储存一个空对象
4.3 总结
缓存穿透的解决方案
- 缓存空值
- 布隆过滤
- 增强id的复杂度,避免被猜到id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
5.缓存雪崩
缓存雪崩是指同一时间大量的key同时失效或者redis宕机,导致大量的请求到达数据库,带来巨大的压力 解决方案:
- 给不同的key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
6.缓存击穿
缓存击穿也叫热点key问题,与缓存雪崩不同,缓存击穿是一个高并发访问或者缓存业务复杂的key突然失效,导致无数的请求对数据库造成了巨大的冲击
解决方案:
- 互斥锁
查询缓存未命中的情况下,获取互斥锁,然后查询数据库重建缓存数据,再写入缓存之前如果又有请求来查询缓存,就会获取互斥锁失败,为了防止他一直重试,会让他休眠一会再重试,直到写入缓存成功释放了锁,才能缓存命中
- 逻辑删除
在存入缓存时不设置ttl,而是假如一个字段表示过期时间 查询缓存发现逻辑时间已经过期时。获取互斥锁并开启一个全新的线程,去查询数据库并写入缓存和重置过期时间,原线程直接返回过期数据,再写入缓存之前来查询的的其他线程,发现获取互斥锁失败会直接返回过期数据,写入缓存并释放锁之后,来查询的线程才能命中
优缺点:
互斥锁,大量的请求会一直等待,影响性能,并且有死锁的风险,牺牲可用性
逻辑过期,无需等待直接返回过期时间,并且有额外的内存消耗,牺牲一致性
6.1 基于互斥锁方式解决缓存击穿问题
按照流程图,缓存未命中时尝试获取互斥锁,获取不到就休眠并重新查询,能获取到就查数据库,写缓存,最后释放互斥锁
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次: 可以看到请求全部通过了 吞吐量 只执行了两次查询数据库的操作,商铺1一次,商铺2一次 redis中有对应的数据
6.2 基于逻辑过期方式解决缓存击穿问题
如果缓存命中,要判断缓存是否过期,不过期直接返回数据,过期要尝试获取互斥锁,没获取到返回旧数据,获取到要开启独立线程,在新线程中写入缓存并设置逻辑过期时间,释放互斥锁,最后返回商铺信息
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次: 可以看到,刚开始查的还是旧数据,后面才变成新数据 只发送了一次请求
7.封装redis工具类
难点:使用了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);
}
}