Redis实现分布式锁-原理与写法

  • Post author:
  • Post category:其他


多线程好玩嘿,以前都不怎么写。。



关于实现

其实并没有太多看过Redisson,但是推荐还是用这个框架,简单粗暴。并且多线程写的是真的少。。。代码目前我觉得没啥大问题,但是怕有对多线程不熟导致的一些坑,求大佬发现问题一定告诉一下哈。。



分布式下的情况

分布式与单机情况下最大的不同在于其不是多线程而是多进程。

多线程由于可以共享堆内存,因此可以简单的采取内存作为标记存储位置。而进程之间甚至可能都不在同一台物理机上,因此需要将标记存储在一个所有进程都能看到的地方。



分布式锁

当在分布式模型下,数据只有一份(或有限制),此时需要利用锁的技术控制某一时刻修改数据的进程数。

与单机模式下的锁不仅需要保证进程可见,还需要考虑进程与锁之间的网络问题。

分布式锁还是可以将标记存在内存,只是该内存不是某个进程分配的内存而是公共内存如Redis、Memcache。至于利用数据库、文件等做锁与单机的实现是一样的,只要保证标记能互斥就行。



Redis实现分布式锁的思想



简单的想法

分布式与单机的区别,无非在于锁大概率不是在一台机器上加的,或者说,多台服务器开启了多个服务,根本无法确定同时进行操作的两个线程是在一个服务上。那么对于锁而言,单机情况是同进程下多线程,等于说锁是对所有线程可见的,那么在分布式下,如果将锁放到整个分布式系统都可见的地方,自然能够完成锁的操作。只是不会是说利用Java语言的锁,例如synchronized关键字等。

这个解决方案有很多,Redis是其中一种。即将锁的数据放在Redis中。等于说,获取锁就是设置一个key,然后放到Redis上,而线程进行操作的时候先去Redis上查看是否有这个key。完成了整个锁的思想。

等于说对于锁,还是有抢的过程:使用

setnx

,若返回1说明抢锁成功,同时完成了加锁操作,根本没有这个值,否则就是失败。反之,使用

del

删除key,就是解锁。

但是这样会非常容易引起死锁,因为如果线程在执行过程中挂掉了,没有释放锁,那么就会造成死锁。这个解决方法非常简单,考虑在加锁后,使用

expire

设定超时时间,超时自动释放。



原子性的问题

上述的方法有一个比较重要的问题,就是执行了两个命令,等于说是非原子性的操作。这样有可能引起非常致命的错误,所以解决方法上需要考虑用

set

命令代替。但是这个是在Redis 2.6之后才有的,可以:

set key value EX time

这样的格式,若key存在,则返回0,若key不存在,设置key同时设置超时时间。

当然其实这个没有用上。。后面会使用set,以及考虑重用锁,所以还是用了其他方法。



误删锁的问题

虽然设置了超时时间,但是超时时间的设定却有待商榷。因为无法确定这个方法会不会出现问题,假设方法出现了问题,设定的超时时间是20s,方法来个超级大的慢查询,20s还没有执行成功。此时key自动失效,等于说方法没有执行成功却释放了锁。

此时其他线程的请求会获得锁,然后执行方法。如果恰好该线程也出问题了,而之前的方法执行完毕,执行了

del

等于说提前将别的锁给解了。

解决方法就是确定,锁只有当前线程才能解,那么就是在设置key的时候,value还什么都没设置呢,将线程id设置进去就可以了。

而这样解决了误删锁的问题,还有自动释放锁的问题。或者说是超时时间的设定问题。一个解决方法是,设定一个时间,时间无所谓,但是给线程开启一个守护进程。当守护进程发现时间快到了,不管方法执行情况,直接续时。这样可能会消费很长的时间,但是保证线程必然会保持锁直到完成。如果碰见挂掉的情况,此时守护进程会跟随挂掉,所以自然不能续时,保证了不会死锁。

也可以看到,上述条件可能会引起饥饿,但是这个就不是分布式锁的问题了,而是程序的问题。

具体的实现下面会说。



再次回到原子性的问题

此时,因为额外考虑了验证value是否是自己线程,所以又出现了不是原子性操作的问题。而解决的方法一般是使用脚本。这也是Redis分布式锁一般会使用到Lua脚本的情况:

if (redis.call('get', KEYS[1]) == ARGV[1])
then
	return redis.call('del', KEYS[1])
else
	return 0
end

看不懂需要先简单了解Lua以及Redis的

eval

执行Lua脚本。而在传参上:

jedis.eval(luaScript , Collections.singletonList(key), Collections.singletonList(threadId));

等于说就是判定,key的值只有是当前线程id时,才删除该key,否则不管直接返回。

值得注意的是,在写具体代码的时候,一定不要分开写,否则就不是原子性了。所以获取、释放锁的全部逻辑都写在Lua脚本,然后直接一行代码写

eval()

方法,如果想要好看,像上面一样写一个luaScript变量放脚本,这样就无法保证方法的原子性。



可重入锁

可重入锁还是比较简单的。我们之前对value设置线程id,而key是一个自定义的数。那么反过来,将key设定为线程id,而value是重入次数,就能实现可重入锁了。而在释放的时候也不是单纯的

del

了,而是将value-1,当为0的时候再

del



但是数据结构就需要换一换了,不能采用默认的string,最好是换成hash,hash的名字是锁名,然后key来限定线程名。



续时-watch dog机制

这个可能跟Redisson的Watch Dog不太一样的实现,也不是Redis 2.6新出的特性。主要思路还是创建一个守护线程,守护线程持续监听该锁的剩余时间,如果剩余1/3则重新重置时间,直到主线程完成操作。而主要就是在获取锁后跑逻辑的时候,开启线程,逻辑完成一定要将守护线程关闭。但是这里使用了

stop()

方法关闭,IDE显示说已过时,确实不知道新的关闭的方法。。。



利用Jedis实现

算是手搓的吧,有一些代码上的问题,首先忽略代码风格,理论上这些大量的常量都应该单独拿出来的,并且设置的各种时间也都应该作为参数。等于说是手写了一个简单的Redisson框架了,但是watch dog机制应该是和Redisson不一样的。Redis的地址和端口请自己添加。

两个Lua脚本,传参key为锁名,value为线程id。逻辑基本如下:

class WatchDog implements Runnable {
    @Override
    public void run() {
        System.out.println("守护线程启动");
        Jedis jedis = new Jedis(HOST, PORT);
        while (true) {
            long ttl = jedis.ttl("redisLock");
            if (ttl > 10) {
                try {
                    System.out.println("时间未重置,进入睡眠");
                    Thread.sleep(5000);
                } catch (Exception ex) {
                    ex.printStackTrace();
                }
            } else {
                System.out.println("时间重置");
                jedis.expire("redisLock", 30);
            }
        }
    }
}

class Person implements Runnable {
    private static int num = 10;
    private Jedis jedis;

    public boolean getLock(String threadId) {
        return ((long) jedis.eval("if (redis.call('exists', KEYS[1]) == 0) then\n" +
                "\tredis.call('hset', KEYS[1], ARGV[1], 1);\n" +
                "\tredis.call('expire', KEYS[1], 30);\n" +
                "\treturn 1;\n" +
                "elseif (redis.call('hexists', KEYS[1], ARGV[1]) == 1) then\n" +
                "\tredis.call('hincrby', KEYS[1], ARGV[1], 1);\n" +
                "\tredis.call('expire', KEYS[1], 30);\n" +
                "\treturn 1;\n" +
                "end\n" +
                "return 0;", Collections.singletonList("redisLock"), Collections.singletonList(threadId)) == 1);
    }

    public void unLock(String threadId) {
        jedis.eval("if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then\n" +
                "\treturn nil;\n" +
                "else\n" +
                "\tlocal count = redis.call('hincrby', KEYS[1], ARGV[1], -1);\n" +
                "\tif (count > 0) then\n" +
                "\t\tredis.call('expire', KEYS[1], 30);\n" +
                "\telse\n" +
                "\t\tredis.call('del', KEYS[1]);\n" +
                "\tend\n" +
                "end", Collections.singletonList("redisLock"), Collections.singletonList(threadId));
    }

    @Override
    public void run() {
        jedis = new Jedis(HOST, PORT);
        String threadName = Thread.currentThread().getName();
        String threadId = String.valueOf(Thread.currentThread().getId());
        System.out.println(threadName + ": 线程开始执行");
        while (num > 0) {
            if (getLock(threadId)) {
                try {
                    Thread daemonThread = new Thread(new WatchDog());
                    daemonThread.setDaemon(true);
                    daemonThread.start();
                    if (num > 0) {
                        num--;
                        System.out.println(threadName + ": 线程已经获得锁,此时num处理后为:"+num);
                    } else {
                        System.out.println(threadName + ": 线程已经获得锁,此时num已经不能操作:"+num);
                    }
                    System.out.println(threadName + ": 线程已释放锁");
                    unLock(threadId);
                    daemonThread.stop();
                } catch (Exception ex) {
                    ex.printStackTrace();
                }
            }
        }
    }
}



单独拿出来的Lua代码

Lua脚本基本参考Redisson的Lua脚本,其实这个逻辑比较简单粗暴。。。大家写的都一样。

加锁的:

if (redis.call('exists', KEYS[1]) == 0) then
	redis.call('hset', KEYS[1], ARGV[1], 1);
	redis.call('expire', KEYS[1], 30);
	return 1;
elseif (redis.call('hexists', KEYS[1], ARGV[1]) == 1) then
	redis.call('hincrby', KEYS[1], ARGV[1], 1);
	redis.call('expire', KEYS[1], 30);
	return 1;
end
return 0;

解锁的:

if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then
	return nil;
else
	local count = redis.call('hincrby', KEYS[1], ARGV[1], -1);
	if (count > 0) then
		redis.call('expire', KEYS[1], 30);
	else
		redis.call('del', KEYS[1]);
	end
end



Redisson

使用这个框架,就比较简单了,Redis分布式锁相比Zookeeper等,就是需要考虑大量条件,比较恶心,而使用Redisson就比较简单了:

Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);
RLock lock = redisson.getLock("key");
lock.lock();
// 逻辑
lock.unlock();

而实际上,这个机制基本和上面的一样了。而默认的超时是30s。

在续期上有些不同,Redisson有watch dog机制。也就是说,在获取锁成功后,会开启一个定时任务,在时间还剩1/3的时候进行一次续时。这个定时任务利用了netty-common包中的HashedWheelTimer。上面的写法等于说是用守护线程。



版权声明:本文为iwts_24原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。