写点什么

浅析“分布式锁”的实现方式丨 C++ 后端开发丨底层原理

发布于: 2021 年 04 月 15 日

线程锁、进程锁以及分布式锁相关视频讲解:详解线程锁、进程锁以及分布式锁

如何高效学习使用 redis 相关视频讲解:10年大厂程序员是如何高效学习使用redis

Linux 服务器开发高级架构学习视频:C/C++Linux服务器开发/Linux后端开发架构师

前言

我们在开发应用时,如果需要对一个共享变量进行多线程同步访问的时候,我们可以使用 Java 多线程的各个技能点来处理,保证完美运行无 BUG。但是这里的都只是单机应用,即在同一个 JVM 中;然后随着业务发展、微服务化,一个应用需要部署到多台服务器上然后做负载均衡,大概的架构图如下:

在上图可以看到,变量 A 在 JVM1、JVM2、JVM3 三个 JVM 内存中(这个变量 A 主要体现是在一个类中的一个成员变量,是一个有状态的对象),如果我们不加任何控制的话,变量 A 同进都会在 JVM 分配一块内存,三个请求发过来同时对这个变量进行操作,显然结果不是我们想要的。

如果我们业务中存在这样的场景的话,就需要找到一种方法来解决。

为了保证一个方法或属性在高并发的情况下同一时间只能被同一个线程执行,在传统单机部署的情况下,可以使用 Java 并发处理相关的 API(如 ReentrantLock 或 Synchronized)进行互斥控制。但是,随之业务发展的需要,原单机部署的系统演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同的机器上,这将原来的单机部署情况下的并发控制锁策略失效,单纯的 Java API 并不能提供分布式锁的能力。为了解决这个问题,就需要一种跨 JVM 的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题!

分布式锁应该具备哪些条件

  • 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;

  • 高可用、高性能的获取锁与释放锁;

  • 具备可重入特性;

  • 具备锁失效机制、防止死锁;

  • 具备非阻塞锁特性,即没有获取到锁直接返回获取锁失败;


分布式锁的实现方式

目前几乎所有大型网站及应用都是分布式部署,分布式场景中的数据一致性问题一直是一个比较重要的话题,分布式的 CAP 理论告诉我们任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。一般情况下,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致性,只要这个最终时间是在用户可以接受的范围内即可。在很多时候,为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一信方法在同一时间内只能被同一个线程执行。而分布式锁的具体实现方案有如下三种:

基于数据库实现;

基于缓存(Redis 等)实现;

基于 Zookeeper 实现;

以上尽管有三种方案,但是我们需要根据不同的业务进行选型。

基于数据库的实现方式

基于数据库的实现方式的思想核心为:

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


一、创建一个表

DROP TABLE IF EXISTS `method_lock`;CREATE TABLE `method_lock` (  `id`          INT(11) UNSIGNED NOT NULL AUTO_INCREMENT  COMMENT '主键',  `method_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 `uidx_method_name` (`method_name`) USING BTREE)  ENGINE = InnoDB  AUTO_INCREMENT = 3  DEFAULT CHARSET = utf8  COMMENT = '锁定中的方法';
复制代码

二、想要执行某个方法,就使用这个方法名向表中插入数据

INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的methodName');
复制代码

由于我们对 method_name 做了唯一性约束,如果有多个请求同时提交插入操作时,数据库能确保只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体中的内容。

三、执行完成后,删除对应的行数据释放锁

delete from method_lock where method_name ='methodName';
复制代码

这里只是基于数据库实现的一种方法(比较粗的一种)。但是对于分布式锁应该具备的条件来说,还有一些问题需要解决及优化

  • 因为是基于数据库实现的,数据库的可用性和性能将直接影响分布式锁的可用性及性能。所以,数据库需要双机部署、数据同步、主备切换;

  • 它不具备可重入的特性,因为同一个线程在释放锁之前,行数据一直存在,无法再次成功插入数据。所以,需要在表中新增一列,用于记录当前获取到锁的机器和线程信息,在再次获取锁的时候,先查询表中机器和线程信息是否和当前机器线程相同,若相同则直接获取锁。

  • 没有锁失效机制,因为有可能出现成功插入数据后,服务器宕机了,对应的数据没有被删除,当服务恢复后一直获取不到锁,所以,需要在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据;

  • 不具备阻塞锁特性,获取不到锁直接返回失败,所以需要优化获取逻辑,循环多次去获取;

  • 依赖数据库需要一定的资源开销,性能问题需要考虑;

文章福利 Linux 后端开发网络底层原理知识学习提升 点击 学习资料 获取,完善技术栈,内容知识点包括 Linux,Nginx,ZeroMQ,MySQL,Redis,线程池,MongoDB,ZK,Linux 内核,CDN,P2P,epoll,Docker,TCP/IP,协程,DPDK 等等。

基于缓存(Redis)的实现方式

使用 Redis 实现分布式锁的理由:

  1. Redis 具有很高的性能;

  2. Redis 的命令对此支持较好,实现起来很方便;

Redis 命令介绍:

SETNX

// 当且仅当key不存在时,set一个key为val的字符串,返回1;// 若key存在,则什么都不做,返回0。SETNX key val;
复制代码

expire

// 为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁。expire key timeout;
复制代码

delete

// 删除keydelete key;
复制代码

我们通过 Redis 实现分布式锁时,主要通过上面的这三个命令。

通过 Redis 实现分布式的核心思想为:

获取锁的时候,使用 setnx 加锁,并使用 expire 命令为锁添加一个超时时间,超过该时间自动释放锁,锁的 value 值为一个随机生成的 UUID,通过这个 value 值,在释放锁的时候进行判断。 获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。3.释放锁的时候,通过 UUID 判断是不是当前持有的锁,若时该锁,则执行 delete 进行锁释放。


具体实现代码如下:


private final JedisPool jedisPool;private final static String KEY_PREF = "lock:"; // 锁的前缀
public DistributedLock(JedisPool jedisPool) { this.jedisPool = jedisPool;}
/** * 加锁 * * @param lockName String 锁的名称(key) * @param acquireTimeout long 获取超时时间 * @param timeout long 锁的超时时间 * @return 锁标识 */public String lockWithTimeout(String lockName, long acquireTimeout, long timeout) { Jedis conn = null;
try { // 获取连接 conn = jedisPool.getResource(); // 随机生成一个value String identifier = UUID.randomUUID().toString(); // 锁名,即 key值 String lockKey = KEY_PREF + lockName; // 超时时间, 上锁后超过此时间则自动释放锁 int lockExpire = (int) (timeout / 1000);
// 获取锁的超时时间,超过这个时间则放弃获取锁 long end = System.currentTimeMillis() + acquireTimeout; while (System.currentTimeMillis() < end) { if (conn.setnx(lockKey, identifier) == 1) { conn.expire(lockKey, lockExpire); // 返回value值,用于释放锁时间确认 return identifier; }
// 返回-1代表key没有设置超时时间,为key设置一个超时时间 if (conn.ttl(lockKey) == -1) { conn.expire(lockKey, lockExpire); }
try { Thread.sleep(10); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } } } catch (JedisException e) { e.printStackTrace(); } finally { if (conn != null) { conn.close(); } } return null;}
/** * 释放锁 * * @param lockName String 锁key * @param identifier String 释放锁的标识 * @return boolean */public boolean releaseLock(String lockName, String identifier) { Jedis conn = null; String lockKey = KEY_PREF + lockName; boolean retFlag = false; try { conn = jedisPool.getResource(); while (true) { // 监视lock, 准备开始事务 conn.watch(lockKey); // 通过前面返回的value值判断是不是该锁,若时该锁,则删除释放锁 if (identifier.equals(conn.get(lockKey))) { Transaction transaction = conn.multi(); transaction.del(lockKey); List<Object> results = transaction.exec(); if (results == null) continue;
retFlag = true; }
conn.unwatch(); break; } } catch (Exception e) { e.printStackTrace(); } finally { if (conn != null) { conn.close(); } } return retFlag;}
复制代码

基于 Zookeeper 实现分布式锁

基于 Zookeeper 临时有序节点同样可以实现分布式锁。Zookeeper 分布式锁应用了临时顺序节点(在创建节点时,Zookeeper 根据创建的时间顺序给该节点名称进行编号)。

具体实现步骤:

获取锁

首先,在 Zookeeper 当中创建一个持久节点 ParentLock。当第一个客户端想要获得锁时,需要在 ParentLock 这个节点下面创建一个临时顺序节点 Lock1。

之后,Client1 查找 ParentLock 下面所有的临时顺序节点并排序,判断自己所创建的节点 Lock1 是不是顺序最靠前的一个。如果是第一个节点,则成功获得锁。

这时候,如果再有一个客户端 Client2 前来获取锁,则在 ParentLock 下载再创建一个临时顺序节点 Lock2。Client2 查找 ParentLock 下面所有的临时顺序节点并排序,判断自己所创建的节点 Lock2 是不是顺序最靠前的一个,结果发现节点 Lock2 并不是最小的。

于是,Client2 向排序仅比它靠前的节点 Lock1 注册 Watcher,用于监听 Lock1 节点是否存在。这意味着 Client2 抢锁失败,进入了等待状态。

这时候,如果又有一个客户端 Client3 前来获取锁,则在 ParentLock 下载再创建一个临时顺序节点 Lock3。

Client3 查找 ParentLock 下面所有的临时顺序节点并排序,判断自己所创建的节点 Lock3 是不是顺序最靠前的一个,结果同样发现节点 Lock3 并不是最小的。

于是,Client3 向排序仅比它靠前的节点 Lock2 注册 Watcher,用于监听 Lock2 节点是否存在。这意味着 Client3 同样抢锁失败,进入了等待状态。

这样一来,Client1 得到了锁,Client2 监听了 Lock1,Client3 监听了 Lock2。这恰恰形成了一个等待队列,很像是 Java 当中 ReentrantLock 所依赖的 AQS 。

释放锁

释放锁分为两种情况:

1.任务完成,客户端显示释放

当任务完成时,Client1 会显示调用删除节点 Lock1 的指令。

2.任务执行过程中,客户端崩溃

获得锁的 Client1 在任务执行过程中,如果 Duang 的一声崩溃,则会断开与 Zookeeper 服务端的链接。根据临时节点的特性,相关联的节点 Lock1 会随之自动删除。

由于 Client2 一直监听着 Lock1 的存在状态,当 Lock1 节点被删除,Client2 会立刻收到通知。这时候 Client2 会再次查询 ParentLock 下面的所有节点,确认自己创建的节点 Lock2 是不是目前最小的节点。如果是最小,则 Client2 顺理成章获得了锁。

同理,如果 Client2 也因为任务完成或者节点崩溃而删除了节点 Lock2,那么 Client3 就会接到通知。

最终,Client3 成功得到了锁。

基于 Zookeeper 实现分布式锁优缺点:

优点具备高可用、可重入、阻塞锁特性、可解决失效死锁问题。缺点因为需要频繁的创建和删除节点,性能上不如 Redis 方式。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK 中创建和删除节点只能通过 Leader 服务器来执行,然后将数据同不到所有的 Follower 机器上。

PS: 可以直接使用 zookeeper 第三方库 Curator 客户端,这个客户端中封装了一个可重入的锁服务。Curator 提供的 InterProcessMutex 是分布式锁的实现。acquire 方法用户获取锁,release 方法用于释放锁。

用户头像

Linux服务器开发qun720209036,欢迎来交流 2020.11.26 加入

专注C/C++ Linux后台服务器开发。

评论

发布
暂无评论
浅析“分布式锁”的实现方式丨C++后端开发丨底层原理