目录
一. 前言
1.1. 分布式锁需要具备的条件
1.2. 分布式锁的实现方式
1.3. 锁续期
二. 实现分布式锁的三种方式对比
2.1. 分布式锁和普通锁对比
三. 基于数据库的分布式锁
3.1. 基于乐观锁
3.1.1. 基于表主键唯一做分布式锁
3.1.2. 基于表字段版本号做分布式锁
3.2. 基于悲观锁
3.2.1. 基于数据库排他锁做分布式锁
3.3. 小结
四. 基于 Zookeeper 实现分布式锁
4.1. Zookeeper 分布式锁相关基础知识
4.2. Zookeeper 基本锁
4.3. 实现原理
4.4. Zookeeper 分布式锁的羊群效应和解决方法
4.5. 小结
五. 基于 Redis 实现分布式锁
5.1. 基于 Redis 的 SETNX()、EXPIRE() 方法做分布式锁
5.1.1. 基本命令
5.1.2. 使用步骤
5.1.3. 小结
5.2. 基于 Redis 的 SETNX()、GET()、GETSET() 方法做分布式锁
5.2.1. 基本命令
5.2.2. 使用步骤
5.3. 基于 Redisson 做分布式锁
5.3.1. 基本实现原理
5.3.2. 使用方式
5.3.3. 小结
5.4. 基于 RedLock 做分布式锁
5.4.1. RedLock 算法
5.4.2. 小结
六. 总结
6.1. 数据库分布式锁实现
6.2. Redis(缓存)分布式锁实现
6.3. Zookeeper 分布式锁实现
什么是分布式锁?线程锁和进程锁仅能满足在单机 JVM 或者同一个操作系统下,才能有效。跨JVM 系统,无法满足。因此就产生了分布式锁,完成锁的工作。
分布式锁需要具备哪些条件?
互斥性:任意时刻,只能有一个客户端才能获取锁。
防止死锁:分布式锁应该设计成在锁的持有者异常退出或崩溃时能够自动释放,以防止死锁的发生。一般通过设置合适的锁超时时间来避免死锁。
高可用性:在节点故障时也能正常工作,确保锁的可靠性。
可重入性:允许同一个线程或客户端在持有锁的情况下多次获取同一个锁,而不会出现死锁或阻塞的情况。这对于递归函数调用等场景尤其重要。
唯一标识:分布式锁应该具备唯一的标识,以便客户端可以识别和管理不同的锁。
实现分布式锁的方式有以下几种:
1. 使用 MySQL,基于唯一索引。
2. 使用 Zookeeper,基于临时有序节点。
3. 使用 Redis,基于 setnx 命令。
在 Redis 中,常用的分布式锁实现包括:
1. SETNX(阻塞式):使用 SETNX 命令可以实现一个基本的分布式锁。当键不存在时设置键的值,如果键已经存在(锁已被其他客户端持有),则返回 0,表示获取锁失败。这是一个阻塞式的实现,因为请求会在获取锁前等待。
2. RedLock(阻塞式):RedLock 是一个由多个 Redis 实例组成的分布式锁实现,它使用了多个 Redis 节点以提高锁的可靠性。它是阻塞式的,因为每个节点在尝试获取锁时会阻塞,直到获取锁或超时。
3. Redisson(阻塞式和非阻塞式):Redisson 是一个基于 Redis 的分布式锁库,提供了阻塞式和非阻塞式两种锁。阻塞式锁在获取锁失败时会阻塞等待,而非阻塞式锁则会立即返回获取失败的标识。
4. 基于Lua脚本的锁(非阻塞式):使用 Redis 的 Lua 脚本可以实现非阻塞式的分布式锁。脚本尝试获取锁,并在获取失败时返回获取失败的标识。
阻塞/非阻塞:可以是阻塞式(等待锁释放)或非阻塞式(立即返回结果)。
分布式锁是否需要锁续期取决于具体的实现和使用场景。在使用分布式锁时,考虑业务逻辑的性质、执行时间,以及对锁的使用方式,从而决定是否需要设置锁续期。续期可以提高锁的安全性,但也需要注意避免续期时间过长导致锁的长时间占用,影响其他操作。
需要锁续期的情况:
1. 长时间任务:如果获取分布式锁的业务逻辑较为复杂或耗时,那么可能需要设置锁续期,以防止持有锁的客户端在执行业务逻辑时由于各种原因无法及时释放锁。
2. 业务处理时间不确定:如果业务处理时间不确定,无法预测锁会持有多长时间,那么设置锁续期可以确保在业务逻辑执行期间锁不会过早地被释放。
不需要锁续期的情况:
1. 短时间任务:如果获取分布式锁的业务逻辑非常简单且耗时很短,可以在执行完业务逻辑后立即释放锁,不需要设置锁续期。
2. 业务逻辑可控:如果业务逻辑可以控制在一个较短的时间内完成,且不会出现无法释放锁的情况,也可能不需要设置锁续期。
理解的容易程度(从低到高) | 数据库 > 缓存 > Zookeeper |
实现的复杂性(从低到高) | Zookeeper ≥ 缓存 > 数据库 |
性能(从高到低) | 缓存 > Zookeeper ≥ 数据库 |
可靠性(从高到低) | Zookeeper > 缓存 > 数据库 |
Zookeeper 分布式锁的可靠性最高,有封装好的框架,很容易实现分布式锁的功能,并且几乎解决了数据库锁和缓存式锁的不足,因此是实现分布式锁的首选方法。
普通锁(本地锁) | 分布式锁 | |
---|---|---|
作用范围 | 单个进程或单个计算机内的多个线程之间的同步。当多个线程尝试访问同一资源时,普通锁可以确保只有一个线程可以访问该资源,其他线程需要等待锁的释放。 | 分布式锁用于跨多个进程或多个计算机之间的同步。允许不同的进程或计算机协调对共享资源的访问,以避免冲突和数据不一致性。 |
锁的获取方式 | 普通锁通常是基于本地内存的互斥量或自旋锁实现的,可以通过在内存中的标记或计数器来判断锁的状态,并通过执行CPU自旋等待来获取锁。 | 分布式锁通常使用基于分布式系统的外部组件或服务,如分布式缓存系统(如Redis)或分布式协调服务(如Zookeeper)实现。进程或计算机通过与这些组件进行通信来获取和释放锁。 |
可靠性和容错性 | 普通锁在单个计算机上运行,受限于该计算机的可靠性和容错性。如果计算机故障或程序崩溃,可能会导致锁被永久占用或意外释放。 | 分布式锁通过将锁状态存储在外部组件中,可以提供更高的可靠性和容错性。即使其中一个计算机或进程崩溃,其他进程仍然可以通过与外部组件通信来获取锁。 |
思路:利用主键唯一的特性,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,当方法执行完毕之后,想要释放锁的话,删除这条数据库记录即可。
上面这种简单的实现有以下几个问题:
1. 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
2. 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
3. 这把锁只能是非阻塞的,因为数据的 insert 操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
4. 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
5. 这把锁是非公平锁,所有等待锁的线程凭运气去争夺锁。
6. 在 MySQL 数据库中采用主键冲突防重,在大并发情况下有可能会造成锁表现象。
当然,我们也可以有其他方式解决上面的问题:
1. 数据库是单点?搞两个数据库,数据之前双向同步,一旦挂掉快速切换到备库上。
2. 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
3. 非阻塞的?搞一个 while 循环,直到 insert 成功再返回成功。
4. 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。但是这样是很消耗性能的,增加数据库压力。
5. 非公平的?再建一张中间表,将等待锁的线程全记录下来,并根据创建时间排序,只有最先创建的允许获取锁。
6. 比较好的办法是在程序中生产主键进行防重。
这个策略源于 mysql 的 mvcc 机制,使用这个策略其实本身没有什么问题,唯一的问题就是对数据表侵入较大,我们要为每个表设计一个版本号字段,然后写一条判断 sql 每次进行判断,增加了数据库操作的次数,在高并发的要求下,对数据库连接的开销也是无法忍受的。
在查询语句后面增加 for update,数据库会在查询过程中给数据库表增加排他锁(注意:InnoDB 引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给要执行的方法字段名添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上)。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。
我们可以认为获得排他锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,通过 connection.commit() 操作来释放锁。
这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题,但是还是无法直接解决数据库单点和可重入问题。
1. 阻塞锁? for update 语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。
2. 锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。
这里还可能存在另外一个问题,虽然我们对方法字段名使用了唯一索引,并且显示使用 for update 来使用行级锁。但是,MySQL 会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。如果发生这种情况就悲剧了。
还有一个问题,就是我们要使用排他锁来进行分布式锁的 lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆。
1. 悲观锁(Pessimistic Locking):悲观锁基于数据库的排他锁机制,即在获取锁时直接对数据库记录进行锁定,防止其他事务修改。在实现中,可以使用数据库支持的锁语句,如 SELECT ... FOR UPDATE。当事务想要获取锁时,会阻塞其他事务对同一行记录的修改,从而实现锁的效果。
2. 乐观锁(Optimistic Locking):乐观锁通过记录版本号或时间戳来实现。在获取锁前,先读取记录的版本号或时间戳,然后在修改时检查是否与之前读取的值相同,如果相同则表示没有其他事务干扰,可以执行更新操作。如果不同,则说明其他事务已经修改了记录,需要处理冲突。
3. 数据库表锁:可以使用数据库的表级锁来实现分布式锁。在获取锁时,可以在某个特定的表中插入一条特殊的记录,表示锁已被持有。其他事务在获取锁时会检查表中是否存在这条记录,如果存在则表示锁已被占用。
4. 数据库行锁:类似于表锁,但是锁定的是表中的特定行记录。在获取锁时,可以锁定某一行记录,防止其他事务修改这一行。
优点:
1. 持久性锁:数据库锁是持久性的,即使系统发生故障也不容易丢失锁,能够确保锁的可靠性。
2. 数据一致性:基于数据库的锁可以借助数据库事务来保证数据一致性,避免脏数据问题。
3. 容易实现:简单,易于理解,易于实现。
4. 锁的粒度控制:可以通过数据库的事务来控制锁的粒度,灵活地控制锁的范围。
缺点:
1. 死锁:数据库锁没有失效时间,未获得锁的进程只能一直等待已获得锁的进程主动释 放锁。一旦已获得锁的进程挂掉或者解锁操作失败,会导致锁记录一直存在数据库中,其它进程无法获得锁
2. 性能差:数据库操作通常相对较慢,基于数据库的分布式锁可能会影响性能,尤其在高并发场景下。
3. 单点故障:如果使用单个数据库作为锁服务,那么数据库成为了单点故障,可能影响整个系统的可用性。
4. 数据库连接开销:基于数据库的锁可能需要频繁地获取和释放数据库连接,增加了数据库连接的开销。
5. 复杂性:虽然基于数据库的锁相对易于理解,但在高并发和分布式环境中,需要处理事务隔离级别、锁的超时等问题,可能会增加实现的复杂性。
1. Zookeeper 一般由多个节点构成(单数),采用 zab 一致性协议。因此可以将 Zookeeper 看成一个单点结构,对其修改数据其内部自动将所有节点数据进行修改而后才提供查询服务。
2. Zookeeper 的数据以目录树的形式,每个目录称为 znode, znode 中可存储数据(一般不超过 1M),还可以在其中增加子节点。
3. 子节点有三种类型。序列化节点,每在该节点下增加一个节点自动给该节点的名称上自增。临时节点,一旦创建这个 znode 的客户端与服务器失去联系,这个 znode 也将自动删除。最后就是普通节点。
4. Watch 机制,client 可以监控每个节点的变化,当产生变化会给 client 产生一个事件。
关于 Zookeeper 的详细介绍,请参见《中间件 - 分布式协调服务Zookeeper》。
原理:利用临时节点与 watch 机制。每个锁占用一个普通节点 /lock,当需要获取锁时在 /lock 目录下创建一个临时节点,创建成功则表示获取锁成功,失败则 watch /lock 节点,有删除操作后再去争锁。临时节点的好处在于当进程挂掉后能自动上锁的节点自动删除即取消锁。
缺点:所有取锁失败的进程都监听父节点,很容易发生羊群效应,即当释放锁后所有等待进程一起来创建节点,并发量很大。
1. 当一个客户端想要获取锁时,它在 Zookeeper 上创建一个有序的临时节点。
2. 每个客户端创建的节点会按照顺序排列,形成一个有序的节点路径。客户端会监视前一个节点,一旦前一个节点被删除(代表锁被释放),该客户端就获得了锁。
3. 如果某个客户端没有获得锁,它会监听自己创建的节点,并等待前一个节点被删除,从而触发自己获取锁的机会。
4. 当客户端完成任务后,它会删除自己创建的临时节点,从而释放锁。
步骤:
1. 在 /lock 节点下创建一个有序临时节点 (EPHEMERAL_SEQUENTIAL)。
2. 判断创建的节点序号是否最小,如果是最小则获取锁成功。不是则取锁失败,然后 watch 序号比本身小的前一个节点。
3. 当取锁失败,设置 watch 后则等待 watch 事件到来后,再次判断是否序号最小。
4. 取锁成功则执行代码,最后释放锁(删除该节点)。
羊群效应:
1. 在整个分布式锁的竞争过程中,大量的【Watcher通知】和【子节点列表的获取】操作重复运行,并且大多数节点的运行结果都是判断出自己当前并不是编号最小的节点,继续等待下一次通知,而不是执行业务逻辑。
2. 这就会对 Zookeeper 服务器造成巨大的性能影响和网络冲击。更甚的是,如果同一时间多个节点对应的客户端完成事务或事务中断引起节点消失,Zookeeper 服务器就会在短时间内向其他客户端发送大量的事件通知。
解决方法:
1. 在与该方法对应的持久节点的目录下,为每个进程创建一个临时顺序节点。
2. 每个进程获取所有临时节点列表,对比自己的编号是否最小,若最小,则获得锁。
3. 若本进程对应的临时节点编号不是最小的,则继续判断:
3.1. 若本进程为读请求,则向比自己序号小的最后一个写请求节点注册 watch 监听,当监听到该节点释放锁后,则获取锁;
3.2. 若本进程为写请求,则向比自己序号小的最后一个读请求节点注册 watch 监听,当监听到该节点释放锁后,获取锁。
有序临时节点的机制确保了获取锁的顺序,避免了循环等待,从而有效地避免了死锁问题。因为任何一个客户端在释放锁之前都会删除自己的节点,从而触发下一个等待的客户端获取锁。
需要注意的是,这种机制虽然能够有效避免死锁,但也可能带来性能问题。当某个客户端释放锁时,需要触发所有等待的客户端获取锁,可能会导致较多的网络通信和监听事件。因此,在高并发情况下,需要综合考虑性能和锁的可靠性。
总的来说,基于 Zookeeper 的分布式锁能够确保数据一致性和锁的可靠性,但需要权衡性能和复杂性。在选择时,需要根据具体场景来决定是否使用该种锁机制。
优点:
1. 可靠性:Zookeeper 是一个高可用的分布式协调服务,基于它的分布式锁具有较高的可靠性和稳定性。
2. 顺序性:Zookeeper 的有序临时节点保证了锁的获取顺序,避免了死锁和竞争问题。
3. 避免死锁:在锁的持有者释放锁之前,其他节点无法获取锁,从而避免了死锁问题。
4. 容错性:即使部分节点发生故障,其他节点仍然可以正常获取锁,保证了系统的稳定性。
缺点:
1. 性能:Zookeeper 是一个中心化的协调服务,可能在高并发场景下成为性能瓶颈。
2. 复杂性:Zookeeper 的部署和维护相对复杂,需要一定的运维工作。
3. 单点故障:尽管 Zookeeper 本身是高可用的,但如果 Zookeeper 集群出现问题,可能会影响到基于它的分布式锁。
选用 Redis 实现分布式锁原因:1)Redis有很高的性能;2)Redis命令对此支持较好,实现起来比较方便。
setnx() 的含义就是 SET if Not Exists,其主要有两个参数 setnx(key, value)。该方法是原子的,如果 key 不存在,则设置当前 key 成功,返回 1;如果当前 key 已经存在,则设置当前 key 失败,返回 0。
expire() 设置过期时间,要注意的是 setnx 命令不能设置 key 的超时时间,只能通过 expire() 来对 key 设置。
del(key) 删除key。
1. setnx(lockkey, 1) 如果返回 0,则说明占位失败;如果返回 1,则说明占位成功。
2. expire() 命令对 lockkey 设置超时时间,为的是避免死锁问题。
3. 执行完业务代码后,可以通过 delete 命令删除 key。
加锁:使用 setnx key value 命令,如果 key 不存在,设置 value(加锁成功)。如果已经存在 lock(也就是有客户端持有锁了),则设置失败(加锁失败)。
解锁:使用 del 命令,通过删除键值释放锁。释放锁之后,其他客户端可以通过 setnx 命令进行加锁。
key 的值可以根据业务设置,比如是用户中心使用的,可以命令为 USER_REDIS_LOCK,value可以使用 uuid 保证唯一,用于标识加锁的客户端。保证加锁和解锁都是同一个客户端。
使用单命令+Lua脚本:
1. 加锁改进:set lock_key unique_value NX PX 10000。
2. 释放锁改进:lua 脚本。
这个方案其实是可以解决日常工作中的需求的,但从技术方案的探讨上来说,可能还有一些可以完善的地方。比如,如果在第一步 setnx 执行成功后,在 expire() 命令执行成功前,发生了宕机的现象,那么就依然会出现死锁的问题,所以如果要对其进行完善的话,可以使用 redis 的 setnx()、get() 和 getset() 方法来实现分布式锁。
优点:
1. 性能更好。数据被存放在内存,而不是磁盘,避免了频繁的 IO 操作。
2. 很多缓存可以跨集群部署,避免了单点故障。
3. 很多缓存服务都提供了可以用来实现分布式锁的方法,比如 Redis 的 setnx 方法等。
4. 可以直接设置超时时间来控制锁的释放,因为这些缓存服务器一般支持自动删除过期数据。
缺点:通过超时时间来控制锁的失效时间,并不是十分靠谱,因为一个进程执行时间可能比较长,或受系统进程做内存回收等影响,导致时间超时,从而不正确地释放了锁。
这个方案的背景主要是在 setnx() 和 expire() 的方案上针对可能存在的死锁问题,做了一些优化。
getset() 这个命令主要有两个参数 getset(key,newValue)。该方法是原子的,对 key 设置 newValue 这个值,并且返回 key 原来的旧值。假设 key 原来是不存在的,那么多次执行这个命令,会出现下边的效果:
getset(key, "value1") 返回 null,此时 key 的值会被设置为 value1;
getset(key, "value2") 返回 value1,此时 key 的值会被设置为 value2;
依次类推!
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 释放锁;如果大于锁设置的超时时间,则不需要再锁进行处理。
Redisson 是基于 Redis 的分布式对象和服务库,它实现了一系列分布式数据结构和分布式服务。
下面是 Redisson 分布式锁的基本实现原理:
1. Redisson 客户端:开发者通过引入 Redisson 的依赖库,创建 Redisson 客户端连接到 Redis 服务器。
2. 分布式锁对象创建:使用 Redisson 客户端,通过 getLock 方法创建分布式锁对象。
3. 锁的获取:当一个客户端想要获取锁时,它会向 Redis 服务器发送 SETNX(SET if Not eXists)命令,尝试在 Redis 中创建一个指定键名的值。
4. 设置过期时间:锁的持有者可以在创建锁时设置一个过期时间,用于防止锁被长时间持有。过期时间一般通过 Redis 的 EXPIRE 命令实现。
5. 锁的续期:当一个客户端成功获取锁后,它可以通过定时任务或其他机制定期向 Redis 更新锁的过期时间,从而实现锁的续期。
6. 锁的释放:当锁的持有者完成任务后,它可以通过解锁操作释放锁。解锁操作会发送 DEL(Delete)命令,删除锁对应的 Redis 键。
7. 监听锁状态:Redisson 支持锁的监听机制,当锁的状态发生变化时,可以触发相应的监听事件。
Redisson 的实现原理基于 Redis 提供的原子操作,如 SETNX 和 EXPIRE 命令,以及 Redis 提供的发布-订阅机制来实现锁的监听。通过这些机制,Redisson 能够提供可靠的分布式锁,并支持锁的续期和监听等功能。
Redisson 分布式锁的使用非常简单,通常包括以下几个步骤:
1. 引入 Redisson 依赖:在项目中引入 Redisson 的依赖库。
2. 创建 Redisson 客户端:通过配置 Redisson 的连接信息创建一个 Redisson 客户端。
3. 获取分布式锁:使用 Redisson 客户端调用 getLock() 方法获取分布式锁对象。
4. 加锁和解锁:使用分布式锁对象的 lock 方法进行加锁操作,使用 unlock 方法进行解锁操作。
5. 锁续期:Redisson 会自动续期锁,不需要手动操作。
优点:
1. 丰富的功能:Redisson 提供了丰富的分布式锁功能,包括可重入锁、续期锁、公平锁等,适应不同的业务需求。
2. 易于使用:Redisson 的 API 设计简单易用,开发人员可以快速上手使用分布式锁。
3. 高性能:Redisson 底层使用 Redis,具有高性能和高并发的特点。
4. 维护更新:Redisson 是一个活跃维护的开源项目,可以享受到持续的更新和bug修复。
缺点:
1. 依赖性:使用 Redisson 需要引入额外的依赖库,增加了项目的复杂性。
2. 集成难度:对于不熟悉 Redisson 的开发人员,初次集成可能需要一些时间。
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 的正确性。
优点:性能高。
缺点:失效时间设置多长时间为好?如果设置的失效时间太短,方法没等执行完,锁就自动释放了,那么就会产生并发问题。如果设置的时间太长,其他获取锁的线程就可能要平白的多等一段时间。
缺点:
1. DB 操作性能较差,并且有锁表的风险。
2. 非阻塞操作失败后,需要轮询,占用 cpu 资源。
3. 长时间不 commit 或者长时间轮询,可能会占用较多连接资源。
缺点:
1. 锁删除失败,过期时间不好控制;
2. 非阻塞,操作失败后,需要轮询,占用cpu资源。
缺点:性能不如 Redis实现,主要原因是写操作(获取锁释放锁)都需要在 Leader 上执行,然后同步到 Follower。
但是,Zookeeper 有较好的性能和可靠性。
综上所述,以上的实现思路仅仅考虑在单机版 Redis 上,如果是集群版 Redis 需要考虑的问题还要再多一点。Redis 由于他的高性能读写能力,所以在并发高的场景下使用 Redis 分布式锁会多一点。
第一个问题是设置有效期防止死锁,并且引入守护线程给锁续期,第二个问题是支持可重入锁,第三个问题是加锁失败后阻塞等待,等锁释放后再次尝试加锁。Redisson框架解决这三个问题的思路也非常值得学习。