分布式锁的实现方案

基于 Mysql 实现的分布式锁

悲观锁

MySQL 的悲观锁是假设在处理数据时会发生并发冲突,并在访问数据之前就对数据进行加锁,以防止其他事务对其进行修改。

原理

MySQL 的悲观锁是通过使用 SELECT ... FOR UPDATE 语句来实现的。

悲观锁的原理是在事务开始时,将要访问的数据行加上锁,确保其他事务在该事务释放锁之前无法修改该数据行,直到当前事务提交或回滚。

具体原理如下:

  1. 当一个事务执行 SELECT ... FOR UPDATE 语句时,MySQL 会自动给查询结果集中的每一行加上排它锁(X 锁)。
  2. 如果其他事务在此事务释放锁之前尝试修改或删除被锁定的数据行,它们将被阻塞,直到该事务释放锁。
  3. 当事务提交或回滚时,MySQL 会自动释放该事务持有的所有锁。

悲观锁的特点是在事务开始时就加上锁,确保数据的一致性,但是会降低并发性能,因为其他事务需要等待锁的释放才能进行操作。因此,在使用悲观锁时需要谨慎考虑锁的粒度和持有时间,以免影响系统的性能。

生效时机

悲观锁机制并不会考虑查询条件的一致性,它只关注对数据行的访问和修改是否会发生冲突。无论查询条件是否一致,如果一个事务使用了悲观锁对数据行加锁,其他事务在此事务释放锁之前仍然无法修改被锁定的数据行。

示例

事务 1

START TRANSACTION;

-- 锁定指定行
SELECT * FROM products WHERE id = 1 FOR UPDATE;

-- 对行进行修改
UPDATE products SET stock = stock - 1 WHERE id = 1;

COMMIT;

事务 2

START TRANSACTION;

-- 尝试锁定同一行,但事务1已经锁定了该行,因此此处会阻塞等待

COMMIT;

在事务 1 提交或回滚之前,事务 2 无法获取到该行的锁,因此事务 2 的执行会被阻塞,直到事务 1 释放锁。

悲观锁并不是将整个表上锁,而是在访问特定行时加锁。其他事务仍然可以读取被锁定行之外的数据,但无法修改和读取被锁定的行。由于悲观锁会在访问数据之前就进行加锁,因此在高并发场景下,可能会导致大量的阻塞和等待。

乐观锁

乐观锁的核心思想是假设并发操作不会导致冲突,只在提交时检查是否发生冲突,如果发生冲突,则回滚事务或者进行相应的处理。

原理

乐乐观锁通常使用版本号(Versioning)或时间戳(Timestamp)来实现。在 MySQL 中,乐观锁一般通过使用版本号字段(例如使用 TIMESTAMP 或者 DATETIME 类型的列)和 WHERE 子句来实现。在事务提交时,检查在事务执行期间数据是否被其他事务修改过。如果数据没有被修改过,则提交事务;如果数据被修改过,则回滚事务。

示例

假设有一个名为 products 的表,包含 id、name 和 stock 三列,我们需要对某个产品的库存进行更新操作。

INSERT INTO products (id, name, stock, version) VALUES (1, 'Product A', 10, 0);

接下来,执行两个事务尝试同时更新库存:

事务 1:


START TRANSACTION;

-- 获取当前版本号
SELECT version INTO @version FROM products WHERE id = 1;

-- 更新库存
UPDATE products SET stock = stock - 1, version = @version + 1 WHERE id = 1 AND version = @version;

COMMIT;

事务 2:

START TRANSACTION;

-- 获取当前版本号
SELECT version INTO @version FROM products WHERE id = 1;

-- 更新库存
UPDATE products SET stock = stock - 1, version = @version + 1 WHERE id = 1 AND version = @version;

COMMIT;

在上述示例中,两个事务都会读取当前版本号,并尝试更新库存。如果事务 2 在事务 1 提交之前执行,它会读到的版本号与事务 1 的版本号不一致,因此更新操作的 WHERE 条件不满足,更新语句不会生效,从而避免了并发冲突。

具体原理如下:

  1. 在数据库表中添加一个版本号字段,用于记录数据的版本信息。
  2. 当一个事务执行更新操作时,会读取该数据的当前版本号,并将版本号加 1。
  3. 在事务提交时,会检查数据的当前版本号是否和事务开始时读取的版本号一致。
    • 如果一致,说明在事务执行期间数据没有被其他事务修改过,事务可以提交。
    • 如果不一致,说明在事务执行期间数据被其他事务修改过,事务会回滚。

乐观锁的特点是不会主动加锁,而是在事务提交时检查数据是否被修改过,从而避免了事务的等待和阻塞,提高了并发性能。但是,乐观锁也存在一定的风险,如果并发修改的概率较高,可能会导致较多的事务回滚,影响系统的性能。

优点

  1. 操作简单
  2. 不需要额外的组件,维护起来简单

缺点

  • 性能较低

基于 Redis 实现的分布式锁

Redis 本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁,而且 Redis 的读写性能高,可以应对高并发的锁操作场景。

Redis 的 SET 命令有个 NX 参数可以实现「key 不存在才插入」,所以可以用它来实现分布式锁:

  • 如果 key 不存在,则显示插入成功,可以用来表示加锁成功;
  • 如果 key 存在,则会显示插入失败,可以用来表示加锁失败。

基于 Redis 节点实现分布式锁时,对于加锁操作,我们需要满足三个条件。

  • 加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用 SET 命令带上 NX 选项来实现加锁;
  • 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在 SET 命令执行时加上 EX/PX 选项,设置其过期时间;
  • 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端;

满足这三个条件的分布式命令如下:

SET lock_key unique_value NX PX 10000 
  • lock_key 就是 key 键;
  • unique_value 是客户端生成的唯一的标识,区分来自不同客户端的锁操作;
  • NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;
  • PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁。

而解锁的过程就是从 Redis 中获取当前锁的标识符,然后检查该标识符是否与传入的标识符一致。如果一致,则说明当前锁属于当前客户端,可以安全地释放锁,即将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,需要要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。

存在的问题

  1. 互斥性:任意时刻只能有一个客户端拥有锁,不能同时多个客户端获取。
  2. 安全性:锁只能被持有该锁的用户删除,而不能被其他用户删除。
  3. 死锁:获取锁的客户端因为某些原因而宕机,而未能释放锁,其他客户端无法获取此锁,需要有机制来避免该类问题的发生。 a. 代码异常,导致无法运行到 release b. 网络出问题 c. 断电
  4. 容错:当部分节点宕机,客户端仍能获取锁或者释放锁。

如何解决上述的问题,可以给锁设置一个过期时间。但是设置过期会产生新的问题:当线程在 key 的有效期内还没执行完操作,key 就被删除了。

解决方式

启动一个守护线程去定时检查,假设锁的过期时间为 15s,在过期时间 2/3 的时候发现主线程还没有执行结束,就进行续约,也就是运行 10s 以后去将过期时间重新设置为 15s。

先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间。实现方式就是:写一个守护线程,然后去判断锁的情况,当锁快失效的时候,再次进行续约加锁,当主线程执行完成后,销毁续约锁即可,不过这种方式实现起来相对复杂。

优缺点

优点

  1. 性能高效(这是选择缓存实现分布式锁最核心的出发点)。
  2. 实现方便。使用 Redis 来实现分布式锁,很大原因是 Redis 提供了 setnx 方法,实现分布式锁很方便。
  3. 避免单点故障(因为 Redis 是跨集群部署的,自然就避免了单点故障)。

缺点

  1. 过期时间不好设置。如果锁的过期时间设置过长,会影响性能,如果设置的过期时间过短会保护不到共享资源。

  2. Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。

可靠性保证

为了保证集群环境下分布式锁的可靠性,Redis 官方已经设计了一个分布式锁算法 Redlock(红锁)。

它是基于多个 Redis 节点的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。

Redlock 算法的基本思路,是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败

这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。

基于 ZooKeeper 实现的分布式锁

https://zookeeper.apache.org/

ZooKeeper 是一个分布式协调服务,它也可以用于实现分布式锁。在 ZooKeeper 中,分布式锁的实现通常涉及以下步骤:

  1. 创建一个基于 ZooKeeper 的锁根节点(Lock Root Node),该节点将用于存储所有锁实例。

  2. 当一个进程/线程需要获取锁时,它会在锁根节点下创建一个临时顺序节点(Ephemeral Sequential Node)。

  3. 进程/线程检查是否它创建的节点是当前锁根节点下最小的节点。如果是最小节点,则表示该进程/线程获取了锁。

  4. 如果进程/线程没有获取到锁,它会监听前一个节点的删除事件,一旦前一个节点被删除,表示锁被释放,进程/线程有机会再次检查自己是否成为最小节点。

  5. 当进程/线程释放锁时,它会删除自己创建的节点。

这种方式通过 ZooKeeper 的有序节点和监听机制,确保只有一个进程/线程能够成为最小节点,从而获取锁。其他进程/线程在等待锁的过程中监听前一个节点的删除事件,以便在锁被释放时尝试获取锁。

此方案没用过,改天研究下。

使用 Hugo 构建
主题 StackJimmy 设计