分布式锁之基于Redis的分布式锁
一、简述
分布式锁一般有三种实现方式:第一,数据库乐观锁;第二,基于Redis的分布式锁;第三,基于Zookeeper的分布式锁。目前,在项目中有需要用到分布式锁的场景,因此学习并总结了。今天,咱们先来聊聊基于Redis的分布式锁。
要保证基于Redis的分布式锁可用,必须同时满足以下四个条件:1、互斥性:在任何时刻只能有一个客户端持有锁;2、避免死锁:即使有一个客户端在持锁阶段出现崩溃而没有主动释放锁,也要保证后续其他客户端能加锁;3、具有容错性:只要大部分Redis的节点能正常运行,客户端就可以加锁和解锁;4、唯一性:客户端在加锁和解锁的过程中,必须只能是同一个客户端,客户端自己不能把别的客户端的锁解了。以上这四点,称之为可靠性。
二、简单示例
1、maven依赖
<properties> <jedis.version>2.9.0</jedis.version> </properties> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>${jedis.version}</version> </dependency>
2、RedisTool工具类
public class RedisTool { private static final String LOCK_SUCCESS = "OK"; private static final String SET_IF_NOT_EXIST = "NX"; private static final String SET_WITH_EXPIRE_TIME = "PX"; private static final Long RELEASE_SUCCESS = 1L; /** * 尝试获取分布式锁 * * @param jedis Redis客户端 * @param lockKey 锁 * @param requestId 请求标识 * @param expireTime 超时时间 * @return */ public static boolean tryGetLock(Jedis jedis, String lockKey, String requestId, int expireTime){ String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime); if (LOCK_SUCCESS.equals(result)) { return true; } return false; } /** * 释放分布式锁 * * @param jedis Jedis客户端 * @param lockKey 锁 * @param requestId 请求标识 * @return */ public static boolean releaseLock(Jedis jedis, String lockKey, String requestId){ String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Object object = jedis.eval(luaScript); if (RELEASE_SUCCESS.equals(object)) { return true; } return false; } }
从加锁的方法中可以看出,加锁就一行代码:jedis.set(String key, String value, String nxxx, String expx, int time),这个set方法一共五个参数:
- key:使用key来作为锁,因为key是唯一的。
- value:我们请求的是requestId,之所以有这个参数,就是根据可靠性的第四点,换言之,解铃还须系铃人。一般来说,我们会通过UUID.randomUUID().toString()的方式生成requestId。
- nxxx:这个参数请求的是NX,即SET IF NOT EXIST。当key不存在时,进行set操作;若key已经存在,则不做任何操作。
- expx:请求的是PX,就是给key设置一个过期的时间,具体时间由参数time决定。
上面的代码执行结果只有两种:1.当前没有锁,就进行加锁操作,并设置锁的过期时间,同时value设置为加锁的客户端;2.锁已经存在,不做任何操作。
三、加锁示例分析
如果你认真阅读了前面的内容,你会发现,上面的示例并没有满足可靠性中的容错性。这是因为,容错性是在redis集群的环境下需要考虑的因素。而单机部署redis,容错性的优先级是最低的。
在阅读团队中其他小伙伴写的关于redis实现分布式锁的代码中,发现了以下两种错误的实现方式,分别如下:
- 错误示例一
public static void tryGetLockWithWrong(Jedis jedis, String lockKey, String requestId, int expireTime){ Long result = jedis.setnx(lockKey, requestId); if (result == 1) { jedis.expire(lockKey, expireTime); } }
setnx()的作用就是SET IF NOT EXIST,expire()方法就是给锁加一个过期时间。然而,由于这两条redis命令不具备原子性,如果jedis.expire(String key, int expireTime)发生异常或出现崩溃,此方法设置的锁过期时间就不会生效,就会导致死锁的出现。expire源码片段如下:
//Jedis.java public Long expire(final String key, final int seconds) { checkIsInMultiOrPipeline(); client.expire(key, seconds); return client.getIntegerReply(); } //Client.java public void expire(final String key, final int seconds) { expire(SafeEncoder.encode(key), seconds); } //BinaryClient.java public void expire(final byte[] key, final int seconds) { sendCommand(EXPIRE, key, toByteArray(seconds)); } //Connection.java protected Connection sendCommand(final Command cmd, final byte[]... args) { try { connect(); Protocol.sendCommand(outputStream, cmd, args); pipelinedCommands++; return this; } catch (JedisConnectionException ex) { try { String errorMessage = Protocol.readErrorLineIfPossible(inputStream); if (errorMessage != null && errorMessage.length() > 0) { ex = new JedisConnectionException(errorMessage, ex.getCause()); } } catch (Exception e) { } // Any other exceptions related to connection? broken = true; throw ex; } }
之所以出现这种写法,是因为低版本的jedis不支持多参数的set方法。
- 错误示例二
public static boolean getLockWithWrong(Jedis jedis, String lockKey, int expireTime) { long expires = System.currentTimeMillis() + expireTime; String expiresStr = String.valueOf(expires); if (jedis.setnx(lockKey, expiresStr) == 1) { return true; } String currentValueStr = jedis.get(lockKey); if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) { String oldValueStr = jedis.getSet(lockKey, expiresStr); if (oldValueStr != null && oldValueStr.equals(currentValueStr)) { return true; } } return false; }
这段代码的实现思路:使用jedis.setnx()命令实现加锁,其中key是锁,value是锁的过期时间。执行过程:1. 通过setnx()方法尝试加锁,如果当前锁不存在,返回加锁成功。2. 如果锁已经存在则获取锁的过期时间,和当前时间比较,如果锁已经过期,则设置新的过期时间,返回加锁成功。
乍看之下,这段逻辑没有问题,仔细分析,这里面存在以下的问题:1. 由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步。 2. 当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖。3. 锁不具备拥有者标识,即任何客户端都可以解锁。
四、解锁示例分析
从解锁的方法中可以看出,解锁只需要两行代码:第一行代码,一个简单的Lua脚本代码;第二行代码,将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。
引入lua脚本只是为了保证解锁操作的原子性(在eval命令执行Lua代码的时候,lua代码将被当成一个命令去执行,并且直到eval命令执行完成,redis才会执行其他命令)。逻辑很简单,首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。
在阅读团队中小伙伴解锁方法时,也存在两种错误的实现方式,分别如下:
- 错误示例一
public static void releaseLockWithWrong(Jedis jedis, String lockKey) { jedis.del(lockKey); }
直接使用jedis.del()方法删除锁,这种不先判断锁的拥有者而直接解锁的方式,会导致任何客户端都可以随时进行解锁,即使这把锁不是它的。
- 错误示例二
public static void wrongReleaseLock(Jedis jedis, String lockKey, String requestId) { if (requestId.equals(jedis.get(lockKey))) { jedis.del(lockKey); } }
问题在于如果调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了。
五、总结
基于Redis实现的分布式锁在实现的时候应该要多看多思考,而不能一味地盲目在网上找一找。