引言
在我们学习各种编程语言的时候,或多或少都会接触到语言层面自带或者相关类库提供的锁。比如,Golang 中的 sync.Mutex 提供的加锁功能。在这里,当两个 goroutine 对临界资源产生竞争的时候(往往是写操作),Golang 提供的锁便派上了用场,此时,一个 goroutine 获得了锁,而另一个 goroutine 则需要等待,直到锁释放才能对临界区资源进行操作。但是语言级别的锁往往只能防止一个进程或者线程内部的并发问题,在多个进程,多个客户端,比如抢购电商网站上面的某件商品的时候,语言级别的锁就有点无能为力了。而这时候,就需要用到分布式锁。分布式锁的实现有很多,比如通过 Zookeeper、Redis 等等,而这里,我们主要介绍一下基于 Redis 的分布式锁。
分布式锁
分布式锁一般用于控制分布式系统或者多个系统对于临界资源的访问。
分布式锁的特征
- 互斥:锁的基本特征,同一时刻,只能有一个进程(线程)持有锁
- 超时释放:超过一定时间需要释放锁,防止出现异常导致锁无法释放进而引起不可预知的其他问题
- 高性能与高可用:加锁和释放锁应该低开销,且尽可能防止锁意外失效
基于 Redis 单机实现的分布式锁
利用 SETNX 命令
Redis 的 SETNX 命令,如果设置 kv 成功,则会返回 1,否则返回 0,利用这个特性,我们可以实现一个简单的分布式锁。比如:秒杀活动中对于某个商品加锁,以商品编号为 key,任意值为 value,则执行 SETNX 命令成功则加锁成功,之后执行临界区任务,执行完毕后删除这个 key 达到释放锁的目的。
SETNX key value
do something
DEL key
但是这样做会有一个问题,如果在执行临界区任务的时候,出现异常,删除操作无法正常执行,导致无法正常释放锁,则资源会一直被锁住
利用 SET 的扩展命令
上面的情况我们很容易想到对 key 加一个过期时间
SETNX key value
EXPIRE key 10
do something
DEL key
但是 SETNX 和 EXPIRE 命令之间不是原子操作,这样就可能发生这样的情况:SETNX 成功,但是 EXPIRE 失败,于是又变成了上面的情况。
Redis 的 SET 命令的扩展参数提供了将 SETNX 和 EXPIRE 命令合成原子操作的办法
SET key NX EX 10
do something
DEL key
这里虽然解决了 SETNX 和 EXPIRE 命令不是原子操作的问题,但是还有其他的问题。如果线程 A 获得锁,但是执行临界区任务的时候超过了锁的过期时间,于是锁被释放,线程 B 获得锁执行任务,然后线程 A 执行完毕,这时候删除锁,这个时候就会误删线程 B 的锁。于是,我们需要一个 CAD(Compare And Delete) 的机制来释放锁。
上面我们讲到 SET 的时候 value 是一个随机数(可以使用线程 id 等),因此,在删除 key 的时候,可以先与 key 的 value 做比较,如果相等,才释放锁(删除 key)。但是判断 value 是否相等与删除 key 不是原子操作,而 Redis 目前也没有相关的扩展参数支持这个原子操作,因此可以考虑使用 lua 脚本来让多个指令原子执行(据了解,不少公司的基架部门实际上也封装了一层类似 CAD 的命令来提供给业务方直接使用),lua 脚本如下所示
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
扩展阅读
上面的命令虽然解决了原子操作的问题,但是始终没有解决锁在临界区任务没有执行完毕但是因为超时被释放的问题。可以考虑类似这样的方式:获取锁的线程同时开启一个额外线程,这个线程每 ExpireTime/3 的时候去检查线程的锁是否存在,如果存在,则将锁的过期时间重新设置为 ExpireTime,来防止锁的提前释放。当然,实际可能没这么简单,感兴趣的朋友可以参考: github.com/redisson/re… 。此外,还有基于 Redis 多机实现的的分布式锁,感兴趣的朋友也可以自己去了解一下。