Redis实现分布式锁


本文章借鉴于程序员小灰的文章: 漫画:什么是分布式锁?

转载请注明文章来源,保护文章版权

高可用,高并发,安全性……
随着互联网不断的发展,这些要求越来越高,不论是在面试还是在日常的工作中。作为程序员必须要做到与时俱进,学习和了解这些知识。平时在通勤路上都会去看看一些大神的博客和公众号,学习他们的经验,也会在项目中借鉴。刚好这次项目上用到同步锁,对比数据库的乐观锁、悲观锁、显示锁Lock以及synchronized等,最终还是觉得使用分布式锁更为科学,恰恰就这前两天我看到了程序员小灰的一篇博客讲了redis分布式锁,直接就用上了,在此也感谢那些致力于技术分享的大神们。

1. 几种常见的分布式锁

1.1 Memcached分布式锁

利用Memcached的add命令。此命令是原子性操作,只有在key不存在的情况下,才能add成功,也就意味着线程得到了锁。

1.2 Redis分布式锁

和Memcached的方式类似,利用Redis的setnx命令。此命令同样是原子性操作,只有在key不存在的情况下,才能set成功。(setnx命令并不完善,后续会介绍替代方案)

1.3 Zookeeper分布式锁

利用Zookeeper的顺序临时节点,来实现分布式锁和等待队列。Zookeeper设计的初衷,就是为了实现分布式锁服务的。

1.4 Chubby

Google公司实现的粗粒度分布式锁服务,底层利用了Paxos一致性算法。
上面是常见的几种分布式锁的实现方式,其中Memcached、Redis、Zookeeper也是在开发项目中经常用到的组件,使用和学习成本会较低,更容易上手。现在就让我们一起进入Redis的世界,一起看看Redis是如何实现分布式锁的。

2. Redis实现分布式锁

2.1 逻辑梳理

  • 多个线程竞争一个资源,当单个线程获取到资源的执行权后会加锁
  • 当对资源执行结束后,线程会将锁释放,由其它线程接着操作

注意这里的要保证锁的安全性,以及对锁操作的原子性。

2.2 加锁

最简单的方法是使用setnx命令。key是锁的唯一标识,按业务来决定命名。比如想要给一种商品的秒杀活动加锁,可以给key命名为 “lock_sale_商品ID” 。而value设置成什么呢?我们可以姑且设置成1。加锁的伪代码如下:

setnx(key,value);

当一个线程执行setnx返回1,说明key原本不存在,该线程成功得到了锁;当一个线程执行setnx返回0,说明key已经存在,该线程抢锁失败。

2.3 解锁

有加锁就得有解锁。当得到锁的线程执行完任务,需要释放锁,以便其他线程可以进入。释放锁的最简单方式是执行del指令。

del(key);

释放锁之后,其他线程就可以继续执行setnx命令来获得锁。

2.4 锁超时

锁超时是什么意思呢?如果一个得到锁的线程在执行任务的过程中挂掉,来不及显式地释放锁,这块资源将会永远被锁住,别的线程再也别想进来。
所以,setnx的key必须设置一个超时时间,以保证即使没有被显式释放,这把锁也要在一定时间后自动释放。setnx不支持超时参数,所以需要额外的指令。

expire(key,30);

综合上面的分析可以得到实现的基本逻辑如下:

if(redisClient.setnx("key","value") == 1){//1、设值成功,获得锁
    expire("key",30);//2、设值锁的有效时间是30秒
    try{
        // 业务逻辑 ……
    }finally{
        del("key");//3、释放锁
    }
}

但是上面的代码存在很多问题。

2.5 setnx和expire操作非原子性

在第1步执行结束后,获取到了锁,还没来得及执行expire进行设置超时时间就宕机了,这个时候锁就没法释放。那么改进就是将1、2两步的非原子操作变成原子操作。在redis的2.6.X版本后提供了set(key,value,nxxx,expx,time)。

参数解释:
key:键
value:值
nxxx:表示两种模式NX和XX,NX表示键在redis中已经存在不会再更新,XX表示存在也会更新
expx:表示有效时间的两种时间单位:EX表示秒,PX表示毫秒
time:long类型,表示有效时间

改进后的代码如下:

if(redisClient.set("key","value","NX","EX",30) == 1){//1、设值和设置超时时间
    try{
        // 业务逻辑 ……
    }finally{
        del("key");//2、释放锁
    }
}

这个时候就能实现设置值和超时时间原子化,但是到这里还不够。

2.6 del误删

执行业务逻辑的时间是不可控的,我们设置了30秒的过期时间,但是如果业务逻辑执行超过30秒,这个时候就会自动释放锁,然后其他线程会重新设置锁到redis中,等上一个业务逻辑执行结束,会删除锁,但是这个时候删除的锁已经不是之前设置的锁,而是后续线程设置的锁。这个可以通过设置锁的唯一性来实现。代码如下:

String threadName = Thread.currentThread().getName();
if(redisClient.set("key","value","NX","EX",30) == 1){//1、设值和设置超时时间
    try{
        // 业务逻辑 ……
    }finally{
    	String value = get("key");//2、查询锁信息
    	if(threadName.equals(value)) del("key");//3、释放锁
    }
}

但是这里很明显又出现了原子操作的问题(2、3步,判断和删除锁的操作是两步独立的操作),之前有set的衍生方法实现,这里就没有了,怎么办?

2.7 Lua脚本

脚本如下:

String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redisClient.eval(luaScript , Collections.singletonList(key), Collections.singletonList(threadId));

这样就能实现删除和验证过程的原子性。
但是这里我有思考过,一般在公司项目中使用的Redis都不是单一的实现某一功能,有可能除了实现同步锁,还要缓存其他的数据,在使用上面的Lua脚本会不会对其他缓存产生影响,还不知道。有兴趣的可以试试。

2.8 最后的续命

综上看一下,还是存在一点小的瑕疵。A线程执行业务逻辑超过有效时间,在删除的时候是不会误删了。但是在实际场景中,一般会存在的情况是其他线程获取这个锁,必须要等到A线程业务逻辑执行完成结束才可以,也就意味着A线程必须要执行结束才去释放锁,而不能用超时自动释放锁。那么这里就需要在A线程执行业务逻辑快到30秒时,自动给锁续命。使用守护线程就能实现。代码如下:

String threadName = Thread.currentThread().getName();
if(redisClient.set("key","value","NX","EX",30) == 1){//1、设值和设置超时时间
    try{
    	//守护线程
    	Thread daemonThread = new Thread(new Runnable(){
            public void run(){
                //续命操作 ……
            }
    	});
    	daemonThread.start();
    	daemonThread.setDaemon(true);
        // 业务逻辑 ……
    }finally{
    	String value = get("key");//2、查询锁信息
    	if(threadName.equals(value)) del("key");//3、释放锁
    }
}

在这种情况下,即使当前持有锁的机器断电停机了,锁也会被自动释放。同时也能保证并发时的安全性。

2.9 最终的实现方式

对照上面的代码,就我个人感觉有点冗余,这里其实不需要对锁做唯一标识,看上面我们可以知道这个唯一标识就是为了防止误删,但是如果加上了续命,就不会出现误删,因为在业务没有执行结束,锁并不会出现超时自动释放的问题,会一直续命。实现的代码如下:

//守护线程
Thread daemonThread = new Thread(new Runnable(){
    public void run(){
    	//续命操作 ……
    }
});
if(redisClient.set("key","value","NX","EX",30) == 1){//1、设值和设置超时时间
    try{
    	//开启守护线程
    	daemonThread.start();
    	daemonThread.setDaemon(true);
        // 业务逻辑 ……
    }finally{
    	del("key");//2、释放锁
    }
    daemonThread.interrupt();//通知守护线程终止
    //其他操作 ……
}

这里在释放锁后执行了一次终止守护线程操作,守护线程如果不主动终止,它会和主线程共死,若主线程战线比较长,且大部分逻辑不需要持有锁,那么就会造成守护线程的续命操作未终止。有可能会对后续的操作产生影响。


文章作者: 程序猿洞晓
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 程序猿洞晓 !
评论
 上一篇
死锁的两种体现形式分析和解决方案 死锁的两种体现形式分析和解决方案
死锁在多线程开发过程中比较经常遇到,并且这个问题很隐性,很难排查到问题的所在,即使是查看项目的日志都找不到,很让人头疼。死锁的体现形式主要有两种,分别是简单的死锁,在写代码的时候很容易避免,动态死锁就很麻烦。因为出现死锁的主要原因是两个锁的加锁顺序不同,动态死锁看似是加载顺序都相同,但是实际不同,所以一旦发生,就很难排查。
2018-07-08
下一篇 
Java虚拟机那些事儿(一):浅析JVM与JMM模型结构与关系 Java虚拟机那些事儿(一):浅析JVM与JMM模型结构与关系
最近翻看了java线程相关的东西,书中有一边专门讲到java内存模型,读完之后边回想起java虚拟机模型,那时心中便在思考java内存模型(以下简称JMM)和java虚拟机模型(以下简称JVM)之间的关系,下面将详细讲述。根据JMM的设计,系统存在一个主内存(Main Memory),Java中所有变量都储存在主存中,对于……
2018-07-04
  目录