WangYu::Space

Study, think, create, and grow. Teach yourself and teach others.

Redis 与分布式锁

分类:Redis创建时间:2021-07-10 00:00:00

前言

在多线程编程中,为了避免多个线程同时修改共享变量,或者需要保证一小段代码原子性地执行,此时需要加锁。在分布式系统中,数据存储在数据库中,多个客户端独立运行,客户端之间也存在竞争,为了避免多个客户端同时对同一份数据做修改,此时也需要进行加锁。这里的锁,就叫做分布式锁。

锁,可以视为一种标记。多线程编程中,锁是多个线程都能看到的一块数据。分布式锁,自然也需要分布在不同机器上的多个应用程序都能看到,因此分布式锁可以是存储在数据库中的一块数据。应用程序根据数据库中数据的存在与否、数据的取值,来确定是否已经加锁,以及锁的归属者是谁。

分布式锁需要存储在一个数据库中,Redis 以其极高的性能,成为极具竞争力的选择。后文将描述描述使用 Redis 构建分布式锁的原理和一些注意事项。

基于 Redis 的分布式锁实现

分布式锁是用数据库中的数据来作为锁,加锁与解锁可以使用数据的状态来表示。锁的状态如何与数据映射,这这是一种约定。比如,数据若存在则认为已经加锁,不存在则认为未加锁。或者,数据取值为 1 认为加锁,否则认为未加锁。至于怎么设计,这取决于设计者。

初步实现方案

可以以一个 key 是否存在于 Redis 中作为加锁的依据,如果 key 存在,认为已加锁,否则认为未加锁。如此,可以实现以下方案:

加锁时,先看看是否已经加锁,如果加了锁此次加锁尝试失败,如果还没有加锁,就立刻加锁。

# 加锁
def lock(key):
	if redis.exists(key) == 1:
		return False
	redis.set(key, 1)
	return True

# 解锁
def unlock(key):
	redis.del(key)

上面的实现中存在严重的问题,判断锁是否存在与加锁这两步不是原子的。exists 可能返回 key 不存在,但在执行 set 之前,其他客户端插入了这个 key,这就出现了问题。

改进:保证加锁操作的原子性

为了解决这个问题,可以使用 set 命令的 nx 选项,nx 表示 not exists,只有 key 不存在时,set 才会成功,如此就避免了使用 exists 检查的过程,实现如下:

def lock(key):
	ret = redis.set(key, 1, nx=True)
	return ret == "OK"

改进:设置超时时间防止死锁

如果一个成功加锁的客户端意外退出,分布式锁就无法被解除,因此,分布式锁一般都需要加一个自动解除的时间,防止客户端意外退出导致死锁。在 Redis 中,这可以通过设置 key 的过期时间来实现。

def lock(key):
	ret = redis.set(key, 1, nx=True, ex=20) # 设置过期时间为 20 秒
	return ret == "OK"

改进:防止删除别人的加的锁

引入了过期时间后,同时也引入了另外一个问题。考虑以下场景:客户端获取锁之后,迟迟没有释放锁,在此期间,锁超时主动释放了,其他客户端获取了锁,如果此时释放锁,就会把其他客户端的锁释放掉。

lock("key")

do work 1
do work 2   # <- 此时锁过期了
do work 3   # <- 其他客户端已经获取了锁
...

unlock("lock") # 释放了其他客户端的锁

为了避免这种情况发生,可以加一个约束,只能释放由自己持有的锁。如何表明锁是被自己持有呢?可以给锁关联一个每个客户端特有的值,在删除锁之前先检查锁里面的值是否匹配,如果匹配再执行删除。

def lock(key):
	ret = redis.set(key, client_id, nx=True, ex=20)
	return ret == "OK"


def unlock(key):
	value = redis.get(key)
	if value == client_id:
		redis.del(key)

这里删除锁的操作不是原子的,存在问题。

改进:保证删除操作的原子性

为了删除操作的原子性,可以使用 LUA 脚本来实现:

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
def unlock(key):
	redis.eval(lua, key, client_id)

至此,分布式锁的加锁和解锁就没太大问题了。

存在的问题

前面就是分布式锁的一种实现原理,通过多次改进看似很完美,但实际使用分布式锁时候,依然存在以下问题:

问题一:客户端执行时间过长,锁主动超时,客户端未察觉

锁主动过期的时候,客户端会以为自己依然持有锁,只会在删除锁的时候才能发现。客户端以为自己持有锁,但实际上锁已经主动释放了。改进方法是避免这种情况,保证在超时之前完成操作。但执行时间长短往往不受客户端的控制,比如垃圾回收器可能让整个程序停止好几秒、网络可能突然产生较大的延迟。

一种解决方法是,对锁定期续期。可以使用一个后台线程,定期延长锁的过期时间,这样就可以保证在客户端持有锁期间,锁不会主动释放。

def extend_expiration_time(key):
	value = redis.get(key)
	if value == client_id:
		redis.set(key, client_id, ex=20)

这里,get 和 set 不是原子的,可以使用 lua 来处理:

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("set", KEYS[1], ARGV[1], "ex", "20")
else
    return 0
end
def extend_expiration_time(key):
	redis.eval(lua, key, client_id)

问题二:单点问题

单一节点存储锁,锁服务可靠性缺少保障。如果使用主从架构,若主节点出现故障,主节点中锁的信息还未同步到从节点,此时执行了主从切换,这个时候其他客户端会在新主中加锁成功。为了解决这个问题,Redis 社区有人提出了一种叫做 RedLock 的算法。

RedLock 使用多个服务器来存储锁,总数需要是奇数。加锁的时候向多个服务器尝试获取锁,如果成功地从一半以上的服务器中获取到锁,就算加锁成功。否则,需要从少量已经获取到锁的服务器解锁。该算法的思路很简单,就是从多个服务器获取锁,多数成功才算加锁成功。这样就算有服务器故障了,对锁服务也不会有影响。

总结

多个服务器协同工作时,为了避免竞态条件需要使用分布式锁。为了避免客户端宕机出现死锁,通常会在加锁时设置超时时间。这会引入锁主动超时但加锁方未察觉的问题,这问题需要客户端自行避免,或者使用定期延长超时时间的方法。为了保证分布式锁服务的可靠性,可以部署多个节点来提供锁服务,RedLock 在这方面提供不错的解法。

评论 (评论内容仅博主可见,不会公开显示)