一、概述
为了保证一个方法或属性在高并发情况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用Java并发处理相关的API(如ReentrantLock或Synchronized)进行互斥控制。在单机环境中,Java中提供了很多并发处理相关的API。但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的Java API并不能提供分布式锁的能力。为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题。
先了解一下分布式锁应该具备哪些条件:
1、在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;
2、高可用的获取锁与释放锁;
3、高性能的获取锁与释放锁;
4、具备可重入特性;
5、具备锁失效机制,防止死锁;
6、具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。
分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。
分布式锁的实现有三种实现方式
基于数据库实现分布式锁;
基于缓存(Redis等)实现分布式锁;
基于Zookeeper实现分布式锁;
二、具体方法
1.数据库级别锁
数据库的锁机制是并发控制的重要内容,是对程序控制数据一致性的补充,更细粒度的保障数据的一致性,而使各种共享资源在被并发访问变得有序所设计的一种规则。
乐观并发控制和悲观并发控制是并发控制采用的主要方法。乐观锁和悲观锁不仅在关系数据库里应用,在Hibernate、Memcache等等也有相关概念。
1.1 悲观锁
现在互联网高并发的架构中,受到fail-fast思路的影响,悲观锁已经非常少见了。
悲观锁(Pessimistic Locking),悲观锁是指在数据处理过程,使数据处于锁定状态,一般使用数据库的锁机制实现。
1.1.1 数据表中的实现
在MySQL中使用悲观锁,必须关闭MySQL的自动提交,set autocommit=0,MySQL默认使用自动提交autocommit模式,也即你执行一个更新操作,MySQL会自动将结果提交。
select...for update 是MySQL提供的实现悲观锁的方式。
MySQL有个问题是select...for update语句执行中所有扫描过的行都会被锁上,因此在MySQL中用悲观锁务必须确定走了索引,而不是全表扫描,否则将会将整个数据表锁住。
悲观锁并不是适用于任何场景,它也存在一些不足,因为悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。如果加锁的时间过长,其他用户长时间无法访问,影响了程序的并发访问性,同时这样对数据库性能开销影响也很大,特别是对长事务而言,这样的开销往往无法承受,这时就需要乐观锁。
在Oracle中,也存在select ... for update,和mysql一样,但是Oracle还存在了select ... for update nowait,即发现被锁后不等待,立刻报错。
1.2 乐观锁
乐观锁相对悲观锁而言,它认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回错误信息,让用户决定如何去做。
1.2.1 数据表中的实现
利用数据版本号(version)机制是乐观锁最常用的一种实现方式。一般通过为数据库表增加一个数字类型的 “version” 字段,当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值+1。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据,返回更新失败。
既然可以用version,那还可以使用时间戳字段,该方法同样是在表中增加一个时间戳字段,和上面的version类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致则OK,否则就是版本冲突。
需要注意的是,如果你的数据表是读写分离的表,当master表中写入的数据没有及时同步到slave表中时会造成更新一直失败的问题。此时,需要强制读取master表中的数据(将select语句放在事物中)。
即:把select语句放在事务中,查询的就是master主库了!
1.3 共享锁(shared locks,S锁)
共享锁又叫读锁,如果事务T1对行R加上S锁,则
- 其它事务T2/T3/Tn只能对行R再加S锁,不能加其它锁
- 获得S锁的事务只能读数据,不能写数据(你傻呀,当然也不能删咯)。
1.4 排他锁(exclusive locks,X锁)
排它锁又叫写锁,如果事务T1对行R加上X锁,则
- 其它事务T2/T3/Tn都不能对行R加任何类型的锁,直到T1事务在行R上的X锁释放。
- 获得X锁的事务既能读数据,又能写数据(也可以删除数据)。
1.5 意向锁(Intention Locks)
意向锁是表锁,多用在innoDB中,是数据库自身的行为,不需要人工干预,在事务结束后会自行解除。
意向锁分为意向共享锁(IS锁)和意向排它锁(IX锁)
- 锁:表示事务中将要对某些行加S锁
- IX锁:表示事务中将要对某些行加X锁
意向锁的主要作用是提升存储引擎性能,innoDB中的S锁和X锁是行锁,每当事务到来时,存储引擎需要遍历所有行的锁持有情况,性能较低,因此引入意向锁,检查行锁前先检查意向锁是否存在,如果存在则阻塞线程。
意向锁协议
- 事务要获取表A某些行的S锁必须要获取表A的IS锁
- 事务要获取表A某些行的X锁必须要获取表A的IX锁
step1:判断表A是否有表级锁
step2:判断表A每一行是否有行级锁
发现表A有IS锁,说明表肯定有行级的S锁,因此,T2申请X锁阻塞等待,不需要判断全表,判断效率极大提高(是不是省了很多钱)。
1.6 间隙锁(gap lock)
当我们用范围条件条件检索数据(非聚簇索引、非唯一索引),并请求共享或排他锁时,InnoDB会给符合条件的数据记录的索引项加锁;对于键值在条件范围内但并不存在的记录,称为间隙,InnoDB也会为这些间隙加锁,即间隙锁。
Next-Key锁是符合条件的行锁加上间隙锁。
在InnoDB下,间隙锁的产生需要满足三个条件:
- 隔离级别为RR
- 当前读
- 查询条件能够走到索引
间隙锁的目的是为了让其他事务无法在间隙中新增数据。
在RR模式的InnoDB中,间隙锁能起到两个作用:
1. 保障数据的恢复和复制
2. 防止幻读
- 防止在间隙中执行insert语句
- 防止将已有数据update到间隙中
数据库数据的恢复和复制是通过binlog实现的,binlog中记录了执行成功的DML语句,在数据恢复时需要保证数据之间的事务顺序,间隙锁可以避免在一批数据中插入其他事务。
2.基于缓存(Redis等)实现分布式锁
Redis 客户端加锁也要根据Redis 部署情况来使用不同的加锁方式。
2.1 单机Redis的分布式锁
锁的实现主要基于redis的SETNX
命令(SETNX详细解释参考这里),我们来看SETNX
的解释
SETNX key value
将 key 的值设为 value ,当且仅当 key 不存在。
若给定的 key 已经存在,则 SETNX 不做任何动作。
SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。返回值:
设置成功,返回 1 。
设置失败,返回 0 。
使用SETNX
完成同步锁的流程及事项如下:
- 使用
SETNX
命令获取锁,若返回0(key已存在,锁已存在)则获取失败,反之获取成功 - 为了防止获取锁后程序出现异常,导致其他线程/进程调用
SETNX
命令总是返回0而进入死锁状态,需要为该key设置一个“合理”的过期时间 - 释放锁,使用
DEL
命令将锁数据删除
这种方式有两大要点:
1)一定要用SET key value NX PXmilliseconds 命令
如果不用,先设置了值,再设置过期时间,这个不是原子性操作,有可能在设置过期时间之前宕机,会造成死锁(key永久存在)。
2)value要具有唯一性
这个是为了在解锁的时候,需要验证value是和加锁的一致才删除key。
这是避免了一种情况:假设A获取了锁,过期时间30s,此时35s之后,锁已经自动释放了,A去释放锁,但是此时可能B获取了锁。A客户端就不能删除B的锁了。
如果采用单机部署模式,会存在单点问题,只要redis故障了。加锁就不行了。
2.2 redis集群分布式
redis 集群分布式集群方式有两种
- master-slave + sentinel选举模式
- redis cluster模式
集群使用redis锁的问题:
采用master-slave模式,如果设置锁之后,主机在传输到从机的时候挂掉了,从机还没有加锁信息,如何处理?即采用master-slave模式,加锁的时候只对一个节点加锁,即便通过sentinel做了高可用,但是如果master节点故障了,发生主从切换,此时就会有可能出现锁丢失的问题。
redis cluster 模式下,edis的作者提出可依据RedLock算法:
这个算法的意思大概是这样的:假设redis的部署模式是redis cluster,总共有6个master节点,通过以下步骤获取一把锁:
1.获取当前时间戳,单位是毫秒;
2.轮流尝试在每个master节点上创建锁,过期时间设置较短,一般就几十毫秒;
3.尝试在大多数节点上建立一个锁,比如5个节点就要求是3个节点(n / 2+1);
4.客户端计算建立好锁的时间,如果建立锁的时间小于超时时间,就算建立成功了;
5.要是锁建立失败了,那么就依次删除这个锁;
6.只要别人建立了一把分布式锁,你就得不断轮询去尝试获取锁。
但是这样的这种算法还是颇具争议的,可能还会存在不少的问题,无法保证加锁的过程一定正确。
2.3 基于Redission的实现
Redission是Redis的客户端,相比于Jedis功能简单。Jedis简单使用阻塞的I/O和redis交互,Redission通过Netty支持非阻塞I/O。
Redission封装了锁的实现,其继承了java.util.concurrent.locks.Lock的接口,让我们像操作我们的本地Lock一样去操作Redission的Lock。
如果自己写代码来通过redis设置一个值,是通过下面这个命令设置的。
SET d_lock unique_value NX PX 30000
这里设置的超时时间是30s,假如我超过30s都还没有完成业务逻辑的情况下,key会过期,其他线程有可能会获取到锁。这样一来的话,第一个线程还没执行完业务逻辑,第二个线程进来了也会出现线程安全问题。所以我们还需要额外的去维护这个过期时间,我们来看看redisson是怎么实现的:
Config config = new Config(); config.useClusterServers() .addNodeAddress("redis://192.168.1.101:7001") .addNodeAddress("redis://192.168.1.101:7002") .addNodeAddress("redis://192.168.1.101:7003") .addNodeAddress("redis://192.168.1.102:7001") .addNodeAddress("redis://192.168.1.102:7002") .addNodeAddress("redis://192.168.1.102:7003"); RedissonClient redisson = Redisson.create(config); RLock lock = redisson.getLock("d_lock"); lock.lock(); lock.unlock();
我们只需要通过它的api中的lock和unlock即可完成分布式锁,他帮我们考虑了很多细节:
-
redisson所有指令都通过lua脚本执行,redis支持lua脚本原子性执行
-
redisson设置一个key的默认过期时间为30s,如果某个客户端持有一个锁超过了30s怎么办?
redisson中有一个watchdog的概念,它会在你获取锁之后,每隔10秒帮你把key的超时时间设为30s
这样的话,就算一直持有锁也不会出现key过期了,其他线程获取到锁的问题了。
- redisson的“watchdog”逻辑保证了没有死锁发生
(如果机器宕机了,watchdog也就没了。此时就不会延长key的过期时间,到了30s之后就会自动过期了,其他线程可以获取到锁)
小结
优点: 对于Redis实现简单,性能对比ZK和Mysql较好。如果不需要特别复杂的要求,那么自己就可以利用setNx进行实现,如果自己需要复杂的需求的话那么可以利用或者借鉴Redission。对于一些要求比较严格的场景来说的话可以使用RedLock。
缺点: 需要维护Redis集群,如果要实现RedLock那么需要维护更多的集群。
3.基于Zookeeper实现分布式锁
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提供的InterProcessMutex是分布式锁的实现。acquire方法用户获取锁,release方法用于释放锁。
//创建ZK的互斥锁组件实例,需要将监控用的客户端实例、精心构造的共享资源作为构造参数
InterProcessMutex ipm = new InterProcessMutex(client,"/d_lock");
//采用互斥锁组件尝试获取分布式锁
ipm.acquire();
//处理完核心业务逻辑之后,需要释放该分布式锁
ipm.release();
核心代码:
public void WithZKLock(Dto dto) throws Exception { //创建ZooKeeper互斥锁组件实例,需要将CuratorFramework实例、精心构造的共享资源 作为构造参数 InterProcessMutex mutex = new InterProcessMutex(client,pathPrefix+dto+"-lock"); try{ //采用互斥锁组件尝试获取分布式锁-其中尝试的最大时间在这里设置为15s //当然,具体的情况需要根据实际的业务而定 if(mutex.acquire(15L, TimeUnit.SECONDS)){ //进行业务操作 } }else { throw new RuntimeException("获取ZooKeeper分布式锁失败!"); } } catch (Exception exception) { throw exception; } finally { //不管发生何种情况,在处理完核心业务逻辑之后,需要释放该分布式锁 mutex.release(); } }
总结
zk通过临时节点,解决掉了死锁的问题,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉,其他客户端自动获取锁。
zk通过节点排队监听的机制,也实现了阻塞的原理,其实就是个递归在那无限等待最小节点释放的过程。
zk的集群也是高可用的,只要半数以上的或者,就可以对外提供服务了。
三种方案的比较
三种实现方式,没有在所有场合都是完美的,应根据不同的应用场景选择最适合的实现方式。
从理解的难易程度角度(从低到高)
数据库 > 缓存 > Zookeeper
从实现的复杂性角度(从低到高)
Zookeeper >= 缓存 > 数据库
从性能角度(从高到低)
缓存 > Zookeeper >= 数据库
从可靠性角度(从高到低)
Zookeeper > 缓存 > 数据库
参考:
https://www.jianshu.com/p/ed896335b3b4
https://www.cnblogs.com/wei57960/p/14059772.html
https://segmentfault.com/a/1190000012919740
https://blog.csdn.net/wuzhiwei549/article/details/80692278
https://mp.weixin.qq.com/s/ZqQHWLfVD1Rz1agmH3LWrg