分布式锁的概念及实现
条评论对于运行在同一个JVM中的单进程程序而言,要实现线程同步操作可使用语言和类库提供的锁,而对于如今分布在不同服务器上运行的程序而言,要实现线程同步操作,语言和类库提供的锁已不能满足需求,因此,对于此类场景,则可使用分布式锁。分布式锁的实现有多种形式,常见的主要有三种实现方式,如:基于数据库乐观锁;基于 redis 的 set 操作;基于 zookeeper 临时有序节点的特性
基于数据库实现
基于数据库表
基于数据库的实现方式的核心思想是:在数据库中创建一个表,表中包含方法名(资源)等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。
- 创建被锁定方法或资源表
1 | CREATE TABLE `methodLock` ( |
- 获取锁,锁定资源(方法或资源)
1 | insert into methodLock(method_name,desc) values (‘method_name’,‘desc’) |
当要获取资源或执行某个方法时,向表中插入该方法记录,由于方法名为唯一索引,所以插入成功则表明已成功获取锁,否则失败
- 释放锁
1 | delete from methodLock where method_name ='method_name' |
当方法执行完毕时,删除插入的记录即可释放锁,其他进程中的线程即可通过插入记录获取锁
基于数据库排他锁
除了可以通过增删操作数据表中的记录以外,其实还可以借助数据中自带的锁来实现分布式锁。我们还用刚刚创建的那张数据库表,通过数据库的排他锁来实现分布式锁。主要 基于MySql的InnoDB引擎,当进行
for update操作时,数据库会在查询过程中为当前查询添加排它锁
- 获取锁(加锁)
1 | public boolean lock(){ |
需要注意的是:InnoDB引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。如果我们需要使用行级锁,就要为method_name添加索引,并且一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。
我们可以认为获得排它锁的线程即获得了分布式锁,当获取到锁之后,就可以执行方法的业务逻辑,执行完方法之后,再通过提交事物方法解锁:
- 释放锁
1 | public void unlock(){ |
这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。
for update语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。- 使用排它锁的方式,服务宕机之后数据库会自己把锁释放掉。
存在的问题
- 无法直接解决数据库单点和可重入问题。
- 我们知道,Mysql执行查询语句之前会进行查询优化,因此,尽管我们对
method_name使用了唯一索引,且使用for update来显示使用行级锁,但是查询时是否使用索引具体还要根据Mysql根据内部优化策略判断,如果数据量较小的时候,Mysql可能会认为全表扫描效率更高,则不会使用索引而导致InnoDB使用表锁,此时将导致需要获取其他锁的线程阻塞 - 如果某个事物长时间占用排它锁,导致长时间占用数据库连接,若类似的耗时连接较多,则数据库连接池将支持不住
可优化
- 因为是基于数据库实现的,数据库的可用性和性能将直接影响分布式锁的可用性及性能,所以,数据库需要双机部署、数据同步、主从切换;
- 不具备可重入的特性,因为同一个线程在释放锁之前,行数据一直存在,无法再次成功插入数据,所以,需要在表中新增一列,用于记录当前获取到锁的机器和线程信息,在再次获取锁的时候,先查询表中机器和线程信息是否和当前机器和线程相同,若相同则直接获取锁;
- 没有锁失效机制,因为有可能出现成功插入数据后,服务器宕机了,对应的数据没有被删除,当服务恢复后一直获取不到锁,所以,需要在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据;
- 不具备阻塞锁特性,获取不到锁直接返回失败,所以需要优化获取逻辑,循环多次去获取。
- 在实施的过程中会遇到各种不同的问题,为了解决这些问题,实现方式将会越来越复杂;依赖数据库需要一定的资源开销,性能问题需要考虑。
小结
基于数据库实现的两种方式都是依赖数据库的一张表,一种是通过表中的记录是否存在判断是否持有分布式锁,另一种是通过数据库的排他锁来实现分布式锁。
优点
直接借助数据库,容易理解。
缺点
有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。
操作数据库需要一定的开销,性能问题需要考虑。
使用数据库的行级锁并不一定靠谱,尤其是当我们的锁表并不大的时候。
基于Redis实现
利用Redis的set命令。此命令是原子性操作,只有在key不存在的情况下,才能成功。
- 加锁
最简单的方法是使用set命令。例如想要给一种商品的秒杀活动加锁,key 为 “lock_sale_商品ID” ,value 假设为 11,若 ret 为 1,则表示加锁成功,为 0 则表示已存在 key,加锁失败
1 | // 加锁伪代码 |
- 解锁
当获取锁的线程执行完毕,需要释放锁,通过 del操作删除记录,当所释放之后,其他线程即可通过 set操作获取锁
1 | // 释放锁伪代码 |
- 锁超时
当某个获取所得线程在释放锁之前挂掉,该锁将会长期被占用导致共享资源无法为其他线程提供服务,因此需要为锁添加超时时间,以保证在锁在超时占用时也能被正常释放,Redis 2.6.12 之前 set 操作无法设置超时时间,因此 加锁和设置超时时间的操作为非原子性操作,这会导致在极端情况下,某线程获取锁之后,设置超时时间之前挂掉的话,将无法释放锁。好在后面的版本中 set 操作可设置超时时间
1 | // 加锁,设置超时时间 30s |
以上代码还有一个问题,即若线程A超时时长内方法未执行完毕,其他线程(线程B)在此期间也获取到了锁,则A执行完毕后删除锁,而此时的锁为B的锁,为避免这种情况,在删除锁之前需要判断是否是自己添加的锁,则value可设置为当前线程ID
1 | // 伪代码如下 |
解锁操作中判断操作和解锁操作为非原子性操作,此时可通过lua代码来实现这一段代码
1 | String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; |
此时已经解决了大部分问题,但是多个线程同步操作共享资源时,由于过期时间到达而方法未执行完毕,其他线程还是可以获取同步锁,可通过在获取同步锁的同时开启一个守护线程,例如 过期时间30s 则在29s时,将超时时间延长 一定的时间已达到续航的目的,但是需要注意的是,在方法执行完毕时,一定要手动关闭守护线程
小结
使用缓存来代替数据库来实现分布式锁,可以提供更好的性能,同时,很多缓存服务都是集群部署的,可以避免单点问题。并且很多缓存服务都提供了可以用来实现分布式锁的方法,比如redis的setnx方法等。并且,这些缓存服务也都提供了对数据的过期自动删除的支持,可以直接设置超时时间来控制锁的释放。
优点
性能好,实现起来较为方便。
缺点
通过超时时间来控制锁的失效时间并不是十分的靠谱。
基于zookeeper实现
每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个临时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个临时节点删除即可。
Zookeeper的数据结构就像一棵树,由节点(Znode)组成,节点分为四类,利用Zookeeper实现分布式锁主要使用其临时顺序节点的特性完成,节点分类及特性如下:
- 持久节点
默认的节点类型。创建节点的客户端与zookeeper断开连接后,该节点依旧存在 。
- 持久顺序节点
所谓顺序节点,就是在创建节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号。
- 临时节点
和持久节点相反,当创建节点的客户端与zookeeper断开连接后,临时节点会被删除
- 临时顺序节点
临时顺序节点结合和临时节点和顺序节点的特点:在创建节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号;当创建节点的客户端与zookeeper断开连接后,临时节点会被删除。
实现步骤
- 获取锁
- 在Zookeeper当中创建一个持久节点ParentLock。当第一个客户端想要获得锁时,需要在ParentLock这个节点下面创建一个临时顺序节点 Lock1。
- Client1查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock1是不是顺序最靠前的一个。如果是第一个节点,则成功获得锁。
- 如果再有一个客户端 Client2 前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock2。
- Client2查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock2是不是顺序最靠前的一个,结果发现节点Lock2并不是最小的。
- Client2向排序仅比它靠前的节点Lock1注册Watcher,用于监听Lock1节点是否存在。这意味着Client2抢锁失败,进入了等待状态。
- 如果又有一个客户端Client3前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock3。
- Client3查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock3是不是顺序最靠前的一个,结果同样发现节点Lock3并不是最小的。于是,Client3向排序仅比它靠前的节点Lock2注册Watcher,用于监听Lock2节点是否存在。这意味着Client3同样抢锁失败,进入了等待状态。
- 这样一来,Client1得到了锁,Client2监听了Lock1,Client3监听了Lock2。这恰恰形成了一个等待队列,很像是Java当中ReentrantLock所依赖的AQS(AbstractQueuedSynchronizer)。
- 释放锁
释放锁分为两种情况:
- 任务完成,客户端显示释放
当任务完成时,Client1会显示调用删除节点Lock1的指令。
- 任务执行过程中,客户端崩溃
获得锁的Client1在任务执行过程中,如果程序崩溃,则会断开与Zookeeper服务端的链接。根据临时节点的特性,相关联的节点Lock1会随之自动删除。
由于Client2一直监听着Lock1的存在状态,当Lock1节点被删除,Client2会立刻收到通知。这时候Client2会再次查询ParentLock下面的所有节点,确认自己创建的节点Lock2是不是目前最小的节点。如果是最小,则Client2顺理成章获得了锁。
同理,如果Client2也因为任务完成或者节点崩溃而删除了节点Lock2,那么Client3就会接到通知。最终,Client3成功得到了锁。
Zookeeper可解决问题
- 使用Zookeeper可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。
- 使用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。
- 使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。
- 使用Zookeeper可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。
使用Zookeeper存在的问题
使用ZK实现的分布式锁好像完全符合了本文开头我们对一个分布式锁的所有期望。但是,其实并不是,Zookeeper实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同步到所有的Follower机器上。
其实,使用Zookeeper也有可能带来并发问题,只是并不常见而已。考虑这样的情况,由于网络抖动,客户端可ZK集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。就可能产生并发问题。这个问题不常见是因为zk有重试机制,一旦zk集群检测不到客户端的心跳,就会重试,Curator客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。(所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡。)
小结
优点
有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题。实现起来较为简单。
缺点
性能上不如使用缓存实现分布式锁。 需要对ZK的原理有所了解。
总结
上面三种方式,均无法在复杂性、可靠性、性能等方面无法同时满足,所以,我们需要根据不同的应用场景选择适合的实现方式。
- 数据库实现容易理解,且易于实现,但是性能和可靠性方面较差
- Zookeeper实现方式最难理解和实现,但是可靠性好
- Redis实现方式性能较好,可靠性稍差与zookeeper
参考文章
- 本文链接:https://sangedon.cn/paper/分布式锁的概念及实现/
- 版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 CN 许可协议。转载请注明出处!
分享