type
status
date
slug
summary
tags
category
password

1、概述

1.1 什么是分布式锁

分布式锁是在分布式系统中用于控制多个节点对共享资源访问的同步机制,确保在任意时刻只有一个节点可以访问共享资源。
锁机制是指在多程环境下,为了保证数据的线程安全,锁保证同一时刻,只有一个可以访问和更新共享数据。在单机系统上我们可以使用 Java 提供的 synchronized或者 ReentrantLock ,在单个 JVM 进程中提供线程之间的锁定机制,控制多线程并发。例如系统 A 是一个电商系统,目前是一台机器部署,系统中有一个用户下订单的接口,但是用户下订单之前一定要去检查一下库存,确保库存足够了才会给用户下单。我们可以对下面的 1 到 4 步进行加锁,在第 4 步执行完后才释放锁。
notion image
如果业务量增大,一台机器顶不住了,增加到两台机器,这样Java提供的原生锁机制在多机部署场景下失效了,因为两台机器加的锁不是同一个锁(两个锁在不同的JVM里面)。这种情况就需要分布式锁了。分布式锁就是控制分布式系统不同进程访问共享资源的一种锁的机制。
notion image

1.2 分布式锁的实现

在选择分布式锁的实现方式时,需要考虑以下几个因素:
  1. 可用性:高可用的获取锁与释放锁是关键要求之一。如果锁的实现方式不可用或不稳定的,将导致整个系统的可用性受到影响。
  1. 性能:高性能的获取锁与释放锁同样重要。在分布式系统中,由于涉及多个进程或节点的协调,如果锁的实现方式性能较低,将导致系统整体的性能瓶颈。
  1. 可重入性:具备可重入特性是分布式锁的重要特性之一。可重入意味着同一线程可以多次获取同一把锁而不会导致死锁。
  1. 锁失效机制:具备锁失效机制可以防止死锁的发生。当某个进程或节点持有锁的时间过长而不释放时,其他进程或节点可以通过锁失效机制来避免陷入等待状态。
  1. 非阻塞锁特性:具备非阻塞锁特性意味着在没有获取到锁的情况下,线程可以直接返回而不是等待。这样可以提高系统的并发性能和响应速度。
分布式锁常见实现方式:
实现方式
原理
优点
缺点
数据库
有三种实现方式: 1、唯一索引:创建锁表,利用唯一索引实现互斥 2、乐观锁:通过版本号或时间戳实现 3、悲观锁:SELECT ... FOR UPDATE
实现简单
1、性能差 2、没有失效时间 3、排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆。
zookeeper
基于临时有序节点,原理是每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式:只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。
1、避免死锁问题:临时节点在客户端断开后自动删除,避免服务宕机导致的锁无法释放产生的死锁问题。 2、相比数据库锁性能更高,能避免数据库锁带来的性能瓶颈
1、性能不如redis锁 2、自身实现复杂,一般需要借助第三方客户端例如curator 4、并发安全问题:如果因为网络抖动客户端和ZK集群的session连接断了,zk集群以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。
原生redis
基于SETNX/EXPIRE/DEL命令,可以通过lua脚本保证SETNX+EXPIRE命令执行的原子性,也可以使用扩展的SET命令来代替。
性能高
1、锁超时问题:线程A持有锁,但是执行时间超过了过期时间,结果线程A还没执行完就释放了锁,线程B获取到锁,这时候线程A和B同时获取到锁,造成锁的互斥性问题。 2、锁误删问题:过了一段时间线程A执行完毕后del key删除锁,而且线程B此时还在执行,造成线程B没有执行完毕锁就被提前释放的问题。 3、锁丢失问题:加锁的key只存在一个节点上,如果该节点宕机,会发生主从切换导致锁丢失的问题。
redisson
redisson是单节点redis分布式锁的最优方案,底层是基于一段lua脚本实现的,并使用看门狗机制解决了锁超时和锁误删问题。
1、使用简单 2、看门狗机制解决了锁超时和锁误删问题
锁丢失问题
redlock
redlock是多节点redis分布式锁的解决方案,原理是是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。
即使有节点发生了故障,锁变量仍然是存在的,解决了多节点下主从切换导致的锁丢失问题
1、实现复杂 2、时钟漂移问题,严重依赖于系统时间 3、安全性问题,如果锁设置的实效时间太短,业务还没执行完就会发生锁的安全性问题
分布式锁的技术选型:
  • 简单业务:适用 Mysql 分布式锁。如果对性能要求不高,并且不希望因为要使用分布式锁而引入新组件,可以使用基于 Mysql 的分布式锁,性能上肯定是不如 Redis、Zookeeper。
  • 强一致性,有性能要求:如果对数据安全性要求较高,对性能要求中等,并且并发度不高,可以使用基于 Zookeeper 的分布式锁。因为 Zookeeper 天生设计定位就是分布式协调,强一致性。
  • 高并发:如果对性能要求较高,可以接受一定的容错性(因为 Redis 的设计定位决定了它的数据并不是强一致性的),可以选择基于 Redis 的分布式锁。如果是 Redis 单点系统优先考虑 Redisson 方案,如果是 Redis 多实例集群可以考虑 Redlock 方案,不推荐使用原生 Redis 方案。
特性
实现复杂度角度
性能角度
可靠性角度
数据库
Redis
Zookeeper

2、基于数据库的实现

数据库方案看起来不够高级,而且性能较低,但还是要根据具体场景具体分析。如果对分布式锁的性能要求不高,且数据库基本上是每个系统都需要引入的,直接基于数据库实现分布式锁可以避免引入其他系统,增加系统复杂性,越简单的技术架构越不容易出问题。大家要根据具体业务场景选择合适的技术方案,而不是随便找一个足够复杂、足够新潮的技术方案来解决业务问题。
基于数据库实现分布式锁有三种实现方案:
  • 基于唯一索引
  • 基于乐观锁
  • 基于排他锁

2.1 基于唯一索引

实现过程:
  1. 前置准备:创建一张分布式锁的数据表,包括 id、锁资源标识(唯一索引)、锁持有者标识(用于重入)、重入计数。
    1. 获取锁:插入一条记录,因为 lock_key 字段做了唯一性约束,这样如果有多个请求同时提交到数据库的话,数据库可以保证只有一个操作可以成功,其它的会报错 ERROR 1062 (23000): Duplicate entry ‘key’ for key ‘uk_lock_key’。获取锁失败的线程可以设置一段时间内周期性尝试获取锁。
      1. 执行业务逻辑。
      1. 释放锁:直接删除这条数据。
        这种实现方式非常简单,但是需要注意:
        • 锁失效问题:如果节点宕机,导致释放锁失败,就会导致锁记录一直在数据库中,其它线程无法获得锁。解决方案是做一个定时任务去定时清理。
        • 锁续期问题:如果业务执行时间超过了锁的超时时间,可能会导致在业务没有执行完毕的情况锁被提前释放,解决方案是为每个锁启动单独的守护线程进行锁续期。
          • 强依赖数据库:这种锁的可靠性依赖于数据库。建议设置备库,避免单点,进一步提高可靠性。
          • 这种锁是非阻塞的,因为插入数据失败之后会直接报错,想要获得锁就需要再次操作。如果需要阻塞式的,可以弄个for循环、while循环之类的,直至INSERT成功再返回。

          2.2 基于乐观锁

          乐观锁是指系统认为数据的更新在大多数情况下是不会产生冲突的,只在数据库更新操作提交的时候才对数据作冲突检测。如果检测的结果出现了与预期数据不一致的情况,则返回失败信息。
          乐观锁基于数据版本号(version)机制实现:
          1. 为数据库表添加一个版本号字段(version)
          1. CAS操作:先读取数据和版本号,在更新时比较版本号是否有变化
            1. 如果无变化,则更新成功,版本号加 1
            2. 如果有变化,则更新失败。
          例如使用乐观锁来实现锁库存,步骤如下:
          1. 创建一张分布式锁表,resource 表示具体操作的资源,在这里也就是特指库存,version 表示版本号。
            1. 初始化库存数据:在使用乐观锁之前要确保表中有相应的数据
              1. 获取锁:这是一个普通的查询操作
                1. 执行业务逻辑。
                1. 释放锁:比较 version 字段的新值和旧值,如果相等就同时更新 resource 字段和 version 字段,如果不相等,说明这行数据已经被其他人更新过,更新操作执行失败。
                  乐观锁比较适合并发量不高,并且写操作不频繁的场景。对于大促、秒杀等高并发的场景,version 值在频繁变化,则会导致大量请求失败,对数据库造成很大的压力。

                  2.3 基于悲观锁

                  悲观锁是指系统总是假设最坏的情况,它认为数据的更新在大多数情况下是会产生冲突的。
                  悲观锁的实现方式是通过SELECT ... FOR UPDATE 语句获取行级排他锁。当某条记录被加上排他锁之后,其它线程也就无法再改行上增加悲观锁。
                  基于排他锁的实现步骤如下:
                  1. 创建一张分布式锁表。
                    1. 初始化库存数据:和乐观锁相同,在使用悲观锁之前要确保表中有相应的数据
                      1. 获取锁:注意需要先手动开启事务。
                        1. 执行业务逻辑
                        1. 释放锁:提交事务
                          注意:
                          • SELECT ... FOR UPDATE 仅适用于 InnoDB,且必须在事务区块 BEGIN/COMMIT 中才能生效。
                          • InnoDB 在加锁的时候,只有明确地指定主键(或索引)的才会执行行锁 (只锁住被选取的数据),否则MySQL 将会执行表锁(将整个数据表单给锁住)。
                          • 使用 SELECT ... FOR UPDATE 加锁,会导致每次请求都会额外产生加锁的开销且未获取到锁的请求将会阻塞等待锁的获取,在高并发环境下,容易造成大量请求阻塞,影响系统可用性。

                          3、基于Zookeeper的实现

                          基于Zookeeper实现的分布式锁原理是基于Zookeeper的临时顺序节点 。ZooKeeper有以下特点:
                          • ZooKeeper的每一个节点,都是一个天然的顺序发号器: 在每个节点下面创建临时节点,新的子节点后面,会添加一个次序编号,这个生成的编号,会在上一次的编号进行 +1 操作。
                            • notion image
                          • ZooKeeper节点的递增有序性,可以确保锁的公平:我们只需要在一个持久父节点下,创建对应的临时顺序节点,每个线程在尝试占用锁之前,会调用watch,判断自己当前的序号是不是在当前父节点最小,如果是,那么获取锁。
                          • ZooKeeper的节点监听机制,可以保障占有锁的传递有序而且高效: 每个线程在抢占所之前,会创建属于当前线程的ZNode节点,在释放锁的时候,会删除创建的ZNode,当我们创建的序号不是最小的时候,会等待watch通知,也就是上一个ZNode的状态通知,当前一个ZNode删除的时候,会触发回调机制,告诉下一个ZNode,你可以获取锁开始工作了。ZooKeeper的节点监听机制,能够非常完美地实现这种击鼓传花似的信息传递。
                          • 临时节点自动删除,避免死锁问题: ZooKeeper还有一个好处,当我们客户端断开连接之后,我们出创建的临时节点会进行自动删除操作,所以我们在使用分布式锁的时候,一般都是会去创建临时节点,这样可以避免因为网络异常等原因,造成的死锁。
                          • ZooKeeper的节点监听机制,能避免羊群效应: ZooKeeper节点的顺序访问性,后面监听前面的方式,可以有效的避免羊群效应,什么是羊群效应:当某一个节点挂掉了,所有的节点都要去监听,然后做出回应,这样会给服务器带来比较大压力,如果有了临时顺序节点,当一个节点挂掉了,只有它后面的那一个节点才做出反应。
                          基于 Zookeeper 的分布式锁的实现过程:
                          1. 申请锁:客户端在申请锁的时候,在锁节点(如/locks)下创建一个临时有序节点(如/locks/lock_)。
                          1. 获取锁:客户端获取/locks下所有子节点,并按序号排序,然后判断自己创建的节点序号是不是最小的:
                            1. 如果是最小的,就获取锁成功,执行业务。
                            2. 如果不是最小的,就使用 watch 机制监听比自己序号小的前一个节点的删除事件。当前一个节点被删除时(锁释放),重复前面的步骤。
                          1. 释放锁:这里有两种情况
                            1. 获取锁成功的节点执行业务后,删除临时节点,释放锁。
                            2. 客户端断开连接之后,临时节点会自动删除,自动释放锁。
                          notion image
                          由于实现较为复杂,客户端一般使用封装好的第三方框架例如 Curator。下面是代码示例:

                          4、基于Redis的实现

                          Redis分布式锁的核心是利用 Redis 的SETNX(SET if Not eXists)命令或SET命令的NX选项来实现
                          • 基于 SETNX 命令的实现:包含 SETNXEXPIRE 两个命令。
                            • SETNX 命令,设置key值,获取返回值
                              • 返回 1,说明 key 不存在,将 key 的值设为 value,表示该进程获得锁
                              • 返回 0,说明 key 已经存在,表示其他进程已经获得了锁,则 SETNX 不做任何动作。
                            • EXPIRE 命令:设置 key 的生存时间,当 key 过期(TTL 生存时间为 0),会自动删除。
                            • 由于这是两个命令不是一个原子操作,一般使用Lua脚本(包含 SETNX + EXPIRE 两条指令)来保证原子性,也可以使用扩展的 SET 命令来代替。
                          • 基于 SET 命令的实现:SET 命令操作成功后返回的 OK,失败返回 NIL。
                            • EX:设置键的过期时间为 second 秒
                            • PX:设置键的过期时间为 millisecounds 毫秒
                            • NX:只在键不存在的时候,才对键进行设置操作。也即保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁,才能获取。
                            • XX:只在键已经存在的时候,才对键进行设置操作
                          基于 SET 命令的实现过程:
                          1. 使用 SET key value NX EX seconds 设置一个 key,返回 key 值表示获取锁成功,返回 null 表示获取锁失败,需要不断重试。
                          1. 处理完业务后,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 分布式锁的核心机制:
                          • 封装成 Lua 脚本的 SETNX 命令。
                          • 发布/订阅机制。
                          • 看门狗(Watch Dog)机制:用于解决分布式锁的自动续期问题,防止业务执行时间超过锁有效期而导致的锁自动释放问题。通过看门狗机制,Redisson 解决了锁超时、锁误删和死锁问题,但是仍然会存在锁丢失问题。看门狗的工作原理:
                            • 锁获取时启动看门狗:当使用 Redisson 获取锁时(如lock()方法),
                              • 如果未指定 leaseTime(锁持有时间),默认情况下,Redisson 为 key 设置的锁有效期是 30 秒,然后为每一个锁启动一个看门狗后台线程。
                              • 如果指定了 leaseTime,超过这个时间后锁便自动解开了,不会延长锁的有效期。
                            • 自动续期:会每隔 10 秒检查客户端是否还持有锁 key,如果锁 key 的过期时间到了,但客户端还持有锁 key,延长锁 key 的过期时间。默认情况下,看门狗的续期时间是 30s。也可以通过修改 Config.lockWatchdogTimeout 来另行指定。
                            • 释放锁时停止看门狗:当客户端主动释放锁时,看门狗线程会被停止。
                          基于 Redisson 分布式锁的代码示例:
                          Redisson 分布式锁的底层实现原理:
                          1. 客户端生成一个 UUID 作为锁的 value(用于标识锁的持有者)
                          1. 执行 Lua 脚本尝试获取锁:使用hset命令在 Redis 中设置一个 hash 结构:key 为锁名称,field 为客户端 UUID,value 为 1(表示重入次数)并设置过期时间(默认30秒)
                            1. 如果 key 不存在,加锁成功,返回 null
                            2. 如果锁存在,判断 filed 是否等于当前客户端 UUID,如果相等就把 value 加 1(支持重入锁),加锁成功,返回null。
                            3. 其他情况,返回一个数值,表示 key 的剩余生存时间,订阅锁释放的频道,进入等待状态。如果在过了超时时间都没有收到消息,就返回获取锁失败。
                          1. 获取锁成功后,启动看门狗线程定期(默认每10秒)续期锁的过期时间
                          notion image
                          在申请锁的时候,Redis 会执行如下 Lua 代码
                          释放该锁的时候会执行如下 Lua 脚本:

                          6、基于Redlock的实现

                          为了解决多节点下的锁丢失问题,Redis 作者 antirez 基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock。Redlock 算法核心思想是通过多个独立的 Redis 节点来共同管理锁,避免单点故障问题。基本流程如下:
                          1. 获取当前时间(毫秒精度)
                          1. 依次向 N 个Redis 节点请求获取锁
                          1. 计算获取锁总耗时,只有在大多数节点(N/2+1)上获取成功,并且总耗时小于锁的有效时间,才认为获取成功
                          1. 如果获取失败,则向所有节点发送释放锁请求
                          假如 Redis 分布式集群有 5 个 Redis Master 节点,为了取到锁,客户端应该执行以下操作:
                          1. 获取当前 Unix 时间戳,以毫秒为单位。
                          1. 依次尝试从 5 个实例,使用相同的 key 和具有唯一性的 value(例如 UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为 10 秒,则超时时间应该在 5-50 毫秒之间。这样可以避免服务器端 Redis 已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个 Redis 实例请求获取锁。
                          1. 客户端使用当前时间减去开始获取锁时间(步骤 1 记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的 Redis 节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
                          1. 如果取到了锁,key 的真正有效时间等于有效时间减去获取锁所使用的时间(步骤 3 计算的结果)。
                          1. 如果因为某些原因,获取锁失败(没有在至少 N/2+1 个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的 Redis 实例上进行解锁(即便某些 Redis 实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。
                          notion image
                          RedLock算法仍然存在问题:
                          1. 时钟漂移问题:Redis 的过期时间是依赖系统时钟的,要求多个节点机器时钟都是一致或者大致一致,如果某个节点时钟漂移过大时会影响到过期时间的计算。
                          1. 安全性问题:如果锁设置失效时间太短,方法没等执行完,锁就自动释放了,那么就会产生锁的安全性问题。
                            1. 客户端 1 请求锁定节点 A、B、C、D、E
                            2. 客户端 1 的拿到锁后,进入GC(时间比较久)
                            3. 所有 Redis 节点上的锁都过期了
                            4. 客户端 2 获取到了 A、B、C、D、E 上的锁
                            5. 客户端 1 GC 结束,认为成功获取锁
                            6. 客户端 2 也认为获取到了锁,发生「冲突」
                            7. notion image
                          Redlock 算法较为复杂,算法仍存在争议和问题,而且redis 集群本来就属于 AP 系统,对 AP 系统的安全性和一致性本来就不能要求太高。如果追求绝对的安全性优先考虑基于 Zookeeper 实现的分布式锁,Zookeeper 集群属于 CP 系统,天生提供了绝对的安全性和一致性。
                          Redis系列:数据结构和对象分布式ID:实现汇总
                          Loading...