缓存与分布式锁

一、缓存

1、缓存使用

为了系统性能的提升, 我们一般都会将部分数据放入缓存中,加速访问。 而 db 承担数据落盘工作。
哪些数据适合放入缓存?

  • 即时性、数据一致性要求不高的
  • 访问量大且更新频率不高的数据(读多, 写少)

举例:电商类应用,商品分类,商品列表等适合缓存并加一个失效时间(根据数据更新频率来定), 后台如果发布一个商品,买家需要 5 分钟才能看到新的商品一般还是可以接受的。

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

注意:在开发中,凡是放入缓存中的数据我们都应该指定过期时间,使其可以在系统即使没有主动更新数据也能自动触发数据加载进缓存的流程。 避免业务崩溃导致的数据永久不一致问题。



2、整合redis作为缓存

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>

二、缓存失效问题

先来解决大并发读情况下的缓存失效问题:

1、缓存穿透

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

2、缓存雪崩

  • 缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到 DB,DB 瞬时压力过重雪崩。
  • 解决:原有的失效时间基础上增加一个随机值,比如 1-5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。

3、缓存击穿

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

三、分布式锁

1、分布式锁与本地锁

2、分布式锁演进-基本原理

分布式锁演进-基本原理

①分布式锁演进-阶段一:

②分布式锁演进-阶段二:

③分布式锁演进-阶段三:

④分布式锁演进-阶段四:

⑤分布式锁演进-阶段五-最终形态:

3、分布式锁实现

使用 RedisTemplate 操作分布式锁

    public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {

        //1、占分布式锁。去redis占锁
        String uuid = UUID.randomUUID().toString();
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS);
        if (lock) {
            System.out.println("获取分布式锁成功...");
            //加锁成功...执行业务
            //2、设置过期时间,必须和加锁是同步的,原子的
            //redisTemplate.expire("lock", 30, TimeUnit.SECONDS);
            Map<String, List<Catelog2Vo>> dataFromDb;
            try {
                dataFromDb = getDataFromDb();
            } finally {
                String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
                //删除锁
                Long lock1 = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class)
                        , Arrays.asList("lock"), uuid);
            }

            //获取值对比,对比成功删除=原子操作  Lua脚本解锁
//            String lockValue = redisTemplate.opsForValue().get("lock");
//            if (uuid.equals(lockValue)) {
//                //删除我自己的锁
//                redisTemplate.delete("lock"); //删除锁
//            }
            return dataFromDb;
        } else {
            //加锁失败...重试。synchronized ()
            //休眠200ms重试
            System.out.println("获取分布式锁失败...等待重试");
            try {
                TimeUnit.MILLISECONDS.sleep(200);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            return getCatalogJsonFromDbWithRedisLock(); //自旋的方式
        }
    }

4、Redisson完成分布式锁

4.1、简介

Redisson 是架设在 Redis 基础上的一个 Java 驻内存数据网格(In-Memory Data Grid) 。 充分的利用了 Redis 键值数据库提供的一系列优势,基于 Java 实用工具包中常用接口, 为使用者提供了一系列具有分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式系统的难度。 同时结合各富特色的分布式服务, 更进一步简化了分布式环境中程序相互之间的协作。
官方文档: https://github.com/redisson/redisson/wiki/目录

4.2、配置

//1、创建配置
//Redis url should start with redis:// or rediss:// (for SSL connection)
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.56.10:6379");

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

4.3、使用分布式锁

        //1、获取一把锁,只要锁的名字一样,就是同一把锁
        RLock lock = redisson.getLock("my-lock");

        //2、加锁
//        lock.lock();//阻塞式等待。默认加的锁都是30s时间。
        //1)、锁的自动续期,如果业务超长,运行期间自动给锁续上新的30s。不用担心业务时间长,锁自动过期被删掉
        //2)、加锁的业务只要运行完成,就不会给当前锁续期,即使不手动解锁,锁默认在30s以后自动删除。

        lock.lock(10, TimeUnit.SECONDS); //10秒自动解锁,自动解锁时间一定要大于业务的执行时间。
        //问题:lock.lcok(10, Timeunit.SECONDS);在锁时间到了以后,不会自动续期。
        //1、如果我们传递了锁的超时时间,就发送给redis执行脚本,进行占锁,默认超时就是我们指定的时间
        //2、如果我们未指定锁的超时时间,就使用30000【lockWatchdogTimeout看门狗的默认时间】;
        //      只要占锁成功,就会启动一个定时任务【重新给锁设置过期时间,新的过期时间就是看门狗的默认时间】,每隔10s都会自动再次续期,续成30s
        //      internalLockLeaseTime【看门狗时间】 / 3L, TimeUnit.MILLISECONDS

        //最佳实战
        //1)、lock.lock(30, TimeUnit.SECONDS);省掉了整个续期操作。手动解锁
        try {
            System.out.println("加锁成功,执行业务..." + Thread.currentThread().getId());
            TimeUnit.SECONDS.sleep(30);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            //3、解锁  假设解锁代码没有运行,redisson不会出现死锁
            System.out.println("释放锁..." + Thread.currentThread().getId());
            lock.unlock();
        }

4.4、使用其他

1、RReadWriteLock读写锁测试

  • 保证一定能读到最新数据,修改期间,写锁是一个排他锁(互斥锁、独享锁)。读锁是一个共享锁
  • 写锁没释放读就必须等待
  • 读 + 读:相当于无锁,并发读,只会在redis中记录好,所有当前的读锁。他们都会同时加锁成功
  • 写 + 读:等待写锁释放
  • 写 + 写:阻塞方式
  • 读 + 写:有读锁,写也需要等待。
    • 只要有写的存在,都必须等待

写数据加写锁:

    @GetMapping("write")
    @ResponseBody
    public String writeValue() {
        RReadWriteLock lock = redisson.getReadWriteLock("rw-lock");
        RLock rLock = lock.writeLock();
        //1、改数据加写锁,读数据加读锁
        rLock.lock();
        String s;
        try {
            System.out.println("写锁加锁成功..." + Thread.currentThread().getId());
            s = UUID.randomUUID().toString();
            TimeUnit.SECONDS.sleep(30);
            redisTemplate.opsForValue().set("writeValue", s);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            rLock.unlock();
            System.out.println("写锁释放" + Thread.currentThread().getId());
        }

        return s;
    }

读数据加读锁:

    @GetMapping("read")
    @ResponseBody
    public String readValue() {
        RReadWriteLock lock = redisson.getReadWriteLock("rw-lock");
//        ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();

        //加读锁
        RLock rLock = lock.readLock();
        rLock.lock();
        String s;
        try {
            System.out.println("读锁加锁成功..." + Thread.currentThread().getId());
            TimeUnit.SECONDS.sleep(30);
            s = redisTemplate.opsForValue().get("writeValue");
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            rLock.unlock();
            System.out.println("读锁释放" + Thread.currentThread().getId());
        }

        return s;
    }

2、RSemaphore信号量测试

  • 3车位:车库停车
  • 信号量也可以用作分布式限流

占车位:

    @GetMapping("park")
    @ResponseBody
    public String park() throws InterruptedException {
        RSemaphore park = redisson.getSemaphore("park");
//        park.acquire();//获取一个信号,获取一个值,占一个车位
        boolean b = park.tryAcquire();
        if (b) {
            //执行业务
            return "ok=>" + b;
        }

        return "error";
    }

释放车位:

    @GetMapping("go")
    @ResponseBody
    public String go() {
        RSemaphore park = redisson.getSemaphore("park");
        park.release();//释放一个车位

//        Semaphore semaphore = new Semaphore(5);
//        semaphore.release();
//        semaphore.acquire();

        return "ok";
    }

3、RCountDownLatch闭锁测试

  • 学校放假,校长锁门:5个班全部走完,我们可以锁大门

锁门:

    @GetMapping("lockDoor")
    @ResponseBody
    public String lockDoor() throws InterruptedException {
        RCountDownLatch door = redisson.getCountDownLatch("door");
        door.trySetCount(5);
        door.await();//等待闭锁都完成

//        CountDownLatch countDownLatch = new CountDownLatch(5);
//        countDownLatch.await();
//        countDownLatch.countDown();

        return "放假了...";
    }

班级人都走了:

    @GetMapping("gogogo/{id}")
    @ResponseBody
    public String gogogo(@PathVariable("id") Long id) {
        RCountDownLatch door = redisson.getCountDownLatch("door");
        door.countDown();//计数减一

        return id + "班的人都走了";
    }

四、缓存数据一致性

缓存里面的数据如何和数据库保持一致

1、双写模式

2、失效模式

3、解决方案

无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。怎么办?

  • 1、如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加 上过期时间,每隔一段时间触发读的主动更新即可
  • 2、如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式。
  • 3、缓存数据+过期时间也足够解决大部分业务对于缓存的要求。
  • 4、通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心 脏数据,允许临时脏数据可忽略);

总结:

  • 我们能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可。
  • 我们不应该过度设计,增加系统的复杂性
  • 遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。

4.1、改进方法1 - 分布式读写锁

我们系统的一致性解决方案:
1、缓存的所有数据都有过期时间,数据过期下一次查询触发主动更新
2、读写数据的时候,加上分布式的读写锁。

4.2、改进方法2 - 使用 Canal

五、Spring Cache

1、简介

  • Spring 从 3.1 开始定义了 org.springframework.cache.Cache 和 org.springframework.cache.CacheManager 接口来统一不同的缓存技术;并支持使用 JCache(JSR-107) 注解简化我们开发;
  • Cache 接口为缓存的组件规范定义,包含缓存的各种操作集合;Cache 接 口 下 Spring 提 供 了 各 种 xxxCache 的 实 现 ; 如 RedisCache, EhCacheCache, ConcurrentMapCache 等;
  • 每次调用需要缓存功能的方法时, Spring 会检查指定参数的指定的目标方法是否已经被调用过;如果有就直接从缓存中获取方法调用后的结果,如果没有就调用方法并缓存结果后返回给用户。下次调用直接从缓存中获取。
  • 使用 Spring 缓存抽象时我们需要关注以下两点;
    • 1、确定方法需要被缓存以及他们的缓存策略
    • 2、从缓存中读取之前缓存存储的数据

2、基础概念

3、SpringBoot整合SpingCache简化缓存开发

3.1、引入依赖spring-boot-starter-cache、spring-boot-starter-data-redis

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>
        <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>

3.2、写配置

(1)、自动配置了哪些?
CacheAutoConfiguration会导入RedisCacheConfiguration:
自动配好了缓存管理器RedisCacheManager
(2)、配置使用redis作为缓存

spring.cache.type=redis

3.3、测试使用缓存

  • @Cacheable: Triggers cache population.:触发将数据保存到缓存的操作
  • @CacheEvict: Triggers cache eviction.:触发将数据从缓存删除的操作
  • @CachePut: Updates the cache without interfering with the method execution.不影响方法执行更新缓存
  • @Caching: Regroups multiple cache operations to be applied on a method.:组合以上多个操作
  • @CacheConfig: Shares some common cache-related settings at class-level.:在类级别共享缓存的相同配置

1)、开启缓存功能 @EnableCaching
2)、只需要使用注解就能完成缓存操作

3.3.1、@Cacheable细节设置

    @Cacheable(value = {"category"}, key = "#root.method.name")
    @Override
    public List<CategoryEntity> getLevel1Categorys() {
        System.out.println("getLevel1Categorys");
        long start = System.currentTimeMillis();
        List<CategoryEntity> categoryEntities = this.baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
        System.out.println("消耗时间:" + (System.currentTimeMillis() - start));
        return categoryEntities;
    }
  • 1、每一个需要缓存的数据我们都来指定要放到哪个名字的缓存。【缓存的分区(按照业务类型分区)】
  • 2、@Cacheable({"category"})
    • 代表当前方法的结果需要缓存,如果缓存中有,方法不用调用。
    • 如果缓存中没有,会调用方法,最后将方法的结果放入缓存
  • 3、默认行为
    • 1)、如果缓存中有,方法不用调用。
    • 2)、key默认自动生成:缓存的名字::SimpleKey
    • 3)、缓存的value的值。默认使用jdk序列化机制,将序列化后的数据存到redis
    • 4)、默认ttl时间-1;

自定义:

原理:
CacheAutoConfiguration -> RedisCacheConfiguration -> 自动配置了RedisCacheManager -> 初始化所有的缓存 -> 每个缓存决定使用什么配置 -> 如果redisCacheConfiguration有就用已有的,没有就用默认配置 -> 想改缓存的配置,只需要给容器中放一个RedisCacheConfiguration即可 -> 就会应用到RedisCacheManager管理的所有缓存分区中

自定义缓存配置:
1.缓存配置类:

@EnableConfigurationProperties(CacheProperties.class)
@Configuration
@EnableCaching
public class MyCacheConfig {

    /**
     * 配置文件中的东西没有用上
     * 1、原来和配置文件绑定的配置类是这样子的
     *      @ConfigurationProperties( prefix = "spring.cache")
     *
     * 2、要让它生效
     *      @EnableConfigurationProperties(CacheProperties.class)
     *
     * @param cacheProperties
     * @return
     *
     */
    @Bean
    public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));

        CacheProperties.Redis redisProperties = cacheProperties.getRedis();
        //将配置文件中所有的配置都生效
        if (redisProperties.getTimeToLive() != null) {
            config = config.entryTtl(redisProperties.getTimeToLive());
        }
        if (redisProperties.getKeyPrefix() != null) {
            config = config.prefixCacheNameWith(redisProperties.getKeyPrefix());
        }
        if (!redisProperties.isCacheNullValues()) {
            config = config.disableCachingNullValues();
        }
        if (!redisProperties.isUseKeyPrefix()) {
            config = config.disableKeyPrefix();
        }

        return config;
    }

}

2.CacheProperties配置参数:

spring.cache.type=redis

#spring.cache.cache-names=qq
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=true

4、SpingCache注解的使用:@Cacheable、@CacheEvict、@Caching、@CachePut

  • @Cacheable:缓存操作
  • @CacheEvict:缓存失效模式的使用
  • @CachePut:缓存双写模式的使用
  • @Caching:多种缓存操作

1、使用@Cacheable缓存分类数据
@Cacheable(value = "category", key = "#root.methodName")


    @Cacheable(value = "category", key = "#root.methodName")
    @Override
    public Map<String, List<Catelog2Vo>> getCatalogJson() {
        System.out.println("查询了数据库...");
        List<CategoryEntity> categoryList = baseMapper.selectList(new QueryWrapper<>());
        //1、查出所有1级分类
        List<CategoryEntity> level1Categorys = getParent_cid(categoryList, 0L);
        //2、封装数据
        Map<String, List<Catelog2Vo>> parent_cid = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(),
                v -> {
                    //1、每一个的1级分类,查到这个1级分类下的所有2级分类
                    List<CategoryEntity> categoryEntities = getParent_cid(categoryList, v.getCatId());
                    //2、封装上面的结果
                    List<Catelog2Vo> catelog2Vos = null;
                    if (!CollectionUtils.isEmpty(categoryEntities)) {
                        catelog2Vos = categoryEntities.stream().map(l2 -> {
                            Catelog2Vo catelog2Vo = new Catelog2Vo(
                                    v.getCatId().toString(),
                                    null,
                                    l2.getCatId().toString(),
                                    l2.getName()
                            );
                            //1、找当前二级分类的三级分类封装成vo
                            List<CategoryEntity> level3Catelog = getParent_cid(categoryList, l2.getCatId());
                            if (!CollectionUtils.isEmpty(level3Catelog)) {
                                List<Catelog2Vo.Catelog3Vo> collect = level3Catelog.stream().map(l3 -> {
                                    //2、封装成指定格式
                                    Catelog2Vo.Catelog3Vo catelog3Vo = new Catelog2Vo.Catelog3Vo(
                                            l2.getCatId().toString(),
                                            l3.getCatId().toString(),
                                            l3.getName()
                                    );
                                    return catelog3Vo;
                                }).collect(Collectors.toList());
                                catelog2Vo.setCatalog3List(collect);
                            }
                            return catelog2Vo;
                        }).collect(Collectors.toList());
                    }

                    return catelog2Vos;
                }));

        return parent_cid;
    }

2、分类数据更新,失效模式@CacheEvict删除一级分类、以及所有分类缓存
方式一:使用@Caching同时进行多种缓存操作
@Caching(evict = { @CacheEvict(value = "category", key = "'getLevel1Categorys'"), @CacheEvict(value = "category", key = "'getCatalogJson'") })
方式二:使用@CacheEvict的allEntries = true属性,删除指定缓存分区下的所有数据;前提:分类数据存储在同一个分区
@CacheEvict(value = "category", allEntries = true)

    /**
     * 级联更新所有关联的数据
     * @CacheEvict:缓存失效模式的使用
     * 1、同时进行多种缓存操作 @Caching
     * 2、指定删除某个分区下的所有数据 @CacheEvict(value = "category", allEntries = true)
     * 3、存储同一个类型的数据,都可以指定成同一个分区。分区名默认就是缓存的前缀,方便使用@CacheEvict批量删除
     * @param category
     */
//    @CacheEvict(value = "category", key = "'getLevel1Categorys'")
//    @Caching(evict = {
//            @CacheEvict(value = "category", key = "'getLevel1Categorys'"),
//            @CacheEvict(value = "category", key = "'getCatalogJson'")
//    })
    @CacheEvict(value = "category", allEntries = true)  //失效模式
//    @CachePut   //双写模式
    @Transactional
    @Override
    public void updateCascade(CategoryEntity category) {
        this.updateById(category);
        categoryBrandRelationService.updateCategory(category.getCatId(), category.getName());
    }

5、SpingCache的原理与不足

5.1、原理

CacheManager(RedisCacheManager) -> Cache(RedisCache) -> Cache负责缓存的读写
步骤1:先通过RedisCache的lookup(Object key)方法,判断缓存中是否有数据,若有数据直接返回

步骤2:若没有数据,CacheAspectSupport调用目标方法查询数据

步骤3:将目标方法查询到的数据,调用RedisCache的put(Object key, @Nullable Object value)方法放入缓存中

注解@Cacheable启用sync = true,步骤1中调用的是加了synchronized关键字的get(Object key, Callable valueLoader)方法,本地锁足以解决读模式下的缓存击穿问题。

5.2、不足

1)、读模式:

  • 缓存穿透:查询一个null数据。解决:缓存空数据,cache-null-values=true
  • 缓存击穿:大量并发进来同时查询一个正好过期的数据。解决:加锁?默认是无加锁的,sync = true(加锁,解决击穿)
  • 缓存雪崩:大量的key同时过期。解决:加随机时间。加上过期时间就可以,time-to-live=3600000

2)、写模式:(缓存与数据库一致性)

  • 1)、读写加锁。
  • 2)、引入Canal,感知MySQL的更新去更新数据库
  • 3)、读多写多,直接去数据库查询就行

总结:

  • 常规数据(读多写少,即时性,一致性要求不高的数据):完全可以使用Spring-Cache;写模式(只要缓存的数据有过期时间就足够了)
  • 特殊数据:特殊设计
posted @ 2022-06-19 17:38  冰枫丶  阅读(64)  评论(0编辑  收藏  举报