redis分布式锁的实现(setNx命令和Lua脚本)

本文介绍了Redis分布式锁的实现,包括使用setnx和expire命令的简单实现及其存在的问题,然后通过Lua脚本来增强原子性。接着讨论了使用setkeyvalue[EXseconds][PXmilliseconds][NX|XX]命令的正确做法,以及在Redis集群中可能出现的问题。文章深入讲解了Redlock算法,包括其工作原理和Redisson库的实现,强调了锁的唯一性和释放锁时的验证。

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

redis分布式锁的实现(setNx命令和Lua脚本) - caibaotimes - 博客园

分布式锁概览#

在多线程的环境下,为了保证一个代码块在同一时间只能由一个线程访问,Java中我们一般可以使用synchronized语法和ReetrantLock去保证,这实际上是本地锁的方式。但是现在公司都是流行分布式架构,在分布式环境下,如何保证不同节点的线程同步执行呢?

实际上,对于分布式场景,我们可以使用分布式锁,它是控制分布式系统之间互斥访问共享资源的一种方式。

比如说在一个分布式系统中,多台机器上部署了多个服务,当客户端一个用户发起一个数据插入请求时,如果没有分布式锁机制保证,那么那多台机器上的多个服务可能进行并发插入操作,导致数据重复插入,对于某些不允许有多余数据的业务来说,这就会造成问题。而分布式锁机制就是为了解决类似这类问题,保证多个服务之间互斥的访问共享资源,如果一个服务抢占了分布式锁,其他服务没获取到锁,就不进行后续操作。大致意思如下图所示(不一定准确):

分布式锁的特点#

分布式锁一般有如下的特点:

  • 互斥性: 同一时刻只能有一个线程持有锁
  • 可重入性: 同一节点上的同一个线程如果获取了锁之后能够再次获取锁
  • 锁超时:和J.U.C中的锁一样支持锁超时,防止死锁
  • 高性能和高可用: 加锁和解锁需要高效,同时也需要保证高可用,防止分布式锁失效
  • 具备阻塞和非阻塞性:能够及时从阻塞状态中被唤醒

分布式锁的实现方式#

我们一般实现分布式锁有以下几种方式:

  • 基于数据库
  • 基于Redis
  • 基于zookeeper

本篇文章主要介绍基于Redis如何实现分布式锁

Redis的分布式锁实现#

1. 利用setnx+expire命令 (错误的做法)#

Redis的SETNX命令,setnx key value,将key设置为value,当键不存在时,才能成功,若键存在,什么也不做,成功返回1,失败返回0 。 SETNX实际上就是SET IF NOT Exists的缩写

因为分布式锁还需要超时机制,所以我们利用expire命令来设置,所以利用setnx+expire命令的核心代码如下:

 

Copy

public boolean tryLock(String key,String requset,int timeout) { Long result = jedis.setnx(key, requset); // result = 1时,设置成功,否则设置失败 if (result == 1L) { return jedis.expire(key, timeout) == 1L; } else { return false; } }

实际上上面的步骤是有问题的,setnx和expire是分开的两步操作,不具有原子性,如果执行完第一条指令应用异常或者重启了,锁将无法过期。

一种改善方案就是使用Lua脚本来保证原子性(包含setnx和expire两条指令)

2. 使用Lua脚本(包含setnx和expire两条指令)#

 

Copy

public boolean tryLock_with_lua(String key, String UniqueId, int seconds) { String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" + "redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end"; List<String> keys = new ArrayList<>(); List<String> values = new ArrayList<>(); keys.add(key); values.add(UniqueId); values.add(String.valueOf(seconds)); Object result = jedis.eval(lua_scripts, keys, values); //判断是否成功 return result.equals(1L); }

3. 使用 set key value [EX seconds][PX milliseconds][NX|XX] 命令 (正确做法)#

Redis在 2.6.12 版本开始,为 SET 命令增加一系列选项:

 

Copy

SET key value[EX seconds][PX milliseconds][NX|XX]

  • EX seconds: 设定过期时间,单位为秒
  • PX milliseconds: 设定过期时间,单位为毫秒
  • NX: 仅当key不存在时设置值
  • XX: 仅当key存在时设置值

set命令的nx选项,就等同于setnx命令,代码过程如下:

 

Copy

public boolean tryLock_with_set(String key, String UniqueId, int seconds) { return "OK".equals(jedis.set(key, UniqueId, "NX", "EX", seconds)); }

value必须要具有唯一性,我们可以用UUID来做,设置随机字符串保证唯一性,至于为什么要保证唯一性?假如value不是随机字符串,而是一个固定值,那么就可能存在下面的问题:

  • 1.客户端1获取锁成功
  • 2.客户端1在某个操作上阻塞了太长时间
  • 3.设置的key过期了,锁自动释放了
  • 4.客户端2获取到了对应同一个资源的锁
  • 5.客户端1从阻塞中恢复过来,因为value值一样,所以执行释放锁操作时就会释放掉客户端2持有的锁,这样就会造成问题

所以通常来说,在释放锁时,我们需要对value进行验证

释放锁的实现

释放锁时需要验证value值,也就是说我们在获取锁的时候需要设置一个value,不能直接用del key这种粗暴的方式,因为直接del key任何客户端都可以进行解锁了,所以解锁时,我们需要判断锁是否是自己的,基于value值来判断,代码如下:

 

Copy

public boolean releaseLock_with_lua(String key,String value) { String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then " + "return redis.call('del',KEYS[1]) else return 0 end"; return jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(value)).equals(1L); }

这里使用Lua脚本的方式,尽量保证原子性。

使用 set key value [EX seconds][PX milliseconds][NX|XX] 命令 看上去很OK,实际上在Redis集群的时候也会出现问题,比如说A客户端在Redis的master节点上拿到了锁,但是这个加锁的key还没有同步到slave节点,master故障,发生故障转移,一个slave节点升级为master节点,B客户端也可以获取同个key的锁,但客户端A也已经拿到锁了,这就导致多个客户端都拿到锁。

所以针对Redis集群这种情况,还有其他方案

4. Redlock算法 与 Redisson 实现#

Redis作者 antirez基于分布式环境下提出了一种更高级的分布式锁的实现Redlock,原理如下:

下面参考文章Redlock:Redis分布式锁最牛逼的实现 和 redis.io/topics/dist…

关于Redlock算法的实现,在Redisson中我们可以使用RedissonRedLock来完成,具体使用细节可以参考大佬的文章: mp.weixin.qq.com/s/8uhYult2h…

普通实现

说道Redis分布式锁大部分人都会想到:setnx+lua,或者知道set key value px milliseconds nx。后一种方式的核心实现命令如下:

 

Copy

- 获取锁(unique_value可以是UUID等) SET resource_name unique_value NX PX 30000 - 释放锁(lua脚本中,一定要比较value,防止误解锁) if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end

这种实现方式有3大要点(也是面试概率非常高的地方):

  1. set命令要用set key value px milliseconds nx
  2. value要具有唯一性;
  3. 释放锁时要验证value值,不能误解锁;

事实上这类琐最大的缺点就是它加锁时只作用在一个Redis节点上,即使Redis通过sentinel保证高可用,如果这个master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况:

  1. 在Redis的master节点上拿到了锁;
  2. 但是这个加锁的key还没有同步到slave节点;
  3. master故障,发生故障转移,slave节点升级为master节点;
  4. 导致锁丢失。

正因为如此,Redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock。笔者认为,Redlock也是Redis所有分布式锁实现方式中唯一能让面试官高潮的方式。

Redlock实现

antirez提出的redlock算法大概是这样的:

在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。现在我们假设有5个Redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。

为了取到锁,客户端应该执行以下操作:

  • 获取当前Unix时间,以毫秒为单位。
  • 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
  • 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功
  • 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
  • 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。

Redlock源码

redisson已经有对redlock算法封装,接下来对其用法进行简单介绍,并对核心源码进行分析(假设5个redis实例)。

  • POM依赖

     

    Copy

    <!-- https://mvnrepository.com/artifact/org.redisson/redisson --> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.3.2</version> </dependency>

用法

首先,我们来看一下redission封装的redlock算法实现的分布式锁用法,非常简单,跟重入锁(ReentrantLock)有点类似:

 

Copy

Config config = new Config(); config.useSentinelServers().addSentinelAddress("127.0.0.1:6369","127.0.0.1:6379", "127.0.0.1:6389") .setMasterName("masterName") .setPassword("password").setDatabase(0); RedissonClient redissonClient = Redisson.create(config); // 还可以getFairLock(), getReadWriteLock() RLock redLock = redissonClient.getLock("REDLOCK_KEY"); boolean isLock; try { isLock = redLock.tryLock(); // 500ms拿不到锁, 就认为获取锁失败。10000ms即10s是锁失效时间。 isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS); if (isLock) { //TODO if get lock success, do something; } } catch (Exception e) { } finally { // 无论如何, 最后都要解锁 redLock.unlock(); }

唯一ID

实现分布式锁的一个非常重要的点就是set的value要具有唯一性,redisson的value是怎样保证value的唯一性呢?答案是UUID+threadId。入口在redissonClient.getLock("REDLOCK_KEY"),源码在Redisson.java和RedissonLock.java中:

 

Copy

protected final UUID id = UUID.randomUUID(); String getLockName(long threadId) { return id + ":" + threadId; }

获取锁

获取锁的代码为redLock.tryLock()或者redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS),两者的最终核心源码都是下面这段代码,只不过前者获取锁的默认租约时间(leaseTime)是LOCK_EXPIRATION_INTERVAL_SECONDS,即30s:

 

Copy

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) { internalLockLeaseTime = unit.toMillis(leaseTime); // 获取锁时向5个redis实例发送的命令 return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command, // 首先分布式锁的KEY不能存在,如果确实不存在,那么执行hset命令(hset REDLOCK_KEY uuid+threadId 1),并通过pexpire设置失效时间(也是锁的租约时间) "if (redis.call('exists', KEYS[1]) == 0) then " + "redis.call('hset', KEYS[1], ARGV[2], 1); " + "redis.call('pexpire', KEYS[1], ARGV[1]); " + "return nil; " + "end; " + // 如果分布式锁的KEY已经存在,并且value也匹配,表示是当前线程持有的锁,那么重入次数加1,并且设置失效时间 "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + "redis.call('pexpire', KEYS[1], ARGV[1]); " + "return nil; " + "end; " + // 获取分布式锁的KEY的失效时间毫秒数 "return redis.call('pttl', KEYS[1]);", // 这三个参数分别对应KEYS[1],ARGV[1]和ARGV[2] Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId)); }

获取锁的命令中,

  • KEYS[1]就是Collections.singletonList(getName()),表示分布式锁的key,即REDLOCK_KEY;
  • ARGV[1]就是internalLockLeaseTime,即锁的租约时间,默认30s;
  • ARGV[2]就是getLockName(threadId),是获取锁时set的唯一值,即UUID+threadId:

释放锁

释放锁的代码为redLock.unlock(),核心源码如下:

 

Copy

protected RFuture<Boolean> unlockInnerAsync(long threadId) { // 向5个redis实例都执行如下命令 return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, // 如果分布式锁KEY不存在,那么向channel发布一条消息 "if (redis.call('exists', KEYS[1]) == 0) then " + "redis.call('publish', KEYS[2], ARGV[1]); " + "return 1; " + "end;" + // 如果分布式锁存在,但是value不匹配,表示锁已经被占用,那么直接返回 "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " + "return nil;" + "end; " + // 如果就是当前线程占有分布式锁,那么将重入次数减1 "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " + // 重入次数减1后的值如果大于0,表示分布式锁有重入过,那么只设置失效时间,还不能删除 "if (counter > 0) then " + "redis.call('pexpire', KEYS[1], ARGV[2]); " + "return 0; " + "else " + // 重入次数减1后的值如果为0,表示分布式锁只获取过1次,那么删除这个KEY,并发布解锁消息 "redis.call('del', KEYS[1]); " + "redis.call('publish', KEYS[2], ARGV[1]); " + "return 1; "+ "end; " + "return nil;", // 这5个参数分别对应KEYS[1],KEYS[2],ARGV[1],ARGV[2]和ARGV[3] Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId)); }

分类: redis

### 使用 Redis SETNX 实现分布式锁的最佳实践 Redis 是一种高效的键值存储系统,其支持原子操作的能力使其成为实现分布式锁的理想工具之一。通过 `SETNX` 其他辅助命令,可以构建一个可靠的分布式锁机制。 #### 1. **核心原理** `SETNX`(Set if Not Exists)是一个原子性命令,只有当指定的 key 不存在时才会执行设置操作并返回 1;如果 key 已经存在,则不会修改该 key 并返回 0[^2]。这种特性非常适合用于分布式环境下的互斥控制。 为了进一步增强可靠性,通常会结合以下方法: - 设置锁的有效期(TTL),防止死锁的发生。 - 锁释放时验证客户端身份,避免误删他人持有的锁。 - 处理网络分区或其他异常情况引起的潜在问题。 --- #### 2. **最佳实践** 以下是基于 `SETNX` 的一些推荐做法: ##### (1)**设置带 TTL 的锁** 为了避免因程序崩溃等原因导致锁无法被及时释放而引发死锁现象,应该总是给锁设定一个合理的超时时间(即 Time To Live, TTL)。这可以通过 `SETEX` 或者组合使用 `SETNX` `EXPIRE` 来达成。 示例代码如下: ```python import time import uuid import redis client = redis.StrictRedis(host='localhost', port=6379) def acquire_lock(lock_name, timeout=10): identifier = str(uuid.uuid4()) end_time = time.time() + timeout while time.time() < end_time: if client.setnx(lock_name, identifier): # 尝试获取锁 client.expire(lock_name, int(timeout)) # 成功则设置过期时间 return identifier time.sleep(0.001) # 短暂等待重试 return None ``` 此处的关键在于每次尝试获取锁的同时都重新定义了一个唯一标识符,并设置了相应的过期时间来保障安全性[^1]。 ##### (2)**安全地释放锁** 简单调用 `DEL` 可能会造成错误删除不属于自己的锁的风险。因此建议先检查当前持有者的 ID 是否匹配再决定是否销毁它。 下面展示了一种更稳妥的方式去解除锁定状态: ```lua -- Lua脚本用于确保解锁过程的安全性 if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end ``` 此脚本接受两个参数:一个是代表目标锁名称的 KEY ,另一个则是之前分配好的随机字符串作为凭证。仅当两者一致时才允许真正意义上的移除动作发生。 对应的 Python 调用方式为: ```python def release_lock(lock_name, identifier): script = """ if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end""" return client.eval(script, 1, lock_name, identifier) ``` 这样即使多个进程竞争同一把锁也不会互相干扰到彼此的状态管理逻辑。 --- #### 3. **注意事项** 尽管上述策略已经大大提高了系统的健壮程度但仍需注意以下几个方面: - **高可用架构设计**: 单一节点故障可能导致整个服务不可用所以考虑采用 Sentinel 或 Cluster 方案提升整体稳定性; - **竞态条件规避**: 如果在检测到锁已到期后立即试图清除却正好赶上原拥有方续命成功那么就可能造成短暂冲突故应尽量减少此类间隙的存在; - **性能优化考量**: 对于极高频率请求场景下频繁轮询可能会带来额外负担可探索异步通知机制代替同步查询模式。 --- ### 总结 综上所述,借助 Redis 提供的基础功能配合恰当的设计思路完全可以满足大多数实际需求中的分布协同要求。不过随着业务复杂度增加也可能面临更多挑战此时就需要权衡利弊选取最适合具体情境的技术手段了。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值