最全分布式锁解决方案详解

一. 概述

1.1 锁的概念

  1. 在单进程的系统中,当存在多个线程可以同时改变某个变量(可变共享变量)时,就需要对变量或代码块做同步,使其在修改这种变量时能够线性执行消除并发修改变量。Java的单机并发同步手段是synchronized和java.util.concurrent包。

  2. 而同步的本质是通过锁来实现的。为了实现多个线程在一个时刻同一个代码块只能有一个线程可执行,那么需要在某个地方做个标记,这个标记必须每个线程都能看到,当标记不存在时可以设置该标记,其余后续线程发现已经有标记了则等待拥有标记的线程结束同步代码块取消标记后再去尝试设置标记。这个标记可以理解为锁。

  3. 不同地方实现锁的方式也不一样,只要能满足所有线程都能看得到标记即可。如 Java 中 synchronize 是在对象头设置标记,Lock 接口的实现类基本上都只是某一个 volitile 修饰的 int 型变量其保证每个线程都能拥有对该 int 的可见性和原子修改,linux 内核中也是利用互斥量或信号量等内存数据做标记。

  4. 除了利用内存数据做锁其实任何互斥的都能做锁(只考虑互斥情况),如流水表中流水号与时间结合做幂等校验可以看作是一个不会释放的锁,或者使用某个文件是否存在作为锁等。只需要满足在对标记进行修改能保证原子性和内存可见性即可。

   原理:多个访问方对同一个资源进行操作,需要进行互斥,通常是利用一个这些访问方同时能够访问到的lock来实施互斥的。

场景一

在同一个进程内,多个线程的互斥,我们可以通过加锁来进行串行化访问。

步骤:

(1)多个线程同时抢锁

(2)只一个线程抢到,未抢到的阻塞,或下次再来抢

(3)抢到锁的线程操作临界资源

(4)操作完临界资源后释放锁

画外音:锁是进程内的一个数据结构,将临界资源的冲突转变为对锁结构的冲突。

 

场景二

在分布式环境下,进程内的锁结构就无法作用于进程外了,所以多进程情况下怎么进行临界资源的保护呢?

 

结合进程内锁的机制,我们可以得出几点条件:

(1)需要有一个特殊的数据结构,每个进程都能访问

(2)同时只能一个进程访问成功

(3)访问成功的进程可以访问临界资源

画外音:问题的关键在于找到同时只有一个进程访问成功的外部存储结构。

1.2 分布式场景

分布式的 CAP 理论告诉我们:

任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。

目前很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。基于 CAP理论,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致性。

分布式场景


此处主要指集群模式下,多个相同服务同时开启.

在许多的场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务分布式锁等。很多时候我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,通过 Java 提供的并发 API 我们可以解决,但是在分布式环境下,就没有那么简单啦。

  1. 分布式与单机情况下最大的不同在于其不是多线程而是多进程

  2. 多线程由于可以共享堆内存,因此可以简单的采取内存作为标记存储位置。而进程之间甚至可能都不在同一台物理机上,因此需要将标记存储在一个所有进程都能看到的地方。

1.3分布式锁的概念

  1. 当在分布式模型下,数据只有一份(或有限制),此时需要利用锁的技术控制某一时刻修改数据的进程数。

  2. 与单机模式下的锁不仅需要保证进程可见,还需要考虑进程与锁之间的网络问题。(我觉得分布式情况下之所以问题变得复杂,主要就是需要考虑到网络的延时和不可靠。。。一个大坑)

  3. 分布式锁还是可以将标记存在内存,只是该内存不是某个进程分配的内存而是公共内存如 Redis、Memcache。至于利用数据库、文件等做锁与单机的实现是一样的,只要保证标记能互斥就行。

1.4 设计分布式锁的目标

  1. 互斥性:任意时刻只能有一个客户端拥有锁,不能被多个客户端获取,即可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器-上的一个线程执行。

  2. 这把锁要是一把可重入锁(避免死锁),说白了,获取锁的客户端因为某些原因而宕机,而未能释放锁,其它客户端也就无法获取该锁,需要有机制来避免该类问题的发生

  3. 这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)

  4. 这把锁最好是一把公平锁(根据业务需求考虑要不要这条)

  5. 有高可用的获取锁和释放锁功能,当部分节点宕机,客户端仍能获取锁或者释放锁

  6. 获取锁和释放锁的性能要好

1.5 方案汇总

分布式的CAP理论告诉我们任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。一般情况下,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致性,只要这个最终时间是在用户可以接受的范围内即可。在很多时候,为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。这里我们主要介绍对象分布式锁,分布式锁的的具体实现方案主要如下三种:

  1. 基于数据库的实现

  2. 基于缓存(redis)的实现

  3. 基于zookeeper的实现

二. 基于数据库的实现

2.1 基于数据库实现的乐观锁

2.1.1 基于数据版本号来实现

比如,有个商品表t_goods,有一个字段left_count用来记录商品的库存个数。在并发的情况下,为了保证不出现超卖现象,即left_count不为负数。乐观锁的实现方式为给商品表增加一个版本号字段version,默认为0,每修改一次数据,将版本号加1。

无版本号并发超卖示例:

-- 线程1查询,当前left_count为1,则有记录
select * from t_goods where id = 10001 and left_count > 0
-- 线程2查询,当前left_count为1,也有记录
select * from t_goods  where id = 10001 and left_count > 0
-- 线程1下单成功库存减一,修改left_count为0,
update t_goods set left_count = left_count - 1 where id = 10001
-- 线程2下单成功库存减一,修改left_count为-1,产生脏数据
update t_goods set left_count = left_count - 1 where id = 10001

 

有版本号的乐观锁示例:

-- 线程1查询,当前left_count为1,则有记录,当前版本号为999
select left_count, version from t_goods where id = 10001 and left_count > 0;
-- 线程2查询,当前left_count为1,也有记录,当前版本号为999
select left_count, version from t_goods where id = 10001 and left_count > 0;
-- 线程1,更新完成后当前的version为1000,update状态为1,更新成功
update t_goods set version = 1000, left_count = left_count-1 where id = 10001 and version = 999;
-- 线程2,更新由于当前的version为1000,udpate状态为0,更新失败,再针对相关业务做异常处理
update t_goods set version = 1000, left_count = left_count-1 where id = 10001 and version = 999;

 

可以发现,这种和CAS的乐观锁机制是类似的,所不同的是CAS的硬件来保证原子性,而这里是通过数据库来保证单条SQL语句的原子性。顺带一提CAS的ABA问题一般也是通过版本号来解决。

2.1.2 基于表主键唯一做分布式锁

思路:利用主键唯一的特性,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,当方法执行完毕之后,想要释放锁的话,删除这条数据库记录即可。

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

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

  2. 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。

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

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

  5. 这把锁是非公平锁,所有等待锁的线程凭运气去争夺锁。

  6. 在 MySQL 数据库中采用主键冲突防重,在大并发情况下有可能会造成锁表现象。

当然,我们也可以有其他方式解决上面的问题。
  1. 数据库是单点?搞两个数据库,数据之前双向同步,一旦挂掉快速切换到备库上。

  2. 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。

  3. 非阻塞的?搞一个 while 循环,直到 insert 成功再返回成功。

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

  5. 非公平的?再建一张中间表,将等待锁的线程全记录下来,并根据创建时间排序,只有最先创建的允许获取锁。

  6. 比较好的办法是在程序中生产主键进行防重。

2.2 基于数据库实现的排他锁

基于数据库的排他锁需要通过数据库的唯一性约束UNIQUE KEY来保证数据的唯一性,从而为锁的独占性提供基础。

表结构如下:

CREATE TABLE `distribute_lock` (
   `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
   `unique_mutex` varchar(64) NOT NULL COMMENT '需要锁住的资源或者方法',
   -- `state` tinyint NOT NULL DEFAULT 1 COMMENT '1:未分配;2:已分配
   PRIMARY KEY (`id`),
   UNIQUE KEY `unique_mutex`
);

 

其中,unique_mutex就是我们需要加锁的对象,需要用UNIQUE KEY来保证此对象唯一。

加锁时增加一条记录:

insert into distribute_lock(unique_mutex) values('mutex_demo'); 

如果当前SQL执行成功代表加锁成功,如果抛出唯一索引异常(DuplicatedKeyException)则代表加锁失败,当前锁已经被其他竞争者获取。

解锁锁时删除该记录:

delete from distribute_lock(unique_mutex) values('muetx_demo');

除了增删记录,也可以通过更新state字段来标识是否获取到锁。

-- 获取锁
update distribute_lock set state = 2 where `unique_mutex` = 'muetx_demo' and state=1;

更新之前需要SELECT确认锁在数据库中存在,没有则创建之。如果创建或更新失败,则说明这个资源已经被别的线程占用了。

2.2.1  基于数据库排他锁做分布式锁

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁 (注意:InnoDB 引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给要执行的方法字段名添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。)。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。

我们可以认为获得排他锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,通过connection.commit()操作来释放锁。

这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。

  1. 阻塞锁? for update语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。

  2. 锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。

但是还是无法直接解决数据库单点和可重入问题。

这里还可能存在另外一个问题,虽然我们对方法字段名使用了唯一索引,并且显示使用 for update 来使用行级锁。但是,MySQL 会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。如果发生这种情况就悲剧了。。。

还有一个问题,就是我们要使用排他锁来进行分布式锁的 lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆。

2.3 小结

数据库排他锁可能出现的问题及解决思路:

  1.没有失效时间, 一旦解锁失败,会导致锁记录一直在数据库中,其他线程无法再获得锁。

    可通过定时任务清除超时数据来解决

  2.是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。

    可通过增加字段记录当前主机信息和当线程信息,

  3.这个锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的在线程并不会进入阻塞队列,需要不停自旋直到获得锁,相对耗资源。

总的来说,基于数据库的分布式锁,能够满足一些简单的需求,好处是能够少引入依赖,实现较为简单,缺点是性能较低,且难以满足复杂场景下的高并发需求。会有各种各样的问题(操作数据库需要一定的开销,使用数据库的行级锁并不一定靠谱,性能不靠谱)

三. 基于redis的实现

3.1 基本实现思路

 一个简单的分布式锁机制是使用setnx、expire 、del 三个命令的组合来实现的。setnx命令的含义为:当且仅当key不存在时,value设置成功,返回1;否则返回0。另外两个命令,见名知意,就不多做解释了。

# 加锁,设置锁的唯一标识key,返回1说明加锁成功,返回0加锁失败
setnx key value
# 设置锁超时时间为30s,防止死锁
expire key 30
# 解锁, 删除锁
del key

使用步骤


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

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

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

这个方案其实是可以解决日常工作中的需求的,但从技术方案的探讨上来说,可能还有一些可以完善的地方。

这种思路存在的问题:

  1. setnx和expire的非原子性:如果加锁之后,服务器宕机,导致expire和del均执行不了,会导致死锁。比如,如果在第一步 setnx 执行成功后,在 expire() 命令执行成功前,发生了宕机的现象,那么就依然会出现死锁的问题.

  2. del导致误删:A线程超时之后未执行完, 锁过期释放;B线程获得锁,此时A线程执行完,执行del将B线程的锁删除。

  3. 锁过期后引起的并发:A线程超时之后未执行完, 锁过期释放;B线程获得锁,此时A、B线程并发执行会导致线程安全问题。

对应的解决思路:

  1.将加锁和设置锁过期时间做成一个原子性操作

在Redis 2.6.12版本之后,set命令增加了NX可选参数,可替代setnx命令;增加了EX可选参数,可以设置key的同时指定过期时间

    或者将两个操作封装在lua脚本中,发送给Redis执行,从而实现操作的原子性。

  2.将key的value设置为线程相关信息,del释放锁之前先判断一下锁是不是自己的。(释放和判断不是原子性的,需要封装在lua脚本中)

  3.启动一个守护线程,在后台自动给自己的锁''续期“,执行完成,显式关掉守护进程

基于 REDIS 的 SETNX()、GET()、GETSET()方法做分布式锁


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

getset()


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

  1. getset(key, “value1”) 返回 null 此时 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.2 redis分布式锁的缺点

在大型的应用中,一般redis服务都是集群形式部署的,由于Slave同步Master是异步的,所以会出现客户端A在Master上加锁,此时Master宕机,Slave没有完成锁的同步,Slave变为Master,客户端B此时可以完成加锁操作。

为了解决这一问题,官方给出了redlock算法,即使这样在一些较复杂的场景下也不能100%保证没有问题。

Redlock 是 Redis 的作者 antirez 给出的集群模式的 Redis 分布式锁,它基于 N 个完全独立的 Redis 节点(通常情况下 N 可以设置成 5)。

算法的步骤如下:

1、客户端获取当前时间,以毫秒为单位。

2、客户端尝试获取 N 个节点的锁,(每个节点获取锁的方式和前面说的缓存锁一样),N 个节点以相同的 key 和 value 获取锁。客户端需要设置接口访问超时,接口超时时间需要远远小于锁超时时间,比如锁自动释放的时间是 10s,那么接口超时大概设置 5-50ms。这样可以在有 redis 节点宕机后,访问该节点时能尽快超时,而减小锁的正常使用。

3、客户端计算在获得锁的时候花费了多少时间,方法是用当前时间减去在步骤一获取的时间,只有客户端获得了超过 3 个节点的锁,而且获取锁的时间小于锁的超时时间,客户端才获得了分布式锁。

4、客户端获取的锁的时间为设置的锁超时时间减去步骤三计算出的获取锁花费时间。

5、如果客户端获取锁失败了,客户端会依次删除所有的锁。

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

但是,有一位分布式的专家写了一篇文章《How to do distributed locking》,质疑 Redlock 的正确性。

https://mp.weixin.qq.com/s/1bPLk_VZhZ0QYNZS8LkviA

https://blog.csdn.net/jek123456/article/details/72954106

缺点:

失效时间设置多长时间为好?如何设置的失效时间太短,方法没等执行完,锁就自动释放了,那么就会产生并发问题。如果设置的时间太长,其他获取锁的线程就可能要平白的多等一段时间。

四. 基于zookeeper的实现

4.1 基本实现思路

zookeeper 是一个开源的分布式协调服务框架,主要用来解决分布式集群中的一致性问题和数据管理问题。zookeeper本质上是一个分布式文件系统,由一群树状节点组成,每个节点可以存放少量数据,且具有唯一性。

zookeeper有四种类型的节点:

   1.持久节点(PERSISTENT)

     默认节点类型,断开连接仍然存在

   2.持久顺序节点(PERSISTENT_SEQUENTIAL)

     在持久节点的基础上,增加了顺序性。指定创建同名节点,会根据创建顺序在指定的节点名称后面带上顺序编号,以保证节点具有唯一性和顺序性

   3.临时节点(EPHEMERAL)

     断开连接后,节点会被删除

   4.临时顺序节点(EPHEMERAL_SEQUENTIAL)

     在临时节点的基础上,增加了顺序性。

 基于zookeeper实现的分布式锁主要利用了zookeeper临时顺序节点的特性和事件监听机制。主要思路如下:

  1. 创建节点实现加锁,通过节点的唯一性,来实现锁的互斥。
    如果使用临时节点,节点创建成功表示获取到锁如果使用临时顺序节点,客户端创建的节点为顺序最小节点,表示获取到锁

  2. 删除节点实现解锁

  3. 通过临时节点的断开连接自动删除的特性来避免持有锁的服务器宕机而导致的死锁

  4. 通过节点的顺序性和事件监听机制,大节点监听小节点,形成节点监听链,来实现等待队列(公平锁)

其他思路:

  1. 不使用监听机制,未获取到锁的线程自旋重试或者失败退出(根据业务决定),可实现非阻塞的乐观锁。

  2. 不使用临时顺序节点,而使用临时节点,所有客户端都去监听该临时节点,可实现非公平锁。但是会产生"羊群效应",单个事件,引发多个服务器响应,占用服务器资源和网络带宽,需要根据业务场景选用。

基于 ZooKeeper 做分布式锁


ZOOKEEPER 锁相关基础知识

  1. zk 一般由多个节点构成(单数),采用 zab 一致性协议。因此可以将 zk 看成一个单点结构,对其修改数据其内部自动将所有节点数据进行修改而后才提供查询服务。

  2. zk 的数据以目录树的形式,每个目录称为 znode, znode 中可存储数据(一般不超过 1M),还可以在其中增加子节点。

  3. 子节点有三种类型。序列化节点,每在该节点下增加一个节点自动给该节点的名称上自增。临时节点,一旦创建这个 znode 的客户端与服务器失去联系,这个 znode 也将自动删除。最后就是普通节点。

  4. Watch 机制,client 可以监控每个节点的变化,当产生变化会给 client 产生一个事件。

ZK 基本锁

  1. 原理:利用临时节点与 watch 机制。每个锁占用一个普通节点 /lock,当需要获取锁时在 /lock 目录下创建一个临时节点,创建成功则表示获取锁成功,失败则 watch/lock 节点,有删除操作后再去争锁。临时节点好处在于当进程挂掉后能自动上锁的节点自动删除即取消锁。

  2. 缺点:所有取锁失败的进程都监听父节点,很容易发生羊群效应,即当释放锁后所有等待进程一起来创建节点,并发量很大。

ZK 锁优化

  • 原理:上锁改为创建临时有序节点,每个上锁的节点均能创建节点成功,只是其序号不同。只有序号最小的可以拥有锁,如果这个节点序号不是最小的则 watch 序号比本身小的前一个节点 (公平锁)。

步骤:

1.在 /lock 节点下创建一个有序临时节点 (EPHEMERAL_SEQUENTIAL)。

2.判断创建的节点序号是否最小,如果是最小则获取锁成功。不是则取锁失败,然后 watch 序号比本身小的前一个节点。

3.当取锁失败,设置 watch 后则等待 watch 事件到来后,再次判断是否序号最小。

4.取锁成功则执行代码,最后释放锁(删除该节点)。

4.2 zookeeper分布式锁的缺点

zookeeper分布式锁有着较好的可靠性,但是也有如下缺点:

  1.zookeeper分布式锁是性能可能没有redis分布式锁高,因为每次在创建锁和释放锁的过程中,都要动态创建、销毁临时节点来实现锁功能。

  2.使用zookeeper也有可能带来并发问题,只是并不常见而已。比如,由于网络抖动,客户端与zk集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。就可能产生并发问题。这个问题不常见是因为zk有重试机制,一旦zk集群检测不到客户端的心跳,就会重试,curator客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。

优点:

有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题。实现起来较为简单。

缺点:

性能上可能并没有缓存服务那么高,因为每次在创建锁和释放锁的过程中,都要动态创建、销毁临时节点来实现锁功能。ZK 中创建和删除节点只能通过 Leader 服务器来执行,然后将数据同步到所有的 Follower 机器上。还需要对 ZK的原理有所了解。

 

五 基于 Consul 做分布式锁


DD 写过类似文章,其实主要利用 Consul 的 Key / Value 存储 API 中的 acquire 和 release 操作来实现。

文章地址:http://blog.didispace.com/spring-cloud-consul-lock-and-semphore/

六 总结

上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。

  1.从实现的复杂性角度(从高到低)zookeeper >= redis> 数据库

    数据库实现的分布式锁易于理解和实现,且不会给项目引入其他依赖。zookeeper和redis需要考虑的情况更多,实现相对较为复杂,但是都有现成的分布式锁框架curator和redision,用起来代码反而可能会更简洁。

  2.从性能角度(从高到低)redis>zookeeper > 数据库

    redis数据存在内存,速度很快;zookeeper虽然数据也存在内存中,但是本身维护节点的一致性。需要耗费一些性能;数据库则只有索引在内存中,数据存于磁盘,性能较差。

  3.从可靠性角度(从高到低)zookeeper > redis > 数据库

    zookeeper天生设计定位就是分布式协调,强一致性,可靠性较高;redis分布式锁需要较多额外手段去保证可靠性;数据库则较难满足复杂场景的需求。

使用分布式锁的注意事项

1、注意分布式锁的开销

2、注意加锁的粒度

3、加锁的方式

 

分布式锁需要具备哪些条件

  1. 获取锁和释放锁的性能要好

  2. 判断是否获得锁必须是原子性的,否则可能导致多个请求都获取到锁

  3. 网络中断或宕机无法释放锁时,锁必须被清除,不然会发生死锁

  4. 可重入一个线程中可以多次获取同一把锁,比如一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁;

  5. 阻塞锁和非阻塞锁,阻塞锁即没有获取到锁,则继续等待获取锁;非阻塞锁即没有获取到锁后,不继续等待,直接返回锁失败。

分布式锁实现方式

一、数据库锁

一般很少使用数据库锁,性能不好并且容易产生死锁。

  1. 基于MySQL锁表

该实现方式完全依靠数据库唯一索引来实现,当想要获得锁时,即向数据库中插入一条记录,释放锁时就删除这条记录。这种方式存在以下几个问题:

(1) 锁没有失效时间,解锁失败会导致死锁,其他线程无法再获取到锁,因为唯一索引insert都会返回失败。

(2) 只能是非阻塞锁,insert失败直接就报错了,无法进入队列进行重试

(3) 不可重入,同一线程在没有释放锁之前无法再获取到锁

  1. 采用乐观锁增加版本号

根据版本号判断更新之前有没有其他线程更新过,如果被更新过,则获取锁失败。

二、缓存锁

具体实例可以参考我讲述Redis的系列文章,里面有完整的Redis分布式锁实现方案

这里我们主要介绍几种基于redis实现的分布式锁:基于setnx、expire两个命令来实现

基于setnx(set if not exist)的特点,当缓存里key不存在时,才会去set,否则直接返回false。如果返回true则获取到锁,否则获取锁失败,为了防止死锁,我们再用expire命令对这个key设置一个超时时间来避免。但是这里看似完美,实则有缺陷,当我们setnx成功后,线程发生异常中断,expire还没来的及设置,那么就会产生死锁。

解决上述问题有两种方案

第一种是采用redis2.6.12版本以后的set,它提供了一系列选项

EX seconds – 设置键key的过期时间,单位时秒

PX milliseconds – 设置键key的过期时间,单位时毫秒

NX – 只有键key不存在的时候才会设置key的值

XX – 只有键key存在的时候才会设置key的值

第二种采用setnx(localkey,value),get(localkey),getset(localkey,value)实现,大体的实现过程如下:

(1) 线程Asetnx,值为超时的时间戳(t1),如果返回true,获得锁。

(2) 线程B用get 命令获取t1,与当前时间戳比较,判断是否超时,没超时false,如果已超时执行步骤3

(3) 计算新的超时时间t2,使用getset命令返回t3(这个值可能其他线程已经修改过),如果t1==t3,获得锁,如果t1!=t3说明锁被其他线程获取了

(4) 获取锁后,处理完业务逻辑,再去判断锁是否超时,如果没超时删除锁,如果已超时,不用处理(防止删除其他线程的锁)

一.redis命令讲解: setnx()命令: setnx的含义就是SET if Not Exists,其主要有两个参数 setnx(key, value)。

该方法是原子的,如果key不存在,则设置当前key成功,返回1;如果当前key已经存在,则设置当前key失败,返回0。

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

getset(key, "value1") 返回nil 此时key的值会被设置为value1

1.getset(key, "value2") 返回value1 此时key的值会被设置为value2

2.依次类推!

二.具体的使用步骤如下:

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

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

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

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

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

  1. RedLock算法

redlock算法是redis作者推荐的一种分布式锁实现方式,算法的内容如下:

(1) 获取当前时间;

(2) 使用setNx()尝试从5个相互独立redis客户端获取锁;

(3) 计算获取所有锁消耗的时间,当且仅当客户端从多数节点获取锁,并且获取锁的时间小于锁的有效时间,认为获得锁;

(4) 重新计算有效期时间,原有效时间减去获取锁消耗的时间;

(5) 删除所有实例的锁

redlock算法相对于单节点redis锁可靠性要更高,但是实现起来条件也较为苛刻。

(1) 必须部署5个节点才能让Redlock的可靠性更强

(2) 需要请求5个节点才能获取到锁,通过Future的方式,先并发向5个节点请求,再一起获得响应结果,能缩短响应时间,不过还是比单节点redis锁要耗费更多时间

然后由于必须获取到5个节点中的3个以上,所以可能出现获取锁冲突,即大家都获得了1-2把锁,结果谁也不能获取到锁,这个问题,redis作者借鉴了raft算法的精髓,通过冲突后从随机时间开始,可以大大降低冲突时间,但是这问题并不能很好的避免,特别是在第一次获取锁的时候,所以获取锁的时间成本增加了。

如果5个节点有2个宕机,此时锁的可用性会极大降低,首先必须等待这两个宕机节点的结果超时才能返回,另外只有3个节点,客户端必须获取到这全部3个节点的锁才能拥有锁,难度也加大了。

如果出现网络分区,那么可能出现客户端永远也无法获取锁的情况,介于这种情况,下面我们来看一种更可靠的分布式锁zookeeper锁。

三、zookeeper分布式锁

关于zookeeper的分布式锁实现在之前讲述zookeeper的时候已经介绍了。这里不再赘述、

首先我们来了解一下zookeeper的特性,看看它为什么适合做分布式锁,

zookeeper是一个为分布式应用提供一致性服务的软件它内部是一个分层的文件系统目录树结构,规定统一个目录下只能有一个唯一文件名。

数据模型:

永久节点:节点创建后,不会因为会话失效而消失

临时节点:与永久节点相反,如果客户端连接失效,则立即删除节点

顺序节点:(临时或者永久的顺序节点)与上述两个节点特性类似,如果指定创建这类节点时,zk会自动在节点名后加一个数字后缀,并且是有序的。

监视器(watcher):

当创建一个节点时,可以注册一个该节点的监视器,当节点状态发生改变时,watch被触发时,ZooKeeper将会向客户端发送且仅发送一条通知,因为watch只能被触发一次。

根据zookeeper的这些特性,我们来看看如何利用这些特性来实现分布式锁:

  1. 创建一个锁目录lock

  2. 希望获得锁的线程A就在lock目录下,创建临时顺序节点

  3. 获取锁目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁

  4. 线程B获取所有节点,判断自己不是最小节点,设置监听(watcher)比自己次小的节点(只关注比自己次小的节点是为了防止发生“羊群效应”)

  5. 线程A处理完,删除自己的节点,线程B监听到变更事件,判断自己是最小的节点,获得锁。

小结

在分布式系统中,共享资源互斥访问问题非常普遍,而针对访问共享资源的互斥问题,常用的解决方案就是使用分布式锁,这里只介绍了几种常用的分布式锁,分布式锁的实现方式还有有很多种,根据业务选择合适的分布式锁,下面对上述几种锁进行一下比较:

数据库锁:

优点:直接使用数据库,使用简单

缺点:分布式系统大多数瓶颈都在数据库,使用数据库锁会增加数据库负担。

缓存锁:

优点:性能高,实现起来较为方便,在允许偶发的锁失效情况,不影响系统正常使用,建议采用缓存锁。

缺点:通过锁超时机制不是十分可靠,当线程获得锁后,处理时间过长导致锁超时,就失效了锁的作用。

zookeeper锁:

优点:不依靠超时时间释放锁;可靠性高;系统要求高可靠性时,建议采用zookeeper锁。

缺点:性能比不上缓存锁,因为要频繁的创建节点删除节点。

 

posted @ 2021-11-06 20:59  hanease  阅读(1173)  评论(0编辑  收藏  举报