分布式锁设计原则

  • 互斥性 在任意时刻,只有一个客户端持有锁。
  • 不死锁 分布式锁本质上是一个基于租约(Lease)的租借锁,如果客户端获得锁后自身出现异常,锁能够在一段时间后自动释放,资源不会被锁死。
  • 一致性 硬件故障或网络异常等外部问题,以及慢查询、自身缺陷等内部因素都可能导致Redis发生高可用切换,replica提升为新的master。此时,如果业务对互斥性的要求非常高,锁需要在切换到新的master后保持原状态。

层次一:使用setnx命令,执行结束之后释放锁

redis.SetNX(ctx, key, "1")
defer redis.del(ctx, key)

可以解决互斥性的问题,但不能做到不死锁:如果抛出异常了,没有释放锁,导致锁一直无法释放。

层次二:添加过期时间

redis.SetNX(ctx, key, "1", expiration)
defer redis.del(ctx, key)

为了避免锁一直不释放的情况,添加一个过期时间。依旧存在问题:

  • 如果expiration设置得太大,程序中途退出之后,锁迟迟无法释放
  • 如果expiration设置得太小,程序还没执行完,锁就已经释放了

注意,这里不能写成两句话,因为两句话无法保证原子性。在Redis中,只有lua能够保证多条语句的原子性。

setnx(key,..)
expire(key,...)

层次三

使用lua脚本保证SetNX与Expire的原子性,做到了不死锁,但是做不到一致性

redis.SetNX(ctx, key, randomValue, expiration)
defer redis.del(ctx, key, randomValue)

以下为与之配套的lua脚本

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

如果redis-v6包已经支持cad,因而lua脚本可以精简为以下代码 redis.cad(ctx, key, randomValue)

只删除当前线程/协程抢到的锁,避免在程序运行过慢锁过期时删除别的线程/协程的锁,能做到一定程度的一致性

层次四

func myFunc() (errCode *constant.ErrorCode) {
errCode := DistributedLock(ctx, key, randomValue, LockTime)
    defer DelDistributedLock(ctx, key, randomValue)
    if errCode != nil {
        return errCode
    }
    // doSomeThing
}

// 注意,以下代码还不能用cas优化,因为公司的redis-v6还不支持oldvalue是nil
func DistributedLock(ctx context.Context, key, value string, expiration time.Duration) (errCode *constant.ErrorCode) {

    ok, err := redis.SetNX(ctx, key, value, expiration)
    if err == nil {
        if !ok {
            return constant.ERR_MISSION_GOT_LOCK
        }
        return nil
    }

    // 应对超时且成功场景,先get一下看看情况
    time.Sleep(DistributedRetryTime)
    v, err := redis.Get(ctx, key)
    if err != nil {
    return constant.ERR_CACHE
}
if v == value {
// 说明超时且成功
    return nil
} else if v != "" {
// 说明被别人抢了
return constant.ERR_MISSION_GOT_LOCK
}

// 说明锁还没被别人抢,那就再抢一次
ok, err = redis.SetNX(ctx, key, value, expiration)
if err != nil {
return constant.ERR_CACHE
}
if !ok {
return constant.ERR_MISSION_GOT_LOCK
}
return nil
}

// 以下为del的lua脚本
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end

// 在字节跳动,公司的redis-v6包已经支持cad,因而lua脚本可以精简为以下代码
// redis.cad(ctx, key, randomValue)

func DelDistributedLock(ctx context.Context, key, value string) (errCode *constant.ErrorCode) {
v, err := redis.Cad(ctx, key, value)
if err != nil {
return constant.ERR_CACHE
}
return nil
}

解决超时且成功的问题,写入超时且成功是偶现的、灾难性的经典问题。 还存在的问题是: 1)单点问题,单master有问题,如果有主从,那主从复制过程有问题时,也存在问题 2)锁过期然后没完成流程怎么办

层次五

启动定时器,在锁过期却没完成流程时续租,只能续租当前线程/协程抢占的锁 // 以下为续租的lua脚本,实现CAS(compare and set) if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("expire",KEYS[1], ARGV[2]) else return 0 end

// 在字节跳动,公司的redis-v6包已经支持cas,因而lua脚本可以精简为以下代码 redis.Cas(ctx, key, value, value)

能保障锁过期的一致性,但是解决不了单点问题 同时,可以发散思考一下,如果续租的方法失败怎么办?我们如何解决“为了保证高可用而使用的高可用方法的高可用问题”这种套娃问题?开源类库Redisson使用了看门狗的方式一定程度上解决了锁续租的问题,但是这里,个人建议不要做锁续租,更简洁优雅的方式是延长过期时间,由于我们分布式锁锁住代码块的最大执行时长是可控的(依赖于RPC、DB、中间件等调用都设定超时时间),因而我们可以把超时时间设得大于最大执行时长即可简洁优雅地保障锁过期的一致性 层次六 Redis的主从同步(replication)是异步进行的,如果向master发送请求修改了数据后master突然出现异常,发生高可用切换,缓冲区的数据可能无法同步到新的master(原replica)上,导致数据不一致。如果丢失的数据跟分布式锁有关,则会导致锁的机制出现问题,从而引起业务异常。针对这个问题介绍两种解法:

  • 使用红锁(RedLock)红锁是Redis作者提出的一致性解决方案。红锁的本质是一个概率问题:如果一个主从架构的Redis在高可用切换期间丢失锁的概率是k%,那么相互独立的N个Redis同时丢失锁的概率是多少?如果用红锁来实现分布式锁,那么丢锁的概率是(k%)^N。鉴于Redis极高的稳定性,此时的概率已经完全能满足产品的需求。
    • 红锁的问题在于:
      • 加锁和解锁的延迟较大。
      • 难以在集群版或者标准版(主从架构)的Redis实例中实现。
      • 占用的资源过多,为了实现红锁,需要创建多个互不相关的云Redis实例或者自建Redis。
  • 使用WAIT命令。Redis的WAIT命令会阻塞当前客户端,直到这条命令之前的所有写入命令都成功从master同步到指定数量的replica,命令中可以设置单位为毫秒的等待超时时间。客户端在加锁后会等待数据成功同步到replica才继续进行其它操作。执行WAIT命令后如果返回结果是1则表示同步成功,无需担心数据不一致。相比红锁,这种实现方法极大地降低了成本。
    • 需要注意的是:
      • WAIT只会阻塞发送它的客户端,不影响其它客户端。
      • WAIT返回正确的值表示设置的锁成功同步到了replica,但如果在正常返回前发生高可用切换,数据还是可能丢失,此时WAIT只能用来提示同步可能失败,无法保证数据不丢失。您可以在WAIT返回异常值后重新加锁或者进行数据校验。
      • 解锁不一定需要使用WAIT,因为锁只要存在就能保持互斥,延迟删除不会导致逻辑问题。