目录
前言
当我们提到锁,想必大家可能最先想到的是Java JUC中的synchronized
关键字或者可重入锁ReentrantLock
。它能够保证我们的代码在同一个时刻只有一个线程执行,保证数据的一致性和完整性。但是它仅限于单体项目,也就是说它们只能保证单个JVM应用内线程的顺序执行。
如果你部署了多个节点,也就是分布式场景下如何保证不同节点在同一时刻只有一个线程执行呢?场景的业务场景比如秒杀、抢优惠券等,这就引入了我们的分布式锁。这里我们就讲解一下常见的分布式锁实现的方式:
-
Redis分布式锁(最常用,并发量非常大的情况下)
-
Zookeeper分布式锁(并发量不是非常大的情况下)
-
MySQL分布式锁(并发量比较小的情况下)
一、Redis实现分布式锁
在这里我们就不多言,前提都是非常清楚分布式锁是什么玩意儿了?那么直接开始介绍比较完美的方案。
1.1 Lua脚本+Redis
1.1.1 技术原理图
1.1.2 原理刨析
基于Lua+Redis实现的原理,也就是通过Lua脚本实现对 {SETNX} 命令操作,Lua是为了保证原子性, {SETNX} 命令可以保证数据的唯一性。
1.1.3 代码演示
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
// 脚本解锁 String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end"; redisTemplate.execute( new DefaultRedisScript<Long>(script, Long.class), Arrays.asList("lock"), uuid); // uuid:我们一般采用 前缀 + 线程ID
注意:这里可能有问题,Redis的 {SETNX} 命令是什么鬼?Lua脚本是什么鬼?什么原理?(
别问我,自己想
)
1.2 Redisson
说到自己不想写代码,那我们就可以很好使用到第三方的jar包,非常不错,别人写的我来用。
-
引入依赖:
<!-- redisson --> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.13.6</version> </dependency>
-
配置Redisson客户端:
@Configuration public class RedissonConfig { @Bean public RedissonClient redissonClient() { // 配置 Config config = new Config(); config.useSingleServer() .setAddress("redis://192.168.13.142:6379") .setPassword("root"); // 创建RedissonClient对象 return Redisson.create(config); } }
-
使用Redisson的分布式锁:
@Resource private RedissonClient redissonClient; public void test() { // 创建锁对象 RLock redisLock = redissonClient.getLock("lock:order:" + userId); // 尝试获取锁 boolean isLock = redisLock.tryLock(); if (!isLock) { // 获取锁失败,直接返回失败或者重试 log.error("不允许重复下单!"); return false; } try { // 5.1.查询订单 int count = query().....count(); // ..省略业务代码.. // } finally { // 释放锁 redisLock.unlock(); } }
二、Zookeeper实现分布式锁
2.1 技术原理
利用Zookeeper的临时顺序节点和监听机制两大特性,可以帮助我们实现分布式锁。
-
先得有一个持久节点
/locks
, 路径服务于某个使用场景,如果有多个使用场景建议路径不同。 -
请求进来时首先在
/locks
创建临时有序节点,所有会看到在/locks
下面有seq-000000000, seq-00000001 等等节点。 -
然后判断当前创建得节点是不是
/locks
路径下面最小的节点,如果是,获取锁,不是,阻塞线程,同时设置监听器,监听前一个节点。 -
获取到锁以后,开始处理业务逻辑,最后delete当前节点,表示释放锁。
-
后一个节点就会收到通知,唤起线程,重复上面的判断。
大家有没有想过为什么要设置对前一个节点的监听?
主要为了避免羊群效应。所谓羊群效应就是一个节点挂掉,所有节点都去监听,然后做出反应,这样会给服务器带来巨大压力,所以有了临时顺序节点,当一个节点挂掉,只有它后面的那一个节点才做出反应。
2.2 原生Zookeeper客户端实现分布式锁
通过原生zookeeper api方式的实现,可以加强我们对zk实现分布式锁原理的理解。
2.2.1 锁源码
public class DistributedLock { private String connectString = "10.100.1.176:2281"; private int sessionTimeout = 2000; private ZooKeeper zk; private String rootNode = "lock"; private String subNode = "seq-"; private String waitPath; // 当前client创建的子节点 private String currentNode; // 创建客户端线程需要阻塞,使用Latch实现阻塞 private CountDownLatch countDownLatch = new CountDownLatch(1); // 在获取锁失败后,需要进入阻塞wait private CountDownLatch waitDownLatch = new CountDownLatch(1); public DistributedLock() throws IOException, InterruptedException, KeeperException { zk = new ZooKeeper(connectString, sessionTimeout, new Watcher() { @Override public void process(WatchedEvent event) { // 如果连接建立时,唤醒 wait 在该 latch 上的线程 if(event.getState() == Event.KeeperState.SyncConnected) { countDownLatch.countDown(); } // 发生了 waitPath 的删除事件 if(event.getType() == Event.EventType.NodeDeleted && event.getPath().equals(waitPath)) { waitDownLatch.countDown(); } } }); // 等待连接建立,因为连接建立时异步过程 countDownLatch.await(); // 获取根节点 Stat stat = zk.exists("/" + rootNode, false); // 如果根节点不存在,则创建根节点 if(stat == null) { System.out.println("创建根节点"); zk.create("/" + rootNode, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); } } public void zkLock() { try { // 在根节点创建临时顺序节点 currentNode = zk.create("/" + rootNode + "/" + subNode, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL); // 获取子节点 List<String> childrenNodes = zk.getChildren("/" + rootNode, false); // 如果只有一个子节点,说明是当前节点,直接获得锁 if(childrenNodes.size() == 1) { return; } else { //对根节点下的所有临时顺序节点进行从小到大排序 Collections.sort(childrenNodes); //当前节点名称 String thisNode = currentNode.substring(("/" + rootNode + "/").length()); //获取当前节点的位置 int index = childrenNodes.indexOf(thisNode); if (index == -1) { System.out.println("数据异常"); } else if (index == 0) { // index == 0, 说明 thisNode 在列表中最小, 当前client 获得锁 return; } else { // 获得排名比 currentNode 前 1 位的节点 this.waitPath = "/" + rootNode + "/" + childrenNodes.get(index - 1); // 在 waitPath节点上注册监听器, 当 waitPath 被删除时,zookeeper 会回调监听器的 process 方法 zk.getData(waitPath, true, new Stat()); //进入等待锁状态 waitDownLatch.await(); } } } catch (KeeperException e) { e.printStackTrace(); } catch (InterruptedException e) { e.printStackTrace(); } } public void zkUnlock() { try { zk.delete(this.currentNode, -1); } catch (InterruptedException e) { e.printStackTrace(); } catch (KeeperException e) { e.printStackTrace(); } } }
2.2.2 测试Test
ublic class DistributedLockTest { public static void main(String[] args) throws IOException, InterruptedException, KeeperException { DistributedLock lock1 = new DistributedLock(); DistributedLock lock2 = new DistributedLock(); new Thread(() -> { // 获取锁对象 try { lock1.zkLock(); System.out.println("线程 1 获取锁"); Thread.sleep(5 * 1000); System.out.println("线程 1 释放锁"); } catch (Exception e) { e.printStackTrace(); } finally { lock1.zkUnlock(); } }).start(); new Thread(() -> { // 获取锁对象 try { lock2.zkLock(); System.out.println("线程 2 获取锁"); Thread.sleep(5 * 1000); System.out.println("线程 2 释放锁"); } catch (Exception e) { e.printStackTrace(); } finally { lock2.zkUnlock(); } }).start(); } }
测试结果
线程 2 获取锁 线程 2 释放锁 线程 1 获取锁 线程 1 释放锁
2.3 Curator框架实现分布式锁
在实际的开发钟,我们会直接使用成熟的框架Curator客户端,它里面封装了分布式锁的实现,避免我们去重复造轮子。
-
pom.xml添加如下依赖
<dependency> <groupId>org.apache.curator</groupId> <artifactId>curator-recipes</artifactId> <version>5.2.1</version> </dependency>
-
通过
InterProcessLock
实现分布式锁
public class CuratorLockTest { private String connectString = "10.100.1.14:2181"; private String rootNode = "/locks"; public static void main(String[] args) { new CuratorLockTest().testLock(); } public void testLock() { // 分布式锁1 InterProcessLock lock1 = new InterProcessMutex(getCuratorFramework(), rootNode); // 分布式锁2 InterProcessLock lock2 = new InterProcessMutex(getCuratorFramework(), rootNode); // 第一个线程 new Thread(() -> { // 获取锁对象 try { lock1.acquire(); System.out.println("线程 1 获取锁"); // 测试锁重入 lock1.acquire(); System.out.println("线程 1 再次获取锁"); Thread.sleep(5 * 1000); lock1.release(); System.out.println("线程 1 释放锁"); lock1.release(); System.out.println("线程 1 再次释放锁"); } catch (Exception e) { e.printStackTrace(); } }).start(); // 第二个线程 new Thread(() -> { // 获取锁对象 try { lock2.acquire(); System.out.println("线程 2 获取锁"); // 测试锁重入 lock2.acquire(); System.out.println("线程 2 再次获取锁"); Thread.sleep(5 * 1000); lock2.release(); System.out.println("线程 2 释放锁"); lock2.release(); System.out.println("线程 2 再次释放锁"); } catch (Exception e) { e.printStackTrace(); } }).start(); } public CuratorFramework getCuratorFramework() { CuratorFramework client = CuratorFrameworkFactory.builder() .connectString(connectString).connectionTimeoutMs(2000) .sessionTimeoutMs(2000) .retryPolicy(new ExponentialBackoffRetry(3000, 3)).build(); // 连接 client.start(); System.out.println("zookeeper 初始化完成..."); return client; } }
测试结果
线程 1 释放锁 线程 1 再次释放锁 线程 2 获取锁 线程 2 再次获取锁 线程 2 释放锁 线程 2 再次释放锁
有兴趣的看下源码,它是通过wait、notify来实现阻塞。
代码: https://github.com/alvinlkk/awesome-java-full-demo/tree/master/zookeeper-demo/zookeeper-lock
2.4 总结
ZooKeeper
分布式锁(如InterProcessMutex
),能有效的解决分布式锁问题,但是性能并不高。
因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。大家知道,ZK中创建和删除节点只能通过Leader服务器来执行,然后Leader
服务器还需要将数据同不到所有的Follower
机器上,这样频繁的网络通信,性能的短板是非常突出的。
在高性能,高并发的场景下,不建议使用ZooKeeper
的分布式锁,可以使用Redis
的分布式锁。而由于ZooKeeper
的高可用特性,所以在并发量不是太高的场景,推荐使用ZooKeeper
的分布式锁。
三、MySQL如何实现分布式锁?
3.1 设计目标
-
互斥。不同机器上许多进程/线程中只有一个可以访问特定资源,其他进程/线程应该等到锁被释放才可以用。
-
TTL。从CAP理论我们知道,网络总是不可靠的,任何一台服务器都有可能宕机一段时间。所以我们在设计分布式锁服务的时候,需要考虑到可能有一个持有锁的客户端宕机,无法释放锁,从而阻塞所有等待获取同一个锁的客户端。所以我们需要一种机制,可以在这种情况下自动释放锁来解锁其他客户端。
-
相关API
-
lock()
:获取锁 -
unlock()
:释放锁 -
tryLock()
: 可选,更高级的API,例如:客户端可以指定获取锁的最大等待时间。如果不能在窗口内获得锁,则错误返回而不是继续等待。
-
高性能
-
低延迟:在正常情况下,锁定和解锁应该非常快。比如实际的业务逻辑处理只需要1ms,而单纯的获取和释放锁,处理一个请求又需要100ms,那么最大
QPS
只能达到10,这对于现在的很多服务来说已经很低了。在这种情况下,服务器可以处理的最大QPS
受到锁性能的限制。 -
通知机制:分布式锁理想情况下应该提供通知机制。如果服务器进程A由于被另一个服务器进程B持有而无法获得锁,那么A不应该一直等待并占用CPU。相反,A 应该空闲以避免浪费 CPU资源 。然后当锁可用时,锁服务通知A,A将获得CPU资源并恢复运行。
-
避免惊群效应。假设有 100 个进程想要获取同一个锁,当锁可用时,理想情况下应该只通知队列中的“下一个”进程,而不是突然调用所有 100 个进程来竞争锁。
-
公平。先到先得。等待时间最长的人应该下一个获得锁。如果是这样,则该锁被认为是公平锁。否则就是非公平锁。这两种锁在现实中都有实际使用。
-
重入锁。 想象一下,一个节点或服务器进程获取了一个锁,开始处理业务逻辑,然后遇到一个代码片段要求再次获取同一个锁,在这种情况下,节点或进程不应死锁,相反,它应该能够再次获取相同的锁,因为它已经持有锁。
3.2 唯一键约束
我们可以使用MySQL的唯一性约束来实现分布式锁,整体的思路如下:
-
客户端 A 正在尝试获取锁。此时没有其他客户端持有锁,所以客户端A成功获取到了锁,并向MySQL表中插入一行数据。
-
现在客户端 B 想要获取相同的锁,先查询DB,发现客户端A插入的行已经存在。在这种情况下,客户端B无法获取到锁。然后客户端 B 将等待一段时间后重试。客户端 B 会在指定的 TTL 窗口内不断重试几次,最终要么在客户端 A 释放锁后成功获取锁,要么因为 TTL 而失败。
-
一旦客户端 A 完成其任务,它将通过简单地删除 DB 表中的行来释放锁
lock
。现在其他客户端能够获取锁。
现在我们来简单实现下,创建一个lock
表,其中lock_key
字段有唯一性约束。
CREATE TABLE `lock` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `lock_key` varchar(256) NOT NULL, `holder` varchar(256) NOT NULL, `creation_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uniq_lock_key` (`lock_key`) );
-
lock_key
是锁的唯一名称。我们可以使用project_name
+resource_id
作为锁的名称,表明要抢的资源是什么,具备唯一性。 -
holder
是当前持有锁的客户端ID。我们可以使用service_name
+IP 地址
+thread_id
来标识分布式环境中的客户端。
获取锁:
INSERT INTO `lock`(`lock_key`, `holder`) VALUES ('project1_uid1', 'server1_ip1_tid1');
释放锁:
DELETE FROM `lock` WHERE `lock_key` = 'project1_uid1';
上面的方案已经基本满足通过MySQL实现分布式锁的基本要求。现在让我们考虑一些特殊情况,看看它是否对分布式系统中的常见故障具有鲁棒性。
如果客户端 A 获取了锁,向 DB 中插入了一行,但后来客户端 A 崩溃了,或者网络分区和客户端 A 无法访问 DB 怎么办?在这种情况下,该行将保留在数据库中,不会被删除。换句话说,对于其他客户端来说,就好像客户端 A 仍然持有锁(即使 A 已经崩溃了!)。其他客户端将无法获取锁,并返回错误。
一种常用的方法是为每个锁分配一个 TTL。这个想法很简单:如果客户端 A 崩溃并且无法释放锁,那么其他人应该执行删除 DB 中的行从而释放锁的工作。假设通常客户端 A 需要 3 分钟才能完成任务。我们可以将 TTL 设置为 5 分钟。然后我们需要构建另一个服务来不断扫描lock
表,并删除超过 5 分钟前创建的任何行。但是,还有其他问题:
-
如果 A 没有崩溃,它只需要比平时多一点时间来完成任务怎么办?
-
如果我们为扫描
lock
表而构建的这项新服务本身崩溃了怎么办?
第一个问题用MySQL很难完全解决。我们可以考虑A在获取到分布式锁后,新起个线程去检查锁是否快要过期了,比如发现TTL还剩下1/3时间,但是A还没有结束,这时候去扩大TTL时间,这就是锁的续签机制。但是在现实中,对于大部分的业务案例,我们总是可以设置一个足够大的TTL,使得这种情况很少发生,以至于对公司业务的影响几乎察觉不到。
现在让我们看看第2个问题怎么解决?
3.3 使用时间戳+唯一键约束
我们可以在lock
表中添加一列来存储上次获取锁的时间戳last_lock_time
。
CREATE TABLE `lock` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `lock_key` varchar(128) NOT NULL, `holder` varchar(128) NOT NULL DEFAULT '', `version` int(11) not null, `creation_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `last_lock_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uniq_lock_key` (`lock_key`) );
现在我们用${timeout}
表示分布式锁的TTL。
获取锁:
当客户端 B 试图获取锁时,我们可以添加`last_lock_time
< {timeout}作为
where`条件的一部分。
UPDATE `lock` SET `holder` = 'server1_ip1_tid1', `last_lock_time` = ${now} WHERE `lock_key` = 'project1_uid1' and `last_lock_time` < ${now} - ${timeout};
在这种情况下,只有当`last_lock_time
< {timeout}客户端 B 可以获取锁、将
holder更改为其 ID 并将其重置
last_lock_time为当前时间戳时。假设后面客户端 B 挂了,不能释放锁,最坏的情况是等待
${timeout}`TTL时间以后,其他客户端就能拿到锁。
释放锁:
我们可以把last_lock_time
更新为一个很小时间戳,例如‘1970–01–01 00:00:01
’。
UPDATE `lock` SET `holder` = '', `last_lock_time` = ${min_timestamp} WHERE `lock_key` = 'project1_uid1' and `holder` = 'server1_ip1_tid1';
在WHERE语句中,我们添加了`holder
= ‘server1_ip1_tid1’`,这是为了避免其他客户端不小心释放了当前客户端持有的锁。
成功释放锁后,holder
将其设置为空,并将last_lock_time
设置为最小时间戳,以便其他客户端可以轻松获取锁。
现在我们解决了TTL问题,但是在上面的实现中,如果持有锁,其他客户端将需要一直循环重试,等待锁释放后再获取锁。如果分布式锁服务可以通知等待的客户端锁可用,那就更好了,我们思考下在MySQL中该如何实现。
3.4 使用FOR UPDATE
实现锁释放通知
MySQL具有行级锁功能,在RC隔离级别下,当我们使用FOR UPDATE
时,MySQL会为所有符合过滤条件的行加行级锁。当一个客户端会话获得锁时,所有其他客户端都将等待锁。此外,等待客户端唤醒并获取锁的顺序与它们首次尝试获取锁时的顺序相同。只要持有锁的客户端在 SQL 事务内执行逻辑,FOR UPDATE
就可以执行多次。换句话说,锁是重入锁。
另外,针对FOR UPDATE
,MySQL还支持两种模式:NOWAIT
和 SKIP LOCKED
。
-
NOWAIT
:不等待锁的释放。如果锁被其他客户端持有,无法获取,则立即返回锁冲突消息。 -
SKIP LOCKED
:读取数据时,跳过行级锁被其他客户端持有的行。
通过这两个选项,我们可以实现tryLock
行为,即客户端尝试获取锁,获取不到锁则立即返回,而不是等待。
我们可以简化我们的lock表以仅包含两个字段:
CREATE TABLE `lock` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `lock_key` varchar(128) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uniq_lock_key` (`lock_key`) );
获取锁:
BEGIN; SELECT * FROM `demo`.`lock` WHERE `lock_key` = 'project1_uid1' FOR UPDATE;
这里关于启动新事务BEGIN
做一个说明,只有在第一次获取锁时才需要它。后续重入时,不要执行BEGIN
,否则会启动一个新的事务,现有的事务结束,实际上是在事务结束时释放锁。
非阻塞尝试锁tryLock():
BEGIN; SELECT * FROM `demo`.`lock` WHERE `lock_key` = 'project1_uid1' FOR UPDATE NOWAIT;
释放锁:
COMMIT;
提交事务就可以释放锁。
总结
我们现在回头来看看基于MySQL实现分布式锁,是否满足我们一开始定下的设计目标:
-
互斥,最基本的功能,肯定是可以的。
-
TTL 机制,MySQL 本地管理客户端会话。如果客户端由于机器故障或网络故障而断开连接,MySQL 将自动释放行级锁。
-
支持所有 3 个 API:获取/尝试/释放锁。
-
高性能:释放锁时,MySQL只会通知队列中等待的下一个客户端,而不是一次性通知所有客户端,避免雷群问题。
-
公平。MySQL 行锁本身支持。
-
重入。MySQL 行锁本身也支持。记住第一次获取锁就开始事务,以后再入时不要再开始新的事务。
看来基本上是没什么问题的,但是还有一点,我们需要提前向lock表中插入资源锁的数据,然后获取/尝试/释放锁的 API 才能按预期工作。
参考:
https://medium.com/@bb8s/design-distributed-lock-with-mysql-9bc28ac59629