需求背景:
在分布式多机部署的情况下,我们要求某一个方法,只能同一个时间只能被一台机器的一个线程执行。
在单机中,有synchronized,读写锁等方式可以解决同步问题。 但是,这些只能作用在同一个机器上,只能保证某一个机器中的方法,不会同时执行。多台机器还是可以同时执行。
这时,就需要借助介质redisson,基于redis的分布式锁。。
加锁
redisson是一个在redis基础上实现的java驻内存网格(in-memory data grid ),提供了分布式和可扩展性的java数据结构
特点
基于netty实现,采用非阻塞IO。
支持连接池、pipeline 、lua scripting 、redis sentinel、redis cluster
不支持事务,官方建议使用Lua脚本的原子操作代替
主从、哨兵、集群都支持。
加锁流程
redis锁也是一种资源,底层是通过lua脚本实现保证原子性。
加锁时主要通过lua脚本实现
- 先判断当前锁是否存在,如果不存在,就新增锁,并且设置锁的次数为1,然后设置过期时间。
- 如果锁存在,就判断当前线程是否已经获得了锁,如果是,就将锁的冲入次数加1,并且设置过期时间。
- 如果线程没有持有锁,就说明是被其他线程占用,直接返回锁的剩余过期时间。
整个过程下来如果没有获取到锁,就去订阅解锁消息,一旦其他线程释放了锁,就会广播解锁消息,唤醒阻塞的线程,并重新尝试获得锁。
加锁的过程中,又引入了一个watchdog的机制,为了防止服务器宕机,redis锁一直不释放,会给加的锁默认加上一个超时时间,,默认是30秒,
如果服务器宕机了,锁会自动释放, 如果程序没有执行完,会通过定时任务将过期时间刷新,继续等待30秒。
- 加锁lua 脚本
脚本入参
参数 | 示例值 | 含义 |
---|---|---|
KEY个数 | 1 | KEY个数 |
KEYS[1] | my_first_lock_name | 锁名 |
ARGV[1] | 60000 | 持有锁的有效时间:毫秒 |
ARGV[2] | 58c62432-bb74-4d14-8a00-9908cc8b828f:1 | 唯一标识:获取锁时set的唯一值,实现上为redisson客户端ID(UUID)+线程ID |
//-- 若锁不存在:则新增锁,并设置锁重入计数为1、设置锁过期时间
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hset', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
//-- 若锁存在,且唯一标识也匹配:则表明当前加锁请求为锁重入请求,故锁重入计数+1,并再次设置锁过期时间
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]);
实现
- 加锁
public static void main(String[] args) throws InterruptedException {
RLock rLock=redissonClient.getLock("updateAccount");
// 最多等待100秒、上锁10s以后解锁
if(rLock.tryLock(100,10, TimeUnit.SECONDS)){
System.out.println("获取锁成功");
}
Thread.sleep(20000);
rLock.unlock();
redissonClient.shutdown();
}
tryLock
tryLock里面有三个参数
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;
- watiTime: 等待锁的最大时间,超过时间不再尝试获取所。
- leaseTime:如果没有手动调用unlock,超过时间自动释放
- TimeUnit :释放时间单位。
lock
如果直接使用Lock(),不带参数,如果不手动unlock,线程就会一直持有锁,redis设计为了防止宕机,不能释放锁的情况,就在这个方法里面设置了一个默认的过期时间,30秒。
但是巧妙的地方在于这里,如果此时线程已经获取锁,但是应用没有执行完,redis会在30秒后自动释放锁吗? 答案是不会的,因为在lock方法里面,当线程获取锁成功后,就会有一个定时任务(默认10秒/次),将过期时间刷新,继续等待30秒。直到锁被手动unlock,或者kill 线程了,就会不再刷新时间了。
具体分析可以参考这个连接参考连接
@Override
public void lock() {
try {
lockInterruptibly();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
@Override
public void lockInterruptibly() throws InterruptedException {
lockInterruptibly(-1, null);
}
@Override
public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
long threadId = Thread.currentThread().getId();
//尝试获取锁
Long ttl = tryAcquire(leaseTime, unit, threadId);
// lock acquired
if (ttl == null) { // 获取锁成功直接返回
return;
}
RFuture<RedissonLockEntry> future = subscribe(threadId);
commandExecutor.syncSubscription(future);
try {
while (true) {
ttl = tryAcquire(leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
break;
}
// waiting for message
if (ttl >= 0) {
getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
getEntry(threadId).getLatch().acquire();
}
}
} finally {
unsubscribe(future, threadId);
}
// get(lockAsync(leaseTime, unit));
}
解锁
解锁流程
解锁是调用lock.unlock(),也是调用一个lua脚本保证操作原子性,
- 判断解锁的key是否存在,如果不存在就直接返回
- 根据当前的线程id,判断持有锁的线程是否是当前线程,如果不是就直接返回。
- 如果是当前线程就将值-1,也就是将锁冲入次数-1
- 判断减一之后的值,是否大于0,如果大于0,就返回0。 如果小于0,就说明锁已经释放了,可以删除当前key,并且返回 1
@Override
public void unlock() {
// 执行解锁Lua脚本,这里传入线程id,是为了保证加锁和解锁是同一个线程,避免误解锁其他线程占有的锁
Boolean opStatus = get(unlockInnerAsync(Thread.currentThread().getId()));
if (opStatus == null) {
throw new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
+ id + " thread-id: " + Thread.currentThread().getId());
}
if (opStatus) {
cancelExpirationRenewal();
}
}
// 见org.redisson.RedissonLock#unlockInnerAsync
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
// -- 若锁不存在:则直接广播解锁消息,并返回1
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end;" +
// -- 若锁存在,但唯一标识不匹配:则表明锁被其他线程占用,当前线程不允许解锁其他线程持有的锁
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
// 若锁存在,且唯一标识匹配:则先将锁重入计数减1
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
// 锁重入计数减1后还大于0:表明当前线程持有的锁还有重入,不能进行锁删除操作,但可以友好地帮忙设置下过期时期
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
// 锁重入计数已为0:间接表明锁已释放了。直接删除掉锁,并广播解锁消息,去唤醒那些争抢过锁但还处于阻塞中的线程
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
}
- 解锁lua脚本入参
参数 | 示例值 | 含义 |
---|---|---|
KEY个数 | 2 | KEY个数 |
KEYS[1] | my_first_lock_name | 锁名 |
KEYS[2] | redisson_lock__channel:{my_first_lock_name} | 解锁消息PubSub频道 |
ARGV[1] | 0 | redisson定义0表示解锁消息 |
ARGV[2] | 30000 | 设置锁的过期时间;默认值30秒 |
ARGV[3] | 58c62432-bb74-4d14-8a00-9908cc8b828f:1 | 唯一标识;同加锁流程 |
常见分布式锁方案对比
- Zookeeper作为分布式锁的缺陷
ZooKeeper实现的分布式锁,性能并不太高。为啥呢?
因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后Leader服务器还需要将数据同步到所有的Follower机器上,这样频繁的网络通信,性能的短板是非常突出的。
总之,在高性能,高并发的场景下,不建议使用ZooKeeper的分布式锁。而由于ZooKeeper的高可用特性,所以在并发量不是太高的场景,推荐使用ZooKeeper的分布式锁。
实战
前提不多说了,先安装好redis,使用的Redis主从+哨兵模式。这里多台机器安装的redis哨兵,也可以单机多端口安装
1.1首先创建RedissonManager 获取redisson
package com.test.redisson.manager;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
/**
* @version 1.0.0
* @description
* @date 2018/05/04 16:54
**/
public class RedissonManager {
private static RedissonClient redissonClient;
private static Config config = new Config();
/**
* 初始化Redisson,使用哨兵模式
*/
public static void init(){
try {
config.useSentinelServers()
.setMasterName("cache")
.addSentinelAddress("10.0.0.1:26379","10.0.0.2:26379", "10.0.0.3:26379")
//同任何节点建立连接时的等待超时。时间单位是毫秒。默认:10000
.setConnectTimeout(30000)
//当与某个节点的连接断开时,等待与其重新建立连接的时间间隔。时间单位是毫秒。默认:3000
.setReconnectionTimeout(10000)
//等待节点回复命令的时间。该时间从命令发送成功时开始计时。默认:3000
.setTimeout(10000)
//如果尝试达到 retryAttempts(命令失败重试次数) 仍然不能将命令发送至某个指定的节点时,将抛出错误。如果尝试在此限制之内发送成功,则开始启用 timeout(命令等待超时) 计时。默认值:3
.setRetryAttempts(5)
//在一条命令发送失败以后,等待重试发送的时间间隔。时间单位是毫秒。 默认值:1500
.setRetryInterval(3000)
;
redissonClient = Redisson.create(config);
}catch (Exception e){
e.printStackTrace();
}
}
/**
* 获取Redisson的实例对象
* @return
*/
public static Redisson getRedisson(){
init();
return (Redisson) redissonClient;
}
/**
* 测试Redisson是否正常
*/
public static void main(String[] args) {
Redisson redisson = RedissonManager.getRedisson();
System.out.println("redisson = " + redisson);
}
}
1.2 锁操作LockUtil
package com.test.redisson.lockutil;
import com.test.redisson.manager.RedissonManager;
import org.redisson.Redisson;
import org.redisson.api.RLock;
import java.util.concurrent.TimeUnit;
/**
* Redisson分布式锁 工具类
*/
public class LockUtil {
private static Redisson redisson = RedissonManager.getRedisson();
/**
* 根据name对进行上锁操作,redisson Lock 一直等待获取锁
* @param lockname
*/
public static void lock(String lockname) throws InterruptedException {
String key = lockname;
RLock lock = redisson.getLock(key);
//lock提供带timeout参数,timeout结束强制解锁,防止死锁
lock.lock(60L, TimeUnit.SECONDS);
}
/**
* 根据name对进行上锁操作,redisson tryLock 根据第一个参数,一定时间内为获取到锁,则不再等待直接返回boolean。交给上层处理
* @param lockname
*/
public static boolean tryLock(String lockname) throws InterruptedException {
String key = lockname;
RLock lock = redisson.getLock(key);
//tryLock,第一个参数是等待时间,5秒内获取不到锁,则直接返回。 第二个参数 60是60秒后强制释放
return lock.tryLock(5L,60L, TimeUnit.SECONDS);
}
/**
* 根据name对进行解锁操作
* @param lockname
*/
public static void unlock(String lockname){
String key = lockname;
RLock lock = redisson.getLock(key);
lock.unlock();
}
}
1.3 RedissonTestLock
package com.test.redisson.test;
import com.test.redisson.lockutil.LockUtil;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* @version 1.0.0
* @description
* @date 2018/05/04 17:02
**/
public class RedissonTestLock {
public static void main(String[] args) {
//模拟2个线程
for (int i = 1; i <= 2; i++) {
//可以开2个IDE,分别测试以下三个方法
//打开2个IDE同时执行时,这里可以分别取不同名,区分
new Thread("IDE-ONE-"+i) {
@Override
public void run() {
/**
* 测试testLock结果,每个IDE中线程,依次排队等待获取锁。然后执行任务
*/
testLock("redissonlocktest_testkey");
/**
* 测试testTryLock结果,每个IDE中线程,在TryLock的等待时间范围内,若获取到锁,返回true,则执行任务;若获取不到,则返回false,直接返回return;
*/
// testTryLock("redissonlocktest_testkey");
/**
* 测试testSyncro结果,IDE之间的线程互不影响,同一个IDE中的线程排队值执行,不同IDE之间的互补影响,可同时执行
*/
// testSyncro("redissonlocktest_testkey");
}
}.start();
}
}
//测试lock,拿不到lock就不罢休,不然线程就一直block。
public static void testLock(String preKey) {
try {
System.out.println(getDate()+Thread.currentThread().getName() + "准备开始任务。。。。");
LockUtil.lock(preKey);
System.out.println(getDate()+Thread.currentThread().getName() + "模拟正在执行任务。。。。");
Thread.sleep(5000);//等待5秒,后面的所有线程都“依次”等待5秒,等待获取锁,执行任务
} catch (Exception e) {
System.out.println(getDate()+"线程锁 :" + Thread.currentThread().getId() + " exception :" + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
e.printStackTrace();
} finally {
LockUtil.unlock(preKey);
System.out.println(getDate()+Thread.currentThread().getName() + "释放。。。。");
}
}
//带时间限制的tryLock(),拿不到lock,就等一段时间,超时返回false。
public static void testTryLock(String preKey) {
try {
System.out.println(getDate()+Thread.currentThread().getName() + "准备开始任务。。。。");
boolean falg = LockUtil.tryLock(preKey);
//这里若获取不到锁,就直接返回了
if(!falg){
System.out.println(getDate()+Thread.currentThread().getName() + "--没有获取到锁直接返回--" + falg);
return;
}
System.out.println(getDate()+Thread.currentThread().getName() + "--获取锁--" + falg);
System.out.println(getDate()+Thread.currentThread().getName() + "模拟正在执行任务。。。。");
//由于在LockUtil.tryLock设置的等待时间是5s,所以这里如果休眠的小于5秒,这第二个线程能获取到锁,
// 如果设置的大于5秒,则剩下的线程都不能获取锁。可以分别试试2s,和8s的情况
Thread.sleep(8000);//等待6秒
} catch (Exception e) {
System.out.println(getDate()+"线程锁 :" + Thread.currentThread().getId() + " exception :" + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
e.printStackTrace();
} finally {
try {
LockUtil.unlock(preKey);
System.out.println(getDate()+Thread.currentThread().getName() + "释放。。。。");
}catch (Exception e){
e.printStackTrace();
}
}
}
//synchronized 这种锁只能锁住同一台机器的线程,若部署多台机器,则不能锁住
public static void testSyncro(String preKey) {
synchronized (preKey.intern()){//为什么要intern前篇文章有解释
try {
System.out.println(getDate()+Thread.currentThread().getName() + "准备开始任务。。。。");
System.out.println(getDate()+Thread.currentThread().getName() + "--获取锁--" );
System.out.println(getDate()+Thread.currentThread().getName() + "模拟正在执行任务。。。。");
Thread.sleep(6000);//执行2秒
} catch (Exception e) {
System.out.println(getDate()+"线程锁 :" + Thread.currentThread().getId() + " exception :" + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
e.printStackTrace();
} finally {
try {
System.out.println(getDate()+Thread.currentThread().getName() + "释放。。。。");
}catch (Exception e){
e.printStackTrace();
}
}
}
}
public static String getDate(){
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())+" ";
}
}
**2.1 当两个IDE同时执行testLock **
IDE-ONE
IDE-TWO
根据上面时间,可以看到上面2个IDE中的4个线程,都是依次等候执行
2.2 当两个IDE同时执行testTryLock
IDE-ONE
IDE-TWO
可以看到,在IDE-ONE中,先获取到锁后,休眠了8秒,后面线程,在锁的等待时间5秒内(时间在LockUtil.trylock有设置),无法获取到锁。
2.3 当两个IDE同时测试testSyncro
IDE-ONE
IDE-TWO
可以看到,在同一个IDE中线程,是排队执行。在不同IDE中,是可以同时执行的。这时就体现了上面分布式锁的作用了