⭐️ 前言
想必大家都有过并发编程的经验,在一个单体应用中,可以通过java提供的各种锁机制来控制多线程对于单体应用中同一资源的并发访问;那么在分布式场景下,想要控制多个应用对于同一外部资源的并发访问,就要用到分布式锁。分布式锁不但要保证单个应用程序内部不会产生并发问题,同时也要保证多个应用程序之间不能产生并发问题。分布式锁有很多实现方式,比如使用redis、zookeeper或关系型数据库的唯一索引,也有现成的分布式锁架构,比如redisson、curator等。本文利用spring-data-redis手动实现一个简易的redis分布式锁,剖析redis分布式锁的原理。
⭐️ redis分布式锁实现原理浅析
实现redis分布式锁最简单的想法就是各个应用利用setnx命令向redis中争抢设置key的机会,但我们还应该考虑得更加周全。
死锁
如果成功加锁的应用程序在未释放锁之前就异常终止了,那么这个锁永远无法释放,其他应用程序则永远也无法获取到锁,为了解决这个问题,需要给代表锁的redis的key加上过期时间。
原子性
很多时候我们需要保证多个操作具有原子性,
例如,加锁和设置过期时间
若它们无法保证原子性,则应用程序在刚刚成功加锁后就异常终止了,则仍然会出现上面的死锁问题。
原子性可以通过让redis执行Lua脚本来保证,eval命令可以原子性的执行Lua脚本(Lua的多个步骤会被原子性的执行),在redis内置的Lua脚本中有一个redis对象,可以通过redis.call()方法执行各种redis命令。
防误删
根据上面的讨论,我们需要给redis锁加上过期时间,当业务执行完毕之前锁就过期了,这种情况下,其他线程就会成功加锁,那么之前的程序运行到解锁逻辑时,就会造成对后面线程获得锁的误删。
误删可以通过给每一个线程设置一个id,我们可以叫它 线程标识码。
可重入性
另外,在应用程序中免不了方法的彼此调用,若锁无法重入,则业务根本无法执行,比如A方法需要加锁,它在执行过程中会调用B方法,B方法也需要加锁,若锁不具有可重入性,则程序根本无法运行。
可重入性可以通过hash数据结构来实现
key: 代表锁的key
field:线程的唯一标识id,即上文说的 线程标识码
value:重入次数
自动续期
若应用程序执行需要的时间大于锁的过期时间,则锁过期后,应用程序便不再受锁保护,这样就会导致并发问题。所以分布式锁还要具有自动续期的功能,即只要应用程序业务没有执行完毕,则锁需要不断的自动延长过期时间。
自动续期可以通过Timer定时任务配合Lua脚本来实现。
本文参考了上硅谷课程《【尚硅谷】分布式锁全家桶丨一套搞定Redis/Zookeeper/MySQL实现分布式锁》,B站上就有,更详细的内容读者可以去看这门课程。
这里贴出关键代码,完整代码我已经上传到了gitee。欢迎围观啊!!
⭐️ 加锁主要代码
/**
* 加锁
* @param time
* @param unit
* @return
* @throws InterruptedException
*/
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
if (time != -1){
this.expire = unit.toSeconds(time);
}
// redis加锁的lua脚本
String lockStr="if redis.call('exists', KEYS[1])==0 or redis.call('hexists', KEYS[1], ARGV[1])==1 " +
"then " +
"redis.call('hincrby', KEYS[1], ARGV[1], 1) " +
"redis.call('expire', KEYS[1], ARGV[2]) " +
"return 1 " +
"end " +
"return 0";
// 加锁
while (!this.redisTemplate.execute(new DefaultRedisScript<>(lockStr, Boolean.class), Arrays.asList(this.lockName), this.uuid, String.valueOf(this.expire))){
Thread.sleep(50);
}
// 自动续期
this.autoExpire();
return true;
}
⭐️ 解锁主要代码
/**
* 解锁
*/
@Override
public void unlock() {
// 解锁lua脚本
String unlockStr = "if redis.call('hexists', KEYS[1], ARGV[1])==0 " +
"then " +
"return nil " +
"end " +
"if redis.call('hincrby', KEYS[1], ARGV[1], -1)==0 " +
"then " +
"return redis.call('del', KEYS[1]) " +
"end " +
"return 0";
// 解锁
Long del = this.redisTemplate.execute(new DefaultRedisScript<>(unlockStr, Long.class), Arrays.asList(this.lockName), this.uuid);
if (del == null){
throw new IllegalMonitorStateException("lock wrong");
}
if (del == 1L){
System.out.println("lock deleted");
}
}
⭐️ 自动续期主要代码
/**
* 自动续期
*/
private void autoExpire(){
String expireStr="if redis.call('hexists', KEYS[1], ARGV[1])==1 " +
"then return redis.call('expire', KEYS[1], ARGV[2]) " +
"end " +
"return 0";
new Timer().schedule(new TimerTask() {
@Override
public void run() {
if (redisTemplate.execute(new DefaultRedisScript<>(expireStr, Boolean.class), Arrays.asList(lockName), uuid, String.valueOf(expire))){
autoExpire();
}
}
}, this.expire * 1000 /3);
}
⭐️ 运行架构
运行架构比较简单,可以启动两个应用实例,利用nginx做负载均衡。
⭐️ 压力测试
压力测试可以使用jmeter,其设置如下图所示
笔者水平有限,若有不对的地方欢迎评论指正!