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,保证锁被释放。