分布式锁

为什么使用锁

两个原因:

  1. 效率

    避免多个客户端重复相同的工作,此时对共享资源的操作需要满足幂等性,操作多次的结果是一样的。这种情况下加锁就是为了避免重复相同的工作。

  2. 正确性

    避免多个客户端操作同一份共享资源时由于环境原因导致数据不一致,此时对共享资源的操作不满足幂等性,这种情况下加锁就是为了业务逻辑的正确性。

锁的作用的就是确保同一时间只有一个线程可以进入临界区执行代码

什么是锁

Linux 自旋锁

1
2
3
4
5
6
7
8
9
单处理器自旋锁的工作流程是:
关闭内核抢占->运行临界区代码->开启内核抢占。
更加安全的单处理器自旋锁工作流程是:
保存IF寄存器->关闭当前CPU中断->关闭内核抢占->运行临界区代码->开启内核抢占->开启当前CPU中断->恢复IF寄存器。

多处理器自旋锁的工作流程是:
关闭内核抢占->(忙等待->)获取自旋锁->运行临界区代码->释放自旋锁->开启内核抢占。
更加安全的多处理器自旋锁工作流程是:
保存IF寄存器->关闭当前CPU中断->关闭内核抢占->(忙等待->)获取自旋锁->运行临界区代码->释放自旋锁->开启内核抢占->开启当前CPU中断->恢复IF寄存器。

Linux 互斥锁

1
2
互斥锁是基于自旋锁、等待队列、整型变量的引用计数器的结构体封装,
内部的加锁释放锁的逻辑都是基于自旋锁和CPU原子操作完成的

Java语言层面的锁都是基于Linux锁实现的

本地锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
//本地锁没有锁超时的机制,一个线程获取到锁后,其他线程只能阻塞等待
public static void testThreadPoolException() throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(10);
Lock lock = new ReentrantLock();

Object object = new Object();
new Thread(()->{
try {
System.out.println(Thread.currentThread().getName() + "睡30s后通知");
Thread.sleep(30000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (object) {
object.notify();
}
}).start();
Thread thread = new Thread(() -> {
try {
lock.lock();
System.out.println(Thread.currentThread().getName() + "等待sleep线程30s后通知");
synchronized (object) {
object.wait();
}
System.out.println(Thread.currentThread().getName() + "收到sleep线程30s后通知,退出线程不释放锁");
Thread.currentThread().stop();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
});
thread.start();
// 线程池对任务异常默认不作处理
for (int i = 0; i < 10; i++) {
// 本地锁没有锁超时的机制,一个线程获取到锁后,其他线程只能阻塞等待
executorService.execute(() -> {
try {
lock.lock();
System.out.println(Thread.currentThread().getName() + "持有锁");
//for (;;){}
//Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
//System.out.println(1/0);
});
}

Thread.sleep(1000);
System.out.println(executorService.isTerminated());
//线程池里有非daemon线程在运行中,JVM不退出
}

读写锁

读写锁应用在读多写少的场合,比如缓存

读写锁是两个锁的组合,主要建立了以下关系:

  1. 读锁与写锁互斥(比如:更新缓存时加写锁,读取缓存时加读锁。则更新缓存时,读取缓存的线程要阻塞等待)
  2. 读锁与读锁共享(多个线程可以并发同时读取缓存)

分布式锁-Spring-Integration-Redis

Redis一次只运行一个命令,Lua脚本的运行与其他的Redis命令相同,都是原子操作

获取锁(lua脚本执行命令)

lua脚本逻辑:

先判断key是否存在?

key存在,则判断key对应value是否和本次要设置的值一致,一致则设置超时时间

key不存在或者key对应value不一致,则新设置key-value,并设置超时时间(set的值要保持唯一,可以使用uuid,以防误删其他客户端获取到的锁。场景:【客户端A获取锁成功后,由于操作阻塞较长时间,key过期删除了。然后客户端B获取锁成功,在B操作期间,客户端A又尝试去释放锁,如果没有value判断,就会造成客户端A释放了客户端B获取到的锁】)

1
2
3
4
5
6
7
8
9
local lockClientId = redis.call('GET', KEYS[1])
if lockClientId == ARGV[1] then
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return true
elseif not lockClientId then
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
return true
end
return false

释放锁

删除key

1
2
3
4
// 都是删除key
// unlink是先把key从key space删除,然后异步删除
// del是同步删除
// unlink key / del key

Spring-Integration-Redis实现分布式锁:(ReentrantLock + Redis)

在本地使用ReentrantLock来防止:线程1获取redis锁,操作阻塞,redis锁超时被删除,然后线程2来获取锁时会阻塞在获取同一把ReentrantLock上,只有线程1释放redis锁(由于redis锁超时,会throw IllegalStateException: Lock was released in the store due to expiration. The integrity of data protected by this lock may have been compromised)后,ReentrantLock锁才会释放,然后线程2才可以获取到同一把redis锁+ReentrantLock

但是如果线程1、2不是在同一个JVM里,这种本地锁的方式无法解决:锁超时引起的两个线程同时进入临界区的问题

Spring-Integration-Redis 对锁超时没有进行更好的处理,只是抛出异常

所以,使用Spring-Integration-Redis时,需要慎重设置Key过期时间(默认60s),因为过期时间设置太短会有锁超时问题,过期时间设置太长,如果一个服务在获取锁后崩溃, 其他服务要阻塞很久才可以重新获取锁。

问题:

  1. 每个锁对应的key都设置了超时时间,锁超时删除会引发两个线程同时进入临界区的问题
  2. 这种方式是基于单机Redis的实现,还具有单点失败的风险
  3. 即使将Reids架设成主从结构,在主从切换的时候也会出现两个线程同时进入临界区的问题
  4. 使用RedLock应对Redis高可用环境下分布式锁的获取,也会存在集群节点崩溃和时间跳跃导致两个线程同时进入临界区的问题,另外RedLock也没解决锁超时删除会引发两个线程同时进入临界区的问题

解决:

  1. 解决锁超时删除:锁续约机制
  2. 解决单点失败:部署Redis集群使用RedLock
  3. 解决RedLock集群节点崩溃问题:延迟重启崩溃节点(类似TCP退避重传机制,延迟时间大于锁的有效时间)
  4. 解决RedLock时间跳跃问题:禁止人为修改系统时间,使用一个不会进行“跳跃”式调整系统时钟的ntpd程序。

分布式锁-Reddison

获取锁(lua脚本执行命令)

lua脚本逻辑:

判断HASH key是否存在,

不存在,则新建HASH Key,设置Field和自增Field,并设置Key过期时间

判断HASH Key Field是否存在,

存在,则自增Field,并设置Key过期时间(默认30,000ms=30s)

返回Key过期时间

1
2
3
4
5
6
7
8
9
10
11
if (redis.call('exists', KEYS[1]) == 0) then 
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
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]);

为防止锁超时引起的安全问题,Redisson引入锁续约机制:获取锁成功后开启看门狗定时器默认每30,000/3ms=10s刷新一次Key过期时间

1
2
3
4
5
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then 
redis.call('pexpire', KEYS[1], ARGV[1]);
return 1;
end;
return 0;

释放锁

判断HASH Key-Field是否存在,

不存在,直接返回空,Throw IllegalMonitorStateException:attempt to unlock lock, not locked by current thread

存在,递减 HASH Key-Field,并判断递减后返回count是否为0

为0,则可以释放锁:执行DEL 删除Key,并发布 UNLOCK_MESSAGE 消息(在获取锁时,未马上获取锁成功时会订阅UNLOCK_MESSAGE并同步等待用户指定的tryLock时间)

不为0,则续约锁

1
2
3
4
5
6
7
8
9
10
11
12
13
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then 
return nil;
end;
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
redis.call('del', KEYS[1]);
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
return nil;

优点:解决锁超时问题,支持Redis Cluster环境下的RedLock

缺点:Redis Cluster的缺点:集群节点崩溃问题、时间跳跃问题

分布式锁-ZooKeeper

zookeeper和客户端建立连接后有心跳检测机制,当zookeeper长时间检测不到客户端心跳时,会主动释放客户端创建的临时节点,因此不需要设置锁的过期时间,不依赖于锁过期时间,所以不存在锁超时问题。

【ZooKeeper检测不到客户端心跳主动释放客户端创建的临时节点】这种机制也可能是由客户端长时间GC所导致的,所以也存在安全问题,但是相比redis的锁超时要可靠多了

另外ZooKeeper集群的强一致性保证了客户端要么获取不到锁,要么获取到锁后,ZooKeeper的集群节点数据一定是一致的。即不存在Redis集群的节点崩溃和时间跳跃的问题

优点:强一致性,不需要关心锁超时问题

缺点:性能不高

分布式锁-Mysql

创建一张资源表,获取锁则插入一条记录,释放锁则删除相应的记录。

有两种方式:

悲观锁:select for update + insert (一定要加事务)【事务是保证多个SQL语句要么都执行成功提交,要么任意一个失败都全部回滚,for updte是对要更新的行加锁保证更新的逻辑正确性】

乐观锁:select + update where version=xx + while循环直到成功

优点:不引入第三方组件,减少单点失败概率

缺点:数据库性能较低,支持的并发不高

另外Mysql提供了get_lock & release_lock函数也可以用于分布式锁,加锁释放锁操作都必须是同一个SqlSession,连接断开Mysql还会自动释放锁。这种锁机制是基于单机数据库的,所以存在单点失败问题。

基本用法:

1
2
3
4
#加锁
SELECT GET_LOCK(key, timeout);
#释放锁
SELECT RELEASE_LOCK(key);

参考文章

浅谈分布式锁
分布式之抉择分布式锁
万字长文!不为人知的分布式锁实现,全都在这里了!
-------------本文结束感谢您的阅读-------------
Good for you!