如何通过redis实现分布式锁

分布式锁

介绍

分布式锁是在分布式环境下,保持数据一致性的一种方案。

例如,抽奖的业务逻辑如下:

抽奖业务流程

用户A有1个积分,在抽奖时,短时间内进行了两次请求。由于请求间隔很短,在第一个请求执行积分减1之前,第二个请求检查用户积分,会返回大于1。这样导致用户使用一个积分进行了两次抽奖。

采用分布式锁的逻辑如下:

加锁后抽奖业务流程

先到的请求会将A抽奖的行为加上锁,在释放锁之前,其它的请求都无法进行抽奖操作,这样就保证了数据的一致性。

要求

一个正确的分布式锁需要同时满足以下三个条件:

  1. 互斥性。在任意时刻,只有一个进程占用锁;
  2. 不能发生死锁。即使一个进程在持有锁的期间,由于某种原因崩溃,没有主动释放锁,也需要保证其他进程能够加锁成功;
  3. 解锁和加锁的进程必须是同一个。不能释放别的进程加的锁。

redis版实现方案

分布式锁需要存储在一个所有进程都能看到的地方,通过mysql,redis,zookeeper等都可以实现。这里主要说明下如何使用redis实现分布式锁。

加锁

在加锁时,为了保证互斥性,需要检查锁是否已被其他进程占用;为了保证不发生死锁,需要给锁一个过期时间。

检查锁是否被占用可以通过setnx来实现,设置过期时间有两种方式:一是给这个lock设定过期时间,二是通过lock的值来判断。

首先来看两个错误示范:

错误示范1

res, err := redisClient.Setnx(lockKey, value); 
if err == nil && res == 1 {  
    // 假如该进程在这里退出,则无法设置过期时间,将发生死锁   
    redisClient.Expire(lockKey, expireTime) 
} 

上面的代码只能保证互斥性,不能保证不发生死锁。因为 setnx 和 expire 是分开执行的,不具备原子性。如果进程在执行完 setnx 之后,由于某种原因退出,导致没有设定锁的过期时间,就会导致死锁。锁永远无法被释放,导致其他进程无法加锁,一直阻塞,形成死锁。

错误示范2

expireTime := time.Now().Add(expire).UnixNano()   

// 如果锁不存在,则加锁成功 
res, err := redisClient.Setnx(lockKey, expireTime) 
if err == nil && res == 1 {  
    return true 
}   

// 如果锁存在,获取锁的过期时间 
lockValue := redisClient.Get(lockKey) 
if lockValue < time.Now().UnixNano() {  
    // 锁已过期,通过getset加锁(此时会有多个进程在加锁) 
    oldLockValue := reidsClient.GetSet(lockKey, expireTime) 
    if oldLockValue == lockValue { 
        // 多进程同时抢占加锁,只有第一个加锁后返回之前旧锁的值,认为加锁成功 
        return true 
    } 
}
// 其他情况,返回加锁失败 
return false

上面的代码通过lock的值来作为过期时间的判断,并通过getset来设置新的值。这里存在一个问题:多个进程在发现lock过期后,抢占加锁,虽然只有一个返回加锁成功,但是有可能lock的值被其他进程覆盖,导致在解锁时出现问题。

如果考虑到服务器时间不一致redis主从延迟的情况,这种方案的问题就更多了:

服务器时间不一致:假如A,B两个服务器时间不一致,A比B早1s(实际上不会有这么大),A加锁后1s之内,如果B进行加锁操作,都会认为A加的锁已过期,从而抢占锁;

主从延迟:为了高可用,redis通常会有主从,写操作在主上进行,读操作在从上进行。从会同步主上的数据,但是会存在一定的延迟。按照上面的方案,A,B两个进程同时进行加锁,假如A setnx成功,是在主库上进行,此时B去get,由于主从延迟,B取到的还是之前的旧值,会认为锁已经过期,从而抢占锁;

正确方式

正确的加锁方式如下:

// 生成一个唯一的token作为标识,用于解锁,而不是采用服务器时间 
token := GenUniToken() 

// 通过set的两个参数nx px,保证互斥和过期时间的原子性 
redisClient.Set(lockKey, token, "nx", "px", expireTime);

解锁

解锁时需要注意的是:需要保证解锁的进程和加锁的进程是相同的,不能删除别的进程加的锁。

错误示范1

redisClient.Del(lockKey)

暴力删除lock,不做任何判断,会导致删除别的进程的锁。

错误示范2

lockValue, err := redisClient.Get(lockKey) 
// 与加锁时的token做比较,确认是否是自己加的 
if err == nil && lockVale == token {  
    // 如果此时lockKey过期,其他进程加了锁,会被删除 
    redisClient.Del(lockKey) 
} else {  
    // 解锁失败,锁被其他进程占用,此时需要根据业务来决定如何操作,通常是rollback加锁后的操作 
}

与加锁的错误示范1类似,get和del操作没有保证原子性,导致可能会删除别的进程的锁。

正确方法

由于没有对应的redis命令可以实现get和del的原子操作,此时需要借助lua脚本来实现解锁。

// 判断get到的值是否与参数相等,相等则执行del操作,否则返回0 
delScript := `if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`
res, err := redisClient.Do("eval", delScript, 1, lockKey, token) 
if err == nil && res == 1 {  
    // 删除key,解锁成功 
    return true 
} 
return false

总结

在实现分布式锁的时候,只要保证了以下3点,就不会有问题:

  1. 互斥性;
  2. 不能发生死锁;
  3. 加锁解锁的主体要一致;

通过redis实现分布式锁,加锁时需要注意:

  1. 通过set的nx,px来保证互斥和设定过期时间的原子性;
  2. 锁的value最好采用一个唯一标识,进程保留这个标识解锁时用;

解锁时需要注意:

  1. 需要通过标识判断,锁的值是否已被修改,避免删除别人的锁;
  2. 比较操作和删除操作需要保证原子性,可以通过lua脚本实现;

你可能感兴趣的:(如何通过redis实现分布式锁)