前言:
今天学习了redis中的分布式锁,并且了解了如何用这种锁来解决实际业务生产的问题。
学习收获:
分布式锁:
接上节,在集群模式下或者分布式系统下,有多个JVM存在,每个JVM都有自己的锁,导致每个锁都会有一个线程获取,就会导致并行运行,就会出现线程安全,此时synchronized就会失效。所以我们要使多个JVM只能用一把锁(跨进程锁)。
所以我们引入了分布式锁来使多个JVM同时使用一个锁监视器。
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁

无论使JVM内部的线程还是跨JVM的线程都可以达到一个互斥的效果。 它有很多优点:
- 多线程可见
- 互斥:分布式锁必须能够确保在任何时刻只有一个节点能获取锁,其他节点只能等待。
- 高可用:分布式锁应该具备高可用性,即使在网络分区或节点故障的情况下,仍然能够正常工作。(容错性)当持有锁的节点发生故障或宕机时,系统需要能够自动释放该锁,以确保其他节点能够继续获取锁。
- 高性能:分布式锁需要具备良好的性能,尽可能减少对共享资源的访问等待时间,以及减少锁竞争带来的开销。
- 安全性:可重入性)如果一个节点已经获得了锁,那么它可以继续请求获取该锁而不会造成死锁。(锁超时机制)为了避免某个节点因故障或其他原因无限期持有锁而影响系统正常运行,分布式锁通常应该设置超时机制,确保锁的自动释放。
- 等等。
分布式锁的核心是实现多线程互斥,而满足这一点的方式有很多,常见有以下三种:

主要来讲,在Redis中利用setnx这种命令来往redis中set一个数据,只有数据不存在时,才能成功。并且利用redis 的key过期机制去释放锁。
而Zookeeper是利用有序性来实现互斥的,约定id最小的来获取锁成功,那么只有一个最小的id会获取成功。
获取锁:
首先要了解什么是互斥,互斥就是确保只能有一个线程获取锁。利用setnx的互斥特性实现。

释放锁:
除了使用del手动释放,还可以超时释放。

如果没来得及释放锁,整个服务就宕机了,这样可能这个锁永远存在,并且其他线程也进不来,导致死锁。所以要添加一个超时时间。并且我们要使获取锁和设置过期时间,要么都成功要么都失败,要具有原子性。所以要:

如果获取锁失败,用非阻塞方式,去尝试一次,成功则返回true,失败返回false。
分布式锁解决超卖问题:

通过redis的setnx指令来实现分布式锁去解决超卖问题。
1.创建分布式锁:
public class SimpleRedisLock implements Lock {
/**
* RedisTemplate
*/
private StringRedisTemplate stringRedisTemplate;
/**
* 锁的名称
*/
private String name;
public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
/**
* 获取锁
*
* @param timeoutSec 超时时间
* @return
*/
@Override
public boolean tryLock(long timeoutSec) {
String id = Thread.currentThread().getId() + "";
// SET lock:name id EX timeoutSec NX
Boolean result = stringRedisTemplate.opsForValue()
.setIfAbsent("lock:" + name, id, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(result);
}
/**
* 释放锁
*/
@Override
public void unlock() {
stringRedisTemplate.delete("lock:" + name);
}
}
这里在获取锁的时候,直接返回会有一个自动拆箱的过程,如果失败,则可能产生空指针异常。所以我们应该调用Boolean.TRUE.equals(sueecss);来解决这种问题。
2.使用分布式锁:
// 3、创建订单(使用分布式锁)
Long userId = ThreadLocalUtls.getUser().getId();
SimpleRedisLock lock = new SimpleRedisLock(stringRedisTemplate, "order:" + userId);
boolean isLock = lock.tryLock(1200);
if (!isLock) {
// 索取锁失败,重试或者直接抛异常(这个业务是一人一单,所以直接返回失败信息)
return Result.fail("一人只能下一单");
}
try {
// 索取锁成功,创建代理对象,使用代理对象调用第三方事务方法, 防止事务失效
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(userId, voucherId);
} finally {
lock.unlock();
}
将前面代码中的使用sychronized锁的地方,改成我们自己实现的分布式锁。
分布式锁的优化1:
有一种极端的情况就是由于业务阻塞,导致锁被提前释放了;并且线程一再业务执行完释放锁的时候,把别人的锁给删了。所以我们要获取锁标示,并判断我们获取的锁和删除的锁是否一致。

我们为分布式锁添加一个线程标识,在释放锁时判断当前锁是否是自己的锁,是自己的就直接释放,不是自己的就不释放锁,从而解决多个线程同时获得锁的情况导致出现超卖。
用UUID来表示线程表示,来区分不同的JVM,再拼上线程id来区分不同的线程。确保不同线程,表示一定不一样;相同线程,标示一定一样。
代码实现:
package com.hmdp.utils.lock.impl;
import cn.hutool.core.lang.UUID;
import com.hmdp.utils.lock.Lock;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
/**
* @author ghp
* @title
* @description
*/
public class SimpleRedisLock implements Lock {
/**
* RedisTemplate
*/
private StringRedisTemplate stringRedisTemplate;
/**
* 锁的名称
*/
private String name;
/**
* key前缀
*/
public static final String KEY_PREFIX = "lock:";
/**
* ID前缀
*/
public static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
/**
* 获取锁
*
* @param timeoutSec 超时时间
* @return
*/
@Override
public boolean tryLock(long timeoutSec) {
String threadId = ID_PREFIX + Thread.currentThread().getId() + "";
// SET lock:name id EX timeoutSec NX
Boolean result = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(result);
}
/**
* 释放锁
*/
@Override
public void unlock() {
// 判断 锁的线程标识 是否与 当前线程一致
String currentThreadFlag = ID_PREFIX + Thread.currentThread().getId();
String redisThreadFlag = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
if (currentThreadFlag != null || currentThreadFlag.equals(redisThreadFlag)) {
// 一致,说明当前的锁就是当前线程的锁,可以直接释放
stringRedisTemplate.delete(KEY_PREFIX + name);
}
// 不一致,不能释放
}
}
通过这种方式,解决锁误删的问题。
分布式锁的优化2:
但此时又有新的问题。判断锁标示和释放是两个动作,这两个动作直接产生了阻塞。
当线程1获取锁,执行完业务然后并且判断完当前锁是自己的锁时,但就在此时发生了阻塞,结果锁被超时释放了,线程2立马就趁虚而入了,获得锁执行业务,但就在此时线程1阻塞完成,由于已经判断过锁,已经确定锁是自己的锁了,于是直接就删除了锁,结果删的是线程2的锁,这就又导致线程3趁虚而入了,从而继续发生超卖问题。所以必须确保他俩为原子性的操作必须一起执行,不能出现间隔。

所以我们要解决这种问题,但是Redis的事务能保证原子性,但无法保存事务一致性。并且是一种批处理,一次性执行。所以我们可以使用Lua脚本来实现这种原子性。
Lua脚本语言:
redis提高了Lua脚本功能,在一个脚本中编写多条redis命令,确保多条命令执行时的原子性。具体来说:
1.单实例 Lua 解释器
-
Redis 内置一个 Lua 解释器实例,同一时刻只执行一个 Lua 脚本。
-
脚本执行期间,Redis 会阻塞其他客户端的命令请求,直到脚本执行完成或超时。
2. 命令队列化
-
当客户端发送 Lua 脚本时,Redis 将其作为一个整体放入待执行队列。
-
脚本内部的所有 Redis 命令(如
redis.call())会被序列化执行,不会被其他客户端的命令插入或打断。
3. 原子性保证
-
要么全执行,要么全不执行:脚本执行过程中如果出现错误(如语法错误或运行时异常),已执行的命令不会回滚,但脚本整体会失败。
-
数据一致性:由于单线程执行,脚本执行期间不会看到其他客户端对数据的修改。
注意:虽然Redis在单个Lua脚本的执行期间会暂停其他脚本和Redis命令,以确保脚本的执行是原子的,但如果Lua脚本本身出错,那么无法完全保证原子性。也就是说Lua脚本中的Redis指令出错,会发生回滚以确保原子性,但Lua脚本本身出错就无法保障原子性。
Lua脚本实现代码:

编写Lua脚本:
-- 比较缓存中的线程标识与当前线程标识是否一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
-- 一致,直接删除
return redis.call('del', KEYS[1])
end
-- 不一致,返回0
return 0
通过lua脚本实现的java代码:

package com.hmdp.utils.lock.impl;
import cn.hutool.core.lang.UUID;
import com.hmdp.utils.lock.Lock;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
/**
* @author jierui
* @title
* @description
*/
public class SimpleRedisLock implements Lock {
/**
* RedisTemplate
*/
private StringRedisTemplate stringRedisTemplate;
/**
* 锁的名称
*/
private String name;
/**
* key前缀
*/
private static final String KEY_PREFIX = "lock:";
/**
* ID前缀
*/
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
/**
* 获取锁
*
* @param timeoutSec 超时时间
* @return
*/
@Override
public boolean tryLock(long timeoutSec) {
String threadId = ID_PREFIX + Thread.currentThread().getId() + "";
// SET lock:name id EX timeoutSec NX
Boolean result = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(result);
}
/**
* 加载Lua脚本
*/
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("lua/unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
/**
* 释放锁
*/
@Override
public void unlock() {
// 执行lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId()
);
}
}
Redisson:
经过优化1和优化2,我们实现的分布式锁已经达到生产可用级别了,但是还不够完善,比如:
- 不可重入:同一个线程无法多次获取同一把锁
- 不可重试:获取锁只尝试一次就返回false,没有重试机制
- 超时释放:超市释放机机制虽然一定程度避免了死锁发生的概率,但是如果业务执行耗时过长,期间锁就释放了,这样存在安全隐患。锁的有效期过短,容易出现业务没执行完就被释放,锁的有效期过长,容易出现死锁。
- 主从一致性:如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现
Redisson 是一个基于 Java 的开源框架,它为 Redis 提供了分布式和同步工具的实现,使得在分布式环境中使用 Redis 更加便捷。
Redisson的分布式锁基于Redis的Lua脚本和发布/订阅机制,确保原子性操作。例如,获取锁时通过Lua脚本实现原子性的“SETNX + EXPIRE”,避免死锁。简而言之Redisson就是一个使用Redis解决分布式问题的方案的集合,当然它不仅仅是解决分布式相关问题,还包含其它的一些问题。
Redisson实现分布式锁:
1.引入依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
2.配置Redisson客户端:
@Configuration
public class RedissonConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private String port;
@Value("${spring.redis.password}")
private String password;
/**
* 创建Redisson配置对象,然后交给IOC管理
*
* @return
*/
@Bean
public RedissonClient redissonClient() {
// 获取Redisson配置对象
Config config = new Config();
// 添加redis地址,这里添加的是单节点地址,也可以通过 config.userClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://" + this.host + ":" + this.port)
.setPassword(this.password);
// 获取RedisClient对象,并交给IOC进行管理
return Redisson.create(config);
}
}
3.使用Redisson的分布式锁:我们只需要改一下之前的业务代码即可
// 3、创建订单(使用分布式锁)
Long userId = ThreadLocalUtls.getUser().getId();
RLock lock = redissonClient.getLock(RedisConstants.LOCK_ORDER_KEY + userId);
boolean isLock = lock.tryLock();

这里的tryLock分别可以有三个参数:最大等待时间、释放时间、时间单位。若什么参数都不穿的话,会有一个默认值为-1,则默认不等待;而如果我们不传入释放时间的话,则默认锁超过释放时间为30s。
Redisson可重入锁:
我们自定义的锁无法做到可重入,而Redisson可重入锁的原理:

利用hash结构代替String结构,不仅仅存储线程标示,还要存储可重入的次数。
所以现在获取锁和释放锁的动作和以前就有很大差别。得手动判断锁是否存在,如果不存在,就第一次获取锁,设置线程标示,并让value++;然后在设置过期时间。才能去执行业务。如果判断锁已经存在,则判断锁的标示是否为当前线程,若不是则获取锁失败;若是,则锁重入计数+1,然后设置有效期。执行业务。
释放锁时,也要先判断锁是否为自己(当前线程),若是自己则value-1,然后判断计数器是否为0。若不为0,则重置有效期,去执行接下来的业务;若为0,直接释放锁。
注意:这里的逻辑一定要用lur脚本,来确保获取锁和释放锁的原子性。
测试锁的可重入性:
@SpringBootTest
@Slf4j
public class RedissonLockTest {
@Resource
private RedissonClient redissonClient;
private RLock lock;
/**
* 方法1获取一次锁
*/
@Test
void method1() {
boolean isLock = false;
// 创建锁对象
lock = redissonClient.getLock("lock");
try {
isLock = lock.tryLock();
if (!isLock) {
log.error("获取锁失败,1");
return;
}
log.info("获取锁成功,1");
method2();
} finally {
if (isLock) {
log.info("释放锁,1");
lock.unlock();
}
}
}
/**
* 方法二再获取一次锁
*/
void method2() {
boolean isLock = false;
try {
isLock = lock.tryLock();
if (!isLock) {
log.error("获取锁失败, 2");
return;
}
log.info("获取锁成功,2");
} finally {
if (isLock) {
log.info("释放锁,2");
lock.unlock();
}
}
}
}
Redisson分布式锁:
Redisson分布式锁原理:
- 可重入:利用hash结构记录线程id和重入次数
- 可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制
- 超时续约:利用watchDog,每隔一段时间,重置超时时间。
- 主从一致性问题:利用Redisson的
multiLock,多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功。

在三个节点上获取锁(联锁)。并且都是可重入锁。知道获取到所有的锁才成功。并且等所有锁拿完,才配置过期时间。
业务代码实现:

可以看到,可重入锁需要进行一系列的逻辑判断,这些逻辑代码我们最好将它们全都封装到一个 Lua脚本 中,以确保操作的原子性,从而确保线程安全(Redisson底层也是这么干的)
1)编写获取锁的Lua脚本:
---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by ghp.
--- DateTime: 2023/2/14 16:11
---
-- 获取锁的key,即: KEY_PREFIX + name
local key = KEYS[1];
-- 获取当前线程的标识, 即: ID_PREFIX + Thread.currentThread().getId()
local threadId = ARGV[1];
-- 锁的有效期
local releaseTime = ARGV[2];
-- 判断缓存中是否存在锁
if (redis.call('EXISTS', key) == 0) then
-- 不存在,获取锁
redis.call('HSET', key, threadId, '1');
-- 设置锁的有效期
redis.call('EXPIRE', key, releaseTime);
return 1; -- 返回1表示锁获取成功
end
-- 缓存中已存在锁,判断threadId是否说自己的
if (redis.call('HEXISTS', key, threadId) == 1) then
-- 是自己的锁,获取锁然后重入次数+1
redis.call('HINCRBY', key, threadId, '1');
-- 设置有效期
redis.call('EXPIRE', key, releaseTime);
return 1; -- 返回1表示锁获取成功
end
-- 锁不是自己的,直接返回0,表示锁获取失败
return 0;
2)编写释放锁的Lua脚本
---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by ghp.
--- DateTime: 2023/2/14 16:11
---
-- 获取锁的key,即: KEY_PREFIX + name
local key = KEYS[1];
-- 获取当前线程的标识, 即: ID_PREFIX + Thread.currentThread().getId()
local threadId = ARGV[1];
-- 锁的有效期
local releaseTime = ARGV[2];
-- 判断当前线程的锁是否还在缓存中
if (redis.call('HEXISTS', key, threadId) == 0) then
-- 缓存中没找到自己的锁,说明锁已过期,则直接返回空
return nil; -- 返回nil,表示啥也不干
end
-- 缓存中找到了自己的锁,则重入次数-1
local count = redis.call('HINCRBY', key, threadId, -1);
-- 进一步判断是否需要释放锁
if (count > 0) then
-- 重入次数大于0,说明不能释放锁,且刷新锁的有效期
redis.call('EXPIRE', key, releaseTime);
return nil;
else
-- 重入次数等于0,说明可以释放锁
redis.call('DEL', key);
return nil;
end
3)编写可重入锁:
public class ReentrantLock implements Lock {
/**
* RedisTemplate
*/
private StringRedisTemplate stringRedisTemplate;
/**
* 锁的名称
*/
private String name;
/**
* key前缀
*/
private static final String KEY_PREFIX = "lock:";
/**
* ID前缀
*/
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
/**
* 锁的有效期
*/
public long timeoutSec;
public ReentrantLock(StringRedisTemplate stringRedisTemplate, String name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
/**
* 加载获取锁的Lua脚本
*/
private static final DefaultRedisScript<Long> TRYLOCK_SCRIPT;
static {
TRYLOCK_SCRIPT = new DefaultRedisScript<>();
TRYLOCK_SCRIPT.setLocation(new ClassPathResource("lua/re-trylock.lua"));
TRYLOCK_SCRIPT.setResultType(Long.class);
}
/**
* 获取锁
*
* @param timeoutSec 超时时间
* @return
*/
@Override
public boolean tryLock(long timeoutSec) {
this.timeoutSec = timeoutSec;
// 执行lua脚本
Long result = stringRedisTemplate.execute(
TRYLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId(),
Long.toString(timeoutSec)
);
return result != null && result.equals(1L);
}
/**
* 加载释放锁的Lua脚本
*/
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("lua/re-unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
/**
* 释放锁
*/
@Override
public void unlock() {
// 执行lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId(),
Long.toString(this.timeoutSec)
);
}
}
4)编写测试类
@SpringBootTest
@Slf4j
public class ReentrantLockTest {
@Resource
private StringRedisTemplate stringRedisTemplate;
private ReentrantLock lock;
/**
* 方法1获取一次锁
*/
@Test
void method1() {
boolean isLock = false;
// 创建锁对象
lock = new ReentrantLock(stringRedisTemplate, "order:" + 1);
try {
isLock = lock.tryLock(1200);
if (!isLock) {
log.error("获取锁失败,1");
return;
}
log.info("获取锁成功,1");
method2();
} finally {
if (isLock) {
log.info("释放锁,1");
lock.unlock();
}
}
}
/**
* 方法二再获取一次锁
*/
void method2() {
boolean isLock = false;
try {
isLock = lock.tryLock(1200);
if (!isLock) {
log.error("获取锁失败, 2");
return;
}
log.info("获取锁成功,2");
} finally {
if (isLock) {
log.info("释放锁,2");
lock.unlock();
}
}
}
}
总结:
在学习 Redis 分布式锁与 Redisson 后,我对分布式系统中的资源控制有了更深入的理解。Redis 分布式锁通过SETNX命令或 Lua 脚本实现,能在多节点环境下保证资源的原子性访问,有效解决分布式场景下的并发冲突问题;而 Redisson 作为基于 Redis 的 Java 驻内存数据网格,不仅封装了分布式锁的复杂实现细节,还提供了可重入锁、公平锁、读写锁等多种高级特性,同时具备自动续期机制避免锁失效,显著提升了开发效率与锁的可靠性,两者相辅相成,为分布式系统的稳定性和性能优化提供了有力保障 。
3145

被折叠的 条评论
为什么被折叠?



