为什么要使用分布式锁
加锁,我们都知道,就是为了在并发环境下,使一段代码在同一时间只能有一个线程执行。我们通常通过synchronized或ReentrantLock来实现加锁。比如:
synchronized(this){
//业务逻辑代码
}
这样的代码在单机环境下是可行的,但是不管是synchronized还是ReentrantLock都是JVM层面的,所以在分布式环境下,就不能这么写。
加锁的本质
其实加锁的本质都是在某个地方打一个标志,这个标志必须所有线程可见,线程在执行一块同步代码的时候,需要先打标志,如果标志已经存在,则需要等待拥有标志的线程结束同步代码块后取消标志。
不同加锁方式,打标志的方式也不同,但都必须满足所有线程能够看见的条件。如synchronized是在对象头打标志,Lock接口的实现类基本上都有一个由volitile修饰的int型变量,来保证每个线程都能拥有该int的可见性和原子性修改。Linux内核中也是利用互斥量或信号量等内存数据做标记。
知道了加锁的本质,我们就更清楚问什么synchronized和ReentrantLock都只能在单机下使用了,因为它们打的标志都是在当前JVM进程的内存中,对于其他JVM进程的线程并不可见。
那么在分布式环境下,应该怎么加锁的答案也就鱼跃而出了。我们需要将标志打在一个所有JVM进程都能看得见的地方。
分布式锁的解决方案
基于数据库做分布式锁
基于表主键唯一做分布式锁
思路:利用数据库主键唯一的特性,多个请求同时提交到数据库时,只有一个操作会成功,我们认为这个操作对应的线程获取了该方法的锁,当方法执行完毕后,删掉这条记录,来释放锁。
该方法存在下面几种问题:
- 这把锁强依赖数据库,数据库是一个单点,一旦数据库挂掉,则会导致业务系统不可用
- 这把锁没有失效时间,一旦解锁失败,锁记录一直在数据库中,其他线程将无法再获取锁
- 这把锁只能是非阻塞的,因为数据的插入失败后,就会直接报错,没有获得锁的线程不会进入排队队列,想要再次获得锁就要再次触发获得锁的操作
- 这把锁是非重入锁,同一线程在没有释放锁前无法再次获得该锁,因为锁记录已经存在数据库中
- 这把锁是非公平锁,所有等待锁的线程凭运气去争夺锁
- 在MySQL数据库中采用主键冲突防重,在大并发情况下有可能造成锁表现象
当然,我们也可以有其他方式解决上面的问题。
- 数据库是单点?搞两个数据库,数据之前双向同步,一旦挂掉快速切换到备库上。
- 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
- 非阻塞的?搞一个 while 循环,直到 insert 成功再返回成功。(自旋很占CPU)
- 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
- 非公平的?再建一张中间表,将等待锁的线程全记录下来,并根据创建时间排序,只有最先创建的允许获取锁。
- 比较好的办法是在程序中生产主键进行防重。
说句实话,这个方法问题多,实现起来也麻烦,了解一下思路就可以了。
基于表字段版本号做分布式锁
类我们要为每个表设计一个版本号字段,然后写一条判断 sql 每次进行判断,增加了数据库操作的次数,在高并发的要求下,对数据库连接的开销也是无法忍受的。
基于数据库排他锁做分布式锁
在查询语句后面添加for update
,数据库会在查询过程中添加排他锁,但要注意InnoDB引擎在只有使用索引进行检索的时候,才会使用行级锁,否则会使用表级锁。所以如果我们需要使用行级锁,我们就需要为执行的方法字段添加索引。
我们认为获得排他锁即获得分布式锁,获得锁后,才可以执行方法的业务逻辑,执行方法后,通过connection.commit()
操作来释放锁。
排他锁解决了阻塞锁和无法释放锁的问题:
- 阻塞锁?
for update
语句执行成功后立即返回,在执行失败时一直处于阻塞状态,知道成功。 - 锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。
但是还是无法直接解决数据库单点问题和可重入问题。
这里可能还存在俩个问题:
- 即使我们为执行的字段添加了唯一索引,但是MYSQL是根据执行计划来决定是否走索引的,如果MySQL认为走索引的效率还没有全表扫描快,那么它就不会走索引,这种情况下使用的就是表锁而不是行锁。
- 如果排他锁长时间不提交,就会一直占用数据库的连接,一旦类似的连接变多了,就可能把数据库连接池撑爆。
优点: 简单,容易理解
缺点: 会有各种各样的问题(操作数据库需要一定的开销,使用数据库的行级锁并不一定靠谱,性能不靠谱)
基于Redis做分布式锁
我们可以使用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已经帮我们实现了。
redission
使用步骤:
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做分布式锁
首先回顾一下Zookeeper结点的四种类型:
- 持久节点: 默认节点类型。创建节点的客户端与zookeeper断开连接后,该节点依旧存在 。
- 持久顺序节点: 所谓顺序节点,就是在创建节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号
- 临时节点: 与持久节点相反,当创建节点的客户端与zookeeper断开连接后,临时节点会被删除。
- 临时顺序节点: 在创建节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号;当创建节点的客户端与zookeeper断开连接后,临时节点会被删除。
Zookeeper分布式锁的原理
原理: 利用zk的临时有序节点,以及监听节点删除事件。
具体步骤:
- 客户端连接zk,在/lock中创建临时有序节点,第一个客户端对应的临时节点为/lock/lock-0000000000,第二个为/lock/lock-0000000001,以此类推。
- 当节点删除事件触发时,客户端获取/lock下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁,否则监听自己前一位子节点的变更消息,获得子节点变更通知后重复此步骤直至获得锁;
- 执行业务代码;
- 完成业务流程后,删除对应的子节点释放锁。
几个注意点提一下:
- 为什么使用临时节点而不是持久节点?为了避免加锁成功的客户端宕机,导致锁不能被释放。临时节点客户端断开连接后,会自动被删除。
- 为什么要使用顺序节点?为了避免出现羊群效应,假设所有的客户端都监听同一个节点,当这个节点被删除后,就会使所有等待中的客户端蜂拥而入,从而造成瞬间的高并发。使用顺序节点后,每个节点都只监听它前一个节点,这样就可以一个一个被唤醒。
- Zookeeper可能带来并发问题。如果由于网络原因,使zk误以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁,就可能尝试并发问题。但这种问题不常见,因为zk有重试机制,并不会马上删除临时节点。
与redis一样,zookeeper也有一个优秀的框架curator
可以简单的实现分布式锁。但其实我对zookeeper并不是很了解,所以关于zookeeper这块的内容以后有机会再补充。
总结
Redis
优点:性能高
缺点:如果没有获取到锁,需要不断的自旋尝试获取锁,比较消耗性能
zookeeper
优点:获取不到锁,只需注册个监听器即可,不需要不断尝试获取锁,性能开销较小。
缺点:性能较低。因为加锁和释放锁都是通过动态创建、销毁节点实现的
Mysql
虽然容易理解,实现起来复杂,问题还挺多,性能不也行,我想应该很少用吧。
本篇博客内容概述:
脑图链接地址
参考文章
https://www.cnblogs.com/seesun2012/p/9214653.html
https://mp.weixin.qq.com/s/ZqQHWLfVD1Rz1agmH3LWrg
是博客亦是日记
用博客打造属于自己的知识体系
记录自己的成长