CategoryResourceRepost/极客时间专栏/geek/左耳听风/管理设计/52 | 管理设计篇之“分布式锁”.md
louzefeng bf99793fd0 del
2024-07-09 18:38:56 +00:00

14 KiB
Raw Blame History

我们知道在多线程情况下访问一些共享资源需要加锁不然就会出现数据被写乱的问题。在分布式系统下这样的问题也是一样的。只不过我们需要一个分布式的锁服务。对于分布式的锁服务一般可以用数据库DB、Redis和ZooKeeper等实现。不管怎么样分布式的锁服务需要有以下几个特点。

  • **安全性Safety**:在任意时刻,只有一个客户端可以获得锁(**排他性**)。
  • **避免死锁**:客户端最终一定可以获得锁,即使锁住某个资源的客户端在释放锁之前崩溃或者网络不可达。
  • **容错性**只要锁服务集群中的大部分节点存活Client就可以进行加锁解锁操作。
  • Redis的分布式锁服务

    这里提一下避免死锁的问题。下面以Redis的锁服务为例参考 Redis的官方文档 )。

    我们通过以下命令对资源加锁。

    SET resource_name my_random_value NX PX 30000
    
    

    解释一下:

  • `SET NX` 命令只会在 `key` 不存在的时候给 `key` 赋值,`PX` 命令通知Redis保存这个key 30000ms。
  • `my_random_value` 必须是全局唯一的值。这个随机数在释放锁时保证释放锁操作的安全性。
  • PX 操作后面的参数代表的是这个key的存活时间称作锁过期时间。
  • 当资源被锁定超过这个时间时,锁将自动释放。
  • 获得锁的客户端如果没有在这个时间窗口内完成操作,就可能会有其他客户端获得锁,引起争用问题。
  • 这里的原理是只有在某个key不存在的情况下才能设置set成功该key。于是这就可以让多个进程并发去设置同一个key只有一个进程能设置成功。而其它的进程因为之前有人把key设置成功了而导致失败也就是获得锁失败

    我们通过下面的脚本为申请成功的锁解锁:

    if redis.call("get",KEYS[1]) == ARGV[1] then 
        return redis.call("del",KEYS[1]) 
    else 
        return 0 
    end
    
    

    如果key对应的value一致则删除这个key。

    通过这个方式释放锁是为了避免Client释放了其他Client申请的锁。

    例如下面的例子演示了不区分Client会出现的一种问题。

    1. Client A 获得了一个锁。
    2. 当尝试释放锁的请求发送给Redis时被阻塞没有及时到达Redis。
    3. 锁定时间超时Redis认为锁的租约到期释放了这个锁。
    4. Client B 重新申请到了这个锁。
    5. Client A的解锁请求到达将Client B锁定的key解锁。
    6. Client C 也获得了锁。
    7. Client B 和Client C 同时持有锁。

    通过执行上面脚本的方式释放锁Client的解锁操作只会解锁自己曾经加锁的资源所以是安全的。

    关于value的生成官方推荐从 /dev/urandom中取20个byte作为随机数。或者采用更加简单的方式例如使用RC4加密算法在 /dev/urandom中得到一个种子Seed然后生成一个伪随机流。

    也可以采用更简单的方法,使用时间戳+客户端编号的方式生成随机数。Redis的官方文档说“这种方式的安全性较差一些但对于绝大多数的场景来说已经足够安全了”。

    分布式锁服务的一个问题

    注意虽然Redis文档里说他们的分布式锁是没有问题的但其实还是很有问题的。尤其是上面那个为了避免Client端把锁占住不释放然后Redis在超时后把其释放掉。不知道你怎么想但我觉得这事儿听起来就有点不靠谱。

    我们来脑补一下,不难发现下面这个案例。

  • 如果Client A先取得了锁。
  • 其它Client比如说Client B在等待Client A的工作完成。
  • 这个时候如果Client A被挂在了某些事上比如一个外部的阻塞调用或是CPU被别的进程吃满或是不巧碰上了Full GC导致Client A 花了超过平时几倍的时间。
  • 然后,我们的锁服务因为怕死锁,就在一定时间后,把锁给释放掉了。
  • 此时Client B获得了锁并更新了资源。
  • 这个时候Client A服务缓过来了然后也去更新了资源。于是乎把Client B的更新给冲掉了。
  • 这就造成了数据出错。
  • 这听起来挺严重的吧。我画了个图示例一下。

    千万不要以为这是脑补出来的案例。其实这个是真实案例。HBase就曾经遇到过这样的问题你可以在他们的PPTHBase and HDFS: Understanding FileSystem Usage in HBase)中看到相关的描述。

    要解决这个问题你需要引入fence栅栏技术。一般来说这就是乐观锁机制需要一个版本号排它。我们的流程就变成了下图中的这个样子。

    我们从图中可以看到:

    • 锁服务需要有一个单调递增的版本号。
    • 写数据的时候,也需要带上自己的版本号。
    • 数据库服务需要保存数据的版本号,然后对请求做检查。

    如果使用ZooKeeper做锁服务的话那么可以使用 zxid 或 znode的版本号来做这个fence 版本号。

    从乐观锁到CAS

    但是,我们想想,如果数据库中也保留着版本号,那么完全可以用数据库来做这个锁服务,不就更方便了吗?下面的图展示了这个过程。

    使用数据版本Version记录机制即为数据增加一个版本标识一般是通过为数据库表增加一个数字类型的 “version” 字段来实现的。当读取数据时将version字段的值一同读出数据每更新一次对此version值加一。

    当我们提交更新的时候数据库表对应记录的当前版本信息与第一次取出来的version值进行比对。如果数据库表当前版本号与第一次取出来的version值相等则予以更新否则认为是过期数据。更新语句写成SQL大概是下面这个样子

    UPDATE table_name SET xxx = #{xxx}, version=version+1 where version =#{version};
    
    

    这不就是乐观锁吗?是的,这是乐观锁最常用的一种实现方式。是的如果我们使用版本号或是fence token这种方式就不需要使用分布式锁服务了。

    另外多说一下。这种fence token的玩法在数据库那边一般会用timestamp时间截来玩。也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比如果一致则OK否则就是版本冲突。

    还有我们有时候都不需要增加额外的版本字段或是fence token。比如如果想更新库存我们可以这样操作

    SELECT stock FROM tb_product where product_id=#{product_id};
    UPDATE tb_product SET stock=stock-#{num} WHERE product_id=#{product_id} AND stock=#{stock};
    
    

    先把库存数量stock查出来然后在更新的时候检查一下是否是上次读出来的库存。如果不是说明有别人更新过了我的UPDATE操作就会失败得重新再来。

    细心的你一定发现了这不就是计算机汇编指令中的原子操作CASCompare And Swap大量无锁的数据结构都需要用到这个。关于CAS的话题你可以看一下我在CoolShell上写的无锁队列的实现 )。

    我们一步一步地从分布式锁服务到乐观锁再到CAS你看到了什么你是否得思考一个有趣的问题——我们还需要分布式锁服务吗

    分布式锁设计的重点

    最后,我们来谈谈分布式锁设计的重点。

    一般情况下我们可以使用数据库、Redis或ZooKeeper来做分布式锁服务这几种方式都可以用于实现分布式锁。

    分布式锁的特点是,保证在一个集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。这就是所谓的分布式互斥。所以,大家在做某个事的时候,要去一个服务上请求一个标识。如果请求到了,我们就可以操作,操作完后,把这个标识还回去,这样别的进程就可以请求到了。

    首先,我们需要明确一下分布式锁服务的初衷和几个概念性的问题。

  • 如果获得锁的进程挂掉了怎么办?锁还不回来了,会导致死锁。一般的处理方法是在锁服务那边加上一个过期时间,如果在这个时间内锁没有被还回来,那么锁服务要自动解锁,以避免全部锁住。
  • 如果锁服务自动解锁了,新的进程就拿到锁了,但之前的进程以为自己还有锁,那么就出现了两个进程拿到了同一个锁的问题,它们在更新数据的时候就会产生问题。对于这个问题,我想说:
  • 像Redis那样也可以使用Check and Set的方式来保证数据的一致性。这就有点像计算机原子指令CASCompare And Swap一样。就是说我在改变一个值的时候先检查一下是不是我之前读出来的值这样来保证其间没有人改过。
  • 如果通过像CAS这样的操作的话我们还需要分布式锁服务吗的确是不需要了不是吗
  • 但现实生活中也有不需要更新某个数据的场景只是为了同步或是互斥一下不同机器上的线程这时候像Redis这样的分布式锁服务就有意义了。
  • 所以需要分清楚我是用来修改某个共享源的还是用来不同进程间的同步或是互斥的。如果使用CAS这样的方式无锁方式来更新数据那么我们是不需要使用分布式锁服务的而后者可能是需要的。所以,这是我们在决定使用分布式锁服务前需要考虑的第一个问题——我们是否需要?

    如果确定要分布式锁服务,你需要考虑下面几个设计。

  • 需要给一个锁被释放的方式以避免请求者不把锁还回来导致死锁的问题。Redis使用超时时间ZooKeeper可以依靠自身的sessionTimeout来删除节点。
  • 分布式锁服务应该是高可用的,而且是需要持久化的。对此,你可以看一下 [Redis的文档RedLock](https://redis.io/topics/distlock) 看看它是怎么做到高可用的。
  • 要提供非阻塞方式的锁服务。
  • 还要考虑锁的可重入性。
  • 我认为Redis也是不错的ZooKeeper在使用起来需要有一些变通的方式好在Apache有 Curator 帮我们封装了各种分布式锁的玩法。

    小结

    好了,我们来总结一下今天分享的主要内容。首先,我介绍了为什么需要分布式锁。就像单机系统上的多线程程序需要用操作系统锁或数据库锁来互斥对共享资源的访问一样,分布式程序也需要通过分布式锁来互斥对共享资源的访问。

    分布式锁服务一般可以通过Redis和ZooKeeper等实现。接着以Redis为例我介绍了怎样用它来加锁和解锁由此引出了锁超时后的潜在风险。我们看到类似于数据库的乐观并发控制这种风险可以通过版本号的方式来解决。

    进一步数据库如果本身利用CAS等手段支持这种版本控制方式其实也就没必要用一个独立的分布式锁服务了。最后我们发现分布式锁服务还能用来做同步这是数据库锁做不了的事情。下篇文章中我们将聊聊配置中心相关的技术希望对你有帮助。

    也欢迎你分享一下你在留言区给我分享下哪些场景下你会用到锁你都用哪种平台的锁服务有没有用到数据库锁是OCC还是悲观锁如果是悲观锁的话你又是怎样避免死锁的

    我在这里给出了《分布式系统设计模式》系列文章的目录,希望你能在这个列表里找到自己感兴趣的内容。

  • 弹力设计篇