redis缓存实战

1、添加商品缓存

存在
不存在
存在
不存在
用户查询商品请求
Redis缓存是否存在?
直接返回商品数据
查询数据库
数据库中存在数据?
写入Redis缓存
返回商品数据
返回失败信息
    /**
     * 根据id查询商品信息
     *
     * @param id 商品id
     * @return 商品详情数据
     */
    // 代码思路:
    //   如果缓存有,则直接返回,
    //   如果缓存不存在,则查询数据库,存入redis之后再返回。
    public Result queryById1(Integer id) {
        // 从redis中查询商品数据
        String cacheKey = CACHE_GOODS_KEY_PREFIX + id;
        String goodsJson = stringRedisTemplate.opsForValue().get(cacheKey);

        // 缓存命中
        if (StringUtils.hasText(goodsJson)) {
            // 直接返回数据
            Goods goods = JSON.parseObject(goodsJson, Goods.class);
            return Result.ok(goods);
        }

        // 缓存未命中,查询数据库
        Goods goods = goodsMapper.selectByPrimaryKey(id);

        // 数据库不存在,返回错误
        if (goods == null) {
            return Result.fail("商品不存在");
        }

        // 数据库中存在,重建缓存,并返回数据
        stringRedisTemplate.opsForValue().set(
                cacheKey, JSON.toJSONString(goods), CACHE_GOODS_TTL, TimeUnit.MINUTES
        );
        return Result.ok(goods);
    }

public final class RedisConstant {
    public static final String CACHE_GOODS_KEY_PREFIX = "cache:goods:";
    public static final String LOCK_GOODS_KEY_PREFIX = "lock:goods:";
    public static final Long CACHE_GOODS_TTL = 30L;
    public static final Long CACHE_NULL_TTL = 2L;
    public static final Long CACHE_GOODS_LOGICAL_TTL = 30 * 60L;


    private RedisConstant() {
    } // 禁止实例化
}

2、缓存一致性问题

使用缓存的好处:降低了后端负载,提高了读写的效率,降低了响应的时间。

缓存带来的问题:缓存的添加提高了系统的维护成本,同时也带来了数据一致性问题。

由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步 ,此时就存在缓存数据一致性问题

缓存数据一致性问题的根本原因是缓存和数据库中的数据不同步

那么我们该如何让 缓存数据库中的数据尽可能的保证同步?首先需要选择一个比较好的缓存更新策略

(1)常见的缓存更新策略

  • 内存淘汰(自动): 利用 Redis的内存淘汰机制 实现缓存更新,Redis的内存淘汰机制是当Redis发现内存不足时,会根据一定的策略自动淘汰部分数据。

  • 超时剔除(半自动):手动给缓存数据设置过期时间TTL,到期后Redis自动删除超时的数据。

  • 主动更新(手动):手动编码实现缓存更新,在修改数据库的同时更新缓存。我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题。

缓存更新策略的选择,应该看业务场景对数据一致性的的需求

  • 低一致性需求:使用Redis自带的内存淘汰机制 + 超时更新。
  • 高一致性需求:主动更新,并以超时剔除作为兜底方案。

(2)主动更新策略的三种方案

  • 双写方案(Cache Aside Pattern):人工编码方式,缓存调用者在更新完数据库后再去更新缓存。维护成本高,灵活度高。✔️
  • 读写穿透方案(Read/Write Through Pattern):将数据库和缓存整合为一个服务,由服务来维护缓存与数据库的一致性,调用者无需关心数据一致性问题,降低了系统的可维护性,但是实现困难,也没有较好的第三方服务供我们使用。
  • 写回方案(Write Behind Caching Pattern):调用者只操作缓存,其他独立的线程去异步处理数据库,将待写入的数据放入一个缓存队列,在适当的时机,通过批量操作或异步处理,将缓存队列中的数据持久化到数据库,实现最终一致。

主动更新策略中三种方案的应用场景 :

  • 双写方案 较适用于读多写少的场景,数据的一致性由应用程序主动管理
  • 读写穿透方案 适用于数据实时性要求较高、对一致性要求严格的场景
  • 写回方案 适用于追求写入性能的场景,对数据的实时性要求相对较低、可靠性也相对低,延迟写入的数据是在内存中的。

综合考虑使用方案一,虽然双写方案需要缓存调用者手动编码维护,但可控性更高。

(3)双写方案 操作缓存和数据库需要考虑的三个问题

使用双写方案操作缓存和数据库时有三个问题需要考虑:

a 是删除缓存还是更新缓存?(两种缓存更新方案,选择效率更高的)

  • 更新缓存:每次更新数据库都更新缓存,无效写操作较多(不推荐)❌

    如果采用更新缓存,假如我们执行100次更新数据库操作,那么就要执行100次写入缓存的操作,而在这期间并没有查询请求,也就是写多读少,那么这100次写入缓存的操作就是无效的写操作。

  • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存(推荐)✔️

    如果采用删除缓存,假设更新100次,只需要删一次缓存,在这期间没有被访问,则不会去更新缓存,等到数据库被查询了再去写入缓存,相当于延迟加载模式,这种写缓存的频率更低,有效更新会更多。

b 如何保证缓存与数据库的操作的同时成功或失败(原子性)?

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

c 先操作缓存 还是 先操作数据库 ?(多线程环境需要考虑)

  • 先删除缓存,再操作数据库(线程安全问题发生概率较高)❌

    如果选择第一种方案,在两个线程并发来访问时,线程1先来,先把缓存删了,假设数据库更新的业务比较复杂耗时较久,由于没有加锁,此时线程2过来,他查询缓存数据不存在,此时他查询数据库查到的是数据库未更新完成的旧数据,当他写入旧数据到缓存后,线程1继续将新数据更新到数据库,这样就出现了多线程环境下的数据库与缓存不一致的问题。

    当线程1删除缓存到更新数据库之间的时间段,会有其它线程进来查询数据,且线程1将缓存删除了,这就导致请求会直接打到数据库上,给数据库带来巨大压力,还可能造成缓存击穿。这个事件发生的概率很大,因为数据库的读写速度慢,而缓存的读写速度块。

  • 先操作数据库,再删除缓存(线程安全问题发生概率较低)✔️

    当线程1在查询缓存,此时缓存恰好失效,缓存未命中,此时线程1查询数据库数据,查询完正准备写入缓存时,由于没有加锁线程2抢占到CPU执行权,线程2在这期间对数据库进行了更新,接着删除缓存(此时缓存为空相当于没变),线程2结束后线程1接着写缓存,但是线程1写入缓存的是之前查数据库的旧数据。

    这个事件发生的概率很低,因为先是需要满足 线程在并行执行 查询缓存时恰好失效未命中,且在写入缓存(微秒级别)的那段时间内有一个线程抢占执行更新操作,缓存的查询很快,这段空隙时间很小,在这期间完成耗时的写操作,可能性不大。

因此,我们选择 先操作数据库,再删除缓存


3、实现商铺查询的数据库与缓存双写一致

   /**
     * 更新商品信息(写操作,先更新数据库,再删除缓存)
     *
     * @param goods 商品数据
     * @return
     */
    @Transactional
    public Result update(Goods goods) {
        Integer id = goods.getId();
        if (id == null) {
            return Result.fail("店铺id不能为空");
        }
        // 更新数据库
        goodsMapper.updateByPrimaryKeySelective(goods);
        // 删除缓存
        stringRedisTemplate.delete(CACHE_GOODS_KEY_PREFIX + id);
        return Result.ok();
    }

代码分析:通过之前的淘汰,我们确定了采用删除策略,来解决双写问题,当我们修改了数据之后,把缓存中的数据进行删除,查询时发现缓存中没有数据,则会从mysql中查询数据并重新写入缓存,从而避免数据库和缓存不一致的问题。


4、缓存穿透的解决方案

缓存穿透:缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,如果不断发起这样的请求,这些请求都会打到数据库,给数据库带来巨大压力。

常见的解决方案有两种:

  • 缓存空对象
    • 优点:实现简单,维护方便
    • 缺点:额外的内存消耗、可能造成短期的不一致
  • 布隆过滤
    • 优点:内存占用较少,没有多余key
    • 缺点:实现复杂、存在误判可能(有穿透的风险)、无法删除数据

缓存空对象思路分析: 当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库。数据库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了。

布隆过滤: 布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中,假设布隆过滤器判断这个数据不存在,则直接返回。这种方式优点在于节约内存空间,存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突。

这里使用方案一(缓存空对象)解决缓存穿透问题:

	//
    // 缓存穿透问题:
    //  1. 当查询数据在数据库中不存在时,将空值写入 redis(假设空值用"NULL"表示)
    //  2. 判断缓存是否命中后,再加一个判断是否为空值
    //
    public Result queryById2(Integer id) {
        // 从redis中查询商品数据
        String cacheKey = CACHE_GOODS_KEY_PREFIX + id;
        String goodsJson = stringRedisTemplate.opsForValue().get(cacheKey);

        // 缓存命中
        if (StringUtils.hasText(goodsJson)) {
            // 处理空值标记(假设空值用"NULL"表示)
            if ("NULL".equals(goodsJson)) {
                return Result.fail("商品不存在");
            }
            Goods goods = JSON.parseObject(goodsJson, Goods.class);
            return Result.ok(goods);
        }

        // 缓存未命中,查询数据库
        Goods goods = goodsMapper.selectByPrimaryKey(id);

        // 数据库中不存在,缓存空值并返回错误
        if (goods == null) {
            stringRedisTemplate.opsForValue().set(
                    cacheKey, "NULL", CACHE_NULL_TTL, TimeUnit.MINUTES // 设置较短TTL,如2分钟
            );
            return Result.fail("商品不存在");
        }

        // 数据库中存在,重建缓存,并返回数据
        stringRedisTemplate.opsForValue().set(
                cacheKey, JSON.toJSONString(goods), CACHE_GOODS_TTL, TimeUnit.MINUTES
        );
        return Result.ok(goods);
    }

5、缓存雪崩的解决方案

缓存雪崩:缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机同一时间,缓存大面积过期失效),导致大量请求到达数据库,带来巨大压力。

缓存雪崩常见解决方案:

  • 给不同的Key的TTL添加随机值(让失效时间离散分布,确保Key不会在同一时间大量失效)
  • 利用Redis集群提高服务的可用性(主从集群、哨兵机制)
  • 给缓存业务添加降级限流策略(比如快速失败机制,让请求尽可能打不到数据库上)
  • 给业务添加多级缓存(浏览器缓存 -> Nginx反向代理缓存 -> Redis缓存 -> JVM本地缓存…)

6、缓存击穿的解决方案

缓存击穿:缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了。但是假设查询数据库重建缓存的过程耗时较长,在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法,那么这些线程就会同一时刻来查询缓存,都未命中,接着同一时间去查询数据库,重复进行缓存重建,导致数据库访问压力过大,这就是高并发访问的热点key失效造成缓存击穿。

缓存击穿与缓存雪崩有一定的区别,缓存雪崩是指许多key同时过期,导致大量数据查询失败,从而造成数据库负载激增。而缓存击穿则是由于高并发查同一条数据而导致数据库压力瞬间增大。

缓存击穿的常见解决方案:

(1) 互斥锁(确保一致性、牺牲服务可用性)

  • 优点:没有额外的内存消耗,保证一致性,实现简单。
  • 缺点:线程需要等待、性能较低,可能有死锁风险。

方案分析:因为锁能实现互斥性。假设并发的线程过来,只能允许单个线程去访问数据库,从而避免对于数据库访问压力过大,但这也会影响业务的性能,因为此时会让业务从并行变成了串行,我们可以采用 tryLock方法 + double check 来解决这样的问题。

假设现在线程1过来访问,它查询缓存没有命中,但是此时它获得了锁的资源,那么线程1就会单独去执行查询数据库重建缓存的逻辑。假设现在线程2过来,并没有获得到锁,那么线程2就可以先休眠一段时间再去重试查询缓存,直到线程1把锁释放后,线程2再查询缓存,此时就能命中缓存拿到数据了。

实现:

    //  缓存击穿问题:
    //  1. 利用互斥锁解决
    //   相较于原来从缓存中查询不到数据后直接查询数据库而言,
    //   现在的方案是
    //   如果从缓存没有查询到数据,则进行互斥锁的获取,判断是否获得到了锁,
    //   如果没有得到,则休眠,过一会再进行尝试,直到获取到锁为止,才能进行查询
    //   如果获取到了锁的线程,再去进行查询,查询后将数据写入 redis,再释放锁,返回数据,
    //   利用互斥锁就能保证只有一个线程去执行操作数据库的逻辑,防止缓存击穿。
    public Result queryById3(Integer id) {
        // 从Redis查询商品数据
        String cacheKey = CACHE_GOODS_KEY_PREFIX + id;
        String goodsJson = stringRedisTemplate.opsForValue().get(cacheKey);

        // 缓存命中
        if (StringUtils.hasText(goodsJson)) {
            return Result.ok(JSON.parseObject(goodsJson, Goods.class));
        }

        // 缓存未命中,初始化分布式锁
        RLock lock = redissonClient.getLock(LOCK_GOODS_KEY_PREFIX + id);
        boolean acquired = false;
        try {
            // 循环尝试获取锁(非递归)
            while (true) {
                // 尝试立即获取锁,不阻塞线程(默认启用看门狗自动续期)
                acquired = lock.tryLock();
                if (acquired) {
                    break;
                }
                // 未获取到锁时休眠(避免CPU空转)
                Thread.sleep(50); // 建议值50-100ms
            }

            // 双重检查缓存(可能线程已更新),如果存在则无需重建缓存,防止堆积的线程全部请求数据库
            // DoubleCheck
            goodsJson = stringRedisTemplate.opsForValue().get(cacheKey);
            if (StringUtils.hasText(goodsJson)) {
                return Result.ok(JSON.parseObject(goodsJson, Goods.class));
            }

            // 查询数据库
            Goods goods = goodsMapper.selectByPrimaryKey(id);

            // 数据库中不存在,返回失败信息
            if (goods == null) {
                return Result.fail("商品不存在!");
            }

            // 数据库中存在,重建缓存并返回
            stringRedisTemplate.opsForValue().set(
                    cacheKey, JSON.toJSONString(goods), CACHE_GOODS_TTL, TimeUnit.MINUTES
            );
            return Result.ok(goods);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return Result.fail("请求中断,请重试!");
        } finally {
            // 8. 确保释放锁
            if (acquired && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

(2) 逻辑过期 (确保服务可用性,牺牲一致性)

  • 优点:线程无需等待,性能较好,不会影响并发能力。
  • 缺点:不保证一致性、有额外内存消耗,实现复杂。

方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案,配合redis的缓存淘汰策略,去避免高并发时期的缓存击穿。

我们把过期时间expire设置在redis的value中,注意这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去判断处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程会开启一个异步新线程去进行查询数据库重建缓存的逻辑,直到新开的线程2完成这个逻辑后,才释放锁,而线程1直接返回过期旧数据。假设现在线程3过来访问,由于异步线程2持有着锁,所以线程3无法获得锁,线程3也直接返回过期数据,只有等到新开的线程2把重建数据构建完后,其他线程才能命中缓存,返回没有过期的新数据。

这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。

实现:

添加逻辑过期时间的字段

@Data
public class RedisData<T> {
    // LocalDateTime : 同时含有年月日时分秒的日期对象
    // 并且LocalDateTime是线程安全的!
    private LocalDateTime expireTime;
    private T data;
}

缓存预热

GoodsService

    /**
     * 根据商铺id查询商品数据,并将数据封装逻辑过期时间,保存到缓存中(缓存预热、重建缓存使用)
     * - 逻辑过期时间根据具体业务而定,逻辑过期过长,会造成缓存数据的堆积,浪费内存;过短造成频繁缓存重建,降低性能。
     * - 所以设置逻辑过期时间时,需要实际测试和评估不同参数下的性能和资源消耗情况,可以通过观察系统的表现,在业务需求和性能要求之间找到一个平衡点
     *
     * @param id            商铺id
     * @param expireSeconds 有效期(单位:秒)
     */
    public void saveGoodsCache(Integer id, Long expireSeconds) {
        // 1.查询商品数据
        Goods goods = goodsMapper.selectByPrimaryKey(id);
        // 2.封装逻辑过期数据(热点数据)
        RedisData<Goods> redisData = new RedisData();
        redisData.setData(goods); // 设置缓存数据
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));  // 设置逻辑过期时间=当前时间+有效期TTL
        // 3.将逻辑过期数据写入Redis,不设置TTL过期时间,key永久有效,真正的过期时间为逻辑过期时间
        stringRedisTemplate.opsForValue().set(CACHE_GOODS_KEY_PREFIX + id, JSON.toJSONString(redisData));
    }
@SpringBootTest
public class CacheTest {
    @Autowired
    private GoodsService goodsService;

    /**
     * 先在测试用例中对缓存进行热点数据预热
     */
    @Test
    void testSaveGoodsCache() {
        goodsService.saveGoodsCache(1, 20L);
    }
}

逻辑过期解决

GoodsService

  /**
     * 缓存击穿问题:
     * 2. 利用逻辑过期解决
     * 当用户开始查询redis时,判断是否命中(程序健壮性考虑),如果没有命中则直接返回空数据(说明不是热点key),不查询数据库。
     * 而一旦命中后,将value取出,判断value中的逻辑过期时间是否过期,
     * 如果未过期,直接返回redis中的新数据,
     * 如果过期,则在开启独立线程后直接返回之前的数据,独立线程异步去重建缓存,重建完成后释放互斥锁。
     * 

* 对于热点业务,提前预热缓存数据,设置永不自动过期,默认缓存一定被命中,不用考虑缓存穿透问题 */ public Result queryById4(Integer id) { // 从缓存中获取热点数据 String cacheKey = CACHE_GOODS_KEY_PREFIX + id; String goodsJson = stringRedisTemplate.opsForValue().get(cacheKey); // 判断缓存是否命中(由于是热点数据,提前进行缓存预热,默认缓存一定会命中) if (!StringUtils.hasText(goodsJson)) { // 缓存未命中,说明查到的不是热点key,直接返回空 return Result.fail("商品不存在(非热点数据)"); } // 缓存命中,先把json反序列化为逻辑过期对象 RedisData<Goods> redisData = JSON.parseObject(goodsJson, new TypeReference<RedisData<Goods>>() { }); Goods goods = redisData.getData(); // 判断是否逻辑过期 if (redisData.getExpireTime().isAfter(LocalDateTime.now())) { // 未过期,直接返回正确数据 return Result.ok(goods); } // 已过期,先尝试获取互斥锁,再判断是否需要缓存重建 RLock lock = redissonClient.getLock(LOCK_GOODS_KEY_PREFIX + id); // 获取锁成功 if (lock.tryLock()) { // tryLock 尝试立即获取锁,不阻塞线程 // 在线程1重建缓存期间,线程2进行过期判断,假设此时key是过期状态,线程1重建完成并释放锁,线程2立刻获取锁,并启动异步线程执行重建,那此时的重建就与线程1的重建重复了 // 因此需要在线程2获取锁成功后,在这里再次检测redis中缓存是否过期(DoubleCheck),如果未过期则无需重建缓存,防止数据过期之后,刚释放锁就有线程拿到锁的情况,重复访问数据库进行重建 goodsJson = stringRedisTemplate.opsForValue().get(cacheKey); redisData = JSON.parseObject(goodsJson, new TypeReference<RedisData<Goods>>() { }); goods = redisData.getData(); // 判断是否逻辑过期 if (redisData.getExpireTime().isAfter(LocalDateTime.now())) { // 未过期,直接返回正确数据 return Result.ok(goods); } // 获取锁成功,开启一个独立子线程去重建缓存 CACHE_REBUILD_EXECUTOR.submit(() -> { try { // 缓存重建并设置逻辑过期时间 saveGoodsCache(id, CACHE_GOODS_LOGICAL_TTL); } finally { if (lock.isLocked() && lock.isHeldByCurrentThread()) { lock.unlock(); } } }); // 这里也是直接返回过期的旧数据 return Result.ok(goods); } // 获取锁失败,直接返回过期的旧数据 return Result.ok(goods); }

两者相比较:

  • 互斥锁更加易于实现,但是加锁会导致这些并发的线程 并行 变成 串行 ,导致系统性能下降,还可能 发生不同业务之间的死锁。
  • 逻辑过期实现起来相较复杂,因为需要额外维护一个逻辑过期时间,有额外的内存开销,但是通过异步开启子线程重建缓存,使原来的同步阻塞变成异步,提高系统的响应速度,在重建缓存这段时间内,其他线程来查询缓存发现缓存已过期,会直接返回过期数据。

你可能感兴趣的:(学习整理-后端,缓存,redis)