什么是缓存?
缓存是一种提高数据访问效率的技术,通过在内存中存储数据的副本来减少对数据库或其他慢速存储设备的频繁访问。缓存通常用于存储热点数据或计算代价高的结果,以加快响应速度。
添加Redis缓存有什么好处?
Redis 基于内存存储,读写速度极快,相比于传统的磁盘存储方式,能够显著提高系统的响应速度。
缓存了高频访问的数据后,可以减少对数据库的访问次数,从而减轻数据库的负载。
简单的将数据存入Redis之后存在什么问题?
缓存一致性问题:
当数据库中的数据发生变化时,缓存中的数据可能没有及时更新,导致数据不一致。
解决办法:使用缓存更新策略,如主动更新(推荐)、延迟双删、或设置缓存失效时间。
缓存穿透:
如果客户端频繁请求数据库中不存在的数据,而这些数据不会被缓存,最终所有请求都会直接打到数据库,增加负载。
解决办法:为不存在的数据设置一个短时间的空值缓存。
缓存雪崩:
当大量缓存数据同时过期或 Redis 宕机时,所有请求涌向数据库,可能导致系统崩溃。
解决办法:为不同缓存设置不同的过期时间(分布式过期时间),并配置缓存服务的高可用性(如主从、集群)。
缓存击穿:
某个热点数据突然失效时,大量请求直接打到数据库,可能导致数据库压力骤增。
解决办法:使用互斥锁机制,确保缓存重新加载时只有一个请求访问数据库。或者使用逻辑过期方式。这两种需要根据情况来选择。如果需要确保数据的一致性推荐使用互斥锁机制。如果短暂的数据不一致不要紧,追求的是访问速度,推荐使用逻辑过期方式
缓存更新策略
为什么读操作未命中Redis,读数据库存入Redis还需要设置超时时间呢?
通过设置超时时间,即使缓存更新失败,缓存数据会在过期时间后自动失效,重新加载最新数据,确保数据最终一致性。也就是兜底方案。
为什么写的操作是先写入数据库,再删除缓存呢?
1. 避免读写竞争问题
如果先删除缓存再写数据库,可能出现以下情况:
- 线程 A 删除缓存。
- 线程 B 查询数据时发现缓存为空,去数据库查询旧数据,并将其重新写入缓存。
- 线程 A 写入新的数据到数据库。
这样,缓存中保存的就会是旧数据,导致数据不一致。
为什么先写入数据库就不存在这种读写竞争问题?
因为对Redis的读写时间耗时很短在很短的时间内CPU的执行权被抢夺的概率很小,但是更新数据库的操作比较久,尤其涉及到多表查询更新的时候。被抢夺CPU执行权的概率比较大。
2. 确保缓存数据最终一致
先写入数据库再删除缓存的顺序,可以保证以下情况:
- 数据库中总是有最新的数据。
- 即使缓存被删除后有线程查询,触发缓存更新时,查询到的也是最新的数据。
package com.hmdp.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.CACHE_SHOP_KEY;
import static com.hmdp.utils.RedisConstants.CACHE_SHOP_TTL;
/**
* <p>
* 服务实现类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
@RequiredArgsConstructor
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
final StringRedisTemplate stringRedisTemplate;
/**
* 实现Redis缓存店铺信息
* @param id
* @return
*/
@Override
public Result queryById(Long id) {
// 1. 首先从Redis当中查询是否存在商铺
// 存储对象可以使用HashMap 也可以使用string类型
String shopStr = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 存在直接返回Redis中的信息
if (StrUtil.isNotBlank(shopStr)) {
// 将string类型转换为对象
Shop shop = JSONUtil.toBean(shopStr, Shop.class);
return Result.ok(shop);
}
// 不存在,则查询数据库
Shop shop = getById(id);
if (shop == null) {
// 数据库不存在返回404
return Result.fail("商铺不存在!");
}
// 数据库中存在商铺信息则将商铺信息存入Redis 并且设置超时时间作为兜底方案
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop),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不能为空!");
}
// 先更新数据库
updateById(shop);
// 删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY +id );
return Result.ok();
}
}
缓存穿透(访问不存在的数据)
也就是说我们在之前的查询的代码上,还需要添加如果Redis当中存在值且不为空直接返回Redis中的数据。还需要判断Redis存储的数据是否wield空 如果为空直接返回错误信息(不需要再查询数据库,导致数据库崩溃)。如果Redis当中的值为null我们需要查询数据库 如果数据库查询不到,我们需要将这个不存在的数据保存在Redis,值为空(防止缓存击穿),并且设置TTL
判断 Redis 是否存储了空值
if (shopStr != null)
判断了 Redis 中的值是否为 空
,如果为 ""
或空值标记,表示这是防止缓存穿透写入的特殊数据,直接返回错误信息。
数据库查询为空时缓存空值
使用 ""
或其他标记作为空值,写入 Redis,并设置一个较短的过期时间(例如 2 分钟)。这样可以避免频繁查询数据库,防止缓存穿透。
正常数据的缓存设置 TTL
如果数据库中有数据,写入 Redis 时设置长时间的 TTL(如 CACHE_SHOP_TTL
),确保数据一致性。
更新数据后删除缓存
确保先更新数据库,再删除缓存,避免并发情况下的数据不一致问题。
package com.hmdp.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.CACHE_SHOP_KEY;
import static com.hmdp.utils.RedisConstants.CACHE_SHOP_TTL;
/**
* <p>
* 服务实现类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
@RequiredArgsConstructor
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
final StringRedisTemplate stringRedisTemplate;
/**
* 实现Redis缓存店铺信息
*
* @param id
* @return
*/
@Override
public Result queryById(Long id) {
// 1. 从Redis中查询商铺信息
String shopStr = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 2. 如果Redis中存在值且不为空字符串,直接返回
if (StrUtil.isNotBlank(shopStr)) {
Shop shop = JSONUtil.toBean(shopStr, Shop.class);
return Result.ok(shop);
}
// 3. 判断Redis中是否存储了空值,防止缓存穿透
if (shopStr != null) {
return Result.fail("商铺不存在!");
}
// 4. Redis中不存在数据,从数据库查询
Shop shop = getById(id);
// 5. 如果数据库中也不存在,则写入一个空值到Redis,并设置短TTL,防止缓存穿透
if (shop == null) {
stringRedisTemplate.opsForValue().set(
CACHE_SHOP_KEY + id,
"", // 空值标记
2, // 短时间TTL(如2分钟)
TimeUnit.MINUTES
);
return Result.fail("商铺不存在!");
}
// 6. 如果数据库中存在,将商铺信息写入Redis,并设置TTL
stringRedisTemplate.opsForValue().set(
CACHE_SHOP_KEY + id,
JSONUtil.toJsonStr(shop),
CACHE_SHOP_TTL,
TimeUnit.MINUTES
);
// 7. 返回商铺信息
return Result.ok(shop);
}
/**
* 更新商铺信息
*
* @param shop
* @return
*/
@Override
@Transactional
public Result updateShop(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();
}
}
缓存雪崩
缓存击穿问题:(数据过期同时有大量请求)
缓存击穿问题发生在某些热点数据在缓存失效的瞬间,同时有大量请求涌入,从而导致这些请求直接访问数据库,可能会引发数据库压力过大甚至宕机的情况。
1. 给线程加锁(互斥锁)
原理:
通过对热点数据的访问加互斥锁(如分布式锁或本地锁),保证在缓存失效后,只有一个线程能去查询数据库并更新缓存,其余线程等待锁释放后从缓存读取数据。
优点:
- 简单易实现:逻辑清晰,只需引入锁机制即可。
- 保护数据库:有效防止多线程同时查询数据库,降低数据库压力。
- 通用性强:适用于所有数据场景,尤其是并发量高的热点数据。
缺点:
- 性能问题:
- 在高并发情况下,锁的排队等待会增加响应时间。
- 如果锁竞争激烈,会导致线程阻塞。
- 单点问题:
- 如果锁是本地实现,可能会出现分布式环境中的一致性问题。
- 使用分布式锁(如 Redis 分布式锁)会增加实现复杂度。
- 潜在死锁风险:如果锁机制设计不当,可能会导致死锁。
使用互斥锁的代码逻辑:
查询 Redis:
- 首先查询 Redis 是否存在目标数据。
- 如果命中,直接返回。
- 如果未命中(Redis 中没有目标数据),进入下一步。
-
尝试获取锁:
如果获取锁成功:
- 再次查询 Redis 是否有数据(防止其他线程已加载数据)。
- 如果 Redis 中仍然没有数据,查询数据库,加载数据到 Redis。
- 释放锁,返回数据。
如果未获取到锁:
- 等待一段时间后,重复尝试获取锁。
- 在每次尝试获取锁时,先查询 Redis 是否已存在数据,防止无意义的锁竞争。
为什么获取锁之后还需要再次检查Redis中是否存在数据?
核心原因: 避免重复查询数据库和重复写入 Redis,从而减少资源浪费。
多线程场景下的问题:
- 假设线程 A 获取到锁并完成了缓存重建(从数据库查询数据并写入 Redis)。
- 线程 B 在等待锁的过程中,其实线程 A 已经完成了数据的缓存。
- 线程 B 获取到锁时,如果不再次检查 Redis,就会重复从数据库查询并覆盖 Redis 中的已有数据,导致不必要的开销和延迟。
这个锁应该是什么样的锁?是平常的吗
使用 Redis 实现分布式锁
Redis 是实现分布式锁的常用工具。通过 SETNX
(set if not exists
)命令以及锁的过期时间,可以实现高效且可靠的分布式锁。
- 如果键不存在,
SETNX
会创建这个键并返回成功(true
)。 - 如果键已经存在,
SETNX
会直接返回失败(false
)。
/**
* 尝试获取锁
* @param key 锁的唯一标识
* @return 是否获取成功
*/
private boolean tryLock(String key) {
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(success);
}
/**
* 释放锁
* @param key 锁的唯一标识
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
@Service
@RequiredArgsConstructor
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
final StringRedisTemplate stringRedisTemplate;
/**
* 查询商铺信息,包含缓存击穿的解决方案
*/
@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;
String lockKey = LOCK_SHOP_KEY + id;
// 1. 查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)) {
return JSONUtil.toBean(shopJson, Shop.class);
}
if (shopJson != null) {
return null; // 空值
}
Shop shop = null;
boolean isLockAcquired = false;
try {
// 2. 尝试获取锁
while (!(isLockAcquired = tryLock(lockKey))) {
Thread.sleep(50); // 未获取锁,等待并重试
}
// 3. 再次检查缓存,防止重复查询数据库
String shopJsonAfterLock = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJsonAfterLock)) {
return JSONUtil.toBean(shopJsonAfterLock, Shop.class);
}
if (shopJsonAfterLock != null) {
return null; // 空值
}
// 4. 查询数据库
shop = getById(id);
if (shop == null) {
// 数据库不存在,写入空值防止缓存穿透
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 5. 写入缓存
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复线程中断状态
throw new RuntimeException("线程被中断", e);
} finally {
// 6. 释放锁
if (isLockAcquired) {
unlock(lockKey);
}
}
return shop;
}
/**
* 获取锁
*/
private boolean tryLock(String key) {
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(success);
}
/**
* 释放锁(防止误删其他线程的锁)
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
/**
* 更新商铺信息,并清除缓存
*/
@Override
@Transactional
public Result updateShop(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();
}
}
2. 逻辑过期
原理:
将缓存数据设置为逻辑过期时间,同时保留旧值。当缓存过期时,请求仍然返回旧数据,同时后台异步更新缓存(通过任务队列或线程池刷新数据)。
优点:
- 高性能:
- 由于返回旧数据,请求不会直接打到数据库,避免了数据库的压力。
- 不会阻塞用户线程,用户体验更好。
- 无锁机制:
- 通过异步任务更新缓存,避免了加锁的复杂性和性能问题。
- 支持高并发:
- 用户请求不会因缓存失效而阻塞。
缺点:
- 数据一致性问题:
- 在缓存逻辑过期的时间段内,可能返回的是旧数据,适合对一致性要求不高的场景。
- 实现复杂度高:
- 需要设计异步更新逻辑,增加系统复杂性。
- 需要引入额外的定时任务或异步线程池处理刷新逻辑。
- 资源占用:
- 异步更新的任务可能会带来额外的资源消耗,尤其是在数据量较大时。
选择建议
- 互斥锁适合对数据一致性要求高的业务场景,如订单、库存等核心数据,且并发量较低。
- 逻辑过期更适合高并发、热点数据且对数据一致性要求不高的场景,如新闻热点、排行榜等。
逻辑过期方式解决问题的逻辑:
查询缓存数据
- 通过
Redis
的key
查询数据,结果可能是:- 缓存未命中:直接返回
null
。 - 缓存命中:继续处理逻辑。
- 缓存未命中:直接返回
判断逻辑过期
- 未过期:直接返回缓存中的数据。
- 已过期:需要重建缓存。
-
重建缓存(防止缓存穿透)
获取分布式锁(互斥锁)。
- 如果获取不到锁:
- 直接返回旧的数据,避免阻塞等待。
- 如果获取到锁:
- 再次检查是否过期(双重检查锁机制),防止锁释放期间其他线程已更新数据。
- 开启一个独立线程完成缓存重建,释放锁后返回旧数据。
缓存重建逻辑
- 查询数据库获取最新数据。
- 写入Redis,并设置逻辑过期时间。
- 释放锁,确保其他线程可以继续执行。
public Shop queryWithLogicExpire(Long id){
String key = CACHE_SHOP_KEY+id;
// 1. 首先从Redis当中查询是否存在商铺
// 存储对象可以使用HashMap 也可以使用string类型
String shopStr = stringRedisTemplate.opsForValue().get(key);
// Redis中不存在该信息
if(StrUtil.isBlank(shopStr)){
return null;
}
RedisData redisData = JSONUtil.toBean(shopStr, RedisData.class);
// Shop data = (Shop) redisData.getData();
Shop data = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
// 存在 需要判断缓存是否逻辑过期
// 未过期 直接返回数据
if (redisData.getExpireTime().isAfter(LocalDateTime.now())){
return data;
}
// 过期 缓存重建
// 尝试获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
if (tryLock(lockKey)) {
//如果线程b检查完过期时间后,线程a刚好重建完成并释放锁,
// 此时线程b可以拿到锁并再次重建,所以需要进行二次校验过期时间
// 获取锁之后还需要再次检测Redis缓存是否过期 如果未过期就不需要再开启新的线程
shopStr = stringRedisTemplate.opsForValue().get(key);
RedisData redisDataAfterGetLock = JSONUtil.toBean(shopStr, RedisData.class);
if (redisDataAfterGetLock.getExpireTime().isAfter(LocalDateTime.now())) {
return JSONUtil.toBean((JSONObject) redisDataAfterGetLock.getData(),Shop.class);
}
//TODO 获取到了就开启独立的线程,
CATHE_REBUILD_EXECUTOR.submit(()->{
try {
// 重建缓存
this.saveShop2Redis(id,CACHE_SHOP_TTL);
} catch (Exception e) {
log.error("缓存重建失败", e);
}finally {
// 释放锁
unlock(lockKey);
}
});
}
// 还是返回旧的数据
return data;
为什么需要进行二次校验?
以下是可能的线程执行时间线:
- 线程A:发现缓存过期,获取锁,进入重建逻辑。
- 线程B:也发现缓存过期,但未获取到锁,进入等待状态。
- 线程A:完成缓存重建,更新Redis数据,释放锁。
- 线程B:获取到锁,继续执行(认为缓存仍然过期)。
- 如果没有二次校验,线程B会再次重建缓存。
- 如果有二次校验,线程B会发现缓存数据已经更新,不需要重复重建。
此时,如果线程A完成缓存重建并释放锁后,线程B再次获取锁,直接重建缓存,会造成:
- 重复的缓存重建(浪费资源)。
- 数据被多次写入Redis,增加Redis的负担。
二次校验通过在获取锁后,再次检查缓存数据是否过期(或已经被其他线程更新),可以避免这种重复操作。
为什么没有查到数据的时候直接返回null?
因为对于热点key来说,我们会先将数据存储到Redis当中,并且设置逻辑过期时间。如果根据key在Redis当中查询不到该数据,只能说明这个数据不在热点当中,直接返回空即可。
未命中说明:
- 该
key
不属于热点数据或从未被缓存。 - 逻辑过期方案主要针对热点数据的缓存维护,对非热点数据无需增加负担。
如何设置逻辑超时时间?
引入一个RedisData
类,包含以下字段:
expireTime
:逻辑过期时间(LocalDateTime
类型)。data
:具体的业务数据对象(如Shop
类)。
package com.hmdp.utils;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
缓存数据存储到Redis
RedisData redisData = new RedisData();
redisData.setExpireTime(LocalDateTime.now().plusSeconds(CACHE_SHOP_TTL));
redisData.setData(shop); // shop 是业务对象
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
使用JSON序列化将RedisData
对象保存到Redis中:
封装成工具类
set
:普通的缓存设置方法。
setWithLogicExpire
:设置逻辑过期缓存。
queryWithPassThrough
:解决缓存穿透问题。
queryWithLogicExpire
:解决缓存击穿问题(使用逻辑过期方案)。
需要注意两个点:
提高通用性:面向多个类封装工具包的设计原则
使用泛型:
当工具包需要支持多个类时,提高通用性是关键。可以通过以下方式实现:
1.1 使用泛型
- 为什么使用泛型:不同的调用者可能会涉及不同的数据模型和返回类型。通过泛型,可以让工具方法适配任意类型的返回值,而不需要为每个类重复编写代码。
- 如何使用泛型:
- 定义返回值类型
R
:适配不同的对象类型(如Shop
、User
等)。 - 定义主键类型
ID
:适配不同的数据主键(如Long
或String
)。
- 定义返回值类型
public <R, ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type,
Function<ID, R> dbFallback, Long time, TimeUnit unit
) {
// 逻辑与实现
}
R
代表返回的实体类类型,如Shop
、User
等。ID
代表主键的类型,如Long
(数字型 ID)或String
(UUID)。
2. 如何根据调用者需求获取数据库数据
关键点:调用者可能有不同的需求,例如:
- 查询逻辑不同(按 ID、按名称等)。
- 数据返回类型不同(单个对象、列表等)。
- 数据库表不同。
解决方案: 使用 Function<ID, R>
获取数据
Function<ID, R>
的优势
- 灵活性:调用者可以动态传递 Lambda 表达式,实现灵活的查询逻辑。
- 解耦:工具类不需要依赖具体的服务或 DAO 实现,而是将查询逻辑交给调用者。
- 简单易用:调用者可以用 Lambda 或方法引用直接定义查询逻辑。
工具类的实现
public <R, ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type,
Function<ID, R> dbFallback, Long time, TimeUnit unit
) {
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
// 判断缓存中是否有数据
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, type); // 缓存命中,返回数据
}
// 查询数据库
R result = dbFallback.apply(id);
if (result == null) {
// 防止缓存穿透,缓存空值
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 数据库有数据,写入缓存
this.set(key, result, time, unit);
return result;
}
调用示例
使用 Lambda 表达式或方法引用定义数据库查询逻辑:
// Lambda 表达式
queryWithPassThrough(
"shop:", 1L, Shop.class,
id -> shopService.findById(id),
10L, TimeUnit.MINUTES
);
// 方法引用
queryWithPassThrough(
"user:", "abc123", User.class,
userService::findById,
10L, TimeUnit.MINUTES
);
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 com.hmdp.entity.Shop;
import lombok.RequiredArgsConstructor;
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.*;
@Slf4j
@Component
@RequiredArgsConstructor
public class CacheClient {
final StringRedisTemplate stringRedisTemplate;
public void set(String key, Object value, Long time, TimeUnit unit){
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
}
public void setWithLogicExpire(String key, Object value, Long time, TimeUnit unit){
RedisData redisData = new RedisData();
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
redisData.setData(value);
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
public <R,ID> R queryWithPassThrough(String keyPrefix, ID id,
Class<R> type, Function<ID,R> dbFallback,
Long time, TimeUnit unit){
// 1. 首先从Redis当中查询是否存在商铺
// 存储对象可以使用HashMap 也可以使用string类型
String key = keyPrefix + id;
String Json = stringRedisTemplate.opsForValue().get(key);
// 存在直接返回Redis中的信息
if (StrUtil.isNotBlank(Json)) {
// 将string类型转换为对象
return JSONUtil.toBean(Json,type);
}
// 判断命中的是否是空值
if (Json != null) {
// 是空值,返回一个错误信息
return null;
}
// 不存在,则查询数据库
R r =dbFallback.apply(id);
if (r == null) {
// 数据库不存在返回404
// 要将空值写入Redis 防止缓存穿透
stringRedisTemplate.opsForValue().set(keyPrefix +id,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
// 数据库中存在商铺信息则将商铺信息存入Redis 并且设置超时时间作为兜底方案
this.set(key,r,time,unit);
// 返回商铺信息
return r;
}
// 线程池
// 不知道怎么根据id查询数据库?那么就需要面向函数式接口,让调用者传递函数
private static final ExecutorService CATHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public <R,ID> R queryWithLogicExpire(String prefixKey, String PrefixLock,ID id,
Class<R> type,Function<ID,R> dbFallback, Long time, TimeUnit unit){
String key = prefixKey+id;
// 1. 首先从Redis当中查询是否存在商铺
// 存储对象可以使用HashMap 也可以使用string类型
String json = stringRedisTemplate.opsForValue().get(key);
// Redis中不存在该信息
if(StrUtil.isBlank(json)){
return null;
}
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
// Shop data = (Shop) redisData.getData();
R data = JSONUtil.toBean((JSONObject) redisData.getData(), type);
// 存在 需要判断缓存是否逻辑过期
// 未过期 直接返回数据
if (redisData.getExpireTime().isAfter(LocalDateTime.now())){
return data;
}
// 过期 缓存重建
// 尝试获取互斥锁
String lockKey = PrefixLock + id;
if (tryLock(lockKey)) {
//如果线程b检查完过期时间后,线程a刚好重建完成并释放锁,
// 此时线程b可以拿到锁并再次重建,所以需要进行二次校验过期时间
// 获取锁之后还需要再次检测Redis缓存是否过期 如果未过期就不需要再开启新的线程
json = stringRedisTemplate.opsForValue().get(key);
RedisData redisDataAfterGetLock = JSONUtil.toBean(json, RedisData.class);
if (redisDataAfterGetLock.getExpireTime().isAfter(LocalDateTime.now())) {
return JSONUtil.toBean((JSONObject) redisDataAfterGetLock.getData(),type);
}
//TODO 获取到了就开启独立的线程,
CATHE_REBUILD_EXECUTOR.submit(()->{
try {
// 重建缓存
// 查询数据库
R r = dbFallback.apply(id);
this.setWithLogicExpire(key,r,time,unit);
} catch (Exception e) {
log.error("缓存重建失败", e);
}finally {
// 释放锁
unlock(lockKey);
}
});
}
// 还是返回旧的数据
return data;
}
/**
* 获取锁
*/
private boolean tryLock(String key) {
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(success);
}
/**
* 释放锁(防止误删其他线程的锁)
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}