Redis分布式锁

本文详细介绍了Redis分布式锁的概念、特点以及常见的实现方式,包括单机Redis和多节点RedLock实现,分析了存在的问题及解决方案,探讨了Redis分布式锁的局限性,强调在效率与正确性之间的权衡,并提出了业务数据幂等性的保底方案。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

Redis分布式锁

一、什么是分布式锁

分布式锁,即是在分布式项目中使用的锁,主要用来控制多个实例同时访问共享资源。当服务器抢占到锁时,即可对共享资源进行操作,未抢占到锁的服务器则直接返回失败或者阻塞轮询获取分布式锁。

 

分布式锁主要满足以下几个特点:

  • 互斥性:在任何时刻,对于同一条数据,只有一台应用可以获取到分布式锁;
  • 高性能和高可用性:加锁和释放锁的过程性能开销要尽可能的低,同时也要保证高可用,防止分布式锁意外失效(通过集群实现)。
  • 独占性:加锁解锁必须由同一台服务器进行,锁的持有者才可以释放锁;
  • 可重入性:持有锁的实例可以再次请求锁,防止锁在线程执行完临界区操作前释放
  • 防止死锁:具有锁失效策略(比如持有锁的时间超过指定值时,则锁自动释放)

二、常见分布式锁实现

  • 基于数据库实现

    • 优势:实现简单
    • 缺点:
      • 不能定时释放锁,如果占有锁的应用挂掉了,其他应用就一直不能获取到锁
      • 强依赖数据库,一旦数据库挂掉,则业务系统不可用
      • 不可重入,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
    • 实现方式:
      • 建立一张lock表,表中根据共享资源设置一个唯一字段
      • 当需要访问共享资源时,就向表中插入一条数据
      • 插入成功则表示抢占到锁,执行完成后删除掉对应的数据以实现释放锁。
  • 基于redis实现

    • 优点:具有高性能、高可用,可解决失效死锁问题
    • 缺点:单机redis不支持高可用,且可能存在两个实例同时占有锁的问题
  • 基于zookeeper实现

    • 优点:具备高可用、可重入、阻塞锁特性,可解决失效死锁问题。
    • 缺点:因为需要频繁的创建和删除节点,性能上不如Redis方式。
    • 实现方式:
      • 创建一个目录mylock;
      • 线程A想获取锁就在mylock目录下创建临时顺序节点
      • 获取mylock目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁;
      • 线程B获取所有节点,判断自己不是最小节点,设置监听比自己次小的节点;
      • 线程A处理完,删除自己的节点,线程B监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。

三、单机Redis分布式锁实现

加锁:

SET resource_name my_random_value NX PX 30000

如果set命令执行成功,则表示当前实例获取抢占到锁,即可操作共享资源,如果set失败,则表明当前锁已被其他实例占用,进行阻塞状态或直接返回失败并执行释放锁操作。

  • resource_name:key
  • my_random_value:有客户端随机生成的字符串,类似token的作用(保证在一段时间内唯一),防止在释放锁的时候,释放掉其他客户端占用的锁
  • NX:表示只有当resource_name为key的数据不存在的时候才会set成功,并返回成功,否则返回失败。保证了只有第一个客户端能独占锁
  • PX:设置过期时间,防止占有锁的客户端挂掉后,锁无法释放导致其他客户端死锁。

以上命令在redisClient中使用以下命令:

 // lock
    lockSuccess, err := client.SetNX(resource_name, my_random_value, time.Second*5).Result()

    if err != nil || !lockSuccess {
        fmt.Println(err, "lock result: ", lockSuccess)
        return
    }

释放锁:

当客户端执行完业务逻辑后,需要释放锁,执行以下Redis Lua脚本来释放锁:

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

这段Lua脚本在执行的时候要把前面的my_random_value作为ARGV[1]的值传进去,把resource_name作为KEYS[1]的值传进去。

注意:

  • 获取分布式锁时,必须设置过期时间,防止客户端占用锁后,挂掉导致其他应用一直无法获取锁。
  • 获取锁时,SET和EXPIRE要在一个原子操作内,不能先set再expire,防止set后程序崩溃,一直持有锁
  • set的时候,value值为一个随机字符串,保证了在客户端释放锁的时候,释放的是自己持有的锁
  • 释放锁时需要通过Lua脚本实现get和del操作的原子性
  • 加锁失败时需要执行释放锁操作,防止因为网络波动问题导致的返回err,但是redis侧已加锁成功的情况

存在的问题

对于单节点redis,如果redis挂掉,则整个业务系统都会阻塞,因此引入redis主从模式,但是redis主从模式可能存在以下情形:

  1. 客户端1从Master获取了锁。
  2. Master宕机了,存储锁的key还没有来得及同步到Slave上。
  3. Slave升级为Master。
  4. 客户端2从新的Master获取到了对应同一个资源的锁。

此时,两个客户端就会持有同一个分布式锁,导致共享资源被同时修改。

四、多redis节点分布式锁实现 RedLock

加锁:

  1. 获取当前时间(毫秒数)。
  2. 按顺序依次向N个Redis节点执行获取锁的操作。这个获取操作跟前面基于单Redis节点的获取锁的过程相同,包含随机字符串my_random_value,也包含过期时间(比如PX 30000,即锁的有效时间)。为了保证在某个Redis节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个Redis节点获取锁失败以后,应该立即尝试下一个Redis节点。这里的失败,应该包含任何类型的失败,比如该Redis节点不可用,或者该Redis节点上的锁已经被其它客户端持有(注:Redlock原文中这里只提到了Redis节点不可用的情况,但也应该包含其它的失败情况)。
  3. 计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第1步记录的时间。如果客户端从大多数Redis节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。
  4. 如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间。
  5. 如果最终获取锁失败了(可能由于获取到锁的Redis节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有Redis节点发起释放锁的操作(即前面介绍的Redis Lua脚本)。

释放锁:

向N个Redis节点同时发生是否锁的操作,不管这些节点最终操作的成功与否。

存在的问题:

1)假设有A、B、C、D、E五个redis节点

  1. 客户端1成功锁住了A,B,C,获取锁成功(但D和E没有锁住)。
  2. 节点C的master挂了,然后锁还没同步到slave,slave升级为master后丢失了客户端1加的锁。
  3. 客户端2这个时候获取锁,锁住了C,D,E,获取锁成功

针对这个问题,可以考虑延迟重启redis节点,即在redis主节点挂掉后,不马上让从节点升级为主节点,而是等一段时间,这个时间要大于分布式锁的过期时间,这样就能解决这一问题。

2)redLock算法里在获取到分布式锁之后,会用设置的过期时间-获取锁消耗的时间,如果消耗的时间较久,距离锁过期时间已经所剩无几时,是否需要直接释放掉锁再重新申请?这个时间阈值怎么设置?

五、redis分布式锁的局限:

 

在上面的时序图中,假设锁服务本身是没有问题的,它总是能保证任一时刻最多只有一个客户端获得锁。上图中出现的lease这个词可以暂且认为就等同于一个带有自动过期功能的锁。客户端1在获得锁之后发生了很长时间的GC pause,在此期间,它获得的锁过期了,而客户端2获得了锁。当客户端1从GC pause中恢复过来的时候,它不知道自己持有的锁已经过期了,它依然向共享资源(上图中是一个存储服务)发起了写数据请求,而这时锁实际上被客户端2持有,因此两个客户端的写请求就有可能冲突(锁的互斥作用失效了)。

解决方案:

 

在上图中,客户端1先获取到的锁,因此有一个较小的fencing token,等于33,而客户端2后获取到的锁,有一个较大的fencing token,等于34。客户端1从GC pause中恢复过来之后,依然是向存储服务发送访问请求,但是带了fencing token = 33。存储服务发现它之前已经处理过34的请求,所以会拒绝掉这次33的请求。这样就避免了冲突。

既然在锁失效的情况下已经存在一种fencing机制能继续保持资源的互斥访问了,那为什么还要使用一个分布式锁并且还要求它提供那么强的安全性保证呢?而且对于分布式公共资源,如何保证这个fencing token是递增的。

六、总结:

  • 如果是为了效率(efficiency)而使用分布式锁,允许锁的偶尔失效,那么使用单Redis节点的锁方案就足够了,简单而且效率高。
  • 如果是为了正确性(correctness)在很严肃的场合使用分布式锁,那么不要使用Redlock。它不是建立在异步模型上的一个足够强的算法,它对于系统模型的假设中包含很多危险的成分(对于timing)。可以考虑类似Zookeeper的方案,或者支持事务的数据库。
  • 用Redis控制共享资源并且还要求数据安全要求较高的话,最终的保底方案是对业务数据做幂等控制,这样一来,即使出现多个客户端获得锁的情况也不会影响数据的一致性。

参考文档:

http://zhangtielei.com/posts/blog-redlock-reasoning.html

http://zhangtielei.com/posts/blog-redlock-reasoning-part2.html

https://blog.youkuaiyun.com/skh2015java/article/details/113851226

### Redis 分布式锁的实现方式、使用方法及最佳实践 #### ### 1. Redis 分布式锁的基本原理 Redis 分布式锁的核心思想是利用 Redis 的原子性操作来确保锁的唯一性。通过 Redis 的 `SET` 命令,结合参数 `NX` 和 `EX`,可以在多线程环境下实现加锁和解锁的功能[^1]。此外,为了提高可用性,还可以采用 RedLock 算法或多实例部署的方式。 #### ### 2. Redis 分布式锁的实现方式 #### #### 2.1 单实例 Redis 实现分布式锁 单实例 Redis 实现分布式锁是最简单的实现方式。通过以下命令完成加锁和解锁操作: ```python import time import redis # 初始化 Redis 客户端 client = redis.StrictRedis(host='localhost', port=6379, db=0) lock_key = "distributed_lock" lock_value = "unique_identifier" # 加锁操作 def acquire_lock(): result = client.set(lock_key, lock_value, nx=True, ex=10) # 设置过期时间为 10 秒 return result is not None # 解锁操作 def release_lock(): script = """ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end """ client.eval(script, 1, lock_key, lock_value) ``` 上述代码中,`nx=True` 确保只有当键不存在时才设置键值对,从而实现加锁功能。`ex=10` 参数为锁设置了 10 秒的过期时间,防止死锁的发生[^1]。 #### #### 2.2 多实例 Redis 实现分布式锁(RedLock 算法) 在高可用场景下,可以使用 RedLock 算法实现分布式锁。RedLock 算法通过多个 Redis 实例来确保锁的可靠性。以下是 RedLock 的伪代码实现: ```python import redis import time class RedLock: def __init__(self, redis_nodes): self.redis_nodes = [redis.StrictRedis(**node) for node in redis_nodes] def acquire_lock(self, lock_key, lock_value, ttl): quorum = len(self.redis_nodes) // 2 + 1 start_time = time.time() success_count = 0 for node in self.redis_nodes: if node.set(lock_key, lock_value, nx=True, px=ttl): success_count += 1 elapsed_time = time.time() - start_time validity_time = ttl - int(elapsed_time * 1000) if success_count >= quorum and validity_time > 0: return True, validity_time else: self.release_lock(lock_key, lock_value) return False, 0 def release_lock(self, lock_key, lock_value): for node in self.redis_nodes: try: script = """ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end """ node.eval(script, 1, lock_key, lock_value) except Exception: pass ``` RedLock 算法要求在大多数 Redis 实例上成功加锁,并且整个过程的时间小于锁的有效期,才能认为加锁成功[^3]。 #### ### 3. Redis 分布式锁的最佳实践 #### #### 3.1 设置合理的锁超时时间 为了避免死锁问题,必须为锁设置一个合理的超时时间。如果锁持有者在超时时间内未完成任务,锁将自动释放[^1]。 #### #### 3.2 使用唯一的锁标识符 在加锁时,应为每个锁分配一个唯一的标识符(如 UUID),以便在解锁时验证锁的拥有者身份,防止误删其他线程的锁[^3]。 #### #### 3.3 防止 GC 停顿导致锁失效 Java 程序中的垃圾回收(GC)可能导致线程长时间暂停,从而使锁提前释放。为了解决这一问题,可以使用续租机制,在锁即将到期时主动延长锁的有效期。 #### #### 3.4 监控锁的竞争情况 在高并发场景下,可以通过监控锁的竞争情况来优化系统性能。例如,记录加锁失败的次数或等待时间,分析是否存在锁争用问题[^1]。 #### ### 4. 示例代码:基于 Redisson 的分布式锁实现 Redisson 是一个成熟的 Redis 客户端库,提供了丰富的分布式锁功能。以下是使用 Redisson 实现分布式锁的示例代码: ```java import org.redisson.Redisson; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.redisson.config.Config; public class RedissonLockExample { public static void main(String[] args) throws InterruptedException { Config config = new Config(); config.useSingleServer().setAddress("redis://127.0.0.1:6379"); RedissonClient redisson = Redisson.create(config); RLock lock = redisson.getLock("myDistributedLock"); lock.lock(); // 加锁 try { // 执行业务逻辑 System.out.println("Lock acquired, performing task..."); Thread.sleep(1000); // 模拟任务执行 } finally { lock.unlock(); // 解锁 System.out.println("Lock released."); } redisson.shutdown(); } } ``` Redisson 提供了多种锁类型,包括公平锁、可重入锁和红锁(RedLock),开发者可以根据实际需求选择合适的锁类型[^3]。 #### ### 5. 注意事项 - 在高并发场景下,应尽量减少锁的粒度,避免因锁竞争导致性能下降。 - 如果 Redis 实例发生故障,可能会导致锁丢失。因此,在关键业务场景下,建议使用哨兵模式或集群模式来提高 Redis 的可用性[^2]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值