分布式锁的应用场景
为什么需要用到分布式锁呢?在讨论这个问题之前,我们先看下一个业务场景:
系统A是一个电商系统,目前是一台机器部署,系统中有一个用户下订单的接口,但是用户下订单之前一定要去检查一下库存,确保库存足够了才会给用户下单。
由于系统有一定的并发,所以会预先将商品的库存保存在redis中,用户下单的时候会更新redis的库存,此时系统架构如下:
但是这样会存在一个问题:假如某个时刻,redis里面某个商品库存为1,此时两个请求同时到来,其中一个请求执行到上图中的第三步,更新数据库的库存为0,但是第四步还没有执行。
而另外一个请求执行到第二步,发现库存还是1,就继续执行第三步。
这样的结果,将会导致卖出了2个商品,然而库存其实只有一个。这就是典型的超卖问题。
此时我们很容易想到解决方案:用锁把2,3,4锁住,让他们执行完之后,另一个线程才能进来执行第二步。
按照上图,在执行第二步,使用Java提供的synchronized或者ReentrantLock来锁住,然后在第四步执行完之后才释放锁。
这样2,3,4这三个步骤就被锁住了,多个线程之间只能串行化执行。
但是随着整个系统并发飙升,一台机器扛不住了,现在增加一个机器,如下图:
假设此时两个用户的请求同时到来,但是落在了不同的机器上,那么这两个请求是可以同时执行的,还是会出现超卖问题。
原因就是因为对于上图的两个A系统,运行在两个不同的JVM里面,他们加的锁只对属于自己JVM里面的线程有效,对于其他JVM的线程是无效的。也即Java提供的原生锁机制在多机部署场景下失效了
那么如何解决这个问题呢?方案其实很简单,只要我们保证两台机器加的锁是同一个锁就可以了。
所以就有了分布式锁的用武之地了,分布式锁的思路是:
在整个系统中提供一个全局,唯一的获取锁的”东西”,然后在每个系统加锁时,都去这个”东西”拿到一把锁,这样不同系统拿到的就可以认为是同一把锁
至于这个东西,可以是MySQL,Redis或者zookeeper,也即分布式锁不同的实现方式
分布式锁的特点
上面分析了为啥需要使用分布式锁,那么在讲如何实现分布式锁之前,我们首先需要明确分布式锁需要满足的一些特性
- 互斥性:和原生锁一样,互斥性是最基本的,只不过分布式锁需要保证的是在不同节点的不同线程中保持互斥性
- 可重入性:同一个节点上的同一个线程如果获取了锁之后,那么也可以再次获得这个锁
- 锁超时:防止死锁
- 高效,高可用:加锁和解锁需要高效,同时也需要保证高可用防止分布式锁失效
- 支持阻塞和非阻塞:和ReentrantLock一样支持lock和trylock
基于MySQL实现分布式锁
实现原理
在数据库中创建一张锁表,通过操作该表中的数据来实现
当需要锁住某个资源时,就在表中增加一条记录,想要释放锁就删除这条记录
CREATE TABLE `resourceLock` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`resource_name` varchar(64) NOT NULL DEFAULT '' COMMENT '资源名称',
`node_info` varchar(128) DEFAULT NULL COMMENT '机器名称',
`count` int(11) NOT NULL DEFAULT '0' COMMENT '锁的次数'
`desc` varchar(1024) NOT NULL DEFAULT '备注信息',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
`create_time` timestamp NOT NULL DEFAULT NULL COMMENT '创建时间'
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_resource` (`resource_name `)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
当我们需要锁住某个资源,执行以下SQL
insert into resourceLock(resource_name,count,create_time) values('name',1,'timestamp')
由于对resource_name做了唯一性约束,如果有多个请求同时提交到数据库,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该资源的锁。
当方法执行完毕之后,想要释放锁,需要判断count是否大于1,大于1则count减1,否者则删除这条记录
delete from resourceLock where resource_name = 'xxx'
前面分析过分布式锁需要满足几个特性,那么我们来看下,上述实现是否都满足
- 互斥性:通过数据库的唯一性约束来实现
- 锁超时:数据库自身无法提供这个功能,需要起一个定时任务,每隔一定时间把数据库中超时数据删除
- 可重入性:字段count自己了锁的次数,已获得锁的线程只要把count+1即可再次获得锁
- 非阻塞:设置一个while循坏,直到insert成功在返回
下面看下几个操作的伪代码实现
lock
lock一般是阻塞的,也就意味着会一直尝试获得锁,直到成功为止
public void lock(){
while(true){
if(mysqlLock.lock(resouce)){
return;
}
//休眠一段时间之后在重试
sleep(3)
}
}
下面是mysqlLock.lock的实现,为了实现可重入性,需要判断node_info是否一致,如果一致,则count+1,如果不一致则返回false,如果不存在,则直接插入一条数据。上述这些操作需要在一个事务中
@Transcation
public boolean lock(){
// 节点是否存在
if(select * from resourceLock where resource_name='xx' for update){
//节点信息是否一致
if(currentNodeInfo == resultNodeInfo){
update resourceLock set count = count + 1 where resource_name = 'xx'
return true;
}else{
return false;
}
}else{
//插入新数据
insert into resourceLock
}
}
unlock
如果count为1可以直接删除,如果大于1则减1
@Transcation
public boolean unlock(){
if(select * from resourceLock where resource_name='xx' for update){
if(currentNodeInfo == resultNodeInfo){
if(count > 1){
update count = count - 1
return true
}else{
delete from resourceLock where resource_name='xx'
return true
}
}else{
return false;
}
}else{
return false;
}
}
基于Redis实现分布式锁
实现原理
使用redis实现分布式锁时,如果设置了一个值表示了加锁,然后释放锁,就把这个key删除
//获取锁
//NX表示如果key不存在才创建key,若存在则直接返回false,PX指定了key的过期时间
SET anyLock unique_value NX PX ms
//释放锁:通过执行一段lua脚本
//释放锁涉及到两条指令,这两条指令不是原子性的
//redis执行lua脚本是原子性的
if redis.call("get", KEYS[1] == ARGV[1]) then
return redis.call("del", KEYS[1])
else
return 0
end
上述实现方式有几个注意点
- 一定要用SET key value NX PX milliseconds 命令,也即要保证设置值和过期时间这两个操作一起执行。如果先设置了值,在设置过期时间,这个不是原子性操作,有可能在设置过期时间之前机器宕机,则key会永久存在,造成死锁。
value要具有一致性
这个是为了在解锁时,需要验证value和加锁一致才删除key。这是为了避免一种情况,假设A获取了锁,过期时间为30s,此时35s之后,锁已经自动释放了,A去释放锁,但是此时B可能获得了锁,那么A就不能删除B的锁了
RedLock
使用redis实现分布式锁,还有需要特别考虑到redis的部署方式,因为这关系到分布式锁的高可用
redis有三种部署方式:
- 单机模式
- master-slave + sentinel选举模式
- redis cluster模式
如果采用单机部署模式,会存在单点问题,只要redis挂了,加锁就会失败
如果采用master-slave,加锁的时候就只对一个节点加锁,即使通过sentinel做了高可用,但是如果master节点挂了,发生主从切换,此时就有可能出现所丢失的问题
基于以上的考虑,redis作者提出了一个RedLock的算法:
假设redis的部署模式是redis cluster,总共有5个master节点,通过以下步骤来获取一把锁
- 获取当前时间戳,单位是毫秒
- 轮流尝试在每个master节点上创建锁,过期时间设置较短,一般就几十毫秒
- 尝试在大多数节点建立锁,比如5个节点就要求是3个节点(n/2+1)
- 客户端计算好建锁的时间,如果建立锁的时间小于超时时间,则建立成功。例如超时时间为5ms,在第一个节点建锁就耗时5ms,那么建锁也就失败了
- 要是建锁失败,则依次删除这个锁
- 只要别人建立了一把分布式锁,需要不断轮询去尝试获取锁
Redisson
Redisson是基于redis实现分布式锁的一个开源框架,封装了底层细节,使用起来非常方便
Config config = new Config();
config.useClusterServers().addNodeAddress("redis://192.168.31.101:7001")
.addNodeAddress("redis://192.168.31.101:7002")
.addNodeAddress("redis://192.168.31.101:7003")
.addNodeAddress("redis://192.168.31.102:7001")
.addNodeAddress("redis://192.168.31.102:7002")
.addNodeAddress("redis://192.168.31.102:7003");
RedissonClient redisson = Redisson.create(config);
RLock lock = redisson.getLock("anyLock");
lock.lock();
lock.unlock();
我们只需要通过使用api中的lock和unlock就可以完成分布式锁,它帮我们考虑了很多细节
- redisson所有指令都通过lua脚本执行,redis支持lua脚本原子性执行
redisson设置一个key的默认过期时间为30s,如果某个客户端持有一个锁超过30s,会有什么问题
假设业务超过30s都没有完成业务逻辑,key会过期,其他线程有可能会获取到锁。redisson有一个watchdog的概念,它会在你获取到锁之后,每隔10s帮你把key的超时时间设为30s。这样的话,就算一直持有锁也不会出现key过期,其他线程获取到锁的问题了。
redisson的watchdog机制保证了没有死锁发生
如果机器宕机了,watchdog也没有了,此时就不会延长key的过期时间,到了30s之后锁就会自动过期
基于Zookeeper实现分布式锁
首先来看下zk的模型:zk包含一系列的节点,叫做znode,就好像文件系统一样,每个znode表示一个目录,然后znode有一些特性
有序节点:假如当前有个父节点为/lock,我们可以在父节点下创建子节点
zookeeper提供了可选的有序特性,例如我们可以创建子节点”/lock/node-“并且指明有序,那么zookeeper在生成子节点时会根据档期子节点数据自动添加整数序号
也就是说,如果第一个字节点是lock/node-1,那么下个节点就是lock/node-2
临时节点:客户端可以建立一个临时节点,在会话结束或者会话超时后,zookeeper会自动删除该节点
事件监听:在读取数据时,我们可以同时对节点设置事件监听,当节点数据或者结构发生变化时,zookeeper会通知客户端,当前zookeeper有如下四种事件:
- 节点创建
- 节点删除
- 节点数据修改
- 子节点变更
实现原理
看完zk的特性之后,我们来看下如何利用zk来实现分布式锁
- 使用zk的临时节点和有序节点,每个线程获取锁就是在zk创建一个临时有序的节点,比如在/lock目录下
- 创建界定啊成功之后,获取/lock目录下所有临时节点,在判断当前线程创建的节点是否是所有节点的序号中最小的节点
- 如果当前线程创建的节点是所有节点序号最小的节点,则认为获取锁成功
如果当前线程创建的节点不是所有节点序号最小的节点,则对节点序号的前一个节点添加一个事件监听
比如当前线程获取到的节点序号为/lock/003,然后所有节点列表为[/lock/001,/lock/002,/lock/003],则对/lock/002这个节点添加一个事件监听器
如果锁释放了,会唤醒下一个序号的节点,然后重新执行第3步,判断是否自己的节点序号是最小。
比如/lock/001释放了,/lock/002监听到时间,此时节点集合为[/lock/002,/lock/003],则/lock/002为最小序号节点,获取到锁。
Curator
Curator是一个zookeeper的开源客户端,也提供了分布式锁的实现
InterProcessMutex interProcessMutex = new InterProcessMutex(client,"/anyLock");
interProcessMutex.acquire();
interProcessMutex.release();
curator的实现原理和上面分析的差不多
总结
实现方式 | 优点 | 缺点 |
---|---|---|
数据库 | 实现原理理解简单,不需要额外维护第三方的组件 | 实现复杂,需要自己考虑锁超时,加事务等等。 |
Redis | 实现简单,性能较好 | 1. 获取锁的方式简单粗暴,获取不到锁直接不断尝试,比较消耗性能 2. redis 的设计定位决定了它的数据并不是强一致性的,在某些极端情况下,可能会出现问题,锁的模型不够健壮 |
Zookeeper | 1. 强一直性,锁的模型健壮,简单易用 2. 如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小 |
性能较差,如果有较多的客户端频繁的申请加锁,释放锁,对于zk集群的压力较大 |
从理解的难易程度(从低到高)
数据库 > redis > Zookeeper
从实现的复杂性角度(从低到高)
Zookeeper >= redis > 数据库
从性能角度(从高到低)
redis > Zookeeper >= 数据库
从可靠性角度(从高到低)
Zookeeper > redis > 数据库