目录
一、分布式锁介绍
(一)分布式锁基本介绍
(二)分布式锁满足的条件
(三)常见的分布式锁
1.Mysql
2.Redis
3.Zookeeper
二、Redis分布式锁详解
(一)Redis分布式锁的实现核心思路
获取锁:
释放锁:
(二)基于Redis实现分布式锁初级版本
1.定义一个锁的基本接口
2.创建实现类实现锁接口的业务逻辑
3.使用锁
(三)Redis分布式锁误删情况说明
(四)解决Redis分布式锁误删问题
(五)分布式锁的原子性问题
(六)Lua脚本解决多条命令原子性问题
1.Lua脚本基本介绍
2.释放锁的Lua脚本
(七)利用Java代码调用Lua脚本改造分布式锁
(八)总结——基于Redis的分布式锁实现思路
(九)基于setnx实现的分布式锁存在的问题
1.重入问题:
2.不可重试:
3.超时释放:
4.主从一致性:
《Redis企业开发实战(三)——点评项目之优惠券秒杀》上回书说到,集群模式下,syn锁会失效,因为每个tomcat中会有自己的JVM空间,每个JVM空间中的锁监视器会监事各自的线程,导致超领问题。syn锁只能保证单个JVM中的多个线程之间互斥。因此,我们必须使用分布式锁。
一、分布式锁介绍
(一)分布式锁基本介绍
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
分布式锁的核心思想:就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路。
(二)分布式锁满足的条件
- 可见性:多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思
- 互斥:互斥是分布式锁的最基本的条件,使得程序串行执行
- 高可用:程序不易崩溃,时时刻刻都保证较高的可用性
- 高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能
- 安全性
(三)常见的分布式锁
1.Mysql
mysql本身就带有锁机制,但是由于mysql性能本身一般,所以采用分布式锁的情况下,使用mysql作为分布式锁比较少见。
2.Redis
redis作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都使用redis或者zookeeper作为分布式锁,利用setnx这个方法,如果插入key成功,则表示获得到了锁,如果有人插入成功,其他人插入失败则表示无法获得到锁,利用这套逻辑来实现分布式锁。
3.Zookeeper
ZooKeeper 实现分布式锁主要是利用其提供的临时顺序节点和监听机制来确保在分布式环境下的互斥访问。
二、Redis分布式锁详解
(一)Redis分布式锁的实现核心思路
实现分布式锁时需要实现的两个基本方法:
获取锁:
- 互斥:确保只能有一个线程获取锁
- 非阻塞:尝试一次,成功返回true,失败返回false
# 添加锁,NX是互斥,EX是设置超时时间 SET lock thread1 NX EX 10
释放锁:
- 手动释放
- 超时释放:获取锁时添加一个超时时间
# 释放锁,删除即可 DEL key
(二)基于Redis实现分布式锁初级版本
1.定义一个锁的基本接口
public interface ILock {
/**
* 尝试获取锁
*
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功,false代表获取锁失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unLock();
}
2.创建实现类实现锁接口的业务逻辑
public class SimpleRedisLock implements ILock {
private String name;
private static final String KEY_PREFIX = "lock:";
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标识作为锁的名称
String value = Thread.currentThread().getId() + "";
// 获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, value, timeoutSec, TimeUnit.SECONDS);
// 防止自动拆箱产生null
return Boolean.TRUE.equals(success);
}
@Override
public void unLock() {
// 通过del删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
3.使用锁
原来的sync不再使用
@Override
public Result seckillVoucher(Long voucherId) {
// 查询优惠券是否存在
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
if (seckillVoucher == null) {
return Result.fail("优惠券不存在");
}
// 查询秒杀是否开始
LocalDateTime beginTime = seckillVoucher.getBeginTime();
if (beginTime.isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始");
}
// 查询秒杀是否结束
LocalDateTime endTime = seckillVoucher.getEndTime();
if (endTime.isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束");
}
// 判断库存是否充足
Integer stock = seckillVoucher.getStock();
if (stock < 1) {
return Result.fail("库存不足");
}
Long userId = UserHolder.getUser().getId();
// 获取锁
SimpleRedisLock simpleRedisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate
boolean isLock = simpleRedisLock.tryLock(1200);
// 判断获取锁是否成功
if (!isLock) {
// 获取锁失败返回错误信息
return Result.fail("不允许重复下单!");
}
// 获取锁成功进行事务操作
try {
// 获取和事务有关的代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
// 返回订单id
return proxy.createVoucherOrder(voucherId);
} finally {
// 最后必须要释放锁
simpleRedisLock.unLock();
}
}
当统一用户重复下单优惠券时,只会成功执行一次,优惠券也只会减少一张。
(三)Redis分布式锁误删情况说明
逻辑说明:
持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况说明。
解决方案:
在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果属于自己,则不进行锁的删除。假设还是上面的情况,线程1卡顿,锁自动释放,线程2进入到锁的内部执行逻辑,此时线程1反应过来,然后删除锁,但是线程1,判断当前这把锁不是属于自己,于是不进行删除锁逻辑;当线程2走到删除锁逻辑时,如果没有卡过自动释放锁的时间点,则判断当前这把锁是属于自己的,于是删除这把锁。
(四)解决Redis分布式锁误删问题
需求:修改之前的分布式锁实现
满足:在获取锁时存入线程标识(可以用UUID表示);在释放锁时先获取锁中的线程标识,判断是否与当前线程标识一致。
如果一致则释放锁
如果不一致则不释放锁
核心逻辑:在存入锁时,放入自己线程的标识,在删除锁时,判断当前这把锁的标识是不是自己存入的,如果是,则进行删除,如果不是,则不进行删除。
public class SimpleRedisLock implements ILock {
private String name;
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@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);
// 防止自动拆箱产生null
return Boolean.TRUE.equals(success);
}
@Override
public void unLock() {
// 获取当前线程的标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 从Redis中获取锁的标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 如果两者相等,则删除锁
if (threadId.equals(id)) {
// 通过del删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
(五)分布式锁的原子性问题
更为极端的误删逻辑说明:
线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中,比如他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁,但是此时他的锁到期了,那么此时线程2进来,但是线程1他会接着往后执行,当他卡顿结束后,他直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题。
之所以有删除锁的原子性问题,是因为线程1的拿锁,判断锁,删锁,实际上并不是原子性的。
也就是说,当线程1获取到锁,执行业务逻辑时,可能会因为锁超时,导致线程1的锁被删掉,此时线程1的业务还未执行完毕,线程2又获取到锁,线程2的业务执行完毕,直接就释放锁了,此时线程1要释放锁,就会把线程2的锁删掉。
(六)Lua脚本解决多条命令原子性问题
1.Lua脚本基本介绍
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法参考网址:Lua 教程 | 菜鸟教程。Lua的效率很高,我们可以使用lua去操作redis,又能保证他的原子性,这样就可以实现拿锁比锁删锁是一个原子性动作了。
这里重点介绍Redis提供的调用函数,语法如下:
redis.call('命令名称', 'key', '其它参数', ...)
例如,我们要执行set name jack,则脚本是这样:
-- 执行 set name jack
redis.call('set', 'name', 'jack')
例如,我们要先执行set name Rose,再执行get name,则脚本如下:
-- 先执行 set name jack
redis.call('set', 'name', 'Rose')
-- 再执行 get name
local name = redis.call('get', 'name')
-- 返回
return name
写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:
如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:
注意:KEYS和ARGV必须要大写。
> eval "return redis.call('set','name','jack')" 0
OK
> eval "return redis.call('set',KEYS[1],ARGV[1])" 1 name zhangsan
OK
> eval "return redis.call('set',KEYS[1],ARGV[1])" 1 name ROSE
OK
2.释放锁的Lua脚本
释放锁的业务流程是这样的
- 获取锁中的线程标识
- 判断是否与指定的标识(当前线程标识)一致
- 如果一致则释放锁(删除)
- 如果不一致则什么都不做
最终操作redis的拿锁比锁删锁的lua脚本:
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示 -- 获取锁中的标示,判断是否与当前线程标示一致 if (redis.call('GET', KEYS[1]) == ARGV[1]) then -- 一致,则删除锁 return redis.call('DEL', KEYS[1]) end -- 不一致,则直接返回 return 0
(七)利用Java代码调用Lua脚本改造分布式锁
将上面的lua脚本放在resources目录下
修改SimpleRedisLock类
public class SimpleRedisLock implements ILock {
private String name;
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@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);
// 防止自动拆箱产生null
return Boolean.TRUE.equals(success);
}
@Override
public void unLock() {
// 调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
// 锁的key
Collections.singletonList(KEY_PREFIX + name),
// 获取当前线程的标识
ID_PREFIX + Thread.currentThread().getId()
);
}
}
(八)总结——基于Redis的分布式锁实现思路
利用set nx ex获取锁,并设置过期时间,保存线程标示
释放锁时先判断线程标示是否与自己一致,一致则删除锁
特性:
- 利用set nx满足互斥性
- 利用set ex保证故障时锁依然能释放,避免死锁,提高安全性
- 利用Redis集群保证高可用和高并发特性
(九)基于setnx实现的分布式锁存在的问题
1.重入问题:
重入问题是指获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。
2.不可重试:
是指目前的分布式只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁。
3.超时释放:
我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患。
4.主从一致性:
如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。但是这种情况出现的概率相对较低,因为主从同步是时间往往是在毫秒级别。
因此,使用Redisson可以解决上述的问题。