目录
基本原理
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁
分布式锁需要满足的条件:
- 可见性:多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化
- 互斥:互斥是分布式锁的最基本的条件,使得程序串行执行
- 高可用:程序不易崩溃,时时刻刻都保证较高的可用性
- 高性能:由于加锁本身就让性能降低,对于分布式锁本身需要有较高的加锁性能和释放锁性能
- 安全性
分布式锁的实现
分布式锁的核心是实现多线程之间互斥,常见的三种实现方式:
基于Redis的分布式锁
实现分布式锁时需要实现的两个基本方法:
1.获取锁
-
互斥:确保只能有一个线程获取锁
-
非阻塞:尝试一次,成功返回true,失败返回false
2.释放锁
-
手动释放
-
超时释放:获取锁时添加一个超时时间
流程:
代码实现:
需求:定义一个类,实现下面接口,利用Redis实现分布式锁功能
java
public class SimpleRedisLock implements ILock{
private static final String KEY_PREFIX="lock:";
@Override
public boolean tryLock(long timeoutSec) {
//获取线程标示
String threadId = Thread.currentThread().getId();
//获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
//通过del删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
java
@Override
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(1200);
//加锁失败
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}
Redis分布式锁误删
持有锁的线程在锁的内部出现了阻塞,导致它的锁自动释放,这时线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况说明
解决方案:在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果不属于自己,则不进行锁的删除
需求:
修改之前的分布式锁实现,满足:
1.在获取锁时存入线程标示(可以用UUID表示)
2.在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致
-
如果一致则释放锁
-
如果不一致则不释放锁
代码实现:
获取锁
java
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);
}
释放锁
java
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);
}
}
分布式锁的原子性问题
线程1现在持有锁之后,在执行业务逻辑过程中,正准备删除锁,而且已经走到了条件判断的过程中,比如它已经拿到了当前这把锁,确实是属于自己的,正准备删除锁,但是此时它的锁到期了,那么此时线程2进来,但是线程1它会接着往后执行,当它卡顿结束后,它直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题
Lua脚本解决多条命令原子性问题
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法:Lua 教程 | 菜鸟教程
Redis提供的调用函数:
java
redis.call('命令名称', 'key', '其它参数', ...)
例:执行set name jack
java
# 执行 set name jack
redis.call('set', 'name', 'jack')
例:先执行set name Rose,再执行get name
java
# 先执行 set name jack
redis.call('set', 'name', 'Rose')
# 再执行 get name
local name = redis.call('get', 'name')
# 返回
return name
写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:
例:执行 redis.call('set', 'name', 'jack') 这个脚本
如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:
Lua脚本实现释放锁:
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脚本改造分布式锁
java
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获取锁,并设置过期时间,保存线程标示
-
释放锁时先判断线程标示是否与自己一致,一致则删除锁
特性:
-
利用set nx满足互斥性
-
利用set ex保证故障时锁依然能释放,避免死锁,提高安全性
-
利用Redis集群保证高可用和高并发特性
基于Redis的分布式锁优化
基于setnx实现的分布式锁存在下面的问题:
Redission概述
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid),它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现
Redission提供了分布式锁的多种多样的功能:
Redisson入门
1.引入依赖:
XML
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
2.配置Redisson客户端:
java
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
// 配置
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
// 创建RedissonClient对象
return Redisson.create(config);
}
}
3.使用Redission的分布式锁:
java
@Resource
private RedissionClient redissonClient;
@Test
void testRedisson() throws Exception{
//获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
//尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
boolean isLock = lock.tryLock(1,10,TimeUnit.SECONDS);
//判断获取锁成功
if(isLock){
try{
System.out.println("执行业务");
}finally{
//释放锁
lock.unlock();
}
}
}
4.在 VoucherOrderServiceImpl注入RedissonClient:
java
@Resource
private RedissonClient redissonClient;
@Override
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();
//创建锁对象 使用分布式锁
RLock lock = redissonClient.getLock("lock:order:" + userId);
//获取锁对象
boolean isLock = lock.tryLock();
//加锁失败
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}
Redisson可重入锁原理
Reddisson锁重试和WatchDog机制
Redisson分布式锁原理
可重入:利用hash结构记录线程id和重入次数
可重试:利用信号量和Pubsub功能实现等待、唤醒,获取锁失败的重试机制
超时续约:利用whtchDog,每隔一段时间(releaseTime / 3),重置超时时间
Redission的MultiLock原理
为了提高redis的可用性,会搭建集群或者主从,以主从为例
此时去写命令,写在主机上, 主机会将数据同步给从机,但是假设主机还没有来得及把数据写入到从机时,此时主机宕机,哨兵会发现主机宕机,并且选举一个slave变成master,而此时新的master中实际上并没有锁信息,锁信息已经丢掉了
为了解决这个问题,Redission提出来了MultiLock锁,使用这把锁就不再使用主从,每个节点的地位都是一样的, 这把锁加锁的逻辑需要写入到每一个主丛节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么它去获得锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性
MultiLock加锁原理:
当设置了多个锁时,Redission会将多个锁添加到一个集合中,然后用while循环不停去尝试拿锁,但是会有一个总共的加锁时间,这个时间是用需要加锁的个数 * 1500ms ,假设有3个锁,那么时间就是4500ms,假设在这4500ms内,所有的锁都加锁成功, 那么此时才算是加锁成功,如果在4500ms有线程加锁失败,则会再次去进行重试
分布式锁总结
1.不可重入Redis分布式锁
- 原理:利用setnx的互斥性;利用ex避免死锁;释放锁时判断线程标示
- 缺陷:不可重入、无法重试、锁超时失效
2.可重入的Redis分布式锁
- 原理:利用hash结构,记录线程标示和重入次数;利用watchDog延续锁时间;利用信号量控制锁重试等待
- 缺陷:Redis宕机引起锁失效问题
3.Redisson的MultiLock
- 原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功
- 缺陷:运维成本高,实现复杂