web程序一般都分布在不同的服务器上,如果需要对外部资源进行独占操作就必须使用锁机制,这时候就需要使用分布式锁。一般来说,使用zookeeper、etcd这种强一致性的服务来实现分布式锁是最可靠的。但是有时候条件受限,仅仅为了分布式锁就引入zookeeper还是太奢侈了,反而缓存是比较容易获得的资源。

一般我们使用Redis或者Memcached来作为缓存服务,下面分析一下缓存的分布式锁实现。

Memcached

一般网上搜索,大多数使用Memcached来实现分布式锁的一般方法就是:

  1. 使用add命令加锁,同时设置超时时间,只有当key不存在时才会进行添加。因为memcached的命令是原子性的,所以只有一个客户端的add命令会执行成功,也就表示获取了锁。

  2. 解锁操作就是直接删除这个key。可以在解锁前判断当前是否已经超时,如果超时则不删除。

但是这种实现方式是有问题的。在第2步,可能会遇到超时的临界条件。解锁前判断没有超时,发送了删除请求,但是删除请求到达memcached服务器的时候key超时了,这时就可能将其他客户端刚刚获取到的锁给误删。

而且,Memcached实现分布式锁还有一些缺点:

  • Memcached集群的分布式实现是依靠客户端的一致性hash,如果此时增加或删除机器,就有可能key对应的机器发生变化,导致锁丢失。

  • Memcached采用LRU置换淘汰算法,内存不够后,可能将锁给丢弃。

  • Memcached无法持久化,重启后锁会丢失。

所以,使用Memcached实现分布式锁是不可靠的,在高并发场景下不建议使用。

Redis

Redis根据部署情况,实现分布式锁的方法也略有不同。

单点Redis

单点Redis实现分布式锁的思路和Memcached类似,也是使用一个key作为锁,只有获取锁的客户端才能创建key。但是不同的是,redis可以处理超时的临界条件。首先一个客户端通过SET命令放置一把锁:

1
SET resource_name my_random_value NX PX 30000

分析一下这个SET命令,有以下几点注意的地方:

  • key为resource_name,即标识这把锁的名字
  • value为my_random_value,这是一个随机值,由客户端生成,用于标识放置锁的客户端
  • NX参数确保只有这个key不存在才能SET成功,即如果已经有锁,则创建锁就会失败
  • PX是锁过期时间(ms),保证客户端发生崩溃后锁能自动释放

解锁的操作是通过eval一段Lua脚本实现的:

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

使用Lua脚本的原因是要保证get和del操作是原子性的,这样就不会被其他客户端的释放锁请求插入打断,造成竞争。Lua脚本中的ARGV[1]参数是当时设置锁的my_random_value,要和Redis中存储的值一致,这样就能保证删除锁的客户端就是当时加锁的客户端,否则可能出现其他客户端误删锁的情况。

在单点Redis上实现分布式锁是可行的,通过上面的加锁和解锁操作能保证同一时间只有一个客户端获取锁。但是要保证系统的可用性,是不能有单点存在的。而对于集群Redis,分布式锁的实现就变复杂了。

集群Redis

对于集群Redis,官方提出了一种称为 Redlock 的分布式锁算法。与强一致性的zookeeper、etcd服务不同,Redlock分布式锁是在客户端实现的。

在Redis集群上实现分布式锁是以单点Redis为基础的,具体到集群上的某一台Redis,加锁和解锁的操作还是一样的。假设我们有一个由5台Redis组成的集群,客户端通过以下操作获取锁:

  1. 客户端获取当前时间t0。

  2. 客户端使用同一个key和随机值,尝试在所有的N台Redis实例上获取锁。

  3. 客户端获取当前时间t1,由t1-t0得到获取每个实例锁使用了多少时间。只有客户端从多数的Redis实例(N/2+1)上获取了锁,并且获取锁花费的时间小于锁过期时间,才能被认为真正获取了这把分布式锁。

  4. 如果真正获取了分布式锁,那么锁的有效时间是锁过期时间减去获取锁花费的时间。

  5. 如果没有获取到分布式锁,需要在所有的Redis实例上执行解锁操作,即使有部分Redis实例并没有被上锁。

如果需要进行获取锁失败后的重试,那么重试需要等待一个随机的时间,这是为了防止所有客户端同时获取锁失败后又同时重试,导致谁都获取不了锁(脑裂)。而且为了防止出现脑裂,客户端获取锁的时间越快越好,所以最好的方式是并发的去请求所有的Redis实例获取单个实例锁。获取锁失败后,也需要尽快的释放锁住的Redis实例。

Redis作者证明了Redlock的安全性,在工程运用上,Redlock应该是没有问题的。但是因为Redis是一个内存服务,还存在主从同步等数据一致性的问题,所以比起zookeeper还是稍有逊色。

总结

如果非常关心分布式锁的安全性和可靠性,zookeeper、etcd等强一致性服务是首选。Redis资源因为获取简单,在一般情况下也可以选择。Memcached问题较多,不建议使用。

参考