Redis的常见使用场景有这么几种:
- 通用缓存
- 计数器(点击量、下载量、pv、uv等)
- 分布式锁
这里我们主要谈Redis分布式锁的实现。
分布式锁主要是为了解决以下几个问题:
- 互斥性:多个机器不能同时获得锁,同一时刻只有一台机器占有锁
- 安全性:保证加锁和解锁都是同一台机器,不能误释放别人的锁
- 死锁:节点故障,但是一直占有锁未释放,其他节点一直加锁失败
- 容错:一个节点故障,保证其他节点可以释放和获取到锁
Redis是通过SetNX命令来加锁
SetNX key value
当key不存在时,设置成功返回1;
当key存在,设置失败返回0
这里在加锁时一定要设置过期时间,避免一个节点down掉产生死锁。
在释放锁时我们很多时候会直接del key
,但是这样是不安全的,可能会释放不该释放的锁
因此我们要合理设置过期时间 && 释放前进行判断
锁的过期时间设置合理,不应该太长或太短,锁的过期时间过长影响新的线程重新获得锁的流程,影响业务响应时间,太短导致业务未执行完,锁自动释放,另一个线程获得锁,重新开始执行逻辑,这就间接要求业务保证幂等性,非幂等性的业务会影响数据一致性。
针对这种情况解决方案:守护线程为锁延长过期时间
Value是加锁的唯一标记,它的的值我们可以设置成机器的唯一标示,加锁解锁保证在同一台机器进行。
//release
if c.Get(myLock) == myValue{
c.Del(myValue)
}
但是判断和删除也分两步,不是原子操作,存在判断后锁过期,另一个线程获得锁,然后误释放锁。
使用lua脚本 原子操作
String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end"
ok,err := c.Eval(luaScript,myLock,myValue).Result()
官方在redis2.6.12之后就支持这样的做法:
官方提供的防止死锁的做法是这样的:
进行SetNX操作,key为你加的锁,value为当前的时间戳加上锁的过期时间,并不给锁加真正的过期时间。
SETNX lock.foo <current Unix time + lock timeout + 1>
原理是这样的:
当线程一先执行这条命令,获取到了锁,返回1。
线程二过来尝试加锁,会失败。接着Get(lock),拿到的值和当前时间进行比较,若小于当前时间,说明该锁已经过期,此时执行Del(lock)操作,并SetNx 重新加锁
若线程三也检查之后发现过期,只是在它释放锁之前,线程二已经释放锁并重新加上了自己的锁,那么线程三再去Del并且SetNX,此时,线程二三同时获得锁,存在问题。
Redis有一个GetSet命令,它会在设置key的value时,返回原来旧的value值。这样就能解决上述问题。
线程四进来和上面一样先检查锁是否过期,若过期就执行GetSet lock currentTime+lockTimeout
,若返回值小于当前时间,可以判定线程4已经获得了锁。若有一个线程五,它在线程四之前执行了GetSet操作,那么线程四执行GetSet时会返回一个大于当前时间的值,说明该锁没有超时,不能释放。
golang的代码实现:
//TryLock
func TryLock(lock string, expireTime time.Duration) (bool){
c := getClient()
value := now + expireTime + 1
for{
now := time.Now().Unix()
ok,err := c.SetNX(lock, value).Result()
if ok == true || ((now > c.Get(lock)) && now > c.GetSet(lock,value)){
break
}
time.Sleep(1)
}
return true
}
//Release
func ReleaseLock(lock string, expireTime time.Duration){
c := getClient()
now := time.Now().Unix()
value := now + expireTime + 1
if now < c.Get(lock) {
c.Del(lock)
}
}
存在两个问题:
- 在锁竞争较高的情况下,会出现Value不断被覆盖,但是没有一个Client获取到锁
- 在获取锁的过程中不断的修改原有锁的数据,设想一种场景C1,C2竞争锁,C1获取到了锁,C2锁执行了GETSET操作修改了C1锁的过期时间,如果C1没有正确释放锁,锁的过期时间被延长,其它Client需要等待更久的时间
这里我个人更推荐使用lua脚本+Redis分布式锁结合的方式,这也是目前很多人在使用的方式。
上面描述的是单点情况,若在redis集群环境依然存在问题:
由于Redis集群数据同步为异步,假设在Master节点获取到锁后未完成数据同步情况下Master节点crash,此时在新的Master节点依然可以获取锁,所以多个Client同时获取到了锁。
解决方案:
redlock算法,set向多半节点发送命令,过半节点成功即为成功,释放时想全部节点发送。