商户查询缓存
本文的主题是商铺缓存。主要包括:添加商铺缓存到 redis,实现缓存和数据库的一致,
redis 缓存面临的三个问题的解决:缓存穿透,缓存雪崩,缓存击穿
需求分析:根据 id 查询商铺,若 redis 中有商铺缓存,直接返回。否则查询数据库并将商铺信息缓存到 redis 中
代码实现:
① Controller 层,获取url中的请求参数 “id”
// ShopController.java
/**
* 根据id查询商铺信息
* @param id 商铺id
* @return 商铺详情数据
*/
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}
② redis 查询缓存,若缓存未命中,查询数据库,并保存到 redis 中
\\ ShopServiceImpl.java
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1.从 redis 查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if(StrUtil.isNotBlank(shopJson)){
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
// 5.不存在,返回错误
if(shop == null){
return Result.fail("店铺不存在!");
}
// 6.存在,写入 redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
// 7.返回
return Result.ok(shop);
}
}
问题记录:前端商铺显示 NaN
① return Result.ok() ,里面没有返回 shop
② redis 中有缓存记录,但是给店铺添加了过期时间,然后缓存没有清理掉,所以过期不显示。删除缓存刷新即可
缓存更新策略:
① 内存淘汰:一致性差,利用 redis 的内存淘汰机制,当内存不足时自动淘汰部分数据,下次查询时更新缓存
② 超时剔除:给缓存数据添加 TTL 时间,到期后自动删除缓存,下次查询时更新缓存
③ 主动更新:编写业务逻辑,在修改数据库的同时,更新缓存
采用主动更新方式:修改数据库并更新缓存
问题:先修改数据库再更新缓存,还是先更新缓存再修改数据库?
根据上面添加商铺缓存的逻辑,若线程查询redis 缓存未命中,即执行 查询数据库并写入 redis 缓存的逻辑
方案一:线程1删除缓存之后,导致缓存失效. 由于操作数据库是涉及IO,不如redis直接操作内存,因此大概率情况下线程1在更新数据库期间会有线程查询缓存. 可以看到,线程2在线程1更新数据库的时候,查询缓存并拿到了数据库中的快照版本,并将过期的值写入缓存。最后的结果是 redis 中 a = 10,而 mysql 中 a = 20,缓存和数据库不一致
方案二:线程1 先更新数据库再删除缓存,之后线程2由于缓存未命中,查询到数据库中更新的值并写入缓存. 结果上,数据库和缓存数据库一致
问题:更新数据库和删除缓存之间有线程请求业务呢?
线程取到过期缓存:在线程 1 更新数据库还未来得及删除缓存前,线程2查询缓存,由于缓存还未删除,线程2拿到旧数据。但之后的线程由于缓存未命中,会查询数据库并更新缓存
多线程操作数据库:这种情况也会发生缓存和数据库不一致。接着左侧线程3缓存未命中去查询数据库的情况,这时有线程更新数据库,则会出现对缓存更新丢失的情况.即线程1 写入缓存的操作覆盖了线程2 写入缓存的操作。这种情况发生的前提是:更新数据库和删除缓存之间有线程查询缓存导致缓存被删除【即左图】,其次是 查询数据库和写入缓存之前有线程查询数据库并写入了缓存。这种情况会发生但是因为有条件限制,所以概率较低
综合来看,方案二虽然也会发生缓存过期以及缓存与数据库不一致的情况,但是出错概率要小一点
因此选择:先更新数据库,再删除缓存
更新方式:利用 postman 修改店铺信息并发送给服务器
代码实现:
// ShopController.java
/**
* 更新商铺信息
* @param shop 商铺数据
* @return 无
*/
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
// 写入数据库
return shopService.update(shop);
}
// ShopServiceImpl.java
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if(id == null){
return Result.fail("店铺id不能为空");
}
// 1. 更新数据库
updateById(shop);
// 2. 删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}
缓存穿透:客户端请求的数据在缓存中和数据库中都不存在,每次查询数据库都不会写入缓存,缓存无法生效
解决方案:
① 缓存空对象:查询数据库不存在,写入缓存时写入null
② redis + 布隆过滤器:不存在则直接拒绝,不再查询缓存及数据库
缓存空对象解决缓存穿透:查询数据库不存在,在redis 中写入 null;查询 redis 为 null,返回错误信息
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1.从 redis 查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if(StrUtil.isNotBlank(shopJson)){
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// ② 判断命中的是否是空值
if(shopJson != null){
// 返回一个错误信息
return Result.fail("店铺信息不存在!");
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
// 5.不存在,返回错误
if(shop == null){
//① 将空值写入 redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
//返回错误信息
return Result.fail("店铺不存在!");
}
// 6.存在,写入 redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7.返回
return Result.ok(shop);
}
缓存雪崩:同一时段大量缓存 key 同时失效或 Redis 服务宕机,导致大量请求到达数据库
解决方案:
① 给不同的 key 的TTL 添加随机值
② 利用 Redis 集群提高服务可用性
③ 给缓存业务添加降级限流策略
④ 给业务添加多级缓存
缓存击穿:热点 key 被高并发访问且缓存重建业务较复杂的 key 失效
解决方案:
① 互斥锁:加锁保证只有一个线程执行 缓存重建,未获取互斥锁的线程休眠重试直到缓存命中
② 逻辑过期:不设置TTL而是设置逻辑过期时间,保证国歌线程可读取旧值,也会因逻辑过期执行缓存重建。只有获取互斥锁的线程执行缓存重建,未获取互斥锁的线程返回旧数据
① 缓存未命中的情况下,才执行缓存重建
② 互斥锁实现缓存重建的逻辑
获取锁
缓存重建:查询数据库,若存在则写到缓存
释放锁
@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 = 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(shopJson != null){
// 返回一个错误信息
return null;
}
// 4.未命中,实现缓存重建
// 4.1. 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;// 这里是代码被放到 try-catch后自动添加的
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, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
//返回错误信息
return null;
}
// 6.存在,写入 redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 7.释放互斥锁
unlock(lockKey);
}
// 8. 返回
return shop;
}
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.MINUTES);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key){
stringRedisTemplate.delete(key);
}
问题:如何设置逻辑过期时间?
① 定义 RedisData 类使用聚合的方式封装原有信息为 data 属性,并添加 expireTime 作为逻辑过期时间
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
② 缓存重建时获取当前时间设置 expireTime ,并将 RedisData 保存到 redis 中
public void saveShop2Redis(Long id, Long expireSeconds){
//1.查询店铺数据
Shop shop = getById(id);
//2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//3.写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
逻辑过期解决缓存击穿的思路:
从redis中获取 redisData 对象,从而获取逻辑过期时间
将获取的逻辑过期时间与当前时间比较,未过期直接返回
已过期则执行缓存重建
获取互斥锁进行缓存重建 tryLock()
获取到互斥锁的线程开启独立线程,实现缓存重建
利用上面创建的线程池进行缓存重建
重建缓存 saveShop2Redis()
释放锁
问题:获取到互斥锁的线程如何实现实现缓存重建?
① 创建线程池用于开启独立线程重建缓存
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
② 使用 executor.submit() 异步执行缓存重建任务
if (isLock) {
// 6.3 获取锁成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
// 重建缓存
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
}
// 释放锁
unlock(lockKey);
});
}
完整代码
@Override
public Result queryById(Long id) {
// 逻辑过期解决缓存击穿
Shop shop = queryWithLogicalExpire(id);
if(shop == null){
return Result.fail("店铺不存在!");
}
return Result.ok(shop);
}
// 缓存击穿
// ② 使用逻辑过期时间的方式解决缓存击穿
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire(Long id){
String key = CACHE_SHOP_KEY + id;
// 1.从 redis 查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if(StrUtil.isBlank(shopJson)){
// 3.不存在,返回空
return null;
}
// 4.命中,先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())){
// 5.1 未过期,直接返回店铺信息
return shop;
}
// 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 {
// 重建缓存
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
}
// 释放锁
unlock(lockKey);
});
}
return shop;
}
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.MINUTES);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key){
stringRedisTemplate.delete(key);
}