分布式锁探讨
一、分布式锁背景
a、什么是锁?
从使用场景定义:当存在多个线程可以同时改变某个变量时,就需要对变量或代码块做同步,使其在修改这种变量时能够线性执行消除并发修改变量。
锁的实现方式有多种,只要能满足所有线程都能看得到这个锁标记即可。
Java中常见的锁:
synchronized
ReentrantLock
ReentrantReadWriteLock
b、什么是分布式?
定义:分布式系统一定是由多个节点(计算机服务器)组成的系统,这些互通的节点上部署了各个服务,并且操作需要协同。
分布式系统对于终端用户而言,他们面对的就好像是一个服务器,提供用户需要的服务而已。
什么是CAP?
定义:任何一个分布式系统都无法同时满足一致性Consistency、可用性Availability、和分区容错性Partition Tolerance,最多只能同时满足两项。
因为单个服务器偶尔宕机不可避免(或者因为停电或者自然灾害导致单机房不可用),网络状况不稳定,时而会有网络抖动,时而延时比较高,所以必须满足P,所以一般会出现AP系统和CP系统。
c、a+b==》什么是分布式锁?
定义:在分布式环境下,一个共享的可见的公共资源,各个线程通过对这个公共资源的抢占,能够使得一个代码块在同一时间只能被一个机器的一个线程执行,那这个公共资源就是分布式锁,或者说这整个机制就是分布式锁。
或者从使用场景定义:分布式锁主要用于在分布式环境中保护跨进程、跨主机、跨网络的共享资源实现互斥访问,以达到保证数据的一致性
二、分布式锁实现方式
锁的实现方式有多种,只要能满足所有线程都能看得到这个锁标记即可。
常见的方式是使用数据库、缓存或者zookeeper来实现分布式锁,除了这些,其实一个网络中的共享可见的可读写的资源就可以用作实现锁。
锁的操作主要有两个,即lock()和unlock()。
a、数据库
数据库是分布式集群中最常见的数据存储、共享和交换的设备
基于数据库insert、delete操作实现分布式锁
我们可以利用数据库的特性,lock即插入一条数据到数据库,unlock即删除该条数据。直接看图

具体步骤如下:
1、建mysql表,lock_name上建立唯一性索引;
2、server1开始获取分布式锁"aaa";
3、server1调用lock("aaa"),数据库中没有"aaa",插入"aaa"成功,server1成功获得分布式锁"aaa";
4、server2调用lock("aaa"),插入"aaa"失败,获取锁失败;
5、server1调用unlock("aaa"),删除数据行"aaa";
6、server2调用lock("aaa"),插入"aaa"成功,获取锁成功;

熟悉java锁的同学发现问题了,这个锁也太简陋了,锁都不阻塞的。
好,我们严格地来讨论下锁的特性,
首先,我们罗列下锁的特性,我能想到的有以下这些,欢迎留言补充:
分布式锁的需求
1、在分布式环境下,一个代码块在同一时间只能被一个机器的一个线程执行
2、高可用地获取锁和释放锁
3、高性能的获取锁和释放锁
4、具备锁失效机制,防止死锁
5、是否可重入
6、是否非阻塞
7、自旋锁
8、乐观锁/悲观锁
9、是否公平
10、独享锁/共享锁
11、分段锁
刚才提到的问题,锁最基本的应该具有阻塞特性,我们修正一下,如下图

在插入数据失败后,我们通过不断地重试来达到阻塞的特性。
下面我们来逐条讨论下以上提到的分布式锁的特性:
1、在分布式环境下,一个代码块在同一时间只能被一个机器的一个线程执行
基本的,所有的锁都应该满足
2、高可用地获取锁和释放锁
可用性受数据库单点限制,如果要提高可用性,使用主备库,通过数据同步主备切换达到在主库宕机时的高可用
3、高性能的获取锁和释放锁 数据库读写性能
锁的获取和释放就是数据库的插入和删除,性能因具体硬件和软件基础不同而不同,表的行数的规模大小也影响着性能,性能还受网络的影响,在笔者的开发环境中插入大概耗时约80ms
4、具备锁失效机制,防止死锁
如果节点在获取锁之后释放锁之前宕机,会发生死锁。可以在表中加入时间字段,用一个定时任务,定期地清理时间过期的锁,但是这个过期时间的大小值得探讨,很深入地探讨。
5、是否可重入
如果想获得类似ReentrantLock的特性,可以在表中写入线程的信息(IP地址+进程号+线程号),可以先查询,如果是线程自己拥有的锁,直接返回成功并计数count
6、是否非阻塞
如果要获得阻塞特性,通过不断自旋重试
7、自旋锁
同上
8、乐观锁/悲观锁
分布式锁就是悲观锁。如果不想用分布式锁,对数据不上锁,在表中加入列version,通过CAS版本号来控制并发写
9、是否公平
首先解释下公平锁:公平锁即是根据lock()的时间顺序,先到先得,即FIFO,这个是常见的规则。
单机情况下非公平锁的好处:公平锁忽略了线程之间切换以及线程内核态和用户态转换的耗时,其实可以利用线程切换的间隙,让其它正在被调度执行的线程插队去获取锁,然后释放锁,还给等待线程。这样不耽误事的前提下,提高了锁的性能。请自行去了解下非公平锁。
言归正传,如果需要实现公平锁,设计一张排队表,让lock()的线程首先在排队表中排队,当发现自身排在队头才有资格去真正获取锁。当然引入复杂性,必然会导致其它问题,兵来将挡,水来土掩。
10、独享锁/共享锁
以上的分布式锁,是定义的一把独享锁(独占锁、互斥锁、排它锁)。
ReentrantLock就是一种排它锁。CountDownLatch是一种共享锁。这两类都是单纯的一类,即,要么是排它锁,要么是共享锁。
ReentrantReadWriteLock是同时包含排它锁和共享锁特性的一种锁。这里主要以ReadWriteLock为例来进行分析。
如果要实现读写共享锁,我们需要在排队表的基础上加上锁类型字段,表明是读锁还是写锁。
11、分段锁
暂不讨论,在需要时具体讨论,参考ConcurrentHashMap对于分段锁的实现。
数据库的分布式锁其它实现方式:
数据的分布式锁实现方式还有:
利用select for update数据库排它锁来实现分布式锁。
该方式也值得细细讨论,有很多优点,可以参考其它技术博客相关内容。
这里只提一下一个关键的致命的问题:
在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁,InnoDB引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给lock_name添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则可能会出现多个同名lock的问题。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。
虽然我们对lock_name 使用了唯一索引,并且显示使用for update来使用行级锁。但是,MySql会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。如果发生这种情况就真的是致命的!
b、缓存
缓存redis也是常用的分布式集群的数据共享、交换的中间件
基于setnx()、expire()、delete()命令实现分布式锁
通过setnx()命令设置key即可获得分布式锁
具体步骤如下:
1、针对分布式锁key——“ttt”,尝试执行setnx("ttt","1");
2s、如果设置成功,则获取到分布式锁;
2s.3、设置成功后,执行expire("ttt")命令,尝试设置过期时间;
2s.3.4、当使用完分布式锁“ttt”之后,执行delete("ttt"),释放分布式锁;
2f、如果设置失败,则自旋重试
这里存在一个问题,如果在设置expire之前,线程挂掉,会导致死锁。
所以需要改进,我们设置的value不用简单的“1”,而用一个有意义的值,用时间戳的值。
改进后如下图

具体步骤如下:
1、针对分布式锁key——“ttt”,尝试执行setnx("ttt","674567457");
2s、如果设置成功,则获取到了分布式锁;
2s.3、设置成功后,执行expire("ttt")命令,尝试设置过期时间;
2f、如果设置失败,则get("ttt")获取到其它线程之前设置的时间戳;
2f.3、比较时间戳,查看锁的时间是否过期;
2f.3f、如果没有过期,则自旋重试;
2f.3s、如果过期了,则尝试获取分布式锁,通过命令getSet命令,设置时间戳,比较返回值是否是旧值;
2f.3s.4f、如果不是旧值,则是别的线程获取了分布式锁,不是自己,自己再次自旋重试;
2f.3s.4s、如果是旧值,则是自己获取到了分布式锁;
那实际上redis里面的value是怎样的呢?

因为不断地获取锁(set)和释放锁(delete),value一直在增长。
以上只是一种实现,我们的分布式锁具体需要什么特性呢?
下面我们来逐条讨论下以上提到的分布式锁的特性:
1、在分布式环境下,一个代码块在同一时间只能被一个机器的一个线程执行
基本的,所有的锁都应该满足
2、高可用地获取锁和释放锁
redis节点有从节点,在主节点挂了之后,可以替代主节点。
另外,不同key分布在不同节点,当某个节点挂了之后,只影响该节点上的key,其它的key不影响。
3、高性能的获取锁和释放锁
锁的获取和释放就是redis的读写,性能因具体硬件和软件基础不同而不同,性能还受网络的影响,在笔者的开发环境中redis写大概耗时约40ms
4、具备锁失效机制,防止死锁
如果节点在获取锁之后释放锁之前宕机,会发生死锁。可以在value中设置时间值,后续的获取锁操作会将过期的锁夺回。
5、是否可重入
如果想获得类似ReentrantLock的特性,可以在value中写入线程的信息(IP地址+进程号+线程号),可以先查询,如果是线程自己拥有的锁,直接返回成功并计数count
6、是否非阻塞
如果要获得阻塞特性,通过不断自旋重试
7、自旋锁
同上
8、乐观/悲观
分布式锁就是悲观锁。如果不想用分布式锁,对数据不上锁,在value中加入version的值,通过CAS版本号来控制并发写
9、是否公平
如果需要实现公平锁,用redis的zset做排队队列,让lock()的线程首先排队,当发现自身排在队头才有资格去真正获取锁。
10、独享/共享
如果要实现读写共享锁,我们需要在排队的基础上加上锁类型字段,表明是读锁还是写锁。
11、分段锁
暂不讨论
redis的分布式锁其它实现方式:
在redis的XXX版本之后,基于set(LOCK_KEY, randUUID, "NX", "EX", EXPIRE_TIME)命令实现分布式锁
这种方式是简单的原子的,所以建议直接以这种方式实现分布式锁。
该方式也存在一些要点,当使用完锁之后,做delete操作释放锁的时候,需要判断randUUID是否为自己设置的值,不然会误删除。
c、zookeeper
zookeeper是分布式集群中常见的数据存储、共享和交换的中间件
基于zookeeper的目录做锁,简单来说,创建一个目录,就拥有了与之对应的锁。
zookeeper存储数据有两种方式,一种是目录名本身就是数据,另一种就是在目录上设置data,data中就是业务数据。
zookeeper中的目录有四种类型,分别是永久节点、永久有序节点、临时节点、临时有序节点。
利用临时节点的特性,当节点宕机或者网络链接断开的时候,临时节点自动删除,避免死锁的发生。
直接上图:

利用zookeeper实现分布式锁
具体步骤如下:
1、针对分布式锁key——“ttt”,创建临时有序节点"ttt";
2、获取所有子节点并排序;
3、判断自身是否是最小序列节点;
3s、自身是最小序列节点,获取分布式锁成功
3f、自身不是最小序列节点,获取序列小1的上一节点,并等待上一节点删除的事件event
3f.4、上一节点删除的事件event到来,转回第2步。
zookeeper上的数据是怎样的呢?

细节上可以有诸多不同,总的来说在分布式环境中会有一堆节点在锁下面排队等待,直到获取锁,再释放临时节点。
下面我们来逐条讨论下以上提到的分布式锁的特性:
1、在分布式环境下,一个代码块在同一时间只能被一个机器的一个线程执行
基本的,所有的锁都应该满足
2、高可用地获取锁和释放锁
zookeeper集群节点分为3种,leader、follower、observer,zookeeper集群本身提供分布式一致性服务,是一个CP系统
leader能提供读写服务,follower、observer数据同步后就能提供服务,当数据不同步时,停止服务,直到数据同步完成
当单节点不能提供服务的时候,client重新选择其它可用节点。
3、高性能的获取锁和释放锁
锁的获取和释放就是zookeeper的读写,性能因具体硬件和软件基础不同而不同,性能还受网络的影响,在笔者的开发环境中zookeeper创建目录大概耗时约30~140ms
4、具备锁失效机制,防止死锁
因为是临时节点,无论是因为宕机,或者是网络断开,或者是其它任何原因,断开连接自动删除临时节点,使得不会产生死锁问题
但是,因为是临时节点,如果发生网络抖动,可能会造成假死现象发生,通过zookeeper的自动重连机制来解决。如果一旦发生假死现象,需要增加更复杂的机制来解决问题。
5、是否可重入
如果想获得类似ReentrantLock的特性,可以在value中写入线程的信息(IP地址+进程号+线程号),可以先查询,如果是线程自己拥有的锁,直接返回成功并计数count
6、是否非阻塞
当获取锁不成功时,阻塞自己,等待上一节点删除的event,开始watch,直到event发生,触发再次获得锁的尝试
7、自旋锁
如果要使用自旋机制,即不断地去尝试获取所有子节点,并判断自身是否是最小节点。当然这种方式是浪费计算资源的。
8、乐观/悲观
分布式锁就是悲观锁。如果不想用分布式锁,对数据不上锁,在value中加入version的值,通过CAS版本号来控制并发写
9、是否公平
因为是按照序列号排队的,所以该实现是公平的。
10、独享/共享
如果要实现读写共享锁,我们需要在排队的基础上加上锁类型字段,表明是读锁还是写锁。以下单独开一节重点解析下。
11、分段锁
暂不讨论
用zookeeper实现读写共享锁
编辑中...

编辑中...

编辑中...
d、结论
分布式锁可以根据业务需要,量身定制,根据场景不同,选择性地实现拥有不同特性的分布式锁。
从性能上来讲:
性能:缓存 > Zookeeper >= 数据库
三、畅想
在系统中可以实现多个分布式锁,不同业务不同场景选择不同的分布式锁

浙公网安备 33010602011771号