究事物之际,通技艺之变,成一家之言 |
在单机服务中,锁存在于单机的JVM中,对单机中应用的线程进行加锁和释放锁的操作。然而在分布式系统中,应用是部署在多台机器上的,因此便存在互相独立的多个JVM,传统的锁方式便失效,因为传统的锁只能对单个JVM中的线程作用。于是,分布式锁便应运而生。
分布式锁目前的方案有很多种,包括但不限于:Redis分布式锁(RedLock),Zookeeper分布式锁、数据库(实现的分布式锁(MySQL)。下面分别介绍三种锁的实现方法及细节。
RedLock
RedLock是Redis官方提出的基于Redis实现分布式锁的初级方案(官方redlock),也是目前较为完备的方案之一。分布式的三个基本要求:
- 安全特性:互斥。在任何给定时刻,只有一个客户端可以持有锁。
- 活力属性A:无死锁。即使锁定资源的客户端崩溃或分区,也始终可以获得锁定。
- 活动性B:容错能力。只要大多数Redis节点都处于运行状态,客户端就可以获取和释放锁。
1、单个实例锁的实现
Redis命令实现锁命令:
SET resource_name my_random_value NX PX 30000
该命令仅在密钥不存在(NX选项)且到期时间为30000毫秒(PX选项)时设置密钥。密钥设置为“我的随机值”。该值在所有客户端和所有锁定请求中必须唯一。随机值是为了以安全的方式释放锁,脚本会告诉Redis:仅当密钥存在且存储在密钥上的值恰好是我期望的值时,才删除该密钥。如Lua脚本实现:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
为了避免删除另一个客户端创建的锁,这一点很重要。例如,一个客户端可能获取了该锁,在某些操作中被阻塞的时间超过了该锁的有效时间(密钥将过期的时间),然后又删除了某个其他客户端已经获取的锁。仅使用DEL是不安全的,因为一个客户端可能会删除另一个客户端的锁。使用上述脚本时,每个锁都由一个随机字符串“签名”,因此仅当该锁仍是客户端尝试将其删除时设置的锁时,该锁才会被删除。
**随机字符串如何产生?**一个简单的解决方案是结合使用unix时间和微秒级分辨率,并将其与客户端ID串联在一起,它不那么安全,但在大多数环境中可能可以完成任务。
我们用作生存的关键时间的时间称为“锁定有效时间”。它既是自动释放时间,又是客户端执行另一操作之前客户端可以再次获取锁而技术上不违反互斥保证的时间,该时间仅限于给定的时间范围从获得锁的那一刻起的时间。
因此,现在我们有了获取和释放锁的好方法。该系统基于由一个始终可用的单个实例组成的非分布式系统的推理是安全的。让我们将概念扩展到没有此类保证的分布式系统。
2、Redlock分布式算法步骤
为了获取锁,客户端执行以下操作:
- 它获取当前时间(毫秒)。
- 它尝试在所有N个实例中依次使用所有实例中相同的键名和随机值来获取锁定。在第2步中,在每个实例中设置锁时,客户端使用的超时时间小于总锁自动释放时间,以便获取该超时时间。例如,如果自动释放时间为10秒,则超时时间可能在5到50毫秒之间。这样可以防止客户端长时间与处于故障状态的Redis节点进行通信:如果某个实例不可用,我们应该尝试与下一个实例尽快进行通信。
- 客户端通过从当前时间中减去在步骤1中获得的时间戳,来计算获取锁所花费的时间。当且仅当客户端能够在大多数实例(过半)中获取锁时 ,并且获取锁所花费的总时间小于锁有效时间,则认为已获取锁。
- 如果获取了锁,则将其有效时间视为初始有效时间减去经过的时间,如步骤3中所计算。
- 如果客户端由于某种原因(无法锁定N / 2 + 1个实例或有效时间为负数)而未能获得该锁,它将尝试解锁所有实例(即使它认为不是该实例)能够锁定)。
该算法基于这样的假设:尽管各进程之间没有同步时钟,但每个进程中的本地时间仍以近似相同的速率流动,并且与锁的自动释放时间相比,误差较小。
3、重试失败
当客户端无法获取锁时,它应该在随机延迟后重试,以尝试使试图同时获取同一资源的多个客户端不同步(这可能会导致脑裂的情况,其中没人胜)。同样,客户端在大多数Redis实例中尝试获取锁的速度越快,出现脑裂情况(以及重试的需求)的窗口就越小,因此理想情况下,客户端应尝试将SET命令发送到N个实例同时使用多路复用。
值得强调的是,对于未能获取大多数锁的客户端,尽快释放(部分)获取的锁有多么重要,这样就不必等待密钥期满才能再次获取锁(但是,如果发生了网络分区,并且客户端不再能够与Redis实例进行通信,则在等待密钥到期时要付出可用性代价)。
4、释放锁
释放锁很简单,只需在所有实例中释放锁,无论客户端是否认为它能够成功锁定给定的实例。
5、安全性讨论
该算法安全吗?我们可以尝试了解在不同情况下会发生什么。
首先,让我们假设客户端能够在大多数实例中获取锁。所有实例都将包含一个具有相同生存时间的密钥。但是,密钥是在不同的时间设置的,因此密钥也会在不同的时间失效。但是,如果第一个密钥在时间T1(在与第一台服务器联系之前进行采样的时间)设置为最差,而最后一个密钥在时间T2(从最后一台服务器获得答复的时间)设置为最坏的话,集合中第一个过期的密钥至少存在一次MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT
。所有其他键都将在以后失效,因此我们确保至少在这次同时设置这些键。
在设置大多数键的过程中,另一个客户端将无法获取锁,因为如果已经存在N / 2 + 1个键,则N / 2 + 1 SET NX操作将无法成功。因此,如果获取了锁,则不可能同时重新获取它(违反互斥属性)。
但是,我们还要确保尝试同时获取锁的多个客户端不能同时成功。
如果客户端使用接近或大于锁定最大有效时间(基本上是用于SET的TTL)的时间锁定了大多数实例,则它将认为锁定无效并将实例解锁,因此我们只需要考虑客户端能够在比有效时间短的时间内锁定大多数实例的情况。在这种情况下,对于上面已经说明的参数,MIN_VALIDITY
没有客户端应该能够重新获得该锁。因此,只有当大多数锁定时间大于TTL时间时,多个客户端才可以同时锁定N / 2 + 1个实例(“时间”为步骤2的结尾),从而使锁定无效。
您是否能够提供正式的安全证明,指向相似的现有算法或发现错误?这将不胜感激。
6、性能,崩溃恢复和fsync
使用Redis作为锁定服务器的许多用户在获取和释放锁的延迟以及每秒可能执行的获取/释放操作数方面都需要高性能。为了满足此需求,与N个Redis服务器进行通信以减少延迟的策略肯定是多路复用(或简易版的多路复用,即将套接字置于非阻塞模式,发送所有命令,并读取所有命令)之后,假设客户端和每个实例之间的RTT相似)。
但是,如果我们要针对崩溃恢复系统模型,还需要考虑持久性。
从理论上讲,如果要在遇到任何类型的实例重新启动时都保证锁定安全性,则需要在持久性设置中始终启用fsync = always。反过来,这将完全破坏性能,使其达到传统上以安全方式实现分布式锁的CP系统的水平。
但是,事情总比乍看之下要好。基本上,只要实例在崩溃后重新启动时就一直保持算法安全,它不再参与任何当前活动的锁,因此实例重新启动时的一组当前活动的锁全部是通过锁定实例而不是实例来获得的。正在重新加入系统。
为了保证这一点,我们只需要使一个实例在崩溃后至少不可用,而不是我们使用的最大TTL(即实例崩溃时存在的所有与锁有关的所有键)所需的时间。无效并自动释放。
使用***延迟重新启动***,即使没有任何种类的Redis持久性,也基本上可以实现安全性,但是请注意,这可能会转化为可用性损失。例如,如果大多数实例崩溃,则系统将对TTL全局不可用(此处全局意味着在此期间根本没有资源可锁定)。
Zookeeper实现分布式锁
实现方法:
1、多个客户端争取一把锁,先去ZK上争先在锁节点下创建临时顺序节点,ZK的节点是有顺序创建的,谁第一个创建成功谁获得锁;
2、没有获得锁的节点,就对自己的上一个节点进行监听;
3、自己的上一个节点释放了锁,ZK有通知机制,下一个节点收到通知后排到前面去。
4、如果获得锁的客户端宕机,ZK会感知到,然后自动删除其临时节点,相当于释放了锁。
下图是A先获得锁,B后获得锁的图解:
ZK锁机制讨论
1)羊群效应:ZK会会短时间向客户端推送很多无用的消息,因为每个节点只关心序号比自己小(在自己前面)的节点是否存在,自己能否补上去获取锁,因此ZK会产生很多消耗性能的通知。改进方法:
- 客户端调用getChildren方法获取所有已经创建的子节点列表(不注册任何Watcher);
- 如果获取不到读锁,那么调用exist来对比自己小的那个节点注册监听器:读请求向比自己小的最后一个写请求节点注册Watcher监听,写请求向比自己小的最后一个节点注册监听器;
- 等待Watcher监听,重复第一步。如下图所示:
2)目前使用的ZK分布式锁工具:Apache Curator,是一个Zookeeper的开源客户端,其提供以下锁:
-
Shared Reentrant Lock 可重入锁
-
Shared Lock 共享不可重入锁
-
Shared Reentrant Read Write Lock 可重入读写锁
-
Shared Semaphore 信号量
-
Multi Shared Lock 多锁
MySQL实现分布式锁
首先创建一个锁表
CREATE TABLE `methodLock` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
`desc` varchar(1024) NOT NULL DEFAULT '备注信息',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';
其中,method_name为要锁住的方法名,有唯一性约束。
1、实现方式一:基于表记录
实现原理:当多个客户端向表中insert时,只会有一个提交成功,剩下的都执行失败(收到ERROR 1062 XXX错误信息),没收到错误信息的就是获取锁成功。执行完后delete掉该条数据即完成释放锁的操作。
注意事项:
-
这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
-
这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
-
这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
-
这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
-
这把锁是非公平锁,所有等待锁的线程凭运气去争夺锁。
2、实现方式二:基于数据库自身的排它锁
同样使用上方的表结构,可以通过数据库的排他锁来实现分布式锁,在查询语句后面增加for update
,数据库会在查询过程中给数据库表增加排他锁。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。
我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过connection.commit();
操作来释放锁,代码如下:
public boolean lock(){
connection.setAutoCommit(false)
while(true){
try{
//核心操作
result = select * from methodLock where method_name=xxx for update;
if(result==null) return true;
}catch(Exception e){
}
sleep(1000);
}
return false;
}
public void unlock(){
connection.commit();
}
3、乐观锁实现
一般是通过为数据库表添加一个 “version”字段来实现读取出数据时,将此版本号一同读出,之后更新时,对此版本号加1,在更新过程中,会对版本号进行比较,如果是一致的,没有发生改变,则会成功执行本次操作;如果版本号不一致,则会更新失败,实际就是个diff过程。
问题所在:
(1). 这种操作方式,使原本一次的update操作,必须变为2次操作: select版本号一次;update一次。增加了数据库操作的次数。
(2). 如果业务场景中的一次业务流程中,多个资源都需要用保证数据一致性,那么如果全部使用基于数据库资源表的乐观锁,就要让每个资源都有一张资源表,这个在实际使用场景中肯定是无法满足的。而且这些都基于数据库操作,在高并发的要求下,对数据库连接的开销一定是无法忍受的。
(3). 乐观锁机制往往基于系统中的数据存储逻辑,因此可能会造成脏数据被更新到数据库中。