赞
踩
加锁,我们都知道,就是为了在并发环境下,使一段代码在同一时间只能有一个线程执行。我们通常通过synchronized或ReentrantLock来实现加锁。比如:
synchronized(this){
//业务逻辑代码
}
这样的代码在单机环境下是可行的,但是不管是synchronized还是ReentrantLock都是JVM层面的,所以在分布式环境下,就不能这么写。
加锁的本质
其实加锁的本质都是在某个地方打一个标志,这个标志必须所有线程可见,线程在执行一块同步代码的时候,需要先打标志,如果标志已经存在,则需要等待拥有标志的线程结束同步代码块后取消标志。
不同加锁方式,打标志的方式也不同,但都必须满足所有线程能够看见的条件。如synchronized是在对象头打标志,Lock接口的实现类基本上都有一个由volitile修饰的int型变量,来保证每个线程都能拥有该int的可见性和原子性修改。Linux内核中也是利用互斥量或信号量等内存数据做标记。
知道了加锁的本质,我们就更清楚问什么synchronized和ReentrantLock都只能在单机下使用了,因为它们打的标志都是在当前JVM进程的内存中,对于其他JVM进程的线程并不可见。
那么在分布式环境下,应该怎么加锁的答案也就鱼跃而出了。我们需要将标志打在一个所有JVM进程都能看得见的地方。
思路:利用数据库主键唯一的特性,多个请求同时提交到数据库时,只有一个操作会成功,我们认为这个操作对应的线程获取了该方法的锁,当方法执行完毕后,删掉这条记录,来释放锁。
该方法存在下面几种问题:
当然,我们也可以有其他方式解决上面的问题。
说句实话,这个方法问题多,实现起来也麻烦,了解一下思路就可以了。
类我们要为每个表设计一个版本号字段,然后写一条判断 sql 每次进行判断,增加了数据库操作的次数,在高并发的要求下,对数据库连接的开销也是无法忍受的。
在查询语句后面添加for update
,数据库会在查询过程中添加排他锁,但要注意InnoDB引擎在只有使用索引进行检索的时候,才会使用行级锁,否则会使用表级锁。所以如果我们需要使用行级锁,我们就需要为执行的方法字段添加索引。
我们认为获得排他锁即获得分布式锁,获得锁后,才可以执行方法的业务逻辑,执行方法后,通过connection.commit()
操作来释放锁。
排他锁解决了阻塞锁和无法释放锁的问题:
for update
语句执行成功后立即返回,在执行失败时一直处于阻塞状态,知道成功。但是还是无法直接解决数据库单点问题和可重入问题。
这里可能还存在俩个问题:
优点: 简单,容易理解
缺点: 会有各种各样的问题(操作数据库需要一定的开销,使用数据库的行级锁并不一定靠谱,性能不靠谱)
我们可以使用redis的setnx
和expire
命令来实现分布式锁。
setnx:向redis中存放一个键值对,key不存在执行成功返回1,key存在返回0。我们认为setnx执行成功,即拿到分布式锁。
expire: 给指定键设置过期时间。可以用来做自动释放锁。
原理: 在执行业务代码之前,先使用setnx
往redis中保存一个键值对,执行成功则获取分布式锁,在业务执行完后,再删除这个键值对,从而释放锁。
我们要求setnx
和expire
必须是一个原子操作,避免setnx
执行后,程序宕机,expire
没执行,或者delete
执行之前程序宕机,导致锁无法被释放。整合Spring Boot后的代码如下:
// stringRedisTemplate.opsForValue().setIfAbsent(productKey, "1");
// stringRedisTemplate.expire(productKey,10,TimeUnit.SECONDS);
//合并了上面俩条语句,使它们成为一个原子操作
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);//超时时间为10秒
try {
if (result){
// 获取锁成功,执行业务代码
}else{
//获取锁失败
}
} finally {
// 释放锁
stringRedisTemplate.delete("lockKey");
}
}
使用这种方法,已经可以满足大多数普通的并发场景了,但是对于高并发的场景还是会出现一些问题。
当业务代码还未执行完时,锁自动失效了,导致误删除别人锁的情况,如下图:
线程1的锁在10秒后,自动失效,此时线程2成功加锁。线程1在5秒后业务代码执行完删除锁,但此时的锁是线程2加的,这就出现了误删的情况。
当然,我们可以使用一个唯一标识来判断是不是自己加的锁,如下:
//唯一标识
String clientId = UUID.randomUUID().toString();
try {
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS);//超时时间为30秒
if (result){
// 获取锁成功,执行业务代码
}else{
//获取锁失败
}
} finally {
// 自己释放自己的锁
if (clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))){
stringRedisTemplate.delete(lockKey);
}
}
虽然这样做可以防止删除别人加的锁,但是仍然无法避免锁在业务完成前失效,导致其他线程提前获取到锁。
我们可以使用锁续命技术来解决这个问题。
锁续命技术:假设锁的过期时间是30秒,那么开一个线程,每过10秒(30/3)对锁进行一次检查,如果锁还存在,就重新将过期时间设置为30秒,如果锁不存在,就是被释放了呗。
如何实现?一个优秀的框架redision已经帮我们实现了。
使用步骤:
1、导入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.6.5</version>
</dependency>
2、编写配置类
@Bean(destroyMethod="shutdown") public RedissonClient redisson() { // 此为单机模式 Config config = new Config(); config.useSingleServer(). javasetAddress("redis://118.190.160.249:6379").setDatabase(0); // 集群版本 /*config.useClusterServers() .addNodeAddress("redis://192.168.0.61:8001") .addNodeAddress("redis://192.168.0.62:8002") .addNodeAddress("redis://192.168.0.63:8003") .addNodeAddress("redis://192.168.0.61:8004") .addNodeAddress("redis://192.168.0.62:8005") .addNodeAddress("redis://192.168.0.63:8006");*/ return (Redisson) Redisson.create(config); }
3、使用RedissonClient
客户端
@Autowired
RedissonClient redisson;
RLock redissonLock = redisson.getLock(lockKey);
try {
//加锁,并实现锁续命功能
redissonLock.lock();
//业务代码...
} catch (Exception e) {
e.printStackTrace();
} finally {
//释放锁
redissonLock.unlock();
}
}
使用redisson,可以向使用ReentrantLock一样来加锁解锁,很方便。
首先回顾一下Zookeeper结点的四种类型:
原理: 利用zk的临时有序节点,以及监听节点删除事件。
具体步骤:
几个注意点提一下:
与redis一样,zookeeper也有一个优秀的框架curator
可以简单的实现分布式锁。但其实我对zookeeper并不是很了解,所以关于zookeeper这块的内容以后有机会再补充。
Redis
优点:性能高
缺点:如果没有获取到锁,需要不断的自旋尝试获取锁,比较消耗性能
zookeeper
优点:获取不到锁,只需注册个监听器即可,不需要不断尝试获取锁,性能开销较小。
缺点:性能较低。因为加锁和释放锁都是通过动态创建、销毁节点实现的
Mysql
虽然容易理解,实现起来复杂,问题还挺多,性能不也行,我想应该很少用吧。
本篇博客内容概述:
脑图链接地址
参考文章
https://www.cnblogs.com/seesun2012/p/9214653.html
https://mp.weixin.qq.com/s/ZqQHWLfVD1Rz1agmH3LWrg
是博客亦是日记
用博客打造属于自己的知识体系
记录自己的成长
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。