分布式锁
分布式锁介绍
什么是分布式
一个大型的系统往往被分为几个子系统来做,一个子系统可以部署在多个服务器上,
分布式就是通过计算机网络将后端工作分布到多台主机上,多个主机一起协同完成工作
什么是锁
java程序中,当存在多个线程可以同时改变某个变量(可变共享变量)时,就需要对变量或代码做同步,使其在修改这种变量时能够线性执行消除并发修改变量,而同步的本质是通过锁来实现的.如java中synchronize是在对象头设置标记,Lock接口的实现类基本上都只是某一个volitile修饰的int类型变量其保证每个线程都能拥有对该int的可见性和原子修改
什么是分布式锁
任何一个分布式系统都无法同时满足一致性,可用性和分区容错性,最多只能满足两项
当在分布式模型下,数据只有一份,此时需要利用锁的技术控制某一时刻修改数据的进程数.
分布式锁:在分布式环境下,多个程序都需要对某一份(或有限制)的数据进行修改时,针对程序进行控制,保证同一时间节点下,只有一个程序对数据进行操作的技术
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-83HuClbo-1619060478621)(C:\Users\lenovo\AppData\Local\Temp\1608605072464.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jTDpET0W-1619060478625)(C:\Users\lenovo\AppData\Local\Temp\1608606005527.png)]
分布式锁执行流程
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0S1BwgGe-1619060478628)(C:\Users\lenovo\AppData\Local\Temp\1608606171366.png)]
分布式锁具备的条件
1.互斥性:同一时刻只能有一个服务(或应用)访问资源,特殊情况下有读写锁
2.原子性:一致性要求保证加锁和解锁的行为是原子性的
3.安全性:锁只能被持有该锁的服务(或应用)释放
4.容错性:在持有锁的服务崩溃时,锁仍能得到释放避免死锁
5.可重用性:同一个客户端获得锁后可递归调用 ---重入锁和不可重入锁
6.公平性:看业务是否需要公平,避免饿死--- 公平锁和非公平锁
7.支持阻塞和非阻塞: 和ReentrantLock一样支持lock和unlock 以及trylock(long timeout)--阻塞锁和非阻塞锁
8.高可用: 获取锁和释放锁要高可用
9.高性能: 获取锁和释放锁的性能要好
10.持久性: 锁按业务需要自动续约/自动延期
分布式锁的解决方案
数据库实现分布式锁
1.基于数据库表实现
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SWbSo9MP-1619060478633)(C:\Users\lenovo\AppData\Local\Temp\1608642748748.png)]
2.基于数据库排他锁实现
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vlQ6ngFO-1619060478637)(C:\Users\lenovo\AppData\Local\Temp\1608642943603.png)]
实现步骤:
1.java代码中,进行一个带有for update的查询,锁定某行数据
2.进行休眠5秒中,这5秒时间内,其他的连接一律不能够使用这条数据
3.进行commit,释放锁,其他连接就可以操作这条数据了
优点:简单,好实现 缺点:基于数据库,开销比较大,对数据库性能可能会存在影响
Redis实现分布式锁
基于redis的setnx(),expire(),getset()方法做分布式锁
@RestController
@RequestMapping("/lock")
public class LockController {
@Autowired
private StringRedisTemplate redisTemplate;
private static String PREFIX_KEY = "LOCK_";
/**
* redis 实现分布式锁
* @return
*/
@GetMapping("/order")
public R order(){
String orderId ="1";
//值设置成当前线程的唯一标识
String threadId = UUID.randomUUID().toString();
try{
/*
1. 加锁,setnx 命令, 如果该key有值(意味着已经上锁),则返回null 如果该key无值(意味着无 锁,可以加锁),则返回1
2. 并且设置过期时间,防止程序崩溃导致无法释放锁,从而形成死锁(注意设置值和设置过期时间是一 条API,要保证两者的原子性,
不能分为两个API进行调用,如果在调用完设置值后服务down掉,岂不是过期时间没有设置成功后, 那么当服务起来后,该锁会一直存在且不会被释放)
3. 如果程序执行时间超过了设置的过期时间数,怎么办?当第一个线程如果执行了12秒才结束,那么他 的锁自己早就被释放了,那他在第12秒执行完后去释放的锁是谁的?是后面的第二个线程的...,
而后面的第二个线程还没有执行完,锁却被释放掉了,
此时第三个线程进来轻而易举拿到了锁,那此时的锁还是锁吗?(永久失效)
那么这个过期时间应该怎么设置呢? 设置20S? 30S? 都不太合适
思路一: 可以在主线程获取到锁后,开一个分支线程去执行定时查询,查询锁的过期时间,如果剩余 时间不足三分之一,则重新设置该锁的过期时间,
业务执行完成,停止定时任务并释放锁, 但是思路是对的,实现起来相对复杂,所以我们 可以采用现成的解决方案->redission
*/
Boolean res = redisTemplate.opsForValue()
.setIfAbsent(PREFIX_KEY + orderId, threadId,10, TimeUnit.SECONDS);
if(!res){
return R.failed("服务器繁忙");
}
//执行业务代码
//xxxxxxxx
}finally {
//释放锁(哪个线程加的锁,哪个线程释放锁)
if(threadId.equals(redisTemplate.opsForValue().get(PREFIX_KEY + orderId))){
redisTemplate.delete(PREFIX_KEY + orderId);
}
}
return R.ok();
}
}
Redisson实现分布式锁
/**
* redisson 实现分布式锁
* @return
*/
@GetMapping("/order1")
public R order1(){
RLock redissonLock = redisson.getLock(PREFIX_KEY + orderId);
try{
//加锁
redissonLock.lock(30,TimeUnit.SECONDS);
//执行业务代码
}finally {
//释放锁
redissonLock.unlock();
}
return R.ok();
}
Redisson实现分布式锁原理
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Jfv8Q8EJ-1619060478641)(C:\Users\lenovo\AppData\Local\Temp\1608729458834.png)]
问题一:但是如果redis部署的是集群架构的话:
当主从结构中,主库down掉,那么从库会跟据选举机制选举出新的主库,然后再把数据同步,但就在数据同步之前,第二个请求线程进来想要获取锁,此时是可以获取到的,因为锁还没有通不过来嘛,那么这便出现了BUG
问题二:
虽然redis的性能已经很不错了,可以满足大多数中小互联网公司的业务量,但是如何能将性能成倍数的提升呢?如果有更大并发量的业务需要实现呢?
缓存使用
为了系统性能的提升,我们一般都会将部分数据放入缓存中,加速访问.而DB承担数据落盘工作
哪些数据适合放入缓存?
1.即时性,数据一致性要求不高的.
2.访问量大且更新频率不高的数据(读多,写少)
springboot引入starter依赖,想知道yml里怎么配置,可以查看xxxAutoConfigutarion里的xxxproperties
如果想把json串转换为对象,并且是一个复杂的对象,比如:Map<String,List<xxx>>
JSON.parseObject(jsonStrng,new TypeReference<Map<String,List<xxx>>>(){});
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TVU5bHuw-1619060478645)(C:\Users\lenovo\AppData\Local\Temp\1612680021380.png)]
压测下原生spring-data-redis-starter的问题
进行压力测试,发现运行一会后会爆出OutOfDirectMemoryError->堆外内存溢出
1.springboot2.0以后spring-data-redis-starter默认会使用lettuce作为操作redis的客户端,它使用netty进行网络通信
2.lettuce的bug导致netty堆外内存溢出,这里如果没有指定堆外内存,那么默认使用的就是服务启动指定的jvm内存
3.解决方案:(1)改造lettuce (2)使用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>
springboot2.1.8版本是这样的,后续lettuce如果进行了修复,还是可以直接使用的
后续:
其实不排除lettuce也可以使用jedis,只需要引入jedis依赖后,在yml中做出相应的配置
spring.redis.client-type=jedis
缓存穿透
指查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也无此记录,我们没有将这次查询的null写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义
风险
利用不存在的数据进行攻击,数据库瞬时压力增大,最终导致崩溃
解决
null结果缓存,并加入短暂过期时间
缓存雪崩
指我们设置缓存时key采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩
解决
原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件
缓存击穿
对于一些设置了过期过期时间的key,如果这些key可能会在某些时间点被超高并发的访问,是一种非常"热点"的数据
如果这个key在大量请求同时进来前正好失效,那么所有对这个key的数据查询都落在db,我们称之为缓存击穿
解决
加锁,大量并发只让一个人去查,其他人会等待,查到以后入数据至缓存,然后释放锁,其他人获取到锁,先查缓存,就会有数据,不用去DB查
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-G6V5328P-1619060478649)(C:\Users\lenovo\AppData\Local\Temp\1612684314882.png)]
分布式锁
核心命令
set nx
向redis当中存入一个key,若这个key存在,则执行失败,也就是占锁失败,执行成功,则占锁成功
Redisson
<!-- https://mvnrepository.com/artifact/org.redisson/redisson -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.14.1</version>
</dependency>
1.Redisson实现可重入锁(Reentrant lock)
/**
* redisson 实现分布式锁
* @return
*/
@GetMapping("/hello")
public R hello(){
//1.获取锁,只要锁的名字一样,所有人的锁就是同一把
RLock lock = redisson.getLock("mylock");
System.out.println(Thread.currentThread().getId()+": 尝试获取锁");
//2.加锁
lock.lock();
System.out.println(Thread.currentThread().getId()+": 成功获取锁");
try {
//3.执行业务代码
System.out.println(Thread.currentThread().getId()+": 正在执行业务");
try {
Thread.sleep(30000);
} catch (InterruptedException e) {
}
}finally {
//4.解锁 -------->
lock.unlock();
System.out.println(Thread.currentThread().getId()+": 释放锁");
}
return R.ok();
}
如果再执行解锁代码之前,服务down掉,那么锁会不会被释放掉呢?
答: 锁会自动释放掉
如果业务超长,锁到达了释放时间,会被删除掉吗?
答: 不会,因为锁会自动续期,如果运行时间超长,运行期间redisson会自动给锁续上30s.(redisson锁默认30s)
如果业务运行完成,就不会给锁续期,我们可以手动释放锁,甚至无手动释放代码,锁也会满足过期时间后被释放
lock.lock();该方法锁在过期前如果业务还没有执行完,会自动续期
如果未指定超时时间,就是用默认配置30s为锁过期时间,只要占锁成功,
就会启动定时任务[重新给锁设置过期时间,重新设置的过期时间还是默认配置30s]
定时任务的执行周期是,锁的过期时间的1/3
lock.lock(10,TimeUnit.SECONDS); //10后自动解锁
如果使用该方法,锁不会自动续期
如果传递了锁的超时时间,就发送给redis执行脚本,进行占锁,默认超时就是我们指定的时间
最佳实战:
lock.lock(10,TimeUnit.SECONDS);省掉了整个续期操作,手动解锁
尝试获取锁,超过了一定时间放弃获锁
public void test1(){
RLock mylock = redisson.getLock("mylock");
try {
boolean b = mylock.tryLock(100, 10, TimeUnit.SECONDS);
if(b){
//执行业务代码
}
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
mylock.unlock();
}
}
公平锁
RLock fairLock = redisson.getFairLock("anyLock");
fairLock.lock();
当10个线程争抢锁,第一个线程抢到锁后,剩余的线程则会依照顺序依次获取锁
读写锁
ReadWriteLock rwlock = redisson.getReadWriteLock("xxx");
//
rwlock.readLock().lock();
//
rwlock.writelock().lock();
分布式可重入读写锁允许同时又多个读锁和一个写锁处于加锁状态
保证一定能读到最新数据,修改期间,写锁是一个排它锁,读锁是一个共享锁
写锁没释放,读就必须等待
反之
读锁如果没释放,写锁就必须等待
但是读锁没释放,如果再来读操作,则无需等待
可见: 读锁为共享锁,写锁为排它锁
/**
* 读写锁
* 写操作
*/
@GetMapping("/write")
public String write(){
RReadWriteLock lock = redisson.getReadWriteLock("wr-lock");
RLock rlock = lock.writeLock();
String s =UUID.randomUUID().toString();
try{
//.写操作就加写锁,读操作就加读锁
rlock.lock();
System.out.println("写锁加锁成功......"+Thread.currentThread().getId());
//模拟写的过程,写锁没释放,如果有人请求读,也会阻塞,知道写锁释放后,读请求才能成功
Thread.sleep(10000);
redisTemplate.opsForValue().set("writeValue",s);
}catch (InterruptedException e) {
e.printStackTrace();
}finally {
rlock.unlock();
System.out.println("写锁释放锁");
return s;
}
}
/**
* 读写锁
* 读操作
*/
@GetMapping("/read")
public String read(){
RReadWriteLock lock = redisson.getReadWriteLock("wr-lock");
RLock rlock = lock.readLock();
String writeValue =null;
try{
//.写操作就加写锁,读操作就加读锁
rlock.lock();
System.out.println("读锁加锁成功......"+Thread.currentThread().getId());
writeValue = redisTemplate.opsForValue().get("writeValue");
}catch (Exception e) {
e.printStackTrace();
}finally {
rlock.unlock();
System.out.println("读锁释放锁.....");
return writeValue;
}
}
闭锁(CountDownLatch)
基于Redisson的分布式闭锁,采用了与JUC包相似的接口和用法
/**
* 5个班级人都做了,保安放假锁门
* @return
*/
@GetMapping("/lockDoor")
public R lockDoor() throws InterruptedException {
RCountDownLatch door = redisson.getCountDownLatch("door");
door.trySetCount(5);
door.await(); //等待闭锁都完成
return R.ok("放假了...");
}
@GetMapping("/gogogo/{id}")
public R gogogo(@PathVariable("id")Long id){
RCountDownLatch door = redisson.getCountDownLatch("door");
door.countDown();
return R.ok(id+"班的人都走了");
}
信号量(Semaphore)
/**
* 信号量也可以用作分布式限流
* 车库停车
* @return
* @throws InterruptedException
*/
@GetMapping("/park")
public R park() throws InterruptedException {
RSemaphore semaphore = redisson.getSemaphore("park");
semaphore.acquire();//获取一个信号,占一个车位
semaphore.tryAcquire();// 尝试获取一个车位,有就获取,没有也不等待,直接返回
return R.ok();
}
@GetMapping("/go")
public R go(){
RSemaphore semaphore = redisson.getSemaphore("park");
semaphore.release(); //释放一个车位
return R.ok();
}
缓存数据一致性–解决方案
无论是双写模式还是失效模式,都会导致缓存的不一致问题,即多个实例同时更新会出事,怎么办?
1.如果是用户维度数据(订单数据,用户数据),这种并发几率小,不用考虑这个问题,缓存数据加上过期时间,每隔一段时间触发读的主动更新即可
2.如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式
3.缓存数据+过期时间也足够解决大部分企业业务对于缓存的要求
4.通过加锁保证并发读写,写写的时候按顺序排好队.读读无所谓,所以适合使用读写锁.(业务不关心脏数据,允许临时脏数据可忽略)
总结:
我们能放入缓存的数据本就不该是实时性,一致性要求超高的,所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可
我们不应该过渡设计,增加系统的复杂性
遇到实时性,一致性要求高的数据,就应该查数据库,即使慢点
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-srqzBpN0-1619060478651)(C:\Users\lenovo\AppData\Local\Temp\1614240011107.png)]
canal作为一个阿里开源的中间件,可以实现代码层面无感知的刷新数据缓存,
还可以作为分析处理中间件,比如分析电商网站的用户访问记录表,收藏点赞表,购物车表等等,拿来数据做分析计算,
然后组装好用户推荐表,这样用户登录后直接访问的便是这个表数据,提升了整体加载性能
但canal的使用还是对于相对复杂的或大数据量的处理来用的,一般的系统也要视情况而定