【◐系统架构】分布式锁实现
我们的系统都是分布式部署的,日常开发中,秒杀下单、抢购商品等等业务场景,为了防止库存超卖,都需要用到分布式锁。
分布式锁其实就是,控制分布式系统不同进程共同访问共享资源的一种锁的实现。如果不同的系统或同一个系统的不同主机之间共享了某个临界资源,往往需要互斥来防止彼此干扰,以保证一致性。
业界流行的分布式锁实现,一般有这 3 种方式:
- 基于数据库实现的分布式锁
- 基于 Redis 实现的分布式锁
- 基于 Zookeeper 实现的分布式锁
基于数据库的分布式锁
数据库悲观锁实现的分布式锁
可以使用 select ... for update 来实现分布式锁。我们自己的项目,分布式定时任务,就使用类似的实现方案,我给大家来展示个简单版的
表结构如下:
CREATE TABLE `t_resource_lock` (
`key_resource` varchar(45) COLLATE utf8_bin NOT NULL DEFAULT '资源主键',
`status` char(1) COLLATE utf8_bin NOT NULL DEFAULT '' COMMENT 'S,F,P',
`lock_flag` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '1是已经锁 0是未锁',
`begin_time` datetime DEFAULT NULL COMMENT '开始时间',
`end_time` datetime DEFAULT NULL COMMENT '结束时间',
`client_ip` varchar(45) COLLATE utf8_bin NOT NULL DEFAULT '抢到锁的IP',
`time` int(10) unsigned NOT NULL DEFAULT '60' COMMENT '方法生命周期内只允许一个结点获取一次锁,单位:分钟',
PRIMARY KEY (`key_resource`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin
加锁 lock 方法的伪代码如下:
@Transcational //一定要加事务
public boolean lock(String keyResource,int time){
resourceLock = 'select * from t_resource_lock where key_resource ='#{keySource}' for update';
try{
if(resourceLock==null){
//插入锁的数据
resourceLock = new ResourceLock();
resourceLock.setTime(time);
resourceLock.setLockFlag(1); //上锁
resourceLock.setStatus(P); //处理中
resourceLock.setBeginTime(new Date());
int count = "insert into resourceLock";
if(count==1){
//获取锁成功
return true;
}
return false;
}
}catch(Exception x){
return false;
}
//没上锁并且锁已经超时,即可以获取锁成功
if(resourceLock.getLockFlag=='0'&&'S'.equals(resourceLock.getstatus)
&& new Date()>=resourceLock.addDateTime(resourceLock.getBeginTime,time)){
resourceLock.setLockFlag(1); //上锁
resourceLock.setStatus(P); //处理中
resourceLock.setBeginTime(new Date());
//update resourceLock;
return true;
}else if(new Date()>=resourceLock.addDateTime(resourceLock.getBeginTime,time)){
//超时未正常执行结束,获取锁失败
return false;
}else{
return false;
}
}
解锁 unlock 方法的伪代码如下:
public void unlock(String v,status){
resourceLock.setLockFlag(0); //解锁
resourceLock.setStatus(status); //S:表示成功,F表示失败
//update resourceLock;
return ;
}
整体流程:
try{
if(lock(keyResource,time)){ //加锁
status = process();//你的业务逻辑处理。
}
} finally{
unlock(keyResource,status); //释放锁
}
其实这个悲观锁实现的分布式锁,整体的流程还是比较清晰的。
就是先 select ... for update 锁住主键 key_resource 那个记录,如果为空,则可以插入一条记录,如果已有记录判断下状态和时间,是否已经超时。这里需要注意一下,必须要加事务。
数据库乐观锁实现的分布式锁
除了悲观锁,还可以用乐观锁实现分布式锁。乐观锁,顾名思义,就是很乐观,每次更新操作,都觉得不会存在并发冲突,只有更新失败后,才重试。它是基于 CAS 思想实现的。我以前的公司,扣减余额就是用这种方案。
搞个 version 字段,每次更新修改,都会自增加一,然后去更新余额时,把查出来的那个版本号,带上条件去更新,如果是上次那个版本号,就更新,如果不是,表示别人并发修改过了,就继续重试。
大概流程如下:
查询版本号和余额:
select version,balance from account where user_id ='666';
假设查到版本号是 oldVersion=1。
逻辑处理,判断余额:
if(balance<扣减金额){
return;
}
left_balance = balance - 扣减金额;
进行扣减余额:
update account set balance = #{left_balance} ,version = version+1 where version
= #{oldVersion} and balance>= #{left_balance} and user_id ='666';
大家可以看下这个流程图:
这种方式适合并发不高的场景,一般需要设置一下重试的次数。
基于 Redis 实现的分布式锁
Redis 分布式锁一般有以下这几种实现方式:
- setnx+expire
- setnx+value 值是过期时间
- set 的扩展命令(set ex px nx)
- set ex px nx+校验唯一随机值,再删除
- Redisson
- Redisson+RedLock
setnx+expire
聊到 Redis 分布式锁,很多小伙伴反手就是 setnx+expire,如下:
if(jedis.setnx(key,lock_value) == 1){ //setnx加锁
expire(key,100); //设置过期时间
try {
do something //业务处理
}catch(){
}
finally {
jedis.del(key); //释放锁
}
}
这段代码是可以加锁成功,但是你有没有发现问题,加锁操作和设置超时时间是分开的。
假设在执行完 setnx 加锁后,正要执行 expire 设置过期时间时,进程 crash 掉或者要重启维护了,那这个锁就长生不老了,别的线程永远获取不到锁啦,所以分布式锁不能这么实现。
setnx+value 值是过期时间
long expires = System.currentTimeMillis() + expireTime; //系统时间+设置的过期时间
String expiresStr = String.valueOf(expires);
// 如果当前锁不存在,返回加锁成功
if (jedis.setnx(key, expiresStr) == 1) {
return true;
}
// 如果锁已经存在,获取锁的过期时间
String currentValueStr = jedis.get(key);
// 如果获取到的过期时间,小于系统当前时间,表示已经过期
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
// 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间(不了解redis的getSet命令的小伙伴,可以去官网看下哈)
String oldValueStr = jedis.getSet(key, expiresStr);
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
// 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才可以加锁
return true;
}
}
//其他情况,均返回加锁失败
return false;
日常开发中,有些小伙伴就是这么实现分布式锁的,但是会有这些缺点:
- 过期时间是客户端自己生成的,分布式环境下,每个客户端的时间必须同步。
- 没有保存持有者的唯一标识,可能被别的客户端释放/解锁。
- 锁过期的时候,并发多个客户端同时请求过来,都执行了 jedis.getSet(),最终只能有一个客户端加锁成功,但是该客户端锁的过期时间,可能被别的客户端覆盖。
设置指定 key 的值为 value,并返回 key 的旧值(old value)。当 key 没有旧值时,即 key 不存在时,返回 nil 。
set 的扩展命令(set ex px nx)
SET key value [EX seconds] [PX milliseconds] [NX|XX]
- EX second :设置键的过期时间为 second 秒。
- PX millisecond :设置键的过期时间为 millisecond 毫秒。
- NX :只在键不存在时,才对键进行设置操作。
- XX :只在键已经存在时,才对键进行设置操作。
if(jedis.set(key, lock_value, "NX", "EX", 100s) == 1){ //加锁
try {
do something //业务处理
}catch(){
}
finally {
jedis.del(key); //释放锁
}
}
这个方案可能存在这样的问题:
- 锁过期释放了,业务还没执行完。
- 锁被别的线程误删。
锁为什么会被别的线程误删呢?假设并发多线程场景下,线程 A 获得了锁,但是它没释放锁的话,线程 B 是获取不到锁的,所以按道理它是执行不到加锁下面的代码的,怎么会导致锁被别的线程误删呢?
假设线程 A 和 B,都想用 key 加锁,最后 A 抢到锁加锁成功,但是由于执行业务逻辑的耗时很长,超过了设置的超时时间 100s。
这时候,Redis 就自动释放了 key 锁。这时候线程 B 就可以加锁成功了,接下啦,它也执行业务逻辑处理。假设碰巧这时候,A 执行完自己的业务逻辑,它就去释放锁,但是它就把B的锁给释放了。
set ex px nx+校验唯一随机值,再删除
为了解决锁被别的线程误删问题。可以在 set ex px nx 的基础上,加上个校验的唯一随机值,如下:
if(jedis.set(key, uni_request_id, "NX", "EX", 100s) == 1){ //加锁
try {
do something //业务处理
}catch(){
}
finally {
//判断是不是当前线程加的锁,是才释放
if (uni_request_id.equals(jedis.get(key))) {
jedis.del(key); //释放锁
}
}
}
在这里,判断当前线程加的锁和释放锁不是一个原子操作。如果调用 jedis.del() 释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁。
一般可以用 lua 脚本来包一下。lua 脚本如下:
if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end;
这种方式比较不错了,一般情况下,已经可以使用这种实现方式。但是还是存在:锁过期释放了,业务还没执行完的问题。
Redisson
对于可能存在锁过期释放,业务没执行完的问题。我们可以稍微把锁过期时间设置长一些,大于正常业务处理时间就好啦。
如果你觉得不是很稳,还可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。
当前开源框架 Redisson 解决了这个问题。可以看下 Redisson 底层原理图:
只要线程一加锁成功,就会启动一个 watch dog 看门狗,它是一个后台线程,会每隔 10 秒检查一下,如果线程 1 还持有锁,那么就会不断的延长锁 key 的生存时间。因此,Redisson 就是使用 watch dog 解决了锁过期释放,业务没执行完问题。
Redisson+RedLock
前面这几种方案都只是基于 Redis 单机版的分布式锁讨论,还不是很完美。因为 Redis 一般都是集群部署的:
如果线程一在 Redis 的 master 节点上拿到了锁,但是加锁的 key 还没同步到 slave 节点。恰好这时,master 节点发生故障,一个 slave 节点就会升级为 master 节点。
线程二就可以顺理成章获取同个 key 的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。
为了解决这个问题,Redis 作者 antirez 提出一种高级的分布式锁算法:Redlock。
它的核心思想是这样的:部署多个 Redis master,以保证它们不会同时宕掉。并且这些 master 节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个 master 实例上,是与在 Redis 单实例,使用相同方法来获取和释放锁。
我们假设当前有 5 个 Redis master 节点,在 5 台服务器上面运行这些 Redis 实例。
RedLock 的实现步骤:
1)获取当前时间,以毫秒为单位。
2)按顺序向 5 个 master 节点请求加锁。客户端设置网络连接和响应超时时间,并且超时时间要小于锁的失效时间。(假设锁自动失效时间为 10 秒,则超时时间一般在 5-50 毫秒之间,我们就假设超时时间是 50ms 吧)。如果超时,跳过该 master 节点,尽快去尝试下一个 master 节点。
3)客户端使用当前时间减去开始获取锁时间(即步骤 1 记录的时间),得到获取锁使用的时间。当且仅当超过一半(N/2+1,这里是 5/2+1=3 个节点)的 Redis master 节点都获得锁,并且使用的时间小于锁失效时间时,锁才算获取成功。(如上图,10s> 30ms+40ms+50ms+4m0s+50ms)
4)如果取到了锁,key 的真正有效时间就变啦,需要减去获取锁所使用的时间。
5)如果获取锁失败(没有在至少 N/2+1 个 master 实例取到锁,有或者获取锁时间已经超过了有效时间),客户端要在所有的 master 节点上解锁(即便有些 master 节点根本就没有加锁成功,也需要解锁,以防止有些漏网之鱼)。
简化下步骤就是:
1)按顺序向 5 个 master 节点请求加锁
2)根据设置的超时时间来判断,是不是要跳过该 master 节点。
3)如果大于等于 3 个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功啦。
4)如果获取锁失败,解锁!
Redisson 实现了 RedLock 版本的锁。
Zookeeper 分布式锁
Zookeeper 的节点 Znode 有四种类型:
- 持久节点:默认的节点类型。创建节点的客户端与 zookeeper 断开连接后,该节点依旧存在。
- 持久顺序节点:所谓顺序节点,就是在创建节点时,Zookeeper 根据创建的时间顺序给该节点名称进行编号,持久节点顺序节点就是有顺序的持久节点。
- 临时节点:和持久节点相反,当创建节点的客户端与 zookeeper 断开连接后,临时节点会被删除。
- 临时顺序节点:有顺序的临时节点。
Zookeeper 分布式锁实现应用了临时顺序节点。
ZK 获取锁过程
当第一个客户端请求过来时,Zookeeper 客户端会创建一个持久节点 locks。如果它(Client1)想获得锁,需要在 locks 节点下创建一个顺序节点 lock1。
接着,客户端 Client1 会查找 locks 下面的所有临时顺序子节点,判断自己的节点 lock1 是不是排序最小的那一个,如果是,则成功获得锁。
这时候如果又来一个客户端 client2 前来尝试获得锁,它会在 locks 下再创建一个临时节点 lock2。
客户端 client2 一样也会查找 locks 下面的所有临时顺序子节点,判断自己的节点 lock2 是不是最小的,此时,发现 lock1 才是最小的,于是获取锁失败。
获取锁失败,它是不会甘心的,client2 向它排序靠前的节点 lock1 注册 Watcher 事件,用来监听 lock1 是否存在,也就是说 client2 抢锁失败进入等待状态。
此时,如果再来一个客户端 Client3 来尝试获取锁,它会在 locks 下再创建一个临时节点 lock3。
同样的,client3 一样也会查找 locks 下面的所有临时顺序子节点,判断自己的节点 lock3 是不是最小的,发现自己不是最小的,就获取锁失败。
它也是不会甘心的,它会向在它前面的节点 lock2 注册 Watcher 事件,以监听 lock2 节点是否存在。
释放锁
我们再来看看释放锁的流程,Zookeeper 的客户端业务完成或者发生故障,都会删除临时节点,释放锁。
如果是任务完成,Client1 会显式调用删除 lock1 的指令。
如果是客户端故障了,根据临时节点得特性,lock1 是会自动删除的。
lock1 节点被删除后,Client2 可开心了,因为它一直监听着 lock1。
lock1 节点删除,Client2 立刻收到通知,也会查找 locks 下面的所有临时顺序子节点,发现lock2 是最小,就获得锁。
同理,Client2 获得锁之后,Client3 也对它虎视眈眈:
- Zookeeper 设计定位就是分布式协调,简单易用。如果获取不到锁,只需添加一个监听器即可,很适合做分布式锁。
- Zookeeper 作为分布式锁也缺点:如果有很多的客户端频繁的申请加锁、释放锁,对于 Zookeeper 集群的压力会比较大。