分布式锁探讨

一、分布式锁背景

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 >= 数据库

三、畅想

在系统中可以实现多个分布式锁,不同业务不同场景选择不同的分布式锁

posted @ 2019-03-26 21:25  疯狂的catcher  阅读(1136)  评论(0)    收藏  举报