分布式锁是什么?

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

实现分布式锁的两个要求

  • 1.分布式锁的加锁和释放锁的过程涉及多个操作,需要保证这些锁操作的原子性
  • 2.共享存储系统保存了锁变量,如果共享存储系统发生故障或宕机,那客户端也就无法进行锁操作了。在实现分布式锁时,需考虑保证共享存储系统的可靠性,进而保证锁的可靠性

为了确保分布式锁的可用性,设计时应该考虑哪几点?

  • 1.互斥性:在分布式系统环境下,分布式锁应该能保证一个资源或一个方法在同一时间只能被一个机器的一个线程或进程操作
  • 2.具备锁失效机制,防止死锁:即使有一个进程在持有锁的期间因为崩溃而没有主动解锁, 也能保证后续其他进程可以获得锁
  • 3.可重入性:进程未释放锁时,可多次访问临界资源
  • 有高可用的获取锁和释放锁的功能,且性能要好

实现分布式锁的 3 种主流方法是什么?

  • 基于数据库实现分布式锁,主要指关系型数据库
  • 基于缓存实现分布式锁
  • 基于ZooKeeper实现分布式锁

基于数据库实现分布式锁的原理、使用场景、缺点

  • 原理
    • 创建一张锁表,然后通过操作该表中的数据来实现
    • 当要锁住某个资源时,就在该表中增加一条记录,想要释放锁的时候就删除这条记录。数据库对共享资源做了唯一性约束,如果有多个请求被同时提交到数据库的话,数据库会保证只有一个操作可以成功,操作成功的那个线程就获得了访问共享资源的锁并进行操作
  • 使用场景
    • 因为数据库需要落到硬盘上,频繁读取数据库会导致IO开销大,因此这种分布式锁适用于并发量低,对性能要求低的场景
  • 缺点
    • 单点故障:一旦数据库不可用,会导致整个系统崩溃
    • 死锁:数据库锁没有失效时间,未获得锁的进程只能一直等待已获得锁的进程主动释 放锁。一旦已获得锁的进程挂掉或者解锁操作失败,会导致锁记录一直存在数据库中,其它进程无法获得锁

基于缓存(单个 Redis 节点)实现分布式锁的原理、优缺点

  • 原理
    • 使用键值对来保存锁变量,再接收和处理不同客户端发送的加锁和释放锁的操作请求
  • 使用单命令
    • 加锁:setnx key value
      • Redis 通常可以使用 setnx(key, value) 来实现分布式锁。key和value就是基于缓存的分布式锁的两个属性,其中key表示锁id,value = currentTime + timeOut,表示当前时间+超时时间。即某个进程获得key这把锁后,如果在value的时间内未释放锁,系统就会主动释放锁
      • Redis通过队列来维持进程访问共享资源的先后顺序。Redis 锁主要基于 setnx 函数实现分布式锁,当进程通过 setnx<key,value> 函数返回 1 时,表示已经获得锁。排在后面的进程只能等待前面的进程主动释放锁,或者等到时间超时才能获得锁
    • 释放锁:del key
    • setnx 函数的返回值有0和1
      • 返回1:说明该服务器获得锁,setnx将key对应的value设置为当前时间+锁的有效时间
      • 返回0:说明其它服务器已经获得了锁,进程不能进入临界区。该服务器可不断尝试setnx操作,以获得锁
    • 风险
      • 1.加入某个客户端加锁后崩溃,锁会一直被持有不释放,解决:给锁变量设置一个过期时间
      • 2.客户端A加锁,但被客户端B释放了,解决:区分来自不同客户端的操作,设置值可以让每个客户端给锁变量设置一个唯一标识,只有当前锁变量值和自己唯一标识相等的情况下才能释放锁
  • 使用单命令+Lua脚本
    • 加锁改进:set lock_key unique_value NX PX 10000
    • 释放锁改进:lua脚本
    // 释放锁 比较unique_value是否相等,避免误释放
    // KEYS[1]表示lock_key,ARGV[1]是当前客户端的唯一标识
    // 在执行Lua脚本时作为参数传入
    if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del",KEYS[1])
    else
        return 0
    end
    
    • 执行命令:redis-cli --eval unlock.script lock_key, unique_value
    • 因为释放锁操作的逻辑包含了读取锁变量、判断值、删除锁变量的多个操作,而 Redis 是以原子性的方式执行 Lua 脚本
  • 优势
    • 性能更好。数据被存放在内存,而不是磁盘,避免了频繁的 IO 操作
    • 很多缓存可以跨集群部署,避免了单点故障
    • 很多缓存服务都提供了可以用来实现分布式锁的方法,比如 Redis 的 setnx 方法等
    • 可以直接设置超时时间来控制锁的释放,因为这些缓存服务器一般支持自动删除过期数据
  • 不足
    • 通过超时时间来控制锁的失效时间,并不是十分靠谱,因为一个进程执行时间可能比较长,或受系统进程做内存回收等影响,导致时间超时,从而不正确地释放了锁

基于多个 Redis 节点实现高可靠的分布式锁,算法和基本思路、步骤

  • 使用分布式锁算法 Redlock
  • 基本思路
    • 让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,就认为客户端成功地获得分布式锁了,否则加锁失败
  • 假设有N个独立的 Redis 实例,Redlock 算法的加锁/释放锁步骤
    • 1.客户端获取当前时间
    • 2.客户端按顺序依次向 N 个 Redis 实例执行加锁操作
      • 和单实例上执行的加锁操作一样,使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识
      • 如果客户端在和一个 Redis 实例请求加锁时,一直到超时都没有成功,那么此时客户端会和下一个 Redis 实例继续请求加锁。注意,加锁操作的超时时间需要远远小于锁的有效时间,一般设几十毫秒
    • 3.一旦客户端完成了和所有 Redis 实例的加锁操作,客户端就要计算整个加锁过程的总耗时
      • 客户端只有在满足下面的这两个条件时,才能认为是加锁成功:
      • 1.客户端从超过半数(大于等于 N/2+1 )的 Redis 实例上成功获取到了锁
      • 2.客户端获取锁的总耗时没有超过锁的有效时间
    • 4.满足了这两个条件后,重新计算这把锁的有效时间,计算的结果是锁的最初有效时间减去客户端为获取锁的总耗时。如果锁的有效时间已经来不及完成共享数据的操作了,可以释放锁,以免出现还没完成数据操作,锁就过期了的情况
    • 如果没能同时满足这两个条件,那么客户端向所有 Redis 节点发起释放锁的操作,释放锁的操作和在单实例上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了

ZooKeeper的树形数据存储结构是由哪4个节点构成的?

  • 持久节点:默认的节点类型,一直存在于ZooKeeper中
  • 持久顺序节点:在创建节点时,ZooKeeper根据节点创建的时间顺序对节点进行编号
  • 临时节点:当客户端与ZooKeeper断开连接后,该进程创建的临时节点就会被删除
  • 临时顺序节点:按时间顺序编号的临时节点

基于 ZooKeeper 实现分布式锁的原理、优缺点

  • 原理
    • ZooKeeper 基于临时顺序节点实现了分布锁
    • 以电商售卖吹风机的场景为例。假设用户 A、B、C 同时在 11 月 11 日的零点整提交了购买吹风机的请求,ZooKeeper 会采用如下方法来实现分布式锁
    • 1.在与该方法对应的持久节点shared_lock的目录下,为每个进程创建一个临时顺序节点。如图,吹风机就是一个拥有share_lock的目录,当有人买吹风机时,会为他创建一个临时顺序节点
    • 2.每个进程获取shared_lock目录下的所有临时节点列表,注册子节点变更的Watcher,并监听节点
    • 3.每个节点确定自己的编号是否是shared_lock下所有子节点中最小的,若最小,则获得锁。例如,用户A的的订单最先到服务器,因此创建了编号为 1 的临时顺序节点 LockNode1。该节点的编号是持久节点目录下最小的,因此获取到分布式锁,可以访问临界资源,从而可以购买吹风机
    • 4.若本进程对应的临时节点编号不是最小的,则分为两种情况:
      • a. 本进程为读请求,如果比自己序号小的节点中有写请求,则等待
      • b. 本进程为写请求,如果比自己序号小的节点中有读请求,则等待
    • 例如,用户 B 也想要买吹风机,但在他之前,用户 C 想看看吹风机的库存量。因此,用户 B 只能等用户 A 买完吹风机、用户 C 查询完库存量后,才能购买吹风机
  • 优势
    • 完美解决设计分布式锁时遇到的各种问题,比如单点故障、不可重入、死锁等问题。ZooKeeper 实现的分布式锁,几乎能涵盖所有分布式锁的特性,且易于实现
  • 劣势
    • 需要频繁地添加和删除节点,性能不如基于缓存实现的分布式锁

实现分布式锁的三种方式对比

  • ZooKeeper 分布式锁的可靠性最高,有封装好的框架,很容易实现分布式锁的功能,并且几乎解决了数据库锁和缓存式锁的不足,因此是实现分布式锁的首选方法

Zookeeper 实现分布式锁的羊群效应、和解决方法

  • 羊群效应
    • 在整个分布式锁的竞争过程中,大量的「Watcher通知」和「子节点列表的获取」操作重复运行,并且大多数节点的运行结果都是判断出自己当前并不是编号最小的节点,继续等待下一次通知,而不是执行业务逻辑
    • 这就会对 ZooKeeper 服务器造成巨大的性能影响和网络冲击。更甚的是,如果同一时间多个节点对应的客户端完成事务或事务中断引起节点消失,ZooKeeper 服务器就会在短时间内向其他客户端发送大量的事件通知
  • 解决方法
    • 1.在与该方法对应的持久节点的目录下,为每个进程创建一个临时顺序节点
    • 2.每个进程获取所有临时节点列表,对比自己的编号是否最小,若最小,则获得锁。
    • 3.若本进程对应的临时节点编号不是最小的,则继续判断
      • 若本进程为读请求,则向比自己序号小的最后一个写请求节点注册watch监听,当监听到该节点释放锁后,则获取锁
      • 若本进程为写请求,则向比自己序号小的最后一个读请求节点注册watch监听,当监听到该节点释放锁后,获取锁

参考

  • 极客时间-Redis核心技术与实战-30讲
  • 极客时间-分布式技术原理与算法解析-7讲
Last Updated:
Contributors: Shiqi Lu