Redis를 기반으로 분산 잠금을 구현하는 방법

无忌哥哥
풀어 주다: 2023-04-03 09:34:01
원래의
1527명이 탐색했습니다.

머리말

분산 잠금은 분산 애플리케이션에서 널리 사용됩니다. 새로운 것을 이해하려면 먼저 그 유래를 이해해야 더 잘 이해할 수 있고 추론까지 할 수 있습니다.

먼저 분산 잠금을 이야기할 때 우리는 자연스럽게 분산 애플리케이션을 떠올립니다.

애플리케이션을 분산 애플리케이션으로 분할하기 전의 독립 실행형 시스템에서는 동기화 또는 잠금을 사용하여 공용 리소스를 읽을 때 인벤토리 공제 및 티켓 판매와 같은 일부 동시 시나리오를 간단히 달성할 수 있습니다.

하지만 애플리케이션이 배포된 후에는 시스템이 이전의 단일 프로세스 및 다중 스레드 프로그램에서 다중 프로세스 및 다중 스레드 프로그램으로 변경됩니다. 이때 위의 솔루션을 사용하는 것만으로는 충분하지 않습니다.

따라서 업계에서 일반적으로 사용되는 솔루션은 일반적으로 타사 구성 요소를 사용하고 자체 독점성을 사용하여 여러 프로세스의 상호 배제를 달성하는 것입니다. 예:

  • 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 了该项目想在本地跑一遍单测,结果运行不起来:

  1. 有可能是 Redis 的 ip、端口和单测里的不一致。

  2. Redis 自身可能也有问题。

  3. 也有可能是该同学的环境中并没有 Redis。

所以最好是要把这些外部不稳定的因素排除掉,单测只测我们写好的代码。

于是就可以引入单测利器 Mock

여기서의 논의는 주로 Redis를 기반으로 합니다.

구현

🎜Redis가 선택되었으므로 독점적이어야 합니다. 동시에 잠금의 몇 가지 기본 기능을 갖추는 것이 가장 좋습니다. 🎜🎜🎜🎜고성능(추가 및 잠금 해제 시 고성능)🎜🎜🎜🎜차단 잠금 및 비차단 잠금을 사용할 수 있습니다. 🎜🎜🎜🎜교착상태가 없습니다. 🎜🎜🎜🎜가용성(노드가 다운된 후에는 잠금이 실패할 수 없습니다). 🎜🎜🎜여기서 Redis set key의 NX 매개변수를 사용하여 키가 존재하지 않는 경우에도 성공적인 쓰기를 보장합니다. 그리고 EX 매개변수를 추가하면 시간 초과 후 키가 자동으로 삭제될 수 있습니다. 🎜🎜따라서 위의 두 기능을 사용하면 하나의 프로세스만 동시에 잠금을 획득하고 교착 상태가 발생하지 않도록 할 수 있습니다(최악의 경우는 시간 초과 후 키가 자동으로 삭제된다는 것입니다). 🎜

Lock

🎜구현 코드는 다음과 같습니다. 🎜
    @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());
    }
로그인 후 복사
로그인 후 복사
🎜여기서 사용된 jedis의 🎜rrreee🎜api에 주목하세요. 🎜🎜이 명령은 NX EX의 원자성을 보장할 수 있습니다. 🎜🎜두 명령(NX EX)을 별도로 실행하지 않도록 주의하세요. NX 이후 프로그램에 문제가 있으면 교착 상태가 발생할 수 있습니다. 🎜

차단 잠금

🎜차단 잠금을 구현할 수도 있습니다. 🎜rrreee

잠금 해제

🎜잠금 해제도 매우 간단합니다. 실제로 키만 삭제하면 모든 것이 잘 됩니다. 예를 들어 del key 명령을 사용하세요. 🎜🎜하지만 현실은 그리 쉽지 않은 경우가 많습니다. 🎜🎜프로세스 A가 잠금을 획득하고 타임아웃을 설정했지만 실행 주기가 길어 타임아웃 후에 자동으로 잠금이 해제됩니다. 이때 프로세스 B는 잠금을 획득하고 곧 잠금을 해제합니다. 이런 방식으로 프로세스 B는 프로세스 A의 잠금을 해제합니다. 🎜🎜그러므로 가장 좋은 방법은 잠금을 해제할 때마다 자물쇠가당신의 것인지 여부를 판단하는 것입니다. 🎜🎜이때 잠금 메커니즘과 연동하여 구현해야 합니다. 🎜🎜잠금할 때 매개변수를 전달해야 하며, 이 매개변수를 이 키의 값으로 사용하면 잠금을 해제할 때마다 값이 동일한지 판단할 수 있습니다. 🎜🎜따라서 잠금 해제 코드는 단순히 del일 수 없습니다. 🎜rrreee🎜여기서 lua 스크립트를 사용하여 값이 동일한지 확인하고, 동일한 경우에만 del 명령을 실행합니다. 🎜🎜lua를 사용하면 여기서 두 작업의 원자성을 보장할 수도 있습니다. 🎜🎜그러므로 위에서 언급한 네 가지 기본 기능도 만족할 수 있습니다. 🎜🎜🎜🎜Redis를 사용하면 성능을 보장할 수 있습니다. 🎜🎜🎜🎜차단 잠금 및 비차단 잠금에 대해서는 위를 참조하세요. 🎜🎜🎜🎜교착 상태를 해결하려면 시간 초과 메커니즘을 사용하세요. 🎜🎜🎜🎜Redis는 가용성 향상을 위해 클러스터 배포를 지원합니다. 🎜🎜

사용

🎜완전한 구현을 직접 갖고 있으며, 관심 있는 친구는 즉시 사용할 수 있습니다. 🎜🎜maven 종속성: 🎜rrreee🎜 구성 빈:🎜rrreee🎜사용법:🎜rrreee🎜사용이 매우 간단합니다. 여기서 주요 목적은 Spring을 사용하여 RedisLock 싱글톤 빈을 관리하는 것입니다. 따라서 잠금을 해제할 때 키와 요청을 수동으로 전달해야 합니다(전체 컨텍스트에 RedisLock 인스턴스가 하나만 있기 때문에)(API는 특히 우아함). 🎜🎜새 RedisLock을 생성하고 잠금을 사용할 때마다 키를 전달하고 요청할 수도 있는데, 이는 잠금 해제 시 매우 편리합니다. 하지만 RedisLock 인스턴스는 직접 관리해야 합니다. 각각에는 장단점이 있습니다. 🎜

싱글 테스트

🎜이 프로젝트를 진행하면서 싱글 테스트를 언급해야겠습니다. 🎜🎜이 애플리케이션은 타사 구성 요소(Redis)에 크게 의존하기 때문에 단일 테스트에서는 이러한 종속성을 제외해야 합니다. 예를 들어, 다른 파트너가 프로젝트를 포크하고 로컬에서 단일 테스트를 실행하려고 했지만 결과가 실행되지 않았습니다. 🎜
    🎜🎜Redis의 IP와 포트가 다음과 같을 수 있습니다. 단일 테스트의 결과와 일치하지 않습니다. 🎜🎜🎜🎜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 的话可以很直接的看出来:

Redis를 기반으로 분산 잠금을 구현하는 방법

这里我们所依赖的 JedisCluster 其实是一个 cglib 代理对象。所以也不难想到它是如何工作的。

比如这里我们需要用到 JedisCluster 的 set 函数并需要它的返回值。

Mock 就将该对象代理了,并在实际执行 set 方法后给你返回了一个你自定义的值。

这样我们就可以随心所欲的测试了,完全把外部依赖所屏蔽了

总结

至此一个基于 Redis 的分布式锁完成,但是依然有些问题。

  • 如在 key 超时之后业务并没有执行完毕但却自动释放锁了,这样就会导致并发问题。

  • 就算 Redis 是集群部署的,如果每个节点都只是 master 没有 slave,那么 master 宕机时该节点上的所有 key 在那一时刻都相当于是释放锁了,这样也会出现并发问题。就算是有 slave 节点,但如果在数据同步到 salve 之前 master 宕机也是会出现上面的问题。

感兴趣的朋友还可以参考 Redisson 的实现。

위 내용은 Redis를 기반으로 분산 잠금을 구현하는 방법의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

관련 라벨:
원천:php.cn
본 웹사이트의 성명
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.
인기 튜토리얼
더>
최신 다운로드
더>
웹 효과
웹사이트 소스 코드
웹사이트 자료
프론트엔드 템플릿