category
学习思考
date
Aug 23, 2022
icon
Origin
password
slug
CacheAndDistributedLock
status
Published
summary
对于缓存和分布式锁的一些学习和理解
tags
Tags
type
Post

缓存:

为了系统性能的提升,我们一般都会将部分数据放入缓存中,加速访问。而db承担数据落盘工作。
那些数据适合放入缓存?
即时性、数据一致性要求不高的
访问量大且更新频率不高的数据(读多、写少)
举例:电商类应用,商品分类、商品列表灯适合缓存并加一个失效时间(根据数据更新频率来定),后台如果发布一个商品,买家需要5分钟才能看到新的商品一般还是可以接受的。
本地缓存:和代码属于同一个进程,和代码运行在同一个项目,在同一个JVM。相当于在本地保存一个副本。在分布式系统下,每一个系统都自带本地缓存,每一个服务器都需要先生成一次缓存。最大的问题是当我们修改了数据,我们会修改缓存,这样我们因为负载均衡选择了一台服务器,只修改了一台服务器的本地缓存,这会导致,下次换一台服务器出现数据不一致的问题。所以分布式情况是不应该使用本地缓存的。
分布式缓存一般使用 Redis
整合redis:
引入data-redis-starter的依赖
简单配置redis的host等信息
使用springboot自动配置好的redisTemplate,来操作redis。

Redis exception; nested exception is io.lettuce.core.RedisException: io.netty.util.internal.OutOfDirectMemoryError: failed to allocate 46137344 byte(s) of direct memory (used: 58720256, max: 100663296)问题:

springboot 2.0 以后默认是使用lettuce作为操作redis的客户端。使用netty进行网络通信 ettuce的bug导致堆外内存溢出 -Xm300m netty如果没有指定堆外内存,默认使用-Xm300m 可以通过 -Dio.netty.maxDirectMemory进行设置 解决方案:不能使用 -Dio.netty.maxDirectMemory只去调大堆外内存。 1)、升级lettuce客户端。2)、切换使用jedis

缓存穿透、缓存雪崩、缓存击穿:

缓存穿透:

指查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也无此记录,我们没有将这次查询的null写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。
风险:利用不存在的数据进行攻击,数据库压力瞬时增大,最终导致崩溃
结局:null结果缓存,并加入短暂过期时间

缓存雪崩:

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

缓存击穿:

对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。
如果这个key在大量请求同时进来前正好失效,那么所有对这个key的数据查询都会落到db,我们称为缓存击穿。
解决:加锁,大量并发只让一个去查,其他人等待,查到以后释放锁,其他人获取到锁,先查缓存,就会有数据,不用去db

Redis缓存的应用:

增加缓存的逻辑:

查询数据库之前,先去查询缓存,如果缓存不为null,则返回缓存中的值,如果缓存为null,则查询数据库,并将查询结果返回,并且插入缓存
private Map<String, List<Catelog2Vo>> getDataFromDb() { String catalogJson = redisTemplate.opsForValue().get("catalogJson"); if (!StringUtils.isEmpty(catalogJson)) { // 如果缓存不为null 直接返回。 Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJson, new TypeReference<Map<String, List<Catelog2Vo>>>() { }); return result; } System.out.println("查询了数据库") // 这里写业务逻辑 // 3、查到的数据放入缓存,将对象转为JSON,放入缓存中 String s = JSON.toJSONString(parent_cid); redisTemplate.opsForValue().set("catalogJson", s, 1, TimeUnit.DAYS); return parent_cid; }
我们以JSON的形式接收和写入缓存,这样做的好处是,增强程序的可扩展性。如果我们写入一个对象或者其他形式的数据。如果对接别的微服务,不实用java语言编写,这样就会导致,拿出缓存无法解析的问题。但是JSON不会出现这种问题,这样增加了程序的可扩展性。

锁的使用:

本地锁:

在单体应用中,我们面对并发请求,通常会给程序是用本地锁,保证一个线程在访问修改数据时,数据不会被并发进来的其他线程修改。
具体我们可以通过synchronized,实现lock接口等方式实现,下面给出synchronized使用的例子:
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithLocalLock() { synchronized (this) { // 得到锁以后 应该再去缓存中确定一次,如果没有才需要继续查询 String catalogJson = redisTemplate.opsForValue().get("catalogJson"); if (!StringUtils.isEmpty(catalogJson)) { // 如果缓存不为null 直接返回。 Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJson, new TypeReference<Map<String, List<Catelog2Vo>>>() { }); return result; } System.out.println("查询了数据库"); /** 这里写业务代码 * */ // 3、查到的数据放入缓存,将对象转为JSON,放入缓存中 String s = JSON.toJSONString(parent_cid); redisTemplate.opsForValue().set("catalogJson", s, 1, TimeUnit.DAYS); return parent_cid; } }
还是以上个方法为例,当一个线程进入方法的时候,他会使用synchronized给方法上锁,别的线程,发现该方法上锁,会进入阻塞状态等待,直到拿到锁。只要是同一把锁,就能锁住需要这个锁的所有线程。使用this的方式:synchronized (this),在springboot中所有的组件在容器中都是单例的。相当于就算有100w个请求同时请求该方法,访问CategoryServiceImpl这个实例,但是CategoryServiceImpl这个实例只有一个,可以通过一把锁锁住。但是这是在单体应用里面可行的方案,因为在分布式的系统中,每部署一个服务都会增加一个CategoryServiceImpl的实例,这样如果有10台服务器,最后就会有10个线程,成功访问了数据库。这个时候我们就需要分布式锁了。

分布式锁:

分布式下如何实现加锁:

所有服务去同一个地方“占坑”,如果占到,就执行逻辑,否则就必须等待,知道释放锁。“占坑”可以去redis,可以去数据库,可以去任何大家都能访问的地方。等待可以自璇的方式。
我们可以通过Redis实现分布式锁:
具体如下:首先线程进入程序后,先去redis“占坑”,这里推荐使用UUID作为唯一表示(Token),
通过setIfAbsent方法,可以返回一个Boolean值的对象,这个对象可以告诉我们是否“占坑”成功,即是否拿到了锁,如果返回true,即我们拿到了锁,并且上锁。如果返回false 即这个程序已经被别的线程上锁,这个时候线程进入阻塞状态,开始自璇等待。值得注意的是:设置过期时间,必须和加锁是同步的,是原子操作。获取值,对比成功删除,这两步也必须是原子操作。因此在删除锁的时候我们使用lua脚本进行删除锁。
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbRedisLock() { String uuid = UUID.randomUUID().toString(); Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS); if (lock) { System.out.println("获取分布式锁成功"); 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<Long>(script, Long.class), Arrays.asList("lock"), uuid); } return dataFromDb; } else { System.out.println("获取分布式锁失败,等待重试"); try { Thread.sleep(2000); } catch (Exception e) { } return getCatalogJsonFromDbRedisLock(); //自璇的方式 } }

通过Redisson实现分布式锁:

Redisson 是架设在 Redis 基础上的一个 Java 驻内存数据网格(In-Memory Data Grid)。充分 的利用了 Redis 键值数据库提供的一系列优势,基于 Java 实用工具包中常用接口,为使用者 提供了一系列具有分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的工 具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式 系统的难度。同时结合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间 的协作。
官方文档: https://github.com/redisson/redisson/wiki/%E7%9B%AE%E5%BD%95
使用
// 默认连接地址 127.0.0.1:6379 RedissonClient redisson = Redisson.create(); Config config = new Config(); config.useSingleServer().setAddress("redis://192.168.56.10:6379"); RedissonClient redisson = Redisson.create(config);
伪代码逻辑:
RLock lock = redisson.getLock("anyLock");// 最常见的使用方法 lock.lock(); // 加锁以后 10 秒钟自动解锁// 无需调用 unlock 方法手动解锁 lock.lock(10, TimeUnit.SECONDS); // 尝试加锁,最多等待 100 秒,上锁以后 10 秒自动解锁 boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS); if (res) { try { ... } finally { lock.unlock(); } }
代码实现:
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbRedissonLock() { // 锁的名字,名字一样 锁就一样,锁的粒度越细,锁的速度越快。 // 锁的粒度: 具体缓存的是某个数据, 11-号商品; product-11-lock product-12-lock RLock lock = redisson.getLock("catalogJson-lock"); lock.lock(); Map<String, List<Catelog2Vo>> dataFromDb; try { dataFromDb = getDataFromDb(); } finally { lock.unlock(); } return dataFromDb; }

缓存一致性问题:

读模式:

读模式就是我们说的如何读取一个数据,我们应该遵循先从缓存中读取,缓存中没有再来读取数据库,如果查到数据我们将数据放到缓存然后返回,方便下次查询。

写模式:

双写模式:

更改完数据库,再去更改缓存。会有产生脏数据的风险,所以我们有并发写的时候需要加锁。为缓存数据加上过期时间,这就是暂时性的脏数据问题,具体看业务是否允许。读到的数据又延迟,但是最终一致性可以保证。

失效模式:

更改完数据库,直接删除缓存。如果数据经常修改的话,经常的上锁,还不如不加锁,直接查数据库。
无论是失效模式还是双写模式:设计上都应该为缓存增加过期时间,暂时的脏数据只要缓存时间一到,数据一清我们又能得到正确的数据。

解决方案:

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

总结:

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

SpringCache的引入和使用:

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

整合SpringCache 简化缓存开发:

引入依赖:

<!--引入SpringCache简化开发--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency>
redis缓存,作为开发场景需要引入:
<!--引入redis--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>

写配置:

自动配置了哪些?
1)、CacheAuroConfiguration会导入 RedisCacheConfiguration,自动配好了缓存管理器,
2)、配置使用redis作为缓存 :spring.cache.type=redis
下面截取于官方文档,使用注解进行配置:
For caching declaration, Spring’s caching abstraction provides a set of Java annotations:
  • @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.
    • 在类级别共享缓存的相同配置

开始使用缓存功能:

在启动类使用@EnableCaching 注解。
在方法添加 @Cacheable()注解
代表当前方法的结果需要缓存,如果缓存中有,方法中就不用调用。如果缓存中没有,调用方法,最后将方法的结果放入缓存,每一个需要缓存的数据都来指定要放到哪个名字的缓存,缓存分区(按照业务类型分)
默认行为:
1)、当缓存中有结果的时候方法时,方法不用调用
2)、key是默认自动生成的:缓存的名字::SimpleKey[]{自主生成的key值}
3)、缓存的Value值,默认使用JDK序列化机制,将序列化后的数据存到redis
4)、默认时间是-1,
自定义:
1)、指定生产缓存使用的key : 使用key属性指定,接收一个spEl表达式
2)、指定缓存的数据存活时间: 配置文件中修改,默认是好喵
3)、将数据转换为JSON格式

将数据保存为JSON格式:

原理:

CacheAutoConfiguration帮我们导入了RedisCacheConfiguration这个类给我们注入了缓存管理器,RedisCacheManager这缓存管理器会按照配置文件的名字为我们初始化缓存,每个缓存决定使用什么配置,如果RedisCacheConfiguration有就用已有的,没有就用默认配置。想修改缓存的配置只需要给容器中放一个 RedisCacheConfiguration 即可,就会应用到当前RedisCacheConfiguration 管理的所有缓存分区中。

配置:

@Configuration @EnableCaching @EnableConfigurationProperties({CacheProperties.class}) public class MyCacheConfig { @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 GenericJackson2JsonRedisSerializer())); // 将配置文件中的所有东西都生效 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; } }

写模式使用SpringCache:

使用@CacheEvict 注解,进行更改后删除缓存操作。
使用@Caching 注解,进行同时操作多个缓存操作。
@Caching(evict = { @CacheEvict(value = "category",key = "'getLevel1Categorys'"), @CacheEvict(value = "category",key = "'getCatalogJson'") })
或者使用CacheEvict删除分区数据
@CacheEvict(value = "category",allEntries = true)
存储同一类型的数据类型的数据,都可以指定同一个分区,这样做的好处是我们修改了这个类型的数据,我们就可以把这个类型的分区都删掉。分区名默认就是缓存的前缀。

@CachePut注解:

如果我们修改的方法返回的数据正好还是修改以后最新的对象,以后还要去查,就可用CachePut注解,在去缓存中放一份,是双写模式使用的注解

SpringCache的不足:

读模式:

缓存穿透:查询一个null数据。解决:缓存空数据。cache-null-values=true
缓存击穿:大量并发进来同时查询一个正好过期的数据。解决方案:加锁
混存雪崩:大量的key同时过期。解决:加随机时间。加上过期时间。

写模式(缓存与数据库一直):

读写加锁:这适用于读多写少的系统。
引入Canal,感知到MySQL的更新,去更新数据库。
读多写多的,直接去数据库查询
原理:CacheMananger(RedisCacheManager)—>Cache(RedisCache)—>Cache 负载进行缓存的读写 ,默认是无加锁的增加;sync = true 解决击穿问题

总结

常规数据(读多写少的,一致性、及时性要求不高的数据)完全可以使用Spring Cache,写模式(只要缓存的数据有过期时间就足够了)
特殊数据:特殊设计
 
 
 
 
 
 
异步&线程池Nacos配置Mysql数据源(Mysql和Nacos同为Docker启动)-No DataSource set的解决方法

  • Twikoo
  • Giscus