本文已收录于专栏
🍅《Redis从入门到进阶》🍅
专栏前言
本专栏开启,目的在于帮助大家更好的掌握学习Redis
,同时也是为了记录我自己学习Redis
的过程,将会从基础的数据类型开始记录,直到一些更多的应用,如缓存击穿还有分布式锁等。希望大家有问题也可以一起沟通,欢迎一起学习,对于专栏内容有错还望您可以及时指点,非常感谢大家 🌹。
目录
- 专栏前言
- 1.什么是分布式锁?
- 2. 分布式锁的条件
- 3.常见的分布式锁
- 4.Redis 实现分布式锁
- 5. 分布式锁误删问题
- 6. 分布式锁原子性问题
1.什么是分布式锁?
锁这个东西,大家都知道,在我们 jvm
内部多个线程竞争同一个资源时,我们利用jvm
提供的synchronized
或者一些其他的锁可以帮助我们让线程对资源的串行使用。但这种方法并不适合现在企业广泛使用的分布式架构。因为在这种集群模式下,jvm
内部的锁无法被其他jvm
内部感知到,那这样肯定无法满足我们的要求,因为锁肯定是要被大家都能感知到的,所以分布式锁应用而生。以前的锁竞争对象是线程之间,而分布式系统中竞争共享资源的单位从线程升级为了进程。
2. 分布式锁的条件
那么作为一个分布式锁,它应该具备哪些条件呢?
- 可见性:多个进程之间均可以看见该锁,且可以尝试获取该锁
- 互斥性:锁最基本的特性,同一时间只能保证锁被一个进程持有
- 高可用性:也可以理解为容错性,当提供锁的服务结点产生故障时,程序不会因为守到强烈影响
- 高性能:锁的释放和添加本身十分消耗性能,我们应选择性能较好的锁
3.常见的分布式锁
我们一般常见的分布式锁,有以下三种:
-
MySQL:
MySQL
自带锁机制,但由于其性能一般,所以作为分布式锁比较少见 -
Redis:
Redis
是分布式锁一种非常常见的实现方式,我们可以利用setnx
这个方法,如果插入成功表示锁获取成功,否则获取失败。利用这个机制完成互斥,从而实现分布式锁,而且Redis
存储在内存中本身就符合高性能特点。 -
Zookeeper:
Zookeeper
也是企业开发中较好的一种实现分布式锁的方案,以后有机会讲解。
4.Redis 实现分布式锁
基于Redis
实现分布式锁,我们使用两个方法:
-
1.获取锁
该指令会插入一个结构为lock:thread01
的锁,且超时时间为100
秒,返回值为OK
说明获取锁成功,失败则返回false
,该方法不会进行阻塞。 -
2.释放锁
通过手动删除该锁来进行释放,或者可以等待TTL
让该锁自动过期
核心思路:
利用Redis
的SETNX
方法,当多个进程同时竞争该锁时,都会调用该方法获取锁,只有一个进程成功能成功获取成功,此时Redis
将会生成该锁,其他进程获取失败。那么获取锁成功的进程将会去执行业务,最后删除该锁,这样就将锁释放了。如果想让获取锁失败的进程重新获取,可以手动休眠一段时间后重新获取。
下面是一个简单的使用StringRedisTemplate
实现分布式锁的代码:
public class DistributedLock {
private StringRedisTemplate redisTemplate;
private String lockKey;
private String lockValue;
private long lockTimeout;
//构造方法
public DistributedLock(StringRedisTemplate redisTemplate, String lockKey, String lockValue, long lockTimeout) {
this.redisTemplate = redisTemplate;
//key
this.lockKey = lockKey;
//value
this.lockValue = lockValue;
//过期时间
this.lockTimeout = lockTimeout;
}
public boolean tryLock() {
// 尝试获取锁
Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, lockTimeout, TimeUnit.MILLISECONDS);
return result != null && result;
}
public void unlock() {
// 释放锁
redisTemplate.delete(lockKey);
}
}
5. 分布式锁误删问题
上面的逻辑看上去完美无缺,但还是存在很严重的问题,考虑下面一个场景:
逻辑说明:
线程A
持有锁执行业务的时候发生了堵塞,导致他的锁TTL
到期自动释放了,此时线程B
成功获取到锁了,因为线程A
已经释放该锁了。这时候线程A
阻塞完毕后继续执行完业务,然后删除该锁,线程B
执行完业务时突然发现——woc 我锁呢? 它的锁被线程A
误删了,这就是分布式锁误删问题。
解决方案:
上诉问题的产生主要原因,还是因为每个线程并不知道该锁是不是自己的,那我们可以在删除锁的时候去加以判断,如果该锁不属于自己,则不删除该锁。如果该锁是自己的且还未到期,再进行删除锁。我们这里标识一把锁的时候同时存入线程的ID
,一般在同一个jvm
中线程的标识一般不相同,但我们这是在集群模式下,所以也有可能出现ThreadID
重复的情况,所以我们可以考虑在前面拼接上一个UUID
。
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 获取当前线程的标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁中的标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断标识是否一致
if (threadId.equals(id)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
6. 分布式锁原子性问题
在进行区分锁的处理后,那么是不是一定不会产生问题呢?
考虑一种更加极端的情况,当线程 A
判断完标识发现一致后,准备释放锁的时候又突然出现了阻塞情况(比如JVM
垃圾挥手),锁又到期了,线程B
进来拿了一把锁,因为线程A
已经判断完标识,所以它一删锁又把B
的锁给删掉了,这就又产生了误删的问题。
解决的方案需要我们使用Lua
脚本,来保证拿锁、判断标识、删锁三个操作是一个原子性操作,而Lua
脚本可以同时执行多条Redis
指令并且保证原子性,Lua
脚本是一门脚本语言,有兴趣可以自行了解一下。