분산 잠금을 구현하는 방법에는 일반적으로 세 가지가 있습니다:
1. 데이터베이스 낙관적 잠금
2. ZooKeeper 기반 분산 잠금.
이 글에서는 Redis를 기반으로 분산 잠금을 구현하는 두 번째 방법을 소개합니다. 인터넷에는 Redis 분산 잠금 구현을 소개하는 다양한 블로그가 있지만, 이들의 구현에는 다양한 문제가 있습니다. 독자의 오해를 피하기 위해 이 블로그에서는 Redis 분산 잠금을 올바르게 구현하는 방법을 자세히 소개합니다.
신뢰성우선, 분산 잠금을 사용할 수 있도록 하려면 최소한 잠금 구현이 다음 네 가지 조건을 동시에 충족하는지 확인해야 합니다.
1. 언제든지 하나의 클라이언트만 잠금을 보유할 수 있습니다.
2. 교착상태가 발생하지 않습니다. 클라이언트가 잠금을 적극적으로 잠금 해제하지 않고 잠금을 유지하는 동안 충돌이 발생하더라도 다른 클라이언트가 이후에 잠금을 잠글 수 있다는 것이 보장됩니다.
3. 내결함성. 대부분의 Redis 노드가 정상적으로 실행되는 한 클라이언트는 잠그거나 잠금 해제할 수 있습니다.
4. 방울을 풀려면 방울을 묶어야 합니다. 잠금 및 잠금 해제는 동일한 클라이언트에 의해 수행되어야 합니다. 클라이언트 자체는 다른 사람이 추가한 잠금을 잠금 해제할 수 없습니다.
코드 구현
구성 요소 종속성먼저 Maven을 통해 Jedis 오픈 소스 구성 요소를 도입하고 다음 코드를 pom에 추가해야 합니다.
Talk는 저렴합니다. 코드를 보여주세요. 먼저 코드를 보여주고, 왜 이런 방식으로 구현되었는지 천천히 설명하세요:
<dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>2.9.0</version> </dependency>
잠그려면 한 줄의 코드만 필요하다는 것을 알 수 있습니다: jedis.set(String key, String value, String nxxx, String expx, int time), 이 세트 () 메소드에는 총 5개의 형식 매개변수가 있습니다. 첫 번째는 키가 고유하기 때문에 키를 자물쇠로 사용합니다. 두 번째는 requestId입니다. 많은 아이들이 자물쇠로 사용하는 것만으로는 충분하지 않나요? 그 이유는 위에서 신뢰성에 대해 이야기했을 때 벨을 잠금 해제하려면 분산 잠금이 네 번째 조건을 충족해야 하며 벨을 들고 있는 사람이 벨을 묶은 사람이어야 하기 때문에 requestId에 값을 할당하면 어떤 요청인지 알 수 있기 때문입니다. 잠금을 추가하면 잠금을 해제할 수 있습니다. requestId는 UUID.randomUUID().toString() 메서드를 사용하여 생성할 수 있습니다.
세 번째는 nxxx입니다. 이는 SET IF NOT EXIST를 의미합니다. 즉, 키가 없으면 설정 작업을 수행합니다.
네번째 첫 번째는 expx입니다. 우리가 전달하는 이 매개변수는 PX입니다. 이는 이 키에 만료 설정을 추가하려는 의미입니다. 다섯 번째 매개변수에 의해 결정됩니다.
다섯 번째 매개변수는 time으로, 이는 네 번째 매개변수에 해당하며 키의 만료 시간을 나타냅니다.
일반적으로 위의 set() 메서드를 실행하면 다음 두 가지 결과만 나타납니다. 1. 현재 잠금이 없습니다(키가 존재하지 않음). 그런 다음 잠금 작업을 수행하고 잠금에 대한 유효 기간을 설정하고 값을 설정합니다. 잠긴 클라이언트를 나타냅니다. 2. 잠금이 이미 존재하므로 아무런 작업도 수행되지 않습니다.
조심스러운 아이들은 잠금 코드가 신뢰성에 설명된 세 가지 조건을 충족한다는 것을 알게 될 것입니다.
1. 먼저 set()은 키가 이미 존재하는 경우 함수 호출이 성공하지 않도록 NX 매개변수를 추가합니다. 즉, 단 하나의 클라이언트만이 잠금을 보유할 수 있어 상호 배제를 충족합니다.
2. 두 번째로, 자물쇠에 만료 시간을 설정해 두었기 때문에 나중에 자물쇠 보유자가 충돌하여 잠금이 해제되지 않더라도 만료 시간으로 인해 잠금이 자동으로 잠금 해제(즉, 키가 삭제됨)됩니다. 교착 상태가 발생하지 않습니다.
3. 마지막으로 잠긴 클라이언트 요청 식별을 나타내는 requestId에 값을 할당하므로 클라이언트가 잠금 해제되면 동일한 클라이언트인지 확인할 수 있습니다. Redis 독립 실행형 배포 시나리오만 고려하므로 당분간은 내결함성을 고려하지 않습니다.
오류 예 1더 일반적인 오류 예는 jedis.setnx()와 jedis.expire()의 조합을 사용하여 잠금을 구현하는 것입니다. 코드는 다음과 같습니다.
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"; /** * 尝试获取分布式锁 * @param jedis Redis客户端 * @param lockKey 锁 * @param requestId 请求标识 * @param expireTime 超期时间 * @return 是否获取成功 */ public static boolean tryGetDistributedLock(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; } }
setnx() 메서드는 다음과 같이 작동합니다. 존재하지 않으면 SET, 만료 () 메소드는 잠금에 만료 시간을 추가하는 것입니다. 얼핏 결과는 이전 set() 메소드와 동일해 보이지만 이는 두 개의 Redis 명령이므로 setnx()를 실행한 후 프로그램이 갑자기 충돌하는 경우 잠금이 발생하지 않습니다. 만료 시간이 설정되었습니다. 그러면 교착상태가 발생하게 됩니다. 일부 사람들이 이것을 인터넷에서 구현하는 이유는 하위 버전의 jedis가 다중 매개변수 set() 메서드를 지원하지 않기 때문입니다.
오류 예 2public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {
Long result = jedis.setnx(lockKey, requestId);
if (result == 1) {
// 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
jedis.expire(lockKey, expireTime);
}
}
이런 종류의 오류 예는 문제를 찾기가 더 어렵고 구현도 더 복잡합니다. 구현 아이디어: jedis.setnx() 명령을 사용하여 잠금을 구현합니다. 여기서 키는 잠금이고 값은 잠금 만료 시간입니다.
실행 프로세스:
1. setnx() 메서드를 통해 잠금을 시도합니다. 현재 잠금이 존재하지 않으면 잠금을 성공적으로 반환합니다.
2. 잠금이 이미 존재하는 경우 잠금 만료 시간을 가져와 현재 시간과 비교합니다. 잠금이 만료된 경우 새 만료 시간을 설정하고 잠금을 성공적으로 반환합니다. 코드는 다음과 같습니다. 그럼 이 코드의 문제점은 무엇인가요?
1、由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步。
2、当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖。
3、锁不具备拥有者标识,即任何客户端都可以解锁。
解锁代码
正确姿势
还是先展示代码,再带大家慢慢解释为什么这样实现:
public class RedisTool { private static final Long RELEASE_SUCCESS = 1L; /** * 释放分布式锁 * @param jedis Redis客户端 * @param lockKey 锁 * @param requestId 请求标识 * @return 是否释放成功 */ public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId)); if (RELEASE_SUCCESS.equals(result)) { return true; } return false; } }
可以看到,我们解锁只需要两行代码就搞定了!第一行代码,我们写了一个简单的Lua脚本代码,上一次见到这个编程语言还是在《黑客与画家》里,没想到这次居然用上了。第二行代码,我们将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。
那么这段Lua代码的功能是什么呢?其实很简单,首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。那么为什么要使用Lua语言来实现呢?因为要确保上述操作是原子性的。关于非原子性会带来什么问题,可以阅读【解锁代码-错误示例2】 。
那么为什么执行eval()方法可以确保原子性,源于Redis的特性,下面是官网对eval命令的部分解释:
简单来说,就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。
错误示例1
最常见的解锁代码就是直接使用jedis.del()方法删除锁,这种不先判断锁的拥有者而直接解锁的方式,会导致任何客户端都可以随时进行解锁,即使这把锁不是它的。
public static void wrongReleaseLock1(Jedis jedis, String lockKey) { jedis.del(lockKey); }
错误示例2
这种解锁代码乍一看也是没问题,甚至我之前也差点这样实现,与正确姿势差不多,唯一区别的是分成两条命令去执行,代码如下:
public static void wrongReleaseLock2(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知识请关注redis数据库教程栏目。
위 내용은 Redis 분산 잠금의 올바른 구현 소개의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!