分布式锁的原则
- 互斥性, 一次只能有一个客户端获得锁,
- 不死锁,客户端如果获得锁之后,出现异常,能自动解锁,资源不会被死锁。
- 一致性,redis 因为内部原因,发生了主从切换,锁在切换到新的 master 后依然保持不变。
层次一
代码语言:javascript复制redis.setNX(ctx, key, "1")
defer redis.del(ctx, key)
- 可以保持互斥性,但是没有设置过期时间,不能保证不死锁。
层次二
代码语言:javascript复制redis.setNX(ctx, key, "1",expiration)
defer redis.del(ctx, key)
- 可以使用 lua 脚本保证 setnx 和 expiration 的原子性,可以做到不死锁,但是不能保证一致性。
层次三
代码语言:javascript复制redis.SetNX(ctx, key, randomValue, expiration)
defer redis.del(ctx, key, randomValue)
// 以下为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脚本可以精简为以下代码
// 公司的cas/cad命令见 扩展命令 - CAS/CAD/XDECRBY Extended commands - CAS/CAD/XDECRBY
redis.cad(ctx, key, randomValue)
获得锁和删除锁是一个协程,避免程序运行时间长时删除别的协程的锁,做到一定程度的一致性。
层次四
代码语言:javascript复制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
}
解决了超时且成功的问题,写入超时且成功是偶现的,灾难性的问题。
还存在的问题:
- 单点问题,单 master 有问题,如果主从复制,主从复制有问题,也存在问题。
- 锁过期后没有完成流程怎么办?
层次五
启动定时器,锁过期,但是还没完成流程时,续租,只能续当前协程抢占的锁。
代码语言:javascript复制// 以下为续租的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 发生主从切换,主从切换如果数据丢失,并且丢失的数据和分布式锁有关系,会导致锁机制出现问题,引起业务异常。
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,因为锁只要存在就能保持互斥,延迟删除不会导致逻辑问题。