基于 Redis 的分布式锁

前言

分布式锁在分布式应用中应用普遍,想要搞懂一个新事物首先得了解它的由来,这样才能更加的理解甚至能够触类旁通。java

首先谈到分布式锁天然也就联想到分布式应用。git

在咱们将应用拆分为分布式应用以前的单机系统中,对一些并发场景读取公共资源时如扣库存,卖车票之类的需求能够简单的使用同步或者是加锁就能够实现。github

可是应用分布式了以后系统由之前的单进程多线程的程序变为了多进程多线程,这时使用以上的解决方案明显就不够了。redis

所以业界经常使用的解决方案一般是借助于一个第三方组件并利用它自身的排他性来达到多进程的互斥。如:数据库

  • 基于 DB 的惟一索引。
  • 基于 ZK 的临时有序节点。
  • 基于 Redis 的 NX EX 参数。

这里主要基于 Redis 进行讨论。api

实现

既然是选用了 Redis,那么它就得具备排他性才行。同时它最好也有锁的一些基本特性:多线程

  • 高性能(加、解锁时高性能)
  • 可使用阻塞锁与非阻塞锁。
  • 不能出现死锁。
  • 可用性(不能出现节点 down 掉后加锁失败)。

这里利用 Redis set key 时的一个 NX 参数能够保证在这个 key 不存在的状况下写入成功。而且再加上 EX 参数可让该 key 在超时以后自动删除。并发

因此利用以上两个特性能够保证在同一时刻只会有一个进程得到锁,而且不会出现死锁(最坏的状况就是超时自动删除 key)。dom

加锁

实现代码以下:maven

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 的实例。各有优劣吧。

项目源码在:

https://github.com/crossoverJie/distributed-lock-redis

欢迎讨论。

单测

在作这个项目的时候让我不得不想提一下单测

由于这个应用是强依赖于第三方组件的(Redis),可是在单测中咱们须要排除掉这种依赖。好比其余伙伴 fork 了该项目想在本地跑一遍单测,结果运行不起来:

  1. 有多是 Redis 的 ip、端口和单测里的不一致。
  2. Redis 自身可能也有问题。
  3. 也有多是该同窗的环境中并无 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 的实现。

号外

最近在总结一些 Java 相关的知识点,感兴趣的朋友能够一块儿维护。

地址: https://github.com/crossoverJie/Java-Interview

相关文章
相关标签/搜索