Redis分布式锁
分布式应用在逻辑 处理中经常会遇到并发问题。如一个操作要修改用户的状态,需要先读出用户的状态,再在内存中进行修改,改完了再还回去。但是如果有多个这样的操作同时进行,就会出现并发问题,,因为读取和修改这两个操作不是原子操作(原子操作是指不会被线程调度机制打断的操作,原子操作一旦开始,就会一直运行结束,中间不会有任何线程切换。)
什么是分布式锁
分布式锁就是,控制分布式系统不同进程共同访问共享资源的一种锁的实现。
如果不同的系统或同一个系统的不同主机之间共享了某个临界资源,往往需要互斥来防止彼此干扰,以保证一致性。
简单来说:分布式锁就是在分布式系统下用来控制同一时刻,只有一个 JVM 进程中的一个线程可以访问被保护的资源。
合格分布式锁的特征:
1、互斥性:任意时刻,只有一个客户端能持有锁。
2、锁超时释放:持有锁超时,可以释放,防止不必要的资源浪费,也可以防止死锁。
3、可重入性:一个线程如果获取了锁之后,可以再次对其请求加锁。
4、高性能和高可用:加锁和解锁需要开销尽可能低,同时也要保证高可用,避免分布式锁失效。
5、安全性:锁只能被持有的客户端删除,不能被其他客户端删除
分布式锁的原理
分布式锁本质上就是在Redis里面占一个坑,当别的线程也要来占坑时,发现已经被占了,只好放弃或者稍后再试。
占坑一般使用setnx(set if not exists)指令,如果 key 不存在,则设置 value 给这个key,返回1;否则啥都不做,返回0。只允许一个客户端占坑,先来先占,完成操作再调用del命令释放坑。
需要注意:
1)、一定要用 SET key value [EX seconds|PX milliseconds|KEEPTTL] [NX|XX] [GET] 执行,如SET key value EX 60 NX来保证setnx和expire指令原子执行
2)、value要具有唯一性。这个是为了在解锁的时候,需要验证value是和加锁的一致才删除key(解铃还须系铃人)。同时,验证value和释放锁也要保证原子性,可以通过lua脚本来实现。如:
// 获取锁的 value 与 ARGV[1] 是否匹配,匹配则执行 del if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
死锁问题:
如果逻辑执行中出现异常,del指令没有被调用,导致锁不能释放,就会造成死锁问题,锁永远得不到释放。
因此需在拿到锁时设置过期时间,这样即使出现异常也能保证在到期之后释放锁。
redis1.x版本需要用两个指令来获取锁和设置过期时间,分别是setnx和expire,但是setnx和expire是两条指令而不是原子指令,如果在setnx和expire两个指令之间
服务器挂掉了也会导致expire得不到执行,也会造成死锁。解决这个问题需要使用lua脚本来使这两个指令变成一个原子操作。
Redis2.8版本中加入了set指令的拓展参数,可以使得setnx和expire指令可以原子执行。如:SET key value EX 60 NX
超时问题:
如果在加锁后的逻辑处理执行时间太长,以至于超过了锁的超时机制,就会出现问题,因为这个时候,A线程持有的锁过期了,但A线程的逻辑还未处理
完,这时候B线程获得了锁,仍然存在并发问题。如果这时A线程执行完成了任务,然后去释放锁,这时释放的就是B线程创建和持有的锁。
为了避免这个问题:
1、Redis分布式锁不要用来执行较长时间的任务
2、加锁的value是个特殊值(如uuid),只有持有锁的线程知道(可使用ThreadLocal存放在线程本地变量中),释放锁前先对比value是否相同,相同的话再释放锁。
为了防止对比时,释放锁前当前锁超时,其他线程再创建新的锁,需要使获取锁value和释放锁是一个原子操作,用lua脚本来解决。
分布式锁之过期时间到了锁失效但任务还未执行完毕
某个线程在申请分布式锁的时候,为了应对极端情况,比如机器宕机,那么这个锁就一直不能被释放。一个比较好的解决方案是,申请锁的时候,预估一个程序的执行时间,然后给锁设置一个超时时间,这样,即使机器宕机,锁也能自动释放。
但是这也带来了一个问题,就是在有时候负载很高,任务执行的很慢,锁超时自动释放了任务还未执行完毕,这时候其他线程获得了锁,导致程序执行的并发问题。
对这种情况的解决方案是:
①:锁的超时时间放大为平均执行时间的3~5倍
②:在获得锁之后,就开启一个守护线程,定时去查询Redis分布式锁的到期时间,如果发现将要过期了,就进行续期(Redisson对此做了封装,建议使用)。
代码中加锁和释放锁使用
加锁代码放在try代码块中
释放锁的代码一定放在finally代码块中,保证出现异常,一定会释放锁
分布式锁之Redlock(红锁)算法
我们通常使用Cluster集群或者哨兵集群部署来保证Redis的高可用。
这两种模式都是基于主从架构数据同步复制实现的数据同步,而Redis的主从复制默认是异步的。
集群环境下分布式锁的问题:
在Sentinel集群中,当主节点挂掉时,从节点会取而代之,但客户端上并没有明显感知。比如第一个客户端在主节点上申请成功了一把锁,但是这把锁还没有来得及同步到从节点,主节点突然挂掉了,然后从节点变成了主节点,这个新的主节点内部没有这个锁,所以当另一个客户端过来请求加锁时,立即就批准了。这样导致系统中同样一把锁被两个客户端同时持有,不安全性由此产生。
这种不安全仅在主从发生failover(失效接管)的情况下才会产生,持续的时间极短,业务系统多数情况下可以容忍。
Redlock的出现就是为了解决这个问题。
Redlock 红锁是为了解决主从架构中当出现主从切换导致多个客户端持有同一个锁而提出的一种算法
要使用Redlock,需要提供多个Redis Master实例,这些实例之间相互独立,没有主从关系。同很多分布式算法一样,Redlock也使用 “大多数机制“;
加锁时,它会向过半节点发送 set(key,value,nx=True,ex=xxx)指令,只要过半节点set成功,就认为加锁成功。释放锁时,需要向所有节点发送del指令。
不过Redlock算法还需要考虑出错重试、时钟漂移(时钟抖动频率在10hz一下)等很多细节问题。同时因为Redlock需要向多个节点进行读写,意味着其相比单实例Redis的性能会下降一些
Redlock使用场景:非常看重高可用性,即使Redis挂了一台也完全不受影响就使用Redlock。代价是需要更多的Redis实例,性能也会下降,需要引入额外的library,运维上也需要区别对待,因此不推荐使用。
Redisson
git官方地址:https://github.com/redisson/redisson
Redisson是一个企业级的开源Redis Client,也提供了分布式锁的支持
上面说了为了避免死锁问题,需要加锁的同时设置有效期。但是又存在超时问题,如果超时锁失效了,任务还未执行完毕,其他线程可能获得锁,又会造成安全问题。
Redisson分布式锁的实现:
Config config = new Config(); config.useClusterServers() .addNodeAddress("redis://ip:port") .addNodeAddress("redis://ip:port") ...; RedissonClient redisson = Redisson.create(config); RLock lock = redisson.getLock("key"); lock.lock(); // 获得锁 lock.unlock(); // 释放锁
只需要通过它的api中的lock和unlock即可完成分布式锁,具体细节交给Redisson去实现:
1)redisson所有指令都通过lua脚本执行,redis支持lua脚本原子性执行
2)watch dog自动延期机制
redisson设置一个key的默认过期时间为30s,如果某个客户端持有一个锁超过了30s,还想继续持有这把锁,怎么办?
redisson中有一个watchdog的概念,翻译过来就是看门狗,它会在你获取锁之后,每隔10秒检查一下锁是否释放,如果没有释放,则帮你把key的超时时间重新设为30s。这样的话,就算一直持有锁也不会出现key过期了,其他线程获取到锁的问题了。
redisson的“看门狗”逻辑保证了没有死锁发生。
(如果机器宕机了,看门狗也就没了。此时就不会延长key的过期时间,到了30s之后就会自动过期了,其他线程可以获取到锁)
注意:
①:watchDog 只有在未显示指定加锁超时时间(leaseTime)时才会生效
②:lockWatchdogTimeout 设定的时间不要太小 ,比如设置的是 100 毫秒,由于网络直接导致加锁完后,watchdog 去延期时,这个 key 在 redis 中已经被删除了
3)支持可重入锁
加锁几次,释放几次
Redisson实践
引入依赖
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.15.0</version> </dependency>
1、配置连接Redis
// 1. Create config object Config config = new Config(); config.useClusterServers() // use "rediss://" for SSL connection .addNodeAddress("redis://127.0.0.1:7181"); // or read config from file config = Config.fromYAML(new File("config-file.yaml"));
2、创建Redisson实例
// 2. Create Redisson instance // Sync and Async API RedissonClient redisson = Redisson.create(config);
3、获取map缓存,通过Redisson封装的ConcurrentMap的实现
// 3. Get Redis based implementation of java.util.concurrent.ConcurrentMap RMap<MyKey, MyValue> map = redisson.getMap("myMap");
4、获取分布式锁,通过Redisson封装的Lock的实现
// 4. Get Redis based implementation of java.util.concurrent.locks.Lock RLock lock = redisson.getLock("myLock");
5、获取基于Redis的java.util.concurrent.ExecutorService的实现
// 5. Get Redis based implementation of java.util.concurrent.ExecutorService RExecutorService executor = redisson.getExecutorService("myExecutorService");
6、加锁和释放锁
lock.lock(); // 获得锁 lock.unlock(); // 释放锁
与Spring整合
wiki地址:https://github.com/redisson/redisson/tree/master/redisson-spring-data
1、引入依赖
<!--redisson-->
<dependency>
<groupId>org.redisson</groupId>
<!-- for Spring Data Redis v.2.4.x -->
<artifactId>redisson-spring-data-24</artifactId>
<version>3.15.0</version>
</dependency>
2、配置 RedissonConfig,注册RedissonConnectionFactory到Spring容器中
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.SingleServerConfig;
import org.redisson.spring.data.connection.RedissonConnectionFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Redis分布式锁,Redisson
*
* @author yangyongjie
* @date 2022/11/18
*/
@Configuration
public class RedissonConfig {
@Bean
public RedissonConnectionFactory redissonConnectionFactory(RedissonClient redissonClient) {
return new RedissonConnectionFactory(redissonClient);
}
@Bean(destroyMethod = "shutdown")
public RedissonClient redissonClient() {
Config redissonConfig = new Config();
// 单机模式
SingleServerConfig singleServerConfig = redissonConfig.useSingleServer();
singleServerConfig.setPassword("xxx");
// Redis url should start with redis:// or rediss:// (for SSL connection)
singleServerConfig.setAddress("redis://host:port");
// 主从模式
// redissonConfig.useMasterSlaveServers()
// .setMasterAddress("redis://127.0.0.1:6379")
// .addSlaveAddress(" redis://127.0.0.1:6379");
// 哨兵模式
/* redissonConfig.useSentinelServers()
.addSentinelAddress("redis://127.0.0.1:6379", "redis://127.0.0.1:6389")
.setMasterName("master")
.setPassword("password")
.setDatabase(0);*/
// 集群模式
/* redissonConfig.useClusterServers()
// use "rediss://" for SSL connection
.addNodeAddress("redis://127.0.0.1:6379", "redis://127.0.0.1:7181", "redis://127.0.0.1:7182");*/
// Redisson客户端
RedissonClient redissonClient = Redisson.create(redissonConfig);
// 为RedissonUtil注入RedissonClient 依赖
RedissonUtil.setRedissonClient(redissonClient);
return redissonClient;
}
}
3、使用 RedissonClient 加解锁
需要注意,tryLock、lock、unlock 在执行失败(获取锁/释放锁)时,就会抛出异常,需要进行异常处理判断
1)在需要加锁的服务里注入RedissonClient 依赖,即可使用
@Autowired private RedissonClient redissonClient; @Test void redissonTest() { RLock lock = redissonClient.getLock("myRedissonLock"); try { lock.lock(); // do something } finally { lock.unlock(); } }
2)封装 RedissonUtil,为 RedissonUtil 注入 RedissonClient 依赖
① RedissonUtil :
import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.concurrent.TimeUnit; public class RedissonUtil { private RedissonUtil() { } private static final Logger LOGGER = LoggerFactory.getLogger(RedissonUtil.class); private static RedissonClient redissonClient; public static void setRedissonClient(RedissonClient redissonClient) { RedissonUtil.redissonClient = redissonClient; } /** * 尝试获取锁 * * @param lockKey * @return 成功true,失败false */ public static Boolean tryLock(String lockKey) { try { RLock rLock = redissonClient.getLock(lockKey); return rLock.tryLock(); } catch (Exception e) { LOGGER.error("tryLock error: " + e.getMessage(), e); return false; } } /** * 超时尝试获取锁 * * @param lockKey * @param waitTime 超时等待时间,单位秒 * @return 成功true,失败false */ public static Boolean tryLock(String lockKey, long waitTime) { try { RLock rLock = redissonClient.getLock(lockKey); return rLock.tryLock(waitTime, TimeUnit.SECONDS); } catch (Exception e) { LOGGER.error("tryLock error: " + e.getMessage(), e); return false; } } /** * 加锁,默认持有锁30秒 * * @param lockKey * @return 成功true,失败false */ public static Boolean lock(String lockKey) { try { RLock rLock = redissonClient.getLock(lockKey); // 默认加锁生存时间为30s rLock.lock(); return true; } catch (Exception e) { LOGGER.error("lock error: " + e.getMessage(), e); return false; } } /** * 加锁,自定义持有时间,单位秒 * * @param lockKey * @param holdTime 持有时长 * @return 成功true,失败false */ public static Boolean lock(String lockKey, long holdTime) { try { RLock rLock = redissonClient.getLock(lockKey); // 默认加锁生存时间为30s rLock.lock(holdTime, TimeUnit.SECONDS); return true; } catch (Exception e) { LOGGER.error("lock error: " + e.getMessage(), e); return false; } } /** * 释放锁 * * @param lockKey * @return 成功true,失败false */ public static Boolean unlock(String lockKey) { try { RLock rLock = redissonClient.getLock(lockKey); rLock.unlock(); return true; } catch (Exception e) { LOGGER.error("unlock error: " + e.getMessage(), e); return false; } } }
② 为 RedissonUtil 注入 RedissonClient 依赖(建议在RedissonConfig中在创建完RedissonClient对象之后即为RedissonUtil注入RedissonClient 依赖):
@PostConstruct public void initRedissonClient() { RedissonUtil.setRedissonClient((RedissonClient) applicationContext.getBean("redissonClient")); }
③ 使用
String lockKey = "myRedissonLock"; try { Boolean isLock = RedissonUtil.lock(lockKey); // do something } finally { RedissonUtil.unlock(lockKey); }
锁结构:
hashKey为线程id,value为重入次数,默认生存时间为30s
Redisson源码
加锁:
/** * KEYS[1] 表示的是 getName() ,代表的是锁名 * ARGV[1] 表示的是 internalLockLeaseTime 默认值是30s * ARGV[2] 表示的是 getLockName(threadId) 代表的是 id:threadId 用锁对象id+线程id, 表示当前访问线程,用于区分不同服务器上的线程 * * @param waitTime -1 * @param leaseTime 30 * @param unit * @param threadId * @param command * @param <T> * @return */ <T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) { internalLockLeaseTime = unit.toMillis(leaseTime); return evalWriteAsync(getName(), LongCodec.INSTANCE, command, "if (redis.call('exists', KEYS[1]) == 0) then " + // 如果锁空闲 "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + // 则向redis中添加一个key为指定锁名的set,并且向set中添加一个field为线程id,值=1的键值对,表示此线程的重入次数为1 "redis.call('pexpire', KEYS[1], ARGV[1]); " + // 设置生存时间 "return nil; " + "end; " + "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + // 如果锁存在,且是当前线程持有的 "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + // 重入次数+1 "redis.call('pexpire', KEYS[1], ARGV[1]); " + // 设置生存时间 "return nil; " + "end; " + "return redis.call('pttl', KEYS[1]);", // 锁存在, 但不是当前线程加的锁,则返回锁的过期时间 Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId)); }
解锁:
···/** * KEYS[1] 表示的是getName() 代表锁名test_lock * KEYS[2] 表示getChanelName() 表示的是发布订阅过程中使用的Chanel * ARGV[1] 表示的是LockPubSub.unLockMessage 是解锁消息,实际代表的是数字 0,代表解锁消息 * ARGV[2] 表示的是internalLockLeaseTime 默认的有效时间 30s * ARGV[3] 表示的是getLockName(thread.currentThread().getId()),是当前锁id+线程id * * @param threadId * @return */ protected RFuture<Boolean> unlockInnerAsync(long threadId) { return evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " + // 如果锁不存在;或者锁存在,但不是当前线程锁加的锁,则返回nil结束 "return nil;" + "end; " + "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " + // 锁是当前线程持有,重入次数减1 "if (counter > 0) then " + "redis.call('pexpire', KEYS[1], ARGV[2]); " + // 重入次数减1后仍大于0,则续生存时间 "return 0; " + "else " + "redis.call('del', KEYS[1]); " + // 删除锁 "redis.call('publish', KEYS[2], ARGV[1]); " + // 发布锁删除的消息,channel为 redisson_lock__channel:{lock_name} "return 1; " + "end; " + "return nil;", Arrays.asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId)); }
总结:
Redission锁的结构为hash,key为锁的key
Redission加锁会在锁的hash中添加field为当前线程id,value为1的元素
Redission解锁会获取当前线程的id判断是不是当前线程加的锁,会则释放成功,否则释放失败
注意:Redission适合自动续期,任务处理完手动释放锁的场景。不适合超时自动释放的场景,超时自动释放的场景使用setnx+expire
分布式锁的应用
1、高并发下解决库存超卖问题
方案1:查询库存、扣减库存、下单(扣减数据库库存)和操作 需要先获取该商品的分布式锁
方案2:将查询和扣减库存使用lua脚本保证原子性(因为redis是单线程的,所以高并发下扣减库存操作无需加锁),扣减成功之后发布MQ消息异步消费再创建订单扣减数据库库存
附录:
SETNX:SET if Not eXists。当key已经存在时,什么都不做。
SET key value [EX seconds|PX milliseconds|KEEPTTL] [NX|XX] [GET]
Options
/** * 将 key 的值设为 value ,当且仅当 key 不存在 * 同redisTemplate.opsForValue().setIfAbsent() * * @param key * @param value * @return 拿到锁(设置key成功),返回true;否则,返回false */ public static Boolean setnx(String key, String value) { try { return redisTemplate.execute(new RedisCallback<Boolean>() { @Override public Boolean doInRedis(RedisConnection connection) throws DataAccessException { byte[] keyBys = redisTemplate.getStringSerializer().serialize(key); byte[] valBys = redisTemplate.getStringSerializer().serialize(value); return connection.setNX(keyBys, valBys); } }); } catch (Exception e) { LOGGER.error("setnx error:" + e.getMessage(), e); return false; } } /** * 将 key 的值设为 value ,当且仅当 key 不存在 * * @param key * @param value * @return 拿到锁(设置key成功),返回true;否则,返回false */ public static Boolean setnx2(String key, String value) { try { return redisTemplate.opsForValue().setIfAbsent(key, value); } catch (Exception e) { LOGGER.error("setnx error:" + e.getMessage(), e); return false; } } /** * 将 key 的值设为 value ,当且仅当 key 不存在,并设置有效期,具有原子性 * * @param key * @param value * @param seconds * @return 拿到锁(设置key成功),返回true;否则,返回false */ public static Boolean setnx(String key, String value, long seconds) { try { return redisTemplate.execute(new RedisCallback<Boolean>() { @Override public Boolean doInRedis(RedisConnection connection) throws DataAccessException { byte[] keyBys = redisTemplate.getStringSerializer().serialize(key); byte[] valBys = redisTemplate.getStringSerializer().serialize(value); return connection.set(keyBys, valBys, Expiration.seconds(seconds), RedisStringCommands.SetOption.SET_IF_ABSENT); } }); } catch (Exception e) { LOGGER.error("setnx and expire error:" + e.getMessage(), e); return false; } } /** * 将 key 的值设为 value ,当且仅当 key 不存在,并设置有效期,具有原子性 * * @param key * @param value * @param seconds * @return 拿到锁(设置key成功),返回true;否则,返回false */ public static Boolean setnx2(String key, String value, long seconds) { try { return redisTemplate.opsForValue().setIfAbsent(key, value, seconds, TimeUnit.SECONDS); } catch (Exception e) { LOGGER.error("setnx and expire error:" + e.getMessage(), e); return false; } }
2)解锁:
使用lua脚本校验value确保加锁和解锁是同一线程操作(解铃还须系铃人)
/** * 释放Redis锁 * 使用lua脚本,确保判断是否是加锁人与删除锁的原子性 * * @param lockKey 分布式锁key * @param lockValue 分布式锁value * @return */ public static Boolean unlock(String lockKey, String lockValue) { // 脚本,保证原子性,先判断分布式锁的值是否匹配,匹配再执行删除锁 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; try { RedisScript<Long> redisScript = RedisScript.of(script, Long.class); Long result = redisTemplate.execute(redisScript, Collections.singletonList(lockKey), lockValue); return result == 1; } catch (Exception e) { LOGGER.error("unlock error:" + e.getMessage(), e); return false; } }