使用Redis缓解数据库压力+三种常见问题
目录
使用 Redis 缓存来减缓数据库的压力是一个常见的优化手段,尤其在高并发、数据访问量大的应用场景中。通过将经常访问的数据存储在 Redis 中,可以大大减少对数据库的访问频率,提升系统的响应速度。
在我们web项目中是有多种地方可以使用缓存的:
下面带来如何使用Redis减轻数据库压力:
一.如何使用 Redis 缓存减缓数据库的压力 :
通常情况下,我们对于查询数据库的场景都是直接使用框架进行查询数据库,但是这样在浏览量很多的情况下就会造成数据库压力,所以这个时候我们可以使用Redis进行缓解数据库的压力。
方法如下:
- 当应用需要查询数据库时,首先检查 Redis 缓存中是否存在该数据。
- 如果数据存在,则直接从 Redis 返回数据(缓存命中),无需访问数据库。
- 如果数据不存在,则从数据库中查询,并将查询结果缓存到 Redis 中,以便下次访问。
我们看下面图片:
下面我们基于上述图片步骤来编写代码:
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
if(StrUtil.isNotBlank(shopJson)){
// 不为空,反序列化传回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// Redis内不存在,则查询数据库
Shop shop = getById(id);
if(shop == null){
return Result.fail("店铺不存在");
}
// 存在就写入缓存
stringRedisTemplate.opsForValue().set("cache:shop:" + id, JSONUtil.toJsonStr(shop),30L, TimeUnit.MINUTES);
return Result.ok(shop);
}
}
我们判断Redis缓存内是否有待查询信息,如果有(缓存命中)就直接在缓存中拿取,如果没有则在数据库中取出,并写入缓存以便于下一次的查询直接在Redis获取来做到减轻数据库压力。
上面的方法是读取操作,那么如何进行写入数据库操作呢?
当我们在更新数据库中的数据时,应该同时更新或删除 Redis 中的缓存,以确保缓存中的数据是最新的并且数据一致性而不出现数据不同步的情况。
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
// 缓存更新策略
@Override
@Transactional(rollbackFor = Exception.class)
public Result update(Shop shop) {
// 先更新数据库在删除缓存
if(shop.getId() == null){
return Result.fail("店铺id不能为空");
}
updateById(shop); // 更新数据库
stringRedisTemplate.delete("cache:shop:" + shop.getId()); // 删除缓存
return Result.ok();
}
}
@Transactional(rollbackFor = Exception.class)
:这表示该方法是一个事务性方法。即在方法执行过程中,如果发生任何异常(Exception
或其子类),事务将会回滚,确保数据库更新操作的原子性和一致性。这里rollbackFor = Exception.class
表示对于所有类型的异常都回滚。
先更新数据库,再删除缓存的好处是什么?为什么要这样处理?不能先删缓存后更新数据库吗?
更新数据库后再删除缓存 能确保 数据库操作先行,这意味着无论在删除缓存的过程中发生什么问题,至少数据库中的数据已经更新。如果缓存先被删除再进行数据库更新,可能会导致数据更新失败但缓存已经被删除,这样下一次访问就会直接从数据库读取数据,结果可能是错误的或者导致不一致的状态。比如,数据库更新操作由于异常回滚了,但缓存已经被删除,接下来的请求可能会读取到不一致的空数据。
先删除缓存再更新数据库,意味着 删除缓存后可能会存在一段空缓存的时间。如果这个时间较长,系统会频繁查询数据库,增加了数据库的负载。理论上,如果数据库操作失败,缓存会空,导致很多请求直接去查询数据库,造成大量数据库压力。
所以我们要保证数据库的数据始终是最新的,无论缓存删除过程中出现什么问题,数据库中的数据始终是最新的而不会造成数据不一致情况发生;同时,删除缓存后,下次请求会重新加载更新后的数据,避免缓存和数据库数据不一致。如果先删除缓存,可能导致数据不一致、系统复杂度增加,甚至可能导致缓存穿透等问题。
在上面的过程我们可以非常直观的看出缓存的好处,但是正如有好也有坏,Redis也不例外。
二.Redis缓存穿透:
问题分析:缓存穿透指的是查询一个不存在的数据,缓存中没有而数据库也没有,导致每次请求都会访问数据库,造成数据库压力增大。
解决方案:对于不存在的数据,可以在 Redis 中存储一个空值(如空对象或特殊标记),并为这些空值设置短时间的过期时间,这样后续相同的请求就可以直接返回缓存中的空值,避免多次查询数据库。
首先介绍缓存空对象方法:
我们先根据id查询缓存内的数据,如果不为空则直接反序列化返回数据,但是如果命中数据是个空值,我们就需要返回错误信息,如果Redis不存在,那么在查询数据化的结果没有值的情况需要将空值写入Redis并设置过期时间。
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
public Shop queryWithPassThrough(Long id){
String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
if(StrUtil.isNotBlank(shopJson)){
// 不为空,反序列化传回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
// 判断命中的是否为空值
if(Objects.equals(shopJson, "")){
// 返回错误信息
return null;
}
// Redis内不存在,则查询数据库
Shop shop = getById(id);
if(shop == null){ // 判断数据库内是否有值
// 将空值写入Redis
stringRedisTemplate.opsForValue().set("cache:shop:" + id, "", 30L, TimeUnit.MINUTES);
return null;
}
// 存在就写入缓存
stringRedisTemplate.opsForValue().set("cache:shop:" + id, JSONUtil.toJsonStr(shop),30L, TimeUnit.MINUTES);
return shop;
}
// 缓存穿透的解决办法:缓存空对象 / 布隆过滤
@Override
public Result queryById(Long id) {
// 缓存穿透
Shop shop = queryWithPassThrough(id);
return Result.ok(shop);
}
那么如何使用布隆过滤来解决缓存穿透问题呢?
在上面代码中,我们已经采取了缓存空对象的方式来避免缓存穿透问题。具体来说,当数据库查询不到对应的数据时,会将空对象(即
""
)存入 Redis,这样可以防止同一个无效请求在短时间内反复查询数据库。为了进一步提升缓存穿透的防范能力,我们可以结合 布隆过滤器(Bloom Filter) 来增强这一机制。布隆过滤器是一种空间效率高的概率数据结构,用于测试某个元素是否在一个集合中。它可能会有少量的误报(即判断一个元素在集合中时会误报为存在),但绝不会漏掉任何元素。
使用布隆过滤器解决缓存穿透:
布隆过滤器可以用来预先判断一个元素是否存在,在查询数据库前,通过布隆过滤器进行初步筛查,避免对数据库的无效查询。例如,对于每一个可能存在的店铺 ID,我们可以将其先加入布隆过滤器。然后在每次查询时,首先通过布隆过滤器判断该店铺是否存在,如果布隆过滤器判断不存在,那么就直接返回
null
,避免访问数据库。
在此之前,我们首先需要引入一个布隆过滤器的库,常见的布隆过滤器库有 guava
和 redisson
等:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.1-jre</version>
</dependency>
我们首先创建一个 BloomFilterConfig
配置类来初始化一个布隆过滤器。该过滤器会存储所有已查询过的店铺 ID(即存在于数据库中的 ID)。
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class BloomFilterConfig {
// 定义布隆过滤器的参数
private static final int EXPECTED_INSERTIONS = 1000000; // 预期插入元素数量
private static final double FPP = 0.01; // 允许的误判率(1%)
@Bean
public BloomFilter<Long> shopBloomFilter() {
return BloomFilter.create(Funnels.longFunnel(), EXPECTED_INSERTIONS, FPP);
}
}
在上面的
ShopServiceImpl
类中,我们通过@Resource
注解将布隆过滤器注入,并且在查询方法中使用了它。此时我们就已经可以通过布隆过滤器来判断店铺 ID 是否存在。
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
// 注入布隆过滤器
@Resource
private BloomFilter<Long> shopBloomFilter;
public Shop queryWithPassThrough(Long id) {
String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
if (StrUtil.isNotBlank(shopJson)) {
// 如果缓存命中,反序列化返回数据
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
// 判断命中的是否为空值
if (Objects.equals(shopJson, "")) {
// 返回错误信息
return null;
}
// 先通过布隆过滤器检查该店铺是否存在
if (!shopBloomFilter.mightContain(id)) {
// 如果布隆过滤器判定该店铺ID不存在,则直接返回 null,避免查询数据库
return null;
}
// Redis内不存在,则查询数据库
Shop shop = getById(id);
if (shop == null) { // 判断数据库内是否有值
// 将空值写入Redis,并加入布隆过滤器,避免下次访问
stringRedisTemplate.opsForValue().set("cache:shop:" + id, "", 30L, TimeUnit.MINUTES);
// 将店铺ID添加到布隆过滤器
shopBloomFilter.put(id);
return null;
}
// 数据存在,将结果写入缓存
stringRedisTemplate.opsForValue().set("cache:shop:" + id, JSONUtil.toJsonStr(shop), 30L, TimeUnit.MINUTES);
return shop;
}
// 缓存穿透的解决办法:缓存空对象 / 布隆过滤
@Override
public Result queryById(Long id) {
// 使用布隆过滤器进行查询
Shop shop = queryWithPassThrough(id);
return Result.ok(shop);
}
}
布隆过滤器的工作原理:
BloomFilter.create()
:用于创建布隆过滤器。你可以通过Funnels.longFunnel()
将Long
类型的 ID 插入到布隆过滤器中,EXPECTED_INSERTIONS
是你预计会插入的元素数量,FPP
(False Positive Probability)是你允许的误判率,越低的误判率意味着需要更大的内存。
shopBloomFilter.mightContain(id)
:检查布隆过滤器中是否包含店铺 ID。如果包含,继续执行数据库查询操作;如果不包含,直接返回null
。
shopBloomFilter.put(id)
:将店铺 ID 插入到布隆过滤器中,表示该店铺 ID 已经查询过。
三.缓存雪崩:
问题分析:缓存中大量数据在同一时刻过期或者缓存服务器挂掉导致无法访问缓存。当大量请求同时到达时,由于缓存失效,它们会直接查询数据库,导致数据库的压力瞬间增大,可能会导致数据库宕机或出现性能瓶颈。
解决方案:①缓存数据的过期时间加随机值 ②利用Redis集群提高服务的可用性 ③给缓存业务添加降级限流策略 ④给业务添加多级缓存高并发处理 --- Caffeine内存缓存库
为了处理 缓存雪崩,我们需要确保缓存失效时,不会造成大量的数据库查询压力。我们可以通过给每个缓存设置 不同的过期时间,同时增加 本地缓存 和 预热缓存 来防止缓存雪崩。
在上面代码中,我们已经使用了布隆过滤器和缓存空对象策略来防止 缓存穿透。现在我们需要修改代码来添加 缓存雪崩的处理,特别是为每个缓存设置 随机过期时间,并且通过引入本地缓存来减少对 Redis 的依赖。
缓存雪崩的处理策略:
- 为每个缓存设置随机过期时间:避免大量缓存同时过期,导致雪崩现象。
- 使用本地缓存作为二级缓存:减少数据库的负担,特别是 Redis 服务不可用时。
- 预热缓存:在系统启动时,预先加载常用数据到缓存中,避免缓存空缺。
下面我们指展示加入本地缓存(Cache)来缓解缓存压力:
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
// 注入布隆过滤器
@Resource
private BloomFilter<Long> shopBloomFilter;
// 本地缓存(Guava Cache)
private Cache<Long, Shop> localCache = CacheBuilder.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES) // 本地缓存的过期时间
.build();
// 随机生成缓存的过期时间,防止雪崩
private long getRandomExpireTime() {
return 10 + (long) (Math.random() * 20);
}
public Shop queryWithPassThrough(Long id) {
// 先查询本地缓存
Shop shop = localCache.getIfPresent(id);
if (shop != null) {
return shop; // 本地缓存命中
}
// 查询 Redis 缓存
String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
if (StrUtil.isNotBlank(shopJson)) {
// 缓存命中,反序列化并返回
shop = JSONUtil.toBean(shopJson, Shop.class);
// 将 Redis 缓存的结果加入本地缓存
localCache.put(id, shop);
return shop;
}
// 如果缓存中没有数据,判断是否是空对象
if (Objects.equals(shopJson, "")) {
return null; // 返回空值
}
// 使用布隆过滤器判断该店铺ID是否存在
if (!shopBloomFilter.mightContain(id)) {
return null; // 如果布隆过滤器判定该店铺不存在,则不查询数据库
}
// 从数据库查询
shop = getById(id);
if (shop == null) {
// 如果数据库也没有数据,写入空值到 Redis,并加入布隆过滤器
stringRedisTemplate.opsForValue().set("cache:shop:" + id, "", 30L, TimeUnit.MINUTES);
shopBloomFilter.put(id); // 加入布隆过滤器,防止缓存穿透
return null;
}
// 如果数据库存在数据,更新缓存并返回
stringRedisTemplate.opsForValue().set("cache:shop:" + id, JSONUtil.toJsonStr(shop), getRandomExpireTime(), TimeUnit.MINUTES);
// 更新本地缓存
localCache.put(id, shop);
return shop;
}
// 更新店铺方法(确保本地缓存与外部缓存一致)
@Override
@Transactional(rollbackFor = Exception.class)
public Result update(Shop shop) {
// 更新数据库
if(shop.getId() == null) {
return Result.fail("店铺id不能为空");
}
updateById(shop); // 更新数据库
// 删除本地缓存和Redis缓存
localCache.invalidate(shop.getId()); // 删除本地缓存
stringRedisTemplate.delete("cache:shop:" + shop.getId()); // 删除Redis缓存
// 将新的数据写入缓存
stringRedisTemplate.opsForValue().set("cache:shop:" + shop.getId(), JSONUtil.toJsonStr(shop), getRandomExpireTime(), TimeUnit.MINUTES);
localCache.put(shop.getId(), shop); // 更新本地缓存
return Result.ok();
}
@Override
public Result queryById(Long id) {
// 使用缓存穿透的策略查询店铺
Shop shop = queryWithPassThrough(id);
return Result.ok(shop);
}
}
四.缓存击穿:
问题分析:缓存击穿是指当缓存中的一个或多个被高并发访问并且缓存重建业务较为复杂的key突然失效(或未命中)时,多个请求同时访问该数据导致直接查询数据库,数据库瞬间压力剧增,系统性能急剧下降。
场景分析:当某个缓存中的数据被清空或过期(例如缓存的数据为常用数据),多个并发请求同时访问时,都会直接访问数据库,造成数据库负载过高。
解决方案:
- 互斥锁:通过分布式锁(如 Redis 的
SETNX
命令)来限制在同一时刻只有一个线程能查询数据库并更新缓存,避免并发请求造成数据库压力。- 提前加载缓存:在缓存失效时,可以提前加载缓存,避免每次都查询数据库。
- 缓存预热:在系统启动时,加载常用的数据到缓存中,以减少数据库访问。
1.使用互斥锁:
在上面代码中,如果缓存中的数据失效了,多个请求可能会同时查询数据库并更新缓存,导致数据库压力过大。为了解决这个问题,我们可以引入互斥锁,确保同一时刻只有一个请求会查询数据库并更新缓存。
使用 Redis 锁来防止缓存击穿。当某个缓存失效时,只有一个请求会查询数据库,其他请求会等待,直到该请求完成数据库查询和缓存更新。
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
if(shop == null){
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}
public Shop queryWithMutex(Long id){
String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
if(StrUtil.isNotBlank(shopJson)){
// 不为空,反序列化传回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
// 判断命中的是否为空值
if(Objects.equals(shopJson, "")){
// 返回错误信息
return null;
}
// 实现缓存重建
// 获取互斥锁
String lockKey = "lock:shop:" + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 判断锁是否获取成功
if(!isLock){
// 失败则休眠重试
Thread.sleep(50);
return queryWithMutex(id);
}
// 成功获取锁后根据id查询数据库
shop = getById(id);
// 模拟重建的演示
Thread.sleep(200);
if(shop == null){ // 判断数据库内是否有值
// 将空值写入Redis
stringRedisTemplate.opsForValue().set("cache:shop:" + id, "", 30L, TimeUnit.MINUTES);
return null;
}
// 存在就写入缓存
stringRedisTemplate.opsForValue().set("cache:shop:" + id, JSONUtil.toJsonStr(shop),30L, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 释放互斥锁
unLock(lockKey);
}
return shop;
}
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "", 10L, TimeUnit.MINUTES);
return BooleanUtil.isTrue(flag);
}
private void unLock(String key){
stringRedisTemplate.delete(key);
}
}
2.使用逻辑过期:
逻辑过期的核心思路是:缓存的过期时间不是物理时间的直接过期,而是通过设置一个“逻辑过期时间”来控制缓存的数据是否过期。在本段代码中,缓存的数据有一个逻辑过期时间expireTime
,如果当前时间已经超过了该时间,认为缓存已经过期,但数据本身并没有立即失效。
逻辑过期的核心思想:缓存数据即使已经过期但是仍然存在,并不会立即被删除,而是依赖一个 "逻辑过期时间" 来判断是否重新加载缓存。在我们下面代码中,expireTime
表示缓存数据的逻辑过期时间。
在这里我们先创建一个RedisData类去封装存入Redis的信息:
- expireTime:封装过期时间(以便于判断是否过期)
- data:存入缓存的数据
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class RedisData {
private LocalDateTime expireTime; // 过期时间
private Object data;
}
通过 逻辑过期 和 分布式锁,下面代码解决了这个问题:
- 逻辑过期:缓存数据仍然存在,即使它已经过期,其他请求仍然可以获取到已过期的数据,这样就避免了大量请求都打到数据库。即使缓存数据“过期”,它仍然可以短时间内使用。
- 分布式锁:通过分布式锁,确保只有一个请求会去数据库查询数据并更新缓存。其他请求会在缓存重建期间依然使用过期数据,不会同时访问数据库。这样就避免了多次请求同时去数据库查询,造成数据库的压力过大。
热点 key 的问题通常是因为缓存的失效导致大量并发请求打到数据库,但通过逻辑过期和分布式锁的配合使用,可以避免这个问题:
- 只有一个请求会去重建缓存,其他请求在缓存重建期间依然能够使用过期的缓存。
- 在重建缓存期间,避免了大量并发请求同时访问数据库。
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
// 创建一个固定大小的线程池,用于异步重建缓存,最大线程数为10
private static final ExecutorService CACHE_REEBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 使用逻辑过期的方式查询店铺,避免缓存击穿
Shop shop = queryWithLogicalExpire(id);
// 如果店铺信息为null,表示未找到或店铺不存在
if(shop == null){
return Result.fail("店铺不存在");
}
return Result.ok(shop); // 返回店铺信息
}
// 尝试获取分布式锁,防止并发重建缓存
private boolean tryLock(String key){
// 设置Redis中的lockKey并设置锁的过期时间为10分钟
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "", 10L, TimeUnit.MINUTES);
// 如果flag为true,表示获取锁成功,返回true
return BooleanUtil.isTrue(flag);
}
// 释放分布式锁
private void unLock(String key){
// 删除Redis中的锁,释放资源
stringRedisTemplate.delete(key);
}
// 将店铺数据保存到Redis中,设置逻辑过期时间
private void saveShop2Redis(Long id, Long expireSeconds){
// 查询店铺数据
Shop shop = getById(id);
// 封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop); // 存储店铺数据
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds)); // 设置过期时间
// 将封装好的数据保存到Redis中
stringRedisTemplate.opsForValue().set("cache:shop:" + id, JSONUtil.toJsonStr(redisData));
}
// 使用逻辑过期解决缓存击穿问题
public Shop queryWithLogicalExpire(Long id){
// 从Redis缓存中查询店铺数据
String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
// 判断缓存中是否存在数据
if(StrUtil.isBlank(shopJson)){
// 如果缓存为空,则代表没有缓存数据,直接返回null
return null;
}
// 将从Redis获取到的JSON字符串转换为RedisData对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
// 获取存储的数据部分(店铺信息)
JSONObject data = (JSONObject) redisData.getData();
// 将数据部分转换为Shop对象
Shop shop = JSONUtil.toBean(data, Shop.class);
// 获取缓存的逻辑过期时间
LocalDateTime expireTime = redisData.getExpireTime();
// 判断逻辑过期时间是否已过
if(expireTime.isAfter(LocalDateTime.now())){
// 如果未过期,则直接返回缓存的店铺信息
return shop;
}
// 如果缓存已过期,尝试获取分布式锁来防止多个请求同时去查询数据库并重建缓存
String lockKey = "lock:shop:" + id;
boolean flag = tryLock(lockKey); // 尝试获取锁
if(flag){
// 如果成功获取锁,启动独立线程来重建缓存
CACHE_REEBUILD_EXECUTOR.submit(() -> {
try {
// 异步执行缓存重建
this.saveShop2Redis(id, 30L); // 重新保存缓存,设置过期时间为30秒
} catch (Exception e) {
// 异常处理
throw new RuntimeException(e);
} finally {
// 重建缓存完成后,释放锁
unLock(lockKey);
}
});
}
// 返回店铺信息,不管缓存是否过期,都可以直接返回缓存的店铺数据(避免缓存击穿)
return shop;
}
}
原文地址:https://blog.csdn.net/2302_79840586/article/details/145301144
免责声明:本站文章内容转载自网络资源,如侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!