如何使用Redis实现一个安全可靠的分布式锁
这篇文章给大家分享的是有关如何使用Redis实现一个安全可靠的分布式锁的内容。小编觉得挺实用的,因此分享给大家做个参考,一起跟随小编过来看看吧。
并发场景下多个进程或线程共享资源的读写,需要保证对资源的访问互斥。在单机系统中,我们可以使用Java并发包中的API、synchronized关键字等方式来解决;但是在分布式系统下,这些方式不再适用,我们需要自己实现分布式锁。
常见的分布式锁的实现方案有:基于数据库、基于Redis、基于Zookeeper等。作为Redis专题的一部分,本文将基于Redis聊一聊分布式锁的实现方案。
分析与实现问题分析
分布式锁与JVM内置的锁有着共同的目的:让应用程序以预期的顺序访问或操作共享的资源,防止多个线程同时对同一资源操作,导致系统运行紊乱、不可控。常常用于商品库存扣减、优惠券扣减等场景。
理论上来讲,为了保证锁的安全性和有效性,分布式锁至少需要满足以下条件:
互斥性:在同一时间内,仅有一个线程能够获得锁;
无死锁:线程获取锁后,必须保证能够释放,即使线程获取锁后应用程序宕机,也能在限定时间内释放;
加锁和解锁必须是同一个线程;
在实现方式上,分布式锁大体分为三个步骤:
a-获取资源的操作权;
b-对资源执行操作;
c-释放资源的操作权;
无论是Java内置的锁,还是分布式锁,也无论使用哪种分布式实现方案,都是围绕a、c两个步骤展开。Redis对于实现分布式锁天然友好,原因如下:
命令处理阶段Redis使用单线程处理,同一个key同时只有一个线程能够处理,没有多线程竞态问题。
SET key value NX PX milliseconds
命令在不存在key的情况下添加具有过期时间的key,为安全加锁提供支持。
Lua脚本和DEL命令为安全解锁提供可靠支撑。
代码实现Maven依赖
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId><version>${your-spring-boot-version}</version></dependency>
配置文件
在application.properties增加以下内容,单机版Redis实例。
spring.redis.database=0spring.redis.host=localhostspring.redis.port=6379
RedisConfig
@ConfigurationpublicclassRedisConfig{//自己定义了一个RedisTemplate@Bean@SuppressWarnings("all")publicRedisTemplate<String,Object>redisTemplate(RedisConnectionFactoryfactory)throwsUnknownHostException{//我们为了自己开发方便,一般直接使用<String,Object>RedisTemplate<String,Object>template=newRedisTemplate<String,Object>();template.setConnectionFactory(factory);//Json序列化配置Jackson2JsonRedisSerializerjackson2JsonRedisSerializer=newJackson2JsonRedisSerializer(Object.class);ObjectMapperom=newObjectMapper();om.setVisibility(PropertyAccessor.ALL,JsonAutoDetect.Visibility.ANY);om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);jackson2JsonRedisSerializer.setObjectMapper(om);//String的序列化StringRedisSerializerstringRedisSerializer=newStringRedisSerializer();//key采用String的序列化方式template.setKeySerializer(stringRedisSerializer);//hash的key也采用String的序列化方式template.setHashKeySerializer(stringRedisSerializer);//value序列化方式采用jacksontemplate.setValueSerializer(jackson2JsonRedisSerializer);//hash的value序列化方式采用jacksontemplate.setHashValueSerializer(jackson2JsonRedisSerializer);template.afterPropertiesSet();returntemplate;}}
RedisLock
@ServicepublicclassRedisLock{@ResourceprivateRedisTemplate<String,Object>redisTemplate;/***加锁,最多等待maxWait毫秒**@paramlockKey锁定key*@paramlockValue锁定value*@paramtimeout锁定时长(毫秒)*@parammaxWait加锁等待时间(毫秒)*@returntrue-成功,false-失败*/publicbooleantryAcquire(StringlockKey,StringlockValue,inttimeout,longmaxWait){longstart=System.currentTimeMillis();while(true){//尝试加锁Booleanret=redisTemplate.opsForValue().setIfAbsent(lockKey,lockValue,timeout,TimeUnit.MILLISECONDS);if(!ObjectUtils.isEmpty(ret)&&ret){returntrue;}//计算已经等待的时间longnow=System.currentTimeMillis();if(now-start>maxWait){returnfalse;}try{Thread.sleep(200);}catch(Exceptionex){returnfalse;}}}/***释放锁**@paramlockKey锁定key*@paramlockValue锁定value*@returntrue-成功,false-失败*/publicbooleanreleaseLock(StringlockKey,StringlockValue){//lua脚本Stringscript="ifredis.call('get',KEYS[1])==ARGV[1]thenreturnredis.call('del',KEYS[1])elsereturn0end";DefaultRedisScript<Long>redisScript=newDefaultRedisScript<>(script,Long.class);Longresult=redisTemplate.opsForValue().getOperations().execute(redisScript,Collections.singletonList(lockKey),lockValue);returnresult!=null&&result>0L;}}
测试用例
@SpringBootTestclassRedisDistLockDemoApplicationTests{@ResourceprivateRedisLockredisLock;@TestpublicvoidtestLock(){redisLock.tryAcquire("abcd","abcd",5*60*1000,5*1000);redisLock.releaseLock("abcd","abcd");}}安全隐患
可能很多同学(也包括我)在日常工作中都是使用上面的实现方式,看似是稳妥的:
使用set
命令NX
、PX
选项进行加锁,保证了加锁互斥,避免了死锁;
使用lua脚本解锁,防止解除其他线程的锁;
加锁、解锁命令都是原子操作;
其实以上实现的稳妥有个前提条件:单机版Redis、开启AOF持久化方式并设置appendfsync=always
。
但是在哨兵模式和集群模式下可能存在问题,为什么呢?
哨兵模式和集群模式基于主从架构,主从之间通过命令传播实现数据同步,而命令传播是异步的。
所以就存在主节点数据写入成功,在还未通知从节点情况下,主节点就宕机的可能。
当从节点通过故障转移提升为新的主节点后,其他线程就有机会重新加锁成功,导致不满足分布式锁的互斥条件。
官方RedLock集群模式下,若集群所有节点稳定运行,不出现故障转移的情况下,安全性是有保障的。但是,没有什么系统能够保证100%稳定,基于Redis的分布式锁必须考虑容错。
由于主从同步基于异步复制原理,所以哨兵模式和集群模式天生无法满足此条件。为此,Redis作者专门提出了一种解决方案——RedLock(Redis Distribute Lock)。
设计思路根据官方文档的说明,把RedLock的设计思路进行介绍。
先说环境要求,需要N(N>=3)个独立部署的Redis实例,相互之间不需要主从复制、故障转移等技术。
为了获取锁,客户端将按照以下流程进行操作:
获取当前时间(毫秒)作为开始时间start;
使用相同的key和随机value,按顺序向所有N个节点发起获取锁的请求。当向每个实例设置锁时,客户端会使用一个过期时间(小于锁的自动释放时间)。比如锁的自动释放时间是10秒,这个超时时间应该是5-50毫秒。这是为了防止客户端在一个已经宕机的实例浪费太多时间:如果Redis实例宕机,客户端尽快处理下一个实例。
客户端计算加锁消耗的时间cost(cost=start-now)。只有客户端在半数以上实例加锁成功,并且整个耗时小于整个有效时间(ttl),才能认为当前客户端加锁成功。
如果客户端加锁成功,那么整个锁的真正有效时间应该是:validTime=ttl-cost。
如果客户端加锁失败(可能是获取锁成功实例数未过半,也可能是耗时超过ttl),那么客户端应该向所有实例尝试解锁(即使刚刚客户端认为加锁失败)。
RedLock的设计思路延续了Redis内部多种场景的投票方案,通过多个实例分别加锁解决竞态问题,虽然加锁消耗了时间,但是消除了主从机制下的安全问题。
代码实现官方推荐Java实现为Redisson,它具备可重入特性,按照RedLock进行实现,支持独立实例模式、集群模式、主从模式、哨兵模式等;API比较简单,上手容易。示例如下(直接通过测试用例):
@TestpublicvoidtestRedLock()throwsInterruptedException{Configconfig=newConfig();config.useSingleServer().setAddress("redis://127.0.0.1:6379");finalRedissonClientclient=Redisson.create(config);//获取锁实例finalRLocklock=client.getLock("test-lock");//加锁lock.lock(60*1000,TimeUnit.MILLISECONDS);try{//假装做些什么事情Thread.sleep(50*1000);}catch(Exceptionex){ex.printStackTrace();}finally{//解锁lock.unlock();}}
Redisson封装的非常好,我们可以像使用Java内置的锁一样去使用,代码简洁的不能再少了。关于Redisson源码的分析,网上有很多文章大家可以找找看。
全文总结分布式锁是我们研发过程中常用的的一种解决并发问题的方式,Redis是只是一种实现方式。
关键的是要弄清楚加锁、解锁背后的原理,以及实现分布式锁需要解决的核心问题,同时考虑我们所采用的中间件有什么特性可以支撑。了解这些后,实现起来就不是什么问题了。
感谢各位的阅读!关于“如何使用Redis实现一个安全可靠的分布式锁”这篇文章就分享到这里了,希望以上内容可以对大家有一定的帮助,让大家可以学到更多知识,如果觉得文章不错,可以把它分享出去让更多的人看到吧!
声明:本站所有文章资源内容,如无特殊说明或标注,均为采集网络资源。如若本站内容侵犯了原著者的合法权益,可联系本站删除。