关于缓存分布式锁

目录

缓存

1.缓存的使用

2.整合redis

给业务中加入缓存

缓存失效问题

1.缓存穿透

2.缓存雪崩

缓存数据一致性

先写缓存,再写数据库(差)

先写数据库,再写缓存(一般)

先删缓存,再写数据库(能接受)

先写数据库,再删缓存(比较优秀)

分布式锁

1.使用 Redis 作为分布式锁

2.Redisson 作为分布式锁

SpringCache

1.引入依赖

2.添加配置

3.常用注解

4.业务实现


缓存

1.缓存的使用

使用缓存的目的就是提升读写性能。而实际业务场景下,更多的是为了提升读性能,带来更好的性 能,带来更高的并发量。Redis 的读写性能比 Mysql 好的多,我们就可以把 Mysql 中的热点数据缓 存到 Redis 中,提升读取性能,同时也减轻了 Mysql 的读取压力。

具体流程:

data = cache.load(id);//从缓存加载数据
If(data == null){
data = db.load(id);//从数据库加载数据
cache.put(id,data);//保存到 cache 中
}
return data

2.整合redis

会产生堆外内存溢出异常:OutOfDirectMemoryError。

原因:

  1. SpringBoot 2.0 以后默认使用 lettuce 作为操作 redis 的客户端,它使用 netty 进行网络通信;
  2.  lettuce 的 bug 导致 netty 堆外内存溢出;
  3. netty 如果没有指定堆外内存,默认使用 -Xmx 参数指定的内存;
  4. 可以通过 -Dio.netty.maxDirectMemory 进行设置;

解决方案:

   升级 lettuce 客户端,或使用 jedis 客户端

1、引入 redis-starter
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2、配置 redis
spring:
redis:
host: 192.168.56.10
port: 6379
3、使用 RedisTemplate 操作 redis
@Autowired
StringRedisTemplate stringRedisTemplate;
@Test
public void testStringRedisTemplate(){
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
ops.set("hello","world_"+ UUID.randomUUID().toString());
String hello = ops.get("hello");
System.out.println(hello);
}
4、切换使用 jedis
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>

给业务中加入缓存

这里使用依赖中自带的 StringRedisTemplate 来操作 Redis。这里存储的值为转化成 JSON 字符串的对象信息。

@Autowired
StringRedisTemplate redisTemplate;
@Override
public Map<String, List<Catalogs2Vo>> getCatalogJson() {
    // 1.从缓存中读取分类信息
    String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
    if (StringUtils.isEmpty(catalogJSON)) {
        // 2. 缓存中没有,查询数据库
        Map<String, List<Catalogs2Vo>> catalogJsonFromDB = getCatalogJsonFromDB();
        // 3. 查询到的数据存放到缓存中,将对象转成 JSON 存储
        redisTemplate.opsForValue().set("catalogJSON", JSON.toJSONString(catalogJsonFromDB));
        return catalogJsonFromDB;
    }
    return JSON.parseObject(catalogJSON, new TypeReference<Map<String, List<Catalogs2Vo>>>(){});
}

/**
 * 加缓存前,只读取数据库的操作
 *
 * @return
 */
public Map<String, List<Catalogs2Vo>> getCatalogJsonFromDB() {
    System.out.println("查询了数据库");

    // 性能优化:将数据库的多次查询变为一次
    List<CategoryEntity> selectList = this.baseMapper.selectList(null);

    //1、查出所有分类
    //1、1)查出所有一级分类
    List<CategoryEntity> level1Categories = getParentCid(selectList, 0L);

    //封装数据
    Map<String, List<Catalogs2Vo>> parentCid = level1Categories.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
        //1、每一个的一级分类,查到这个一级分类的二级分类
        List<CategoryEntity> categoryEntities = getParentCid(selectList, v.getCatId());

        //2、封装上面的结果
        List<Catalogs2Vo> catalogs2Vos = null;
        if (categoryEntities != null) {
            catalogs2Vos = categoryEntities.stream().map(l2 -> {
                Catalogs2Vo catalogs2Vo = new Catalogs2Vo(v.getCatId().toString(), null, l2.getCatId().toString(), l2.getName().toString());

                //1、找当前二级分类的三级分类封装成vo
                List<CategoryEntity> level3Catelog = getParentCid(selectList, l2.getCatId());

                if (level3Catelog != null) {
                    List<Catalogs2Vo.Category3Vo> category3Vos = level3Catelog.stream().map(l3 -> {
                        //2、封装成指定格式
                        Catalogs2Vo.Category3Vo category3Vo = new Catalogs2Vo.Category3Vo(l2.getCatId().toString(), l3.getCatId().toString(), l3.getName());

                        return category3Vo;
                    }).collect(Collectors.toList());
                    catalogs2Vo.setCatalog3List(category3Vos);
                }

                return catalogs2Vo;
            }).collect(Collectors.toList());
        }

        return catalogs2Vos;
    }));

    return parentCid;
}

缓存失效问题

1.缓存穿透

缓存穿透是指 查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也无此记录,我们没有将这次查询的 null 写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能 DB 就挂掉了,要是有人利用不存在的 key 频繁攻击我们的应用,这就是漏洞。

解决方法:缓存空结果、并且设置短的过期时间。

2.缓存雪崩

缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到 DB,DB 瞬时压力过重雪崩。

解决方法:原有的失效时间基础上增加一个随机值,比如 1-5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。

3.缓存击穿

对于一些设置了过期时间的 key,如果这些 key 可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:如果这个 key 在大量请求同时进来前正好失效,那么所有对这个 key 的数据查询都落到 db,我们称为缓存击穿。

解决方法:加锁。大量并发只让一个人去查,其他人等待,查到之后释放锁,其他人获取到锁,先查缓存,就会有数据,不用去查数据库。

缓存数据一致性

当数据库有数据更新时,在很长的一段时间内(决定于缓存的过期时间),用户请求从缓存中获取到的都可能是旧值,而非数据库的最新值。那么,该如何更新缓存呢?目前有以下四种解决方案:

先写缓存,再写数据库(差)

缺点:如果刚写完缓存,突然网络出现了异常,导致写数据库失败了。这样缓存中的数据就变成脏数据,这个问题非常严重,也是最差的一种解决方案。

先写数据库,再写缓存(一般)

缺点一:问题又来了,写数据库成功,但写缓存失败了,依然会造成缓存脏数据的问题。但写缓存失败比写数据库失败的概率要小很多了(因为数据库可能有加锁、外键约束、超时等机制限制),所以此方案要比第一种方案好一点。

如果对接口性能要求不高,还可以把写数据库和写缓存放到一个事务中,写缓存失败就回滚数据库。

缺点二:高并发情况下:

  1. 请求a先过来,刚写完了数据库。但由于网络原因,卡顿了一下,还没来得及写缓存。
  2. 这时候请求b过来了,先写了数据库。
  3. 接下来,请求b顺利写了缓存。
  4. 此时,请求a卡顿结束,也写了缓存。

也就是说:在高并发场景中,如果多个线程同时执行先写数据库,再写缓存的操作,可能会出现数据库是新值,而缓存中是旧值,两边数据不一致的情况。

先删缓存,再写数据库(能接受)

缺点:

  1. 请求d先过来,把缓存删除了。但由于网络原因,卡顿了一下,还没来得及写数据库。
  2. 这时请求c过来了,先查缓存发现没数据,再查数据库,有数据,但是旧值。
  3. 请求c将数据库中的旧值,更新到缓存中。
  4. 此时,请求d卡顿结束,把新值写入数据库。

这种极端情况下依然会导致写入的缓存为旧值。

延迟双删

为了避免以上,写完数据库后,再删除一次。

该方案有个非常关键的地方是:第二次删除缓存,并非立马就删,而是要在一定的时间间隔之后。

sleep的时间要对业务读写缓存的时间做出评估,sleep时间大于读写缓存的时间即可。

那么,为什么一定要间隔一段时间之后,才能删除缓存呢?

请求d卡顿结束,把新值写入数据库后,请求c将数据库中的旧值,更新到缓存中。此时,如果请求d删除太快,在请求c将数据库中的旧值更新到缓存之前,就已经把缓存删除了,这次删除就没任何意义。必须要在请求c更新缓存之后,再删除缓存,才能把旧值及时删除了。

先写数据库,再删缓存(比较优秀)

缓存和数据库,无论先处理谁,只要后者有延迟/失败,都会导致不一致的情况,这也正是缓存不一致的根本原因所在。所有解决方案和讨论都是围绕这一点来进行的。

  1. 请求1先写数据库,由于网络原因卡顿了一下,没有来得及删除缓存。
  2. 请求2查询缓存,发现缓存中有数据,直接返回该数据。
  3. 请求1删除缓存

这种情况下,只会影响f或类似f的少数请求读了一次脏数据,看起来好多了。

但如果是读数据请求先过来呢?

  1. 请求2查询缓存,发现缓存中有数据,直接返回该数据。
  2. 请求1先写数据库。
  3. 请求1删除缓存。

总结

  1. 想要提高应用的性能,可以引入「缓存」来解决
  2. 引入缓存后,需要考虑缓存和数据库一致性问题,可选的方案有:「更新数据库 + 更新缓存」、「更新数据库 + 删除缓存」
  3. 更新数据库 + 更新缓存方案,在「并发」场景下无法保证缓存和数据一致性,解决方案是加「分布锁」,但这种方案存在「缓存资源浪费」和「机器性能浪费」的情况
  4. 采用「先删除缓存,再更新数据库」方案,在「并发」场景下依旧有不一致问题,解决方案是「延迟双删」,但这个延迟时间很难评估
  5. 采用「先更新数据库,再删除缓存」方案,为了保证两步都成功执行,需配合「消息队列」或「订阅变更日志」的方案来做,本质是通过「重试」的方式保证数据最终一致
  6. 采用「先更新数据库,再删除缓存」方案,「读写分离 + 主从库延迟」也会导致缓存和数据库不一致,缓解此问题的方案是「延迟双删」,凭借经验发送「延迟消息」到队列中,延迟删除缓存,同时也要控制主从库延迟,尽可能降低不一致发生的概率

具体参考:http://kaito-kidd.com/2021/09/08/how-to-keep-cache-and-consistency-of-db/

分布式锁

本地锁只能锁住当前服务的进程,每一个单独的服务都会有一个进程读取数据库,不能达到只读取依次数据库的效果,所以需要分布式锁。

1.使用 Redis 作为分布式锁

redis 中有一个 SETNX 命令,该命令会向 redis 中保存一条数据,如果不存在则保存成功,存在则返回失败。

我们约定保存成功即为加锁成功,之后加锁成功的线程才能执行真正的业务操作。

/**
* 从数据库查询并封装数据::分布式锁
*
* @return
*/
public Map<String, List<Catalogs2Vo>> getCatalogJsonFromDbWithRedisLock() {

    //1、占分布式锁。去redis占坑 设置过期时间必须和加锁是同步的,保证原子性(避免死锁)
    String uuid = UUID.randomUUID().toString();
    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS);
    if (lock) {
        System.out.println("获取分布式锁成功...");
        Map<String, List<Catalogs2Vo>> dataFromDb = null;
        try {
            //加锁成功...执行业务
            dataFromDb = getCatalogJsonFromDB();
        } finally {
            // lua 脚本解锁
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            // 删除锁
            redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList("lock"), uuid);
        }
        //先去redis查询下保证当前的锁是自己的
        //获取值对比,对比成功删除=原子性 lua脚本解锁
        // String lockValue = stringRedisTemplate.opsForValue().get("lock");
        // if (uuid.equals(lockValue)) {
        //     //删除我自己的锁
        //     stringRedisTemplate.delete("lock");
        // }
        return dataFromDb;
    } else {
        System.out.println("获取分布式锁失败...等待重试...");
        //加锁失败...重试机制
        //休眠一百毫秒
        try {
            TimeUnit.MILLISECONDS.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return getCatalogJsonFromDbWithRedisLock();     //自旋的方式
    }
}

2.Redisson 作为分布式锁

1.引入依赖

 <dependency>
   <groupId>org.redisson</groupId>
   <artifactId>redisson</artifactId>
   <version>3.11.1</version>
</dependency>

2.配置redisson

@Configuration
public class MyRedissonConfig {

    /**
     * 所有丢redis的使用都是通过RedissonClient对象
     * @return
     */
    @Bean(destroyMethod = "shutdown")
    public RedissonClient redisson() {
        // 1、创建配置
        Config config = new Config();
        // Redis url should start with redis:// or rediss://
        //使用单节点方式
        config.useSingleServer().setAddress("redis://192.168.56.10:6379");

        RedissonClient redissonClient = Redisson.create(config);
        // 2、根据 Config 创建出 RedissonClient 实例
        return redissonClient;
    }

3.使用

// 1. 获取一把锁
Rlock lock = redisson.getLock("my-lock");

// 2. 加锁, 阻塞式等待
lock.lock();
try {
	System.out.println("加锁成功,执行业务...");
} catch (Exception e) {
} finally {
	// 3. 解锁 假设解锁代码没有运行,Redisson 会出现死锁吗?(不会)
    lock.unlock();
}
  • 锁的自动续期,如果业务时间很长,运行期间自动给锁续期 30 s,不用担心业务时间过长,锁自动过期被删掉;
  • 加锁的业务只要运行完成,就不会给当前锁续期,即使不手动续期,默认也会在 30 s 后解锁;

修改上述代码为

    /**
     * 缓存里的数据如何和数据库的数据保持一致??
     * 缓存数据一致性
     * 1)、双写模式
     * 2)、失效模式
     *
     * @return
     */
    public Map<String, List<Catalogs2Vo>> getCatalogJsonFromDbWithRedissonLock() {

        //1、占分布式锁。去redis占坑
        //(锁的粒度,越细越快:具体缓存的是某个数据,11号商品) product-11-lock
        //RLock catalogJsonLock = redissonClient.getLock("catalogJson-lock");
        //创建读锁
        RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("catalogJson-lock");

        RLock rLock = readWriteLock.readLock();

        Map<String, List<Catalogs2Vo>> dataFromDb = null;
        try {
            rLock.lock();
            //加锁成功...执行业务
            dataFromDb = getCatalogJsonFromDB();
        } finally {
            rLock.unlock();
        }
        return dataFromDb;
    }

SpringCache

1.引入依赖

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

2.添加配置

spring.cache.type=redis

#spring.cache.cache-names=

#毫秒为单位
spring.cache.redis.time-to-live=3600000
#spring.cache.redis.key-prefix=CACHE_
spring.cache.redis.use-key-prefix=true
#是否缓存空值 防止缓存穿透
spring.cache.redis.cache-null-values=false
@EnableConfigurationProperties(CacheProperties.class)
@Configuration
@EnableCaching
public class MyCacheConfig {


    /**
     * 配置文件中的东西没用上(ttl);应该将配置文件中的所有配置都生效
     * <p>
     * 1.原来和配置文件绑定的配置类是这样子
     *
     * @return
     * @ConfigurationProperties(prefix = "spring.cache")
     * public class CacheProperties
     * 2.要让他生效
     * @EnableConfigurationProperties(CacheProperties.class)
     */
    @Bean
    RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {


        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
//       config = config.entryTtl();
        config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
        config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericFastJsonRedisSerializer()));
        CacheProperties.Redis redisProperties = cacheProperties.getRedis();


        //将配置文件都生效
        if (redisProperties.getTimeToLive() != null) {
            config = config.entryTtl(redisProperties.getTimeToLive());
        }
        if (redisProperties.getKeyPrefix() != null) {
            config = config.prefixKeysWith(redisProperties.getKeyPrefix());
        }
        if (!redisProperties.isCacheNullValues()) {
            config = config.disableCachingNullValues();
        }
        if (!redisProperties.isUseKeyPrefix()) {
            config = config.disableKeyPrefix();
        }

        return config;

    }
}

3.常用注解

  • @Cacheable :触发将数据保存到缓存的操作;
  • @CacheEvict : 触发将数据从缓存删除的操作;
  • @CachePut :不影响方法执行更新缓存;
  • @Cacheing:组合以上多个操作;
  • @CacheConfig:在类级别共享缓存的相同配置;

4.业务实现

 /**
     * 查询所有的一级分类
     *
     * @return
     * @Cacheable: 当前方法的结果需要缓存 如果缓存中有方法不用调用,缓存中没有 调用方法 最后将方法的结果放进缓存
     * <p>
     * 默认行为:
     * 1) 如果缓存中有方法不用调用,缓存中没有 调用方法
     * 2) key是默认自动生成的,包含缓存名字::SimpleKey [](自主生成的key值)
     * 3)缓存的value的值,默认使用jdk序列化机制,将序列化后的数据存在redis
     * 4)默认时间-1(永不过期 ttl=-1)
     * <p>
     * <p>
     * <p>
     * 自定义操作(规范些)
     * 1.指定生成的缓存使用的key  key属性接受一个SpEl表达式
     * 2.指定缓存数据的存活时间   可以在配置文件中指定时间
     * 3.将数据保存为json格式
     * CacheAutoConfiguration
     * RedisCacheConfiguration
     *
     * springCache的不足:
     * 1) 读模式:
     *     缓存穿透:查询一个null数据 解决办法:缓存空数据 spring.cache.redis.cache-null-values=true
     *     缓存击穿:大量并发进来同时查询一个正好过期的数据  解决办法:加锁 默认无锁 sync = true(解决击穿)
     *     缓存雪崩:大量的key同时过期  解决办法:加随机时间 加过期时间  spring.cache.redis.time-to-live=3600000
     * 2) 写模式:(缓存与数据库的一致)
     *     读写加锁
     *     引入中间键canal,感知到mysql的更新去更新数据库
     *     读多写多 :直接去数据库查询就行
     *
     *  总结:常规数据(读多写少的即时性一致性要求不高的)缓存的使用可以使用springCache,写模式只要缓存的数据有过期时间就足够了
     *       特殊数据:特殊设计
     *  原理:springCache有个CacheManager(RedisCacheManager)-->cache(RedisCache)组件-->cache负责缓存的读写
     *
     */
    //每一个需要缓存的数据都需要指定名字(缓存的分区)按照业务类型划分
    @Cacheable(value = {"category"}, key = "#root.method.name",sync = true)
    @Override
    public List<CategoryEntity> getLevel1Categorys() {
        System.out.println("getLevel1Categorys");
        long l = System.currentTimeMillis();

        List<CategoryEntity> categoryEntities = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
        System.out.println("消耗时间:" + (System.currentTimeMillis() - l));
        return categoryEntities;
    }

 

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值