分布式锁

内容来自

  1. 极客时间专栏:《分布式技术原理与算法解析》
  2. 再有人问你分布式锁,这篇文章扔给他
  3. 分布式锁看这篇就够了

一、基本概念

分布式锁是指分布式环境下,系统部署在多个机器中,实现多进程分布式互斥的一种锁。为了保证多个进程能看到锁,锁被存在公共存储(比如 Redis、Memcache、数据库等三方存储中),以实现多个进程并发访问同一个临界资源,同一时刻只有一个进程可访问共享资源,确保数据的一致性

应用场景

  • 提高性能以及效率:使用分布式锁可以避免不同节点重复相同的工作,这些工作会浪费资源,比如用户付了钱之后有可能不同节点会发出多封短信。
  • 保证数据的正确性:加分布式锁同样可以避免破坏正确性的发生,如果两个节点在同一条数据上面操作,比如多个节点机器对同一个订单操作不同的流程有可能会导致该笔订单最后状态出现错误,造成损失

分布式锁的特点

  • 互斥性:和我们本地锁一样互斥性是最基本,但是分布式锁需要保证在不同节点的不同线程的互斥。
  • 可重入性:同一个节点上的同一个线程如果获取了锁之后那么也可以再次获取这个锁。
  • 锁超时:和本地锁一样支持锁超时,防止死锁。
  • 高效,高可用:加锁和解锁需要高效,同时也需要保证高可用防止分布式锁失效,可以增加降级。
  • 支持阻塞和非阻塞:和ReentrantLock一样支持lock和trylock以及tryLock(long timeOut)。
  • 支持公平锁和非公平锁(可选):公平锁的意思是按照请求加锁的顺序获得锁,非公平锁就相反是无序的。这个一般来说实现的比较少

使用分布式锁的注意事项

  1. 注意分布式锁的开销
  2. 注意加锁的粒度
  3. 加锁的方式

分布式锁的实现

  • 基于数据库实现分布式锁,这里的数据库指的是关系型数据库(MySQL)
  • 基于Redis实现分布式锁
  • 基于 ZooKeeper 实现分布式锁
  • 其他中间件实现, 比如 Consul

二、基于数据库实现分布式锁

基于表主键唯一实现

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

缺点

  1. 强依赖数据库的可用性,存在数据库单点问题

    1
    改进思路:做数据库主从高可用设计
  2. 没有失效时间限制,比如释放锁删除数据库记录失败,就会导致阻塞

    1
    改进思路: 做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍
  3. 只支持非阻塞,因为获得锁是通过数据库 insert操作,一旦操作失败只能重新请求获取锁.

    1
    改进思路:可以参考类似自旋锁的思想,通过外层while循环插入操作直到成功
  4. 不支持可重入操作,同一个线程在没有释放锁之前无法再次获得该锁,因为数据中数据已经存在唯一记录了。

    1
    改进思路: 借鉴ReentrantLock的实现思路,添加一个字段记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了
  5. 非公平锁,能否获得锁全凭运气。

    1
    改进思路:建一张中间表,将等待锁的线程全记录下来,并根据创建时间排序,只有最先创建的允许获取锁
  6. 在 MySQL 数据库中采用主键冲突防重,在大并发情况下有可能会造成锁表现象

    1
    改进思路: 在程序中产生主键,而不是通过数据库自动生成主键进行防重

基于表字段版本号实现

这个策略源于 mysql 的 mvcc 机制,使用这个策略其实本身没有什么问题,唯一的问题就是对数据表侵入较大,我们要为每个表设计一个版本号字段,然后写一条判断 sql 每次进行判断,增加了数据库操作的次数,在高并发的要求下,对数据库连接的开销也是无法忍受的。

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

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

分析

解决了基于表主键唯一实现的超时无法释放锁和阻塞锁的问题:

  • for update语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功
  • 使用这种方式,服务宕机之后数据库会自己把锁释放掉

不足:

  • 还是无法直接解决数据库单点和可重入问题
  • 可能存在性能上的问题:MySQL 会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁
  • 使用排他锁来进行分布式锁的 lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆

三、基于Redis实现分布式锁

基于 redis 的 setnx()、expire() 方法做分布式锁

setnx()
setnx 的含义就是 SET if Not Exists,其主要有两个参数 setnx(key, value)。该方法是原子的,如果 key 不存在,则设置当前 key 成功,返回 1;如果当前 key 已经存在,则设置当前 key 失败,返回 0。

expire()
expire 设置过期时间,要注意的是 setnx 命令不能设置 key 的超时时间,只能通过 expire() 来对 key 设置

步骤

  1. setnx(lockkey, 1) 如果返回 0,则说明占位失败;如果返回 1,则说明占位成功
  2. expire() 命令对 lockkey 设置超时时间,为的是避免死锁问题。
  3. 执行完业务代码后,可以通过 delete 命令删除 key

注意
在第一步 setnx() 执行成功后,在 expire() 命令执行成功前,发生了宕机的现象,那么就依然会出现死锁的问题

基于 redis 的 setnx()、get()、getset()方法做分布式锁

这个方案的背景主要是在 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 释放锁;如果大于锁设置的超时时间,则不需要再锁进行处理

其他拓展方案

  1. 基于 Redlock 做分布式锁
  2. 基于 redisson 做分布式锁,GitHub 地址

四、基于 ZooKeeper 实现分布式锁

zookeeper 锁相关基础知识

  • zk 一般由多个节点构成(单数),采用 zab 一致性协议。因此可以将 zk 看成一个单点结构,对其修改数据其内部自动将所有节点数据进行修改而后才提供查询服务。
  • zk 的数据以目录树的形式,每个目录称为 znode, znode 中可存储数据(一般不超过 1M),还可以在其中增加子节点。
  • 子节点有三种类型。序列化节点,每在该节点下增加一个节点自动给该节点的名称上自增。临时节点,一旦创建这个 znode 的客户端与服务器失去联系,这个 znode 也将自动删除。最后就是普通节点。
  • Watch 机制,client 可以监控每个节点的变化,当产生变化会给 client 产生一个事件

基本方案

  • 原理:利用临时节点与 watch 机制。每个锁占用一个普通节点 /lock,当需要获取锁时在 /lock 目录下创建一个临时节点,创建成功则表示获取锁成功,失败则 watch/lock 节点,有删除操作后再去争锁。临时节点好处在于当进程挂掉后能自动上锁的节点自动删除即取消锁。
  • 缺点:所有取锁失败的进程都监听父节点,很容易发生羊群效应,即当释放锁后所有等待进程一起来创建节点,并发量很大

优化方案

原理

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

步骤

  1. 在 /lock 节点下创建一个有序临时节点 (EPHEMERAL_SEQUENTIAL)。
    判断创建的节点序号是否最小,如果是最小则获取锁成功。不是则取锁失败,然后 watch 序号比本身小的前一个节点。
  2. 当取锁失败,设置 watch 后则等待 watch 事件到来后,再次判断是否序号最小
  3. 获取锁成功则执行代码,最后释放锁(删除该节点)

五、分布式锁的安全问题

参考链接

GC的STW(stop-the-world)

Java的虚拟机在进行垃圾回收的时候可能会发生 STW现象, 即全局暂停,例如CMS垃圾回收器,他会有两个阶段进行STW防止引用继续进行变化。那么有可能会出现下面图:
image.png
如图所示: 客户端1在获得锁之后发生了很长时间的GC pause,在此期间,它获得的锁过期了,而客户端2获得了锁。当客户端1从GC pause中恢复过来的时候,它不知道自己持有的锁已经过期了,它依然向共享资源(上图中是一个存储服务)发起了写数据请求,而这时锁实际上被客户端2持有,因此两个客户端的写请求就有可能冲突(锁的互斥作用失效了)

时钟发生跳跃

假设有5个Redis节点A, B, C, D, E:

  1. 客户端1从Redis节点A, B, C成功获取了锁(多数节点)。可能由于网络问题,与D和E通信失败。
  2. 节点C上的时钟发生了向前跳跃,导致它上面维护的锁快速过期。
  3. 客户端2从Redis节点C, D, E成功获取了同一个资源的锁(多数节点)。
  4. 客户端1和客户端2现在都认为自己持有了锁

这个例子可以看到假如锁的过期失效机制强依赖于时间,一旦系统的时钟变得不准确,算法的安全性也就保证不了了。不过基于zk实现的分布式锁倒是不需要依赖时间,而是依赖每个节点的Session

长时间的网络延迟

在一个分布式系统中,网络问题是无法避免的。长时间的网络延迟可以产生与上述两个问题类似的情况,A节点已经获得锁,但因为网络延时超过超时时间限制而失效,这个时候B节点请求获取了锁,等待恢复后A节点并不知道锁已经失效,就会存在A和B冲突问题