缓存击穿详解及解决方案 --互斥锁及逻辑过期案例实战

缓存击穿详解及案例实战

流程分析

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

何为缓存重建业务复杂?缓存在redis里存储,到了一定时间会被清楚失效,需要从数据库重新查询并写入redis,需要从多个数据库中进行查询,甚至要做表关联的运算,这样一来这个业务的耗时就会很长。

线程1在重建缓存数据的同时,又有无数的请求访问进来,此时都无法命中,都会再次查询数据库进行缓存重建,这样一来所有的请求都打到了数据库

解决方案

  • 互斥锁
  • 永不过期+逻辑过期
互斥锁

缓存未命中后,获取锁,直到写入缓存后再次释放锁,其他进程查询缓存未命中后获取锁失败,将会阻塞等待并再次尝试,直到线程1写入缓存并查询成功

如果重建缓存的业务比较复杂,耗时较长,其他的线程将会一直处在阻塞等待的状态。所以互斥锁的方案相对来说性能就差一点,但能保证数据的一致性

永不过期+逻辑过期

将缓存数据设置为永不过期(即不依赖 Redis 的 TTL),这样缓存项本身不会因时间原因自动失效。

每个缓存数据项内部包含一个逻辑过期时间(如时间戳)。当应用程序读取数据时,会检查当前时间与逻辑过期时间的关系:未过期则直接返回缓存数据。已过期则触发新的后台线程(或异步任务)刷新缓存数据,并且立即返回旧的缓存数据,保持应用响应性。存储的数据结构例如下图:

线程1命中后发现时间过期,获取锁并建立一个新的线程来进行缓存的重建,线程1则直接返回旧的数据。新的线程3此时再查询缓存,获取锁失败后将会直接返回旧数据。直至线程2重建缓存成功并释放锁后再次查询缓存才能查询到最新的数据

优缺点对比

这两种方案其实都是在解决缓存重建的这段时间内产生的并发问题,互斥锁的解决方案保证了数据的一致性,牺牲了服务的可用性,性能下降,甚至在阻塞过程中有可能不可用。逻辑过期的方案则保证了可用性,牺牲了一致性。

案例实战

1. 利用互斥锁解决缓存击穿问题
业务流程:

此处的锁需要自定义锁,而不是Synchronized和Lock(拿到锁可以执行,没拿到锁则要一直等待),分布式系统下则采用分布式锁。

自定义锁的实现思路

此处会使用到Redis中的string类型的set nx命令,当且仅当这个key不存在的时候才进行。这样一来只有第一个写入锁的能够成功,而后面写入的都无法成功,思路类似互斥锁:

127.0.0.1:6379> help setnx

  SETNX key value
  summary: Set the value of a key, only if the key does not exist
  since: 1.0.0
  group: string

释放锁则只需要将锁删掉即可

此外,我们还需要对锁设置超时时间,例如设置有效期10s,以防止设置锁成功后因某些原因程序出问题了,导致迟迟无法执行释放锁的动作。

案例实战

先抽象出获取锁和释放锁的方法:

获取锁的方法:

    private boolean tryLock(String key){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

释放锁的方法:

private void unlock(String key){
        stringRedisTemplate.delete(key);
    }

接下来进行业务逻辑编写:

  • 互斥锁
    /**
     * 封装互斥锁解决缓存击穿的代码
     * @param id
     * @return 店铺信息
     */
    public Shop queryWithMutex(Long id) {
        String key = "cache:shop:" + id;
        // 1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,转成Java对象,直接返回
            return JSONUtil.toBean(shopJson, Shop.class);
        }
        // 也可以用("".equals(shopJson))
        if (shopJson != null) {
            return null;
        }
        // 4.实现缓存重建
        // 4.1获取互斥锁
        String lockKey = "lock:shop" + id;
        Shop shop = null;
        try {
            boolean isLock = tryLock(lockKey);
            // 4.2判断是否获取成功
            if (!isLock) {
                // 4.3失败,则休眠并重试
                Thread.sleep(50);
                // 递归重试
                return queryWithMutex(id);
            }
            // 再次从redis查询商铺缓存
            String shopJson2 = stringRedisTemplate.opsForValue().get(key);
            // 判断是否存在
            if (StrUtil.isNotBlank(shopJson2)) {
                // 存在,转成Java对象,直接返回
                return JSONUtil.toBean(shopJson2, Shop.class);
            }
            // 4.4成功,根据id查询数据库
            shop = getById(id);
            // 5.不存在,返回错误
            if (shop == null) {
                // 写入空值
                stringRedisTemplate.opsForValue().set(key,"",2L,TimeUnit.MINUTES);
                //返回错误
                return null;
            }
            // 6.存在,写入redis
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),30L, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            // 7.释放互斥锁
            unlock(lockKey);
        }

        // 8.返回
        return shop;
    }

  • 店铺查询
    @Override
    public Result queryById(Long id) {
        // 用互斥锁解决缓存击穿
        Shop shop = queryWithMutex(id);
        if (shop == null){
            return Result.fail("店铺不存在");
        }
        // 7.返回
        return Result.ok(shop);
    }

2. 基于逻辑过期方式解决缓存击穿

采用这个方案,理论上来讲key是永不过期的,除非“活动”结束,手动删除这个永不过期的热点key,所以不用考虑是否命中的问题,而如果真的没有命中,那就说明这个活动过期了,手动删除了热点key。所以如果未命中,也无需做一些缓存穿透,缓存击穿的解决方案,直接返回null即可。此处的核心逻辑只考虑命中后的情况

a. 大致流程

ⅰ. 设置过期时间的两种思路
  • 方案一:继承

新建一个RedisData类,赋予expireTime属性:

@Data
public class RedisData {
    private LocalDateTime expireTime;

}

在需要添加过期时间的对象类上继承该类:

public class Shop extends RedisData implements Serializable{
    
}

缺点:这种方案仍需要修改源代码,有一定的侵入性

  • 方案二:在RedisData类中添加一个Object类的data属性,用来存放需要的属性

修改RedisData类:

@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object Data;
}
b. 案例实战

先写一个预设缓存的方法:

private void saveShop2Redis(Long id, Long expireSeconds){
        //1.查询点店铺数据
        Shop shop = getById(id);
        //2.封装逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(shop);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        //3.写入redis
        stringRedisTemplate.opsForValue().set("cache:shop:" + id,JSONUtil.toJsonStr(redisData));
    }

编写个单元测试来写入数据:

@SpringBootTest
class HmDianPingApplicationTests {

    @Resource
    private ShopServiceImpl service;

    @Test
    void  testSaveShop(){
        service.saveShop2Redis(1L,10L);
    }
    
}

成功写入缓存以及过期时间:

接下来修改查询店铺的代码:

// 创建线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = 
Executors.newFixedThreadPool(10);

/**
     * 查询店铺
     * @param id
     * @return 店铺信息
     */
    @Override
    public Result queryById(Long id) {
        //逻辑过期解决缓存击穿
        Shop shop = queryWithLogicalExpire(id);
        if (shop == null){
            return Result.fail("店铺不存在");
        }
        // 7.返回
        return Result.ok(shop);
    }

    /**
     * 逻辑过期解决缓存穿透
     * @param id
     * @return shop
     */
public Shop queryWithLogicalExpire(Long id) {
        String key = "cache:shop:" + id;
        // 1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isBlank(shopJson)) {
            // 3.存在,转成Java对象,直接返回
            return null;
        }
        // 4.命中,需要json反序列化成对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        // 5.判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 5.1未过期直接返回
            return shop;
        }
        // 5.2已过期,需要缓存重建

        // 6.缓存重建
        // 6.1 获取互斥锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        // 6.2判断是否获取互斥锁
        if (isLock) {
            // TODO 6.3成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    saveShop2Redis(id, 20L);

                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // 释放锁
                    unlock(lockKey);
                }
            });
        }

        // 6.4失败,直接返回过期的信息
        return shop;
    }

获取锁成功后进行DoubleCheck:

public Shop queryWithLogicalExpire(Long id) {
        String key = "cache:shop:" + id;
        // 1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isBlank(shopJson)) {
            // 3.存在,转成Java对象,直接返回
            return null;
        }
        // 4.命中,需要json反序列化成对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        // 5.判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 5.1未过期直接返回
            return shop;
        }
        // 5.2已过期,需要缓存重建

        // 6.缓存重建
        // 6.1 获取互斥锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);

        // 获取锁后再次进行DoubleCheck,再次查询一下数据看是否过期
        String shopJson2 = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isBlank(shopJson2)) {
            return  null;
        }
        RedisData redisData1 = JSONUtil.toBean(shopJson2, RedisData.class);
        Shop shop1 = JSONUtil.toBean((JSONObject) redisData1.getData(), Shop.class);
        if (expireTime.isAfter(LocalDateTime.now())) {
            return shop1;
        }

        // 6.2判断是否获取互斥锁
        if (isLock) {
            // TODO 6.3成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    saveShop2Redis(id, 20L);

                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // 释放锁
                    unlock(lockKey);
                }
            });
        }
        // 6.4失败,直接返回过期的信息
        return shop;
    }
c. 测试

为了能够明显的看到缓存重建的过程,在重建缓存的代码中添加休眠时间:

public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
        //1.查询点店铺数据
        Shop shop = getById(id);
        // 添加休眠时间
        Thread.sleep(200);
        //2.封装逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(shop);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        //3.写入redis
        stringRedisTemplate.opsForValue().set("cache:shop:" + id,JSONUtil.toJsonStr(redisData));
    }

修改数据库的店铺名称:

打开Jmeter,设置线程100,完成时间1秒:

配置请求:

开始测试,最终在第80次左右的时候成功读取到新的数据:

查看IDEA控制台,发现只走了一次sql查询:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值