【Redis】Redis 实现分布式锁
Redis 实现分布式锁
普通锁
当多个线程访问同一个共享资源
时,需要某种机制来保证只有满足某个条件(获取锁成功)的线程才能访问到资源,而不满足条件(获取锁失败)的线程只能等待,在下一轮竞争中来获取锁才能访问资源。所以锁是一种用来解决多个执行线程或访问共享资源时出现错误或数据不一致问题的工具。
实现方式:
-
使用
synchronized
关键字。synchronized
是一个隐式锁,因为其解锁和锁定的操作是由 JVM 通过对象的 monitor 监视器锁自动完成的,我们也无法插手整个上锁和解锁的过程。 -
使用
java.util.concurrent.locks.Lock
的实现类,例如java.util.concurrent.locks.ReentrantLock
。这种方式的话就是显示锁了,需要手动上锁和解锁。 -
使用
CAS
(Compare And Swap) 无锁机制,这个的话实际上属于乐观锁机制,核心概念就是比较并交换。在执行CAS
操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。
分布式锁
分布式锁应该具备哪些条件:
互斥性: 同一时刻只能有一个线程持有锁
可重入性: 同一节点上的同一个线程如果获取了锁之后能够再次获取锁
锁超时:和J.U.C中的锁一样支持锁超时,防止死锁
高性能和高可用: 加锁和解锁需要高效,同时也需要保证高可用,防止分布式锁失效
具备阻塞和非阻塞性:能够及时从阻塞状态中被唤醒
首先在分布式、微服务架构中,我们的项目通常是以下图模式来部署的,当然网关
、nginx
也都是存在集群的。
上图可以看到,变量A
存在三个服务器内存中(这个变量A
主要体现是在一个类中的一个成员变量,是一个有状态的对象)。假设我们使用的是普通锁,由于三台服务器之间变量互不可见,当多个请求调用时,可能就会出现操作三个不同内存区域的数据,读取的数据也可能会与设值得不一致。
基于以上问题,就出现了所谓的分布式锁(也不知道这个名字的作者是谁)的概念。其实简单点来说,就是在请求我们的接口时加上一个额外的步骤,只是这个步骤需要一个中间的媒介,比如像Redis
、ZooKeeper
甚至使用Mysql
。这个中间媒介的作用就是相当于一个标记,标记了当前是谁获取到了这个锁。整体的过程就是请求来了,先调用判断中间媒介是否存在,不存在则尝试获取,获取成功或者失败后则和普通锁处理逻辑一样。
三种实现方式:
实现方式这一块上面也说了,主要需要一个中间的媒介。
- 基于
Redis
,当一个客户端向缓存中写成功一个key-value时,其他的客户端不能在写入相同的key - 基于数据库,向数据库插入一条数据(比如用id主键,或者唯一索引)等达到其他的客户端无法再插入相同的数据
- 基于
Zookeeper
,原理就是利用了Zookeeper
的临时节点来实现。当客户端向zk写入节点时,如果写入成功,其他的客户端就无法写入成功,可以理解为互斥。
Redis实现
唯一性
使用 Redis 来实现锁的唯一性话主要是使用SETNX(SET if Not eXists)
命令,这个命令的话就是当指定的 key
不存在时,为 key
设置指定的值。设置成功则返回 1
。 设置失败则返回 0
。
当多个请求来时只有一个能通过SETNX(SET if Not eXists)
设置成功,但是如果这个请求报错了,死循环了或者整个项目直接宕掉了,后面所有的线程就不能设置成功了,那么就涉及防止死锁的问题。
public boolean tryLock_with_set(String key, String UniqueId, int seconds) {
return "OK".equals(jedis.set(key, UniqueId, "NX", "EX", seconds));
}
锁超时
Redis 中防止死锁可以使用设置key
的超时时间。讲道理,这个超时时间是必须要设置的,Redis 也提供了expire
命令来对key
设置过期时间。
但是光涉及过期时间是行不通的,因为redis是单线程模型,单线程模型会导致redis整个命令的执行都是要放到队列里面去的,然后依次排队执行,也就是说当执行setnx
和expire
时,由于是两条命令,不具有原子性,所以会有两次请求与响应的步骤,这个时候就会产生原子性的问题。
比如设置完setnx
后程序直接产生异常或者宕掉,锁将无法过期。
public boolean tryLock(String key,String requset,int timeout) {
Long result = jedis.setnx(key, requset);
// result = 1时,设置成功,否则设置失败
if (result == 1L) {
return jedis.expire(key, timeout) == 1L;
} else {
return false;
}
}
命令原子性
redis支持将两条命令合成一条命令,可以使用三种方式来实现 Pipeline、 Redis 事务 和 Lua 脚本。
但是Pipeline
和 Redis 事务
都是不具备原子性的,那么只能使用Lua 脚本
了。使用Lua 脚本
来保证多条命令的原子性问题。
使用Lua脚本(包含setnx和expire两条指令)
public boolean tryLock_with_lua(String key, String UniqueId, int seconds) {
String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" +
"redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";
List<String> keys = new ArrayList<>();
List<String> values = new ArrayList<>();
keys.add(key);
values.add(UniqueId);
values.add(String.valueOf(seconds));
Object result = jedis.eval(lua_scripts, keys, values);
//判断是否成功
return result.equals(1L);
}
假如给锁加的超时时间是5
秒钟,但是我们在做逻辑操作时,可能因为需要调用别人的接口或者查询数据库出现慢查询超过了5
秒,正常逻辑是当前线程还需要持有这把锁。然后这时锁过期失效了,其他线程就能竞争获取锁,又出现了另外一个线程不安全的问题。
value必须要具有唯一性,我们可以用UUID来做,设置随机字符串保证唯一性,至于为什么要保证唯一性?假如value不是随机字符串,而是一个固定值,那么就可能存在下面的问题:
- 1.客户端1获取锁成功
- 2.客户端1在某个操作上阻塞了太长时间
- 3.设置的key过期了,锁自动释放了
- 4.客户端2获取到了对应同一个资源的锁
- 5.客户端1从阻塞中恢复过来,因为value值一样,所以执行释放锁操作时就会释放掉客户端2持有的锁,这样就会造成问题
所以通常来说,在释放锁时,我们需要对value进行验证
释放锁的实现
释放锁时需要验证value值,也就是说我们在获取锁的时候需要设置一个value,不能直接用del key这种粗暴的方式,因为直接del key任何客户端都可以进行解锁了,所以解锁时,我们需要判断锁是否是自己的,基于value值来判断
public boolean releaseLock_with_lua(String key,String value) {
String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then " +
"return redis.call('del',KEYS[1]) else return 0 end";
return jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(value)).equals(1L);
}
Redisson原理解析
要解决上面逻辑还未执行完而锁已经过期失效的问题需要借助Redisson
这个框架来解决。
原理
整个Redisson
加锁的过程如下:
流程:
-
加锁机制上面提到的
SETNX
指令和Lua
脚本来实现 -
watch dog
自动延期机制,就是会启动一个后台线程,定时比如说10
秒检测当前线程是否还持有了锁,是的话就延期。这个的话就是解决上面逻辑还未执行完而锁已经过期失效的问题。但是是不建议开启的,这个还是很耗资源和性能的。 -
可重入性的话
Redisson
存储锁的数据类型是Hash
类型,并且Hash
数据类型的key
值包含了当前线程信息。 -
互斥性是通过 Redis 数据结构来保证分布式锁的唯一性。
-
避免死锁是通过 Redis 对
key
设置超时时间来保证。
存在的问题
是的,到这里还没有完。通过Redisson
是实现分布式锁依然会存在单点/多点
,这里主要是关于部署方式上。使用单点的话 Redis 宕机了,那就没什么说的了,直接加不了锁咯,这个不管用哪个中间媒介都解决不了。
多点的话主要是哨兵和Cluster
集群。假设客户端 1 向某个master
节点写入了Redisson
锁,此时会异步复制给对应的 slave
节点。但是这个过程中一旦发生 master
节点宕机,主备切换,slave
节点从变为了 master
节点。这时客户端 2 来尝试加锁的时候,由于客户端 1 已经宕机的master
节点加锁成功,而客户端 2 在新的master
节点上也能加锁,此时就会导致多个客户端对同一个分布式锁完成了加锁。
解决方案
- 用
Zookeeper
实现
Redis分布式锁如何续期
应用场景
- 避免不同节点重复的执行某一块逻辑,比如说我们的定时任务,不加锁的情况下在某一时间点这个定时任务可能会执行多次。
- 防止表单重复提交,这个其实也是会有问题。
- 避免破坏数据的正确性,当多个线程同时某一块逻辑时,可能会出现数据的错乱或者不一致。
参考:https://juejin.cn/post/6844903830442737671#heading-0