在Spring Boot项目中解决缓存击穿问题,可以通过以下步骤结合Redis实现:
1. 缓存击穿问题概述
- 定义:某个热点数据过期时,大量并发请求直接穿透缓存,瞬间访问数据库,导致数据库压力剧增。
- 场景:高并发请求的热点数据(如热门商品、头条新闻)缓存失效时。
2. 解决方案及实现步骤
方案一:互斥锁(分布式锁)
核心思想:只允许一个线程重建缓存,其他线程等待后重试。也就是说在查询的时候没有在Redis里面找到那么需要在数据库里面进行一个查询然后添加到Redis里面,但是如果一个相同的一个信息进行查询那么会增加压力,那么需要给到一个互斥锁然后进行一个上锁减缓一下压力。
实现步骤:
-
添加Redis依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
-
配置Redis连接(
application.yml
):spring: redis: host: localhost port: 6379
-
获取锁的工具方法:
package com.hmdp.utils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
public class RedisMutex {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 尝试获取分布式锁
*
* @param lockKey 锁的键
* @param expireTime 过期时间(单位:分钟)
* @return 是否成功获取锁
*/
public boolean tryLock(String lockKey, long expireTime) {
Boolean success = redisTemplate.opsForValue().setIfAbsent(
lockKey,
"locked", // 固定值,表示已加锁
expireTime,
TimeUnit.MINUTES
);
return Boolean.TRUE.equals(success);
}
/**
* 释放分布式锁
*
* @param lockKey 锁的键
*/
public void releaseLock(String lockKey) {
Boolean result = redisTemplate.delete(lockKey);
}
}
- 业务逻辑中使用锁:
package com.hmdp.service.impl;
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 com.hmdp.utils.EntityUtils;
import com.hmdp.utils.RedisMutex;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
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;
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Autowired
private RedisTemplate<String,Object> redisTemplate;
@Autowired
private RedisMutex redisMutex;
// 查询商铺信息
@Override
public Result queryById(Long id) {
if (id == null){
return Result.fail("店铺id不能为空");
}
// 1.从redis缓存中查询店铺信息
String shopKey = CACHE_SHOP_KEY + id;
Shop shop = (Shop) redisTemplate.opsForValue().get(shopKey); // 可能出现LinkHashMap的问题,可以编写一个工具类来获取数据
// 2.判断shop对象是否存在并且里面的属性值都不为空
if (shop != null && !EntityUtils.isAllFieldsEmpty(shop)){
// 3.如果存在,直接返回
return Result.ok(shop);
}
String lockKey = "lock:shop:" + id;
if (redisMutex.tryLock(lockKey, 10)){
try {
// 再次检查
shop = (Shop) redisTemplate.opsForValue().get(shopKey);
// 2.判断shop对象是否存在并且里面的属性值都不为空
if (shop != null && !EntityUtils.isAllFieldsEmpty(shop)){
// 3.如果存在,直接返回
return Result.ok(shop);
}
// 4.如果shop对象不存在或者是属性值全部是空,则从数据库中查询
Shop shop1 = this.getById(id);
if (shop1 == null){
// 防止缓存穿透,缓存空对象并设置短过期时间
redisTemplate.opsForValue().set(shopKey, new Shop(), 2, TimeUnit.MINUTES);
return Result.fail("店铺不存在");
}
// 写入缓存并设置随机过期时间,防止雪崩
redisTemplate.opsForValue().set(shopKey, shop1, 30, TimeUnit.MINUTES);
return Result.ok(shop1);
} finally {
// 释放锁
redisMutex.releaseLock(lockKey);
}
}else {
// 获取锁失败,等待并重试
try {
Thread.sleep(50);
return queryById(id);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return Result.fail("获取锁失败");
}
}
}
方案二:逻辑过期(永不过期 + 异步刷新)
核心思想:缓存不设物理过期时间,但在数据中存储逻辑过期时间,由后台任务或访问时异步更新。在互斥锁的业务代码差别就是通过判断这个逻辑过期时间是否超时如果超时需要单独开一个异步的线程去更新这个逻辑过期时间,需要返回就行。
实现步骤:
-
定义缓存对象:
@Data public class CacheWrapper { private Object data; private LocalDateTime expireTime; // 逻辑过期时间 }
-
写入缓存时设置逻辑过期时间:
public void setData(String key, Object data) { CacheWrapper wrapper = new CacheWrapper(); wrapper.setData(data); wrapper.setExpireTime(LocalDateTime.now().plusMinutes(30)); // 30分钟后逻辑过期 redisTemplate.opsForValue().set(key, wrapper); }
-
获取数据时检查逻辑过期:
public Object getData(String key) { CacheWrapper wrapper = (CacheWrapper) redisTemplate.opsForValue().get(key); if (wrapper == null) { return loadDataFromDbAndSetCache(key); // 缓存未命中,直接加载 } if (LocalDateTime.now().isAfter(wrapper.getExpireTime())) { // 触发异步更新 executor.execute(() -> { if (tryLock(key)) { // 使用锁防止并发更新 try { // 重新加载数据并更新缓存 Object newData = database.query(key); setData(key, newData); } finally { releaseLock(key); } } }); } return wrapper.getData(); }
方案三:缓存预热
核心思想:系统启动或数据变更时,主动加载热点数据到缓存。
实现步骤:
-
使用
@PostConstruct
初始化数据:@PostConstruct public void initHotData() { List<String> hotKeys = getHotKeysFromConfig(); hotKeys.forEach(key -> { Object data = database.query(key); redisTemplate.opsForValue().set(key, data); }); }
-
定时刷新缓存:
@Scheduled(fixedRate = 60 * 60 * 1000) // 每小时刷新一次 public void refreshCache() { List<String> hotKeys = getHotKeysFromConfig(); hotKeys.forEach(key -> { Object data = database.query(key); redisTemplate.opsForValue().set(key, data); }); }
3. 注意事项
- 锁超时时间:设置合理的锁超时,避免死锁或过早释放。
- 双重检查:获取锁后再次检查缓存,防止重复更新。
- 降级策略:在数据库访问失败时返回旧数据或默认值。
- 压测验证:使用JMeter模拟高并发场景,验证解决方案有效性。
总结
- 互斥锁:适用于严格的并发控制,但可能增加延迟。
- 逻辑过期:性能更优,但实现复杂度较高。
- 缓存预热:适合已知热点数据,需结合业务场景。