分布式锁

在分布式场景下,有很多种情况都需要实现最终一致性。在设计远程上下文的领域事件的时候,为了保证最终一致性,在通过领域事件进行通讯的方式中,可以共享存储(领域模型和消息的持久化数据源),或者做全局XA事务(两阶段提交,数据源可分开),也可以借助消息中间件(消费者处理需要能幂等)。通过Observer模式来发布领域事件可以提供很好的高并发性能,并且事件存储也能追溯更小粒度的事件数据,使各个应用系统拥有更好的自治性。

本文主要探讨另外一种实现分布式最终一致性的解决方案——采用分布式锁。基于分布式锁的解决方案,比如zookeeper,redis都是相较于持久化(如利用InnoDB行锁,或事务,或version乐观锁)方案提供了高可用性,并且支持丰富化的使用场景。 

数据库锁表

第一次接触分布式锁用的是mysql的锁表。当时我并没有分布式锁的概念。只知道当时有两台交易中心服务器处理相同的业务,每个交易中心处理订单的时候需要保证另一个无法处理。于是用mysql的一张表来控制共享资源。表结构如下:

CREATE TABLE `lockedOrder` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主码',
  `type` tinyint(8) unsigned NOT NULL DEFAULT '0' COMMENT '操作类别',
  `order_id` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的order_id',
  `memo` varchar(1024) NOT NULL DEFAULT '',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_order_id` (`order_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的订单';

order_id记录了订单号,type和memo用来记录下是那种类型的操作锁定的订单,memo用来记录一下操作内容。这张表能完成分布式锁的主要原因正是由于把order_id设置为了UNIQUE KEY,所以同一个订单号只能插入一次。于是对锁的竞争就交给了数据库,处理同一个订单号的交易中心把订单号插入表中,数据库保证了只有一个交易中心能插入成功,其他交易中心都会插入失败。lock和unlock的伪代码也非常简单:

def lock :
    exec sql: insert into lockedOrder(type,order_id,memo) values (type,order_id,memo)
    if result == true :
        return true
    else :
        return false

def unlock :
    exec sql: delete from lockedOrder where order_id='order_id'

读者可以发现,这个锁从功能上有几个问题:

  • 数据库锁实现只能是非阻塞锁,即应该为tryLock,是尝试获得锁,如果无法获得则会返回失败。要改成阻塞锁,需要反复执行insert语句直到插入成功。由于交易中心的使用场景,只要一个交易中心处理订单就行了,所以这里不需要使用阻塞锁。
  • 这把锁没有过期时间,如果交易中心锁定了订单,但异常宕机后,这个订单就无法锁定了。这里为了让锁能够失效,需要在应用层加上定时任务,去删除过期还未解锁的订单。clear_timeout_lock的伪代码很简单,只要执行一条sql即可。

    def clear_timeout_lock :
        exec sql : delete from lockedOrder where update_time <  ADDTIME(NOW(),'-00:02:00')

    这里设置过期时间为2分钟,也是从业务场景考虑的,如果订单处理时间可能超过2分钟的话,这个时候还需要加大。

  • 这把锁是不能重入的,意思就是即使一个交易中心获得了锁,在它为解锁前,之后的流程如果有再去获取锁的话还会失败,这样就可能出现死锁。这个问题我们当时没有处理,如果要处理这个问题的话,需要增加字段,在insert的时候,把该交易中心的标识加进来,这样再获取锁的时候, 通过select,看下锁定的人是不是自己。lock的伪代码版本如下:

    def lock :
        exec sql: insert into lockedOrder(type,order_id,memo) values (type,order_id,memo)
        if result == true :
            return true
        else :
            exec sql : select id from lockedOrder where order_id='order_id' and memo = 'TradeCenterId'
            if count > 0 :
                return true
            else 
                return false

     

  • 在锁定失败后,看下锁是不是自己,如果是自己,那依然锁定成功。不过这个方法解锁又遇到了困难,第一次unlock就把锁给释放了,后面的流程都是在没锁的情况下完成,就可能出现其他交易中心也获取到这个订单锁,产生冲突。解决这个办法的方法就是给锁加计数器,记录下lock多少次。unlock的时候,只有在lock次数为0后才能删除数据库的记录。

可以看出,数据库锁能实现一个简单的避免共享资源被多个系统操作的情况。在并发量不是那么恐怖的情况下,数据库锁的性能也不容易出问题,而且由于数据库的数据具有持久化的特性,一般的应用也足够应付。但是除了上面说的数据库锁的几个功能问题外,数据库锁并没有很好的应付数据库宕机的场景,如果数据库宕机,会带来的整个交易中心无法工作。当时我也没想过这个问题,我们整个交易系统,数据库是个单点,不过数据库实在是太稳定了,两年也没出过任何问题。随着工作经验的积累,构建高可用系统的概念越来越强,系统中是不允许出现单点的。现在想想,通过数据库的同步复制,以及使用vip切换Master就能解决这个问题。

缓存锁

后来我开始接触缓存服务,知道很多应用都把缓存作为分布式锁,比如redis。使用缓存作为分布式锁,性能非常强劲,在一些不错的硬件上,redis可以每秒执行10w次,内网延迟不超过1ms,足够满足绝大部分应用的锁定需求。

redis锁定的原理是利用setnx命令,即只有在某个key不存在情况才能set成功该key,这样就达到了多个进程并发去set同一个key,只有一个进程能set成功。

仅有一个setnx命令,redis遇到的问题跟数据库锁一样,但是过期时间这一项,redis自带的expire功能可以不需要应用主动去删除锁。而且从 Redis 2.6.12 版本开始,redis的set命令直接直接设置NX和EX属性,NX即附带了setnx数据,key存在就无法插入,EX是过期属性,可以设置过期时间。这样一个命令就能原子的完成加锁和设置过期时间。

缓存锁优势是性能出色,劣势就是由于数据在内存中,一旦缓存服务宕机,锁数据就丢失了。像redis自带复制功能,可以对数据可靠性有一定的保证,但是由于复制也是异步完成的,因此依然可能出现master节点写入锁数据而未同步到slave节点的时候宕机,锁数据丢失问题。

分布式缓存锁—Redlock

redis作者鉴于单点redis作为分布式锁的可能出现的锁数据丢失问题,提出了Redlock算法,该算法实现了比单一节点更安全、可靠的分布式锁管理(DLM)。下面我就介绍下Redlock的实现。

Redlock算法假设有N个redis节点,这些节点互相独立,一般设置为N=5,这N个节点运行在不同的机器上以保持物理层面的独立。

算法的步骤如下:

  • 1、客户端获取当前时间,以毫秒为单位。
  • 2、客户端尝试获取N个节点的锁,(每个节点获取锁的方式和前面说的缓存锁一样),N个节点以相同的key和value获取锁。客户端需要设置接口访问超时,接口超时时间需要远远小于锁超时时间,比如锁自动释放的时间是10s,那么接口超时大概设置5-50ms。这样可以在有redis节点宕机后,访问该节点时能尽快超时,而减小锁的正常使用。
  • 3、客户端计算在获得锁的时候花费了多少时间,方法是用当前时间减去在步骤一获取的时间,只有客户端获得了超过3个节点的锁,而且获取锁的时间小于锁的超时时间,客户端才获得了分布式锁。
  • 4、客户端获取的锁的时间为设置的锁超时时间减去步骤三计算出的获取锁花费时间。
  • 5、如果客户端获取锁失败了,客户端会依次删除所有的锁。

使用Redlock算法,可以保证在挂掉最多2个节点的时候,分布式锁服务仍然能工作,这相比之前的数据库锁和缓存锁大大提高了可用性,由于redis的高效性能,分布式缓存锁性能并不比数据库锁差。

分布式专家质疑Redlock

该专家提到,考虑分布式锁的时候需要考虑两个方面:性能和正确性。

如果使用高性能的分布式锁,对正确性要求不高的场景下,那么使用缓存锁就足够了。

如果使用可靠性高的分布式锁,那么就需要考虑严格的可靠性问题。而Redlock则不符合正确性。为什么不符合呢?专家列举了几个方面。

现在很多编程语言使用的虚拟机都有GC功能,在Full GC的时候,程序会停下来处理GC,有些时候Full GC耗时很长,甚至程序有几分钟的卡顿,文章列举了HBase的例子,HBase有时候GC几分钟,会导致租约超时。而且Full GC什么时候到来,程序无法掌控,程序的任何时候都可能停下来处理GC,比如下图,客户端1获得了锁,正准备处理共享资源的时候,发生了Full GC直到锁过期。这样,客户端2又获得了锁,开始处理共享资源。在客户端2处理的时候,客户端1 Full GC完成,也开始处理共享资源,这样就出现了2个客户端都在处理共享资源的情况。

专家给出了解决办法,如下图,看起来就是MVCC,给锁带上token,token就是version的概念,每次操作锁完成,token都会加1,在处理共享资源的时候带上token,只有指定版本的token能够处理共享资源。

然后专家还说到了算法依赖本地时间,而且redis在处理key过期的时候,依赖gettimeofday方法获得时间,而不是monotonic clock,这也会带来时间的不准确。比如一下场景,两个客户端client 1和client 2,5个redis节点nodes (A, B, C, D and E)。

  • 1、client 1从A、B、C成功获取锁,从D、E获取锁网络超时。
  • 2、节点C的时钟不准确,导致锁超时。
  • 3、client 2从C、D、E成功获取锁,从A、B获取锁网络超时。
  • 4、这样client 1和client 2都获得了锁。

总结专家关于Redlock不可用的两点:

  • 1、GC等场景可能随时发生,并导致在客户端获取了锁,在处理中超时,导致另外的客户端获取了锁。专家还给出了使用自增token的解决方法。
  • 2、算法依赖本地时间,会出现时钟不准,导致2个客户端同时获得锁的情况。

所以专家给出的结论是,只有在有界的网络延迟、有界的程序中断、有界的时钟错误范围,Redlock才能正常工作,但是这三种场景的边界又是无法确认的,所以专家不建议使用Redlock。对于正确性要求高的场景,专家推荐了Zookeeper,关于使用Zookeeper作为分布式锁后面再讨论。

redis作者解疑Redlock

redis作者看到这个专家的文章后,写了一篇博客予以回应。作者很客气的感谢了专家,然后表达出了对专家观点的不认同。

I asked for an analysis in the original Redlock specification here: http://redis.io/topics/distlock. So thank you Martin. However I don’t agree with the analysis.

redis作者关于使用token解决锁超时问题可以概括成下面五点:

  • 观点1,使用分布式锁一般是在,你没有其他方式去控制共享资源了,专家使用token来保证对共享资源的处理,那么就不需要分布式锁了。
  • 观点2,对于token的生成,为保证不同客户端获得的token的可靠性,生成token的服务还是需要分布式锁保证服务的可靠性。
  • 观点3,对于专家说的自增的token的方式,redis作者认为完全没必要,每个客户端可以生成唯一的uuid作为token,给共享资源设置为只有该uuid的客户端才能处理的状态,这样其他客户端就无法处理该共享资源,直到获得锁的客户端释放锁。
  • 观点4、redis作者认为,对于token是有序的,并不能解决专家提出的GC问题,如上图所示,如果token 34的客户端写入过程中发送GC导致锁超时,另外的客户端可能获得token 35的锁,并再次开始写入,导致锁冲突。所以token的有序并不能跟共享资源结合起来。
  • 观点5、redis作者认为,大部分场景下,分布式锁用来处理非事务场景下的更新问题。作者意思应该是有些场景很难结合token处理共享资源,所以得依赖锁去锁定资源并进行处理。

专家说到的另一个时钟问题,redis作者也给出了解释。客户端实际获得的锁的时间是默认的超时时间,减去获取锁所花费的时间,如果获取锁花费时间过长导致超过了锁的默认超时间,那么此时客户端并不能获取到锁,不会存在专家提出的例子。

更好的分布式锁—zookeeper

提到分布式协调服务,自然就想到了zookeeper。zookeeper实现了类似paxos协议,是一个拥有多个节点分布式协调服务。对zookeeper写入请求会转发到leader,leader写入完成,并同步到其他节点,直到所有节点都写入完成,才返回客户端写入成功。

zookeeper还有几个特质,让它非常适合作为分布式锁服务。

  • zookeeper支持watcher机制,这样实现阻塞锁,可以watch锁数据,等到数据被删除,zookeeper会通知客户端去重新竞争锁。
  • zookeeper的数据可以支持临时节点的概念,即客户端写入的数据是临时数据,在客户端宕机后,临时数据会被删除,这样就实现了锁的异常释放。使用这样的方式,就不需要给锁增加超时自动释放的特性了。

zookeeper实现锁的方式是客户端一起竞争写某条数据,比如/path/lock,只有第一个客户端能写入成功,其他的客户端都会写入失败。写入成功的客户端就获得了锁,写入失败的客户端,注册watch事件,等待锁的释放,从而继续竞争该锁。

如果要实现tryLock,那么竞争失败就直接返回false即可。

zookeeper实现的分布式锁简单、明了,分布式锁的关键技术都由zookeeper负责实现了。可以看下《从Paxos到Zookeeper:分布式一致性原理与实践》书里贴出来的分布式锁实现步骤

需要使用zookeeper的分布式锁功能,可以使用curator-recipes库。Curator是Netflix开源的一套ZooKeeper客户端框架,curator-recipes库里面集成了很多zookeeper的应用场景,分布式锁的功能在org.apache.curator.framework.recipes.locks包里面,《跟着实例学习ZooKeeper的用法: 分布式锁》文章里面详细的介绍了curator-recipes分布式锁的使用,想要使用分布式锁功能的朋友们不妨一试。

总结

文章写到这里,基本把我关于分布式锁的了解介绍了一遍。可以实现分布式锁功能的,包括数据库、缓存、分布式协调服务等等。根据业务的场景、现状以及已经依赖的服务,应用可以使用不同分布式锁实现。文章介绍了redis作者和分布式专家关于Redlock,虽然最终觉得Redlock并不像分布式专家说的那样缺乏正确性,不过我个人觉得,如果需要最可靠的分布式锁,还是使用zookeeper会更可靠些。curator-recipes库封装的分布式锁,java应用也可以直接使用。而且如果开始依赖zookeeper,那么zookeeper不仅仅提供了分布式锁功能,选主、服务注册与发现、保存元数据信息等功能都能依赖zookeeper,这让zookeeper不会那么闲置。

 

转载于:https://my.oschina.net/hosee/blog/686161

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值