java基于jedisLock—redis分布式锁实现示例代码
分布式锁是啥?
单机锁的概念:我们正常跑的单机项目(也就是在tomcat下跑一个项目不配置集群)想要在高并发的时候加锁很容易就可以搞定,java提供了很多的机制例如:synchronized、volatile、reentrantlock等锁的机制。
为啥需要分布式锁:当我们的项目比较庞大的时候,单机版的项目已经不能满足吞吐量的需求了,需要对项目做负载均衡,有可能还需要对项目进行解耦拆分成不同的服务,那么肯定是做成分布式的项目,分布式的项目因为是不同的程序控制,所以使用java提供的锁并不能完全保证并发需求,需要借助第三方的框架来实现对并发的阻塞控制,来满足实际业务的需要。
一、使用分布式锁要满足的几个条件:
1.系统是一个分布式系统(关键是分布式,单机的可以使用reentrantlock或者synchronized代码块来实现)
2.共享资源(各个系统访问同一个资源,资源的载体可能是传统关系型数据库或者nosql)
3.同步访问(即有很多个进程同事访问同一个共享资源。没有同步访问,谁管你资源竞争不竞争)
二、应用的场景例子
管理后台的部署架构(多台tomcat服务器+redis【多台tomcat服务器访问一台redis】+mysql【多台tomcat服务器访问一台服务器上的mysql】)就满足使用分布式锁的条件。多台服务器要访问redis全局缓存的资源,如果不使用分布式锁就会出现问题。 看如下伪代码:
long n=0l; //n从redis获取值 if(n<5){ n++; //n写回redis }
上面的代码主要实现的功能:
从redis获取值n,对数值n进行边界检查,自加1,然后n写回redis中。 这种应用场景很常见,像秒杀,全局递增id、ip访问限制等。以ip访问限制来说,恶意攻击者可能发起无限次访问,并发量比较大,分布式环境下对n的边界检查就不可靠,因为从redis读的n可能已经是脏数据。传统的加锁的做法(如java的synchronized和lock)也没用,因为这是分布式环境,这个同步问题的救火队员也束手无策。在这危急存亡之秋,分布式锁终于有用武之地了。
分布式锁可以基于很多种方式实现,比如zookeeper、redis...。不管哪种方式,他的基本原理是不变的:用一个状态值表示锁,对锁的占用和释放通过状态值来标识。
这里主要讲如何用redis实现分布式锁。
三、使用redis的setnx命令实现分布式锁
1、实现的原理
redis为单进程单线程模式,采用队列模式将并发访问变成串行访问,且多客户端对redis的连接并不存在竞争关系。redis的setnx命令可以方便的实现分布式锁。
2、基本命令解析
1)setnx(set if not exists)
语法:
setnx key value
将 key 的值设为 value ,当且仅当 key 不存在。
若给定的 key 已经存在,则 setnx 不做任何动作。
setnx 是『set if not exists』(如果不存在,则 set)的简写
返回值:
设置成功,返回 1 。
设置失败,返回 0 。
例子:
redis> exists job # job 不存在 (integer) 0 redis> setnx job "programmer" # job 设置成功 (integer) 1 redis> setnx job "code-farmer" # 尝试覆盖 job ,失败 (integer) 0 redis> get job # 没有被覆盖 "programmer"
所以我们使用执行下面的命令
setnx lock.foo <current unix time + lock timeout + 1>
如返回1,则该客户端获得锁,把lock.foo的键值设置为时间值表示该键已被锁定,该客户端最后可以通过del lock.foo来释放该锁。
如返回0,表明该锁已被其他客户端取得,这时我们可以先返回或进行重试等对方完成或等待锁超时。
2)getset
语法:
getset key value
将给定 key 的值设为 value ,并返回 key 的旧值(old value)。
当 key 存在但不是字符串类型时,返回一个错误。
返回值:
返回给定 key 的旧值。
当 key 没有旧值时,也即是, key 不存在时,返回 nil 。
3)get
语法:
get key
返回值:
当 key 不存在时,返回 nil ,否则,返回 key 的值。
如果 key 不是字符串类型,那么返回一个错误
四、解决死锁
上面的锁定逻辑有一个问题:如果一个持有锁的客户端失败或崩溃了不能释放锁,该怎么解决?
我们可以通过锁的键对应的时间戳来判断这种情况是否发生了,如果当前的时间已经大于lock.foo的值,说明该锁已失效,可以被重新使用。
发生这种情况时,可不能简单的通过del来删除锁,然后再setnx一次(讲道理,删除锁的操作应该是锁拥有这执行的,这里只需要等它超时即可),当多个客户端检测到锁超时后都会尝试去释放它,这里就可能出现一个竞态条件,让我们模拟一下这个场景:
c0操作超时了,但它还持有着锁,c1和c2读取lock.foo检查时间戳,先后发现超时了。
c1 发送del lock.foo
c1 发送setnx lock.foo 并且成功了。
c2 发送del lock.foo
c2 发送setnx lock.foo 并且成功了。
这样一来,c1,c2都拿到了锁!问题大了!
幸好这种问题是可以避免的,让我们来看看c3这个客户端是怎样做的:
c3发送setnx lock.foo 想要获得锁,由于c0还持有锁,所以redis返回给c3一个0
c3发送get lock.foo 以检查锁是否超时了,如果没超时,则等待或重试。
反之,如果已超时,c3通过下面的操作来尝试获得锁:
getset lock.foo <current unix time + lock timeout + 1>
通过getset,c3拿到的时间戳如果仍然是超时的,那就说明,c3如愿以偿拿到锁了。
如果在c3之前,有个叫c4的客户端比c3快一步执行了上面的操作,那么c3拿到的时间戳是个未超时的值,这时,c3没有如期获得锁,需要再次等待或重试。留意一下,尽管c3没拿到锁,但它改写了c4设置的锁的超时值,不过这一点非常微小的误差带来的影响可以忽略不计。
注意:为了让分布式锁的算法更稳键些,持有锁的客户端在解锁之前应该再检查一次自己的锁是否已经超时,再去做del操作,因为可能客户端因为某个耗时的操作而挂起,操作完的时候锁因为超时已经被别人获得,这时就不必解锁了。
五、代码实现
expiremsecs 锁持有超时,防止线程在入锁以后,无限的执行下去,让锁无法释放
timeoutmsecs 锁等待超时,防止线程饥饿,永远没有入锁执行代码的机会
注意:项目里面需要先搭建好redis的相关配置
import org.slf4j.logger; import org.slf4j.loggerfactory; import org.springframework.dao.dataaccessexception; import org.springframework.data.redis.connection.redisconnection; import org.springframework.data.redis.core.rediscallback; import org.springframework.data.redis.core.redistemplate; import org.springframework.data.redis.serializer.stringredisserializer; /** * redis distributed lock implementation. * * @author zhengcanrui */ public class redislock { private static logger logger = loggerfactory.getlogger(redislock.class); private redistemplate redistemplate; private static final int default_acquiry_resolution_millis = 100; /** * lock key path. */ private string lockkey; /** * 锁超时时间,防止线程在入锁以后,无限的执行等待 */ private int expiremsecs = 60 * 1000; /** * 锁等待时间,防止线程饥饿 */ private int timeoutmsecs = 10 * 1000; private volatile boolean locked = false; /** * detailed constructor with default acquire timeout 10000 msecs and lock expiration of 60000 msecs. * * @param lockkey lock key (ex. account:1, ...) */ public redislock(redistemplate redistemplate, string lockkey) { this.redistemplate = redistemplate; this.lockkey = lockkey + "_lock"; } /** * detailed constructor with default lock expiration of 60000 msecs. * */ public redislock(redistemplate redistemplate, string lockkey, int timeoutmsecs) { this(redistemplate, lockkey); this.timeoutmsecs = timeoutmsecs; } /** * detailed constructor. * */ public redislock(redistemplate redistemplate, string lockkey, int timeoutmsecs, int expiremsecs) { this(redistemplate, lockkey, timeoutmsecs); this.expiremsecs = expiremsecs; } /** * @return lock key */ public string getlockkey() { return lockkey; } private string get(final string key) { object obj = null; try { obj = redistemplate.execute(new rediscallback<object>() { @override public object doinredis(redisconnection connection) throws dataaccessexception { stringredisserializer serializer = new stringredisserializer(); byte[] data = connection.get(serializer.serialize(key)); connection.close(); if (data == null) { return null; } return serializer.deserialize(data); } }); } catch (exception e) { logger.error("get redis error, key : {}", key); } return obj != null ? obj.tostring() : null; } private boolean setnx(final string key, final string value) { object obj = null; try { obj = redistemplate.execute(new rediscallback<object>() { @override public object doinredis(redisconnection connection) throws dataaccessexception { stringredisserializer serializer = new stringredisserializer(); boolean success = connection.setnx(serializer.serialize(key), serializer.serialize(value)); connection.close(); return success; } }); } catch (exception e) { logger.error("setnx redis error, key : {}", key); } return obj != null ? (boolean) obj : false; } private string getset(final string key, final string value) { object obj = null; try { obj = redistemplate.execute(new rediscallback<object>() { @override public object doinredis(redisconnection connection) throws dataaccessexception { stringredisserializer serializer = new stringredisserializer(); byte[] ret = connection.getset(serializer.serialize(key), serializer.serialize(value)); connection.close(); return serializer.deserialize(ret); } }); } catch (exception e) { logger.error("setnx redis error, key : {}", key); } return obj != null ? (string) obj : null; } /** * 获得 lock. * 实现思路: 主要是使用了redis 的setnx命令,缓存了锁. * reids缓存的key是锁的key,所有的共享, value是锁的到期时间(注意:这里把过期时间放在value了,没有时间上设置其超时时间) * 执行过程: * 1.通过setnx尝试设置某个key的值,成功(当前没有这个锁)则返回,成功获得锁 * 2.锁已经存在则获取锁的到期时间,和当前时间比较,超时的话,则设置新的值 * * @return true if lock is acquired, false acquire timeouted * @throws interruptedexception in case of thread interruption */ public synchronized boolean lock() throws interruptedexception { int timeout = timeoutmsecs; while (timeout >= 0) { long expires = system.currenttimemillis() + expiremsecs + 1; string expiresstr = string.valueof(expires); //锁到期时间 if (this.setnx(lockkey, expiresstr)) { // lock acquired locked = true; return true; } string currentvaluestr = this.get(lockkey); //redis里的时间 if (currentvaluestr != null && long.parselong(currentvaluestr) < system.currenttimemillis()) { //判断是否为空,不为空的情况下,如果被其他线程设置了值,则第二个条件判断是过不去的 // lock is expired string oldvaluestr = this.getset(lockkey, expiresstr); //获取上一个锁到期时间,并设置现在的锁到期时间, //只有一个线程才能获取上一个线上的设置时间,因为jedis.getset是同步的 if (oldvaluestr != null && oldvaluestr.equals(currentvaluestr)) { //防止误删(覆盖,因为key是相同的)了他人的锁——这里达不到效果,这里值会被覆盖,但是因为什么相差了很少的时间,所以可以接受 //[分布式的情况下]:如过这个时候,多个线程恰好都到了这里,但是只有一个线程的设置值和当前值相同,他才有权利获取锁 // lock acquired locked = true; return true; } } timeout -= default_acquiry_resolution_millis; /* 延迟100 毫秒, 这里使用随机时间可能会好一点,可以防止饥饿进程的出现,即,当同时到达多个进程, 只会有一个进程获得锁,其他的都用同样的频率进行尝试,后面有来了一些进行,也以同样的频率申请锁,这将可能导致前面来的锁得不到满足. 使用随机的等待时间可以一定程度上保证公平性 */ thread.sleep(default_acquiry_resolution_millis); } return false; } /** * acqurired lock release. */ public synchronized void unlock() { if (locked) { redistemplate.delete(lockkey); locked = false; } } }
调用:
redislock lock = new redislock(redistemplate, key, 10000, 20000); try { if(lock.lock()) { //需要加锁的代码 } } } catch (interruptedexception e) { e.printstacktrace(); }finally { //为了让分布式锁的算法更稳键些,持有锁的客户端在解锁之前应该再检查一次自己的锁是否已经超时,再去做del操作,因为可能客户端因为某个耗时的操作而挂起, //操作完的时候锁因为超时已经被别人获得,这时就不必解锁了。 ————这里没有做 lock.unlock(); }
六、一些问题
1、为什么不直接使用expire设置超时时间,而将时间的毫秒数其作为value放在redis中?
如下面的方式,把超时的交给redis处理:
lock(key, expiresec){ issuccess = setnx key if (issuccess) expire key expiresec }
这种方式貌似没什么问题,但是假如在setnx后,redis崩溃了,expire就没有执行,结果就是死锁了。锁永远不会超时。
2、为什么前面的锁已经超时了,还要用getset去设置新的时间戳的时间获取旧的值,然后和外面的判断超时时间的时间戳比较呢?
因为是分布式的环境下,可以在前一个锁失效的时候,有两个进程进入到锁超时的判断。如:
c0超时了,还持有锁,c1/c2同时请求进入了方法里面
c1/c2获取到了c0的超时时间
c1使用getset方法
c2也执行了getset方法
假如我们不加 oldvaluestr.equals(currentvaluestr) 的判断,将会c1/c2都将获得锁,加了之后,能保证c1和c2只能一个能获得锁,一个只能继续等待。
注意:这里可能导致超时时间不是其原本的超时时间,c1的超时时间可能被c2覆盖了,但是他们相差的毫秒及其小,这里忽略了。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。