Redis-黑马点评项目-04-使用逻辑过期来解决缓存击穿问题

1.使用逻辑过期首先要向redis中存放热键

测试代码:

@SpringBootTest
@RunWith(SpringRunner.class)
public class HmDianPingApplicationTests {

    @Resource
    private IShopService shopService; // 用于查询数据库中真实数据


    @Resource
    private StringRedisTemplate stringRedisTemplate;


    @Test
    public void testSetHotShopToCache() {
        // 1. 模拟热点数据:查询数据库中的店铺 id=1
        Long shopId = 1L;
        Shop shop = shopService.getById(shopId);

        if (shop == null) {
            System.out.println("数据库中不存在该店铺!");
            return;
        }



        // 封装数据和逻辑过期时间
        RedisData<Shop> redisData = new RedisData<>();
        redisData.setData(shop);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(10));

        // 序列化后写入 Redis
        String json = JSONUtil.toJsonStr(redisData);
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + shopId, json);
        System.out.println("店铺ID=" + shopId + " 已预热至缓存!");
    }
}

报错如下:
import org.springframework.data.redis.connection.zset.Tuple; 无法解析符号 ‘zset’
原因是当时用redisson版本过高导致的,我的springboot版本是2.3.x,太低了,而redisson版本3.41.0太高了,回退版本到3.17.7。
在这里插入图片描述

  <!--布隆过滤器-->
        <!-- https://mvnrepository.com/artifact/org.redisson/redisson-spring-boot-starter -->
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.17.7</version>
        </dependency>

热键就存入redis中了
在这里插入图片描述

2.逻辑过期工具类编写

package com.hmdp.utils;

import cn.hutool.core.lang.TypeReference;

import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;

/**
 * 缓存工具类:基于逻辑过期策略,防止缓存击穿
 */
@Component
public class LogicalExpirationCacheClient {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    // 缓存重建线程池(用于异步更新缓存)
    private static final ExecutorService CACHE_REBUILD_EXECUTOR =
            Executors.newFixedThreadPool(10);

    /**
     * 将数据写入 Redis,并封装逻辑过期时间
     *
     * @param key   Redis 缓存键
     * @param value 要缓存的数据(数据库查到的)
     * @param time  逻辑过期时间
     * @param unit  时间单位
     * @param <T>   泛型类型
     */
    public <T> void setWithLogicalExpire(String key, T value, Long time, TimeUnit unit) {
        // 封装数据和逻辑过期时间
        RedisData<T> redisData = new RedisData<>();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));

        // 序列化后写入 Redis
        String json = JSONUtil.toJsonStr(redisData);
        stringRedisTemplate.opsForValue().set(key, json);
    }

    /**
     * 尝试获取互斥锁(解决缓存击穿问题)
     *
     * @param key 锁的 key
     * @return 是否成功获取锁
     */
    public boolean tryLock(String key) {
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

    /**
     * 释放互斥锁
     *
     * @param key 锁的 key
     */
    public void unlock(String key) {
        stringRedisTemplate.delete(key);
    }

    /**
     * 查询逻辑过期缓存:缓存未过期则返回,过期则异步重建缓存
     *
     * @param keyPrefix  缓存前缀(如 "shop:")
     * @param id         业务 ID(如店铺 ID)
     * @param dbFallback 数据库查询函数(lambda 形式)
     * @param time       逻辑过期时长
     * @param unit       时间单位
     * @param <T>        数据类型
     * @return 缓存数据
     */
    public <T> T queryWithLogicalExpire(
            String keyPrefix,
            Long id,
            Function<Long, T> dbFallback,
            Class<T> type,
            Long time,
            TimeUnit unit
    ) {
        String key = keyPrefix + id;

        // 1. 查询 Redis 缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isBlank(json)) {
            // 缓存未命中
            return null;
        }

        // 2. 反序列化为带逻辑过期的数据结构
        RedisData<?> redisData = JSONUtil.toBean(json, RedisData.class);
        JSONObject dataJson = (JSONObject) redisData.getData(); // 先转 JSONObject

        T data = dataJson.toBean(type);
        LocalDateTime expireTime = redisData.getExpireTime();

        // 3. 未过期,直接返回数据
        if (expireTime.isAfter(LocalDateTime.now())) {
            return data;
        }

        // 4. 已过期,尝试获取互斥锁,准备重建缓存
        String lockKey = "lock:cache:shop" + key;
        if (tryLock(lockKey)) {
            try {
                // 5. 再次检查缓存是否已被其他线程更新(双重检查)
                String latestJson = stringRedisTemplate.opsForValue().get(key);
                if (StrUtil.isBlank(latestJson)) {
                    return data; // 缓存可能被删除,先返回旧数据
                }

                // 2. 反序列化为带逻辑过期的数据结构
                RedisData<?> redisLatestData = JSONUtil.toBean(latestJson, RedisData.class);
                JSONObject dataLatestJson = (JSONObject) redisLatestData.getData(); // 先转 JSONObject

                T latestData = dataLatestJson.toBean(type);

                if (redisLatestData.getExpireTime().isAfter(LocalDateTime.now())) {
                    return latestData; // 已被其他线程更新,直接返回新数据
                }

                // 6. 异步线程池重建缓存
                CACHE_REBUILD_EXECUTOR.submit(() -> {
                    try {
                        // 查询数据库
                        T fresh = dbFallback.apply(id);
                        // 模拟重建缓存
                        Thread.sleep(200);
                        // 重新写入缓存(逻辑过期)
                        this.setWithLogicalExpire(key, fresh, time, unit);
                    } catch (Exception e) {
                        e.printStackTrace();
                    } finally {
                        // 释放锁
                        unlock(lockKey);
                    }
                });

            } catch (Exception e) {
                // 出现异常也确保释放锁
                unlock(lockKey);
            }
        }

        // 7. 返回旧数据(即使已过期也返回,不让请求失败)
        return data;
    }

}

3.ShopService中调用

    @Override
    public Result queryById2(Long id) {

        // 调用封装好的逻辑过期缓存查询
        Shop shop = logicalExpirationCacheClient.queryWithLogicalExpire(
                CACHE_SHOP_KEY,      // key 前缀
                id,                  // 数据 ID
                this::getById,// 数据库回查函数
                Shop.class,
                10L,                 // 逻辑过期时间
                TimeUnit.SECONDS     // 时间单位
        );

        // 判空处理
        if (shop == null) {
            return Result.fail("店铺不存在");
        }

        return Result.ok(shop);
    }

4.Jmeter测试

在这里插入图片描述
在高并发环境下,只查了一次数据库,而且我们故意修改的是,数据库和redis数据不一样,这样过期了,就会有一个线程去修改,也只能由一个修改,谁拿到了锁就修改呗,也就是查数据库,写回到redis,其他线程,在修改之前查询返回的是旧数据,可能会出现短时间数据不一致问题。但是我们无法避免。

5.细节(两次检查)

第一个就是:两次判断,这个缓存key是否存在以及是否未到期,以下代码是第二次检查。

  // 5. 再次检查缓存是否已被其他线程更新(双重检查)
                String latestJson = stringRedisTemplate.opsForValue().get(key);
                if (StrUtil.isBlank(latestJson)) {
                    return data; // 缓存可能被删除,先返回旧数据
                }
      // 2. 反序列化为带逻辑过期的数据结构
                RedisData<?> redisLatestData = JSONUtil.toBean(latestJson, RedisData.class);
                JSONObject dataLatestJson = (JSONObject) redisLatestData.getData(); // 先转 JSONObject

                T latestData = dataLatestJson.toBean(type);

                if (redisLatestData.getExpireTime().isAfter(LocalDateTime.now())) {
                    return latestData; // 已被其他线程更新,直接返回新数据
                }

以防有一个线程完成缓存重建,刚刚释放完lock,另外一个线程拿到lock,再判断一下是否完成有没有这个key,若有,就判断是否到期,没到期就会直接返回,2次返回前都是先执行的finally,保证锁被释放。

### 黑马点评项目解决缓存击穿 #### 逻辑过期方案实现 为了防止缓存击穿,在黑马点评项目中可以引入逻辑过期机制。当设置缓存时,不仅存储实际的数据,还附加一个自定义的有效时间戳。即使物理上的缓存未到期,程序也会检查该时间戳来判断数据是否已过期。 ```java // 设置带有逻辑过期时间的缓存条目 public void setCacheWithLogicalExpire(String key, Object value, Duration duration) { long expireTime = System.currentTimeMillis() + duration.toMillis(); // 将value和expireTime一起作为新对象保存到RedisredisTemplate.opsForValue().set(key, new CacheObject(value, expireTime)); } ``` 对于读取操作,则需先获取并解析这个组合后的值: ```java // 获取带逻辑过期验证的缓存条目 public Optional<Object> getCacheWithCheckExpired(String key) { CacheObject cacheObj = (CacheObject)redisTemplate.opsForValue().get(key); if(cacheObj != null && cacheObj.getExpireTime() > System.currentTimeMillis()){ return Optional.ofNullable(cacheObj.getValue()); }else{ // 如果已经过期则返回空以便重新加载最新数据 return Optional.empty(); } } ``` 此方法有效解决了因大量并发请求导致的实际过期时间和预期不符的情况[^2]。 #### 缓存预热策略实施 为了避免冷启动带来的性能瓶颈以及可能引发的缓存穿透风险,可以在系统上线前预先填充好常用的关键字及其对应的缓存内容。这一步骤通常称为“缓存预热”。 具体做法如下: 1. **静态资源提前加载**:针对那些变动频率较低但访问量较大的页面元素(如首页轮播图),可在部署阶段即完成初始化; 2. **热门话题/帖子定时刷新**:对于动态变化的内容板块,设定固定周期自动拉取最新的Top N列表,并将其写入缓存; 3. **后台批量处理任务**:开发专门的任务调度器负责定期执行全站范围内的缓存同步工作,确保重要节点始终处于最佳响应状态。 通过上述措施能够显著降低首次查询延迟,提高用户体验满意度的同时减轻服务器压力[^4]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值