目录
基于MYSQL实现
通过 insert 实现
通过 select 实现
基于Redis实现
基于set实现
redlock
基于zookeeper实现
在分布式系统中对于共享资源我们需要确保在任何时刻只有一个节点或进程能够访问,也就需要加锁,而我们的本地锁无法满足这个需求,本地锁是在单个节点或进程内部使用的锁机制,它只能保证在该节点或进程内的线程安全。锁本身就是共享资源需要对所有节点或线可见,我们的本地锁实际上是私有资源。
分布式锁的要求
- 全局唯一性:分布式锁需要在整个分布式系统中保证唯一性,即任何时刻只能有一个节点或进程能够获取到锁。这样才能确保共享资源在分布式环境下的安全访问。
- 高可用性:分布式锁服务应该具备高可用性,即使部分节点出现故障,也不能影响锁的正常获取和释放,以保证分布式系统的正常运行。
我们在这里介绍基于三种中间件实现分布式锁的思路:MYSQL,Redis,ZooKeeper。
基于MYSQL实现
在 MySQL 中实现分布式锁由两种方法:通过 insert 实现,通过 select 实现。
通过 insert 实现
这种方法的原理是 MYSQL 的唯一索引性质。当插入相同唯一键的数据时会失败。通过插入一条具有唯一键的数据来尝试获取锁,如果插入成功则表示获取锁成功,插入失败则表示锁已被其他线程或进程持有。
首先需要建立锁表:
CREATE TABLE distributed_lock (id INT AUTO_INCREMENT PRIMARY KEY,lock_name VARCHAR(255) NOT NULL,UNIQUE KEY unique_lock_name (lock_name)
);
锁中有两个字段,一个是自增主键 id字段,一个是唯一索引 lock_name。
获取锁:
-- 尝试插入一条记录来获取锁
INSERT INTO distributed_lock (lock_name) VALUES ('your_lock_name');
如果插入成功,则表示获取锁成功;如果插入失败(因为唯一索引冲突),则表示锁已被其他线程或进程持有。
释放锁:
-- 通过删除记录来释放锁
DELETE FROM distributed_lock WHERE lock_name = 'your_lock_name';
这种方法实现起来十分简单,但他实现的是非公平锁,锁的分配不依据申请的顺序,而是随机的。对于公平锁,我们可以通过 select 实现。
通过 select 实现
SELECT ... FOR UPDATE 是当前读,为了避免幻读现象MYSQL会为当前行加锁(独占锁),任何对该行的任何写操作与当前读都会被阻塞,直到当前事务提交或回调。
创建锁表:
CREATE TABLE distributed_lock (id INT AUTO_INCREMENT PRIMARY KEY,lock_name VARCHAR(255) NOT NULL,UNIQUE KEY unique_lock_name (lock_name)
);
获取锁:
BEGIN;SELECT * FROM distributed_lock WHERE lock_name = "your_lock_name" FOR UPDATE;
开启事务,并执行当前读即加上独占锁。
释放锁:
COMMIT;
提交事务,独占锁释放。
这种方法实现了公平锁,但是也有许多缺点,比如说被阻塞的连接会一直占用,产生连接爆满的问题。
实际上这两种方法我们实现的都是不可重入锁,大家可以思考一下如何实现可重入锁。
这两种方法都没有实现过期功能,如果有结点在获取锁后没有释放锁就挂掉了,锁会一直存在,会出现死锁问题。我们需要为锁设置过期时间,在到期后,自动删除锁。这点可以通过MYSQL定时任务完成。
基于MYSQL实现分布式锁有许多缺点:
- 并发性能有限:MySQL 本身在处理高并发的锁操作时,性能会受到一定限制。当大量请求同时竞争分布式锁时,MySQL 可能会成为性能瓶颈,导致响应时间变长,吞吐量下降。
- 锁的粒度较粗:基于 MySQL 实现分布式锁,通常是通过对某一行数据或某张表进行锁操作来实现。这种方式的锁粒度相对较粗,会导致一些不必要的锁竞争。比如,如果多个业务操作只是涉及到表中的不同行,但由于锁是针对整个表或某一行进行的,就可能出现本可以并发执行的操作却因为锁的原因而串行执行,降低了系统的并发处理能力。
- 单点故障问题:如果 MySQL 数据库出现故障,如服务器宕机、网络故障等,那么基于该数据库实现的分布式锁将无法正常工作,可能会导致整个分布式系统中依赖该锁的业务出现混乱,影响系统的稳定性和可靠性。
- 数据一致性风险:在 MySQL 的主从复制架构中,存在数据同步延迟的问题。如果在主库上获取锁后,还未来得及将锁的状态同步到从库,此时从库可能会认为锁未被占用,从而导致在从库上也能获取到相同的锁,出现数据不一致的情况。
基于Redis实现
与MYSQL相比,Redis是纯内存操作因此更快,能承受更高的并发。
基于set实现
Redis 实现分布式锁主要基于其原子操作特性。核心思路是在 Redis 中设置一个特定的键值对,当多个客户端尝试设置这个键时,只有一个客户端能够成功,成功设置的客户端就获得了锁,其他客户端则需要等待。当客户端完成对共享资源的操作后,需要释放锁,即删除这个键。
获取锁:
SET lock_key unique_value NX EX 10
- lock_key:锁的键名,多个客户端通过这个键名来竞争锁。
- unique_value:客户端生成的唯一值,用于在释放锁时进行验证,防止误释放其他客户端的锁。
- NX:表示只有当键不存在时才进行设置操作。
- EX 10:表示设置键的过期时间为 10 秒,防止因客户端异常崩溃而导致锁无法释放。
释放锁:
if redis.call("GET", KEYS[1]) == ARGV[1] thenreturn redis.call("DEL", KEYS[1])
elsereturn 0
end
- KEYS[1]:锁的键名。
- ARGV[1]:客户端生成的唯一值。
释放锁时,需要先验证锁的唯一值是否匹配,然后再删除锁。可以使用 Lua 脚本来保证操作的原子性。
这种方法非常简单,同时也设置了过期时间避免了死锁,但是在集群模式下就会出现问题:
现在有线程A,B与redis哨兵集群如下图:
线程A申请锁。
主结点执行命令成功后立即返回,线程 A 获取锁成功。在主从结点数据同步之前,主节点宕机。集群重新选取主结点。
分布式 CAP 原理是指在一个分布式系统中,一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)这三个基本需求不能同时被满足,最多只能同时满足其中的两个。Redis 是AP架构简单来说就是,主节点执行完命令后会立即返回再做主从结点的数据同步。
线程B申请同一把锁,此时线程A还未释放锁,但是新选举的主节点并没有锁的数据,因此线程B也申请成功。
这时就会出现两个线程持有同一把锁的情况,而为了解决单点故障问题又不得不使用集群。怎么解决这个问题呢?redis的作者设计了一种算法 redlock。
redlock
redlock 实现思路是多节点加锁,在redis的分布式环境中有 n 个独立的节点。节点之间必须是完全独立的,不存在集群同时节点也必须是独立节点。
获取锁的过程如下:
- 记录当前时间:客户端记录当前时间(以毫秒为单位)。
- 依次尝试获取锁:客户端依次向 N 个独立的 Redis 节点发送获取锁的请求(使用 SET 命令),每个请求都有一个超时时间,以防止客户端长时间阻塞在某个故障节点上。
- 计算获取锁的总时间:客户端在获取到所有节点的响应后,计算从开始请求到获取到所有响应所花费的总时间。只有当总时间小于锁的有效时间,并且在大多数(超过半数,即 N/2 + 1)节点上都成功获取到锁时,才认为最终获取锁成功。
- 设置锁的有效时间:如果获取锁成功,客户端需要重新计算锁的有效时间,即原来的锁有效时间减去获取锁所花费的总时间。
释放锁:
- 当客户端完成任务后,需要释放锁。释放锁的过程很简单,客户端只需向所有的 Redis 节点发送释放锁的请求,无论该节点上是否成功获取到锁。
这种方法通过在多个独立的 Redis 节点上获取锁,提高了分布式锁的可用性和容错性,即使部分节点发生故障,仍然可以保证锁的正常使用。保证了高可用。
但由于需要在多个节点上依次尝试获取锁,会增加一定的网络延迟和性能开销。同时算法依赖于各个 Redis 节点的时钟同步,如果节点之间的时钟存在较大偏差,可能会导致锁的安全性受到影响。
为什么节点自身必须独立,不能有主从?
假设这里有四个节点 A B C D 其中 C D 为 主从集群。
服务端有两个线程,线程一与线程二同时申请锁,线程一在 节点 B C 加锁成功,线程二在节点 A 加锁成功。线程一获取锁成功。
这时节点C宕机,但是主从未同步,节点D成为主节点。此时线程二向节点D发送请求成功。这时 线程二也获得了一半以上节点。所以此时就会出现两个线程同时持有锁的情况。
基于zookeeper实现
zookeeper的集群是CP架构,在接收请求后,会先保证节点的数据一致性。在超过半数节点同步后再返回。所以对于zk集群不需要考虑主节点故障问题。
zookeeper 实现分布式锁的核心原理基于其临时顺序节点和监听机制。
获取锁:
- 创建临时顺序节点:当客户端请求锁时,在 ZooKeeper 的特定节点下创建一个临时顺序节点。
- 判断锁的归属:客户端检查自己创建的节点是否是当前所有子节点中序号最小的。如果是,则表示该客户端获得了锁;否则,它需要等待。
- 监听:若客户端没有获得锁,它会监听比自己序号小一位的节点的删除事件。当该节点被删除时,客户端会收到通知,再次检查自己是否成为序号最小的节点,若是则获得锁。
释放锁
- 当客户端完成操作后,删除自己创建的临时顺序节点,释放锁。
ZooKeeper 的临时节点有一个特性:当创建该临时节点的会话失效(如客户端与 Zk 服务器断开连接)时,该临时节点会被自动删除。因此当客户端异常断开连接时,zk会自动释放锁。