一文理解分布式锁的实现方式

全菜工程师小辉

共 9726字,需浏览 20分钟

 ·

2021-06-12 14:33

分布式锁的应用场景

分布式锁的应用场景主要包括两类:

  1. 处理效率提升:减少重复任务的执行,避免资源处理效率的浪费(例如幂等场景)。

  2. 数据准确性保障:在数据资源的并发访问时,避免数据不一致情况,甚至数据更新损失等。

分布式锁的设计要求

  1. 分布式锁需要是一把可重入锁(避免死锁)。

  2. 分布式锁最好是一把阻塞锁(没有获得锁的线程不是直接返回,而是在阻塞状态)。

  3. 分布式锁最好是一把公平锁,防止过度饥饿。

  4. 分布式锁有高可用的获取锁和释放锁功能。

分布式锁的实现方式

分布式锁一般有四种实现方式:

  1. 基于数据库。

  2. 基于Redis的分布式锁。

  3. 基于ZooKeeper的分布式锁。

  4. 基于etcd的分布式锁。

数据库

基于数据库表

要实现分布式锁,最简单的方式就是直接创建一张锁表,然后通过操作该表中的数据来实现锁。

当要锁住某个方法或资源时,就在该表中增加一条记录,想要释放锁的时候就删除这条记录。

创建这样一张数据库表:

CREATE TABLE `method_lock` (
`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 `uk_method_name_desc` (`method_name `,`desc`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

当想要锁住某个方法时,执行以下SQL:

insert into method_lock(method_name,desc) values (`method_name`,`desc`)

因为对method_name和desc做了唯一性约束,如果有多个请求同时提交到数据库的话,数据库会保证同一个资源只有一个操作可以成功,那么就可以认为操作成功的那个线程获得了该方法的锁,可以继续执行业务逻辑。

当方法执行完毕之后,想要释放锁的话,需要执行以下Sql:

delete from method_lock where method_name ='xxx' and desc='xxxx'

这种实现方式存在的问题:

  1. 锁强依赖数据库的可用性,数据库是单点,一旦数据库挂掉,会导致业务系统不可用。

  2. 锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。

  3. 锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。

  4. 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在。

相应的解决方法:

  1. 同时部署两个数据库,一台业务用,另一台做热备。

  2. 设置一个定时任务,每隔一定时间把数据库中的超时锁清理掉。

  3. 使用while重复执行。同时需要设置重试次数,防止持续拿不到锁导致服务器资源耗尽。

  4. 在表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果主机信息和线程信息与表中的信息吻合,直接把锁分配给该线程。

基于数据库排他锁

除了基于数据库表,还可以借助数据库的排他锁来实现分布式的锁。

依旧使用上面创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。基于MySql的InnoDB引擎,可以使用以下方法来实现加锁操作:

public boolean lock() {
connection.setAutoCommit(false);
/**
* 设置重试次数,防止持续拿不到锁导致服务器资源耗尽
*/

int count = 0;
while (count < 4) {
try {
result = select * from method_lock where method_name = xxxx and desc = xxx for update;
if (result == null) {
return true;
}
} catch (Exception e) {
}
//为空或者抛异常的话都表示没有获取到锁
sleep(1000);
count++;
}
return false;
}

获得排它锁的线程即获得分布式锁。当获取到锁之后,可以继续执行业务逻辑,执行完方法之后,再通过以下方法解锁:

public void unlock(){
connection.commit();
}

针对加锁之后服务宕机,无法释放的问题,使用这种方式,服务宕机之后数据库会自己把锁释放掉。

但还是无法解决数据库单点和可重入问题。

此外,要使用排他锁来进行分布式锁的lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆。

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁。但需要注意的是MySQL会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由MySQL通过判断不同执行计划的代价来决定的:如果MySQL认为全表扫效率更高,比如对一些数据量小的表,MySQL就不会使用索引。这种情况下查询将出现表锁,而不是行锁,这会导致所有sql写操作阻塞。。。

基于数据库乐观锁

前面基于数据库悲观锁实现的分布式锁,基于数据库也可以使用乐观锁来实现分布式锁(资源表增加version字段)。

  1. 先执行SELECT操作查询当前数据的数据版本号,比如当前数据版本号是30:SELECT id,version FROM method_lock WHERE method_name ='xxx' and desc='xxxx';

  2. 执行更新操作:UPDATE method_lock SET version=27, update_time=NOW() WHERE id=xx AND version=30;

  3. 如果上述UPDATE语句更新影响到了一行数据,那就说明抢占锁成功。如果没有更新影响到一行数据,则说明这个资源已经被别人占位了。

基于数据库表做乐观锁的缺点:

  1. 原本一次的UPDATE操作,变为2次操作:SELECT版本号一次和UPDATE一次。增加了数据库操作的次数。

  2. 如果业务场景中的一次业务流程中,多个资源都需要用保证数据一致性,那么如果全部使用基于数据库资源表的乐观锁,就要让每个资源都有一张资源表,这个在实际使用场景中肯定是无法满足的。而且这些都基于数据库操作,在高并发的要求下,对数据库连接的开销一定是无法忍受的。

总结

数据库实现分布式锁的优点:

直接使用数据库,使用简单且节省运维成本。

数据库实现分布式锁的缺点:

  1. 系统不稳定会造成各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。

  2. 操作数据库需要一定的开销且长时间不commit或者长时间轮询,可能会占用较多连接资源。

  3. 数据库的行级锁在数据量小等情况时有可能转变表级锁,增加锁表风险。

  4. 对于主从复制且读写分离的数据库来说,如果select操作在从库,而update在主库的话,如果存在主从延迟,可能会出现加锁错误问题。建议将select与update放在同一个事务中,这样就可以都在主库进行select和update了。

基于Redis的分布式锁

基于Redis实现分布式锁主要有两大类,一类是基于单机,另一类是基于Redis多机。

基于Redis单机实现的分布式锁

加锁命令基于Redis命令:SET key value NX EX max-lock-time

加锁与解锁的代码示例:

class RedisTool {
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
private static final Long RELEASE_SUCCESS = 1L;
/**
* 尝试获取分布式锁
*
* @param jedis Redis客户端
* @param lockKey
* @param requestId 请求标识 自定义的随机字符串,用于解锁的时候判断是否是当前用户加锁
* @param expireTime 超期时间
* @return 是否获取成功
*/

public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}

/**
* 释放分布式锁
* 获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)
* @param jedis Redis客户端
* @param lockKey
* @param requestId 请求标识
* @return 是否释放成功
*/

public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
}

这种实现方式存在的问题:

  1. 这把锁只能是非阻塞的,无论成功还是失败都直接返回。

  2. 这把锁是非重入的,一个线程获得锁之后,在释放锁之前,无法再次获得该锁,因为使用到的key在Redis中已经存在。

  3. 分布式锁过期,而业务逻辑没执行完。

相应的解决方法:

  1. 使用while重复执行并设置重试次数,防止持续拿不到锁导致服务器资源耗尽。

  2. 在一个线程获取到锁之后,把当前主机信息和线程信息保存起来,下次再获取之前先检查自己是不是当前锁的拥有者。

  3. 自行维护续期逻辑。

使用Redisson的分布式锁

目前互联网公司在生产环境用的比较广泛的开源框架Redisson很好地解决了锁被提前释放这个问题,非常的简便易用,且支持Redis单实例、Redis主从、Redis Sentinel、Redis Cluster等多种部署架构。

Redisson框架会开启一个定时器的守护线程,每expireTime/3执行一次,去检查该线程的锁是否存在,如果存在则对锁的过期时间重新设置为expireTime,即利用守护线程对锁进行“续命”,防止锁由于过期提前释放。

其实现原理如图所示:

这种实现方式存在的问题和解决方法:

  1. Redission的分布式锁也是非阻塞的,同样需要while重复执行。

  2. Redission的分布式锁是可重入的。因为Redisson的锁是hset结构,key值就是客户端的身份标识,value是加锁次数,从而实现了可重入加锁。

基于Redis多机实现的分布式锁Redlock

以上两种基于Redis单机实现的分布式锁都存在一个问题:加锁时只作用在一个Redis节点上,即使Redis通过Sentinel或者Cluster保证了高可用,但由于Redis的复制是异步的,Master节点获取到锁后在未完成数据同步的情况下发生故障转移,此时其他客户端上的线程依然可以获取到锁,因此会丧失锁的安全性。

正因为如此,Redis的作者antirez提供了RedLock的算法来实现一个分布式锁。该算法流程是这样的:

假设有 N(N>=5)个Redis节点,这些节点完全互相独立。(不存在主从复制或者其他集群协调机制,确保这N个节点使用与在Redis单实例下相同的方法获取和释放锁)

获取锁的过程,客户端应执行如下操作:

  1. 获取当前Unix时间,以毫秒为单位。

  2. 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个创建锁的超时时间,这个超时时间应该远小于锁的失效时间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在一直等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。

  3. 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁消耗的时间。当且仅当从大多数(大于N/2+1,这里是3个节点)的Redis节点都取到锁,并且获取锁消耗的时间小于锁失效时间时,锁才算获取成功。

  4. 如果取到了锁,key的真正有效时间等于有效时间减去获取锁消耗的时间(步骤3计算的结果)。

  5. 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁。(虽然某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)

而分布式系统专家Martin针对Redlock提出了一个场景:假设多节点Redis系统有五个节点A/B/C/D/E和两个客户端C1和C2,如果其中一个Redis节点上的时钟向前跳跃会发生什么?

  1. 客户端C1获得了对节点A、B、c的锁定,由于网络问题,法到达节点D和节点E。

  2. 节点C上的时钟向前跳,导致锁提前过期。

  3. 客户端C2在节点C、D、E上获得锁定,由于网络问题,无法到达A和B。

  4. 客户端C1和客户端C2现在都认为他们自己持有锁。

这说明时钟跳跃对于Redlock算法影响较大,这种情况一旦发生,Redlock是没法正常工作的。

对此,Antirez指出Redlock算法对系统时钟的要求并不需要完全精确,只要误差不超过一定范围不会产生影响,在实际环境中是完全合理的,通过恰当的运维完全可以避免时钟发生大的跳动。

更多有关着Martin对Redlock算法的质疑以及Antirez的回应,请查阅参考文档3和4,感兴趣的同学可以阅读一下。同时也可以看参考文档5和6铁蕾大神的文章,更加快捷了解这场争论。

总结

使用Redis实现分布式锁的优点:

相比数据库来,Redis实现分布式锁,可以提供更好的性能。

使用Redis实现分布式锁的缺点:

如果应用场景是为了处理效率提升,协调各个客户端避免做重复的工作,即使锁失效了,发生业务逻辑重复执行也不会有大的影响,则可以使用Redis实现分布式锁。但是如果你的应用场景是为了数据准确性保障,那么用Redis实现分布式锁并不合适(因为Redis集群是AP模型)。为了正确性,需要考虑接口幂等性,同时使用zab(Zookeeper)、raft(etcd)等共识算法的中间件来实现严格意义上的分布式锁。

补充

从Redis 2.6.12版本开始,SET命令的行为可以通过一系列参数来修改:

  • EX seconds:将键的过期时间设置为seconds秒。执行SET key value EX seconds的效果等同于执行SETEX key seconds value

  • PX milliseconds:将键的过期时间设置为 milliseconds 毫秒。执行SET key value PX milliseconds的效果等同于执行 PSETEX key milliseconds value

  • NX:只在键不存在时,才对键进行设置操作。执行SET key value NX的效果等同于执行SETNX key value

  • XX:只在键已经存在时,才对键进行设置操作。

因为SET命令可以通过参数来实现SETNX、SETEX以及PSETEX命令的效果,所以Redis将来的版本可能会废弃并移除SETNX、SETEX和PSETEX这三个命令。

基于Zookeeper实现分布式锁

ZooKeeper是一个分布式协调服务的开源框架。主要用来解决分布式集群中应用系统的一致性的问题。

ZooKeeper本质上是一个分布式的小文件存储系统。提供基于类似于文件系统的目录树方式的数据存储,并且可以对树的节点进行有效管理。

使用ZooKeeper实现分布式锁的过程:

  1. 客户端连接ZooKeeper,并在/tmp下创建临时且有序的子节点,第一个客户端对应的子节点为lock-0000,第二个为lock-0001,以此类推。

  2. 客户端获取/lock下的子节点列表,判断创建的节点是否为当前子节点列表中序号最小的节点,如果是则认为获得锁,否则监听前一个子节点的删除消息。

  3. 获取锁后,执行业务代码流程,删除当前客户端对应的子节点,锁释放。

例如:/tmp下的子节点列表为:lock-0000、lock-0001、lock-0002,序号为1的客户端监听序号为0000子节点的删除消息,序号为2的监听序号为0001子节点的删除消息(业务代码执行完结束后删除子节点)。

这种实现方式存在的问题和解决方法:

  • 针对分布式锁无法自动释放的问题,Zookeeper可以有效地解决。因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。

  • 针对分布式锁最好是阻塞锁的问题,Zookeeper通过在节点上绑定监听器,当获取到锁的时候,调用回调函数的方式,实现了阻塞锁的效果。

  • 针对分布式锁的可重入特性,Zookeeper可以有效地解决。客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。

  • 针对分布式单点问题问题导致的锁失效问题,Zookeeper可以有效地解决。Zookeeper是集群部署,只要集群中有半数以上的机器存活,就可以对外提供服务。

Apache Curator是一个Zookeeper的开源客户端,它提供了Zookeeper各种应用场景(如共享锁服务、master选举、分布式计数器等)的抽象封装,简化了ZooKeeper的操作。

总结

使用Zookeeper实现分布式锁的优点:

有效的解决单点问题、不可重入问题、非阻塞问题以及锁无法释放的问题。实现起来较为简单。

使用Zookeeper实现分布式锁的缺点:

因为Zookeeper集群采用zab一致性协议,所以高并发场景,性能上不如使用Redis实现分布式锁。

基于etcd的分布式锁

有关etcd的机制以及分布式锁的实现,等小辉写到Service Mesh、K8s和etcd的时候,再将这一块空缺填上。

分布式锁的技术选型

目前以小辉的了解,生产环境应该很少使用数据库来做分布式锁,即使基于数据库的分布式锁实现比较简单。

目前比较热门的技术选型有基于Redis、Zookeeper和etcd的分布式锁。其中基于Redis单机和Redisson的分布式锁,都属于AP模型;而基于Zookeeper与etcd的分布式锁属于CP模型。

在CAP理论中,由于分布式系统中多节点通信不可避免出现网络延迟、丢包等问题一定会造成网络分区,在造成网络分区的情况下,一般有两个选择:CP或者AP。

  1. 选择AP模型实现分布式锁时,client在通过集群主节点加锁成功之后,则立刻会获取锁成功的反馈。在主节点还没来得及把数据同步给从节点时就发生宕机的话,系统会在从节点中选出一个节点作为新的主节点,新的主节点没有宕机的主节点的锁数据,导致其他client可以在新的主节点上拿到相同的锁。这就会导致多个进程来操作相同的临界资源数据,从而引发数据不一致性等问题。

  2. 选择CP模型实现分布式锁,只有在主节点把数据同步给大于1/2的从节点之后才被视为加锁成功。此时,主节点突然宕机,系统会在从节点中选取出数据比较新的一个从节点作为新的主节点,从而避免锁数据丢失的问题。

对于严格的分布式锁来说,CP模型会更为理想。虽然,基于Redlock实现的分布式锁也可以看做是CP模型,但由于需要部署、维护比较复杂,在生产环境很少被使用。所以在对一致性要求很高的业务场景下(电商、银行支付),一般选择使用Zookeeper或者etcd。如果可以容忍少量数据丢失,出于维护成本等因素考虑,AP模型的分布式锁可优先选择Redis。

参考文档:

  1. https://github.com/redisson/redisson/wiki

  2. https://redis.io/topics/distlock

  3. https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

  4. http://antirez.com/news/101

  5. http://zhangtielei.com/posts/blog-redlock-reasoning.html

  6. http://zhangtielei.com/posts/blog-redlock-reasoning-part2.html

  7. https://github.com/etcd-io/etcd/


浏览 55
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报