分布式锁

Posted by 令德湖周杰伦 on 03-26,2024

1. 分布式锁

在并发场景下,为了保证并发安全,我们常常要通过互斥(加锁)手段来保证数据同步安全。

JDK 虽然提供了大量锁工具,但是只能作用于单一 Java 进程,无法应用于分布式系统。为了解决这个问题,需要使用分布式锁。

分布式锁的解决方案大致有以下几种:

  • 基于数据库实现
  • 基于缓存(redis等)实现
  • 基于 Zookeeper 实现

2. 思路

分布式锁的主要思路如下:

  1. 互斥、可重入

    1. 创建锁必须是唯一的,表现形式为向数据存储服务器或容器插入一个唯一的 key,一旦有一个线程插入这个 key,其他线程就不能再插入了。
    2. 唯一key:最简单直接的方式UUID
    3. 存储锁的重入次数,以及分布式环境下唯一的线程标识。举例来说,可以使用 json 存储结构化数据,为了保证唯一,可以考虑将 mac 地址(IP 地址、机器 ID)、Jvm 进程 ID(应用 ID、服务 ID)、线程 ID 拼接起来作为唯一标识。
  2. 避免死锁

    1. 数据库分布式锁和缓存分布式锁(Redis)的思路都是引入超时机制,即成功申请锁后,超过一定时间,锁失效(删除 key),原因在于它们无法感知申请锁的客户端节点状态。
    2. 而 ZooKeeper 由于其 znode 以目录、文件形式组织,天然就存在物理空间隔离,只要 znode 存在,即表示客户端节点还在工作,所以不存在这种问题。
  3. 容错 - 只要大部分 Redis 节点可用,客户端就能正常加锁。

  4. 自旋重试 - 获取不到锁时,不要直接返回失败,而是支持一定的周期自旋重试,设置一个总的超时时间,当过了超时时间以后还没有获取到锁则返回失败。

3. 实现

3.1 数据库分布式锁

  1. 创建表
CREATE TABLE `methodLock` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
  `desc` varchar(1024) NOT NULL DEFAULT '备注信息',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';
  1. 获取锁
insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)

唯一索引会校验,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。成功插入则获取锁。
3. 释放锁
当方法执行完毕之后,想要释放锁的话,需要执行以下 Sql:

delete from methodLock where method_name ='method_name'

存在的问题:

  • 强依赖数据库的可用性。如果数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
  • 没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
  • 这把锁只能是非阻塞的,因为数据的 insert 操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
  • 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

解决方法:

  • 单点问题可以用多数据库实例,同时塞 N 个表,N/2+1 个成功就任务锁定成功
  • 写一个定时任务,隔一段时间清除一次过期的数据。
  • 写一个 while 循环,不断的重试插入,直到成功。
  • 在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。

结论:

  • 优点: 直接借助数据库,容易理解。
  • 缺点:会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。操作数据库需要一定的开销,性能问题需要考虑。

3.2 Redis 分布式锁

相比于用数据库来实现分布式锁,基于缓存实现的分布式锁的性能会更好。

3.2.1 Redis 分布式锁原理

  • 互斥(只能有一个客户端获取锁)
  • 不能死锁
  • 容错(只要大部分 redis 节点创建了这把锁就可以)

对应的指令:

- setnx - setnx key val:当且仅当 key 不存在时,set 一个 key 为 val 的字符串,返回 1;若 key 存在,则什么都不做,返回 0。
- expire - expire key timeout:为 key 设置一个超时时间,单位为 second,超过这个时间锁会自动释放,避免死锁。
- delete - delete key:删除 key

需要注意:setnx和expire操作要保证原子性,否则如果客户端在 setnx 之后崩溃,那么将导致锁无法释放。正确的做法应是在 setnx 命令中指定 expire 时间。

3.2.2 Redis 分布式锁实现

  1. 申请锁
SET resource_name my_random_value NX PX 30000
  • NX:表示只有 key 不存在的时候才会设置成功。(如果此时 redis 中存在这个 key,那么设置失败,返回 nil)
  • PX 30000:意思是 30s 后锁自动释放。别人创建的时候如果发现已经有了就不能加锁了
  1. 释放锁
    释放锁就是删除 key ,但是一般可以用 lua 脚本删除,判断 value 一样才删除:
-- 删除锁的时候,找到 key 对应的 value,跟自己传过去的 value 做比较,如果是一样的才删除。
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

  • 使用随机的原因:
    因为如果某个客户端获取到了锁,但是阻塞了很长时间才执行完,比如说超过了 30s,此时可能已经自动释放锁了,此时可能别的客户端已经获取到了这个锁,要是你这个时候直接删除 key 的话会有问题,所以得用随机值加上面的 lua 脚本来释放锁。
  1. 存在的问题
  • 如果是普通的 redis 单实例,那就是单点故障。
  • 或者是 redis 普通主从,那 redis 主从异步复制,如果主节点挂了(key 就没有了),key 还没同步到从节点,此时从节点切换为主节点,别人就可以 set key,从而拿到锁。

3.2.3 RedLock

在Redis的分布式环境中,可以假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们在N个实例上使用与单机Redis相同的方法获取和释放锁。现在我们假设有5个Redis master节点(官方文档里将N设置成5,实际大等于3就满足要求)并在在5台服务器上面运行这5个实例,这样保证他们不会同时都宕掉。为了取到锁,Redis客户端会执行以下操作:

  1. 获取当前Unix时间,以毫秒为单位。
  2. 使用同一个key和具有唯一值的value,依次尝试从这5个实例中获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
  3. 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
  4. 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
  5. 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。

3.2.3.1 原理:

  • 用时间解决共识(用时间去代替 其他共识算法生成 递增序号token)
锁服务生成严格单调递增的令牌,这使得锁是安全的。
因为在操作时可以检查令牌,并拒绝较小值令牌的任何写操作。
例如,如果你使用 ZooKeeper 作为锁定服务,你可以使用 zxid 或 znode 版本号作为防护令牌
  • 安全性取决于很多时间假设
    • 设所有 Redis 节点在过期前都持有合适的时间长度
    • 网络延迟比过期时间短得多
    • 进程暂停时间比过期时间短得多

但分布式理论告诉我们,这些假设都是不太现实的

3.2.3.2 存在的问题:

即基于时间假设的Redlock不可靠

例子1:
假设系统有五个 Redis 节点(A、B、C、D 、 E)和两个客户端(1 和 2)。如果其中一个 Redis 节点上的时钟向前跳跃会发生什么?

1. 客户端 1 获取节点 A、B、C 上的锁。由于网络问题,无法访问 D 和 E。
2. 节点 C 上的时钟向前跳跃,导致锁到期。
3. 客户端 2 获取节点 C、D、E 上的锁。由于网络问题,无法访问 A 和 B。
4. 客户端 1 和 2 现在都相信他们持有锁。

如果 C 在将锁定持久化到磁盘之前崩溃并立即重新启动,则可能会发生类似的问题。出于这个原因,Redlock 文档建议至少将崩溃节点的重启延迟到锁的最长存活时间。但是这种重新启动延迟再次依赖于合理准确的时间测量,但在发生时钟跳跃时也会导致失败。

例子2:
可能你认为发生时钟跳跃不现实,因为你对正确配置 NTP 以调整时钟非常有信心。在这种情况下,让我们看一个进程暂停如何导致算法失败的示例:

1. 客户端 1 请求在节点 A、B、C、D、E 上锁定。
2. 当对客户端 1 的响应在进行中时,客户端 1 去进入 stop-the-world GC。
3. 所有 Redis 节点上的锁都会过期。
4. 客户端 2 在节点 A、B、C、D、E 上获取锁。
5. 客户端 1 完成 GC,并收到来自 Redis 节点的响应,表明它已成功获取锁(它们在进程暂停时保存在客户端 1 的内核网络缓冲区中) )。
6. 客户端 1 和 2 现在都相信他们持有锁。

3.2.3.3 结论:

  • RedLock算法的价值在于它引入了多数派思想(paxos分布式一致性算法),来解决单点故障对数据安全性和服务可用性的影响。
  • 但它严重依赖系统时钟和合适的租约时间设置也注定它不可能用于对强正确性有要求的场景。

3.3 ZooKeeper锁

3.3.1 ZooKeeper锁的原理

基于 ZooKeeper 的两个特性:

  • 顺序临时节点:ZooKeeper 的存储类似于 DNS 那样的具有层级的命名空间。ZooKeeper 节点类型可以分为持久节点(PERSISTENT )、临时节点(EPHEMERAL),每个节点还能被标记为有序性(SEQUENTIAL),一旦节点被标记为有序性,那么整个节点就具有顺序自增的特点
  • Watch 机制:ZooKeeper 允许用户在指定节点上注册一些 Watcher,并且在特定事件触发的时候,ZooKeeper 服务端会将事件通知给用户。

3.3.2 ZooKeeper实现

ZooKeeper 客户端 curator 的分布式锁实现:

  1. 创建一个目录 mylock;
  2. 线程 A 想获取锁就在 mylock 目录下创建临时顺序节点;
  3. 获取 mylock 目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁;
  4. 线程 B 获取所有节点,判断自己不是最小节点,设置监听比自己次小的节点;
  5. 线程 A 处理完,删除自己的节点,线程 B 监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。

3.3.3 结论

zk分布式锁和redis锁的对比:

  • 锁的占用时间限制:redis 有占用时间限制,而 ZooKeeper 则没有,最主要的原因是 redis 目前没有办法知道已经获取锁的客户端的状态,是已经挂了呢还是正在执行耗时较长的业务逻辑。而 ZooKeeper 通过临时节点就能清晰知道,如果临时节点存在说明还在执行业务逻辑,如果临时节点不存在说明已经执行完毕释放锁或者是挂了。
  • 是否有单点故障:redis 虽然可以做集群方案(通过 sentinel 方案或者 cluster 方案),但每种方案都难实现;而 ZooKeeper 只有一种玩法,多台机器的节点数据是一致的,没有 redis 的那么多的麻烦因素要考虑。

优点:zk实现的分布式锁更加简单,可靠性和一致性更高
缺点:频繁的创建和删除节点,性能上不如Redis锁,不适合并发特别高的场景

4. 分布式锁结论

4.1 你为何使用要使用分布式锁?在什么场景下?

在分布式应用程序中使用锁的原因可能有两个:为了效率或正确性。
怎么区分这两种原因呢?
你可以问自己如果获取锁失败会发生什么:

  • 效率:获取锁可以避免不必要地做两次相同的工作(例如一些昂贵的计算)。如果加锁失败并且两个节点最终完成相同的工作,可能会导致成本略有增加(比如你最终向 AWS 支付的费用比其他情况多 5 美分)或带来轻微的不便(例如,用户最终两次收到相同的电子邮件通知)
  • 正确性:使用锁可以防止并发进程相互干扰并破坏系统状态。如果加锁失败导致两个节点同时处理同一条数据,后果可能是文件损坏、数据丢失、永久性不一致,或者发生给患者服用的药物剂量错误或其他一些更严重的问题。

4.2 根据场景选择合适的分布式锁

  1. 如果你仅出于效率目的使用锁,则没有必要承担 Redlock 的成本和复杂性,运行 5 个 Redis 服务器并检查大多数以获得你的锁。你最好方案就是redis 主从异步复制的方式(已经可以满足绝大数该场景)

  2. 如果是为了保证确保正确性,请使用适当的共识系统,如ZooKeeper分布式锁