Redis 缓存实战方案
一、Redis 缓存(Cache)介绍
Redis 是一款高性能的键值对存储数据库,在缓存领域表现卓越。它支持字符串、哈希、列表、集合、有序集合等多种数据结构,能满足不同场景下的数据存储需求。其高速的读写能力得益于数据存储在内存中,相比传统的磁盘存储数据库,响应速度大幅提升。同时,Redis 具备持久化特性,可将内存中的数据定期写入磁盘,防止数据丢失。凭借这些优势,Redis 能有效减轻数据库的访问压力,显著提升系统的整体响应速度,成为众多系统架构中不可或缺的缓存组件
二、添加 Redis 缓存
(一)引入依赖
在项目中引入 Redis 相关依赖,需根据项目所使用的构建工具选择合适的依赖包。以 Maven 项目为例,引入如下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<!-- 注意选择合适的版本,建议与Spring Boot版本匹配 -->
<version>2.7.0</version>
</dependency>
选择依赖版本时,要考虑与项目中其他依赖的兼容性,避免版本冲突。
(二)配置连接信息
在项目的配置文件(如 application.properties 或 application.yml)中配置 Redis 的连接信息,包括连接地址、端口、密码以及连接池相关参数等。示例如下:
spring:
redis:
redis:
redis:
host: 127.0.0.1
port: 6379
password: 123456
lettuce:
pool:
max-active: 8 # 连接池最大连接数
max-idle: 8 # 连接池最大空闲连接数
min-idle: 2 # 连接池最小空闲连接数
max-wait: -1ms # 连接池最大阻塞等待时间,-1表示无限制
合理配置连接池参数能提高 Redis 连接的复用率,减少连接建立和关闭的开销。
(三)编写缓存操作代码
通过 Redis 客户端进行数据的缓存操作,以 Spring Data Redis 为例,可使用 RedisTemplate 或 StringRedisTemplate 来操作 Redis。
@Service
public class RedisCacheService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
// 存储数据到缓存
public void setCache(String key, String value, long timeout, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, value, timeout, unit);
}
// 从缓存获取数据
public String getCache(String key) {
return stringRedisTemplate.opsForValue().get(key);
}
// 删除缓存数据
public void deleteCache(String key) {
stringRedisTemplate.delete(key);
}
}
三、缓存更新策略
(一)查询时写入 Redis 并添加超时时间
当应用程序查询数据时,先检查 Redis 缓存中是否存在该数据。如果不存在,则从数据库中查询,查询到数据后,将数据写入 Redis 缓存,并为其设置合适的超时时间。这样做的好处是,既能保证后续查询可以直接从缓存中获取数据,提高响应速度,又能避免缓存数据长期占用内存,当数据不再被频繁访问时,会自动过期释放内存。
示例代码如下:
public String queryData(String key) {
// 先从缓存查询
String data = redisCacheService.getCache(key);
if (data != null) {
return data;
}
// 缓存不存在,从数据库查询
data = databaseQuery(key);
if (data != null) {
// 写入缓存,设置超时时间为1小时
redisCacheService.setCache(key, data, 1, TimeUnit.HOURS);
}
return data;
}
// 模拟从数据库查询
private String databaseQuery(String key) {
// 数据库查询逻辑
return "data_" + key;
}
(二)更新数据库时删除 Redis 缓存
当对数据库中的数据进行更新操作(如修改、删除)时,应同时删除 Redis 中对应的缓存数据。这是因为数据库中的数据发生了变化,若缓存中的旧数据未被删除,后续查询会获取到不一致的数据,导致数据脏读。删除缓存后,下次查询时会重新从数据库获取最新数据并写入缓存,保证数据的一致性。
示例代码如下:
public void updateData(String key, String newData) {
// 更新数据库
databaseUpdate(key, newData);
// 删除对应的缓存
redisCacheService.deleteCache(key);
}
// 模拟更新数据库
private void databaseUpdate(String key, String newData) {
// 数据库更新逻辑
}
四、重建缓存
重建缓存是指当缓存中的数据失效(如过期)、被删除或因其他原因丢失时,重新从数据库获取数据并写入缓存的过程。其核心步骤主要有以下两步:
1. 查询数据库:当检测到缓存中不存在所需数据时,通过数据库查询语句从数据库中获取最新的数据。
2. 写入缓存:将从数据库中查询到的数据写入 Redis 缓存,并根据业务需求设置合适的超时时间,以便后续查询能够直接从缓存中获取数据。
重建缓存通常发生在以下场景:缓存过期、缓存被主动删除、缓存服务重启导致数据丢失等。在重建缓存过程中,需要注意并发情况下的问题,例如多个请求同时发现缓存缺失,都去查询数据库并写入缓存,可能会导致数据库压力增大和数据不一致。可以通过加锁等方式来避免此类问题。
五、缓存穿透
(一)概念
缓存穿透指的是客户端请求的数据在缓存中不存在,并且在数据库中也不存在,导致每次请求都直接穿透缓存层,打到数据库上。正常情况下,应用程序会先检查缓存中是否存在所需数据,如果存在则直接返回,以减轻数据库的压力;若缓存中没有,再去数据库查询,查询到后将数据存入缓存以便后续使用。但缓存穿透场景下,由于数据在缓存和数据库都缺失,使得数据库不得不频繁处理这类无效请求,增加了数据库的负担,甚至可能导致数据库崩溃。
(二)解决方法
1. 缓存空值:当从数据库查询到数据不存在时,将一个空值或特殊标识写入缓存,并设置较短的超时时间。这样,后续相同的请求会从缓存中获取到空值,避免再次穿透到数据库。
示例代码如下:
public String queryDataWithPenetrationProtection(String key) {
// 先从缓存查询
String data = redisCacheService.getCache(key);
if (data != null) {
// 若缓存的值为空标识,返回空
if ("NULL_VALUE".equals(data)) {
return null;
}
return data;
}
// 缓存不存在,从数据库查询
data = databaseQuery(key);
if (data != null) {
// 写入缓存,设置超时时间为1小时
redisCacheService.setCache(key, data, 1, TimeUnit.HOURS);
} else {
// 数据库中也不存在,写入空标识,设置超时时间为5分钟
redisCacheService.setCache(key, "NULL_VALUE", 5, TimeUnit.MINUTES);
}
return data;
}
1. 布隆过滤器:布隆过滤器是一种概率型数据结构,它可以快速判断一个元素是否在一个集合中。在缓存之前设置布隆过滤器,将数据库中已存在的数据的键存入布隆过滤器。当有请求到来时,先通过布隆过滤器判断该键是否存在于数据库中,如果不存在,则直接返回空,避免穿透到数据库;如果存在,再进行后续的缓存和数据库查询操作。布隆过滤器存在一定的误判率,但可以通过合理设置参数来降低误判概率。
六、缓存雪崩
(一)概念
缓存雪崩是指在某一时间段内,大量的 Redis 缓存同时失效,或者 Redis 缓存服务发生故障,导致大量请求无法命中缓存,全部穿透到数据库,使数据库在短时间内承受巨大的访问压力,甚至可能被压垮,造成系统瘫痪。
(二)解决方法
1. 随机化过期时间:在为缓存数据设置过期时间时,不要设置固定的时间,而是在一个基础时间上加上一个随机值。这样可以避免大量缓存在同一时间点同时过期,分散缓存失效的时间,减少数据库的瞬时压力。
示例代码如下:
// 基础过期时间为1小时,随机添加0-300秒的时间
long baseTimeout = 3600;
long random = new Random().nextInt(300);
redisCacheService.setCache(key, data, baseTimeout + random, TimeUnit.SECONDS);
1. 使用缓存集群:搭建 Redis 集群,避免单一 Redis 节点故障导致整个缓存服务不可用。集群可以提供高可用性和容错能力,当某个节点出现故障时,其他节点可以继续提供服务,降低缓存雪崩的风险。
2. 服务熔断与降级:在系统中引入服务熔断和降级机制。当检测到数据库压力过大时,暂时停止部分非核心业务的请求,或者返回预设的降级数据,减轻数据库的负担,保证核心业务的正常运行。
七、缓存击穿
(一)概念
缓存击穿指的是热点数据的缓存失效瞬间,有大量的请求同时访问该数据,导致这些请求全部穿透到数据库,对数据库造成巨大压力。高并发场景下,这种情况更容易发生,比如热门商品的详情页缓存过期时,大量用户同时访问该商品。
(二)解决方法
1. 基于互斥锁解决缓存击穿
1. 原理:当缓存失效时,不是让所有请求都去查询数据库,而是通过互斥锁保证同一时间只有一个请求能够去查询数据库并重建缓存,其他请求则等待缓存重建完成后再从缓存中获取数据。
2. 实现:
1. 获取锁:使用 Redis 的 setnx(set if not exists)命令来获取锁,若获取成功,则执行查询数据库和重建缓存的操作;若获取失败,则等待一段时间后重新尝试获取锁,直到获取到锁或超时。
2. 释放锁:在完成缓存重建后,需要释放锁,让其他请求可以获取锁并获取数据。为了防止因异常导致锁无法释放而形成死锁,需要给锁设置一个有效期,同时在代码中使用 try - catch - finally 块,确保无论是否发生异常,锁都能被释放。
示例代码如下:
public String queryHotDataWithLock(String key) {
// 先从缓存查询
String data = redisCacheService.getCache(key);
if (data != null) {
return data;
}
// 缓存不存在,尝试获取锁
String lockKey = "lock_" + key;
boolean locked = false;
try {
// 获取锁,设置锁的有效期为10秒,防止死锁
locked = redisCacheService.setNx(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
// 获取锁成功,从数据库查询
data = databaseQuery(key);
if (data != null) {
// 写入缓存,设置超时时间为1小时
redisCacheService.setCache(key, data, 1, TimeUnit.HOURS);
}
return data;
} else {
// 获取锁失败,等待50毫秒后重试
Thread.sleep(50);
return queryHotDataWithLock(key);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} finally {
// 释放锁
if (locked) {
redisCacheService.deleteCache(lockKey);
}
}
}
// Redis缓存服务中添加setNx方法
public boolean setNx(String key, String value, long timeout, TimeUnit unit) {
return Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit));
}
1. 基于逻辑过期时间解决缓存击穿
2. 原理:为缓存数据设置一个逻辑过期时间(在缓存数据中包含一个过期时间字段),而不是设置 Redis 的实际过期时间(即 Redis 的 TTL 为永久)。当查询缓存时,先判断逻辑过期时间是否已到,如果未到,则直接返回数据;如果已到,则启动一个后台线程去重建缓存,当前请求仍返回旧数据。这样可以避免大量请求同时等待缓存重建,保证系统的响应速度。
3. 实现:
1. 缓存数据时,将数据和逻辑过期时间一起存入 Redis,例如使用 JSON 格式存储,包含 data 和 expireTime 字段。
2. 查询缓存时,解析缓存数据,判断逻辑过期时间。若未过期,直接返回数据;若已过期,通过线程池启动一个新线程进行缓存重建,当前请求返回旧数据。
示例代码如下:
// 定义缓存数据结构
class CacheData {
private String data;
private long expireTime; // 逻辑过期时间,毫秒级
// 构造方法、getter和setter
public CacheData(String data, long expireTime) {
this.data = data;
this.expireTime = expireTime;
}
public String getData() {
return data;
}
public long getExpireTime() {
return expireTime;
}
}
// 线程池,用于异步重建缓存
private ExecutorService executorService = Executors.newFixedThreadPool(10);
public String queryHotDataWithLogicalExpire(String key) {
// 先从缓存查询
String cacheStr = redisCacheService.getCache(key);
if (cacheStr == null) {
// 缓存不存在,可能是首次加载,从数据库查询并设置逻辑过期
return loadDataAndSetLogicalExpire(key);
}
// 解析缓存数据
CacheData cacheData = JSON.parseObject(cacheStr, CacheData.class);
long currentTime = System.currentTimeMillis();
if (currentTime < cacheData.getExpireTime()) {
// 逻辑未过期,返回数据
return cacheData.getData();
}
// 逻辑已过期,启动后台线程重建缓存,当前请求返回旧数据
executorService.submit(() -> {
loadDataAndSetLogicalExpire(key);
});
return cacheData.getData();
}
// 从数据库加载数据并设置逻辑过期
private String loadDataAndSetLogicalExpire(String key) {
String data = databaseQuery(key);
if (data != null) {
// 设置逻辑过期时间为1小时后
long expireTime = System.currentTimeMillis() + 3600 * 1000;
CacheData cacheData = new CacheData(data, expireTime);
// 写入缓存,Redis的TTL设置为永久
redisCacheService.setCache(key, JSON.toJSONString(cacheData), 0, TimeUnit.SECONDS);
}
return data;
}
八、封装 Redis 工具类
(一)工具类作用
封装 Redis 工具类可以简化缓存操作,统一处理缓存相关的异常,提供更便捷的接口供业务代码调用。工具类可以封装常用的 Redis 操作,如设置缓存、获取缓存、删除缓存、设置带过期时间的缓存、获取锁、释放锁等,避免在业务代码中重复编写相同的 Redis 操作逻辑。
(二)工具类实现
@Component
public class RedisUtils {
@Autowired
private StringRedisTemplate stringRedisTemplate;
// 设置缓存,不带过期时间
public void set(String key, String value) {
stringRedisTemplate.opsForValue().set(key, value);
}
// 设置缓存,带过期时间
public void set(String key, String value, long timeout, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, value, timeout, unit);
}
// 获取缓存
public String get(String key) {
return stringRedisTemplate.opsForValue().get(key);
}
// 删除缓存
public void delete(String key) {
stringRedisTemplate.delete(key);
}
// 设置带过期时间的锁(setNx)
public boolean setNx(String key, String value, long timeout, TimeUnit unit) {
return Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit));
}
// 其他常用操作方法,如哈希、列表等操作的封装
}
九、总结
本文详细阐述了 Redis 缓存的实战方案,包括 Redis 缓存的介绍、添加方法、缓存更新策略、重建缓存的步骤,以及缓存穿透、缓存雪崩、缓存击穿等常见问题的解决方案,并提供了相应的代码示例和工具类封装。
Redis 缓存作为提升系统性能的重要手段,在实际应用中需要根据业务场景合理设计缓存策略。添加缓存时要注意配置的正确性和合理性;缓存更新策略要保证数据的一致性;面对缓存穿透、雪崩、击穿等问题,要选择合适的解决方法,如缓存空值、布隆过滤器、随机化过期时间、互斥锁、逻辑过期时间等。
通过合理运用 Redis 缓存及相关策略,可以有效减轻数据库负担,提高系统的响应速度和稳定性,为用户提供更好的服务体验。在实际项目中,还需要根据系统的运行情况不断优化缓存策略,以适应业务的发展和变化。