分布式环境下实现分布式锁的关键技术

1,082 阅读8分钟

摘要:在日常开发中,应用大多数是分布式部署的,经常会面临分布式环境下应用对数据操作的一致性问题。这时就需要找出一个在分布式环境下同一个应用多个实例之间能够访问的临界资源,并对该临界资源做互斥访问,从而保证数据一致性。本文结合笔者实际工作中的经验,对分布式环境下实现应用分布式锁的关键思路进行探讨。

关键词: 分布式锁、互斥资源、数据一致性 分布式环境下,分布式部署的应用很多时候需要对同一个资源数据进行操作以满足业务需要,这里就会面临数据一致性的问题。 例如商品售卖,同一时间可能会有多个线程请求库存扣减,如果不对库存扣减进行互斥访问,将会导致商品超卖,这是不可容忍的。因此,利用分布式锁对库存扣减进行互斥访问,可以解决库存数据的一致性问题。 本文介绍利用数据库,缓存,zookeepr三种资源实现分布式锁的关键思路。

1.分布式锁的原理

利用一个分布式环境下,多实例能够访问的公共资源来实现分布式锁,具体这个公共资源可以是数据库,缓存(redis/memcache),zookeeper等等。 这里需要明确一点,以JDK为例,在实现分布式锁时可能会考虑使用ReentrantLock,但实际上是有问题的,ReentrantLock是同一个JVM上的锁,lock和unlock都需要在同一个线程上进行,而分布式环境下,是多JVM的,而且lock和unlock极有可能是两次不同的甚至不相关的请求,因此肯定不是同一个线程,从而无法使用ReentrantLock来实现分布式锁。

2 基于数据库的分布式锁

目前主流的数据库引擎,都支持select for update的语法,这是一种排他锁,当两个不同事务都执行到select xxx where id=? for update时,假设id上有唯一索引,那么后面执行的事务将会阻塞在该sql语句上,直到前一个事务提交或者回滚。因此可以利用这种特性来实现分布式锁。

2.1流程图

在这里插入图片描述

图 1 数据库分布式锁关键流程 (1) 设置数据库连接为事务不自动提交。 (2) 执行select * for lock where lock_name=xxx for update语句,判断结果集,如果结果集有记录,表示某一个事务已经获得到了锁资源,则转向步骤(3),否则结束。 (3) 进行业务操作,转向步骤(4)。 (4) 释放锁:事务提交。

2.2 伪代码 2.2.1 获取锁

在这里插入图片描述

上述为获取锁的具体实现,关键是

select * for lock where lock_name=xxx for update

语句,同一时间只有一个事务能够顺利执行该sql得到返回值,其余事务将在该sql语句上阻塞。 当sql语句的返回的结果集不为空时,表示获取到锁,否则结果集为空或者抛异常都视为没有获取到锁。

2.2.2 释放锁!

释放说的方式相对简单,只需要简单调用commit方法就可以。

2.3 锁表的设计 上述伪代码lock表的定义根据场景的不用而有所不同。 这里举个例子,lock表可以设计成如下(以MySQL为例子):

在这里插入图片描述

注意到lock_name字段建了唯一索引,因为对于select for update语句,如果where条件的字段没有唯一索引,那么for update将会失效,这点需要特别注意。

3.基于缓存的分布式锁

分布式缓存,例如redis,memcache等都会提供一些命令,这些命令都有一个共同的特征:原子性。 例如:redis的setnx命令,memcache的add命令等等。这里以redis为例,实际场景中,一般是setnx,get,getset三个命令结合使用来实现分布式锁。 setnx命令的含义是set if not exists,参数有两个:key,value。该命令是原子的,如果key不存在,则设置当前key为value成功,返回1;如果key已经存在,则设置当前key为value失败,返回0。 get命令的含义是获取指定key的值。 getset命令的含义是设置指定key一个新的值,并且返回旧的值,该命令也是原子的。

3.1 流程图

在这里插入图片描述

图 2 redis缓存分布式锁关键流程 (1)调用setnx:flag=setnx(lockkey,当前时间+过期超时时间),判断flag的返回值,如果flag=1代表获取锁成功,则转向步骤(4)。如果flag=0代表没有获取到锁,则转向步骤(2)

(2)获取lockkey的值:oldExpireTime=get(lockkey)。如果当前系统时间<= oldExpireTime,表示未超时,仍然有另外的线程持有锁,因此转向步骤(1)继续尝试抢锁;如果前系统时间> oldExpireTime,表示已经超时,转向步骤(3)

(3)计算新的时间值:newExpireTime=当前时间+过期超时时间,调用getset命令,即currentExpireTime=getset(lockkey, newExpireTime),如果这时newExpireTime不等于currentExpireTime,代表已经有另外的线程在当前线程调用getset之前调用了setnx,并且返回值是1,因此当前线程抢锁失败,转向步骤(1)继续尝试抢锁。如果newExpireTime等于currentExpireTime,表示当前线程抢锁成功,则转向步骤(4)。

(4)具体业务操作。业务操作完成之后,比较业务处理时间和锁的超时时间。如果业务处理时间>=锁超时时间,表示锁已经被redis的超时机制删除了,则转向步骤(6)。如果业务处理时间<锁超时时间,则转向步骤(5)。

(5)调用del命令删除锁:del(lockkey)。转向步骤(6)。

(6)其他业务操作。

3.2 伪代码 3.2.1 获取锁

在这里插入图片描述

3.2.2 释放锁

在这里插入图片描述

4 基于zookeeper的分布式锁

Zookeeper是一个分布式的应用程序协调服务,它包含一些简单的原语集,分布式应用可以基于它实现诸如同步服务,配置管理,命名服务等。 Zookeeper的管理基于层级命名空间,类似操作系统的目录结构,每个目录节点可以关联数据。其中有一种非常特殊的节点:临时节点,同一时间只有一个会话可以创建节点成功,当会话结束节点会被自动删除。因此可以利用该特性来实现分布式锁。

4.1 流程图

在这里插入图片描述

图 3 zookeeper缓存分布式锁关键流程 (1) 创建连接zookeeper的会话。 (2) 尝试创建临时节点:/exclusiveLock/lock,如果创建成功,则转向步骤(3),否则继续进行循环,尝试下一次创建节点。 (3) 进行业务操作,转向步骤(4)。 (4) 释放锁:会话结束或者删除临时节点。

4.2 伪代码 4.2.1 获取锁 通过构建一个目录,当叶子节点能创建成功,则认为获取到锁,因为一旦一个节点被某个会话创建,其它会话再次创建创这个节点时,将会抛出异常。 比如目录为:

在这里插入图片描述

图 4 zookeeper临时节点结构

在这里插入图片描述

4.2.2 释放锁 删除节点或者会话失效

在这里插入图片描述

5.结语

本文阐述了分布式环境下实现分布式锁的关键技术,通过基于数据库的分布式锁,基于缓存的分布式锁,基于zookeeper的分布式锁三种常用的分布式锁实现技术进行原理说明,对关键代码进行了流程说明和伪代码说明。 这三种方案基本可以解决日常工作中很多业务场景下的分布式锁问题,从而解决数据一致性问题。

当然,三种方案都有各自的优缺点: (1)基于数据库的分布式锁:在并发量很高的情况下,系统会有很多个分布式锁资源,对数据库性能有一定影响,特别是在分布式锁表和业务表在同一个数据库时性能下降尤为明显,这时可以对分布式锁表进行分库分表来降低压力,提供性能。总的来说,基于数据库分布式锁的方案,只适用于并发量不大的场景下使用。 (2)基于缓存的分布式锁:会存在单点问题,如果master节点宕机了,那么分布式锁就无效了,从而导致数据一致性问题。而假如redis是master-slave架构,那么会有如下情况出现:请求A在master节点上拿到了锁,master节点把请求A创建的锁信息写入到slave节点之前就宕机了,slave节点变成master节点之后,这时请求B有可能会拿到跟请求A相同的锁,因为slave节点还没有请求A的锁信息。 (3)基于zookeeper的分布式锁:zookeeper的优点是高可用,公平锁,心跳保持锁,顺序节点,临时节点,能够支撑大并发。同事zookeeper有成熟的客户端框架Curator,该框架封装了与zookeeper通信的细节,实现起分布式锁更加简单。总上所述:没有一种一劳永逸的分布式锁解决方案,只有适用某种场景下的具体实现方案,在真实环境下需要具体问题具体分析。 另外,同时也需要考虑如下问题: (1)如何避免死锁的出现,一旦出现死锁,对应用的影响是致命的。(2)怎么释放锁。(3)怎么知道锁释放了。(4)锁超时处理。