Redis的缓存实战方案

发布于:2025-09-14 ⋅ 阅读:(19) ⋅ 点赞:(0)

什么是缓存

简单来说,缓存就是数据交换的缓存区(Cache),是存贮数据的临时地方,一般都是基于内存进行存储,所以读写性能一般会比较高。

缓存看起来视乎很好,但是它也有一些问题. 如下图:缓存可能会出现数据一致性问题、代码维护成本高、运维成本高等等问题。那我们肯定不会因为这些缺点就不去使用它吧,毕竟带来的效果还是非常好的。可以降低后端负载减小数据库压力,同时提高读写效率降低响应时间,带给用户非常好的体验。

在这里插入图片描述

添加Redis缓存

如何使用Redis缓存,这里就不去说明啦,具体安装可以去看我的另一篇文章~(里面有详细的安装教程).

在Java程序中使用的话,我们只需要导入对于的依赖、配置相关连接信息即可。

下面主要介绍如何在业务中添加Redis缓存

简单流程:客户端发送请求 —> Redis缓存中查找数据 —>命中缓存则直接返回数据

—>未命中缓存则去数据库中查找数据返回,并把查找到的数据保存到缓存中。如下图

在这里插入图片描述

下面贴一张查询商铺的方法,实现了如上的具体流程。

在这里插入图片描述

缓存更新策略

缓存的更新策略主要有三种:

1)内存淘汰:不用自己维护,利用Redis的内存淘汰机制,当内存不足时自动淘汰部分数据下次查询时更新缓存。

2)超时剔除:给缓存数据添加TTL时间,到期后自动删除缓存,下次查询时更新缓存。这种数据一致性一般,维护成本低,只需要缓存建立完之后设置expire即可。

3)主动更新:编写业务逻辑,当我们修改数据库的同时去更新缓存,这种方法数据一致性最好,同时维护成本也是最高的。

不同业务场景的使用

  • 低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存(这种一般不太会改变)
  • 高一致性需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存

所以,我们需要还是需要根据不同的业务场景去选择不同的缓存更新策略,这是最优的。

因为其余两种使用起来比较简单,所以下面主要说明一下缓存的主动更新策略

在这里插入图片描述

如上图,缓存的主动更新策略其实也有三种方式。

1)Cache Aside Pattern:这种是比较常见的,就是当我们在进行增删改操作数据库的时候,同时把缓存的数据进行修改/删除。

2)依赖某种模式读写:这种的话,需要统一去维护一个服务

3)Write Behind Caching Pattern:操作者只去操作缓存,这种方法是很容易出问题的,而且实现起来也比较复杂,维护成本较高。

所以第一种方式Cache Aside Pattern这种方式目前来说是最好的,当然这个要看其他人如何使用,尽量和团队成员保持一致。

既然我们采用主动更新缓存的策略去操作,并且在我们更新数据库的同时更新缓存。那么就会出现以下问题需要去考虑。

1)删除缓存还是去更新缓存呢?

  • 更新缓存:每次更新数据库都去更新缓存,如果一个业务操作对数据的增删改是比较多的话,那么就会造成大量无效的写操作。
  • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存

2)如何保证缓存和数据库的操作同时成功或失败?

  • 单体系统,将缓存与数据库操作放在一个事务
  • 分布式系统,利用TCC等分布式事务方案

3)先操作数据库还是先操作缓存?

无论是先操作数据库,还是先操作缓存都会出现数据一致性问题。但是相比之下,先操作数据库发生异常的概率比较低,所以一般都是先去操作数据库再删除缓存

在这里插入图片描述

如下图,为总结

在这里插入图片描述

缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

主要防止有人多线程、并发的请求一个不存在的数据这些所有请求都会到数据库 增加数据库压力,使系统瘫痪。

解决这种问题主要有两种方案:

1)缓存空对象

实现简单,维护方便。但是会占用额外的内存空间,可能会出现短期的数据不一致问题。就比如:我们数据库中突然新增了一个数据,这时候客户端请求,命中了缓存得到了null值。

2)布隆过滤

布隆过滤器是一种空间效率很高的概率型数据结构,用于判断一个元素“可能存在于集合中”或“一定不存在于集合中”。它通过多个哈希函数将元素映射到位数组中的多个位置,并将这些位置置为 1。查询时,若所有对应位均为 1,则认为元素可能存在(有误判可能);若任意一位为 0,则元素一定不存在。

由于多个不同元素可能共同设置相同的位,因此存在“假阳性”(False Positive),但不会有“假阴性”。布隆过滤器常用于缓存穿透防护,在访问数据库前快速过滤掉大量不存在的请求。

Redis 本身不原生支持布隆过滤器,但可通过 RedisBloom 模块实现。布隆过滤器可以部署在客户端、Redis 服务端或中间层,具体取决于系统架构。

布隆过滤器的核心是用空间换时间 + 接受可控误判率

在这里插入图片描述

下面是某个业务具体的代码实现,可以参考:

    /**
     * 解决缓存穿透(设置null值)
     * @param id 
     * @return 店铺数据
     */
    private Shop queryWithPassThrough(Long id){
        Shop shop = new Shop();
        String key = RedisConstants.CACHE_SHOP_KEY + id;
        //1.先去Redis缓存中查找数据
        String shopCacheData = stringRedisTemplate.opsForValue().get(key);
        //2.判断缓存是否命中
        if(shopCacheData != null) {
            if(StrUtil.isNotBlank(shopCacheData)) {
                //3.查找到店铺数据 反序列化直接返回
                return JSON.parseObject(shopCacheData, Shop.class);
            } else {
                // 返回空值缓存
                return null;
            }
        }
        //5.去数据库中查询店铺数据
        shop = shopMapper.getById(id);
        //6.数据库中不存在该数据
        if(shop == null){
            //在redis中设置空值 防止缓存穿透 + TTL过期时间 超时剔除
            stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
            return null;
        }
        //7.将数据库中查询到的数据保存到Redis中
        stringRedisTemplate.opsForValue().set(key,JSON.toJSONString(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);

        //8.shop信息
        return shop;
    }

缓存雪崩

在这里插入图片描述

缓存击穿

在这里插入图片描述

互斥锁

查询缓存未命中时,给当前线程加互斥锁,如果未获取到互斥锁,则休眠重试,重新尝试获取互斥锁。获取到互斥锁的线程,则会查询数据库中的数据,然后进行缓存重建(这个地方的缓存Key可能比较复杂 重建需要一定的时间) ,重建完后则释放锁。其他线程便会查询到缓存继续执行。

这种方法可以保证数据的一致性,但是会浪费一定的时间,适用于对数据一致性要求较高的业务。

逻辑过期

逻辑过期的过程比较复杂。当我们查询缓存时,如果发现缓存未过期则直接返回即可,若缓存已经过期,则我们会去获取互斥锁,如果未能获取互斥锁那么会返回旧数据,获取到互斥锁后,会单独开一个线程去查询数据库并且重建缓存数据,然后释放锁。其他线程,这个时候如果获取到互斥锁失败,则同样返回旧数据。

这种方法没有保证数据的一致性,会出现短暂的不一致性,但是速度较快,对用户体验感较好

在这里插入图片描述

实现模拟互斥锁代码:

在这里插入图片描述

    /**
     * 解决缓存击穿问题(Key)(模拟互斥锁)
     * @param id
     * @return
     */
    private Shop queryWithMutex(Long id) {
        Shop shop = new Shop();
        String key = RedisConstants.CACHE_SHOP_KEY + id;
        //1.先去Redis缓存中查找数据
        String shopCacheData = stringRedisTemplate.opsForValue().get(key);
        //2.判断缓存是否命中
        if(shopCacheData != null) {
            if(StrUtil.isNotBlank(shopCacheData)) {
                //3.查找到店铺数据 反序列化直接返回
                return JSON.parseObject(shopCacheData, Shop.class);
            } else {
                // 返回空值缓存
                return null;
            }
        }
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        try {
            //尝试获取互斥锁
            Boolean isLock = tryLock(lockKey);
            if(!isLock){
               //未获取到互斥锁
               Thread.sleep(50);
               return queryWithMutex(id);
            }
            //获取到互斥锁之后 二次检查 防止其他线程正好获取到锁
            String doubleCheckCache = stringRedisTemplate.opsForValue().get(key);
            if(StrUtil.isNotBlank(doubleCheckCache)){
                return JSON.parseObject(doubleCheckCache,Shop.class);
            }
            //5.去数据库中查询店铺数据
            Thread.sleep(200);  //模拟重构该缓存 所需要的时间
            shop = shopMapper.getById(id);
            //6.数据库中不存在该数据
            if(shop == null){
                //在redis中设置空值 + TTL过期时间 超时剔除 防止缓存穿透
                stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
                return null;
            }
            //7.将数据库中查询到的数据保存到Redis中
            stringRedisTemplate.opsForValue().set(key,JSON.toJSONString(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            unlock(lockKey);
        }
        //8.shop信息
        return shop;
    }

实现逻辑过期代码:

在这里插入图片描述

    //定义一个线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
     * 逻辑过期解决缓存击穿问题
     * @param id
     * @return
     */
    private Shop queryWithLogicDelete(Long id){
        //先去Redis中查找数据
        String key = RedisConstants.CACHE_SHOP_KEY + id;
        String stringJson = stringRedisTemplate.opsForValue().get(key);
        //缓存未命中
        if(stringJson == null){
            return null;
        }
        //缓存命中 判断缓存是否过期
        RedisData<Shop> redisData = JSON.parseObject(stringJson, new TypeReference<RedisData<Shop>>(){});
        //RedisData<Shop> redisData = JSON.parseObject(stringJson, RedisData.class);
        Shop shop = redisData.getData();
        LocalDateTime expireTime = redisData.getExpireTime();
        if(expireTime.isAfter(LocalDateTime.now())){
            //未过期
            return shop;
        }
        //缓存过期 重建缓存
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        //获取锁
        Boolean flag = tryLock(lockKey);
        if(!flag){
            //未获取到锁 返回旧数据
            return shop;
        }
        //开启独立线程
        CACHE_REBUILD_EXECUTOR.submit(() ->{
            //重建缓存
            try {
                this.saveShop2Redis(id,20L);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }finally {
                //释放锁
                unlock(lockKey);
            }
        });
        return shop;
    }

缓存工具封装

package com.hmdp.utils;

import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.TypeReference;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;

/**
 * @author 灵感蛙
 * @create 2025/9/10 19:36
 */
@Component
@Slf4j
public class CacheClient {
    private final StringRedisTemplate stringRedisTemplate;

    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 将任意java对象 序列化成json后保存到redis中 并设置TTL过期时间
     * @param key
     * @param data
     * @param time
     * @param timeUnit
     */
    public void set(String key, Object data, Long time, TimeUnit timeUnit){
        //将data序列化成json
        String json = JSON.toJSONString(data);
        //写入缓存 并设置TTL过期时间
        stringRedisTemplate.opsForValue().set(key,json,time,timeUnit);
    }

    /**
     * 将任意java对象 序列化成json保存到redis 并设置逻辑逻辑过期时间 防止缓存击穿
     * @param key
     * @param data
     * @param time
     * @param timeUnit
     */
    public void setWithLogicExpire(String key, Object data, Long time ,TimeUnit timeUnit){
        //设置逻辑过期
        RedisData<Object> redisData = new RedisData<>();
        redisData.setData(data);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(time)));
        //序列化
        String json = JSON.toJSONString(redisData);
        //存入缓存
        stringRedisTemplate.opsForValue().set(key,json);
    }

    /**

    /**
     * 根据指定key查询缓存
     * 反序列化成指定类型利用缓存空值解决缓存穿透问题
     * @param keyPrefix
     * @param id
     * @param type
     * @param dbFallBack
     * @param time
     * @param timeUnit
     * @return
     * @param <R>
     * @param <T>
     */
    private <R,T> R getWithPassThrough(String keyPrefix,
                                       T id,
                                       Class<R> type,
                                       Function<T,R> dbFallBack,
                                       Long time ,
                                       TimeUnit timeUnit){
        String key = keyPrefix + id;
        //1.先去Redis缓存中查找数据
        String cacheData = stringRedisTemplate.opsForValue().get(key);
        //2.判断缓存是否命中
        if(cacheData != null) {
            if(StrUtil.isNotBlank(cacheData)) {
                //3.查找到店铺数据 反序列化直接返回
                return JSON.parseObject(cacheData, type);
            } else {
                // 返回空值缓存
                return null;
            }
        }
        //5.去数据库中查询店铺数据
        R r = dbFallBack.apply(id);
        //6.数据库中不存在该数据
        if(r == null){
            //在redis中设置空值 + TTL过期时间 超时剔除 防止缓存穿透
            stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
            return null;
        }
        //7.将数据库中查询到的数据保存到Redis中
        set(key,r,time,timeUnit);

        //8.shop信息
        return r;
    }

    //定义一个线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
     * 逻辑过期解决缓存击穿问题
     * @param id
     * @return
     */
    private <R,T> R getWithLogicDelete(String keyPrefix,
                                      T id,
                                      Class<R> type,
                                      Function<T,R> dbFallBack,
                                      Long time ,
                                      TimeUnit timeUnit){
        //先去Redis中查找数据
        String key = keyPrefix + id;
        String stringJson = stringRedisTemplate.opsForValue().get(key);
        //缓存未命中
        if(stringJson == null){
            return null;
        }
        //缓存命中 判断缓存是否过期
        RedisData<R> redisData = JSON.parseObject(stringJson, new TypeReference<RedisData<R>>() {});
        //RedisData<Shop> redisData = JSON.parseObject(stringJson, RedisData.class);
        R r = redisData.getData();
        LocalDateTime expireTime = redisData.getExpireTime();
        if(expireTime.isAfter(LocalDateTime.now())){
            //未过期
            return r;
        }
        //缓存过期 重建缓存
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        //获取锁
        Boolean flag = tryLock(lockKey);
        if(!flag){
            //未获取到锁 返回旧数据
            return r;
        }
        //开启独立线程
        CACHE_REBUILD_EXECUTOR.submit(() ->{
            //重建缓存
            try {
                //查询数据库
                R r1 = dbFallBack.apply(id);
                this.setWithLogicExpire(key,r1,time,timeUnit);
            } finally {
                //释放锁
                unlock(lockKey);
            }
        });
        return r;
    }

    /**
     * 加锁
     * @param key
     * @return
     */
    private Boolean tryLock(String key){
        //给锁加TTL 超时时间 防止死锁
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1",RedisConstants.LOCK_SHOP_TTL,TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

    /**
     * 释放锁
     * @param key
     */
    private void unlock(String key){
        stringRedisTemplate.delete(key);
    }
}