分布式锁几种实现方案

2,157 阅读18分钟

分布式锁使用的背景

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

一致性(Consistency):所有节点上的数据,时刻保持一致。

可用性(Availability):每个请求都能够收到一个响应,无论响应成功或者失败。

分区容错性(Partition tolerance):分布式系统在遇到任何网络分区故障的时候,仍然能够保证对外提供满足一致性和可用性的服务,除非是整个网络环境都发生了故障。

CP、AP组合:分区容错性必须考虑(因为网络出现的问题,在分布式架构中无法避免。eg. 淘宝光纤被挖断…网络分区的情形不能忽视)

CAP理论仅适用于原子读写的Nosql场景(redis、缓存…),不适用于数据库场景(脑裂之后更新了错误数据导致的数据紊乱,数据库高可用方案对此无能为力)

在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,Java中其实提供了很多并发处理相关的API(如JUC中的锁),但是这些API在分布式场景中就无能为力了。也就是说单纯的Java Api并不能提供分布式锁的能力。所以针对分布式锁的实现目前有多种方案。

针对分布式锁的实现,目前比较常用的有以下几种方案:

基于数据库实现分布式锁

基于缓存(redis、squirrel)实现分布式锁

基于Zookeeper实现分布式锁

在分析这几种实现方案之前我们先来想一下,我们需要的分布式锁应该是怎么样的?

1、在分布式系统环境下,一个方法(资源)在同一时间只能被一个机器的一个线程执行(访问);

2、高可用的获取锁与释放锁;

3、高性能的获取锁与释放锁;

4、具备可重入特性;

5、具备锁失效机制(自动过期),防止死锁;

6、具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。

基于数据库实现分布式锁

基于数据库唯一索引

要实现分布式锁,最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。

当我们要锁住某个方法或资源时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录。

创建这样一张数据库表:

CREATE TABLE `methodlock` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `method_name` varchar(100) NOT NULL DEFAULT '' COMMENT '加锁的方法名',
  `note` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
  `add_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '锁创建时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_method` (`method_name`) USING BTREE COMMENT 'method_name的唯一索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='加锁的方法';

当我们想要锁住某个方法时,执行以下SQL:

insert into methodLock(method_name,note) values ('method_name','note');

因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

当方法执行完毕之后,想要释放锁的话,需要执行以下Sql:

delete from methodLock where method_name ='method_name';

上面这种简单的实现有以下几个问题:

1、这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。

搞两个数据库,双机部署,数据双向同步,主备切换。

2、这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

在数据库表中加个字段(thread_id),记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。

3、这把锁没有失效时间,一旦解锁操作失败(如:数据库宕机),就会导致锁记录一直在数据库中,其他线程无法再获得到锁。

记录锁失效时间(del_time),使用定时任务把数据库中的超时数据清理。

  • MySQL的定时事件清理
  • 程序定时任务
  • 脚本...

4、这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。

循环多次获取。

基于数据库排他锁

解决阻塞和数据库宕机释放锁

我们还用刚刚创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。 基于MySQL的InnoDB引擎,可以使用以下方法来实现加锁操作:

public boolean lock(){ 
	connection.setAutoCommit(false)
	while(true){
		try{ 
			result = select * from methodLock where method_name=xxx for update; 
      if(result==null){ 
     		return true; 
   		} 
  	}catch(Exception e){ }
   	sleep(1000); 
 } 
 return false; 
}

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。

我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁:

public void unlock(){ 
  connection.commit(); 
}

通过connection.commit()操作来释放锁。

  • MySQL中验证:
SET autocommit = 0;
START TRANSACTION;
SELECT * FROM methodlock WHERE method_name = 'method_name' FOR UPDATE;
COMMIT;
SELECT * FROM methodlock WHERE method_name = 'method_name' FOR UPDATE;

这种方法可以有效的解决上面提到部分问题:

  • 阻塞锁: for update语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。
  • 锁定之后服务宕机,无法释放:使用这种方式,服务宕机之后数据库会自己把锁释放掉。

问题:

  1. 需要数据库中初始化所有需要加锁的资源(没有对应记录无法加锁)
  2. 还是无法直接解决数据库单点和可重入问题
  3. 无法实现超时自动释放锁

数据库实现改良

CREATE TABLE `methodlock_pro` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `method_name` varchar(100) NOT NULL DEFAULT '' COMMENT '加锁的方法名',
  `thread_id` varchar(32) NOT NULL DEFAULT '' COMMENT '获得锁的线程唯一ID',
  `note` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
  `add_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '锁创建时间',
  `del_time` timestamp NULL DEFAULT NULL COMMENT '锁过期时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_method` (`method_name`) USING BTREE COMMENT 'method_name的唯一索引'
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='加锁的方法';

总结

数据库实现分布式锁的优点:

  • 直接借助数据库,容易理解。

数据库实现分布式锁的缺点:

  • 因为数据库本身的缺陷会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。

  • 操作数据库需要一定的开销,性能问题需要考虑。

redis实现分布式锁

1、使用redis的setnx()、expire()方法:

**setnx()命令:**setnx的含义就是SET if Not Exists,其主要有两个参数 setnx(key, value)。该方法是原子的,如果key不存在,则设置当前key成功,返回1;如果当前key已经存在,则设置当前key失败,返回0。但是要注意的是setnx命令不能设置key的超时时间,只能通过expire()来对key设置。

具体的使用步骤如下:

  1. setnx(lockkey, 1) 如果返回0,则说明占位失败;如果返回1,则说明占位成功

  2. expire()命令对lockkey设置超时时间,为的是避免死锁问题。

  3. 执行完业务代码后,可以通过delete命令删除key。

这个方案其实是可以解决日常工作中的需求的。

问题:

  • 如果在第一步setnx执行成功后,在expire()命令执行成功前,发生了宕机的现象,那么就依然会出现死锁的问题。

2、使用redis的setnx()、get()、getset()方法:

​ 这个方案的背景主要是在setnx()和expire()的方案上针对可能存在的死锁问题,做了一版优化。

那么先说明一下这三个命令,对于setnx()和get()这两个命令,相信不用再多说什么。

**getset()命令:**这个命令主要有两个参数 getset(key,newValue)。该方法是原子的,对key设置newValue这个值,并且返回key原来的旧值。假设key原来是不存在的,那么多次执行这个命令,会出现下边的效果:

  1. getset(key, “value1″) 返回nil 此时key的值会被设置为value1

  2. getset(key, “value2″) 返回value1 此时key的值会被设置为value2

  3. 依次类推!

介绍完要使用的命令后,具体的使用步骤如下:

  1. setnx(lockkey, 当前时间+过期超时时间) ,如果返回1,则获取锁成功;如果返回0则没有获取到锁,转向2。

  2. get(lockkey)获取值oldExpireTime ,并将这个value值与当前的系统时间进行比较,如果小于当前系统时间,则认为这个锁已经超时,可以允许别的请求重新获取,转向3。

  3. 计算newExpireTime=当前时间+过期超时时间,然后getset(lockkey, newExpireTime) 会返回当前lockkey的值currentExpireTime。

  4. 判断currentExpireTime与oldExpireTime 是否相等,如果相等,说明当前getset设置成功,获取到了锁。如果不相等,说明这个锁又被别的请求获取走了,那么当前请求可以直接返回失败,或者继续重试。

  5. 在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行delete释放锁;如果大于锁设置的超时时间,则不需要再对锁进行处理。

3、使用redis 2.6.12的set()(squirrel):

squirrel的setnx(key, value) 封装的jedis的setnx(key, value)

squirrel的setnx(key, value, expireTime)使用2.6.12之后的redisCommand:set(key, value, nx|xx, ex|px, expireTime)

此方法已经自带过期时间,而且set这个操作是原子性的,所以不会出现:在第一步setnx执行成功后,在expire()命令执行成功前,发生了宕机的现象,那么就依然会出现死锁的问题

4、如何实现重入锁:

线程内部保存锁信息 + compareAndSet原子操作(lua脚本实现多个操作原子执行)。

每次一个获得锁就把锁放到ThreadLocal里,每次重新获得锁,就把获取的次数加一操作。只要ThreadLocal中存在锁信息,表示一致获得锁,再次获得锁时不需要从redis获取,将ThreadLocal中的值修改。

compareAndSet(key, expect, expect+1, expireTime)

释放锁:

5、redis集群

前面的方案基本在单点情况下没有问题,在集群环境中因为各节点的数据同步问题,不能完全保证安全性。需要额外的算法支持,redlock(时钟发生跳跃)

基于Redis的分布式锁到底安全吗(上)?

使用zookeeper实现分布式锁

1、原生zk方案

Zookeeper中有一种节点叫做顺序节点,假如我们在/lock/目录下创建节3个点,ZooKeeper集群会按照提起创建的顺序来创建节点,节点分别为/lock/0000000001、/lock/0000000002、/lock/0000000003。

ZooKeeper中还有一种名为临时节点的节点,临时节点由某个客户端创建,当客户端与ZooKeeper集群断开连接,则开节点自动被删除(依赖连接监控,可能有延迟)。

EPHEMERAL_SEQUENTIAL为临时顺序节点

实现分布式锁的基本逻辑:

  • 客户端调用create()方法创建名为“locknode/guid-lock-”的节点,需要注意的是,这里节点的创建类型需要设置为EPHEMERAL_SEQUENTIAL。
  • 客户端调用getChildren(“locknode”)方法来获取所有已经创建的子节点。
  • 客户端获取到所有子节点path之后,如果发现自己在步骤1中创建的节点是所有节点中序号最小的,那么就认为这个客户端获得了锁。
  • 如果创建的节点不是所有节点中需要最小的,那么则监视比自己创建节点的序列号小的最大的节点,进入等待。直到下次监视的子节点变更的时候,再进行子节点的获取,判断是否获取锁。

释放锁的过程相对比较简单,就是删除自己创建的那个子节点即可。

以下为流程图

共享锁、读写锁

  • 和排他锁一样,同样是通过zk上的数据节点来表示一个锁,是一个类似于/shared_lock/[hostname]-请求类型-序号的临时节点,例如//shared_lock/192.168.1.1-R-0000001,那么这个就代表了一个共享锁

步骤:

1 客户端调用create()方法创建一个类似于“/shared_lock/[Hostname]-请求类型-序号”的临时顺序节点

2 客户端调用getChildren()接口来获取所有已经创建的子节点列表,注意这里不注册任何的wather

3 如果无法获得共享锁,那么就调用exist()来对比自己小的那个节点注册wather

如果是读请求:向比自己小的最后一个写请求节点注册watcher监听

如果是写请求:向比自己序号小的最后一个节点注册watcher监听

4 等待watcher通知,继续进入步骤2

以下为流程图

2、zk分布式锁升级版

实现了一个分布式lock后,可以解决多进程之间的同步问题,但设计多线程+多进程的lock控制需求,单jvm中每个线程都和zookeeper进行网络交互成本就有点高了,所以基于DistributedLock,实现了一个分布式二层锁。

大致原理就是ReentrantLock 和 DistributedLock的一个结合。

  • 单jvm的多线程竞争时,首先需要先拿到第一层的ReentrantLock的锁
  • 拿到锁之后这个线程再去和其他JVM的线程竞争锁,最后拿到之后锁之后就开始处理任务。

锁的释放过程是一个反方向的操作,先释放DistributedLock,再释放ReentrantLock。 可以思考一下,如果先释放ReentrantLock,假如这个JVM ReentrantLock竞争度比较高,一直其他JVM的锁竞争容易被饿死。

需要注意的地方

node节点选择为EPHEMERAL_SEQUENTIAL很重要。

  • 自增长的特性,可以方便构建一个基于Fair特性的锁,前一个节点唤醒后一个节点,形成一个链式的触发过程。可以有效的避免"惊群效应"(一个锁释放,所有等待的线程都被唤醒),有针对性的唤醒,提升性能。

  • 选择一个EPHEMERAL临时节点的特性。因为和zookeeper交互是一个网络操作,不可控因素过多,比如网络断了,上一个节点释放锁的操作会失败。临时节点是和对应的session挂接的,session一旦超时或者异常退出 其节点就会消失,类似于ReentrantLock中等待队列Thread的被中断处理

  • 使用EPHEMERAL会引出一个风险:在非正常情况下,网络延迟比较大会出现session timeout,zookeeper就会认为该client已关闭,从而销毁其id标示,竞争资源的下一个id就可以获取锁。这时可能会有两个process同时拿到锁在跑任务,所以设置好session timeout很重要

  • 要避免zk分布式锁的羊群效应

3、Curator方案

Curator是Netflix公司开源的一个Zookeeper客户端,与Zookeeper提供的原生客户端相比,Curator的抽象层次更高,简化了Zookeeper客户端的开发量。 提供了不同的锁类型:

  • 可重入锁:实现类为InterProcessMutex,将线程对象,节点,锁对象相关联。InterProcessMutex内部维护了一个使用线程为key,{thread,path}为值的map,所以对不同的线程和请求加锁的节点进行一一对应。提供方法acquire 和 release。

  • 不可重入锁:实现类为InterProcessSemaphoreMutex,类似InterProcessMutex,只是没有维护线程的map。

  • 可重入读写锁:类似JDK的ReentrantReadWriteLock一个读写锁管理一对相关的锁。 主要由两个类实现:

    • InterProcessReadWriteLock
    • InterProcessLock

使用时首先创建一个InterProcessReadWriteLock实例,然后再根据你的需求得到读锁或者写锁, 读写锁的类型是InterProcessLock

4、zk实现分布式锁的优缺点

优点:

  • 锁安全性高,zk可持久化,且能实时监听获取锁的客户端状态。一旦客户端宕机,则瞬时节点随之消失,zk因而能第一时间释放锁。这也省去了用分布式缓存实现锁的过程中需要加入超时时间判断的这一逻辑。
  • zookeeper支持watcher机制,这样实现阻塞锁,可以watch锁数据,等到数据被删除,zookeeper会通知客户端去重新竞争锁。
  • zookeeper的数据可以支持临时节点的概念,即客户端写入的数据是临时数据,在客户端宕机后,临时数据会被删除,这样就实现了锁的异常释放。使用这样的方式,就不需要给锁增加超时自动释放的特性了。

缺点:

  • 性能开销比较高。因为其需要动态产生、销毁瞬时节点来实现锁功能。所以不太适合直接提供给高并发的场景使用。

5、zk集群分布式锁也有不安全的地方(瞬时节点的自动删除机制)

ZooKeeper是怎么检测出某个客户端已经崩溃了呢?实际上,每个客户端都与ZooKeeper的某台服务器维护着一个Session,这个Session依赖定期的心跳(heartbeat)来维持。如果ZooKeeper长时间收不到客户端的心跳(这个时间称为Sesion的过期时间),那么它就认为Session过期了,通过这个Session所创建的所有的ephemeral类型的znode节点都会被自动删除。

设想如下的执行序列:

客户端1创建了znode节点/lock,获得了锁。
客户端1进入了长时间的GC pause。
客户端1连接到ZooKeeper的Session过期了。znode节点/lock被自动删除。
客户端2创建了znode节点/lock,从而获得了锁。
客户端1从GC pause中恢复过来,它仍然认为自己持有锁。
最后,客户端1和客户端2都认为自己持有了锁,冲突了。这与之前Martin在文章中描述的由于GC pause导致的分布式锁失效的情况类似。

*6、Google内部使用的分布式锁服务-Chubby

三种方案的比较

从理解的难易程度角度(从低到高)

数据库 > 缓存 > Zookeeper

从实现的复杂性角度(从低到高)

Zookeeper >= 缓存 > 数据库

从性能角度(从高到低)

缓存 > Zookeeper >= 数据库

从可靠性角度(从高到低)

Zookeeper > 缓存 > 数据库

总结

可重入锁不可重入锁持有锁断开连接后释放锁持有锁后阻塞后释放锁不会误删除别人的锁不会有羊群效应不受机器时间不同步的影响可响应中断优点缺点
Redis支持支持支持,过期时间支持,过期时间支持支持不支持,需要机器保证时间同步支持并发效率高功能实现较难,高可靠难以保证
ZK支持支持支持,临时节点当连接中断会删除支持,过期时间支持支持支持支持安全性高,有封装好的客户端,实现容易效率较低,生产销毁节点开销大