- 谷粒商城-分布式基础篇【环境准备】
- 谷粒商城-分布式基础【业务编写】
- 谷粒商城-分布式高级篇【业务编写】持续更新
- 谷粒商城-分布式高级篇-ElasticSearch
- 谷粒商城-分布式高级篇-分布式锁与缓存
- 项目托管于gitee
为了系统性能的提升,我们一般都会将部分数据放入缓存中,加速访问。而 db 承担数据落盘工作。
哪些数据适合放入缓存?
- 即时性、数据一致性要求不高的
- 访问量大且更新频率不高的数据(读多,写少)
一、整合Redis测试
1.1、整合Redis
第一步、引入依赖
<!--引入Redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
第二步、配置redis主机地址
spring:
redis:
host: 124.222.223.222
port: 6379
1.2、测试Redis
@Autowired
StringRedisTemplate redisTemplate;
@Test
public void testStringRedisTemplate() {
// 存入一个 hello world
ValueOperations<String, String> ops = redisTemplate.opsForValue();
// 保存数据
ops.set("hello", "world_"+ UUID.randomUUID().toString());
// 查询数据
System.out.println("之前保存的数据:" + ops.get("hello"));
}
二、改造三级分类业务
2.1、编写 从缓存中查询并封装分类数据的方法
优化菜单获取业务getCatalogJson,使用 从缓存中查询并封装分类数据
在 CategoryServiceImpl 实现类中 将原来的从数据库中查询并封装分类数据 的方法名改为 getCatalogJsonFromDb()
供调用!,并编写 从缓存中查询并封装分类数据的方法
/**
* 从缓存中查询并封装分类数据
* 给换从中放JSON字符串,拿出来的JSON字符串,还要逆转为能用的对象类型【序列化与反序列化】
* @return
*/
@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
// 1、加入缓存逻辑,缓存中存放的数据是json字符串
// JSON跨语言,跨平台兼容
String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
if (StringUtils.isEmpty(catalogJSON)) {
// 2、缓存中没有数据,则查询数据库并保存
Map<String, List<Catelog2Vo>> catalogJsonFromDb = getCatalogJsonFromDb();
// 3、查到的数据放入缓存,将查出的对象转为json放在缓存中
redisTemplate.opsForValue().set("catalogJSON", JSON.toJSONString(catalogJsonFromDb));
return catalogJsonFromDb;
}
// 4、将读到的JSON字符串转为我们想要的
Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON, new TypeReference< Map<String, List<Catelog2Vo>>>(){});
return result;
}
2.2、lettuce堆外内存溢出bug
测试爆出 lettuce堆外内存溢出bug
当进行压力测试时后期后出现堆外内存溢出OutOfDirectMemoryError
产生原因:
-
springboot2.0以后默认使用lettuce作为操作redis的客户端,它使用netty进行网络通信
-
lettuce的bug导致netty堆外内存溢出。netty如果没有指定堆外内存,默认使用Xms的值,可以使用-Dio.netty.maxDirectMemory进行设置
解决方案:由于是lettuce的bug造成,不要直接使用-Dio.netty.maxDirectMemory去调大虚拟机堆外内存,治标不治本。
- 1)、升级lettuce客户端。但是没有解决的
- 2)、切换使用jedis
2.3、切换使用jedis
lettuce和jedis是操作redis的底层客户端,RedisTemplate是再次封装
<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>
lettuce 和 jedis 都是操作Redis的底层客户端。Spring 再次封装 redisTemplate ;
三、缓存击穿、穿透、雪崩
3.1、高并发缓存失效问题-缓存穿透(查不到)
缓存穿透
缓存失效 :缓存没有命中到,没有使用
- 缓存穿透:
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求(指查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也无此记录),我们没有将这次查询的null写入缓存,这将导致这个不存在的数据每次请求都要去存储层去查询,失去了缓存的意义 - 风险:
利用不存在的数据进行攻击,数据库瞬间压力增大,最终导致崩溃 - 结局:
null结果缓存,并加入短暂过期时间
### 3.2、高并发缓存失效问题-==缓存击穿== (量太大)
缓存击穿 (量太大)
-
缓存击穿:
-
缓存击穿 , 是指一个key非常热点 , 在不停的扛着大并发 , 大并发中对这一个点进行访问 , 当这个key在失效的瞬间 , 持续的大并发就穿破缓存 , 直接请求数据库 , 就像在一个屏幕上凿开了一个洞 .
-
当某个key在过期的瞬间 , 有大量的请求并发访问 , 这类数据一般是热点数据 , 由于缓存过期 , 会同时访问数据库来查询最新数据 , 并且会写缓存 , 会导致数据库瞬间压力过大 .
比如热搜排行上,一个热点新闻被同时大量访问就可能导致缓存击穿。
-
-
解决:
- 设置热点数据永远不过时
- 这样就不会出现热点数据过期的情况,但是当Redis内存空间满的时候也会清理部分数据,而且此种方案会占用空间,一旦热点数据多了起来,就会占用部分空间。
- 加互斥锁(分布式锁)
- 在访问key之前,采用SETNX(set if not exists)来设置另一个短期key来锁住当前key的访问,访问结束再删除该短期key。保证同时刻只有一个线程访问。这样对锁的要求就十分高。
- 在访问key之前,采用SETNX(set if not exists)来设置另一个短期key来锁住当前key的访问,访问结束再删除该短期key。保证同时刻只有一个线程访问。这样对锁的要求就十分高。
- 设置热点数据永远不过时
3.3、高并发缓存失效问题-缓存雪崩
缓存雪崩
- 缓存雪崩:
大量的key设置了相同的过期时间,导致在缓存在同一时刻全部失效,造成瞬时DB请求量大、压力骤增,引起雪崩。 - 解决:
原油的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
四、优化三级分类业务
1、空结果缓存:解决缓存穿透
2、设置过期事件(加随机值):解决缓存雪崩
3、加锁:解决缓存击穿
-
方法一:本地锁 在代码块上加
synchronized(this)
,SpringBoot所有的组件在容器中都是单例的。- 查缓存没有,然后取竞争锁查数据库。
- 竞争到锁之后再次查询缓存中有没有
- 有:则直接返回缓存中的数据
- 没有:则查询数据库返回
- 缺点:单体架构下可以,但在分布式的时候多个服务相当于多个实例。本地锁,只能锁住当前进程,所以我们需要分布式锁!故此方法不附代码
-
方法二:分布式锁
4.1、本地锁
方法一:本地锁 在代码块上加synchronized(this)
,SpringBoot所有的组件在容器中都是单例的。
- 查缓存没有,然后取竞争锁查数据库。
- 竞争到锁之后再次查询缓存中有没有
- 有:则直接返回缓存中的数据
- 没有:则查询数据库返回
- 缺点:单体架构下可以,但在分布式的时候多个服务相当于多个实例。本地锁,只能锁住当前进程,所以我们需要分布式锁!
4.1.1、锁-时序问题
锁-时序问题
这里进行压力测试的时候出现了查询多次数据库没锁住,即锁-时序问题,因为我们将结果放入缓存这一步写在了锁外面,比如1号请求在数据库中查到了数据之后就释放了锁,此时还没将结果放入缓存,此时2号请求竞争到锁由于此时1号请求在数据库中查到的数据还没有放入到缓存,故缓存中没有再次查询数据库
解决:
将结果放入缓存这一步写到锁里面去,不要放在释放锁之后做
4.1.2、本地锁在分布式下的问题
本地锁在分布式下的问题
复制配置创建多个进程服务
和我们预想的一样在分布式情况下,每个服务都要查询一次数据库。虽然没有完全锁住,但确实优化了。
4.1.3、附上代码
CategoryServiceImpl 实现类:
@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
/*
* 1、空结果缓存:解决缓存穿透
* 2、设置过期事件(加随机值):解决缓存雪崩
* 3、加锁:解决缓存击穿
* */
// 1、加入缓存逻辑,缓存中存放的数据是json字符串
// JSON跨语言,跨平台兼容
String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
if (StringUtils.isEmpty(catalogJSON)) {
System.out.println("缓存不命中__查询数据库");
// 2、缓存中没有数据,则查询数据库并保存(保存缓存的代码写在了getCatalogJsonFromDb()方法里面)
Map<String, List<Catelog2Vo>> catalogJsonFromDb = getCatalogJsonFromDb();
}
System.out.println("缓存命中__直接返回");
// 4、将读到的JSON字符串转为我们想要的
Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON, new TypeReference< Map<String, List<Catelog2Vo>>>(){});
return result;
}
/**
* 从数据库中查询并封装分类数据
* @return
*/
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDb() {
// 方法一、用线程安全方法
// 只要是同一把锁,就能锁住这个锁的所有线程
// 1、synchronized (this):SpringBoot所有的组件在容器中都是单例的
// TODO 本地锁:synchronized,JUC(Lock),在分布式情况下想要锁住所有,必须使用分布式锁
synchronized (this) {
// 得到锁以后,我们应该再去缓存中确定一次,如果没有才需要继续查询
String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
if (!StringUtils.isEmpty(catalogJSON)) {
// 如果缓存不为空,直接返回
Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON, new TypeReference< Map<String, List<Catelog2Vo>>>(){});
return result;
}
System.out.println("查询数据库");
/**
* 1、将数据库的多次查询变为一次
*/
List<CategoryEntity> selectList = baseMapper.selectList(null);
// 1、查出所有分类
List<CategoryEntity> level1Categorys = getParent_cid(selectList,0L);
// 2、封装数据
Map<String, List<Catelog2Vo>> parent_cid = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
// 2.1、每一个一集分类,查到这个一集分类的所有二级分类
List<CategoryEntity> categoryEntities = getParent_cid(selectList,v.getCatId());
// 2.2、封装上面的结果
List<Catelog2Vo> catelog2Vos = null;
if (categoryEntities != null) {
catelog2Vos = categoryEntities.stream().map(l2 -> {
Catelog2Vo catelog2Vo = new Catelog2Vo(v.getCatId().toString(), null, l2.getCatId().toString(), l2.getName());
// 2.3、找当前二级分类的三级分类封装成vo
List<CategoryEntity> level3Catelog = getParent_cid(selectList,l2.getCatId());
if (level3Catelog!=null) {
List<Catelog2Vo.Catelog3Vo> collect = level3Catelog.stream().map(l3 -> {
// 封装成指定格式
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;
}));
// 3、查到的数据放入缓存,将查出的对象转为json放在缓存中
redisTemplate.opsForValue().set("catalogJSON", JSON.toJSONString(parent_cid),1, TimeUnit.DAYS);
return parent_cid;
}
}
### 4.2、分布式锁
4.2.1、分布式锁原理与使用
分布式锁原理与使用
4.2.1.1、分布式锁演进-阶段一
-
问题:
setnx占好了位,业务代码异常或者程序在页面过程中宕机。没有执行删除锁逻辑,这就造成了死锁
-
解决:
设置锁的自动过程,即使没有删除,会自动删除
4.2.1.2、分布式锁演进-阶段二
-
问题:
setnx设置好,正要去设置过期时间,宕机,又成死锁了
-
解决:
设置过期时间和占位必须是原子的。redis支持使用 setnx ex命令
4.2.1.3、分布式锁演进-阶段三
- 问题:
- 删除锁直接删除?
如果由于业务时间很长,锁自己过期了,我们直接删除,有可能把别人正在持有的锁删除了
- 删除锁直接删除?
- 解决:
设置过期时间和占位必须是原子的。redis支持使用 setnx ex命令- 占锁的时候,值指定为uuid,每个人匹配是自己的锁才删除。
4.2.1.4、分布式锁演进-阶段四
4.2.1.5、分布式锁演进-阶段五
最终代码:
/**
* 从数据库中查询并封装分类数据(Redis分布式锁)
* @return
*/
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、加锁成功 ...执行业务
Map<String, List<Catelog2Vo>> dataFromDb;
try{
dataFromDb = getDataFromDb();
}finally {
// 3、删锁
String script = "if redis.call(\"get\",KEYS[1]) == ARGV[1] then\n" +
" return redis.call(\"del\",KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end";
redisTemplate.execute(new DefaultRedisScript<Long>(script,Long.class),
Arrays.asList("lock"),uuid);
}
return dataFromDb;
} else {
// 加锁失败...重试
// 休眠100ms重试
System.out.println("获取分布式锁失败...等待重试");
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
return getCatalogJsonFromDbWithRedisLock(); // 自旋的方式
}
}
五、分布式锁-Redisson
5.1、Redisson 简介
https://github.com/redisson/redisson
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。其中包括(BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter, Remote service, Spring cache, Executor service, Live Object service, Scheduler service) Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。
本文我们仅关注分布式锁的实现,更多请参考官方文档
5.2、Redisson 整合
第一步、导入依赖
<!-- 以后使用redisson作为分布式锁,分布式对象等功能框架 -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.12.0</version>
</dependency>
第二步、编写配置类 RedissonClient
在 com/atguigu/gulimall/product/config
路径下
@Configuration
public class MyRedissonConfig {
/**
* 所有对Redisson的使用都是通过RedissonClient对象
* @return
* @throws IOException
*/
@Bean(destroyMethod="shutdown")
public RedissonClient redisson() throws IOException {
// 1、创建配置
Config config = new Config();
// 可以使用 "rediss://"来启用SSL安全连接
config.useSingleServer().setAddress("redis://124.222.223.222:6379");
// 2、根据Config创建出实例
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
}
### 5.3、分布式锁-Redisson-lock锁测试
5.3.1、可重入锁(Reentrant Lock)
分布式锁:github.com/redisson/redisson/wiki/8.-分布式锁和同步器
A调用B。AB都需要同一锁,此时可重入锁就可以重入,A就可以调用B。不可重入锁时,A调用B将死锁
- 1、如果我们传递了锁的超时时间,就发送给redis执行脚本 进行占锁,默认超时就是我们指定的时间
- 2、如果我们未指定锁的超时时间,就是使用 30 * 1000【看门狗的超时间 lockWatchdogTimeout】,只要占锁成功,就会启动一个定时任务【重新给锁设置过期时间,新的过期时间就是开门狗的默认时间】,每隔1/3的看门狗时间10s再次续期
- 默认
RLock lock = redisson.getLock("anyLock");
// 最常见的使用方法
lock.lock();
锁的续期:大家都知道,如果负责储存这个分布式锁的Redisson节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个**监控锁的看门狗,**它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟(每到20s就会自动续借成30s,是1/3的关系),也可以通过修改Config.lockWatchdogTimeout来另行指定。
- 指定锁的超时时间
另外Redisson还通过加锁的方法提供了leaseTime
的参数来指定加锁的时间。超过这个时间后锁便自动解开了。
// 加锁以后10秒钟自动解锁
// 无需调用unlock方法手动解锁
lock.lock(10, TimeUnit.SECONDS);
// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
try {
...
} finally {
lock.unlock();
}
}
- Redisson同时还为分布式锁提供了异步执行的相关方法:
RLock lock = redisson.getLock("anyLock");
lock.lockAsync();
lock.lockAsync(10, TimeUnit.SECONDS);
Future<Boolean> res = lock.tryLockAsync(100, 10, TimeUnit.SECONDS);
RLock
对象完全符合Java的Lock规范。也就是说只有拥有锁的进程才能解锁,其他进程解锁则会抛出IllegalMonitorStateException
错误。但是如果遇到需要其他进程也能解锁的情况,请使用分布式信号量Semaphore
对象.
测试代码附上:
// 测试接口
@ResponseBody
@GetMapping("/hello")
public String hello() {
// 1、获取一把锁,只要锁的名字一样,就是同一把锁
RLock lock = redisson.getLock("my-lock");
// 2、加锁
//lock.lock(); // 阻塞式等待,默认加的锁都是30s时间
// 1)、锁的自动续期,如果业务超长,运行期间自动给锁续上新的30s。不用担心业务长锁自动过期被删掉
// 2)、加锁的业务只要运行完成就不会给当前锁续期,即使不手动解锁,锁默认在30s以后自动删除
lock.lock(10, TimeUnit.SECONDS); // 10s自动解锁,自动解锁时间一定要大于业务的执行时间
// lock.lock(10, TimeUnit.SECONDS); 锁时间到了以后不会自动续期
// 1、如果我们传递了锁的超时时间,就发送给redis执行脚本 进行占锁,默认超时就是我们指定的时间
// 2、如果我们未指定锁的超时时间,就是使用 30 * 1000【看门狗的超时间 lockWatchdogTimeout】
// 只要占锁成功,就会启动一个定时任务【重新给锁设置过期时间,新的过期时间就是开门狗的默认时间】,每隔1/3的看门狗时间10s再次续期
// internallockLeaseTime【看门狗时间】/3,10s
// 最佳实战
// 指定锁的超时时间,省掉了整个续期操作。手动解锁
try {
System.out.println("加锁成功,执行业务..." + Thread.currentThread().getId());
Thread.sleep(30000);
} catch (Exception e){
} finally {
// 3、解锁 假设解锁代码没有运行,redisson会不会出现死锁
lock.unlock();
System.out.println("释放锁..." + Thread.currentThread().getId());
}
return "hello";
}
5.3.2、公平锁(Fair Lock)
- 基于Redis的Redisson分布式可重入公平锁也是实现了
java.util.concurrent.locks.Lock
接口的一种RLock
对象。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。 - 它保证了当多个Redisson客户端线程同时请求加锁时,
- 优先分配给先发出请求的线程。所有请求线程会在一个队列中排队,当
- 某个线程出现宕机时,Redisson会等待5秒后继续下一个线程,也就是说如果前面有5个线程都处于等待状态,那么后面的线程会等待至少25秒。
RLock fairLock = redisson.getFairLock("anyLock");
// 最常见的使用方法
fairLock.lock();
使用方法都同上可重入锁一样,可重入锁是不公平锁!
5.3.3、读写锁(ReadWriteLock)
- 保证一定能读到最新数据,修改期间写锁是互斥锁(排他锁)。读锁是一个共享锁
- 写锁没释放读就必须等待
- 读 + 读:相当于无锁,并发读只会在Redis中记录好,所有当前的读锁。它们都会同时加锁成功
- 写 + 读:等待写锁释放
- 写 + 写:阻塞方式
- 读 + 写:有读锁,写也需要等待
- 只要有写锁的时候,都得等待
分布式可重入读写锁允许同时有多个读锁和一个写锁处于加锁状态。
RReadWriteLock rwlock = redisson.getReadWriteLock("anyRWLock");
// 最常见的使用方法
rwlock.readLock().lock();
// 或
rwlock.writeLock().lock();
另外Redisson还通过加锁的方法提供了leaseTime
的参数来指定加锁的时间。超过这个时间后锁便自动解开了。
// 10秒钟以后自动解锁
// 无需调用unlock方法手动解锁
rwlock.readLock().lock(10, TimeUnit.SECONDS);
// 或
rwlock.writeLock().lock(10, TimeUnit.SECONDS);
// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = rwlock.readLock().tryLock(100, 10, TimeUnit.SECONDS);
// 或
boolean res = rwlock.writeLock().tryLock(100, 10, TimeUnit.SECONDS);
...
lock.unlock();
测试代码:
@ResponseBody
@GetMapping("/write")
public String writeValue() {
RReadWriteLock lock = redisson.getReadWriteLock("rw-lock");
RLock rLock = lock.writeLock();
String s = "";
try {
// 1、该数据加写锁,读数据加读锁
rLock.lock();
System.out.println("写锁加锁成功..."+Thread.currentThread().getId());
s = UUID.randomUUID().toString();
Thread.sleep(30000);
redisTemplate.opsForValue().set("writeValue", s);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
rLock.unlock();
}
return s;
}
@ResponseBody
@GetMapping("/read")
public String readValue() {
RReadWriteLock lock = redisson.getReadWriteLock("rw-lock");
// 加读锁
RLock rLock = lock.readLock();
String s = "";
try {
rLock.lock();
System.out.println("读锁加锁成功..."+Thread.currentThread().getId());
Thread.sleep(30000);
s = (String) redisTemplate.opsForValue().get("writeValue");
} catch (Exception e) {
e.printStackTrace();
} finally {
rLock.unlock();
}
return s;
}
}
5.3.4、信号量(Semaphore)
信号量为存储在redis中的一个数字,当这个数字大于0时,即可以调用acquire()
方法增加数量,也可以调用release()
方法减少数量,但是当调用release()
之后小于0的话方法就会阻塞,直到数字大于0
RSemaphore semaphore = redisson.getSemaphore("semaphore");
semaphore.acquire();
//或
semaphore.acquireAsync();
semaphore.acquire(23);
semaphore.tryAcquire();
//或
semaphore.tryAcquireAsync();
semaphore.tryAcquire(23, TimeUnit.SECONDS);
//或
semaphore.tryAcquireAsync(23, TimeUnit.SECONDS);
semaphore.release(10);
semaphore.release();
//或
semaphore.releaseAsync();
测试代码:
@GetMapping("/park")
@ResponseBody
public String park() throws InterruptedException {
RSemaphore park = redisson.getSemaphore("park");
//park.acquire(); // 获取一个信号,获取一个值, 占一个车位
boolean b = park.tryAcquire(); // 尝试获取一下,有就执行没就不执行
return "ok=>"+b;
}
@GetMapping("/go")
@ResponseBody
public String gp() throws InterruptedException {
RSemaphore park = redisson.getSemaphore("park");
park.release(); // 释放一个车位
return "ok";
}
5.3.5、闭锁(CountDownLatch)
等待其他都完成之后,才关闭
基于Redisson的Redisson分布式闭锁(CountDownLatch)Java对象RCountDownLatch
采用了与java.util.concurrent.CountDownLatch
相似的接口和用法。
RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch");
latch.trySetCount(1);
latch.await();
// 在其他线程或其他JVM里
RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch");
latch.countDown();
测试代码:
/**
* 放假、锁门
* 1班没人了
* 5个班全部走完,我们才可以锁大门
*/
@GetMapping("/lockDoor")
@ResponseBody
public String lockDoor() throws InterruptedException {
RCountDownLatch door = redisson.getCountDownLatch("door");
door.trySetCount(5);
door.await(); // 等待闭锁都完成
return "放假啦...";
}
@GetMapping("/gogo/{id}")
public String goGo(@PathVariable("id") Long id){
RCountDownLatch door = redisson.getCountDownLatch("door");
door.countDown(); // 计数减1
return id+"班的人都走了...";
}
5.4、使用Redisson 来优化 从数据库中查询并封装分类数据方法
/**
* Redisson优化分布式锁
* @return
*/
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedissonLock() {
// 1、锁的名字。锁的粒度,越细越快。
// 锁的粒度:具体缓存的是某个数据。比如:11号商品;product-11-lock
RLock lock = redisson.getLock("catalogJson-lock");
lock.lock();
Map<String, List<Catelog2Vo>> dataFromDb;
try{
dataFromDb = getDataFromDb();
}finally {
lock.unlock();
}
return dataFromDb;
}
这个时候来解决我们一直思考的问题:缓存里面的数据如何和数据库保持一致
六、缓存数据一致性
6.1、缓存数据一致性【问题】
问题:缓存里面的数据如何和数据库保持一致
缓存数据一致性
-
双写模式 :修改数据库的时候同时修改缓存中的数据
- 问题:并发时,存在脏数据!
- 解决方案:
- 双写的时候加锁,写数据库和写缓存封装成原子性操作
- 允许暂时的脏读,设置缓存的过期时间,最终一致性
-
失效模式 :袖该数据库的时候删除缓存中的数据,等待下次主动查询进行更新
- 问题:还没存入数据库呢,线程2又读到旧的DB了
- 解决:
- 缓存设置过期时间,定期更新
- 写数据写时,加分布式的读写锁。
6.2、缓存数据一致性【解决方案】
缓存数据一致性——解决方案
- 无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。怎么办?
- 如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加上过期时间,每隔一段时间出触发读的主动更新即可
- 如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式
- 缓存数据+过期时间也足够解决大部分业务对于缓存的要求
- 通过加锁保证并发读写,并发写的时候按顺序排好队。并发读的时候无所谓。所以适合使用读写锁(业务不关心脏数据,允许临时脏数据可省略)
- 总结:
- 我们能放入缓存的根本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可。
- 我们不应该过度设计,增加系统的复杂性
- 遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点
6.3、本项目的缓存数据一致性的解决方案
本系统的一致性解决方案:
1、缓存的所有数据都有过期时间,数据过期下次查询触发主动更新
2、读写数据的时候,加上分布式的读写锁。(经常读写会有影响、偶尔写经常读没多大影响)
七、SpringCache
7.1、SpringCache简介
SpringCache简介
- 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、从缓存中读取之前缓存存储的数据
7.2、整合SpringCache
第一步、引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
第二步、加入配置
spring:
cache:
type: redis #指定缓存类型为redis
redis:
time-to-live: 3600000 # 指定缓存的数据的存活时间(毫秒为单位)
key-prefix: CACHE_ # 设置key的前缀,用来区分和reids其他键不同的.如果制定了前缀就用我们指定的前缀,如果没有就默认使用缓存的名字作为前缀
use-key-prefix: true # 设置是否使用前缀
cache-null-values: true # 是否缓存空值,防止缓存穿透
2.1、自动配置了哪些
-
CacheAutoCOnfiguration 会导入 RedisCacheConfiguration
-
RedisCacheConfiguration :自动配好了缓存管理器(RedisCacheManager)
2.2、我们需要配置哪些
-
配置使用redis作为缓存
第三步、给主类加上开启缓存注解
@EnableCaching
@EnableCaching
@EnableFeignClients(basePackages = "com.atguigu.gulimall.product.feign")
@EnableDiscoveryClient
@MapperScan("com.atguigu.gulimall.product.dao")
@SpringBootApplication
public class GulimallProductApplication {
public static void main(String[] args) {
SpringApplication.run(GulimallProductApplication.class, args);
}
}
7.3、使用缓存
@Cacheable
: 触发缓存填充(将数据保存到缓存的操作)
-
`@CacheEvict` : 触发缓存逐出(将数据从缓存中删除的操作)
-
`@CachePut` : 在不干扰方法执行的情况下更新缓存(不影响方法执行更新缓存)
-
`@Caching` : 重新组合要应用于一个方法的多个缓存操作(组合以上多个操作)
-
`@CacheConfig` : 在类级别共享一些常见的缓存相关设置(在类级别共享缓存的相同配置)
7.3.1、@Cacheable【读】
7.3.1.1、使用@Cacheable
给查找所有的一级分类方法加上@Cacheable,将该数据保存到缓存中去
@Cacheable
- 代表当前方法的结果需要缓存,如果缓存中有,方法不用调用。如果缓存中没有,会调用方法,最后将方法的结果放入缓存
- 每一个需要缓存的数据我们都来指定放到那个名字的缓存。【缓存的分区(按照业务类型分)】
查找所有的一级分类
- 1、每一个需要缓存的数据我们都来指定要放到那个名字的缓存。【缓存的分区(按照业务类型分)】
- 2、@Cacheable
- 代表当前方法的结果需要缓存,如果缓存中有,方法不用调用。如果缓存中没有,会调用方法,最后将方法的结果放入缓存
- 每一个需要缓存的数据我们都来指定放到那个名字的缓存。【缓存的分区(按照业务类型分)】CategoryServiceImpl实现类
-
3、默认行为
- 如果缓存中有,方法不用调用
- hey默认自动生成:缓存的名字::SimpleKey [] (自主生成的key值)如:category::SimpleKey []
- 缓存的value的值。默认使用jdk序列化机制,将序列化后的数据存到redis
- 默认ttl时间:-1
-
4、自定义
-
指定生成的缓存使用的key key属性指定,接收一个SpEL SpEL的详细语法
-
指定缓存的数据的存活时间 在配置文件中指定(spring.cache.redis.time-to-live=num毫秒)
-
将数据保存为json格式 需要自定义缓存配置
-
@Cacheable(value = {"category"}, key = "#root.methodName")
@Override
public List<CategoryEntity> getLevel1Categorys() {
System.out.println("getLevel1Categorys...");
List<CategoryEntity> categoryEntities = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
return categoryEntities;
}
测试,之后每次刷新首页都不再进入getLevel1Categorys()方法了!
7.3.1.2、自定义缓存配置
原理:
-
CacheConfigurations 缓存的自动配置帮我们导入了 -> RedisCacheConfiguration
-
-> 自动配置了RedisCacheManager -> 初始化所有的缓存 -> 每个缓存决定使用什么配置
-
->如果 redisCacheConfiguration 有就有已有的,没有就用默认配置
-
-> 想改缓存的配置,只需要给容器中放一个 RedisCacheConfiguration 即可
-
-> 就会应用到当前缓存 RedisCacheManager 管理的所有缓存分区中
编写自定义的缓存机制
默认使用jdk进行序列化(可读性差),默认ttl为-1永不过期,自定义序列化方式需要编写配置类
com.atguigu.gulimall.product.config
包下
@EnableConfigurationProperties(CacheProperties.class)
@EnableCaching
@Configuration
public class MyCacheConfig {
/**
* 配置文件中的配置没有用上
* 1、原来和配置文件绑定的配置类是这样子的
* @ConfigurationProperties(prefix = "spring.cache")
* public class CacheProperties {
* 2、要让他生效
* @EnableConfigurationProperties(CacheProperties.class)
* @return
*/
@Bean
RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties){
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
// 每个方法执行完都是返回了一个新对象,得覆盖原对象得值
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;
}
}
7.3.2、@CacheEvict【写】
触发缓存逐出(将数据从缓存中删除的操作)
@CacheEvict
:缓存失效模式(当修改数据库的时候删除指定key的缓存)
删除指定某个分组下的所有数据 :@CacheEvict(value = “category”, allEntries = true)@Caching
:同时进行多个缓存操作- @Caching(evict = {
- @CacheEvict(value = {“category”}, key = “‘getLevel1Categorys’”),
- @CacheEvict(value = {“category”}, key = “‘getCatalogJson’”)
- })
7.3.2.1、修改(级联更新所有关联数据)的方法
@CacheEvict(value = "category", allEntries = true)
@Transactional
@Override
public void updateCascade(CategoryEntity category) {
this.updateById(category);
categoryBrandRelationService.updateCategory(category.getCatId(),category.getName());
// 双写
// 失效:redis.del("catalogJSON"); 等待下次主动查询进行更新
}
7.3.2.2、修改(从数据库中查询并封装分类数据)的方法
/**
* 修改(从数据库中查询并封装分类数据)的方法【SpringCache】
* @return
*/
@Cacheable(value = "category",key = "#root.methodName")
@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
System.out.println("进行查询数据库");
List<CategoryEntity> selectList = baseMapper.selectList(null);
List<CategoryEntity> level1Categorys = getParent_cid(selectList, 0L);
// 2、封装数据
Map<String, List<Catelog2Vo>> parent_cid = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
// 2.1、每一个一集分类,查到这个一集分类的所有二级分类
List<CategoryEntity> categoryEntities = getParent_cid(selectList, v.getCatId());
// 2.2、封装上面的结果
List<Catelog2Vo> catelog2Vos = null;
if (categoryEntities != null) {
catelog2Vos = categoryEntities.stream().map(l2 -> {
Catelog2Vo catelog2Vo = new Catelog2Vo(v.getCatId().toString(), null, l2.getCatId().toString(), l2.getName());
// 2.3、找当前二级分类的三级分类封装成vo
List<CategoryEntity> level3Catelog = getParent_cid(selectList, l2.getCatId());
if (level3Catelog != null) {
List<Catelog2Vo.Catelog3Vo> collect = level3Catelog.stream().map(l3 -> {
// 封装成指定格式
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;
}
7.4、SpringCache不足
1)、读模式
- 缓存穿透:查询一个null数据; 解决:缓存空数据:cache-null-values: true
- 缓存击穿:大量并发进来同时查询一个正好过期的数据; 解决:加锁 ?sync = true本地锁
- 缓存雪崩:大量的key同时过期。 解决:加随机时间。加上过期时间 time-to-live: 3600000
2)、写模式:(缓存与数据库一致)
- 读写加锁
- 引入Canal,感知到MySQL的更新去更新数据库
- 读多写多,直接去数据库查询就行
原理:
- CacheManager(createRedisCacheManager)->Cache(createRedisCache)->Cache负责缓存的读写
总结:
- 常规数据(读多写少,及时性和一致性要求不高的数据)完全可以使用SpringCache;邪魔恶事(只要缓存的数据有过期时间就足够了)
- 特殊数据:特殊设计