ReplicaLock:金毛突发奇想的安全分布式锁策略!


ReplicaLock:金毛突发奇想的安全分布式锁策略!

1. 前言:

  普通的 Redis 分布式锁即单节点加锁,以及 Redis 作者 Antirez 发明的 RedLock 都存在一种丢失锁的情况,也就是由于主从复制是异步的,所以到一个主节点加锁成功后,由于主节点未完成同步而宕机,触发故障转移后从节点变为主节点,此时从节点并没有之前加过的锁,这样就导致了锁丢失,进而有另外一个用户能够与前一个加锁成功的用户同时持有锁的风险。如果去网上搜索 “Redis 主节点宕机丢失锁的解决方案”,他们大多都是说用 Redlock,然而 Redlock 并不能解决这个问题。假设有3台主节点 A1 A2 A3,客户端1使用 Redlock 给 A1,A2设置上了锁,A3 因为网络原因没有收到,但是由于超过半数节点加锁所以客户端1认为获取到了锁。之后 A2 还未来得及主从复制就宕机了,触发故障转移后从节点上来当主节点 A2,这时客户端2去加锁,会发现 A2,A3 都能加上锁,则因为超过半数节点加锁成功,客户端2还是认为自己获取到了锁,此时就存在了客户端1和客户端2同时持有锁的情况。

  还有一种经典情况是持久化策略导致丢失锁,一般 AOF 持久化设置为1s同步一次,从而能够保证高性能,但如果未完成持久化后宕机重启也会丢失锁,不过它已经有了解决方案,第一个是把 AOF 持久化改成一有更新命令就同步,但是这个策略我并不推荐,因为它会失去 Redis 的高性能,而且还是有风险存在的,就算一有更新数据就写入 AOF,由于更多潜在的因素(比如硬件)并不能保证真的能够成功写入。第二个方案是延迟到锁过期时间之后再重启,虽然会造成一段时间内无法提供服务,但是它更加安全且能继续保持高性能,比起第一个方案我更推荐这一个。

  而对于主节点宕机前未完成主从复制的情况,我认为通用的解决方案是让替换上来的从节点延迟到锁过期时间之后再提供服务。但是如果我们不想要这个节点暂停服务一段时间,而是故障转移后马上提供服务,那么 ReplicaLock 就是适用于这种情况的分布式锁策略。

  首先我要说明 ReplicaLock 是一种思想,不止是可以使用 Redis 来实现,使用 Redis 来实现也不以我当前实现的版本为准,我相信会有更好的实现方法。

  金毛用 Golang + Redis 实现的 ReplicaLock 仓库:https://github.com/ncghost1/Redis-ReplicaLock


2. ReplicaLock 构想:

  ReplicaLock 与 Redlock 一样,是一种多节点加锁策略,但不同的是 ReplicaLock 只用发出一次设置锁的命令。Redlock 是在一定时间内超半数以上节点有锁后才认为加锁成功,而 ReplicaLock 的思想是在一定时间内等待从节点完成同步后才认为加锁成功。

  那么 ReplicaLock 要等待多少从节点完成同步后才认为加锁成功呢?这个我认为是根据情况自定义的一部分。比如可以所有从节点完成同步后才认为加锁成功,这样是最安全的,但是性能也是最不好的,部分从节点可能会由于网络延迟或者正在执行慢查询导致阻塞的原因,而无法在一定时间内和主节点完成同步,加锁成功率将会下降不少。况且我们并不需要真的等待所有从节点完成同步,我个人认为有2个从节点能完成同步就足够了,加上主节点有3个节点,同时宕机的概率微乎其微。

  但是部分从节点同步完成就认为加锁成功的话,我认为想要达成这样的实现是复杂的。因为为了安全,我们需要知道是哪些从节点完成了同步,同时还需要一个配套监控节点负责监测宕机情况并执行故障转移,我们还需要配套的故障转移策略。即让监控节点知道是哪些从节点完成了同步,故障转移时将优先转移到这些完成同步的从节点,这样可以确保锁不丢失。而如果这些同步的从节点都宕机了,不得不把未完成同步的从节点换成主节点的话,这个监控节点应该还要智能地采用延迟提供服务的方法来保障安全性。

  我个人认为哈,在多节点加锁方案来讲 ReplicaLock 是比 Redlock 更胜一筹的。因为 ReplicaLock 能够很好地(说99.99%应该没人反对叭?🤣)解决了 Redlock 未能解决的由于异步复制未完成导致故障转移后丢失锁的情况,同时 ReplicaLock 可以根据对性能以及安全性的需求,自定义地去调整认为加锁成功时从节点应完成同步的数量。而且从节点数量一般来说是比较少的,我认为即使要求所有从节点完成同步,在网络和节点负载相同的情况下,一般 ReplicaLock 还是要比 Redlock 加锁更快的。


3. 金毛用 Redis 实现的 ReplicaLock:

  目前由于 Redis 自身限制的原因,使用 Redis 实现的 ReplicaLock 效果并不是很好,当前只能等待所有从节点完成同步才能认为加锁成功。以下是加锁成功的一个比较抽象的图示:

  实际上当前的实现加锁需要3个 RTT(有点慢啊!),因为 ROLE 和 WAIT 命令不能写在 lua 脚本中执行。第一个 RTT 是发送 ROLE 命令,我们当前是通过解析 ROLE 命令返回的从节点信息数组长度来获取从节点数量。第二个 RTT 则是发送加锁 lua 脚本。第三个 RTT 是发送 WAIT 命令,获取所指定的时间内完成同步的从节点数量,最后用返回的数量与所有从节点数量去比较判断是否所有从节点完成了同步,如果相等则可以认为加锁成功。

  在加锁和释放锁的时候用的是 RedissonLock 的 lua 脚本,并且注意我们的锁是可重入锁。下面是加锁核心函数 “tryLockInner”:

// ReplicaLock 加锁核心函数
func (RepLock *ReplicaLock) tryLockInner(waitTime int64, leaseTime int64, lockKeyName string) (interface{}, error) {
    // 获取从节点数量(使用 ROLE 命令)
    NumReplicas, err := getNumReplicas(RepLock)
    if err != nil {
        return -1, err
    }
    // 锁期限(锁超时时间)
    internalLeaseTime = leaseTime

    // 加锁 lua 脚本,使用 hincrby 命令实现可重入锁
    res, err := RepLock.conn.Do("eval", "if (redis.call('exists', KEYS[1]) == 0) then "+
        "redis.call('hincrby', KEYS[1], ARGV[2], 1); "+
        "redis.call('pexpire', KEYS[1], ARGV[1]); "+
        "return nil; "+
        "end; "+
        "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then "+
        "redis.call('hincrby', KEYS[1], ARGV[2], 1); "+
        "redis.call('pexpire', KEYS[1], ARGV[1]); "+
        "return nil; "+
        "end; "+
        "return redis.call('pttl', KEYS[1]);", 1, getRawName(RepLock), internalLeaseTime, lockKeyName)
    if err != nil {
        return -1, err
    }

    // 使用 wait 命令在 waitTime(单位ms) 内等待从节点同步,会返回成功同步的数量
    replyNum, err := waitForReplicas(RepLock, NumReplicas, waitTime)

    // wait 命令发生错误时解锁并返回
    if err != nil {
        err = RepLock.unlockInner()
        if err != nil {
            return -1, err
        }
        return -1, err
    }

    // 全部从节点未能都完成同步,解锁并返回
    if replyNum != NumReplicas {
        err = RepLock.unlockInner()
        if err != nil {
            return -1, err
        }
        return -1, nil
    }

    // 如果开启了锁续期,则开启一个 goroutine 负责异步进行锁续期。
    if renewExpirationOption == true {
        go RepLock.renewExpiration(lockKeyName)
    }
    return res, nil
}

  当前的实现还有个缺陷,并没有计算 wait 命令耗时了多久,因为我们实际的锁可用时间还要减去 wait 命令等待的时间。但我个人认为这不是一个很重要的缺陷,只要 waitTime 不要调到超过锁期限的2/3,打开了异步锁续期并不用担心进程还在使用资源时锁超时的。

  这里只展示加锁核心代码,若想查看更多关于我的简陋实现的话,还请到 github 仓库查看,代码行数并不是很多。🍭🍭


4. 对 ReplicaLock 的展望

  首先说说 ReplicaLock 的命名由来,因为加锁条件主要是和从节点(Replica)同步有关,所以我就拿 ReplicaLock 来命名了嘻嘻。

  ReplicaLock 我认为 Redis 暂时还无法很好的进行实现,这是一款对技术要求较高的安全分布式锁策略,最好的使用并不是等待所有节点完成同步,而是部分同步,这就需要实现的 ReplicaLock 可以对加锁成功的从节点数量进行调整。并且需要有监控节点,优先故障转移至完成同步的从节点。实际 ReplicaLock 应用时我认为还会遇到更多潜在的问题,比如也许为了减少主节点进行主从复制的压力,采用了多主架构,从节点作为其它从节点的主节点发起主从复制,这种情况也是需要能做到可以监控到的,然而当前 Redis 并不能做到。能有实力实现这款 ReplicaLock 的,我认为还是云服务商,也许有一天 ReplicaLock 成熟了,云服务商可以把这款分布式锁当作一个卖点也说不定呢🍭🍭


文章作者: 金毛败犬
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 金毛败犬 !
评论
  目录