基于 Mysql 实现的分布式锁
悲观锁
MySQL 的悲观锁是假设在处理数据时会发生并发冲突,并在访问数据之前就对数据进行加锁,以防止其他事务对其进行修改。
原理
MySQL 的悲观锁是通过使用 SELECT ... FOR UPDATE
语句来实现的。
悲观锁的原理是在事务开始时,将要访问的数据行加上锁,确保其他事务在该事务释放锁之前无法修改该数据行,直到当前事务提交或回滚。
具体原理如下:
- 当一个事务执行
SELECT ... FOR UPDATE
语句时,MySQL 会自动给查询结果集中的每一行加上排它锁(X 锁)。 - 如果其他事务在此事务释放锁之前尝试修改或删除被锁定的数据行,它们将被阻塞,直到该事务释放锁。
- 当事务提交或回滚时,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。
- 在事务提交时,会检查数据的当前版本号是否和事务开始时读取的版本号一致。
- 如果一致,说明在事务执行期间数据没有被其他事务修改过,事务可以提交。
- 如果不一致,说明在事务执行期间数据被其他事务修改过,事务会回滚。
乐观锁的特点是不会主动加锁,而是在事务提交时检查数据是否被修改过,从而避免了事务的等待和阻塞,提高了并发性能。但是,乐观锁也存在一定的风险,如果并发修改的概率较高,可能会导致较多的事务回滚,影响系统的性能。
优点
- 操作简单
- 不需要额外的组件,维护起来简单
缺点
- 性能较低
基于 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 键删除。
存在的问题
- 互斥性:任意时刻只能有一个客户端拥有锁,不能同时多个客户端获取。
- 安全性:锁只能被持有该锁的用户删除,而不能被其他用户删除。
- 死锁:获取锁的客户端因为某些原因而宕机,而未能释放锁,其他客户端无法获取此锁,需要有机制来避免该类问题的发生。 a. 代码异常,导致无法运行到 release b. 网络出问题 c. 断电
- 容错:当部分节点宕机,客户端仍能获取锁或者释放锁。
如何解决上述的问题,可以给锁设置一个过期时间。但是设置过期会产生新的问题:当线程在 key 的有效期内还没执行完操作,key 就被删除了。
解决方式
启动一个守护线程去定时检查,假设锁的过期时间为 15s,在过期时间 2/3 的时候发现主线程还没有执行结束,就进行续约,也就是运行 10s 以后去将过期时间重新设置为 15s。
先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间。实现方式就是:写一个守护线程,然后去判断锁的情况,当锁快失效的时候,再次进行续约加锁,当主线程执行完成后,销毁续约锁即可,不过这种方式实现起来相对复杂。
优缺点
优点:
- 性能高效(这是选择缓存实现分布式锁最核心的出发点)。
- 实现方便。使用 Redis 来实现分布式锁,很大原因是 Redis 提供了 setnx 方法,实现分布式锁很方便。
- 避免单点故障(因为 Redis 是跨集群部署的,自然就避免了单点故障)。
缺点:
-
过期时间不好设置。如果锁的过期时间设置过长,会影响性能,如果设置的过期时间过短会保护不到共享资源。
-
Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。
可靠性保证
为了保证集群环境下分布式锁的可靠性,Redis 官方已经设计了一个分布式锁算法 Redlock(红锁)。
它是基于多个 Redis 节点的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。
Redlock 算法的基本思路,是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。
这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。
基于 ZooKeeper 实现的分布式锁
ZooKeeper 是一个分布式协调服务,它也可以用于实现分布式锁。在 ZooKeeper 中,分布式锁的实现通常涉及以下步骤:
-
创建一个基于 ZooKeeper 的锁根节点(Lock Root Node),该节点将用于存储所有锁实例。
-
当一个进程/线程需要获取锁时,它会在锁根节点下创建一个临时顺序节点(Ephemeral Sequential Node)。
-
进程/线程检查是否它创建的节点是当前锁根节点下最小的节点。如果是最小节点,则表示该进程/线程获取了锁。
-
如果进程/线程没有获取到锁,它会监听前一个节点的删除事件,一旦前一个节点被删除,表示锁被释放,进程/线程有机会再次检查自己是否成为最小节点。
-
当进程/线程释放锁时,它会删除自己创建的节点。
这种方式通过 ZooKeeper 的有序节点和监听机制,确保只有一个进程/线程能够成为最小节点,从而获取锁。其他进程/线程在等待锁的过程中监听前一个节点的删除事件,以便在锁被释放时尝试获取锁。
此方案没用过,改天研究下。