一、简介
(1)基本原理和特性
在集群模式下,多个jvm将会有多个锁监视器,那么加在某一个线程的锁,将不会被其他jvm下的线程所看到。
而分布式锁,将使多个jvm共享一个锁监视器,让一个锁被多个进程可见。
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
(2)实现方式
常见的有三种
二、基于redis的分布式锁
利用redis的setnx操作设置一个若干秒后自动释放的锁,初级版分布式锁
但是,这样的逻辑还有可能会发生锁的误删问题,如图
当线程1由于某种原因阻塞时间过长,导致锁提前释放,此时线程2拿到了锁并开始执行任务,若这时恰好线程1结束阻塞开始执行任务,并释放锁,这时线程2将丢失锁。这时线程3拿到锁,就出现了线程2、3同时操作的状况。
因此,需要在释放锁之前进行线程判断,仅当这个锁是当前线程的锁时,当前线程才能对锁进行释放。
在存入线程标识时,为了解决线程id冲突的问题,采用uuid作为线程的标识。
此外,线程的阻塞还有可能发生在【判断是否是自己的锁】和【释放锁之间】,也会导致类似上一种问题。如图
原以为一切顺利时,在判断是自己锁之后,刚要准备释放,线程1就发生了阻塞。这样,阻塞的时间过长,超出了锁的到期自动释放时间,线程2就拿到了锁,开始执行任务。若此时,线程1结束阻塞,便会不分青红皂白立即释放锁,线程3就会乘虚而入,与线程2并行。
为了使【判断是否是自己的锁】和【释放锁之间】操作具有原子性,首先想到了redis事务,但redis事务其实是一种批处理,若将这两者放在一个事务中,可能会导致释放失败。
更好的解决办法是:使用Lua脚本(Lua 是一种轻量小巧的脚本语言)。
那么,如何Lua语言来调用redis呢?其实,Lua具有很强的扩展性,提供了redis专门的接口来调用redis。如
-- 执行set name jack
redis.call('set','name','jack')
更多信息可查看菜鸟教程:Lua 教程 | 菜鸟教程
分析释放锁的流程主要是:获取当前锁中的线程id——>获取当前线程id——>对比一致——>释放锁。编写lua脚本
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET',KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致 则直接返回
return 0
Redis 脚本使用 Lua 解释器来执行脚本,执行脚本的常用命令为 EVAL。
那么在java代码中,eval对应的api是stringRedisTemplate中的execute。
【 定义接口和工具类】
public interface ILock {
boolean tryLock(long timeoutSec);
void unlock();
}
public class SimpleRedisLock implements ILock{
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name,StringRedisTemplate stringRedisTemplate){
this.name=name;
this.stringRedisTemplate=stringRedisTemplate;
}
private static final String KEY_PREFIX="lock:";
private static final String ID_PREFIX= UUID.randomUUID().toString(true)+"-";
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);
return Boolean.TRUE.equals(success);
//若直接返回Boolean类型的success将会进行拆箱,将会有安全风险
}
//使用lua脚本来让释放锁具备原子性
@Override
public void unlock() {
//加载lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),//当前锁的名称,传入脚本中,拿到锁中的线程id
ID_PREFIX+ Thread.currentThread().getId()//当前线程id
);
}
//原 不具备原子性的释放锁操作
/*@Override
public void unlock() {
//获取当前线程id
String threadId =ID_PREFIX+ Thread.currentThread().getId();
//获取当前锁中的线程id
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
//判断当前线程id是否与当前锁的中存放的线程id一致
if(threadId.equals(id)){
//释放
stringRedisTemplate.delete(KEY_PREFIX+name);
}
}*/
}
修改原秒杀券订单代码,调用分布式锁,给创建订单业务加锁。
//优惠券足够 创建订单
Long userId = UserHolder.getUser().getId();
//创建redis分布式锁 代替悲观锁
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
boolean isLock = lock.tryLock(1200);
if(!isLock){
//获取锁失败
return Result.fail("不能重复下单");
}
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
lock.unlock();
}
三、Redisson的分布式锁
如图,基于setnx命令实现的分布式锁还存在着这样四类问题。为了解决这些问题,我们选用现成的工具包Redisson作为替代方案。
(1)Redisson是个在Redis的基础上实现的Java驻内存数据网格 (In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现,包括可重入锁、公平锁、联锁、红锁等。
使用redisson分布式锁主要分3步:
1、导入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
2、配置客户端
@Bean
public RedissonClient redissonClient(){
Config config = new Config();
config.useSingleServer()
.setAddress("redis://192.168.126.128:6379").setPassword("123456");
return Redisson.create(config);
}
3、使用分布式锁
装配客户端
@Resource
private RedissonClient redissonClient;
调用锁
RLock lock = redissonClient.getLock("locak:order:" + userId);
boolean isLock = lock.tryLock();
(2)redisson锁的可重入性原理
锁数据结构:哈希
内置的lua脚本示意
获取锁
local key = KEYS[1]; -- 锁的key
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;-- 返回结果
end;
-- 锁已经存在,判断threadId是否是自己
if(redis.call('hexists', key, threadId) == 1) then
-- 不存在,获取锁,重入次数+1
redis.call('hincrby', key, threadId, '1') ;
--设置有效期
redis.call('expire', key, releaseTime);
return 1;-- 返回结果
end;
return 0;-- 代码走到这里,说明获取锁的不是自己,获取锁失败
释放锁
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; --线程唯一标识
local releaseTime = ARGV[2];-- 锁的自动释放时间
-- 判断当前锁是否还是被自己持有
if (redis.call('HEXISTS',key, threadId) ==0) then
return nil;-- 如果已经不是自己,则直接返回
end;
-- 是自己的锁,则重入次数-1
local count = redis.call('HINCRBY', key, threadId, -1);
-- 判断是否重入次数是否已经为0
if(count > 0) then
-- 大于0说明不能释放锁,重置有效期然后返回
redis.call('EXPIRE', key, releaseTime);
return nil;
else
--等于0说明可以释放锁,直接删除
redis.call('DEL',key);
return nil;
end;
(2)redisson锁的可重试性
对于tryLock()方法,可以传三个参数:
long waitTime, long leaseTime, TimeUnit unit
//重试等待时间,锁自动释放时间,时间单位
当传入waitTime即等待时间后,则会在等待时间内进行重试获取锁:基于释放锁的发布和订阅,当任一进程释放锁时会进行发布,当前进程则订阅这一消息,获得释放消息后比对自己剩余等待时间,未超过则尝试获取。(原理详见底层源码)
(3)总结
获取锁和释放锁的代码层面的原理
(4)Redisson锁对于redis主从一致性问题的解决
【redis主从一致性问题】当java应用在获取锁时(执行setnx命令到主节点),主节点写入锁后尚未来得及向从节点同步数据就发生宕机。此时,哨兵将选出新的主节点,当java应用再次访问redis时就会发现锁已经失效。其他应用可以随意获取锁。
既然这种主从模式会导致主从一致性问题,那么就可以采用多个主节点的模式,多人联锁以解决。
联锁(multiLock)在获取锁时要同时在多个redis节点上setnx,仅当都成功时才能拿到锁。事实上,每一个锁其实都是一个redisson可重入锁。
四、分布式锁总结