1、为什么需要分布式锁
目前,几乎所有的大型网站以及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的CAP理论告诉我们,“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足其中的两项”。所以,很多系统在设计之初就需要考虑这三者之间的取舍。在互联网领域的绝大多数场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围之内即可。
在很多场景中,我们为了保证数据最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境下,很多语言像Java都提供了很多并发处理相关的API,但是这些API在分布式场景中就无能为力了,并不能提供分布式锁的能力。那么,针对分布式的场景,我们所期待的分布式锁需要具备哪些能力呢?
- 在分布式系统环境下,一个方法在同一时间只能被一台机器的一个线程执行。
- 高可用的获取锁和释放锁。
- 高性能的获取锁和释放锁。
- 具备可重入特性。
- 具备锁失效机制,防止死锁。
- 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。
在满足以上能力的前提下,分布式锁的实现目前有多种方案。其中比较常用的有以下几种方案:
- 基于数据库实现分布式锁
- 基于缓存(Redis)实现分布式锁
- 基于Zookeeper实现分布式锁
2、基于数据库实现分布式锁
基于数据库实现分布式锁的核心思想是:在数据库中创建一张表,表中包含方法名称等字段,并在方法名称字段上加一个唯一键。想要执行某个方法,就是用这个方法名称作为字段值向表中插入一条记录,插入成功则获取到锁。执行完成后删除对应的行数据来释放锁。
- 创建表
DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`method_name` varchar(64) NOT NULL COMMENT '锁定的方法名',
`desc` varchar(255) NOT NULL COMMENT '备注信息',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';
- 使用方法名作为字段值往表中插入记录,用于获取锁。
INSERT INTO method_lock (method_name, desc) VALUES ('methodName', 'test_method_name');
- 执行完方法后,删除对应行记录,用于删除锁。
delete from method_lock where method_name ='test_method_name';
说明:这只是基于数据库实现分布式锁的一种方式,还有很多其它的方式来实现。总的来说,基于数据库实现分布式锁的使用并不多见,因为存在如下的一些问题:
- 因为是基于数据库实现的,所以数据库的可用性和性能都将直接影响分布式锁的可用性及性能。对此,数据库需要多机部署,并且需要实现数据同步、主备切换等。
- 不具备可重入性,因为同一个线程在释放锁之前,行数据一直都在,所以无法再次成功的插入数据。因此需要在表中新增一列,用于记录当前获取到锁的机器和线程信息,当再次获取锁的时候,如果是同一个机器的同一个线程,则直接返回获取锁成功。
- 没有锁失效机制,因为有可能在插入数据之后,服务器宕机了,对应的行数据没有被删除,当服务器恢复之后会一直都获取不到锁。所以还需要在表中新增一列,用于记录锁的超时时间,并且需要有定时任务定时的清理掉这些超时的行数据。
- 不具备阻塞锁的特性,获取不到锁会直接返回失败,所以需要优化获取锁的逻辑,循环多次的去获取锁。
- 为了处理以上的问题,基于数据库实现分布式锁的方式会变得越来越复杂。并且依赖数据库也需要一定的资源开销。
3、基于Redis实现分布式锁
3.1、Redis几个基本命令
在说Redis之前,我们先来整理一下用Redis实现分布式锁所涉及到的几个命令。
- set:用于设置给定 key 的值。如果key已经存储了其它的值,set命令就直接覆盖旧的值,无视旧值的类型。总是返回OK,不可能失败。
- setnx:set if not exists的缩写,也就是只有在key不存在时才会给key设置新的值,设置成功返回1,设置失败返回0。如果key已经存在,则不做任何操作,直接返回0。
- getset:将指定key的值设置为新值value,并且返回key的旧值。当key存在但不是字符串时,返回一个错误。当key没有旧值的时候返回nil。
- get:返回key所关联的字符串的值,如果key不存在,那就返回一个特殊值nil,如果key存储的值不是字符串类型,返回一个错误。因为get只能用于处理字符串值。
3.2、Redis官方加锁方式
Redis官网推荐的setnx实现分布式锁的方式如下,多个进程同时执行以下命令:
setnx keyname currenttime + timeout + 1
如果setnx命令返回1,说明该进程获得锁,setnx命令将keyname键的值设置为锁的超时时间(当前时间+锁的有效时间)。
如果setnx命令返回0,说明其它进程已经获得了锁,进程不能进入临界区。进程可以在一个循环中不断的尝试setnx操作来获得锁。
3.3、死锁问题
setnx可以很好的解决分布式锁的问题,但是考虑一种场景,如果某个进程在获得锁之后,断开了与Redis的连接(可能是进程死了或者是网络等其他问题),这时如果没有有效的方式来释放锁的话,其他进程会一直处于等待状态,俗称“等到死”,学名“死锁”。
在使用setnx获得锁的时候,我们将键keyname的值设置为锁的有效时间。在某个进程获得锁之后,其他进程会不断的检测锁是否已经超时,如果超时,那么等待的进程就有机会获得锁了(备胎还是有可能转正的)。然而,在锁超时时,我们不能简单粗暴的直接del keyname来释放锁。考虑以下场景,进程p1已经首先获得了锁keyname,然后进程p1挂掉了。进程p2和p3正在锲而不舍的检测锁keyname是否已经超时,执行流程如下:
- p2和p3进程执行get命令读取键keyname的值,检测锁是否已经超时(通过比较keyname的值和当前时间来判断是否超时)。
- 巧了,p2和p3都发现锁keyname已经超时。
- p2执行del keyname命令,删除p1设置的keyname键。
- p2执行setnx keyname命令,返回1。p2获得了锁(备胎转正)。
- p3也执行del keyname命令(因为p3之前也是检测到锁keyname已经超时了,所以也del,虽然是只想删除p1设置的keyname键,但是把p2刚刚设置的keyname键也给干掉了)。
- p3也执行setnx keyname命令,这是也返回1,p3页获得了锁(备胎转正)。
- 结果是p2和p3同时获得了锁(2个备胎都转正了,很恐怖)。
看了以上场景,我们清楚了,在检测到锁超时之后,不能简单直接的执行del来删除键以获得锁。为了解决上述场景中可能出现的多个进程同时获得锁的问题,我们再来看一下下面这个执行流程(前提还是p1已经获得了锁keyname,然后p1莫名其妙的挂了):
- 进程p4执行setnx命令来尝试获取锁,当然失败。
- p4再执行get命名来获取keyname键的值,判断锁是否已经超时。如果没超时,没关系,等会我再来一次。
- 如果某一次p4检测到锁keyname超时了(当前时间大于keyname的值),这时p4不会直接del,会执行getset命令。
- getset命令在返回键keyname旧值(oldtime)的同时,还会给keyname设置新的值(currenttime + timeout),然后比较旧值(oldtime)是否小于当前时间,如果旧值(oldtime)小于当前时间,说明此时还没有别的进程获取到该锁(就算是有获取到,也是超时了),那么当前进程p4就获取到了锁keyname。
- 假设与此同时另一个进程p5也检测到锁keyname超时,并在p4之前执行了getset操作,那么p4的getset操作就会返回一个大于当前时间的时间戳,这样p4就会获取锁失败,然后继续的等待并尝试获取锁。注意到,即使p4的getset操作将键keyname的值设置成了一个比p5设置的更大(由于Redis是单线程的,所以后执行的getset操作一定会给键keyname设置一个更大的值)的值也没有什么影响。
4、基于ZooKeeper实现分布式锁
4.1、什么是临时顺序节点?
先回顾一下Zookeeper节点的概念:
Zookeeper的数据存储结构就像一棵树,这棵树由节点组成,这种节点就叫做Znode。Znode一共分为四种类型:
- 持久节点(Persistent):默认的节点类型,创建节点的客户端在与Zookeeper断开连接后,该节点依然存在。
- 持久顺序节点(Persistent_Sequential):所谓的顺序节点,就是在创建节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号:
- 临时节点(Ephemeral):和持久节点相反,当创建临时节点的客户端与Zookeeper断开连接后,临时节点会被删除:
- 临时顺序节点(Ephemeral_Sequential):顾名思义,临时顺序节点就是临时节点和顺序节点的组合,其特点就是在创建节点的时候,Zookeeper根据创建的时间顺序给节点名称进行排序,当创建节点的客户端与Zookeeper断开连接后,临时节点都会被删除。
4.2、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也会获得锁
4.3、Zookeeper和Redis分布式锁的比较
分布式锁 | 优点 | 缺点 |
Zookeeper | 1、有封装好的框架,容易实现。 2、有等待锁的队列,大大提升了抢锁的效率。 | 添加和删除节点性能比较低。 |
Redis | Set和Del命令的性能比较高 | 1、实现复杂,需要考虑超时、原子性、误删等情形。 2、没有等待所的队列,只能在客户端自旋来等待锁,效率低下。 |
有人说Zookeeper实现的分布式锁支持可重入,Redis实现的分布式锁不支持可重入,这种观点其实是错误的,两者都可以在客户端实现可重入的逻辑。