1、为什么要使用分布式锁
我们在开发应用的时候,如果需要对某一个共享变量进行多线程同步访问的时候,可以使用我们学到的 Java 多线程的 18 般武艺进行处理,并且可以完美的运行,毫无 Bug!但是这是单机的应用,也就是所有的请求都会分配到当前服务器的 JVM 内部,然后映射为操作系统的线程进行处理!而这个共享变量只是在这个 JVM 内部的一块内存空间!
后来业务发展,需要做集群,一个应用需要部署到几台机器上然后做负载均衡:
如上图所示,变量 A 存在 JVM1、JVM2、JVM3 三个 JVM 内存中(这个变量 A 主要体现是在一个类中的一个成员变量,是一个有状态的对象,例如:UserController 控制器中的一个整形类型的成员变量),如果不加任何控制的话,变量 A 同时都会在 JVM 分配一块内存,三个请求发过来同时对这个变量操作,操作的结果不对。即使不是同时发过来,三个请求分别操作三个不同 JVM 内存区域的数据,变量 A 之间不存在共享,也不具有可见性,处理的结果也是不对的!
集群环境中存在各个服务器之间数据不共享、不可见的问题
- 单机环境:为了保证一个方法或属性在高并发情况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用 Java 并发处理相关的 API(如 ReentrantLock 或 Synchronized)进行互斥控制。在单机环境中,Java 中提供了很多并发处理相关的 API。
- 集群环境:随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的 Java API 并不能提供分布式锁的能力。为了解决这个问题就需要一种跨 JVM 的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题!
2、分布式锁应该具备哪些条件?
- 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;
- 高可用的获取锁与释放锁;
- 高性能的获取锁与释放锁;
- 具备可重入特性;
- 具备锁失效机制,防止死锁;
- 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。
3、分布式锁的三种实现方式
在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。
- 基于缓存(Redis 等)实现分布式锁;
- 基于数据库实现分布式锁;
- 基于 Zookeeper 实现分布式锁。
4、基于 Redis 的分布式锁
4.1、利用 SETNX 和 SETEX
- SETNX(SET If Not Exists):当且仅当 Key 不存在时,则可以设置,否则不做任何动作;
- SETEX:可以设置超时时间。
原理
通过 SETNX 设置 Key-Value 来获得锁,随即进入死循环,每次循环判断,如果存在 Key 则继续循环,如果不存在 Key,则跳出循环,当前任务执行完成后,删除 Key 以释放锁。
这种方式可能会导致死锁,为了避免这种情况,需要设置超时时间。
4.2、实现步骤
4.2.1、创建一个 Maven 工程,并在 pom.xml 中加入下述依赖:
点击查看代码
4.2.2、添加配置文件 application.yml
点击查看代码
4.2.3、创建一个全局锁类 Lock.java
点击查看代码
4.2.4、创建分布式锁类 DistributedLockHandler.java
点击查看代码
4.2.5、创建 HelloController 来测试分布式锁
点击查看代码
4.2.6、测试
启动项目,并在浏览器输入 http://localhost:8080/index,连续刷新界面,控制台在打印了一次“执行方法”,5 秒之后再次打印。
4.3、SETNX 和 SETEX 的缺点
- 高并发的情况下,如果两个线程同时进入循环,可能导致加锁失败;
- SETNX 是一个耗时操作,因为它需要判断 Key 是否存在,因为会存在性能问题。
4.4、红锁(RedLock)
4.4.1、pom.xml 添加依赖
点击查看代码
4.4.2、增加几个类
点击查看代码
4.4.3、修改 HelloController
点击查看代码
4.4.4、测试
启动项目,并在浏览器输入 http://localhost:8080/index,连续刷新界面,控制台在打印了一次“执行方法”,5 秒之后再次打印。
5、基于数据库的分布式锁
5.1、基于数据库表
它的基本原理和 Redis 的 SETNX 类似,其实就是创建一个分布式锁表,加锁后,我们就在表增加一条记录,释放锁即把该数据删掉,具体实现,我这里就不再一一举出。
缺点
- 没有失效时间,容易导致死锁;
- 依赖数据库的可用性,一旦数据库挂掉,锁就马上不可用;
- 这把锁只能是非阻塞的,因为数据的 insert 操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作;
- 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据库中数据已经存在了。
5.2、乐观锁
乐观锁一般通过 version 来实现,也就是在数据库表创建一个 version 字段,每次更新成功,则 version + 1,读取数据时,我们将 version 字段一并读出,每次更新时将会对版本号进行比较,如果一致则执行此操作,否则更新失败!
5.3、悲观锁
5.3.1、创建一个表
点击查看代码
5.3.2、通过数据库的排他锁实现分布式
基于 Mysql 的 InnoDB 引擎的设计:
点击查看代码
5.3.3、释放锁
我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁:
点击查看代码
6、基于 Zookeeper 的分布式锁
6.1、Zookeeper 的简介
ZooKeeper 是一个分布式的,开放源码的分布式应用程序协调服务,是 Google Chubby 的一个开源实现,是 Hadoop 和 Hbase 的重要组件。它是一个为分布式应用提供一致性服务的软件,提供的功能包括:配置维护、域名服务、分布式同步、组服务等。
6.2、zk 分布式锁实现的原理
- 建立一个节点,假如名为 lock 。节点类型为持久节点(Persistent);
- 每当进程需要访问共享资源时,会调用分布式锁的 lock() 或 tryLock() 方法获得锁,这个时候会在第一步创建的 lock 节点下建立相应的顺序子节点,节点类型为临时顺序节点(EPHEMERAL_SEQUENTIAL),通过组成特定的名字 name+lock+顺序号;
- 在建立子节点后,对 lock 下面的所有以 name 开头的子节点进行排序,判断刚刚建立的子节点顺序号是否是最小的节点,假如是最小节点,则获得该锁对资源进行访问;
- 假如不是该节点,就获得该节点的上一顺序节点,并监测该节点是否存在注册监听事件。同时在这里阻塞。等待监听事件的发生,获得锁控制权。
- 当调用完共享资源后,调用 unlock() 方法,关闭 ZooKeeper,进而可以引发监听事件,释放该锁。
6.3、代码实现
6.3.1、创建 DistributedLock 类
点击查看代码
6.3.2、修改 HelloController
点击查看代码
6.3.3、测试
启动项目,并在浏览器输入 http://localhost:8080/index,连续刷新界面,控制台在打印了一次“执行方法”,5 秒之后再次打印。
7、总结
- 通过数据库实现分布式锁是最不可靠的一种方式,对数据库依赖较大,性能较低,不利于处理高并发的场景。
- 通过 Redis 的 Redlock 和 ZooKeeper 来加锁,性能有了比较大的提升。
- 针对 Redlock,曾经有位大神对其实现的分布式锁提出了质疑,但是 Redis 官方却不认可其说法,所谓公说公有理婆说婆有理,对于分布式锁的解决方案,没有最好,只有最适合的,根据不同的项目采取不同方案才是最合理的。
Redis、Mysql 和 zk 实现方案的对比
- 从理解的难易程度角度(从低到高):数据库 > 缓存 > Zookeeper;
- 从实现的复杂性角度(从低到高):Zookeeper >= 缓存 > 数据库;
- 从性能角度(从高到低):缓存 > Zookeeper >= 数据库;
- 从可靠性角度(从高到低):Zookeeper > 缓存 > 数据库。