type
status
date
slug
summary
tags
category
icon
password
1、概述
1.1 什么是分布式锁
在多线程环境下,为了保证数据的线程安全,锁保证同一时刻,只有一个可以访问和更新共享数据。在单机系统上我们可以使用 Java 提供的
synchronized
或者 ReentrantLock
,在单个 JVM
进程中提供线程之间的锁定机制,控制多线程并发。例如系统 A 是一个电商系统,目前是一台机器部署,系统中有一个用户下订单的接口,但是用户下订单之前一定要去检查一下库存,确保库存足够了才会给用户下单。我们可以对下面的 1 到 4 步进行加锁,在第 4 步执行完后才释放锁。如果业务量增大,一台机器顶不住了,增加到两台机器,这样Java提供的原生锁机制在多机部署场景下失效了,因为两台机器加的锁不是同一个锁(两个锁在不同的JVM里面)。这种情况就需要分布式锁了。分布式锁就是控制分布式系统不同进程访问共享资源的一种锁的机制。不同客户端之间获取锁需要保持互斥性,任意时刻,只有一个客户端能持有锁。
1.2 分布式锁的实现
在选择分布式锁的实现方式时,需要考虑以下几个因素:
- 可用性:高可用的获取锁与释放锁是关键要求之一。如果锁的实现方式不可用或不稳定的,将导致整个系统的可用性受到影响。
- 性能:高性能的获取锁与释放锁同样重要。在分布式系统中,由于涉及多个进程或节点的协调,如果锁的实现方式性能较低,将导致系统整体的性能瓶颈。
- 可重入性:具备可重入特性是分布式锁的重要特性之一。可重入意味着同一线程可以多次获取同一把锁而不会导致死锁。
- 锁失效机制:具备锁失效机制可以防止死锁的发生。当某个进程或节点持有锁的时间过长而不释放时,其他进程或节点可以通过锁失效机制来避免陷入等待状态。
- 非阻塞锁特性:具备非阻塞锁特性意味着在没有获取到锁的情况下,线程可以直接返回而不是等待。这样可以提高系统的并发性能和响应速度。
分布式锁常用的有基于数据库、zookeeper、原生redis、redisson 和 redlock 这几种实现方式。
实现方式 | 原理 | 优点 | 缺点 |
数据库 | 基于唯一索引(insert)和基于排他锁(for update)两种实现方式。最简单,但一般不会用,简单了解即可。 | • 实现简单 | • 性能差
• 没有失效时间
• 排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆。 |
zookeeper | 基于临时有序节点,原理是每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式:只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。 | • 临时节点在客户端断开后自动删除,避免服务宕机导致的锁无法释放,而产生的死锁问题。 | • 性能不如redis锁
• 自身实现复杂,一般需要借助第三方客户端例如curator
• 并发安全问题:如果因为网络抖动客户端和ZK集群的session连接断了,zk集群以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。 |
原生redis | 基于SETNX/EXPIRE/DEL命令,可以通过lua脚本保证SETNX+EXPIRE命令执行的原子性,也可以使用扩展的SET命令来代替。 | • 性能高 | • 锁超时问题:线程A持有锁,但是执行时间超过了过期时间,结果线程A还没执行完就释放了锁,线程B获取到锁,这时候线程A和B同时获取到锁,造成锁的互斥性问题。
• 锁误删问题:过了一段时间线程A执行完毕后del key删除锁,而且线程B此时还在执行,造成线程B没有执行完毕锁就被提前释放的问题。
• 锁丢失问题:加锁的key只存在一个节点上,如果该节点宕机,会发生主从切换导致锁丢失的问题。 |
redisson | redisson是单节点redis分布式锁的最优方案,底层是基于一段lua脚本实现的,并使用看门狗机制解决了锁超时和锁误删问题。 | • 使用简单
• 看门狗机制解决了锁超时和锁误删问题 | • 锁丢失问题 |
redlock | redlock是多节点redis分布式锁的解决方案,原理是是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。 | • 即使有节点发生了故障,锁变量仍然是存在的,解决了多节点下主从切换导致的锁丢失问题 | • 实现复杂
• 时钟漂移问题,严重依赖于系统时间
• 安全性问题,如果锁设置的实效时间太短,业务还没执行完就会发生锁的安全性问题 |
分布式锁的选择:
特性 | 实现复杂度角度 | 性能角度 | 可靠性角度 |
数据库 | 高 | 低 | 低 |
Redis | 中 | 高 | 中 |
Zookeeper | 低 | 中 | 高 |
- 如果对性能要求不高,并且不希望因为要使用分布式锁而引入新组件,可以使用基于Mysql的分布式锁,性能上肯定是不如Redis、Zookeeper。
- 如果对数据安全性要求较高,对性能要求中等,并且并发度不高(因为 Zookeeper 天生设计定位就是分布式协调,强一致性),可以使用基于Zookeeper的分布式锁。
- 如果对性能要求较高,可以接受一定的容错性(因为Redis 的设计定位决定了它的数据并不是强一致性的),可以选择基于Redis的分布式锁。如果是Redis单点系统优先考虑Redisson方案,如果是Redis多实例集群可以考虑redlock方案,不推荐使用原生Redis方案。
2、基于数据库的实现
数据库方案看起来不够高级,而且性能较低,但还是要根据具体场景具体分析。如果对分布式锁的性能要求不高,且数据库基本上是每个系统都需要引入的,直接基于数据库实现分布式锁可以避免引入其他系统,增加系统复杂性,越简单的技术架构越不容易出问题。大家要根据具体业务场景选择合适的技术方案,而不是随便找一个足够复杂、足够新潮的技术方案来解决业务问题。
基于数据库实现分布式锁有三种实现方案:
- 基于唯一索引
- 基于乐观锁
- 基于排他锁
2.1 基于唯一索引
实现过程:
- 创建一张分布式锁的数据表,包括 id、资源名(唯一索引)、主机线程名(用于重入)、重入计数。
- 获取锁:插入一条记录,因为resource字段做了唯一性约束,这样如果有多个请求同时提交到数据库的话,数据库可以保证只有一个操作可以成功(其它的会报错:ERROR 1062 (23000): Duplicate entry ‘1’ for key ‘uiq_idx_resource’)。获取锁失败的线程可以周期性尝试获取锁直到结束或者可以定义方法来限定时间内获取锁。
- 执行业务逻辑。
- 释放锁:直接删除这条数据。
这种实现方式非常简单,但是需要注意:
- 这种锁没有失效时间,一旦释放锁的操作失败就会导致锁记录一直在数据库中,其它线程无法获得锁。这个缺陷也很好解决,比如可以做一个定时任务去定时清理。
- 这种锁的可靠性依赖于数据库。建议设置备库,避免单点,进一步提高可靠性。
- 这种锁是非阻塞的,因为插入数据失败之后会直接报错,想要获得锁就需要再次操作。如果需要阻塞式的,可以弄个for循环、while循环之类的,直至INSERT成功再返回。
2.2 基于乐观锁
乐观锁是指系统认为数据的更新在大多数情况下是不会产生冲突的,只在数据库更新操作提交的时候才对数据作冲突检测。如果检测的结果出现了与预期数据不一致的情况,则返回失败信息。
乐观锁大多数是基于数据版本(version)的记录机制实现的。一般是通过为数据库表添加一个
version
字段。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加1。在更新过程中,会对版本号进行比较,如果是一致的,没有发生改变,则会成功执行本次操作;如果版本号不一致,则会更新失败。例如使用乐观锁来实现锁库存,步骤如下:
- 创建一张分布式锁表,
resource
表示具体操作的资源,在这里也就是特指库存,version
表示版本号。
- 初始化库存数据:在使用乐观锁之前要确保表中有相应的数据
- 获取锁:这是一个普通的查询操作
- 执行业务逻辑。
- 释放锁:比较
version
字段的新值和旧值,如果相等就同时更新resource
字段和version
字段,如果不相等,说明这行数据已经被其他人更新过,更新操作执行失败。
乐观所注意事项:
- 乐观锁比较适合并发量不高,并且写操作不频繁的场景。对于大促、秒杀等高并发的场景,
version
值在频繁变化,则会导致大量请求失败,对数据库造成很大的压力。
2.3 基于排他锁
悲观锁是指系统总是假设最坏的情况,它认为数据的更新在大多数情况下是会产生冲突的。
悲观锁的实现方式是在查询语句后面增加
FOR UPDATE
,数据库会在查询过程中给数据库表增加悲观锁,也称排他锁。当某条记录被加上悲观锁之后,其它线程也就无法再改行上增加悲观锁。基于排他锁的实现步骤如下:
- 创建一张分布式锁表。
- 初始化库存数据:和乐观锁相同,在使用悲观锁之前要确保表中有相应的数据
- 获取锁:注意需要先手动开启事务。
- 执行业务逻辑
- 释放锁:提交事务
悲观锁注意事项:
FOR UPDATE
仅适用于 InnoDB,且必须在事务区块 (BEGIN/COMMIT) 中才能生效。
- InnoDB 在加锁的时候,只有明确地指定主键(或索引)的才会执行行锁 (只锁住被选取的数据),否则MySQL 将会执行表锁(将整个数据表单给锁住)。
- 使用
FOR UPDATE
加锁,会导致每次请求都会额外产生加锁的开销且未获取到锁的请求将会阻塞等待锁的获取,在高并发环境下,容易造成大量请求阻塞,影响系统可用性。
3、基于Zookeeper的实现
基于Zookeeper实现的分布式锁原理是基于Zookeeper的临时顺序节点 。ZooKeeper有以下特点:
- ZooKeeper的每一个节点,都是一个天然的顺序发号器: 在每个节点下面创建临时节点,新的子节点后面,会添加一个次序编号,这个生成的编号,会在上一次的编号进行 +1 操作。
- ZooKeeper节点的递增有序性,可以确保锁的公平:我们只需要在一个持久父节点下,创建对应的临时顺序节点,每个线程在尝试占用锁之前,会调用watch,判断自己当前的序号是不是在当前父节点最小,如果是,那么获取锁。
- ZooKeeper的节点监听机制,可以保障占有锁的传递有序而且高效: 每个线程在抢占所之前,会创建属于当前线程的ZNode节点,在释放锁的时候,会删除创建的ZNode,当我们创建的序号不是最小的时候,会等待watch通知,也就是上一个ZNode的状态通知,当前一个ZNode删除的时候,会触发回调机制,告诉下一个ZNode,你可以获取锁开始工作了。ZooKeeper的节点监听机制,能够非常完美地实现这种击鼓传花似的信息传递。
- 临时节点自动删除,避免死锁问题: ZooKeeper还有一个好处,当我们客户端断开连接之后,我们出创建的临时节点会进行自动删除操作,所以我们在使用分布式锁的时候,一般都是会去创建临时节点,这样可以避免因为网络异常等原因,造成的死锁。
- ZooKeeper的节点监听机制,能避免羊群效应: ZooKeeper节点的顺序访问性,后面监听前面的方式,可以有效的避免羊群效应,什么是羊群效应:当某一个节点挂掉了,所有的节点都要去监听,然后做出回应,这样会给服务器带来比较大压力,如果有了临时顺序节点,当一个节点挂掉了,只有它后面的那一个节点才做出反应。
由于实现较为复杂,客户端一般使用封装好的第三方框架例如Curator。过程如下:
- 申请锁:客户端在申请锁的时候,在zk指定目录下创建一个临时有序节点,然后判断自己创建的节点序号是不是最小的
- 获取锁成功:如果是最小的,就获取锁成功,执行业务。
- 尝试获取锁:如果不是最小的,就使用watch来监听前一个节点的删除事件,如果监听节点被删除了就获取锁成功,否则就继续监听。
- 释放锁:这里有两种情况
- 获取锁成功的节点执行业务后,删除临时节点,释放锁。
- 客户端断开连接之后,临时节点会自动删除,自动释放锁。
4、基于Redis的实现
实现原理:基于
SETNX + EXPIRE
命令或者 扩展 SET
命令。SETNX命令,设置key值,返回1,说明 key 不存在,将 key 的值设为 value,表示该进程获得锁;返回0,说明 key 已经存在,则 SETNX 不做任何动作。表示其他进程已经获得了锁,进程不能进入临界区。
EXPIRE命令:设置key 的生存时间,当key过期(生存时间为0),会自动删除。
但由于这是两个命令不是一个原子操作,一般使用Lua脚本(包含 SETNX + EXPIRE 两条指令)来保证原子性。可以使用扩展的 SET 命令来代替。
- EX seconds:设置键的过期时间为second秒
- PX millisecounds:设置键的过期时间为millisecounds 毫秒
- NX:只在键不存在的时候,才对键进行设置操作。也即保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁,才能获取。
- XX:只在键已经存在的时候,才对键进行设置操作
- SET操作成功后,返回的是OK,失败返回NIL
使用过程:
- 使用
SET key value NX EX seconds
设置一个key,返回 key 值表示获取锁成功,返回 null 表示获取锁失败,需要不断重试。
- 处理完业务后,DEL key 表示释放锁。
无论是
SETNX + EXPIRE
命令还是扩展 SET
命令,都会存在下面问题:问题 | 情形 | 解决方案 |
锁超时问题 | 1. A、B两个线程来尝试给key myLock加锁,A线程先拿到锁(假如锁3秒后过期),B线程就在等待尝试获取锁
2. A此时业务逻辑比较耗时,执行时间已经超过redis锁过期时间,这时A线程的锁自动释放(删除key),B线程检测到myLock这个key不存在,执行 SETNX命令也拿到了锁。
3. A 和 B 两个线程同时拿到同一个锁,发生了锁的互斥性问题。 | 需要一个后台线程自动延长A线程的过期时间 |
锁误删问题 | 1. 在发生了锁超时的情况下,A,B两个线程先后拿到同一把锁。
2. 此时A线程执行完业务逻辑之后,还是会去释放锁(删除key),这就导致B线程的锁被A线程给释放了。 | 在加锁设置key的时候把value设置为线程id,在删除key前判断value是否等于当前线程id |
死锁问题 | 1. 线程A设置了key获取到锁,执行完业务后准备释放锁,还没有执行删除key操作的时候,redis服务器宕机了,这个key永远不能被删除,发生死锁问题。 | 设置key的时候默认带一个超时时间,时间到了即使没有执行 DEL 命名删除也会自动删除 |
锁丢失问题 | 1. 线程A想要加锁,它会根据路由规则选择一台master节点写入key,在加锁成功后,master节点会把key异步复制给对应的slave节点。
2. 在master还没有把key复制到slave节点之前,master突然宕机了,然后进行主从切换,slave节点成为新的master。
3. 线程B对同一个key加锁,因为这个新的master还没有把线程A加锁的key同步过来,所以线程B可以加锁成功,这样A,B两个线程就同时拿到同一个锁,发生了锁的互斥性问题。 | 目前只有redlock算法能解决这个问题。 |
5、基于Redisson的实现
Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的 Java 常用对象,还实现了可重入锁(Reentrant Lock)、公平锁(Fair Lock、联锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)等,还提供了许多分布式服务。
通过看门狗机制,Redisson解决了锁超时、锁误删和死锁问题,但是仍然会存在锁丢失问题。
Redisson 的看门狗(watch dog)机制原理:Redisson 客户端加锁的锁 key 默认生存时间是30s,在加锁之后每个锁会启动一个 watch dog 后台线程,会每隔10秒检查一下,如果锁 key 的过期时间到了,但客户端还持有锁 key,延长锁 key 的过期时间。默认情况下,看门狗的续期时间是30s。也可以通过修改
Config.lockWatchdogTimeout
来另行指定。另外Redisson 还提供了可以指定leaseTime参数的加锁方法来指定加锁的时间。超过这个时间后锁便自动解开了,不会延长锁的有效期。使用 Redisson 分布式锁的过程:
- 判断当前锁是否存在,不存在就设置key,key为客户端id,value为锁次数,返回null,说明加锁成功。
- 如果锁存在,判断key是否等于当前客户端id,相等就把value加1(支持重入锁),返回null,说明加锁成功。
- 其他情况,返回一个数值,表示key的剩余生存时间,说明加锁失败,但不会立刻返回失败。此时获取锁失败的线程通过pub/sub机制等待获取锁释放的消息,并不断重试;如果在过了超时时间都没有收到消息,就返回获取锁失败。
Redisson 的底层是使用 Lua 脚本的 Redis 命令,保证获取和释放锁是原子操作。使用的核心数据结构是 hash,在申请锁的时候,Redis 会执行如下 Lua 代码
释放该锁的时候会执行如下 Lua 脚本:
6、基于Redlock的实现
为了解决多节点下的锁丢失问题,Redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock。假如 Redis 分布式集群有 5 个 Redis master 节点,为了取到锁,客户端应该执行以下操作:
- 获取当前Unix时间戳,以毫秒为单位。
- 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
- 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
- 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
- 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。
RedLock算法仍然存在问题:
- 时钟漂移问题:redis的过期时间是依赖系统时钟的,要求多个节点机器时钟都是一致或者大致一致,如果某个节点时钟漂移过大时会影响到过期时间的计算。
- 安全性问题:如果锁设置失效时间太短,方法没等执行完,锁就自动释放了,那么就会产生锁的安全性问题。
- 客户端 1 请求锁定节点 A、B、C、D、E
- 客户端 1 的拿到锁后,进入GC(时间比较久)
- 所有 Redis 节点上的锁都过期了
- 客户端 2 获取到了 A、B、C、D、E 上的锁
- 客户端 1 GC 结束,认为成功获取锁
- 客户端 2 也认为获取到了锁,发生「冲突」
Redlock 算法较为复杂,算法仍存在争议和问题,而且redis 集群本来就属于AP系统,对AP系统的安全性和一致性本来就不能要求太高。如果追求绝对的安全性优先考虑基于 Zookeeper 实现的分布式锁,Zookeeper 集群属于 CP 系统,天生提供了绝对的安全性和一致性。
- Author:mcbilla
- URL:http://mcbilla.com/article/182211a2-470e-46de-98bf-882378b12718
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!