基于数据库实现分布式锁
思路
- 新建一张锁表就是用来加锁的表格;
- 谁往数据表格里添加数据成功就获得了锁;
利用数据库的行级锁,主键唯一性。 - 解锁的时候把数据删除掉。
别的线程就可以竞争插入这个数据从而获得锁。
代码实现
创建表格
使用mybatis实现操作锁表
定义mapper接口
定义mapper文件
定义锁类实现Lock接口
这里实现Lock接口是为了使用Lock中的对锁抽象出来的接口,而且也有利于我们替换实现。
只需要重写,lock(),tryLock(),unLock()方法即可!
- tryLock方法,向数据库中的t_lock表格中添加id为1的数据,如果添加成功代表获取到了锁。没有代表没有。
- lock方法应该实现阻塞式加锁,所以会通过递归方法不断调用tryLock方法获取锁。
- unLock方法就是通过删除t_lock中的数据进行解锁的。
使用
我们使用的时候只需要像使用ReentrantLock类一样:
public static void main(String[] args) {
Lock lock=new MySqlLock();
try {
lock.lock();
//这里做业务即可!
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
缺点
- 数据库连接数量是有限的,如果并发比较大不建议使用。
- 锁的失效时间难以控制,容易造成死锁;
这里理解死锁:当程序即将要去执行解锁操作时程序挂了,就是数据库中那条数据一直在数据库中存在其他的线程无法插入也就无法获取到锁;
使用Redis实现分布式锁
Redis实现分布式锁的关键知识
-
SETNX命令的理解
SETNX key Value,这个命令的特征是,如果这个key不存在就将value存储到缓存中,如果key存在不做任何事情,
SETNX的意思是:SET if not exists (如果不存在就是set),加上Redis是单进程单线程模式的,所以不用担心Redis本身的并发问题。所以:可以利用这点实现锁,如果set成功就获取锁,如果set失败就获取锁失败。 -
缓存有效期
Redis中的数据可以设置有效期,当key过期的时候,会被自动删除。这个特性可以保证不会出现死锁。 -
lua脚本
lua是一个清凉小乔的脚本语言,被Redis用来支持操作序列的原子性,可以简单理解为数据库的存储过程。
我们使用它来实现解锁的原子性。
Redis加锁和解锁必须注意的
加锁
- 通过setnx向特定的key写入一个随机值, 并同时设置失效时间,写值成功就是加锁成功!
这里要理解几点:
为啥必须要设置一个失效时间?
因为一旦线程加锁成功但是在解锁的时候挂了,如果没有失效时间这样就导致redis中始终存在这个key,那其他的线程就无法再set值了也就是无法再获取锁了,这个就是死锁;
为啥加锁时要产生一个随机字符串或者是其他唯一标识呢?
因为为了避免锁的误删除,比如:A线程获取到了锁并且设置了失效时间为2s,但是,某次执行时间超过了2s,那这个redis中的key就过期自动删除了,这个时候B线程set成功,获取到了锁,
等线程A执行完业务之后要去解锁的时候,获取到的锁就是B设置的,如果没有唯一个value去限制就把B线程的锁删除了。
为啥写入随机值的操作和设置失效时间必须是同时的呢,使用set,expire?
因为如果不保证这两个操作是原子的话,假设,执行完第一步set,等到要去执行expire设置有效时间的时候,程序挂了,这也是导致了死锁。
解锁
解锁时要匹配随机值,删除redis上的key数据,必须保证获取数据,判断一致以及删除数据这三个操作必须是原子的;
为什么呢?同样的道理,当你去读取完key值之后,刚判断完这个锁是会否是在自己加的,准备去删除key,就在这个时候你的key过期了,其他的进程就趁机写入了一个key
这个时候其实这个key的值已经不是你设置的了,但是你的判断已经成功了,所以你执行了删除,就把别人的锁解开了。
可以借助lua脚本实现三个操作的原子性:
if redis.call("get",KEYS[1])==ARGV[1] then
return redis.call("del",KEYS[1]);
else
return 0
end
代码实现
引入必要的依赖
redis的客户端,以及spring对redis的封装;
在全局配置文件中配置redis连接信息
创建lua脚本
定义锁类实现Lock接口
- 需要知道的一点是ThreadLocal实现了方法之间的参数传递!
主从的单点失败问题
场景描述
客户端A从master获取锁
master将锁同步到slave之前就宕机了
slave被晋升为master
这时候客户端B从redis中是可以获取到锁的,这个时候系统中就存在了A,B都持有锁,破坏了锁的互斥性!
解决办法:RedLock算法
将获取锁方式改变一下;
- 获取当前时间,单位是毫秒
- redis要搭建一个集群使用多个master的方式,
- 依次向 多个master的 redis 服务器发送请求
- 若有超过半数的服务器返回加锁成功,并且时间在锁的有效期内,就完成加锁,如果没有则自动依次执行解锁,并等待一段随机时间后重试。
(客观原因加锁失败:网络情况不好、服务器未响应等问题, 等待一段随机时间后重试可以避开“蜂拥而进”的情况造成服务器资源占用瞬时猛增 ) - 如有其中任意一台服务器已经持有该锁,则加锁失败, 等待一段随机时间后重试。 (主观原因加锁失败:已经被被别人锁上了)
使用ZooKeeper实现分布式锁
ZooKeeper实现分布式锁的关键知识
Zookeeper的内存数据结构
- Zookeeper在内存会维护一个具有层次关系的数据结构,它非常类似于一个文件系统目录树结构。
- Zookeeper和文件系统不同的是linux文件系统有目录和文件的区别,ZooKeeper统一叫做znode,一个znode节点可以包含子znode,同时也可以包含数据!
临时顺序节点
- Zookeeper中的节点分为4中:
1.持久化节点
所谓持久节点,是指在节点创建后,就一直存在,直到有删除操作来主动清除这个节点——不会因为创建该节点的客户端会话失效而消失。
2.持久化顺序节点
这类节点的基本特性和上面的节点类型是一致的。特征是,在ZK中,每个父节点会为他的第一级子节点维护一份时序,会记录每个子节点创建的先后顺序。
基于这个特性,在创建子节点的时候,可以设置这个属性,那么在创建节点过程中,ZK会自动为给定节点名加上一个数字后缀,作为新的节点名。这个数字后缀的范围是整型的最大值。
3.临时节点
是指在节点创建后,如果创建该节点的客户端会话结束改节点就会被删除。
4.临时顺序节点
临时顺序节点可以用来实现分布式锁
Zookeeper的监听机制
客户端可以在znode在某个节点上注册监听器,当目录节点发生变化(数据改变、节点删除、子目录节点增加删除)时,
ZooKeeper会通知客户端。监听机制保证ZooKeeper保存的任何的数据的任何改变都能快速的响应到监听了该节点的应用程序。
本质:监听器的工作机制,其实是在客户端会专门创建一个监听线程,在本机的一个端口上等待zk集群发送过来的事件!
利用zk这一特性可以实现对获取锁时阻塞的逻辑
代码实现
定义锁类实现Lock接口
解释代码:
在初始化这个zookeeperImproveLock类的时候(构造函数内)就创建了一个根节点:LOCK_PATH="/LOCK",
让所有的客户端在这个节点下创建临时有序的子节点。
- tryLock方法的逻辑;(非阻塞获取锁)
如果当前节点不等于空,就在/LOCK节点下创建一个临时有序的节点,并返回赋值给变量currentPath;
然后获取/LOCK节点下的所有子节点,并进行排序。
判断当前节点是否为所有子节点的第一个,
如果是那就代表获取到了锁。(假设共有节点:0001,0002,0003,那么0001是最小的节点,也就是它是第一个节点)
如果不是当前节点第一个节点,就获取当前节点的前一个节点,并赋值给全局变量:beforePath
- lock()方法的逻辑(阻塞获取锁)
大致逻辑先执行tryLock方法尝试获取锁,如果获取失败就阻塞当前线程,然后递归调用;
注意:这里的阻塞实现和数据库的实现分布式事务不一样是使用了Zookeeper的监听机制,
可以实现让等待的线程不在继续阻塞,进行执行。具体代码如下:
创建一个监听器IZKDataListener对象,注册当前监听器监听的zk节点为当前节点的前一个节点。
然后使用CountDownLatch实现线程的等待和通知,当前一个节点还存在时就等待,
当前节点的前一个节点被删除之后,监听器会回调我们,调用CountDownLatch的countDown方法实现通知,然后阻塞的线程继续执行。
- unlock()方法
这个方法简单直接将创建的临时节点删除即可!
整个流程下面这个图描述的不错
-
如果某个客户端创建临时顺序节点之后,不小心自己宕机了怎么办?
Zookeeper能够感知到那个客户端宕机,会自动删除对应的临时顺序节点,相当于自动释放锁,或者是自动取消自己的排队
对比
redis分布式锁需要不断尝试获取锁,比较消耗性能
zookeeper获取不到锁,注册个监听器就行,不需要不断轮询
redis获取锁的客户端挂了,需要等待锁的超时时间才能释放锁
zookeeper因为创建的是临时节点,只要客户端挂了,znode就没了,自动释放锁
zookeeper实现分布式锁相对于redis简单点