10分钟搞定分布式锁的三种实现方式

1、为什么需要分布式锁

目前,几乎所有的大型网站以及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的CAP理论告诉我们,“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足其中的两项”。所以,很多系统在设计之初就需要考虑这三者之间的取舍。在互联网领域的绝大多数场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围之内即可。

在很多场景中,我们为了保证数据最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境下,很多语言像Java都提供了很多并发处理相关的API,但是这些API在分布式场景中就无能为力了,并不能提供分布式锁的能力。那么,针对分布式的场景,我们所期待的分布式锁需要具备哪些能力呢?

  1. 在分布式系统环境下,一个方法在同一时间只能被一台机器的一个线程执行。
  2. 高可用的获取锁和释放锁。
  3. 高性能的获取锁和释放锁。
  4. 具备可重入特性。
  5. 具备锁失效机制,防止死锁。
  6. 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。

在满足以上能力的前提下,分布式锁的实现目前有多种方案。其中比较常用的有以下几种方案:

  • 基于数据库实现分布式锁
  • 基于缓存(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分布式锁的比较

分布式锁优点缺点
Zookeeper1、有封装好的框架,容易实现。
2、有等待锁的队列,大大提升了抢锁的效率。
添加和删除节点性能比较低。
RedisSet和Del命令的性能比较高1、实现复杂,需要考虑超时、原子性、误删等情形。
2、没有等待所的队列,只能在客户端自旋来等待锁,效率低下。

有人说Zookeeper实现的分布式锁支持可重入,Redis实现的分布式锁不支持可重入,这种观点其实是错误的,两者都可以在客户端实现可重入的逻辑。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值