背景
在类似秒杀这样的并发场景下,为了确保同一时刻只能允许一个用户访问资源,需要利用加锁的机制控制资源的访问权。如果服务只在单台机器上运行,可以简单地用一个内存变量进行控制。而在多台机器的系统上,则需要用分布式锁的机制进行并发控制。基于redis的一些特性,利用redis可以既方便又高效地模拟锁的实现。
一个简单方案
让我们先从一个简单的实现说起,这里用到了redis的两个命令,SETNX和EXPIRE。如果lock_key不存在,那么就设置lock_key的值为1,并且设置过期时间;如果lock_key存在,说明已经有人在使用这把锁,访问失败。
def acquire_lock(lock_key, expire_timeout=60):
if redis.setnx(lock_key, 1):
redis.expire(lock_key, expire_timeout)
return True
return False
逻辑上看似乎没有问题,但是考虑一下异常情况:如果setnx设置成功,但expire由于某些原因(比如超时)操作失败,那么这把锁就永远存在了,也就是所谓的死锁,后面的人永远无法访问这个资源。
利用时间戳取值的方案
为了解决死锁,我们可以利用setnx的value来做文章。上例中的我们设的value是1,其实并没有派上用场。因此可以考虑将value设为当前时间加上expire_timeout,当setnx设置失败后,我们去读lock_key的value,并且和当前时间作比对,如果当前时间大于value,那么资源理当被释放。代码示例如下:
def acquire_lock(lock_key, expire_timeout=60):
expire_time = int(time.time()) + expire_timeout
if redis.setnx(lock_key, expire_time):
redis.expire(lock_key, expire_timeout)
return True
redis_value = redis.get(lock_key)
if redis_value and int(time.time()) > int(redis_value):
redis.delete(lock_key)
return False
然而仔细推敲下这段代码仍然能发现一些问题。第一,这个方案依赖时间,如果在分布式系统中的时间没有同步,则会对方案产生一定偏差。第二,假设C1和C2都没拿到锁,它们都去读value并对比时间,在竞态条件(race condition)下可能产生如下的时序:C1删除lock_key,C1获得锁,C2删除lock_key,C2获得锁。这样C1和C2同时拿到了锁,显然是不对的。
改进后的方案
幸运的是,redis里还有一个指令可以帮助我们解决这个问题。GETSET指令在set新值的同时会返回老的值,这样的话我们可以检查返回的值,如果该值和之前读出来的值相同,那么这次操作有效,反之则无效。代码示例如下:
def acquire_lock(lock_key, expire_timeout=60):
expire_time = int(time.time()) + expire_timeout
if redis.setnx(lock_key, expire_time):
redis.expire(lock_key, expire_timeout)
return True
redis_value = redis.get(lock_key)
if redis_value and int(time.time()) > int(redis_value):
expire_time = int(time.time()) + expire_timeout
old_value = redis.getset(lock_key, expire_time)
if int(old_value) == int(redis_value):
return True
return False
这个方案基本可以满足要求,除了有一个小瑕疵,由于getset会去修改value,在竞态条件下可能会被修改多次导致timeout有细微的误差,但这个对结果影响不大。
最终方案
以上方案实现起来略显繁琐,但从redis 2.6.12版本开始有一个更为简便的方法。我们可以使用SET指令的扩展 ** SET key value [EX seconds] [PX milliseconds] [NX|XX] **,这个指令相当于对SETNX和EXPIRES进行了合并,因而我们的算法可以简化为如下一行:
def acquire_lock(lock_key, expire_timeout=60):
ret = redis.set(lock_key, int(time.time()), nx=True, ex=expire_timeout):
return ret
总结
在redis 2.6.12版本之后我们可以用一个简单的SET命令实现分布式锁,而在此版本之前则需要将SETNX和GETSET配合使用一个较为繁琐的方案。简化后的方案对于开发者来说当然是好事,但通过学习这一演变过程我们会对问题有更深刻的印象。