详细代码在我的Github上,地址:
https://github.com/CodeTeng/RedisCase
感兴趣的朋友可以去我的语雀平台进行查看更多的知识。
https://www.yuque.com/ambition-bcpii/muziteng
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执
行,这就是分布式锁的核心思路。
分布式锁应满足的条件
注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思
常见的分布式锁
锁的基本接口
public interface ILock {
/**
* 尝试获取锁
*
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功; false代表获取锁失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
分布式锁的实现—SimpleRedisLock
利用setnx方法进行加锁,同时增加过期时间,防止死锁,此方法可以保证加锁和增加过期时间具有原子性
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程的标识
long threadId = Thread.currentThread().getId();
// 获取锁
Boolean isSuccess = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return BooleanUtil.isTrue(isSuccess);
}
@Override
public void unlock() {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
业务代码修改:
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠卷信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2. 判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 秒杀未开始
return Result.fail("秒杀尚未开始!");
}
// 3. 判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 秒杀已结束
return Result.fail("秒杀已结束");
}
// 4. 判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
// 改用自己创建的分布式锁进行测试
// 创建锁对象
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
// 获取锁
boolean isLock = lock.tryLock(1200L);
// 判断是否获取锁成功
if (!isLock) {
// 获取失败 返回错误或重试
return Result.fail("不允许重复下单!");
}
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
// 释放锁
lock.unlock();
}
}
逻辑说明:
持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁
执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是
误删别人锁的情况说明。
解决方案:
解决方案就是在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果属于自己,则不进行锁的删除,假设还是上边的情
况,线程1卡顿,锁自动释放,线程2进入到锁的内部执行逻辑,此时线程1反应过来,然后删除锁,但是线程1,一看当前这把锁不是属
于自己,于是不进行删除锁逻辑,当线程2走到删除锁逻辑时,如果没有卡过自动释放锁的时间点,则判断当前这把锁是属于自己的,于
是删除这把锁。
修改:
需求:修改之前的分布式锁实现,满足:在获取锁时存入线程标示(可以用UUID表示)
在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致
核心逻辑:在存入锁时,放入自己线程的标识,在删除锁时,判断当前这把锁的标识是不是自己存入的,如果是,则进行删除,如果不
是,则不进行删除。
核心代码:
加锁:
public boolean tryLock(long timeoutSec) {
// 获取线程的标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean isSuccess = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return BooleanUtil.isTrue(isSuccess);
}
释放锁:
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);
}
}
有关代码实操说明:
在我们修改完此处代码后,我们重启工程,然后启动两个线程,第一个线程持有锁后,手动释放锁,第二个线程此时进入到锁内部,再
放行第一个线程,此时第一个线程由于锁的value值并非是自己,所以不能释放锁,也就无法删除别人的锁,此时第二个线程能够正确释
放锁,通过这个案例初步说明我们解决了锁误删的问题。
更为极端的误删逻辑说明:
线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中。比如他已经拿到了当前这把锁确
实是属于他自己的,正准备删除锁,此时发生阻塞,但是此时线程2进来,当线程1阻塞结束后,他直接就会执行删除锁那行代码,即还是
存在分布式锁误删的情况,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比
锁,删锁,实际上并不是原子性的,我们要防止刚才的情况发生,必须确保操作是原子性的。
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。
Lua是一种编程语言,它的基本语法参考网站:https://www.runoob.com/lua/lua-tutorial.html
这里重点介绍Redis提供的调用函数,我们可以使用lua去操作redis,又能保证他的原子性。
Redis提供的调用函数
redis.call('命令名称', 'key', '其它参数', ...)
写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:
如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从
KEYS和ARGV数组获取这些参数:
用Lua脚本改造的的代码:
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
-- 一致 删除
return redis.call('delete', KEYS[1])
end
-- 不一致 直接返回
return 0
我们的RedisTemplate
中,可以利用execute
方法去执行lua
脚本,参数对应关系就如下图
核心代码:
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
public void unlock() {
// 调用lua脚本解决原子性问题
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId()
);
}
基于Redis的分布式锁实现思路:
利用set nx ex
获取锁,并设置过期时间,保存线程标示
释放锁时先判断线程标示是否与自己一致,一致则删除锁,并且采用lua脚本保证原子性
特性:
利用set nx
满足互斥性
利用set ex
保证故障时锁依然能释放,避免死锁,提高安全性
利用Redis集群保证高可用和高并发特性
基于setnx
实现的分布式锁存在下面的问题:
重入问题:重入问题是指获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码
中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以
可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。
不可重试:是指目前的分布式只能尝试一次,我们认为合理的情况是,当线程在获得锁失败后,他应该能再次尝试获得锁。
**超时释放:**我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的
时候,误删别人的锁,但是毕竟没有锁住,有安全隐患。
主从一致性: 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕
机了,就会出现死锁问题。
这时就需要Redisson出场。
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对
象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
官网地址: https://redisson.org
GitHub地址: https://github.com/redisson/redisson
引入依赖:
<dependency>
<groupId>org.redissongroupId>
<artifactId>redissonartifactId>
<version>3.17.6version>
dependency>
配置Redisson客户端:
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
// 配置
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.150.101:6379")
.setPassword("123321");
// 创建RedissonClient对象
return Redisson.create(config);
}
}
使用
void testRedisson() throws InterruptedException {
// 获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
// 尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
// 判断是否获取成功
if (isLock) {
try {
System.out.println("执行业务");
} finally {
// 释放锁
lock.unlock();
}
}
}
在redisson的分布式锁中,他采用hash
结构用来存储锁,其中大key表示表示这把锁是否存在,用小key表示当前这把锁被哪个线程持
有。
redisson中tryLock中的lua脚本
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hincrby', KEYS[1], ARGV[2], 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)
redis.call('pexpire', KEYS[1], ARGV[1])
return nil
end
return redis.call('pttl', KEYS[1])
redisson添加锁对应的逻辑
redisson中unLock中的lua脚本
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end ;
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
redis.call('del', KEYS[1]);
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end ;
return nil;
redisson删除锁对应的逻辑
Redisson分布式锁原理:
可重入:利用**hash
结构记录线程id和重入次数**
可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制
超时续约:利用watchDog,每隔一段时间(releaseTime/3),重置超时时间
redis主从中出现的问题
此时我们去写命令,写在主机上,主机会将数据同步给从机,但是假设在主机还没有来得及把数据写入到从机去的时候,此时主机宕机,
哨兵会发现主机宕机,并且选举一个slave变成master,而此时新的master中实际上并没有锁信息,此时锁信息就已经丢掉了。
为了解决这个问题,redission提出来了MutiLock锁,使用这把锁咱们就不使用主从了,每个节点的地位都是一样的, 这把锁加锁的逻辑
需要写入到每一个主丛节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获得锁的时候,只
要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。
代码实现:
首先配置三个节点
@Bean
public RedissonClient redisClient() {
Config config = new Config();
config.useSingleServer().setAddress("redis://×××.×××.×××.××:6379").setPassword("××××××");
return Redisson.create(config);
}
@Bean
public RedissonClient redisClient2() {
Config config = new Config();
config.useSingleServer().setAddress("redis://×××.×××.×××.××:6380");
return Redisson.create(config);
}
@Bean
public RedissonClient redisClient3() {
Config config = new Config();
config.useSingleServer().setAddress("redis://×××.×××.×××.××:6381");
return Redisson.create(config);
}
然后进行创建MultiLock锁即可使用
@Resource
private RedissonClient redissonClient;
@Resource
private RedissonClient redissonClient2;
@Resource
private RedissonClient redissonClient3;
@BeforeEach
void setUp() {
RLock lock1 = redissonClient.getLock("order");
RLock lock2 = redissonClient2.getLock("order");
RLock lock3 = redissonClient3.getLock("order");
// 创建MultiLock锁
RLock lock = redissonClient.getMultiLock(lock1, lock2, lock3);
}
Redis分布式锁总结
不可重Redis分布式锁
setnx
的互斥性;利用ex
避免死锁;释放锁时判断线程标示可重入的Redis分布式锁
原理:利用hash
结构,记录线程标示和重入次数;利用watchDog
延续锁时间;利用信号量控制锁重试等待
缺陷:redis
宕机引起锁失效问题
Redisson的multiLock
原理:多个独立的Redis
节点,必须在所有节点都获取重入锁,才算获取锁成功
缺陷:运维成本高、实现复杂