分布式锁
分布式锁
1.什么是分布式锁
分布式锁
:满足分布式系统或集群模式下多线程可见并且互斥的锁。
2.分布式锁的实现
分布式锁的核心是实现多线程之间的互斥,而满足这一点的方式有很多,常见的有三种:
Mysql | Redis | Zookeeper | |
---|---|---|---|
互斥 | 利用mysql本身互斥锁机制 | 利用setnx这样的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
高可用 | 好 | 好 | 好 |
高性能 | 一般 | 好 | 一般 |
安全性 | 断开连接,自动释放锁 | 利用锁超时的时间,到期释放 | 临时节点,断开连接自动释放 |
实现 分布式锁
时需要实现的两个基本方法:
- 获取锁:
- 互斥:确保只能有一个线程获取锁
# 添加锁,利用setnx的互斥特性 SETNX lock thread1 # 添加锁过期时间,避免服务宕机引起的死锁现象 EXPIRE lock 10
# 改善应该放在一起写,分开写的话在 SETNX lock thread1执行后, EXPIRE lock 10执行前,如果服务器发生宕机,lock将无法释放 # 添加锁,NX是互斥,EX是设置过期时间 SET lock thread1 NX EX 10
- 互斥:确保只能有一个线程获取锁
- 释放锁:
- 手动释放
- 超时释放: 获取锁时添加一个超时时间
# 释放锁,删除即可 DEL key
2.1 Redis实现分布式锁的初级版本
public interface ILock{
//尝试获取锁
boolean tryLock(long timeoutSec);
//释放锁
void unLock();
}
//接口的实现
package com.zhn.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
public class SimpleRedisLock implements ILock {
private StringRedisTemplate stringRedisTemplate;
//定义业务名称
private String name;
private static final String KEY_PREFIX = "lock:";
public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
String key = KEY_PREFIX+name;
@Override
public boolean tryLock(long timeoutSec) {
long threadId = Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(key,String.valueOf(threadId), timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success); //防止自动拆箱产生空指针风险
}
@Override
public void unlock() {
stringRedisTemplate.delete(key);
}
}
2.2 Redis实现分布式锁的误删(锁)问题
2.2.1 误删问题1
**问题产生原因:**业务阻塞超过锁的超时时间导致锁提前释放,业务执行完释放的锁不是自己线程的锁,造成误删锁
改进分布式锁:
需求 :修改之前的分布式锁实现
解决思路:
- 在获取锁时存入线程标示(可以用UUID表示)
- 在释放锁的时候先获取锁中的线程标示,判断是否与当前线程标示一致
- 如果一致则释放锁
- 如果不一致则不释放锁
2.2.1 误删问题2(原子性问题)
线程1判断锁的标识符一致后,由于JVM的垃圾回收机制,导致业务阻塞,当阻塞时间超过了锁的超时释放时间,会产生误删(锁)现象
实现原子性方案:Redis的lua脚本:
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令的原子性.Lua是一种编程语言,参考网站
这里重点介绍Redis提供的调用函数,语法如下:
# 执行redis命令
redis.call('命令名称','key','其他参数',...)
```java
例如,当执行set name jack,则脚本是这样的:
```java
redis.call('set','name','jack')
例如,我们要先执行set name Rose,再执行get name,则脚本如下:
//先执行 set name jack
redis.call('set','name','jack')
local name = redis.call('get','name')
return name
写好脚本后,需要使用redis命令来调用脚本,调用脚本的常用命令如下:
127.0.0.1:6379> help @scripting
EVAL script numkeys key [key ...] arg [arg ...]
summary: Execute a Lua script server side
since: 2.6.0
EVALSHA sha1 numkeys key [key ...] arg [arg ...]
summary: Execute a Lua script server side
since: 2.6.0
SCRIPT DEBUG YES|SYNC|NO
summary: Set the debug mode for executed scripts.
since: 3.2.0
SCRIPT EXISTS sha1 [sha1 ...]
summary: Check existence of scripts in the script cache.
since: 2.6.0
SCRIPT FLUSH [ASYNC|SYNC]
summary: Remove all the scripts from the script cache.
since: 2.6.0
SCRIPT KILL -
summary: Kill the script currently in execution.
since: 2.6.0
SCRIPT LOAD script
summary: Load the specified Lua script into the script cache.
since: 2.6.0
例如,我们要执行redis.call(‘set’,’name’,’jack’)这个脚本,语法如下:
127.0.0.1:6379> eval "return redis.call('set','name','jack')" 0
OK
如果脚本中的key,value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其他参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数
释放锁的业务流程是这样的:
- 获取锁中的线程标示
- 判断是否与指示的标示(当前线程标示)一致
- 如果一致则释放锁(删除)
- 如果不一致则什么都不做
Lua改进分布式方案
RedisTemplate调用Lua脚本的API如下:
-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁 del key
return redis.call('del', KEYS[1])
end
return 0
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 void unlock() {
stringRedisTemplate.execute(UNLOCK_SCRIPT,
Collections.singletonList(key),
ID_PREFIX + Thread.currentThread().getId());
}
2.3 基于Redis的分布式锁优化
基于setnx实现的分布式锁存在下面的问题:
- 不可重入
- 同一个线程无法多次获取同一把锁
- 不可重试
- 获取锁只尝试一次就返回false,没有重试机制
- 超时释放
- 锁超时释放虽然可以避免死锁,但如果业务执行耗时较长,也会导致锁被释放,存在安全隐患
- 主从一致性
- 如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现
2.3.1 Redisson
Redisson是一个在Redis的基础上实现的Java驻内数据网络。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
点击跳转Redisson Github地址
- 引入依赖:
<!--redisson--> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.13.6</version> </dependency>
- 配置Redisson客户端
@Configuration public class RedisConfig { @Bean public RedissonClient redissonClient(){ Config config = new Config(); config.useSingleServer().setAddress("redis://192.168.6.129:6379").setPassword("101011"); return Redisson.create(config); } }
- 使用Redisson的分布式锁
@Resource RedissonClient redissonClient; 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(); }
2.3.2 Redisson可重入锁原理
概述
:
什么是 “可重入”,可重入就是说某个线程已经获得某个锁,可以再次获取锁而不会出现死锁
利用Hash结构,记录线程标识,和获取锁的次数,引入了一个计数器;
方法A里面调方法B,A、B都要同一把锁,A一拿到锁,计数器+1 ,B拿到锁,计数器也+1,B执行完逻辑,计数器-1;A当业务执行完成之后,计数器-1,最后判断计数器的数是否为0,为0 ,说明所有业务执行完成,最后释放锁;
由于代码逻辑复杂,为了保证原子性,所以最后用lua脚本
编写
总结
- 可重入:基于Hash结构,hash里field存储线程标识threaId,value存储重入次数,每一次获取锁的时候,先判断锁是否存在,不存在直接获取锁,如果存在,不代表获取锁失败了,再去判断线程的标识是不是当前线程threaId,是当前线程,可以再次获取,重入次数+1,释放锁的时候重入次数-1,直到重入次数为0,所有业务结束,再真正释放锁;实现锁的可重入,类似jdk的ReetrantLock;
- 可重试:利用信号量和消息订阅Pubsub机制,如果第一次获取锁失败,不是立即失败,而是等待释放锁的消息,获取锁成功的线程释放锁的时候会发送消息,从而被捕获到;当线程得到消息时,就可以重新获取锁,如此反复;超过了等待时间,就不会重试了;由于使用了等待、唤醒这样的方案,cpu的性能也不会过多的消耗;
- 锁超时释放:基于看门狗机制,获取锁成功之后开启一个定时 任务,每隔一段时间重置超时时间;
2.3.3 Redisson如何解决主从一致性问题
利用MultiLock —-联锁;
1、redis主从一致性发生的原因:Redis主节点处理写操作,从节点处理读操作,主从节点需要进行数据的同步,但是因为主从不在一个机器,同步会有延时,如果主节点突然故障了,同步没有完成,redis就会从从节点选出一个新的主节点,但由于主节点的锁没有及时同步,所以新的主节点没有锁,此时其他线程来获取锁也能成功,引发线程安全问题;
2、必须依次向redis多个节点都获取锁,全部获取了才算成功总结
①不可重入Redis 分布式锁
原理:利用 setnx 的互斥性;利用 ex 避免死锁;释放锁时判断线程标示
缺陷:不可重入、无法重试、锁超时失效
②可重入的 Redis 分布式锁
原理:利用 hash 结构,记录线程标示和重入次数;利用 watchDog 延续锁时间;利用信号量控制锁重试等待
缺陷:Redis 宕机引起锁失效问题
③Redisson 的 multiLock连锁
原理:多个独立的 Redis 节点,必须在所有节点都获取重入锁,才算获取锁成功
缺陷:运维成本高、实现复杂