文章目录
- 背景
- 代码实现
- 前置
- 实体类
- 常量类
- 工具类
- 结果返回类
- 控制层
- 互斥锁方案
- 逻辑过期方案
背景
缓存击穿也叫热点 Key 问题,就是一个被高并发访问并且缓存重建业务较复杂的 key 突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击
常见的解决方案有两种:
1.互斥锁
2.逻辑过期
互斥锁:
本质就是让所有线程在缓存未命中时,需要先获取互斥锁才能从数据库查询并重建缓存,而未获取到互斥锁的,需要不断循环查询缓存、未命中就尝试获取互斥锁的过程。因此这种方式可以让所有线程返回的数据都一定是最新的,但响应速度不高
逻辑过期:
本质就是让热点 key 在 redis 中永不过期,而通过过期字段来自行判断该 key 是否过期,如果未过期,则直接返回;如果过期,则需要获取互斥锁,并开启新线程来重建缓存,而原线程可以直接返回旧数据;如果获取互斥锁失败,就代表已有其他线程正在执行缓存重建工作,此时直接返回旧数据即可
两者的对比:
解决方案 | 优点 | 缺点 |
---|---|---|
互斥锁 | 没有额外的内存消耗 保证一致性 实现简单 | 线程需要等待,性能受影响 可能有死锁风险 |
逻辑过期 | 线程无需等待,性能较好 | 不保证一致性 有额外内存消耗 实现复杂 |
代码实现
前置
这里以根据 id 查询商品店铺为案例
实体类
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_shop")
public class Shop implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 商铺名称
*/
private String name;
/**
* 商铺类型的id
*/
private Long typeId;
/**
* 商铺图片,多个图片以','隔开
*/
private String images;
/**
* 商圈,例如陆家嘴
*/
private String area;
/**
* 地址
*/
private String address;
/**
* 经度
*/
private Double x;
/**
* 维度
*/
private Double y;
/**
* 均价,取整数
*/
private Long avgPrice;
/**
* 销量
*/
private Integer sold;
/**
* 评论数量
*/
private Integer comments;
/**
* 评分,1~5分,乘10保存,避免小数
*/
private Integer score;
/**
* 营业时间,例如 10:00-22:00
*/
private String openHours;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
@TableField(exist = false)
private Double distance;
}
常量类
public class RedisConstants {
public static final String CACHE_SHOP_KEY = "cache:shop:";
public static final String LOCK_SHOP_KEY = "lock:shop:";
public static final Long LOCK_SHOP_TTL = 10L;
public static final String EXPIRE_KEY = "expire";
}
工具类
public class ObjectMapUtils {
// 将对象转为 Map
public static Map<String, String> obj2Map(Object obj) throws IllegalAccessException {
Map<String, String> result = new HashMap<>();
Class<?> clazz = obj.getClass();
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
// 如果为 static 且 final 则跳过
if (Modifier.isStatic(field.getModifiers()) && Modifier.isFinal(field.getModifiers())) {
continue;
}
field.setAccessible(true); // 设置为可访问私有字段
Object fieldValue = field.get(obj);
if (fieldValue != null) {
result.put(field.getName(), field.get(obj).toString());
}
}
return result;
}
// 将 Map 转为对象
public static Object map2Obj(Map<Object, Object> map, Class<?> clazz) throws Exception {
Object obj = clazz.getDeclaredConstructor().newInstance();
for (Map.Entry<Object, Object> entry : map.entrySet()) {
Object fieldName = entry.getKey();
Object fieldValue = entry.getValue();
Field field = clazz.getDeclaredField(fieldName.toString());
field.setAccessible(true); // 设置为可访问私有字段
String fieldValueStr = fieldValue.toString();
// 根据字段类型进行转换
fillField(obj, field, fieldValueStr);
}
return obj;
}
// 将 Map 转为对象(含排除字段)
public static Object map2Obj(Map<Object, Object> map, Class<?> clazz, String... excludeFields) throws Exception {
Object obj = clazz.getDeclaredConstructor().newInstance();
for (Map.Entry<Object, Object> entry : map.entrySet()) {
Object fieldName = entry.getKey();
if(Arrays.asList(excludeFields).contains(fieldName)) {
continue;
}
Object fieldValue = entry.getValue();
Field field = clazz.getDeclaredField(fieldName.toString());
field.setAccessible(true); // 设置为可访问私有字段
String fieldValueStr = fieldValue.toString();
// 根据字段类型进行转换
fillField(obj, field, fieldValueStr);
}
return obj;
}
// 填充字段
private static void fillField(Object obj, Field field, String value) throws IllegalAccessException {
if (field.getType().equals(int.class) || field.getType().equals(Integer.class)) {
field.set(obj, Integer.parseInt(value));
} else if (field.getType().equals(boolean.class) || field.getType().equals(Boolean.class)) {
field.set(obj, Boolean.parseBoolean(value));
} else if (field.getType().equals(double.class) || field.getType().equals(Double.class)) {
field.set(obj, Double.parseDouble(value));
} else if (field.getType().equals(long.class) || field.getType().equals(Long.class)) {
field.set(obj, Long.parseLong(value));
} else if (field.getType().equals(String.class)) {
field.set(obj, value);
} else if(field.getType().equals(LocalDateTime.class)) {
field.set(obj, LocalDateTime.parse(value));
}
}
}
结果返回类
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
private Boolean success;
private String errorMsg;
private Object data;
private Long total;
public static Result ok(){
return new Result(true, null, null, null);
}
public static Result ok(Object data){
return new Result(true, null, data, null);
}
public static Result ok(List<?> data, Long total){
return new Result(true, null, data, total);
}
public static Result fail(String errorMsg){
return new Result(false, errorMsg, null, null);
}
}
控制层
@RestController
@RequestMapping("/shop")
public class ShopController {
@Resource
public IShopService shopService;
/**
* 根据id查询商铺信息
* @param id 商铺id
* @return 商铺详情数据
*/
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryShopById(id);
}
}
互斥锁方案
流程图为:
服务层代码:
public Result queryShopById(Long id) {
Shop shop = queryWithMutex(id);
if(shop == null) {
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}
// 互斥锁解决缓存击穿
public Shop queryWithMutex(Long id) {
String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
boolean flag = false;
try {
do {
// 从 redis 查询
Map<Object, Object> entries = redisTemplate.opsForHash().entries(shopKey);
// 缓存命中
if(!entries.isEmpty()) {
try {
// 刷新有效期
redisTemplate.expire(shopKey, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
Shop shop = (Shop) ObjectMapUtils.map2Obj(entries, Shop.class);
return shop;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// 缓存未命中,尝试获取互斥锁
flag = tryLock(id);
if(flag) { // 获取成功,进行下一步
break;
}
// 获取失败,睡眠后重试
Thread.sleep(50);
} while(true); //未获取到锁,休眠后重试
// 查询数据库
Shop shop = this.getById(id);
if(shop == null) {
// 不存在,直接返回
return null;
}
// 存在,写入 redis
try {
// 测试,延迟缓存重建过程
/*try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}*/
redisTemplate.opsForHash().putAll(shopKey, ObjectMapUtils.obj2Map(shop));
redisTemplate.expire(shopKey, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
return shop;
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
if(flag) { // 获取了锁需要释放
unlock(id);
}
}
}
测试:
这里使用 JMeter 进行测试
运行结果如下:
可以看到控制台只有一个查询数据库的请求,说明互斥锁生效了
逻辑过期方案
流程图如下:
采用逻辑过期的方式时,key 是不会过期的,而这里由于是热点 key,我们默认其是一定存在于 redis 中的(可以做缓存预热事先加入 redis),因此如果 redis 没命中,就直接返回空
服务层代码:
public Result queryShopById(Long id) {
// 逻辑过期解决缓存击穿
Shop shop = queryWithLogicalExpire(id);
if (shop == null) {
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}
// 逻辑过期解决缓存击穿
private Shop queryWithLogicalExpire(Long id) {
String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
// 从 redis 查询
Map<Object, Object> entries = redisTemplate.opsForHash().entries(shopKey);
// 缓存未命中,返回空
if(entries.isEmpty()) {
return null;
}
try {
Shop shop = (Shop) ObjectMapUtils.map2Obj(entries, Shop.class, RedisConstants.EXPIRE_KEY);
LocalDateTime expire = LocalDateTime.parse(entries.get(RedisConstants.EXPIRE_KEY).toString());
// 判断缓存是否过期
if(expire.isAfter(LocalDateTime.now())) {
// 未过期则直接返回
return shop;
}
// 过期需要先尝试获取互斥锁
if(tryLock(id)) {
// 获取成功
// 双重检验
entries = redisTemplate.opsForHash().entries(shopKey);
shop = (Shop) ObjectMapUtils.map2Obj(entries, Shop.class, RedisConstants.EXPIRE_KEY);
expire = LocalDateTime.parse(entries.get(RedisConstants.EXPIRE_KEY).toString());
if(expire.isAfter(LocalDateTime.now())) {
// 未过期则直接返回
unlock(id);
return shop;
}
// 通过线程池完成重建缓存任务
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
rebuildCache(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(id);
}
});
}
return shop;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// 尝试加锁
private boolean tryLock(Long id) {
Boolean isLocked = redisTemplate.opsForValue().setIfAbsent(RedisConstants.LOCK_SHOP_KEY + id,
"1", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
return Boolean.TRUE.equals(isLocked);
}
// 解锁
private void unlock(Long id) {
redisTemplate.delete(RedisConstants.LOCK_SHOP_KEY + id);
}
// 重建缓存
private void rebuildCache(Long id, Long expireTime) throws IllegalAccessException {
Shop shop = this.getById(id);
Map<String, String> map = ObjectMapUtils.obj2Map(shop);
// 添加逻辑过期时间
map.put(RedisConstants.EXPIRE_KEY, LocalDateTime.now().plusMinutes(expireTime).toString());
redisTemplate.opsForHash().putAll(RedisConstants.CACHE_SHOP_KEY + id, map);
}
测试:
这里先预热,将 id 为 1 的数据加入,并且让过期字段为过去的时间,即表示此数据已过期
然后将数据库中对应的 name 由 “101茶餐厅” 改为 “103茶餐厅”
然后使用 JMeter 测试
测试结果:
可以看到部分结果返回的旧数据,而部分结果返回的是新数据
且 redis 中的数据也已经更新
并且,系统中只有一条查询数据库的请求