머리말
분산 잠금은 분산 애플리케이션에서 널리 사용됩니다. 새로운 것을 이해하려면 먼저 그 유래를 이해해야 더 잘 이해할 수 있고 추론까지 할 수 있습니다.
먼저 분산 잠금을 이야기할 때 우리는 자연스럽게 분산 애플리케이션을 떠올립니다.
애플리케이션을 분산 애플리케이션으로 분할하기 전의 독립 실행형 시스템에서는 동기화 또는 잠금을 사용하여 공용 리소스를 읽을 때 인벤토리 공제 및 티켓 판매와 같은 일부 동시 시나리오를 간단히 달성할 수 있습니다.
하지만 애플리케이션이 배포된 후에는 시스템이 이전의 단일 프로세스 및 다중 스레드 프로그램에서 다중 프로세스 및 다중 스레드 프로그램으로 변경됩니다. 이때 위의 솔루션을 사용하는 것만으로는 충분하지 않습니다.
따라서 업계에서 일반적으로 사용되는 솔루션은 일반적으로 타사 구성 요소를 사용하고 자체 독점성을 사용하여 여러 프로세스의 상호 배제를 달성하는 것입니다. 예:
DB의 고유 인덱스를 기반으로 합니다.
ZK를 기반으로 임시로 주문한 노드입니다.
Redis의 NX EX
매개변수를 기반으로 합니다. NX EX
参数。
这里主要基于 Redis 进行讨论。
既然是选用了 Redis,那么它就得具有排他性才行。同时它最好也有锁的一些基本特性:
高性能(加、解锁时高性能)
可以使用阻塞锁与非阻塞锁。
不能出现死锁。
可用性(不能出现节点 down 掉后加锁失败)。
这里利用 Redis set key
时的一个 NX 参数可以保证在这个 key 不存在的情况下写入成功。并且再加上 EX 参数可以让该 key 在超时之后自动删除。
所以利用以上两个特性可以保证在同一时刻只会有一个进程获得锁,并且不会出现死锁(最坏的情况就是超时自动删除 key)。
实现代码如下:
private static final String SET_IF_NOT_EXIST = "NX"; private static final String SET_WITH_EXPIRE_TIME = "PX"; public boolean tryLock(String key, String request) { String result = this.jedis.set(LOCK_PREFIX + key, request, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, 10 * TIME); if (LOCK_MSG.equals(result)){ return true ; }else { return false ; } }
注意这里使用的 jedis 的
String set(String key, String value, String nxxx, String expx, long time);
api。
该命令可以保证 NX EX 的原子性。
一定不要把两个命令(NX EX)分开执行,如果在 NX 之后程序出现问题就有可能产生死锁。
同时也可以实现一个阻塞锁:
//一直阻塞 public void lock(String key, String request) throws InterruptedException { for (;;){ String result = this.jedis.set(LOCK_PREFIX + key, request, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, 10 * TIME); if (LOCK_MSG.equals(result)){ break ; } //防止一直消耗 CPU Thread.sleep(DEFAULT_SLEEP_TIME) ; } } //自定义阻塞时间 public boolean lock(String key, String request,int blockTime) throws InterruptedException { while (blockTime >= 0){ String result = this.jedis.set(LOCK_PREFIX + key, request, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, 10 * TIME); if (LOCK_MSG.equals(result)){ return true ; } blockTime -= DEFAULT_SLEEP_TIME ; Thread.sleep(DEFAULT_SLEEP_TIME) ; } return false ; }
解锁也很简单,其实就是把这个 key 删掉就万事大吉了,比如使用 del key
命令。
但现实往往没有那么 easy。
如果进程 A 获取了锁设置了超时时间,但是由于执行周期较长导致到了超时时间之后锁就自动释放了。这时进程 B 获取了该锁执行很快就释放锁。这样就会出现进程 B 将进程 A 的锁释放了。
所以最好的方式是在每次解锁时都需要判断锁是否是自己的。
这时就需要结合加锁机制一起实现了。
加锁时需要传递一个参数,将该参数作为这个 key 的 value,这样每次解锁时判断 value 是否相等即可。
所以解锁代码就不能是简单的 del
了。
public boolean unlock(String key,String request){ //lua script String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Object result = null ; if (jedis instanceof Jedis){ result = ((Jedis)this.jedis).eval(script, Collections.singletonList(LOCK_PREFIX + key), Collections.singletonList(request)); }else if (jedis instanceof JedisCluster){ result = ((JedisCluster)this.jedis).eval(script, Collections.singletonList(LOCK_PREFIX + key), Collections.singletonList(request)); }else { //throw new RuntimeException("instance is error") ; return false ; } if (UNLOCK_MSG.equals(result)){ return true ; }else { return false ; } }
这里使用了一个 lua
脚本来判断 value 是否相等,相等才执行 del 命令。
使用 lua
也可以保证这里两个操作的原子性。
因此上文提到的四个基本特性也能满足了:
使用 Redis 可以保证性能。
阻塞锁与非阻塞锁见上文。
利用超时机制解决了死锁。
Redis 支持集群部署提高了可用性。
我自己有撸了一个完整的实现,并且已经用于了生产,有兴趣的朋友可以开箱使用:
maven 依赖:
<dependency> <groupId>top.crossoverjie.opensource</groupId> <artifactId>distributed-redis-lock</artifactId> <version>1.0.0</version> </dependency>
配置 bean :
@Configuration public class RedisLockConfig { @Bean public RedisLock build(){ RedisLock redisLock = new RedisLock() ; HostAndPort hostAndPort = new HostAndPort("127.0.0.1",7000) ; JedisCluster jedisCluster = new JedisCluster(hostAndPort) ; // Jedis 或 JedisCluster 都可以 redisLock.setJedisCluster(jedisCluster) ; return redisLock ; } }
使用:
@Autowired private RedisLock redisLock ; public void use() { String key = "key"; String request = UUID.randomUUID().toString(); try { boolean locktest = redisLock.tryLock(key, request); if (!locktest) { System.out.println("locked error"); return; } //do something } finally { redisLock.unlock(key,request) ; } }
使用很简单。这里主要是想利用 Spring 来帮我们管理 RedisLock 这个单例的 bean,所以在释放锁的时候需要手动(因为整个上下文只有一个 RedisLock 实例)的传入 key 以及 request(api 看起来不是特别优雅)。
也可以在每次使用锁的时候 new 一个 RedisLock 传入 key 以及 request,这样倒是在解锁时很方便。但是需要自行管理 RedisLock 的实例。各有优劣吧。
在做这个项目的时候让我不得不想提一下单测。
因为这个应用是强依赖于第三方组件的(Redis),但是在单测中我们需要排除掉这种依赖。比如其他伙伴 fork 了该项目想在本地跑一遍单测,结果运行不起来:
有可能是 Redis 的 ip、端口和单测里的不一致。
Redis 自身可能也有问题。
也有可能是该同学的环境中并没有 Redis。
所以最好是要把这些外部不稳定的因素排除掉,单测只测我们写好的代码。
于是就可以引入单测利器 Mock
여기서의 논의는 주로 Redis를 기반으로 합니다.
Redis set key
의 NX 매개변수를 사용하여 키가 존재하지 않는 경우에도 성공적인 쓰기를 보장합니다. 그리고 EX 매개변수를 추가하면 시간 초과 후 키가 자동으로 삭제될 수 있습니다. 🎜🎜따라서 위의 두 기능을 사용하면 하나의 프로세스만 동시에 잠금을 획득하고 교착 상태가 발생하지 않도록 할 수 있습니다(최악의 경우는 시간 초과 후 키가 자동으로 삭제된다는 것입니다). 🎜@Test public void tryLock() throws Exception { String key = "test"; String request = UUID.randomUUID().toString(); Mockito.when(jedisCluster.set(Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyLong())).thenReturn("OK"); boolean locktest = redisLock.tryLock(key, request); System.out.println("locktest=" + locktest); Assert.assertTrue(locktest); //check Mockito.verify(jedisCluster).set(Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyLong()); }
del key
명령을 사용하세요. 🎜🎜하지만 현실은 그리 쉽지 않은 경우가 많습니다. 🎜🎜프로세스 A가 잠금을 획득하고 타임아웃을 설정했지만 실행 주기가 길어 타임아웃 후에 자동으로 잠금이 해제됩니다. 이때 프로세스 B는 잠금을 획득하고 곧 잠금을 해제합니다. 이런 방식으로 프로세스 B는 프로세스 A의 잠금을 해제합니다. 🎜🎜그러므로 가장 좋은 방법은 잠금을 해제할 때마다 자물쇠가당신의 것인지 여부를 판단하는 것입니다. 🎜🎜이때 잠금 메커니즘과 연동하여 구현해야 합니다. 🎜🎜잠금할 때 매개변수를 전달해야 하며, 이 매개변수를 이 키의 값으로 사용하면 잠금을 해제할 때마다 값이 동일한지 판단할 수 있습니다. 🎜🎜따라서 잠금 해제 코드는 단순히 del
일 수 없습니다. 🎜rrreee🎜여기서 lua
스크립트를 사용하여 값이 동일한지 확인하고, 동일한 경우에만 del 명령을 실행합니다. 🎜🎜lua
를 사용하면 여기서 두 작업의 원자성을 보장할 수도 있습니다. 🎜🎜그러므로 위에서 언급한 네 가지 기본 기능도 만족할 수 있습니다. 🎜🎜🎜🎜Redis를 사용하면 성능을 보장할 수 있습니다. 🎜🎜🎜🎜차단 잠금 및 비차단 잠금에 대해서는 위를 참조하세요. 🎜🎜🎜🎜교착 상태를 해결하려면 시간 초과 메커니즘을 사용하세요. 🎜🎜🎜🎜Redis는 가용성 향상을 위해 클러스터 배포를 지원합니다. 🎜🎜Mock
을 소개할 수 있습니다. 🎜🎜아이디어는 매우 간단합니다. 의존하는 모든 외부 리소스를 차단하는 것입니다. 예: 데이터베이스, 외부 인터페이스, 외부 파일 등 🎜使用方式也挺简单,可以参考该项目的单测:
@Test public void tryLock() throws Exception { String key = "test"; String request = UUID.randomUUID().toString(); Mockito.when(jedisCluster.set(Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyLong())).thenReturn("OK"); boolean locktest = redisLock.tryLock(key, request); System.out.println("locktest=" + locktest); Assert.assertTrue(locktest); //check Mockito.verify(jedisCluster).set(Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyLong()); }
这里只是简单演示下,可以的话下次仔细分析分析。
它的原理其实也挺简单,debug 的话可以很直接的看出来:
这里我们所依赖的 JedisCluster 其实是一个 cglib 代理对象
。所以也不难想到它是如何工作的。
比如这里我们需要用到 JedisCluster 的 set 函数并需要它的返回值。
Mock 就将该对象代理了,并在实际执行 set 方法后给你返回了一个你自定义的值。
这样我们就可以随心所欲的测试了,完全把外部依赖所屏蔽了。
至此一个基于 Redis 的分布式锁完成,但是依然有些问题。
如在 key 超时之后业务并没有执行完毕但却自动释放锁了,这样就会导致并发问题。
就算 Redis 是集群部署的,如果每个节点都只是 master 没有 slave,那么 master 宕机时该节点上的所有 key 在那一时刻都相当于是释放锁了,这样也会出现并发问题。就算是有 slave 节点,但如果在数据同步到 salve 之前 master 宕机也是会出现上面的问题。
感兴趣的朋友还可以参考 Redisson 的实现。
위 내용은 Redis를 기반으로 분산 잠금을 구현하는 방법의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!