分布式锁的实现原理学习笔记

前言

在分布式场景下,单机的锁已经没有办法满足控制不同节点对同一资源的并发访问。

常见的分布式锁有三种:

  • 基于Mysql
  • 基于缓存(redisMemcached)
  • 基于ZooKeeper

基于Mysql实现分布式锁

核心思想是:在数据库中创建一个表,表中包含资源名等字段,并在资源名字段上创建唯一索引,想要执行某个方法,就使用这个资源名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。

创建表:

DROP TABLE IF EXISTS `resource_lock`;
CREATE TABLE `resource_lock` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `resource_name` varchar(64) NOT NULL COMMENT '资源名字',
  `desc` varchar(255) NOT NULL COMMENT '备注信息',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `unq_resource_name` (`resource_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

要想获取某个资源,则使用该资源向表中插入插入数据。因为对资源进行了唯一性约束,因此数据库会保证只有一个操作成功,成功的操作可以看做拿到了锁。

想要释放某个锁时,利用delete from删除对应的行数据即可。

但是这个简单版本的方法存在一些不足:

  • 无法实现阻塞的特性,获取不到锁时是直接返回,只能手动实现循环获取
  • 无法实现可重入的特性,因此需要再加上node_infocount用来表示节点信息以及获取的锁个数。如果是同一节点,则获取时锁的个数加1;释放时,如果锁个数大于1,则减1,如果等于1可以直接删除该行
  • 无法实现超时,如果获取锁的节点由于出现了宕机,将导致该锁永远无法释放。因此可以新增一列,设定超时时间,并在后台开启一个任务进行定期的回收
  • 需要数据库保证可用,否则直接影响分布式锁的可用性及性能。所以可以通过双机部署,数据同步,主备切换等。

因此,虽然易于理解,但是实现起来需要考虑很多方面,与基于缓存实现的分布式锁相比性能较低。

基于redis实现分布式锁

通常使用setnx key value来实现,当返回1时表明这个key不存在,获取锁成功,否则为抢锁失败。

当任务执行完毕后,使用del key删除这个key,表明已经释放成功。

看起来很简便,但是会出现:

问题一:

同样没有锁超时,可能导致某个锁永远都不会被释放掉。因此需要在setnx使用expires紧跟其后设定超时。但是这样带来第二个问题是,这两个操作并不是原子性的,可能在还没执行expires时节点已经挂掉,那么锁仍然不会释放。

问题二:

setnxexpire操作不具有原子性。redis 2.8后可以使用set key value ex 5 nx来支持nxex是同一原子操作。

问题三:

如果获取到锁的A执行太久导致锁超时释放,那么当B获取到锁后执行过程中,A执行完了,再执行del key的操作,那么可能会将B的锁释放掉。也就是说,释放掉了不属于自己的锁。

可以通过在执行set时,将自己节点的信息写入,例如set lock a_node ex 5 nx。当释放锁时,先get lock查看值是不是自己的节点,如果是才能释放。可是这样又带来第四个问题。

问题四:

判断是否是自己的锁和释放锁的操作不是原子性的了。

  1. 假设A获取锁成功,执行完毕后准备释放锁
  2. 首先执行get获取key的值,是自己的锁,但是某些原因阻塞了
  3. 锁超时了,B拿到了锁
  4. A从阻塞中恢复,执行del,又把B的锁释放了

因此释放锁的操作必须使用LUA脚本实现。

问题五:

如果A获取了锁,但是执行时间很长,锁提前释放了,那么A接下来对资源操作的安全性将得不到保证。这个可以通过一个守护进程,发现要超时了就延长一下超时时间来解决。

问题六:

如果redis宕机了,所有客户端都无法获得锁。因此通常会使用Master-slave机制,但是主从复制是异步的。因此可能会出现:

  1. Amaster获取了锁
  2. master挂了,key还没同步到slave
  3. slave升级为master
  4. B从新的master获取到了锁

于是问题六是针对多redis节点的,只能使用Redlock来解决。

Redlock的大概原理:

  1. 获取当前时间
  2. 依次向N个节点执行获取锁操作,获取锁操作存在超时时间。如果获取某个节点锁失败,应该立即尝试下一个节点
  3. 计算遍历完N个节点获取锁总共消耗的时间,只有从大于等于N/2+1个节点中成功获取到了锁,并且此时锁仍然没有到达超时时间,才认为锁获取成功
  4. 如果最终获取锁成功,锁的有效时间等于最初有效时间减去取锁成功所所耗的时间
  5. 如果获取锁失败了,应该向所有节点释放锁操作(同单节点相同,使用LUA脚本)

为了避免redis服务器短暂失效重新上线导致前一个节点的锁没有持久化却又被下一个节点所获得,还引入了延迟重启。即redis失效后,至少要等到key过期了,再重启。

为了防止实际上获得了锁,但是却没收到redisack而认为获得锁失败,在释放时应当对所有节点进行释放锁。

由于存在N个节点,只要能从大部分节点中获取锁,就可以视为获取锁成功,因此故障转移时发生的锁失效问题不存在了。但是程序执行时间过长导致锁过期的问题仍然没有解决。

基于ZooKeeper实现分布式锁

ZooKeeper实现分布式锁的步骤如下:

  1. 创建一个目录mylock
  2. 线程A想获取锁就在mylock目录下创建临时顺序节点;
  3. 获取mylock目录下所有的子节点,然后获取比自己编号小的节点,如果不存在,则说明当前线程顺序号最小,获得锁;
  4. 否则监听比自己编号小1的节点,等待其释放锁。

因此基于zk的分布式锁,不用考虑超时时间。因为一旦节点发生宕机(心跳检测),服务器会自动删除该节点释放锁。同时,使用ZooKeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。

References

基于 Redis 的分布式锁到底安全吗?

分布式锁简单入门以及三种实现方式介绍

再有人问你分布式锁,这篇文章扔给他

什么是分布式锁?

posted @ 2020-10-25 15:58  yuyinzi  阅读(345)  评论(0)    收藏  举报