Redis学习(hmdp-缓存优化模块)
缓存
添加redis缓存
无论有没有查询前都先查询redis
无论是redis还是数据库,查询出来的结果都需要进行判断
redis放置的都是value为jsonstr的数据
判断缓存是否命中:StrUtil.isNotBlank()、Objects.isNull()
双检
1、从Redis中查询店铺数据
2、判断缓存是否命中(不为空就是命中)
2.1 缓存命中,直接返回店铺数据
2.2 缓存未命中,从数据库中查询店铺数据 (数据库中可能也没有这个数据)
3、判断数据库是否存在店铺数据
3.1 数据库中不存在,返回失败信息
3.2 数据库中存在,写入Redis,并返回店铺数据
/**
* 根据id查询商铺数据
*
* @param id
* @return
*/
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1、从Redis中查询店铺数据
String shopJson = stringRedisTemplate.opsForValue().get(key);
Shop shop = null;
// 2、判断缓存是否命中
if(StrUtil.isNotBlank(shopJson)){
// 2.1 缓存命中,直接返回店铺数据
shop = JSONUtil.toBean(shopJson,shop.class);
return Result.ok(shop);
}
// 2.2 缓存未命中,从数据库中查询店铺数据
shop = this.getById(id);
// 3、判断数据库是否存在店铺数据
if(Objects.isNull(shop)){
// 3.1 数据库中不存在,返回失败信息
return Result.fail("店铺不存在");
}
// 3.2 数据库中存在,写入Redis,并返回店铺数据
stringRedisTemplate.opsValueFor().set(key,JSONUtil.toJsonStr(shop)); return Result.ok(shop);
}
细节
- shopJson(json)使用JSONUtil转化为shop(java对象)
- 数据库和缓存中可能都没有要查找的数据,都没有就返回失败
shoptype
/**
* 查询店铺的类型
*
* @return
*/
@Override
public Result queryTypeList() {
// 1、从Redis中查询店铺类型
String key = CACHE_SHOP_TYPE_KEY + UUID.randomUUID().toString(true);
String shopTypeJSON = stringRedisTemplate.opsForValue().get(key);
List<ShopType> typeList = null;
// 2、判断缓存是否命中
if(StrUtil.isNotBlank(typeList)){
// 2.1 缓存命中,直接返回缓存数据
return Result.ok(typeList);
}
// 2.2 缓存未命中,查询数据库
typeList = this.list(this.list(new LambdaQueryWrapper<ShopType>() .orderByAsc(ShopType::getSort));
// 3、判断数据库中是否存在该数据
if (Objects.isNull(typeList)) {
// 3.1 数据库中不存在该数据,返回失败信息
return Result.fail("店铺类型不存在");
}
// 3.2 店铺数据存在,写入Redis,并返回查询的数据
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(typeList), CACHE_SHOP_TYPE_TTL, TimeUnit.MINUTES);
return Result.ok(typeList);
}
缓存更新策略
修改ShopController中的业务逻辑,满足下面的需求:
- 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间(同上)
- 根据id修改店铺时,先修改数据库,再删除缓存
采用TTL过期+内存淘汰机制作为兜底方案,同时将缓存和数据库的操作放到同一个事务来保障操作的原子性
@Transactional
/**
* 更新商铺数据(更新时,更新数据库,删除缓存)
*
* @param shop
* @return
*/
@Transactional
@Override
public Result updateShop(Shop shop) {
// 参数校验, 略
// 1、更新数据库中的店铺数据
boolean f = this.updateById(shop);
if (!f){
// 缓存更新失败,抛出异常,事务回滚
throw new RuntimeException("数据库更新失败");
}
// 2、删除缓存
f = stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());
if (!f){
// 缓存删除失败,抛出异常,事务回滚
throw new RuntimeException("缓存删除失败");
}
return Result.ok();
}
缓存穿透
参考!
方案一:设置缓存空对象,缓解压力,一定要设置过期时间,否则数据库更新了查到的还是空值。
/**
* 根据id查询商铺数据
*
* @param id
* @return
*/
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1、从Redis中查询店铺数据
String shopJson = stringRedisTemplate.opsForValue().get(key);
Shop shop = null;
// 2、判断缓存是否命中
if(StrUtil.isNotBlank(shopJson)){
// 2.1 缓存命中,判断是不是空值
// 是不是空值,都直接返回
shop = JSONUtil.toBean(shopJson,shop.class);
return Result.ok(shop);
}
// 2.2 缓存未命中,从数据库中查询店铺数据
shop = this.getById(id);
// 3、判断数据库是否存在店铺数据
if(Objects.isNull(shop)){
// 3.1 数据库中不存在,redis写入空值,并设置过期时间
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.SECONDS);
}
// 3.2 数据库中存在,写入Redis,并返回店铺数据
stringRedisTemplate.opsValueFor().set(key,JSONUtil.toJsonStr(shop)); return Result.ok(shop);
}
方案二:布隆过滤器
/**
* 根据id查询商铺数据
* 使用布隆过滤器防止缓存穿透
*
* @param id 商铺id
* @return Result
*/
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1、通过布隆过滤器判断id是否存在
if (!bloomFilter.mightContain(id)) {
// 1.1 布隆过滤器显示id不存在,直接拒绝
return Result.fail("店铺不存在");
}
// 2、通过布隆过滤器,从Redis中查询店铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
Shop shop = null;
// 3、判断缓存是否命中
if (StrUtil.isNotBlank(shopJson)) {
// 3.1 缓存命中,直接返回店铺数据
shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 3.2 缓存未命中,判断是否是缓存的空值(之前的缓存穿透方案可以移除,因为有布隆过滤器兜底)
// 4、从数据库中查询店铺数据
shop = this.getById(id);
// 5、判断数据库中是否存在该店铺
if (Objects.isNull(shop)) {
// 5.1 数据库不存在,说明布隆过滤器出现了误判,返回错误信息
return Result.fail("店铺不存在");
}
// 5.2 数据库中存在,写入Redis缓存
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),
CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
/**
* 初始化布隆过滤器
* 这个方法应该在服务启动时调用,将所有店铺ID加载到布隆过滤器中
*/
@PostConstruct
public void initBloomFilter() {
// 创建布隆过滤器,设置预期元素数量和误判率
bloomFilter = BloomFilter.create(
Funnels.longFunnel(),
1000000, // 预期店铺数量
0.01 // 期望的误判率
);
// 查询所有店铺ID
List<Long> shopIds = this.listObjs(wrapper -> wrapper.select("id"), Object::toString);
// 将所有店铺ID添加到布隆过滤器
for (Long shopId : shopIds) {
bloomFilter.put(shopId);
}
}
缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
需要把从redis查询商铺数据单独抽出来
新建两个方法,pv操作
在缓存重建前后加锁和释放锁
判断是否获取锁后还需要判断是否需要重建缓存,因为其他线程也会重构,流程图少了这部分
/**
* 根据id查询商铺数据
*
* @param id
* @return
*/
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
// 如果命中
Result result = getShopFromCache(key);
if(Objects.nonNull(result)){
return result;
}
try{
// 缓存未命中,需要重建,判断能否能够获取互斥锁
String lockKey =
boolean isLock = tryLock();
if(!isLock){
Thread.sleep(50);
return queryById(id);
}
result = getShopFromCache(key);
// 其他线程已经重构完了
if(Objects.nonNull(result)){
return result;
}
Shop shop = this.getById(id);
if(Objects.isNull(shop)){
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.SECONDS); return Result.fail("店铺不存在");
}
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),
CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}catch(Exception e){
throw new RuntimeException("发生异常");
}finally{
unlock(key);
}
return Result.ok(shop);
}
private Result getShopFromCache(String key){
// 1、从Redis中查询店铺数据
String shopJson = stringRedisTemplate.opsForValue().get(key);
Shop shop = null;
// 2、判断缓存是否命中
if(StrUtil.isNotBlank(shopJson)){
// 2.1 缓存命中,直接返回店铺数据
shop = JSONUtil.toBean(shopJson,shop.class);
return Result.ok(shop);
}
// 2.2 缓存未命中,从数据库中查询店铺数据
shop = this.getById(id);
// 3、判断数据库是否存在店铺数据
if(Objects.isNull(shop)){
// 3.1 数据库中不存在,返回失败信息
return Result.fail("店铺不存在");
}
}
/**
* 获取锁
*
* @param key
* @return
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
// 拆箱要判空,防止NPE
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
*
* @param key
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
- 这里使用Redis中的
setnx
指令实现互斥锁,只有当值不存在时才能进行set
操作 - 锁的有效期更具体业务有关,需要灵活变动,一般锁的有效期是业务处理时长10~20倍
- 线程获取锁后,还需要查询缓存(也就是所谓的双检),这样才能够真正有效保障缓存不被击穿
@Data
public class RedisData {
/**
* 过期时间
*/
private LocalDateTime expireTime;
/**
* 缓存数据
*/
private Object data;
}
/**
* 缓存重建线程池
*/
public static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 根据id查询商铺数据
*
* @param id
* @return
*/
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1、从Redis中查询店铺数据,并判断缓存是否命中
// 1.1 缓存未命中,直接返回失败信息
// 1.2 缓存命中,将JSON字符串反序列化未对象,并判断缓存数据是否逻辑过期
// 这里需要先转成JSONObject再转成反序列化,否则可能无法正确映射Shop的字段
// 当前缓存数据未过期,直接返回
// 2、缓存数据已过期,获取互斥锁,并且重建缓存
// 获取锁成功,开启一个子线程去重建缓存
// 3、获取锁失败,再次查询缓存,判断缓存是否重建(这里双检是有必要的)
// 3.1 缓存未命中,直接返回失败信息
// 3.2 缓存命中,将JSON字符串反序列化未对象,并判断缓存数据是否逻辑过期
// 这里需要先转成JSONObject再转成反序列化,否则可能无法正确映射Shop的字段
// 当前缓存数据未过期,直接返回
// 4、返回过期数据
return Result.ok(shop);
}
/**
* 将数据保存到缓存中
*
* @param id 商铺id
* @param expireSeconds 逻辑过期时间
*/
public void saveShopToCache(Long id, Long expireSeconds) {
// 从数据库中查询店铺数据
Shop shop = this.getById(id);
// 封装逻辑过期数据
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 将逻辑过期数据存入Redis中
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
/**
* 获取锁
*
* @param key
* @return
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
// 拆箱要判空,防止NPE
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
*
* @param key
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
/**
* 缓存重建线程池
*/
public static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 根据id查询商铺数据
*
* @param id
* @return
*/
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1、从Redis中查询店铺数据,并判断缓存是否命中
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(shopJson)) {
// 1.1 缓存未命中,直接返回失败信息
return Result.fail("店铺数据不存在");
}
// 1.2 缓存命中,将JSON字符串反序列化对象,并判断缓存数据是否逻辑过期
RedisData redisData = JSONUtil.toBean(shopJson,RedisData.class);
// 这里需要先转成JSONObject再转成反序列化,否则可能无法正确映射Shop的字段
JSONObject data = (JSONObject) redisData.getData();
LocalDataTime expireTime = redisData.getExpireTime();
if(expireTime.isAfter(LocalDataTime.now())){
// 当前缓存数据未过期,直接返回
return Result.ok(shop);
}
// 2、缓存数据已过期,获取互斥锁,并且重建缓存
String lockKey = LOCK_SHOP_KEY + id;
if(isLock){
// 获取锁成功,开启一个子线程去重建缓存
CACHE_REBUILD_EXECUTOR.submit(()->{
try{
this.saveShopToCache(id,)
}finally{
unlock(lockKey);
}
});
}
// 3、获取锁失败,再次查询缓存,判断缓存是否重建(这里双检是有必要的)
shopJson = stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isBlank(shopJson)){
// 3.1 缓存未命中,直接返回失败信息
return Result.fail("");
}
// 3.2 缓存命中,将JSON字符串反序列化为对象,并判断缓存数据是否逻辑过期
redisData = JSONUtil.toBean(shopJson, RedisData.class);
// 这里需要先转成JSONObject再转成反序列化,否则可能无法正确映射Shop的字段
data = (JSONObject) redisData.getData();
shop = JSONUtil.toBean(data, Shop.class);
expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())) {
// 当前缓存数据未过期,直接返回
return Result.ok(shop);
}
// 4、返回过期数据
return Result.ok(shop);
}
/**
* 将数据保存到缓存中
*
* @param id 商铺id
* @param expireSeconds 逻辑过期时间
*/
public void saveShopToCache(Long id, Long expireSeconds) {
// 从数据库中查询店铺数据
Shop shop = this.getById(id);
// 封装逻辑过期数据
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 将逻辑过期数据存入Redis中
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
/**
* 获取锁
*
* @param key
* @return
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
// 拆箱要判空,防止NPE
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
*
* @param key
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
使用逻辑过期要先进行数据预热
@Component
@Slf4j
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 将数据加入Redis,并设置有效期
*
* @param key
* @param value
* @param timeout
* @param unit
*/
public void set(String key, Object value, Long timeout, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), timeout, unit);
}
/**
* 将数据加入Redis,并设置逻辑过期时间
*
* @param key
* @param value
* @param timeout
* @param unit
*/
public void setWithLogicalExpire(String key, Object value, Long timeout, TimeUnit unit) {
RedisData redisData = new RedisData();
redisData.setData(value);
// unit.toSeconds()是为了确保计时单位是秒
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(timeout)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), timeout, unit);
}
/**
* 根据id查询数据(处理缓存穿透)
*
* @param keyPrefix key前缀
* @param id 查询id
* @param type 查询的数据类型
* @param dbFallback 根据id查询数据的函数
* @param timeout 有效期
* @param unit 有效期的时间单位
* @param <T>
* @param <ID>
* @return
*/
public <T, ID> T handleCachePenetration(String keyPrefix, ID id, Class<T> type,
Function<ID, T> dbFallback, Long timeout, TimeUnit unit) {
String key = keyPrefix + id;
// 1、从Redis中查询店铺数据
String jsonStr = stringRedisTemplate.opsForValue().get(key);
T t = null;
// 2、判断缓存是否命中
if (StrUtil.isNotBlank(jsonStr)) {
// 2.1 缓存命中,直接返回店铺数据
t = JSONUtil.toBean(jsonStr, type);
return t;
}
// 2.2 缓存未命中,判断缓存中查询的数据是否是空字符串(isNotBlank把null和空字符串给排除了)
if (Objects.nonNull(jsonStr)) {
// 2.2.1 当前数据是空字符串(说明该数据是之前缓存的空对象),直接返回失败信息
return null;
}
// 2.2.2 当前数据是null,则从数据库中查询店铺数据
t = dbFallback.apply(id);
// 4、判断数据库是否存在店铺数据
if (Objects.isNull(t)) {
// 4.1 数据库中不存在,缓存空对象(解决缓存穿透),返回失败信息
this.set(key, "", CACHE_NULL_TTL, TimeUnit.SECONDS);
return null;
}
// 4.2 数据库中存在,重建缓存,并返回店铺数据
this.set(key, t, timeout, unit);
return t;
}
/**
* 缓存重建线程池
*/
public static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 根据id查询数据(处理缓存击穿)
*
* @param keyPrefix key前缀
* @param id 查询id
* @param type 查询的数据类型
* @param dbFallback 根据id查询数据的函数
* @param timeout 有效期
* @param unit 有效期的时间单位
* @param <T>
* @param <ID>
* @return
*/
public <T, ID> T handleCacheBreakdown(String keyPrefix, ID id, Class<T> type,
Function<ID, T> dbFallback, Long timeout, TimeUnit unit) {
String key = keyPrefix + id;
// 1、从Redis中查询店铺数据,并判断缓存是否命中
String jsonStr = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(jsonStr)) {
// 1.1 缓存未命中,直接返回失败信息
return null;
}
// 1.2 缓存命中,将JSON字符串反序列化未对象,并判断缓存数据是否逻辑过期
RedisData redisData = JSONUtil.toBean(jsonStr, RedisData.class);
// 这里需要先转成JSONObject再转成反序列化,否则可能无法正确映射Shop的字段
JSONObject data = (JSONObject) redisData.getData();
T t = JSONUtil.toBean(data, type);
LocalDateTime expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())) {
// 当前缓存数据未过期,直接返回
return t;
}
// 2、缓存数据已过期,获取互斥锁,并且重建缓存
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
if (isLock) {
// 获取锁成功,开启一个子线程去重建缓存
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
T t1 = dbFallback.apply(id);
// 将查询到的数据保存到Redis
this.setWithLogicalExpire(key, t1, timeout, unit);
} finally {
unlock(lockKey);
}
});
}
// 3、获取锁失败,再次查询缓存,判断缓存是否重建(这里双检是有必要的)
jsonStr = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(jsonStr)) {
// 3.1 缓存未命中,直接返回失败信息
return null;
}
// 3.2 缓存命中,将JSON字符串反序列化未对象,并判断缓存数据是否逻辑过期
redisData = JSONUtil.toBean(jsonStr, RedisData.class);
// 这里需要先转成JSONObject再转成反序列化,否则可能无法正确映射Shop的字段
data = (JSONObject) redisData.getData();
t = JSONUtil.toBean(data, type);
expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())) {
// 当前缓存数据未过期,直接返回
return t;
}
// 4、返回过期数据
return t;
}
/**
* 获取锁
*
* @param key
* @return
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
// 拆箱要判空,防止NPE
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
*
* @param key
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}
总结
- 从Redis中查询店铺类型
- 判断缓存是否命中
- 缓存命中,直接返回缓存数据
- 缓存未命中,查询数据库
- 判断数据库中是否存在该数据
- 数据库中不存在该数据,返回失败信息
- 店铺数据存在,写入Redis,并返回查询的数据
queryById
方法:
- 从Redis中查询店铺数据
- 判断缓存是否命中
- 缓存命中,直接返回店铺数据
- 缓存未命中,从数据库中查询店铺数据
- 判断数据库是否存在店铺数据
- 数据库中不存在,返回失败信息
- 数据库中存在,重建缓存,并返回店铺数据
updateShop
方法:
- 更新数据库中的店铺数据
- 如果数据库更新失败,抛出异常,事务回滚
- 删除缓存
- 如果缓存删除失败,抛出异常,事务回滚
- 从Redis中查询店铺数据
- 判断缓存是否命中
- 缓存命中,直接返回店铺数据
- 缓存未命中,判断缓存中查询的数据是否是空字符串(
isNotBlank
把null
和空字符串排除)
- 当前数据是空字符串
- 当前数据是空字符串(说明该数据是之前缓存的空对象),直接返回失败信息
- 当前数据是
null
- 当前数据是
null
,则从数据库中查询店铺数据
- 当前数据是
- 判断数据库是否存在店铺数据
- 数据库中不存在,缓存空对象(解决缓存穿透),返回失败信息
- 数据库中存在,重建缓存,并返回店铺数据
queryById
方法:
-
从Redis中查询店铺数据,并判断缓存是否命中
- 缓存命中,直接返回
-
缓存未命中,需要重建缓存,判断能否获取互斥锁
- 获取锁失败,已有线程在重建缓存,则休眠重试
- 获取锁成功,判断缓存是否重建,防止堆积的线程全部请求数据库
-
从数据库中查询店铺数据,并判断数据库是否存在店铺数据
- 数据库中不存在,缓存空对象(解决缓存穿透),返回失败信息
- 数据库中存在,重建缓存,响应数据
-
释放锁(释放锁一定要记得放在finally中,防止死锁)
getShopFromCache
方法: -
判断缓存是否命中
- 缓存命中,直接返回店铺数据
-
判断缓存中查询的数据是否是空字符串
- 当前数据是空字符串,说明缓存也命中了(该数据是之前缓存的空对象),直接返回失败信息
-
缓存未命中(缓存数据既没有值,又不是空字符串)
tryLock
方法: -
获取锁
unlock
方法: -
*释放锁
CACHE_REBUILD_EXECUTOR
:
- 缓存重建线程池
queryById
方法: - 从Redis中查询店铺数据,并判断缓存是否命中
- 缓存未命中,直接返回失败信息
- 缓存命中,将JSON字符串反序列化为对象,并判断缓存数据是否逻辑过期
- 缓存数据已过期,获取互斥锁,并且重建缓存
- 获取锁成功,开启一个子线程去重建缓存
- 获取锁失败,再次查询缓存,判断缓存是否重建(双检)
- 缓存未命中,直接返回失败信息
- 缓存命中,将JSON字符串反序列化为对象,并判断缓存数据是否逻辑过期
- 返回过期数据
saveShopToCache
方法: - 将数据保存到缓存中
- 从数据库中查询店铺数据
- 封装逻辑过期数据
- 将逻辑过期数据存入Redis中
tryLock
方法:
- 获取锁
unlock
方法: - 释放锁
引用
为了解决数据一致性问题,我们可以选择适当的缓存更新策略:
以缓存主动更新(双写方案+删除缓存模式+先操作数据库后操作缓存+事务)为主,超时剔除为辅
- 查询时,先查询缓存,缓存命中直接返回,缓存未命中查询数据库并重建缓存,返回查询结果
- 更新时,先修改数据删除缓存,使用事务保证缓存和数据操作两者的原子性
除了会遇到数据一致性问题意外,我们还会遇到缓存穿透、缓存雪崩、缓存击穿等问题 - 对于缓存穿透,我们采用了缓存空对象解决
- 对于缓存击穿,我们分别演示了互斥锁(setnx实现方式)和逻辑过期两种方式解决
最后我们通过抽取出一个工具类,并且利用泛型编写几个通用方法,形成最终的形式
附录
双写方案(Cache Aside Pattern):人工编码方式,缓存调用者在更新完数据库后再去更新缓存。使用困难,灵活度高。
1)读取(Read):当需要读取数据时,首先检查缓存是否存在该数据。如果缓存中存在,直接返回缓存中的数据。如果缓存中不存在,则从底层数据存储(如数据库)中获取数据,并将数据存储到缓存中,以便以后的读取操作可以更快地访问该数据。
2)写入(Write):当进行数据写入操作时,首先更新底层数据存储中的数据。然后,根据具体情况,可以选择直接更新缓存中的数据(使缓存与底层数据存储保持同步),或者是简单地将缓存中与修改数据相关的条目标记为无效状态(缓存失效),以便下一次读取时重新加载最新数据
1. 什么是数据预热?
数据预热(Cache Warming)是指在缓存失效或系统启动前,主动将高频访问的数据预先加载到缓存中的过程。它的核心目标是:
- 避免冷启动问题:当系统重启或缓存完全失效时,如果用户请求直接穿透到数据库,可能导致瞬时高负载,甚至引发宕机。
- 提升用户体验:通过预先加载数据,用户请求可以直接命中缓存,减少延迟。
- 平滑流量峰值:在业务高峰期前预热缓存,避免突发流量压垮数据库。
常见预热场景:
- 系统启动时(如每日凌晨重启服务)。
- 缓存批量失效时(如促销活动开始前)。
- 周期性热点数据更新时。
2. 什么是逻辑过期?
逻辑过期(Logical Expiration)是一种缓存管理策略,其核心思想是:
- 物理缓存不过期:缓存的键值(Key-Value)在Redis等缓存服务中永不过期(或设置较长的TTL)。
- 业务层控制有效期:在缓存值的元数据中嵌入过期时间字段,由业务代码判断数据是否逻辑失效。
示例:
{
"data": "实际数据",
"expire_time": "2023-10-01 12:00:00"
}
当业务代码读取缓存时,先检查expire_time
:
- 若未过期:直接使用缓存数据。
- 若已过期:触发异步更新(如通过消息队列重建缓存),并短暂允许旧数据继续服务。
3. 为什么逻辑过期需要数据预热?
逻辑过期机制必须依赖数据预热,原因如下:
(1) 避免缓存空洞
- 问题:如果逻辑过期后未预热,缓存中将长期存在已过期的脏数据。此时新请求可能触发异步更新,但异步更新需要时间,期间用户可能读到旧数据。
- 解决:通过预热,确保缓存中始终有逻辑未过期的数据,即使旧数据过期,也能立即用预热的新数据替换。
(2) 降低并发更新风险
- 问题:逻辑过期后,多个请求可能同时触发缓存更新,导致大量请求穿透到数据库(缓存击穿)。
- 解决:预热可以提前加载数据,减少缓存击穿的概率。
(3) 保证一致性
- 问题:逻辑过期依赖业务代码判断时间,如果缓存中完全没有数据,异步更新可能因系统延迟导致数据不一致。
- 解决:预热确保缓存中始终有基准数据,即使过期也能作为兜底。
4. 数据预热与逻辑过期的工作流程
-
预热阶段:
- 定时任务或事件触发,提前加载高频数据到缓存。
- 设置较长的TTL(如24小时),但元数据中嵌入较短的逻辑过期时间(如5分钟)。
-
逻辑过期阶段:
- 用户请求命中缓存后,校验逻辑过期时间。
- 若数据已过期:
- 触发异步更新(如发送消息到MQ)。
- 仍返回旧数据,直到异步任务完成更新。
-
缓存更新阶段:
- 异步任务从数据库读取最新数据,更新缓存。
- 更新时需保证原子性(如用Redis Lua脚本)。
5. 总结
- 数据预热是主动填充缓存的过程,解决冷启动和缓存击穿问题。
- 逻辑过期是数据不过期,由业务代码控制有效期的策略。
- 二者结合:逻辑过期需要预热来保证缓存的连续性,避免因异步更新延迟导致的数据不一致或缓存空洞。
通过这种设计,可以在高并发场景下显著提升系统的可用性和性能。