分布式锁设计
分布式锁
在许多场景下,不同的进程需要对共享资源以互斥的方式访问,以保证数据安全,分布式锁便是强有力的保障。
常见的分布式锁实现方案
- Zookeeper
- Redis
- MySQL
Zookeeper分布式锁
要了解基于Zookeeper实现的分布式锁,需要了解关于Zookeeper的基本知识。
四种节点类型:
- 持久化节点
- 持久化顺序节点
- 临时节点
- 临时顺序节点
Watch机制:
Zookeeper客户端可以监听某个一节点的状态,如果某节点被删除或者子节点数量发生变化,会通知客户端。
加锁&解锁
客户端尝试创建一个临时顺序节点(znode ),比如/lock。那么第一个客户端就创建成功了,相当于拿到了锁;而其它的客户端会创建失败(znode 已存在),获取锁失败。
临时节点有个特性,创建节点的客户端与Zookeeper之间的会话连接断开后,节点会自动删除,相当于释放锁。另外如果为了保证公平性,可以建立临时顺序节点,这样创建的节点会加在节点链最后的位置,等待锁的客户端会按照先来先得的顺序获取到锁。
客户端选型:如果是Java技术栈,推荐使用 Apache Curator,比Zookeeper官方提供的客户端类库更高级和易用。
优缺点分析
优点
- ZooKeeper 分布式锁基于分布式一致性算法实现,能有效的解决分布式问题,不受时钟变迁影响,不可重入问题,使用起来也较为简单;
- 当锁持有方发生异常的时候,它和 ZooKeeper 之间的 session 无法维护。ZooKeeper 会在 Session 租约到期后自动删除该 Client 持有的锁,以避免锁长时间无法释放而导致死锁。
缺点
ZooKeeper 实现的分布式锁,性能并不太高。原因是什么呢?
因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。大家知道,ZK 中创建和删除节点只能通过 Leader 服务器来执行,然后 Leader 服务器还需要将数据同步到超半数的 Follower 机器上,这样频繁的网络通信,对性能是有影响的。
Redis分布式锁
基于Redis实现分布式锁分为两种:
- 单节点Redis分布式锁方案
- 多节点Redis分布式锁方案
单节点Redis分布式锁
整个逻辑就是,让所有想操作「共享资源」的客户端都去 Redis 中 SETNX
同一条数据,谁先添加成功谁就算拥有分布式锁,然后就可以操作「共享资源」,操作完后在删掉该数据,释放锁。
加锁
SET lock_key $unique_id EX 30 NX
注:
NX 的意思是不存在时才会设置它的值,否则什么也不做,这保证读改写3个操作在Redis服务器中是原子操作;
设置过期时间30S,是为了防止加锁成功后的客户端还没释放锁就自己宕机,导致锁无法释放;
设置的value是客户端自己知道的随机值或线程ID,其他客户端不知道,这样可以避免客户端释放不属于他的锁;
释放锁
释放锁时需要使用 GET + DEL 两条命令,为了让着两条命令作为一个原子操作执行,我们可以使用Lua脚本来保证。
示例:
EVAL ./release.lua 1 my:lock lock:394A23CC43
release.lua 脚本:如果指定键的值与入参值相同,则删除该键值。
if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end
避免锁提前释放
到现在还有一个问题没解决,那就是如果业务执行时间超过锁的过期时间,锁就会在业务执行完之前提前释放,可能导致其他客户端也获取锁,造成线程不安全。
我们可以设计这样一个方案:加锁时,先设置一个过期时间,然后开启一个「守护线程」,定时去检测这个锁的过期时间,如果锁快要过期了,操作共享资源还未完成,那么就对锁进行「自动续期」,重新设置过期时间。如果你是 Java 技术栈,已经有一个 Redisson 库把这些工作都封装好了。
多节点Redis分布式锁
集群下的问题
来看下面这个场景
- 客户端 1 在主库上执行 SET 命令,加锁成功
- 此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的)
- 从库被哨兵提升为新主库,这个锁在新的主库上,丢失了!
可以看到在主从切换时,上面的单节点解决方案是有问题的。Redis官方提供了一种规范的算法来实现Redis 多节点的分布式锁 RedLock 。
RedLock
RedLock 解决方案基于 2 个前提:
- 只使用主库,不需要从库和哨兵实例
- 主库要部署多个,官方推荐至少 5 个实例
也就是说,想用使用 Redlock,至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系。
注:RedLock 不强调部署 Redis Cluster,部署 5 个不相关的 Redis 实例即可,当然RedLock 和集群部署也并不冲突。
Redlock 整个流程如下:
- 客户端先获取「当前时间戳 T1」
- 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),并设置超时时间(毫秒级),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
- 如果客户端从超过半数的 Redis 实例(>=3 )加锁成功,则再次获取「当前时间戳 T2」,判断如果锁的租期 > T2 - T1 ,则认为客户端加锁成功,否则认为加锁失败。
- 加锁成功后去操作共享资源。
- 加锁失败或操作结束,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)
整个逻辑说白了就是大家都去抢注锁,谁抢注的多谁就拥有锁。Redlock 通过向当前存活的多个主库抢注锁,实现了即使部分节点不可用,也不会影响到分布式锁系统,解决主从切换时,锁丢失的问题。
注意 Redlock 依然需要搭配 Redisson 来解决锁提前释放的问题
RedLock的争议
那么 Redlock 真的绝对安全吗?有人并不这么认为。Redis 作者提出的 Redlock 方案后,马上受到英国剑桥大学、业界著名的分布式系统专家 Martin (《数据密集型应用系统设计》作者)的质疑!认为这个 Redlock 的算法模型是有问题的,并写了篇文件对分布式锁的设计,提出了自己的看法。
之后,Redis 作者 Antirez 面对质疑,不甘示弱,也写了一篇文章,反驳了对方的观点,并详细剖析了 Redlock 算法模型的更多设计细节。关于争议的具体细节不在本文详细描述了,但强烈建议大家去仔细读一读,看看神仙是怎么“打架”的。
最后比较中肯的结论是,无论是 RedLock还是其他分布式锁的算法,在极端情况下都有可能出现问题, 只要明确极端情况下应该怎么提前采取措施避免,RedLock 当然是可以使用的。印证了那句话,在软件领域,没有一个解决方案是万能通用的,只有在不同的场景下选择合适的解决方案。同时,对于分布式锁的争议本身这件事,也会加深我们对分布式锁的理解。
MySQL分布式锁
基于MySQL实现分布式锁
需要在 MySQL 数据库创建一张加锁用的表:
CREATE TABLE shedlock
(
name VARCHAR(64),
lock_until TIMESTAMP(3) NULL,
locked_at TIMESTAMP(3) NULL,
locked_by VARCHAR(255),
PRIMARY KEY (name)
)
加锁
通过插入同一个 name(primary key),或者更新同一个 name 来抢,对应的 intsert、update 的 SQL 为:
INSERT INTO shedlock
(name, lock_until, locked_at, locked_by)
VALUES
(锁名字, 当前时间+最多锁多久, 当前时间, 主机名)
------------------------
UPDATE shedlock
SET lock_until = 当前时间+最多锁多久,
locked_at = 当前时间,
locked_by = 主机名 WHERE name = 锁名字 AND lock_until <= 当前时间
解锁
通过设置 lock_until 来实现释放,再次抢锁的时候需要通过 lock_util 来判断锁失效了没。对应的 SQL 为:
UPDATE shedlock
SET lock_until = lockTime WHERE name = 锁名字
问题
- 单点问题;
- 主动同步问题。假如使用全同步模式,分布式锁将会有性能上的问题。
总结
- 基于Zookeeper的分布式锁,适用于高可用而并发量不是太大的场景;
- 基于Redis的分布式锁,适用于并发量很大、性能要求很高的,而可靠性问题可以通过其他方案去弥补的场景;
- 基于MySQL的分布式锁一般均有单点问题,高并发场景下对数据库的压力比较大;
需要考虑的问题:
我们的业务对极端情况的容忍度,为了一把绝对安全的分布式锁导致过度设计,引入的复杂性和得到的收益是否值得。
README
作者:银法王
修改记录:
2023-07-01 第一次修订
参考: