分布式锁的实现原理以及实现方式

182 阅读37分钟

分布式锁的三种实现原理

分布式锁

1. 基于数据库实现分布式锁

要实现分布式锁,最简单的方式就是创建一张锁表,然后通过操作该表中的数据来实现。

当我们要锁住某个资源时,就在该表中增加一条记录,想要释放锁的时候就删除这条记录。数据库对共享资源做了唯一性约束,如果有多个请求被同时提交到数据库的话,数据库会保证只有一个操作可以成功,操作成功的那个线程就获得了访问共享资源的锁,可以进行操作。

基于数据库实现的分布式锁,是最容易理解的。但是,因为数据库需要落到硬盘上,频繁读取数据库会导致 IO 开销大,因此这种分布式锁适用于并发量低,对性能要求低的场景。对于双 11、双 12 等需求量激增的场景,数据库锁是无法满足其性能要求的。而在平日的购物中,我们可以在局部场景中使用数据库锁实现对资源的互斥访问。

优缺点

可以看出,基于数据库实现分布式锁比较简单,绝招在于创建一张锁表,为申请者在锁表里建立一条记录,记录建立成功则获得锁,消除记录则释放锁。 该方法依赖于数据库,主要有两个缺点:

  • 单点故障问题。一旦数据库不可用,会导致整个系统崩溃。
  • 死锁问题。数据库锁没有失效时间,未获得锁的进程只能一直等待已获得锁的进程主动释放锁。一旦已获得锁的进程挂掉或者解锁操作失败,会导致锁记录一直存在数据库中,其他进程无法获得锁。

2. 基于缓存实现分布式锁

数据库的性能限制了业务的并发量,那么对于双 11、双 12 等需求量激增的场景是否有解决方法呢?

基于缓存实现分布式锁的方式,非常适合解决这种场景下的问题。所谓基于缓存,也就是说把数据存放在计算机内存中,不需要写入磁盘,减少了 IO 读写。 接下来,我以 Redis 为例与你展开这部分内容。

Redis 通常可以使用 setnx(key, value) 函数来实现分布式锁。key 和 value 就是基于缓存的分布式锁的两个属性,其中 key 表示锁 id,value = currentTime + timeOut,表示当前时间 + 超时时间。也就是说,某个进程获得 key 这把锁后,如果在 value 的时间内未释放锁,系统就会主动释放锁。

setnx 函数的返回值有 0 和 1:

  • 返回 1,说明该服务器获得锁,setnx 将 key 对应的 value 设置为当前时间 + 锁的有效时间。
  • 返回 0,说明其他服务器已经获得了锁,进程不能进入临界区。该服务器可以不断尝试 setnx 操作,以获得锁。

总结来说,Redis 通过队列来维持进程访问共享资源的先后顺序。Redis 锁主要基于 setnx 函数实现分布式锁,当进程通过 setnx<key,value> 函数返回 1 时,表示已经获得锁。排在后面的进程只能等待前面的进程主动释放锁,或者等到时间超时才能获得锁。

优缺点

相对于基于数据库实现分布式锁的方案来说,基于缓存实现的分布式锁的优势表现在以下几个方面:

  • 性能更好。数据被存放在内存,而不是磁盘,避免了频繁的 IO 操作。
  • 很多缓存可以跨集群部署,避免了单点故障问题。
  • 很多缓存服务都提供了可以用来实现分布式锁的方法,比如 Redis 的 setnx 方法等。
  • 可以直接设置超时时间来控制锁的释放,因为这些缓存服务器一般支持自动删除过期数据。

这个方案的不足是,通过超时时间来控制锁的失效时间,并不是十分靠谱,因为一个进程执行时间可能比较长,或受系统进程做内存回收等影响,导致时间超时,从而不正确地释放了锁。

3. 基于 ZooKeeper 实现分布式锁

定义锁

ZooKeeper 通过一个数据节点来表示一个锁,类似于“/shared_lock/[Hostname]-请求类型-序号” 的临时顺序节点,例如 /shared_lock/192.168.0.1-R-0000000001,这个节点就代表了一个锁,如图所示。

image

获取锁

在需要获取锁时,所有客户端都会到/shared_lock这个节点下面创建一个临时顺序节点:

  • 如果当前是读请求,那么就创建如 /shared_lock/192.168.0.1-R-0000000001 的节点;
  • 如果当前是写请求,那么就创建如 /shared_lock/192.168.0.1-W-0000000001 的节点。

判断读写顺序

由于不同的事务都可以同时对同一个数据对象进行读取操作,而更新操作必须在当前没有任何事务进行读写操作的情况下进行。基于这个原则,大致可以分为如下4个步骤:

  1. 创建完节点后,获取/shared_lock节点下的所有子节点,并对该节点注册所有子节点变更的 Watcher 监听。

  2. 确定自己的节点序号在所有子节点中的顺序。

  3. 对于读请求:

    • 如果没有比自己序号小的子节点,或是所有比自己序号小的子节点都是读请求,那么表明自己已经成功获取到了读锁,同时开始执行读取逻辑。

    • 如果比自己序号小的子节点中有写请求,那么就需要进入等待。

      对于写请求:

    • 如果自己不是序号最小的子节点,那么就需要进入等待。

  4. 接收到 Watcher 通知后,重复步骤1。

释放锁

/exclusive_lock/lock 是一个临时节点,在以下两种情况下可能释放锁:

  • 当前获取锁的客户端机器发生宕机,那么ZooKeeper 上的这个临时节点就会被移除。
  • 正常执行完业务逻辑后,客户端就会主动将自己创建的临时节点删除。

无论在什么情况下移除了lock节点,ZooKeeper 都会通知所有在 /exclusive_lock 节点上注册了子节点变更Watcher 监听的客户端。这些客户端在接收到通知后,再次重新发起分布式锁获取,即重复“获取锁”过程。

羊群效应

上面讲解的这个锁实现,大体上能够满足一般的分布式集群竞争锁的需求,并且性能都还可以——这里说的一般场景是指集群规模不是特别大,一般是在10台机器以内。但是如果机器规模扩大之后,会有什么问题呢?我们着重来看上面“判断读写顺序”过程的步骤3,结合下图给出的实例,看看实际运行中的情况。

image

针对图中的实际情况,我们看看会发生什么事情。

  1. 192.168.0.1 这台机器首先进行读操作,完成读操作后将节点/192.168.0.1- R-0000000001删除。
  2. 余下的4台机器均收到了这个节点被移除的通知,然后重新从/shared_lock节点上获取一份新的子节点列表。
  3. 每个机器判断自己的读写顺序。其中192.168.0.2这台机器检测到自己已经是序号最小的机器了,于是开始进行写操作,而余下的其他机器发现没有轮到自己进行读取或更新操作,于是继续等待。
  4. 继续……

很明显,我们看到,192.168.0.1这个客户端在移除自己的共享锁后,ZooKeeper 发送了子节点变更Watcher通知给所有机器,然而这个通知除了给192.168.0.2这台机器产生实际影响外,对于余下的其他所有机器都没有任何作用。

在这整个分布式锁的竞争过程中,大量的“Watcher通知”和“子节点列表获取”两个操作重复运行,并且绝大多数的运行结果都是判断出自己并非是序号最小的节点,从而继续等待下一次通知——这个看起来显然不怎么科学。

客户端无端地接收到过多和自己并不相关的事件通知,如果在集群规模比较大的情况下,不仅会对ZooKeeper服务器造成巨大的性能影响和网络冲击,更为严重的是,如果同一时间有多个节点对应的客户端完成事务或是事务中断引起节点消失,ZooKeeper服务器就会在短时间内向其余客户端发送大量的事件通知——这就是所谓的羊群效应。

上面这个ZooKeeper分布式共享锁实现中出现羊群效应的根源在于,没有找准客户端真正的关注点。我们再来回顾一下上面的分布式锁竞争过程,它的核心逻辑在于:判断自己是否是所有子节点中序号最小的。于是,很容易可以联想到,每个节点对应的客户端只需要关注比自己序号小的那个相关节点的变更情况就可以了——而不需要关注全局的子列表变更情况。

改进后的分布式锁实现

现在我们来看看如何改进上面的分布式锁实现。首先,我们需要肯定的一点是,上面提到的锁实现,从整体思路上来说完全正确。这里主要的改动在于:每个锁竞争者,只需要关注 /shared_lock 节点下序号比自己小的那个节点是否存在即可,具体实现如下。

  1. 客户端调用 create() 方法创建一个类似于“/shared_lock/[Hostname]-请求类型-序号”的临时顺序节点。

  2. 客户端调用 getChildren() 接口来获取所有已经创建的子节点列表,注意,这里不注册任何Watcher。

  3. 如果无法获取共享锁,那么就调用exist()来对比自己小的那个节点注册Watcher。注意,这里“比自己小的节点”只是一个笼统的说法,具体对于读请求和写请求不一样。

    • 读请求:向比自己序号小的最后一个写请求节点注册 Watcher 监听。
    • 写请求:向比自己序号小的最后一个节点注册 Watcher 监听。
  4. 等待Watcher通知,继续进入步骤2。

注意

看到这里,相信很多读者都会觉得改进后的分布式锁实现相对来说比较麻烦。确实如此,如同在多线程并发编程实践中,我们会去尽量缩小锁的范围——对于分布式锁实现的改进其实也是同样的思路。那么对于开发人员来说,是否必须按照改进后的思路来设计实现自己的分布式锁呢?答案是否定的。在具体的实际开发过程中,我们提倡根据具体的业务场景和集群规模来选择适合自己的分布式锁实现:在集群规模不大、网络资源丰富的情况下,第一种分布式锁实现方式是简单实用的选择;而如果集群规模达到一定程度,并且希望能够精细化地控制分布式锁机制,那么不妨试试改进版的分布式锁实现。

优缺点

可以看到,使用 ZooKeeper 可以完美解决设计分布式锁时遇到的各种问题,比如单点故障、不可重入、死锁等问题。虽然 ZooKeeper 实现的分布式锁,几乎能涵盖所有分布式锁的特性,且易于实现,但需要频繁地添加和删除节点,所以性能不如基于缓存实现的分布式锁。 Zookeeper实现的分布式锁在中小型公司的普及率不高,尤其是非 Java 技术栈的公司使用的较少,如果只是为了实现分布式锁而重新搭建一套 ZooKeeper 集群,显然实现成本和维护成本太高。

4. 三种实现方式对比

image

Mysql实现分布式锁的的方式

实现方法

MySQL可以通过使用数据库的行级锁来实现分布式锁。

在MySQL中,我们可以使用SELECT ... FOR UPDATE语句获取行级锁。当多个客户端同时访问相同的表并且需要对其进行修改操作时,只有第一个成功获得了行级锁的客户端才能执行更新操作,而其他客户端则会被阻塞直到该行级锁被释放。这样就确保了每次只有一个客户端能够修改特定的记录,从而达到了分布式锁的效果。

实现原理

逐一执行、唯一执行。这是分布式锁使用的大致场景。

逐一执行

逐一执行顾名思义,就是多台服务器同一时间只有一台服务器执行,其他服务器等待,当执行服务器执行完成后其他服务器会继续抢占执行。

唯一执行

唯一执行类似于leader的概念,多台服务器同一时间只有一台执行,其他服务器放弃执行。

基于记录

适用于唯一执行场景

要实现分布式锁,最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。当我们想要获得锁的时候,就可以在该表中增加一条记录,想要释放锁的时候就删除这条记录。

为了更好的演示,我们先创建一张数据库表,参考如下:

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

其中update_time是用来判断加锁时间,用于后续定时任务解决使用!

当我们想要获得锁时,可以插入一条数据:

INSERT INTO database_lock(resource, description) VALUES (1, 'lock');

注意:在表database_lock中,resource字段做了唯一性约束,这样如果有多个请求同时提交到数据库的话,数据库可以保证只有一个操作可以成功(其它的会报错:ERROR 1062 (23000): Duplicate entry ‘1’ for key ‘uiq_idx_resource’),那么那么我们就可以认为操作成功的那个请求获得了锁。

当需要释放锁的时,可以删除这条数据:

DELETE FROM database_lock WHERE resource=1;

这种实现方式非常的简单,但是需要注意以下几点:

1.这种锁没有失效时间,一旦释放锁的操作失效就会导致锁记录一直在数据库中,其他线程无法获取锁,这个缺陷也很好解决,比如可以做个定时任务去定时清理。

2.这种锁的可靠性依赖于数据库,建议设置备库,避免单点,进一步提高可靠性。

3.这种锁是非阻塞的,因为插入数据失败后会直接报错,想要获取锁就需要再次操作,如果需要阻塞式的,可以弄个for循环,while循环之类的,直到INSERT成功再返回。

4.这种锁是非可重入的。因为同一个线程在没有释放锁之前无法再次获得锁,因为数据库已经存在同一份记录了。想要实现可重入锁,可以在数据库中添加一些字段,比如获得锁的主机信息,线程信息等,那么在再次获得锁的时候就可以先查询数据,如果当前的主机信息和线程信息等能被查到的话,可以直接把锁分配给它。

乐观锁

适用于唯一执行场景

顾名思义,系统认为数据的更新在大多数情况下是不会产生冲突的。只在数据库更新操作提交的时候才对数据作冲突检测。如果检测的结果出现了与预期数据不一数的情况,则返问失败信息。

乐观锁大多数是基于数据版本(version)的记录机制实现的。何谓数据版本号?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表添加一个"version‘字段来实现读取出数据时,将此版本号一同读出,之后更新时,对此版本号加1。在更新过程中,会对版本号进行比较,如果是一致的,没有发生改变,则会成功执行本次操作;如果版本号不一致,则会更新失败。

为了更好的理解数据库乐观锁在实际项目中的使用,这里就列举一个典型的电商库存的例子。一个电商平台都会存在商品的库存,当用户进行购买的时候就会对库存进行操作(库存减1代表已经卖出了一件)。我们将这个库存模型用下面的一张表optimistic_lock来表述,参考如下:

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='数据库分布式锁表';

其中:id表示主键;resource表示具体操作的资源,在这里也就是特指库存;version表示版本号。

在使用乐观锁之前要确保表中有相应的数据,比如:

INSERT INTO optimistic_lock( resource,version,created_at , updated_at) VALUES(20,1,CURTDNE(),CURT1E());

如果只是一个线程进行操作,数据库本身就能保证操作的正确性。主要步骤如下:

  • STEP1 - 获取资源:SELECT resource FROM optimistic_lock WHERE id = 1
  • STEP2 - 执行业务逻辑
  • STEP3 - 更新资源:UPDATE optimistic_lock SET resource = resource -1 WHERE id = 1

然而在并发的情况下就会产生些意想不到的问题:比如两个线程同时购买一件商品,在数据库层面实际操作应该是库存(resource)减2,但是由于是高并发的情况,第一个线程执行之后(执行了STEP1、STEP2但是还没有完成STEP3),第二个线程在购买相同的商品(执行STEP1),此时查询出的库存并没有完成减1的动作,那么最终会导致2个线程购买的商品却出现库存只减1的情况。

在引入了version字段之后,那么具体的操作就会演变成下面的内容:

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

其实,借助更新时间戳(updated_at)也可以实现乐观锁,和采用version字段的方式相似:更新操作执行前线获取记录当前的更新时间,在提交更新时,检测当前更新时间是否与更新开始时获取的更新时间戳相等。

注意:乐观锁的优点比较明显,由于在检测数据冲突时并不依赖数据库本身的锁机制,不会影响请求的性能,当产生并发且并发量较小的时候只有少部分请求会失败。缺点是需要对表的设计增加额外的字段,增加了数据库的冗余,另外,当应用并发量高的时候,version值在频繁变化,则会导致大量请求失败,影响系统的可用性。我们通过上述sqi语句还可以看到,数据库锁都是作用于同一行数据记录上;这就导致一个明显的缺点,在一些特殊场景,如大促、秒杀等活动开展的时候,大量的请求同时请求同一条记录的行锁,会对数据库产生很大的写压力。所以综合数据库乐观锁的优缺点,乐观锁比较适合并发是不高,并且写操作不频繁的场景。

缺点:不适合大量请求,会涉及大量事务回滚从而导致性能瓶颈

悲观锁

除了可以通过增删操作数据库表中的记录以外,我们还可以借助数据库中自带的锁来实现分布式锁。在查询语句后面增加FOR UPDATE,数据库会在查询过程中给数据库表增加悲观锁,也称排他锁。当某条记录被加上悲观锁之后,其它线程也就无法再改行上增加悲观锁。

悲观锁,与乐观锁相反,总是假设最坏的情况,它认为数据的更新在大多数情况下是会产生冲突的。 在使用悲观锁的同时,我们需要注意一下锁的级别。MySQL InoDB引起在加锁的时候,只有明确地指定主键(或索引)的才会执行行锁(只锁住被选取的数据),否则MySQL将会执行表锁(将整个数据表单给锁住)。 在使用悲观锁时,我们必须关闭MySQL数据库的自动提交属性(参考下面的示例),因为MySQL默认便用autocommit模式,也就是说.当你执行一个更新操作后,MySQL会立刻特结果进行提交。

    mysql> SET AUTOCOMIT = 0;
    Query OK,0 rows affected(0.00 sec)

这样在使用FOR UPDATE获得锁之后可以执行相应的业务逻辑,执行完之后再使用COMMIT来释放锁。

我们不妨沿用前面的database_lock表来具体表述一下用法。假设有一线程A需要获得锁并执行相应的操作,那么它的具体步骤如下:

  • STEP1 - 获取锁:SELECT * FROM database_lock WHERE id = 1 FOR UPDATE;。
  • STEP2 - 执行业务逻辑。
  • STEP3 - 释放锁:COMMIT。

如果另一个线程B在线程A释放锁之前执行STEP1,那么它会被阻塞,直至线程A释放锁之后才能继续。注意,如果线程A长时间未释放锁,那么线程B会报错,参考如下(lock wait time可以通过innodb_lock_wait_timeout来进行配置):

    ERROR 1205 (HY000):LOCK wait timeout exceeded;try restarting transaction

上面的示例中演示了指定主键并且能查询到数据的过程(触发行锁),如果查不到数据那么也就无从“锁”起了。

如果未指定主键(或者索引)且能查询到数据,那么就会触发表锁,比如STEP1改为执行(这里的version只是当做一个普通的字段来使用,与上面的乐观锁无关):

    SELECT * FROM database_lock WHERE description='lock' FOR UPDATE;

或者主键不明确也会触发表锁,又比如STEP1改为执行:

    SELECT * FROM database_lock WHERE id>8 FOR UPDATE;

注意,虽然我们可以显示使用行级锁(指定可查询的主键或索引),但是MySQL会对查询进行优化,即便在条件中使用了索引字段,但是否真的使用索引来检索数据是由MySQL通过判断不同执行计划的代价来决定的,如果MySQL认为全表扫描效率更高,比如对一些很少的表,它有可能不会使用索引,在这种情况下InnoDB将使用表锁,而不是行锁。 在悲观锁中,每一次行数据的访问都是独占的,只有当正在访问该行数据的请求事务提交以后,其他请求才能依次访问该数据,否则将阻塞等待锁的获取。悲观锁可以严格保证数据访问的安全。但是缺点也明显,即每次请求都会额外产生加锁的开销且未获取到锁的请求将会阻塞等待锁的获取,在高并发环境下,容易造成大是请求阻寨,影响系统可用性。另外,悲观锁便用不当还可能产生死锁的情况。

缺点也很明显,要考虑上锁超时所导致的报错问题。

Redis实现分布式锁的方式

Redis实现分布式锁主要依赖于SETNX命令和EXPIRE命令,以及Lua脚本。以下是几种常见的实现方式:

  • SETNX + EXPIRE。使用SETNX命令来尝试获取锁,如果成功则设置锁的过期时间,如果失败则表示锁已被其他客户端持有。这种方式简单高效,但SETNX和EXPIRE不是原子操作,可能导致锁无法释放。123
  • SETNX + 客户端ID + EXPIRE。在SETNX的基础上,为每个获取锁的客户端设置一个唯一的客户端ID,在释放锁时,需要验证持有锁的客户端ID是否匹配,以避免误释放其他客户端持有的锁。13
  • SET的扩展命令(SET EX PX NX)。使用SET命令的扩展参数来实现分布式锁,EX表示过期时间,PX表示毫秒单位,NX表示只有当键不存在时才设置值。这种方式简单高效,可以一条命令完成加锁和设置过期时间。12
  • SET EX PX NX + 校验唯一随机值,再释放锁。在SET EX PX NX的基础上,增加了一个唯一随机值作为锁的持有者标识,只有持有者才能释放锁,这种方式增加了安全性,避免了其他客户端误删或者覆盖锁。1
  • 使用Lua脚本。使用Lua脚本来保证SETNX和EXPIRE的原子性,如果抢占锁成功,就设置锁的过期时间。
  • Redisson框架。Redisson是一个基于Redis的Java分布式对象和服务框架,提供了多种分布式锁的实现,如可重入锁、公平锁、读写锁等。
  • 多机实现的分布式锁Redlock。这种方式通过在多个独立的Redis节点上获取锁,并通过大多数节点成功获取锁的原则来确保锁的可用性。

以上方式各有优缺点,可以根据实际需求选择合适的实现方式。

Redis实现分布式锁的7种方案

日常开发中,秒杀下单、抢红包等等业务场景,都需要用到分布式锁。而Redis非常适合作为分布式锁使用。本文将分七个方案展开,跟大家探讨Redis分布式锁的正确使用方式。如果有不正确的地方,欢迎大家指出哈,一起学习一起进步。

  • 什么是分布式锁
  • 方案一:SETNX + EXPIRE
  • 方案二:SETNX + value值是(系统时间+过期时间)
  • 方案三:使用Lua脚本(包含SETNX + EXPIRE两条指令)
  • 方案四:SET的扩展命令(SET EX PX NX)
  • 方案五:SET EX PX NX + 校验唯一随机值,再释放锁
  • 方案六: 开源框架~Redisson
  • 方案七:多机实现的分布式锁Redlock

概要的说一下七种方法

  • 方案一:SETNX + EXPIRE。使用SETNX命令来抢占锁,如果成功,再使用EXPIRE命令给锁设置一个过期时间。这种方案的缺点是SETNX和EXPIRE不是原子操作,可能导致锁无法释放123。
  • 方案二:SETNX + value值是(系统时间+过期时间) 。使用SETNX命令来抢占锁,如果失败,再获取锁的过期时间,如果已经过期,就用GETSET命令更新锁的过期时间。这种方案的缺点是需要客户端的时间同步,而且可能导致锁的过期时间被覆盖或者被其他客户端删除123。
  • 方案三:使用Lua脚本(包含SETNX + EXPIRE两条指令) 。使用Lua脚本来保证SETNX和EXPIRE的原子性,如果抢占锁成功,就设置锁的过期时间。这种方案的优点是避免了方案一的问题,但是还是存在方案二的问题123。
  • 方案四:SET的扩展命令(SET EX PX NX) 。使用SET命令的扩展参数来实现分布式锁,EX表示过期时间,PX表示毫秒单位,NX表示只有当键不存在时才设置值。这种方案的优点是简单高效,可以一条命令完成加锁和设置过期时间123。
  • 方案五:SET EX PX NX + 校验唯一随机值,再释放锁。在方案四的基础上,增加了一个唯一随机值作为锁的持有者标识,只有持有者才能释放锁。这种方案的优点是增加了安全性,避免了其他客户端误删或者覆盖锁。
  • 方案六: 开源框架~Redisson。Redisson是一个基于Redis的Java分布式对象和服务框架,提供了多种分布式锁的实现,如可重入锁、公平锁、读写锁等。这种方案的优点是功能丰富,易于使用和集成。
  • 方案七:多机实现的分布式锁Redlock。Redlock是Redis作者提出的一种基于多个Redis节点实现分布式锁的算法,可以保证在任何时刻只有一个客户端持有锁,并且能够容忍一定数量的节点故障。这种方案的优点是高可用性和高可靠性。

什么是分布式锁

分布式锁其实就是,控制分布式系统不同进程共同访问共享资源的一种锁的实现。如果不同的系统或同一个系统的不同主机之间共享了某个临界资源,往往需要互斥来防止彼此干扰,以保证一致性。

我们先来看下,一把靠谱的分布式锁应该有哪些特征:

img

  • 「互斥性」: 任意时刻,只有一个客户端能持有锁。
  • 「锁超时释放」:持有锁超时,可以释放,防止不必要的资源浪费,也可以防止死锁。
  • 「可重入性」:一个线程如果获取了锁之后,可以再次对其请求加锁。
  • 「高性能和高可用」:加锁和解锁需要开销尽可能低,同时也要保证高可用,避免分布式锁失效。
  • 「安全性」:锁只能被持有的客户端删除,不能被其他客户端删除

Redis分布式锁方案一:SETNX + EXPIRE

提到Redis的分布式锁,很多小伙伴马上就会想到setnx+ expire命令。即先用setnx来抢锁,如果抢到之后,再用expire给锁设置一个过期时间,防止锁忘记了释放。

SETNX 是SET IF NOT EXISTS的简写.日常命令格式是SETNX key value,如果 key不存在,则SETNX成功返回1,如果这个key已经存在了,则返回0。

假设某电商网站的某商品做秒杀活动,key可以设置为key_resource_id,value设置任意值,伪代码如下:

if(jedis.setnx(key_resource_id,lock_value) == 1){ //加锁
    expire(key_resource_id,100); //设置过期时间
    try {
        do something  //业务请求
    }catch(){
  }
  finally {
       jedis.del(key_resource_id); //释放锁
    }
}

但是这个方案中,setnxexpire两个命令分开了,「不是原子操作」。如果执行完setnx加锁,正要执行expire设置过期时间时,进程crash或者要重启维护了,那么这个锁就“长生不老”了,「别的线程永远获取不到锁啦」

Redis分布式锁方案二:SETNX + value值是(系统时间+过期时间)

为了解决方案一,「发生异常锁得不到释放的场景」,有小伙伴认为,可以把过期时间放到setnx的value值里面。如果加锁失败,再拿出value值校验一下即可。加锁代码如下:

long expires = System.currentTimeMillis() + expireTime; //系统时间+设置的过期时间
String expiresStr = String.valueOf(expires);

// 如果当前锁不存在,返回加锁成功
if (jedis.setnx(key_resource_id, expiresStr) == 1) {
        return true;
} 
// 如果锁已经存在,获取锁的过期时间
String currentValueStr = jedis.get(key_resource_id);

// 如果获取到的过期时间,小于系统当前时间,表示已经过期
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {

     // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间(不了解redis的getSet命令的小伙伴,可以去官网看下哈)
    String oldValueStr = jedis.getSet(key_resource_id, expiresStr);
    
    if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
         // 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才可以加锁
         return true;
    }
}
        
//其他情况,均返回加锁失败
return false;
}

这个方案的优点是,巧妙移除expire单独设置过期时间的操作,把**「过期时间放到setnx的value值」**里面来。解决了方案一发生异常,锁得不到释放的问题。但是这个方案还有别的缺点:

  • 过期时间是客户端自己生成的(System.currentTimeMillis()是当前系统的时间),必须要求分布式环境下,每个客户端的时间必须同步。
  • 如果锁过期的时候,并发多个客户端同时请求过来,都执行jedis.getSet(),最终只能有一个客户端加锁成功,但是该客户端锁的过期时间,可能被别的客户端覆盖
  • 该锁没有保存持有者的唯一标识,可能被别的客户端释放/解锁。

Redis分布式锁方案三:使用Lua脚本(包含SETNX + EXPIRE两条指令)

实际上,我们还可以使用Lua脚本来保证原子性(包含setnx和expire两条指令),lua脚本如下:

if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then
   redis.call('expire',KEYS[1],ARGV[2])
else
   return 0
end;

加锁代码如下:

 String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" +
            " redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";   
Object result = jedis.eval(lua_scripts, Collections.singletonList(key_resource_id), Collections.singletonList(values));
//判断是否成功
return result.equals(1L);

这个方案,跟方案二对比,你觉得哪个更好呢?

Redis分布式锁方案方案四:SET的扩展命令(SET EX PX NX)

除了使用,使用Lua脚本,保证SETNX + EXPIRE两条指令的原子性,我们还可以巧用Redis的SET指令扩展参数!(SET key value[EX seconds][PX milliseconds][NX|XX]),它也是原子性的!

SET key valueEX seconds[NX|XX]

  • NX :表示key不存在的时候,才能set成功,也即保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁,才能获取。
  • EX seconds :设定key的过期时间,时间单位是秒。
  • PX milliseconds: 设定key的过期时间,单位为毫秒
  • XX: 仅当key存在时设置值

伪代码demo如下:

if(jedis.set(key_resource_id, lock_value, "NX", "EX", 100s) == 1){ //加锁
    try {
        do something  //业务处理
    }catch(){
  }
  finally {
       jedis.del(key_resource_id); //释放锁
    }
}

但是呢,这个方案还是可能存在问题:

  • 问题一:「锁过期释放了,业务还没执行完」。假设线程a获取锁成功,一直在执行临界区的代码。但是100s过去后,它还没执行完。但是,这时候锁已经过期了,此时线程b又请求过来。显然线程b就可以获得锁成功,也开始执行临界区的代码。那么问题就来了,临界区的业务代码都不是严格串行执行的啦。
  • 问题二:「锁被别的线程误删」。假设线程a执行完后,去释放锁。但是它不知道当前的锁可能是线程b持有的(线程a去释放锁时,有可能过期时间已经到了,此时线程b进来占有了锁)。那线程a就把线程b的锁释放掉了,但是线程b临界区业务代码可能都还没执行完呢。

方案五:SET EX PX NX + 校验唯一随机值,再删除

既然锁可能被别的线程误删,那我们给value值设置一个标记当前线程唯一的随机数,在删除的时候,校验一下,不就OK了嘛。伪代码如下:

if(jedis.set(key_resource_id, uni_request_id, "NX", "EX", 100s) == 1){ //加锁
    try {
        do something  //业务处理
    }catch(){
  }
  finally {
       //判断是不是当前线程加的锁,是才释放
       if (uni_request_id.equals(jedis.get(key_resource_id))) {
        jedis.del(lockKey); //释放锁
        }
    }
}

在这里,**「判断是不是当前线程加的锁」「释放锁」**不是一个原子操作。如果调用jedis.del()释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁。

img

为了更严谨,一般也是用lua脚本代替。lua脚本如下:

if redis.call('get',KEYS[1]) == ARGV[1] then 
   return redis.call('del',KEYS[1]) 
else
   return 0
end;

Redis分布式锁方案六:Redisson框架

方案五还是可能存在**「锁过期释放,业务没执行完」**的问题。有些小伙伴认为,稍微把锁过期时间设置长一些就可以啦。其实我们设想一下,是否可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。

当前开源框架Redisson解决了这个问题。我们一起来看下Redisson底层原理图吧:

img

只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用Redisson解决了**「锁过期释放,业务没执行完」**问题。

Redis分布式锁方案七:多机实现的分布式锁Redlock+Redisson

前面六种方案都只是基于单机版的讨论,还不是很完美。其实Redis一般都是集群部署的:

img

如果线程一在Redis的master节点上拿到了锁,但是加锁的key还没同步到slave节点。恰好这时,master节点发生故障,一个slave节点就会升级为master节点。线程二就可以获取同个key的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。

为了解决这个问题,Redis作者 antirez提出一种高级的分布式锁算法:Redlock。Redlock核心思想是这样的:

搞多个Redis master部署,以保证它们不会同时宕掉。并且这些master节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个master实例上,是与在Redis单实例,使用相同方法来获取和释放锁。

我们假设当前有5个Redis master节点,在5台服务器上面运行这些Redis实例。

img

RedLock的实现步骤:如下

  • 1.获取当前时间,以毫秒为单位。
  • 2.按顺序向5个master节点请求加锁。客户端设置网络连接和响应超时时间,并且超时时间要小于锁的失效时间。(假设锁自动失效时间为10秒,则超时时间一般在5-50毫秒之间,我们就假设超时时间是50ms吧)。如果超时,跳过该master节点,尽快去尝试下一个master节点。
  • 3.客户端使用当前时间减去开始获取锁时间(即步骤1记录的时间),得到获取锁使用的时间。当且仅当超过一半(N/2+1,这里是5/2+1=3个节点)的Redis master节点都获得锁,并且使用的时间小于锁失效时间时,锁才算获取成功。(如上图,10s> 30ms+40ms+50ms+4m0s+50ms)
  • 如果取到了锁,key的真正有效时间就变啦,需要减去获取锁所使用的时间。
  • 如果获取锁失败(没有在至少N/2+1个master实例取到锁,有或者获取锁时间已经超过了有效时间),客户端要在所有的master节点上解锁(即便有些master节点根本就没有加锁成功,也需要解锁,以防止有些漏网之鱼)。

简化下步骤就是:

  • 按顺序向5个master节点请求加锁
  • 根据设置的超时时间来判断,是不是要跳过该master节点。
  • 如果大于等于3个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功啦。
  • 如果获取锁失败,解锁!

Redisson实现了redLock版本的锁,有兴趣的小伙伴,可以去了解一下哈~

  • 如何选择:

  • 一般来说,你可以根据以下几个因素来选择合适的方案:

    • 性能和效率。如果你需要一个简单高效的分布式锁,可以选择方案四或者方案五,它们只需要一条命令就可以完成加锁和设置过期时间,而且可以避免锁无法释放的问题。
    • 安全性和可靠性。如果你需要一个安全可靠的分布式锁,可以选择方案五或者方案七,它们可以保证锁只能被持有者释放,而且可以容忍一定数量的节点故障。
    • 功能和易用性如果你需要一个功能丰富易于使用的分布式锁,可以选择方案六, 它提供了多种分布式锁的实现,如可重入锁、公平锁、读写锁等,而且可以方便地集成到Java项目中。

    当然,这些方案并不是完美的,它们可能存在一些潜在的问题或者局限性,你需要根据自己的业务需求和场景来权衡利弊,选择最适合你的方案。

  • 关于方案六和方案7

  • 方案六:开源框架~Redisson

    。Redisson是一个基于Redis的Java分布式对象和服务框架,提供了多种分布式锁的实现,如可重入锁、公平锁、读写锁等。这种方案的优点是功能丰富,易于使用和集成。使用Redisson框架,你可以通过以下几个步骤来实现分布式锁:

    1. 引入Redisson的依赖包,如Maven或Gradle。
    2. 创建RedissonClient对象,配置Redis的连接方式,如单节点、集群、哨兵等。
    3. 调用RedissonClient的getLock方法,传入锁的名称,获取RLock对象。
    4. 调用RLock的lock或tryLock方法,尝试加锁,如果成功,执行业务逻辑。
    5. 调用RLock的unlock方法,释放锁。
  • 方案七:多机实现的分布式锁Redlock

    。Redlock是Redis作者提出的一种基于多个Redis节点实现分布式锁的算法,

    可以保证在任何时刻只有一个客户端持有锁,并且能够容忍一定数量的节点故障。使用Redlock算法,

    你可以通过以下几个步骤来实现分布式锁:

    1. 准备N个(N>=3)独立的Redis节点或实例,它们之间不进行数据同步。
    2. 客户端获取当前时间戳T,并生成一个唯一随机值V作为锁的标识。
    3. 客户端依次向N个节点发送SETNX命令,尝试设置相同的键K和值V,并设置过期时间E(单位为毫秒)。
    4. 客户端计算从发送命令到接收响应所消耗的时间D,并判断是否有至少N/2+1个节点成功加锁。
    5. 如果成功加锁,则客户端认为已经获得了分布式锁,并设置锁的有效时间为E-D(单位为毫秒)。
    6. 如果失败加锁,则客户端向所有节点发送DEL命令,尝试删除键K,并放弃执行业务逻辑。