深入理解分布式锁的原理和使用场景

分布式锁是一种常用的技术,在高并发场景下,为了避免多个进程或线程同时操作同一资源造成冲突,引入分布式锁机制。本文将介绍分布式锁的原理和使用场景,并通过 Redis、Zookeeper、Redisson 等中间件来实现分布式锁。

分布式锁的特性

  • 互斥:不同线程之间互斥,只有一个线程能持有锁。
  • 超时机制:代码耗时过长,网络原因等,导致锁一直被占用,造成死锁,所以引入超时机制,超过指定时间自动释放锁。
  • 完备的锁接口:阻塞的和非阻塞的接口都要有,lock 和 tryLock。
  • 可重入性:当前请求的节点 + 线程唯一标识,可以再次获取同一把锁
  • 公平性:锁唤醒时候,按照顺序唤醒,不公平的话,有可能出现饥饿现象。

分布式锁的原理与实现

分布式锁的目的是区别于 JVM 单机锁。

JVM 单机锁就是在同一个 JVM 中的锁。比如你使用了 synchronized 关键字,那么就是在这同一个 JVM 中,同一时刻只能有一个线程持有锁,其他线程只能等待。但是当你的后端服务式多个机器的集群部署方式,那么 JVM 单机锁就无法满足需求了。因为你这个 JVM 锁住了,我的线程打到了另一台机器上,那就相当于没锁住,所以我们需要分布式锁。分布式锁的目的就是在多个 JVM 层之前设置的锁,这样就可以在多个机器上实现同一把锁的目的。

分布式锁的原理就是在多个机器上设置同一把锁,这个锁通常通过某些中间件实现。当一个线程想要获取锁的时候,首先会去尝试获取锁,如果获取成功,那么就可以执行任务,如果获取失败,那么就只能等待,直到锁被释放。

MySQL

基于索引

基于索引的实现,是通过在数据库的某个字段上加了唯一的索引,那么只有一个线程能够对写入同一个数据,其他的线程由于索引的唯一性而无法写入,只能等待资源释放——这个唯一值被 DELETE 掉,那么可以重新写入来获取锁

我们可以先创建一个类似的表:

CREATE TABLE `database_lock` (
 `id` BIGINT NOT NULL AUTO_INCREMENT,
 `resource` int NOT NULL COMMENT '锁定的资源',
 `description` varchar(1024) NOT NULL DEFAULT "" COMMENT '描述',
 PRIMARY KEY (`id`),
 UNIQUE KEY `uiq_idx_resource` (`resource`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';

其中的 resource 就是锁的名字,locked_at 就是锁的创建时间。

这里我们给数据库加了一个唯一索引,目的是对资源进唯一性约束。这样在写入同一个数据时,只能有一个线程写入,其他线程只能失败。通过这样的方法就实现了一个分布式锁。

这种锁的实现比较简单,但也会面临锁无法过期,锁的可靠性依赖于 MySQL 数据库的可用性等等问题。

基于乐观锁

基于乐观锁的实现原理是多个线程可以同时对资源进行修改,但最终只能有一个修改成功,其他的回退。乐观锁的实现一般是基于版本号的机制,比如在更新数据时,先获取当前版本号,然后更新数据,再更新版本号。如果更新失败,说明数据已经被其他线程更新过了,那么就需要重试。

例如建立如下的数据库表:

CREATE TABLE `optimistic_lock` (
 `id` BIGINT NOT NULL AUTO_INCREMENT,
 `resource` int NOT NULL COMMENT '锁定的资源',
 `version` int NOT NULL COMMENT '版本信息',
 `created_at` datetime COMMENT '创建时间',
 `updated_at` datetime COMMENT '更新时间',
 `deleted_at` datetime COMMENT '删除时间',
 PRIMARY KEY (`id`),
 UNIQUE KEY `uiq_idx_resource` (`resource`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';

每个线程的执行逻辑如下:

  • 获取资源: SELECT resource, version FROM optimistic_lock WHERE id = 1
  • 执行业务逻辑
  • 更新资源:UPDATE optimistic_lock SET resource = resource -1, version = version + 1 WHERE id = 1 AND version = oldVersion

通过比对修改后的 version 和修改之前的 oldVersion,如果一致,说明数据没有被其他线程更新过,那么就更新成功,否则就需要重试。

这种锁的实现比较复杂,但也能保证数据的一致性。在检测数据冲突时并不依赖数据库本身的锁机制,不会影响请求的性能。但是需要对表的设计增加额外的字段,增加了数据库的冗余。并且高并发的情况下增加了重试的次数,会影响性能。

基于悲观锁

基于悲观锁的实现原理是多个线程只能一个一个地获取锁,直到获取锁的线程释放锁,其他线程才能获取锁。我们在基于 MySQL 的悲观锁的实现中,一般采用 MySQL 自带的锁机制,比如 SELECT ... FOR UPDATE。数据库会在查询的过程中加上排他锁,那么这样别的事务就无法对该资源进行修改。

基于悲观锁的实现过程如下:

  • 获取资源: SELECT * FROM optimistic_lock WHERE id = 1 FOR UPDATE
  • 执行业务逻辑
  • 释放资源:COMMIT

相当于我们基于 SELECT ... FOR UPDATE 获取了这行数据的锁,并且在同一事务下执行修改的业务逻辑,最终在 COMMIT 提交事务时释放锁。

这种锁的的实现也比较简单,主要是基于数据库的事务和行锁。但要注意行锁失效的情况。并且每次请求都会额外产生加锁的开销且未获取到锁的请求将会阻塞等待锁的获取,在高并发环境下,容易造成大量请求阻塞,影响系统可用性。

Zookeeper

基于 Zookeeper 的分布式锁,主要来自于 Zookeeper 的两个机制

  • 临时顺序节点机制

Zookeeper 的节点是一个类似于文件系统的目录结构,每个节点都可以设置临时顺序节点,也就是说,在创建节点时,可以指定一个顺序,然后 Zookeeper 会根据这个顺序来分配节点的唯一标识符。除此以外节点也可以被标记为持久节点,持久节点会一直存在直到主动删除。

  • watch 机制

Zookeeper 的 watch 机制允许用户在指定的节点上注册一个监听器,当节点发生变化时,Zookeeper 会通知监听器,并触发监听器的回调函数。

基于这两个机制,我们可以实现一个基于 Zookeeper 的分布式锁。

我们首先建立一个父节点,这个父节点是一个持久节点,用来表示共享资源。然后在父节点下创建临时顺序节点,这个临时顺序节点用来标识当前获得锁的线程。最终在父节点之下建立了一个类似于队列的结构。然后判断当前节点是不是最小的节点,如果是最小的节点,那么就获取锁,否则就监听前一个节点的删除事件,直到获得锁。每次节点使用完共享资源,就会删除该节点,进而释放锁,后面的节点通过 watch 监听前一个节点的删除事件,获得锁。

Zookeeper 实现分布式锁的好处就是可以实现顺序的公平锁。并且可以实现强一致性,所有的操作都可以被保证是原子性的。假如某个节点宕机了,那么会自动释放锁,防止了死锁,提高了系统的可用性。

但是坏处就是,节点的创建和销毁对性能开销比较大,在高并发的环境下可能有较大的性能问题。另外,Zookeeper 的 watch 机制也会增加系统的复杂度,需要考虑节点的删除和创建的时机,以及节点的连接状态等。

Redis

用 Redis 实现分布式锁,利用的是 SETNX+EXPIRE 命令。

SETNX 命令的作用是设置一个 key,当 key 不存在时,返回 1,如果 key 已经存在,返回 0。EXPIRE 命令的作用是设置一个 key 的过期时间,当 key 过期时,Redis 会自动删除该 key。

一般这两条命令写在一行来确保指令的原子性,如:

SETNX lock_key some_unique_value EXPIRE lock_key 10  # 设置过期时间为10秒

其中 lock_key 是锁的名字,some_unique_value 是唯一的值,10 是过期时间。

当两个线程同时执行这个命令时,只有一个线程会成功对 lock_key 的值进行修改,其他线程会失败,这样就达到了分布式锁的目的。

基于 Redis 实现分布式锁,由于是对值的修改,性能比较高。但是如果是在 Redis 集群环境下,由于 Redis 集群同步是异步的。如果在 Master 节点上设置锁,Slave 节点可能没有同步到最新的数据。此时 Master 节点崩溃了但是理论上锁不应当被释放,但由于 Master 的宕机导致了锁物理上被释放,所以其他客户端可能会加新的锁来对共享资源进行修改,这样就出现了问题。

解决这个问题的方法就是 RedLock 算法——也就是 Redisson 的实现原理。

Redisson

Redisson 的特性有:看门狗机制、集群支持、公平锁等等

Redisson 的公平锁的实现原理类似于 ReentrankLock 的公平锁机制,主要维护一个等待队列,通过控制锁的获取顺序来实现。

Redisson 的看门狗机制目的是检查锁的状态,自动管理分布式锁过期时间。其实现主要通过一个后台线程(俗称看门狗),每隔锁的 1/3 时间检查锁的状态,只要持有锁的线程仍在执行且没有主动释放锁,看门狗就会持续进行续期操作。如果没有线程持有锁,看门狗就会自动释放锁。

RedLock 算法的主要目的是为了解决 Master 节点宕机导致锁的释放问题。RedLock 算法的基本思路是,在多个 Redis 节点上同时加锁,只要大多数 Redis 节点都加锁成功,那么加锁成功;如果加锁失败,则释放所有锁并重试。

RedLock 算法的流程如下:

  1. 客户端获取当前时间戳。
  2. 客户端在每个 Redis 节点上尝试用相同的锁名和 UUID 获取锁,并设置一个较短的过期时间。获取成功则记录加锁节点,否则记录失败节点。并记录加锁的总用时。
  3. 如果成功加锁的节点大于等于 N/2+1(N 为 Redis 节点数),并且获取锁的总时间小于锁的过期时间,则认为加锁成功并执行业务逻辑;否则认为获取锁失败,释放所有锁

Redisson 通过 RedLock 算法,保证了集群环境中锁的可靠性。

分布式锁的使用场景

分布式锁的核心目的是保证共享资源的独占。其使用场景有如下:

  1. 多个应用实例需要同时修改同一份数据,需要保证数据的一致性。例如:秒杀抢购、优惠券领取等。
  2. 系统需要进行任务调度,任务之间需要互斥执行。例如:定时任务等。
  3. 避免重复处理数据。例如:调度任务在多台机器重复执行,缓存过期所有请求都去加载数据库。
  4. 保证数据的正确性。例如:秒杀的时候防止商品超卖,表单重复提交,接口幂等性。

参考文献

基于 MySQL 实现的分布式锁

【MySQL】优雅的使用 MySQL 实现分布式锁

Zookeeper 实现分布式锁(Zk 分布式锁)

阿里技术-分布式锁实现原理与最佳实践

本文用来记录学习技术过程,有问题欢迎在评论区交流呀~

#技术博客#
技术博客记录 文章被收录于专栏

记录自己学习撰写的技术博客,欢迎讨论技术问题!

全部评论

相关推荐

12.16:完成用户编辑接口的开发;用户编辑功能和创建功能有很多类似的地方,因此为了避免重复代码,我将参数校验抽取成了一个公共方法;且由于官网已经上过了编辑功能,基本逻辑和后台编辑用户是类似的,唯一的区别在于,web端需要回显个人信息,而后台则返回true或者false就可以。而又由于保存了用户的变更记录,因此从数据库获取的敏感信息,不能解密,需要加密保存到变更记录里面,因此web端就还需要将敏感信息单独解密,回显到个人信息;12.17:完成用户编辑接口提测,修改留资推送线索中的语言动态;原有的推送线索里包含了语言,但是是在代码中写死的常量-英语,由于现在多语言表已经创建,可以获取用户选择的语言,因此将语言通过获取当前支持的语言列表来动态获取;然后推送给ocrm12.18:专项代码review,优化已有的代码;在代码review的过程中,我的接口返回值,是基本数据类型boolean,而通用的都是返回Boolean,于是stone老师给我提了个问题,接口返回到底选择boolean还是Boolean?然后我去查资料我本以为是序列化的问题,但其实返回的时候底层已经转成了包装类Boolean,所以本质的区别其实是特殊值的问题,boolean是基本变量,只有两个值:true和false;而Boolean是包装类,有三个值:true、false和null,在某些特殊场景,用null可以进行一些特殊判断;因此接口返回布尔值最好选择包装类。12.19:修改seo设置出现的bug;在解决这个bug的时候,我发现项目中的多语言获取其实存在问题,现有的多语言配置涉及三张表,字典、模板配置表和多语言字段表,而在配置多语言时,仅仅修改了配置表,但是获取多语言字段时,只获取了字典表,因此就会出现一个问题:配置默认语言的时候,只修改了配置表,如果这个时候去获取多语言表,默认语言还是修改前的默认语言,导致了数据不一致。和孙老师讨论后,孙老师在配置默认语言时,同步修改多语言表,解决了这个问题。而seo的bug也类似,由于我是直接获取的多语言表,假如现在只有一种语言,我去新增语言,多语言字段仍然只有原先语言字段,导致回显seo时仍然只有一种语言,因此我需要去读取配置表,根据配置表的语言去获取多语言字段值。12.20:后台筛选整合优化;主要完成了字段整合的需求,例如学员邮箱分为注册邮箱和备用邮箱,现在整合到一起,并且支持多选。
投递思源智通等公司10个岗位
点赞 评论 收藏
分享
有担当的灰太狼又在摸鱼:1. ssm工作和项目中会用到的,早晚得学,面试也会问,但相对你提到那几个确实问的会相对少一点。 2. juc是java并发基础,和jvm有关系,但和ssm(也就是黑马的java web)关系不大,可以直接背,而且问的频率很高。 3. 个人面试过的几次,感觉java基础+juc+jvm+mysql频率更高一些。也可能是我简历没有写os和计网的缘故,学习顺序仅供参考
点赞 评论 收藏
分享
评论
4
1
分享
牛客网
牛客企业服务