分布式锁的实现方式

目录标题

    • 什么是分布式锁
    • 基于MySQL数据库表实现
      • 问题
    • 基于数据库排他锁实现
    • 用zookeeper实现分布式锁
    • 基于Redis的SETNX实现
    • 使用etcd实现分布式锁
    • 分布式锁的选择

什么是分布式锁

分布式锁是指分布式应用各节点对共享资源的排他式访问而设定的锁。分布式CAP理论:任何一个分布式系统都不无法同时满足一致性、可用性和分区容错性,最多执行同时满足两项。在互联网领域绝大数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致性,只要这个最终时间是在用户可以接受的范围内容即可。

基于MySQL数据库表实现

通过MySQL实现分布式锁,最简单的方式可能就是直接创建一种锁表,然后通过操作该表中的数据来实现了。当锁住某个方法或者资源时,就在该表中增加一条记录,如果想要释放锁的时候就删除这条记录。

问题

  • 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库出故障,会导致业务系统不可用

解决方案:可以建立两个数据库,数据之间双向同步,一旦出故障快速切换到备用库上

  • 这把锁没有失效时间,一旦解锁操作失败,锁记录就会一直在数据库中,导致其他线程无法再获得锁

解决方案:可以做一个定时任务,每隔一段时间把数据库中的超时数据清理一遍即可

  • 这把锁只能是非阻塞的,因为数据的插入操作一旦失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想获得锁就要再次触发获得锁操作

解决方案:加一个while循环,直到insert语句成功再返回

  • 这把锁是非重入的,同一个线程在没有释放锁之前无法再再次获得该锁,因为数据已经存在了

解决方案:在数据库表中加个字段,记录当前获取锁机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给它就可以了

基于数据库排他锁实现

在查询语言后面增加“FOR UPDATE”,数据库会在查询过程中给数据库表增加排他锁。当某条记录被加上排他锁之后,其他线程无法再在该记录上增加排他锁。获得排他锁的线程即可获得分布式锁,当获取锁之后,可以执行方法的业务逻辑,执行完方法后,通过Commit()提交事务操作来释放锁。此方法可以有效得解决上面提到的无法释放锁和阻塞锁的问题。FOR UPDATE 语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,知道成功。针对锁定之后服务宕机,无法释放问题,可以使用这种方式服务宕机之后数据库会自己把锁释放掉。但是还是无法直接 解决数据库单点和可重入问题。

用zookeeper实现分布式锁

zookeeper是Apache软件基金会的一个软件项目,它为大型分布式计算提供开源的分布式配置服务、同步服务和命令注册。zookeeper的架构通过冗余服务实现高可用性。zookeeper是一种用于协调的服务分布式应用程序。zookeeper是一个分布式应用提供一致性服务的开源组件,它内部一个分层的文件系统目录树结构,它规定同一个目录下只能有一个唯一文件名。基于zookeeper实现分布式锁步骤如下:

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

Apache的开源库Curator,它是一个zookeeper客户端,Curator提供的Inter-ProcessMutex是分布式锁的实现,其中,acquire()方法用于获取锁,release()方法用于释放锁。

基于zookeeper的锁与基于Redis的锁不同之处在于Lock成功之前会一直阻塞,这与单机场景中的mutex.Lock很相似。其原理也是基于临时Sequence节点和watch API,例如这里使用的是“/lock”节点。Lock会在该节点下的节点列表插入自己的值,只要节点下的子节点发生变化,就会通知所有监听该节点的程序。这时程序会检查当前节点下最小子节点的id是否与自己一致。如果一致,说明加锁成功。

这种分布式的阻塞锁比较适合分布式任务调度场景,但不适合高频次,持锁时间短的抢锁场景。基于强一致协议的锁适用于粗粒度的加锁操作。这里的粗粒度是指锁占用时间较长。

zookeeper的优点是具备高可用,可重入,阻塞锁特性,可解决失效死锁问题。zookeeper缺点:因为需要频繁地创建和删除节点,性能上不如Redis方式。

基于Redis的SETNX实现

Redis官方提供了一个名为RedLock的分布式锁算法来实现分布式锁。Redlock算法是Antirez(Redis作者)在单Redis节点基础上引入的高可用模式。在Redis的分布式环境中,假设有N个完全互相独立的Redis节点,有N个Redis实例上使用与在Redis单实例下相同方法获取锁和释放锁。现在假设有N个Redis主节点(大于3的奇数个),这样基本保证他们不会同时都宕掉。在获取锁和释放锁的过程中,客户端会执行以下操作:

  • 获取当前Unix时间,以ms为单位

  • 依次尝试从N个实例中,使用相同的key和具有唯一性的value获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间,这样可以避免客户端一直等待

  • 客户端使用当前时间减去开始获取锁时间就得到获取锁使用时间。而且仅当从半数以上的Redis节点取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功

  • 如果取到了锁,则key的实际有效时间等于有效时间减去获取锁所使用的时间

  • 如果因为某些原因,获取锁失败(没有在半数以上的实例取到锁或者取锁时间已经超过了有效时间),则客户端应该在所有的Redis实例上进行解锁,无论Redis实例是否加锁成功。因为可以服务端响应消息丢失了但是实际成功了,毕竟多释放一次也不会有问题。

    上面5个步骤是RedLock算法的主要过程,这种分布式锁有三个重要的考量点:

    1. 互斥,只能有一个客户端获取锁
    2. 不能死锁
    3. 容错,只要大部分Redis节点创建了这把锁就可以

    实现分布式锁的另一种方式就是通过Redis等缓存系统实现。使用Redis实现分布式锁,根本原理是使用SETNX指令:

    SETNX key value

    如果key不存在,则设置key的值为value,如果key已经存在,则不执行赋值操作,并使用不同的返回值标识。

    • 使用 SETNX + DELETE命令实现,通过SETNX设置一个随机值,然后删除这个随机值。
    SETNX lock_key random_value
    // 逻辑处理
    DELETE lock_key
    // 问题:服务获取锁后,因为某种原因出现故障,则锁一直无法自动释放,从而导致死锁
    
    • 使用 SETNX + SETEX命令实现,通过SETNX设置一个随机值,然后通过SETEX设置超时时间,最后删除随机值
    SETNX lock_key random_value
    SETEX lock_key 5 random_value // 5s超时
    // 逻辑处理
    DELETE lock_key
    // 问题:在 SETNX之后,SETEX之前服务出故障,会陷入死锁
    
    • 使用 SET···NX+PX命令实现,将加锁、设置超时两个步骤合并为一个原子操作
    SET lock_key random_value NX PX 5000 // 5s超时
    // 逻辑处理
    DELETE lock_key
    // 问题:如果锁被错误地释放(如超时),或被错误地抢占,或因Redis问题等导致锁丢失,则无法很快地感知到
    
    • 使用 SET Key RandomValue NX PX 命令实现,此方案比上一个方案增加了对value的检查,只解除自己加的锁,类似于CAS(Compare And Swap,比较并交换),是一种原子操作。可用于在多线程编程中实现不被打断的数据交换操作,从而避免多线程同时改写某一数据时由于执行顺序不确定行以及中断的不可预知性产生的数据不一致问题。该操作通过将内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值。此处不过是先比较后删除,此方案Redis原生命令不支持,为保证原子性,需要使用Lua脚本实现,伪代码如下:
    SET lock_key random_value NX PX 5000 // 5s超时
    // 逻辑处理
    eval "ifredis.call('get',KEYS[1])==ARGV[1]then return redis.call('del',KEYS[1]) 
    else return 0 end 1" lock_key random_value
    // 此方案更加严谨,即使因为某些异常导致锁被错误地抢占,也能部分保证锁的正确释放。并且在释放锁时能检测到锁是否被错误抢占,错误释放,从而进行特殊处理。
    

    注意事项:

    • 超时时间
    1. 不能太短,否则在任务执行完成前就自动释放锁了,导致资源暴露在锁保护之外
    2. 不能太长,否则会导致以意外死锁后时间的等待,除非人为介入处理
    3. 建议根据任务内容,合理衡量超时时间,将超时时间设置为任务内容的几倍即可。如果实在无法确定而又要求比较严格,可以采用SETEX/Expire定期更新超时时间实现
    • 重试:等待次数需要参考任务执行时间

    • 与Redis事务比较,SETNX使用更为灵活方便。Multi/Exec事务的实现形式更为复杂,且部分Redis集群方案不支持Multi/Exec事务

    使用etcd实现分布式锁

    etcd是使用go语言开发的一个开源的,高可用的分布式key-value存储系统,可以用于配置共享和服务的注册和实现。etcd使用Raft算法保持了数据的 强一致性,每次操作存储到集群中的值必然是全局一致性,很容易实现分布式锁。锁服务有“保持独占” “控制时序” 两种使用方式。

    “保持独占”即所有获取锁的用户最终只有一个可以得到。etcd为此提供了一套实现分布式锁原子操作CAS(Compare AND Swap,比较并交换)的API。通过设置prevExist值,可以保证在多个节点同时去创建某个目录时,只有一个成功,而成功创建的用户就可以认为是获得了锁。

    “控制时序” 是指所有想要获得锁的用户都会被安排执行,但是获得锁的顺序也是全局唯一的,同时决定了执行顺序。etcd为此也提供了一套自动创建有序键的API接口,对一个目录建值时指定为POST动作,这样etcd会自动在目录下生成一个当前最大的值为键,存储这个新的值(客户端编号)。同时还可以使用API接口按顺序列出所有当前目录下的键值。此时这些键的值就是客户端的时序,也可以是代表客户端的编号。

    etcd分布式锁实现原理总结如下:

    • 利用租约在etcd集群中创建一个key,这个key有两种形态,存在和不存在,而这两种形态就是互斥量
    • 如果key不存在,则线程创建key,成功则获取到锁,该key为存在状态
    • 如果key已经存在,则线程就不能创建key,获取锁失败

    分布式锁的选择

    实现方式 功能要求 实现难度 学习程度 运维成本
    MySQL借助表锁/行锁实现 满足基本要求 不难 熟悉 一般 小量可以使用;大量影响现有业务。主多从架构,不方便扩容
    通过zookeeper的方式实现 满足要求 要求熟悉zookeeper API 需要学习 较高,需要队机器,有跨机房请求
    Redis使用SET NX PX 满足基本要求 不难 熟悉 一般,扩容方便,方便使用现有服务
    通过etcd实现 满足要求 较易 熟悉 较高,不能增加节点来提高其性能

    对锁数据的可靠性要求极高的话,那只能使用etcd或者zookeeper这种通过一致性协议保证数据可靠性的方案,但吞吐量低和较高的延迟。

你可能感兴趣的:(分布式)