0%

分布式锁

JVM中提供的synchronized和Lock锁都是JVM级别的,也就是说synchronized和Lock只在同一Java进程内有效,当我们需要实现多个JVM进程之前的线程互斥时,我们就需要使用分布式锁了。

注意:相比单机锁,分布式锁并不能提高性能,甚至由于网络IO,会降低系统的性能。在高并发环境,可以先尝试获取单机锁,如果单机锁获取失败则无需尝试获取分布式锁,避免多余的网络IO降低系统性能。如果获取单机锁成功,再尝试获取分布式锁。

分布式锁特性

  • 互斥性:保证只有一个线程持有同一把锁。
  • 健壮性:避免死锁,锁不会被永久持有,即使服务奔溃了,也要保证锁在一定期间内会被安全释放。
  • 高可用性:除非整个分布式系统瘫痪,只要有服务存活,都允许获取和释放锁。
  • 安全性:谁上的锁由谁释放,不能被其他线程解锁。

常见的分布式锁方案

  1. 基于数据库实现
  2. 基于Redis实现
  3. 基于Zookeeper实现
  4. 基于Etcd实现etcd:一款比Redis更骚的分布式锁的实现方式!用它

根据获取分布式锁失败后的操作,可以将上述方案进行简单分类:

  • 类CAS自旋式获取分布式锁:MySQL、Redis
  • Event事件通知后再尝试获取分布式锁:Zookeeper、Etcd

JVM的锁获取失败,为了避免线程上下文切换,一般会进行CAS自旋,自旋到一定次数依然获取失败后线程才进入休眠状态。但分布式锁由于有网络IO,所以类CAS自旋式获取分布式锁的方式并不能提高性能,还会频繁的网络请求,Event事件一般基于长连接,开销也不小,但相对来说还是基于Event事件的方式更好些。

无论采用哪种方式,我们应该保证一个JVM,同时只有一个线程去尝试获取同一把分布式锁(获取分布式锁前需先获取JVM锁)。

基于数据库实现

利用唯一索引

加锁
1
insert into methodLock(method_name,desc) values ('method_name','desc');
解锁
1
delete from methodLock where method_name ='method_name';
小结
  1. 数据库存在单点故障,会导致业务系统不可用。
  2. 锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
  3. 锁只能是非阻塞的,因为 insert 操作一旦插入失败就会直接报错,没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
  4. 这把锁是不可重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

基于Redis实现

加锁

1
SET key value PX NX

SET key value [EX seconds|PX milliseconds|KEEPTTL] [NX|XX]

  • EX seconds – Set the specified expire time, in seconds.
  • PX milliseconds – Set the specified expire time, in milliseconds.
  • NX – Only set the key if it does not already exist.
  • XX – Only set the key if it already exist.
  • KEEPTTL – Retain the time to live associated with the key.

NX可以保证只有一个线程能够获取到同一把锁(也就是只有一个线程能够SET成功)

PN可以保证不会出现死锁(一段时间后,锁会自动过期)

发布订阅+超时后获取

上面分析过,采用类CAS自旋获取分布式锁的方式会导致频繁的网络请求。所以这里可以进行一定的优化。

采用发布订阅的方式,释放锁时,发布锁释放消息,收到消息的JVM进程再尝试获取锁。或者一段时间(可以设置相对较长)没有收到消息,再尝试获取分布式锁(超时时间的作用是防止客户端没有发布消息)。

守护线程续租

当业务执行的时间大于锁超时时间,无法保证只有一个线程获取同一把锁。

所以在获取到锁后,在锁超时之前应该给锁进行续租(重新设置超时时间)。

解锁

解锁时,为了防止锁被错误的释放,可以加上UUID,使用UUID+线程ID的方式来进行标识(UUID只需要生成一次即可)。所以解锁的操作就是 delete if exist and value = 锁标识,但是Redis没有这个原子命令,需要借助Lua脚本,例如:

1
2
3
4
5
6
if (redis.call('get', KEYS[1]) == ARGV[1]) 
then
return redis.call('del', KEYS[1])
else
return 0
end

高可用 Trade off

因为Redis集群的主从复制是异步的,当发生主备切换时,可能导致多个线程获取到同一把锁

这个时候就要进行 trade off,如果可以接受极少数情况下多个线程获取到同一把锁,那可以使用集群。否者就需要:

  1. Redis 使用单点,使用安全级别更高的服务器部署Redis单点服务
  2. 多个Redis集群,使用 RedLock 算法

RedLock

在 Redis 的分布式环境中,我们假设有 N 个 Redis Master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。官网文档

现在假设有 5 个完全独立的 Redis Master 节点,他们分别运行在 5 台服务器中,可以基本可以保证他们不会同时宕机。一个客户端加锁/解锁,必须经过以下的五个步骤:

  1. 获取当前 Unix 时间,以毫秒为单位。
  2. 依次尝试从 5 个实例,使用相同的 key 和随机值获取锁。(向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间,这样可以避免客户端死等)
  3. 客户端使用当前时间减去开始获取锁时间(步骤 1 记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是 3 个节点)的 Redis 节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
  4. 如果取到了锁,key 的真正有效时间等于有效时间减去获取锁所使用的时间(步骤 3 计算的结果)
  5. 如果因为某些原因,获取锁失败(没有在至少 N/2+1 个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的 Redis 实例上进行解锁(即便某些 Redis 实例根本就没有加锁成功)。

为了进一步保证互斥性,假设步骤1的时间为T1,步骤3的时间为T2,锁的有效时间为TTL,那么锁的真正有效时间应该为:

1
MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT

CLOCK_DRIFT:是指时钟漂移的误差值,通常是一个经验值。

更多资料:

  1. Redis锁从面试连环炮聊到神仙打架
  2. How to do distributed locking

基于Zookeeper实现

利用临时节点实现

加锁

创建临时节点,谁创建成功谁获取到锁。

Watch监控

获取失败后,监控该节点的事件,收到事件后,再尝试获取锁。

解锁

断开连接后临时节点会被自动删除。

小结

这种方式实现的是非公平锁,但存在惊群效应,一般不会考虑。

利用临时顺序节点实现

流程图如下:

zk分布式锁

加锁

创建临时顺序节点,排序后如果自己是自小节点则可以获取锁。

否则监控上一个节点,上一个节点删除后,再排序判断自己是否为最小节点直到获取锁成功。

解锁

断开连接后临时节点会被自动删除。

小结

这种方式实现的是公平锁,zk分布式锁一般都是实现的这种方式。

基于Zookeeper实现的分布式锁也不是完全可靠,例如:客户端A获取到锁之后,由于网络抖动,客户端和ZK集群的session连接断开了,ZK会以为客户端挂了并删除临时节点,这个时候其他客户端就可以获取到分布式锁了。但这种问题并不常见,因为一般Zookeeper的客户端都有重试机制。

小结

上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以要根据不同的应用场景选择最适合的方案。

  • 从性能角度比较:Redis > Zookeeper > 数据库
  • 从可靠性角度比较:数据库 > Zookeeper >= Redis
坚持原创技术分享,您的支持将鼓励我继续创作!