redis 分布式锁简单实践
redis 分布式锁简单实践
参考:
- https://zhuanlan.zhihu.com/p/129740066
- https://my.oschina.net/wangnian/blog/668830
- https://www.cnblogs.com/0201zcr/p/5942748.html
- https://github.com/redisson/redisson/wiki/2.-配置方法
- https://segmentfault.com/a/1190000023038777
1. 分布式锁
- 分布式锁要满足的几个条件:
- 系统是一个分布式系统(关键是分布式,单机的可以使用ReentrantLock或者synchronized代码块来实现)
- 共享资源(各个系统访问同一个资源,资源的载体可能是传统关系型数据库或者NoSQL)
- 同步访问(即有很多个进程同事访问同一个共享资源。没有同步访问,谁管你资源竞争不竞争)
- 所以分布式需要满足的特性:
- 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;
- 高可用的获取锁与释放锁;
- 高性能的获取锁与释放锁;
- 具备可重入特性;
- 具备锁失效机制,防止死锁;
- 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。
通常为了保证数据的最终一致性,需要来实现分布式锁。一般有以下方法来实现:
基于数据库实现分布式锁;
基于缓存(Redis等)实现分布式锁;
基于Zookeeper实现分布式锁;
2. 基于redis实现分布式锁的基本思想
- 选用Redis实现分布式锁原因:
(1)Redis有很高的性能;
(2)Redis命令对此支持较好,实现起来比较方便
- 使用命令介绍:
(1)SETNX
SETNX key val:当且仅当key不存在时,set一个key为val的字符串,返回1;若key存在,则什么都不做,返回0。
(2)expire
expire key timeout:为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁。
(3)delete
delete key:删除key
在使用Redis实现分布式锁的时候,主要就会使用到这三个命令。
- 实现思想:
(1)获取锁的时候,使用setnx加锁,并使用expire命令为锁添加一个超时时间,超过该时间则自动释放锁,锁的value值为一个随机生成的UUID,通过此在释放锁的时候进行判断。
(2)获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
(3)释放锁的时候,通过UUID判断是不是该锁,若是该锁,则执行delete进行锁释放。
3. jedisLock—redis分布式锁实现
方法一:
package redisLock;
import redis.clients.jedis.Jedis;
/**
* @author xingzhou
* @version 1.0.0
* @ClassName JedisLock.java
* @Description TODO
* @createTime 2022年04月04日 20:58:00
*/
public class JedisLock {
Jedis jedis;
/**
* redis key
*/
String lockKey;
/**
* 过期时间
*/
int expireMsecs;
/**
* 超时时间
*/
int timeoutMsecs;
/**
* redis锁状态
*/
boolean locked;
public JedisLock(Jedis jedis, String lockKey) {
this.expireMsecs = 60000;
this.timeoutMsecs = 10000;
this.locked = false;
this.jedis = jedis;
this.lockKey = lockKey;
}
public JedisLock(Jedis jedis, String lockKey, int timeoutMsecs) {
this(jedis, lockKey);
this.timeoutMsecs = timeoutMsecs;
}
public JedisLock(Jedis jedis, String lockKey, int timeoutMsecs, int expireMsecs) {
this(jedis, lockKey, timeoutMsecs);
this.expireMsecs = expireMsecs;
}
public JedisLock(String lockKey) {
this((Jedis) null, lockKey);
}
public JedisLock(String lockKey, int timeoutMsecs) {
this((Jedis) null, lockKey, timeoutMsecs);
}
public JedisLock(String lockKey, int timeoutMsecs, int expireMsecs) {
this((Jedis) null, lockKey, timeoutMsecs, expireMsecs);
}
public String getLockKey() {
return this.lockKey;
}
public synchronized boolean acquire() throws InterruptedException {
return this.acquire(this.jedis);
}
/**
* 获得 lock.
* 实现思路: 主要是使用了redis 的setnx命令,缓存了锁.
* reids缓存的key是锁的key,所有的共享, value是锁的到期时间(注意:这里把过期时间放在value了,没有时间上设置其超时时间)
* 执行过程:
* 1.通过setnx尝试设置某个key的值,成功(当前没有这个锁)则返回,成功获得锁
* 2.锁已经存在则获取锁的到期时间,和当前时间比较,超时的话,则设置新的值
*/
public synchronized boolean acquire(Jedis jedis) throws InterruptedException {
int timeout = this.timeoutMsecs;
while (timeout >= 0) {
long expires = System.currentTimeMillis() + (long) this.expireMsecs + 1L;
//锁到期时间
String expiresStr = String.valueOf(expires);
if (jedis.setnx(this.lockKey, expiresStr) == 1L) {
this.locked = true;
return true;
}
/**
* 判断是否为空,不为空的情况下,如果被其他线程设置了值,则第二个条件判断是过不去的
* 获取上一个锁到期时间,并设置现在的锁到期时间,
* 只有一个线程才能获取上一个线上的设置时间,因为jedis.getSet是同步的
*/
String currentValueStr = jedis.get(this.lockKey);
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
/**
* 判断是否为空,不为空的情况下,如果被其他线程设置了值,则第二个条件判断是过不去的
* lock is expired
*/
String oldValueStr = jedis.getSet(this.lockKey, expiresStr);
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
/**
* 防止误删(覆盖,因为key是相同的)了他人的锁——这里达不到效果,这里值会被覆盖,但是因为什么相差了很少的时间,所以可以接受
* [分布式的情况下]:如过这个时候,多个线程恰好都到了这里,但是只有一个线程的设置值和当前值相同,他才有权利获取锁
* lock acquired
*/
this.locked = true;
return true;
}
}
/**
延迟100 毫秒, 这里使用随机时间可能会好一点,可以防止饥饿进程的出现,即,当同时到达多个进程,
只会有一个进程获得锁,其他的都用同样的频率进行尝试,后面有来了一些进行,也以同样的频率申请锁,这将可能导致前面来的锁得不到满足.
使用随机的等待时间可以一定程度上保证公平性
*/
timeout -= 100;
Thread.sleep(100L);
}
return false;
}
public synchronized void release() {
this.release(this.jedis);
}
public synchronized void release(Jedis jedis) {
if (this.locked) {
jedis.del(new String[]{this.lockKey});
this.locked = false;
}
}
}
方法二:
package redisLock;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
import java.util.UUID;
/**
* @author xingzhou
* @version 1.0.0
* @ClassName JedisLock_02.java
* @Description TODO
* @createTime 2022年04月04日 21:21:00
*/
public class JedisLock_02 {
private Jedis jedis;
/**
* 锁key
*/
private String key;
/**
* 锁超时时间,毫秒为单位
*/
private int expire;
/**
* 获取锁等待时间,毫秒为单位
*/
private int timeout;
/**
* 是否占有锁
*/
private volatile boolean locked = false;
/**
* 唯一标识
*/
private UUID uuid;
/**
* 线程等待时间
*/
private static final int DEFAULT_ACQUIRY_RESOLUTION_MILLIS = 100;
/**
* 删除key的lua脚本
*/
private static final String LUA_DEL_SCRIPT = "if redis.call('GET',KEYS[1]) == ARGV[1] then return redis.call('DEL',KEYS[1]) else return 0 end";
public JedisLock_02(Jedis videoJedis, String key, int timeout, int expire) {
this.jedis = videoJedis;
this.key = key;
this.timeout = timeout;
this.expire = expire;
this.uuid = UUID.randomUUID();
}
/**
* set值
* 说明:这个命令仅在不存在key的时候才能被执行成功(NX选项),并且这个key有一个自动失效时间(PX属性)
* 这个key的值是一个唯一值,这个值在所有的客户端必须是唯一的,所有同一key的获取者(竞争者)这个值都不能一样。
*
* @param value
* @return
*/
public String setNX(final String value) {
SetParams params = new SetParams();
params.ex(this.expire);
return jedis.set(key, value, params);
}
/**
* 获取锁
*
* @return
*/
public synchronized boolean lock() throws InterruptedException {
long timeout = this.timeout;
while (timeout > 0) {
//获取锁,返回OK则代表获取锁成功
if ("OK".equals(this.setNX(this.getLockValue(Thread.currentThread().getId())))) {
this.locked = true;
return true;
}
timeout -= DEFAULT_ACQUIRY_RESOLUTION_MILLIS;
Thread.sleep(DEFAULT_ACQUIRY_RESOLUTION_MILLIS);
}
return false;
}
/**
* 释放锁
* 说明:通过key和唯一value值删除
*
* @return
*/
public synchronized void release() {
if (this.locked) {
long result = (long) this.jedis.eval(LUA_DEL_SCRIPT, 1, this.key, this.getLockValue(Thread.currentThread().getId()));
if (result > 0) {
this.locked = false;
}
}
}
/**
* 判断当前线程是否还继续拥有锁
* 说明:该方法主要用来判断操作时间已经超过key的过期时间,可以用来做业务过滚
*
* @return
*/
public boolean checkTimeOut() {
if (this.locked) {
String value = this.jedis.get(this.key);
if (this.getLockValue(Thread.currentThread().getId()).equals(value)) {
return true;
}
}
return false;
}
/**
* 锁的value值(唯一值)
* 说明:value的值必须是唯一主要是为了更安全的释放锁,
* 释放锁的时候使用脚本告诉Redis:只有key存在并且存储的值和我指定的值一样才能告诉我删除成功
*
* @param threadId
* @return
*/
public String getLockValue(Long threadId) {
return this.uuid.toString() + "_" + threadId;
}
}
4. 通过redisson简单实现redis分布式锁
redisson官网:https://redisson.org/
redisson文档:https://github.com/redisson/redisson/wiki/2.-配置方法
- 引入redisson客户端
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>2.7.0</version>
</dependency>
4.1 Redisson原理分析
1、加锁机制
线程去获取锁,获取成功: 执行lua脚本,保存数据到redis数据库。
线程去获取锁,获取失败: 一直通过while循环尝试获取锁,获取成功后,执行lua脚本,保存数据到redis数据库。
2、watch dog自动延期机制
简单理解就是:
在一个分布式环境下,假如一个线程获得锁后,突然服务器宕机了,那么这个时候在一定时间后这个锁会自动释放,你也可以设置锁的有效时间(不设置默认30秒),这样的目的主要是防止死锁的发生。
但在实际开发中会有下面一种情况--锁失效之后,业务逻辑还没结束
//设置锁1秒过去
redissonLock.lock("redisson", 1);
/**
* 业务逻辑需要咨询2秒
*/
redissonLock.release("redisson");
/**
* 线程1 进来获得锁后,线程一切正常并没有宕机,但它的业务逻辑需要执行2秒,这就会有个问题,在 线程1 执行1秒后,这个锁就自动过期了,
* 那么这个时候 线程2 进来了。那么就存在 线程1和线程2 同时在这段业务逻辑里执行代码,这当然是不合理的。
* 而且如果是这种情况,那么在解锁时系统会抛异常,因为解锁和加锁已经不是同一线程了,具体后面代码演示。
*/
所以这个时候 看门狗
就出现了,它的作用就是 线程1 业务还没有执行完,时间就过了,线程1 还想持有锁的话,就会启动一个watch dog后台线程,不断的延长锁key的生存时间。
注意 正常这个看门狗线程是不启动的,还有就是这个看门狗启动后对整体性能也会有一定影响,所以不建议开启 看门狗
。
3、为啥要用lua脚本呢?
这个不用多说,主要是如果你的业务逻辑复杂的话,通过封装在lua脚本中发送给redis,而且redis是单线程的,这样就保证这段复杂业务逻辑执行的 原子性
。
4、可重入加锁机制
Redisson可以实现可重入加锁机制的原因,我觉得跟两点有关:
1、Redis存储锁的数据类型是 Hash类型
2、Hash数据类型的key值包含了当前线程信息。
- Redis分布式锁的缺点
Redis分布式锁会有个缺陷,就是在Redis哨兵模式下:
客户端1 对某个 master节点 写入了redisson锁,此时会异步复制给对应的 slave节点。但是这个过程中一旦发生 master节点宕机,主备切换,slave节点从变为了 master节点。
这时 客户端2 来尝试加锁的时候,在新的master节点上也能加锁,此时就会导致多个客户端对同一个分布式锁完成了加锁。
这时系统在业务语义上一定会出现问题, 导致各种脏数据的产生 。
缺陷 在哨兵模式或者主从模式下,如果 master实例宕机的时候,可能导致多个客户端同时完成加锁。
4.2 redisson实践
引入maven依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.10.1</version>
</dependency>
然后,通过配置获取RedissonClient
客户端的实例,然后getLock获取锁的实例,进行操作即可。
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
config.useSingleServer().setPassword("redis1234");
final RedissonClient client = Redisson.create(config);
RLock lock = client.getLock("lock1");
try{
lock.lock();
}finally{
lock.unlock();
}
}
4.2.1 获取锁实例
我们先来看RLock lock = client.getLock("lock1"); 这句代码就是为了获取锁的实例,然后我们可以看到它返回的是一个RedissonLock对象。
public RLock getLock(String name) {
return new RedissonLock(connectionManager.getCommandExecutor(), name);
}
在RedissonLock构造方法中,主要初始化一些属性。
public RedissonLock(CommandAsyncExecutor commandExecutor, String name) {
super(commandExecutor, name);
//命令执行器
this.commandExecutor = commandExecutor;
//UUID字符串
this.id = commandExecutor.getConnectionManager().getId();
//内部锁过期时间
this.internalLockLeaseTime = commandExecutor.
getConnectionManager().getCfg().getLockWatchdogTimeout();
this.entryName = id + ":" + name;
}
4.2.2 加锁
当我们调用lock方法,定位到lockInterruptibly。在这里,完成了加锁的逻辑。
public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
//当前线程ID
long threadId = Thread.currentThread().getId();
//尝试获取锁
Long ttl = tryAcquire(leaseTime, unit, threadId);
// 如果ttl为空,则证明获取锁成功
if (ttl == null) {
return;
}
//如果获取锁失败,则订阅到对应这个锁的channel
RFuture<RedissonLockEntry> future = subscribe(threadId);
commandExecutor.syncSubscription(future);
try {
while (true) {
//再次尝试获取锁
ttl = tryAcquire(leaseTime, unit, threadId);
//ttl为空,说明成功获取锁,返回
if (ttl == null) {
break;
}
//ttl大于0 则等待ttl时间后继续尝试获取
if (ttl >= 0) {
getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
getEntry(threadId).getLatch().acquire();
}
}
} finally {
//取消对channel的订阅
unsubscribe(future, threadId);
}
//get(lockAsync(leaseTime, unit));
}
如上代码,就是加锁的全过程。先调用tryAcquire来获取锁,如果返回值ttl为空,则证明加锁成功,返回;如果不为空,则证明加锁失败。这时候,它会订阅这个锁的Channel,等待锁释放的消息,然后重新尝试获取锁。流程如下:
- 获取锁
获取锁的过程是怎样的呢?接下来就要看tryAcquire方法。在这里,它有两种处理方式,一种是带有过期时间的锁,一种是不带过期时间的锁。
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {
//如果带有过期时间,则按照普通方式获取锁
if (leaseTime != -1) {
return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
//先按照30秒的过期时间来执行获取锁的方法
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(
commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
//如果还持有这个锁,则开启定时任务不断刷新该锁的过期时间
ttlRemainingFuture.addListener(new FutureListener<Long>() {
@Override
public void operationComplete(Future<Long> future) throws Exception {
if (!future.isSuccess()) {
return;
}
Long ttlRemaining = future.getNow();
// lock acquired
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}
接着往下看,tryLockInnerAsync方法是真正执行获取锁的逻辑,它是一段LUA脚本代码。在这里,它使用的是hash数据结构。
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit,
long threadId, RedisStrictCommand<T> command) {
//过期时间
internalLockLeaseTime = unit.toMillis(leaseTime);
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
//如果锁不存在,则通过hset设置它的值,并设置过期时间
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
//如果锁已存在,并且锁的是当前线程,则通过hincrby给数值递增1
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
//如果锁已存在,但并非本线程,则返回过期时间ttl
"return redis.call('pttl', KEYS[1]);",
Collections.<Object>singletonList(getName()),
internalLockLeaseTime, getLockName(threadId));
}
这段LUA代码看起来并不复杂,有三个判断:
- 通过exists判断,如果锁不存在,则设置值和过期时间,加锁成功
- 通过hexists判断,如果锁已存在,并且锁的是当前线程,则证明是重入锁,加锁成功
- 如果锁已存在,但锁的不是当前线程,则证明有其他线程持有锁。返回当前锁的过期时间,加锁失败
加锁成功后,在redis的内存数据中,就有一条hash结构的数据。Key为锁的名称;field为随机字符串+线程ID;值为1。如果同一线程多次调用lock
方法,值递增1。
127.0.0.1:6379> hgetall lock1
1) "b5ae0be4-5623-45a5-8faa-ab7eb167ce87:1"
2) "1"
4.2.3 解锁
我们通过调用unlock方法来解锁。
public RFuture<Void> unlockAsync(final long threadId) {
final RPromise<Void> result = new RedissonPromise<Void>();
//解锁方法
RFuture<Boolean> future = unlockInnerAsync(threadId);
future.addListener(new FutureListener<Boolean>() {
@Override
public void operationComplete(Future<Boolean> future) throws Exception {
if (!future.isSuccess()) {
cancelExpirationRenewal(threadId);
result.tryFailure(future.cause());
return;
}
//获取返回值
Boolean opStatus = future.getNow();
//如果返回空,则证明解锁的线程和当前锁不是同一个线程,抛出异常
if (opStatus == null) {
IllegalMonitorStateException cause =
new IllegalMonitorStateException("
attempt to unlock lock, not locked by current thread by node id: "
+ id + " thread-id: " + threadId);
result.tryFailure(cause);
return;
}
//解锁成功,取消刷新过期时间的那个定时任务
if (opStatus) {
cancelExpirationRenewal(null);
}
result.trySuccess(null);
}
});
return result;
}
然后我们再看unlockInnerAsync方法。这里也是一段LUA脚本代码。
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, EVAL,
//如果锁已经不存在, 发布锁释放的消息
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end;" +
//如果释放锁的线程和已存在锁的线程不是同一个线程,返回null
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
//通过hincrby递减1的方式,释放一次锁
//若剩余次数大于0 ,则刷新过期时间
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
//否则证明锁已经释放,删除key并发布锁释放的消息
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()),
LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
}
如上代码,就是释放锁的逻辑。同样的,它也是有三个判断:
- 如果锁已经不存在,通过publish发布锁释放的消息,解锁成功
- 如果解锁的线程和当前锁的线程不是同一个,解锁失败,抛出异常
- 通过hincrby递减1,先释放一次锁。若剩余次数还大于0,则证明当前锁是重入锁,刷新过期时间;若剩余次数小于0,删除key并发布锁释放的消息,解锁成功
至此,Redisson中的可重入锁的逻辑,就分析完了。