前言
📖 全文字数 : 3.7k
📢 关键词 : 分布式锁、MySQL、Redis、Zookeeper、羊群效应、Redisson
在当今这个数据驱动的时代,分布式系统因其高效率、可扩展性和容错性而变得越来越普遍。然而,这些系统的设计和管理并非没有挑战。特别是在处理多个并行计算和操作共享资源时,如何确保数据的一致性和系统的稳定性成为了一个重要议题。并发控制就是实现这一目标的关键技术之一。 分布式锁作为并发控制中的一个核心工具,其作用不可小觑。它主要用于在分布式系统中各个节点间的协调,保证在同一时刻只有一个节点可以操作特定的资源或执行特定的操作。通过这种方式,分布式锁帮助系统维护了操作的原子性,避免了潜在的数据冲突和不一致。
分布式锁的基础知识
什么是锁
在了解分布式锁之前,我们先来了解一下,什么是锁: 通俗地说,锁是一种机制,用于控制多个参与者(例如线程或进程)对共享资源的访问。在编程中,我们经常会遇到多个线程同时操作共享数据的情况。如果没有适当的同步机制,就可能导致数据状态不一致。而锁的引入,就是为了防止这种"竞态条件"(race condition),确保任何时候至多只有一个线程能够操作特定的数据或执行特定的代码段。
常见的锁
-
互斥锁(Mutex) :
- 用途:保证同一时刻只有一个线程可以访问共享资源。
- 特点:防止数据竞争,但如果不正确使用可能会导致死锁或资源饥饿。
-
可重入锁(Recursive Lock) :
- 用途:与互斥锁类似,但允许同一个线程多次获得同一资源的锁定。
- 特点:适用于一个线程需要反复锁定一个资源的情况。
-
读写锁(Read-Write Lock) :
- 用途:允许多个读线程同时访问,但写线程独占访问。
- 特点:提高读操作的并发性,但当写锁被占用时其他线程无法读取或写入。
-
自旋锁(Spinlock) :
- 用途:用于保护很短时间内的操作。
- 特点:线程在获取锁之前会在一个循环中自旋,不释放CPU资源,适用于锁竞争不激烈且锁持有时间非常短的场景。
-
条件变量(Condition Variable) :
- 用途:用于线程间的通信,允许一个线程在某个条件不满足时挂起,直到另一个线程满足条件并通知它。
- 特点:常与互斥锁一起使用,用于等待某个条件的变化。
-
公平锁(Fair Lock) :
- 用途:保证线程获取锁的顺序按照请求锁的顺序(FIFO)来获取。
- 特点:可以防止饥饿,但可能导致性能下降。
分布式锁和本地锁的区别
分布式锁是分布式系统中同步分布式进程或线程的一种机制,而本地锁则用于单个系统内的同步。
-
本地锁
- 内存访问:本地锁通常在同一个内存空间内协调线程,因此不存在网络延迟。
- 性能:本地锁由于没有网络开销,因此通常性能更高。
- 简单性:实现和理解本地锁相对简单。
-
分布式锁
- 网络通信:分布式锁依赖网络来协调不同机器上的进程或线程。
- 容错性:分布式锁通常需要设计得能够应对网络分区和延迟。
- 一致性保证:需要确保分布式环境中所有节点都对锁的状态有一致的视图。
实现分布式锁的技术方案
基于数据库实现
使用数据库实现分布式锁通常涉及将锁的状态存储在数据库中,并通过控制访问这些记录来实现跨多个进程或多个服务器的同步。虽然在某些场景下可以实现简单的分布式锁,但是碍于性能和一些其它问题(下面会说),数据库不会是首选方案。
创建表结构
我们创建一个表 distributed_locks 来存储锁的状态
sql
CREATE TABLE distributed_locks (
lock_id VARCHAR(255) PRIMARY KEY COMMENT '锁的唯一标识符,用来区分不同的锁',
acquired_by VARCHAR(255) COMMENT '标识哪个进程或服务器持有了这个锁',
acquired_at DATETIME DEFAULT NOW() COMMENT '记录锁被获取的时间'
) COMMENT='分布式锁';
获取锁逻辑
当一个线程要获取锁时,会执行写入操作,如果 lock_id 已经存在,则插入操作将失败,表示锁已被持有。
sql
INSERT INTO distributed_locks (lock_id, acquired_by) VALUES ('my_lock', 'server1') ON DUPLICATE KEY UPDATE acquired_by = 'server1';
如果插入成功,表示该线程池有锁。如果失败,表示该线程没有获取到锁,并可以选择重试或退出。
释放锁逻辑
当持有的进程完成其任务后,它需要执行下面操作来释放锁:
ini
DELETE FROM distributed_locks WHERE lock_id = 'my_lock' AND acquired_by = 'server1';
优缺点
优点
- 简单易懂:使用数据库实现分布式锁的逻辑直接且容易理解。
- 利用现有资源:大多数应用程序都会用到数据库,所以不需要额外引入其它技术栈。
缺点
- 性能问题:数据库操作相对较慢,尤其是在高并发的情况下。
- 缺乏灵活性:在释放锁前服务如果挂机,锁如何释放;释放锁后,如何通知其它进程获取锁?这些都是问题。
基于 Zookeeper 实现
ZooKeeper是一个分布式的协调服务,它允许系统维护配置信息、命名、提供分布式同步以及提供组服务等功能。
方案一:使用临时节点
- 当一个线程获取锁时,创建临时节点 /dlock。
- 如果加锁成功,则获取锁。
- 如果加锁失败,则监听 /dlock 节点并等待。
- 当业务执行完成后删除 /dlock 节点,那么其它应用节点也会收到通知并尝试获取锁。
之前基于数据库实现,我们遇到了两个问题,利用 Zookeeper 是否解决了呢?
- 释放锁之前应用程序挂掉。
- 释放锁之后通知其它进程。
可以看到,这两个问题 Zookeeper 其实是都解决了的。我们创建的是临时节点,这意味着服务挂掉之后,锁也会被释放;当服务释放锁之后,所有其他正在等待这把锁的客户端可能会被同时唤醒。但是这样做合理吗?当一个锁被释放时,如果有很多客户端都在监听这个同一个节点,那么这时就会产生羊群效应。 羊群效应的影响:
- 网络风暴:当锁被释放时,大量的客户端可能会同时向ZooKeeper服务器发送请求来检查自己是否是下一个最小的节点,从而获得锁。这可能会导致大量的网络流量和请求,称为网络风暴。
- 性能问题:大量的并发请求可能对ZooKeeper服务器造成负担,导致性能下降。
- 资源竞争:在锁竞争激烈的情况下,很多客户端可能会反复尝试获取锁并收到通知,但是大多数时候这些尝试都是无效的,因为只有一个客户端能够获得锁。
如何解决?如果业务侧依然使用 Zookeeper 来实现的话,可以使用临时有序节点。
方案二:使用临时有序节点(公平锁)
-
首先,在ZooKeeper的命名空间中创建一个持久节点,作为所有锁节点的父节点,例如/locks。
-
当一个客户端想要获取锁时,它在/locks节点下创建一个临时顺序节点,例如/locks/lock_。ZooKeeper会自动追加一个唯一的序列号到这个节点名字上,形成类似/locks/lock_000000001的节点路径。
-
客户端获取/locks下的所有子节点,并比较自己创建的节点序列号。
- 如果该客户端创建的节点序列号在所有子节点中是最小的,那么它就认为获取了锁。
- 如果不是,客户端就找出比自己的节点序列号小的那个节点,并对其设置监听(watch)。当这个节点被删除时(锁被释放),客户端会收到一个通知。
-
获取锁的客户端在完成其任务之后,会删除它创建的那个临时有序节点,从而释放锁。删除操作会触发通知给其他正在监听这个节点的客户端。
优缺点
优点
- 强一致性保证: ZooKeeper保证了跨所有节点的数据一致性,利用这一特性,可以确保在任何时刻只有一个客户端能持有锁。
- 容错性: ZooKeeper集群(也称为一个ensemble)通过复制数据到所有的节点来处理服务器故障。如果一个节点失败,其他节点会接管,保证锁服务可用。
- 顺序保证: ZooKeeper节点可以创建顺序临时节点,这对于实现公平锁(锁请求按照顺序满足)是很重要的。
- 自动锁释放: 通过使用临时节点,ZooKeeper能够在持有锁的客户端会话结束时自动释放锁,这避免了客户端故障导致的死锁问题。
- 实时通知: 客户端可以在ZooKeeper节点上设置watcher,当节点发生变化时(如被删除),客户端会获得通知,这对锁的实现来说是必要的。
缺点
- 性能开销: 相比于本地锁或者基于内存缓存的分布式锁方案(如Redis),ZooKeeper基于磁盘存储,并且其所有写操作都需要集群中的多数节点确认,这导致ZooKeeper的锁操作相对较慢。
- 羊群效应: 如前所述,羊群效应是ZooKeeper锁的一个问题,锁释放时可能导致大量客户端同时醒来尝试获取锁,这会给ZooKeeper服务带来瞬间的高负载。
基于 Redis 缓存实现
V1:利用 SETNX 实现
SETNX是Redis中的"set if not exists"的简写,这个命令在指定的键不存在时,为这个键设置值。这个特性可以用来实现分布式锁。
SETNX lock_key unique_lock_value
执行命令后如果返回1,表示获取锁成功;如果返回0,则表示锁已经存在。
这样做虽然简单,但是缺点也很明显
- 锁不具备容错性: 如果持有锁的客户端在业务处理过程中崩溃,锁将不会被释放,其他客户端无法再获得锁。
- 不具备自动解锁的能力: 需要客户端在业务处理完毕后显式地释放锁。
于是我们进行了第二轮迭代
V2:添加超时时间
sql
SET lock_key unique_lock_value NX EX 30
加了过期时间后,又引发了新的问题
- 业务也许会执行很久,锁提前释放
- 释放的是其它客户端的锁
来,我们逐个击破
- 针对锁提前释放,我们可以利用锁续约的机制来解决,也就是获取锁成功后,后台跑一个任务,来检测锁的到期时间,如果临近到期时间了,这时再进行续约。Redisson 就是这么做的。
- 针对释放错误的问题,我们可以给锁设置 value 时做区分。比如说可以设置 UUID,确保客户端的唯一性。
V3:利用 Redisson 客户端
可以看到使用 Redis 客户端实现分布式锁还是有很多问题需要解决的。不过我们也不需要重复造轮子,对于分布式锁,已经有客户端帮我们实现了,比如说 Redisson。 Redisson 是一个在 Redis 的基础上实现的 Java 内存数据网格(In-Memory Data Grid,IMDG),为分布式和可扩展的 Java 应用提供了丰富的功能。Redisson 提供的一些主要功能:
- 分布式对象:例如 RBucket, RAtomicLong, RAtomicDouble, RCountDownLatch, RSemaphore, RBitSet 等。
- 分布式集合:例如 RList, RSet, RSortedSet, RScoredSortedSet, RMap(分布式 Map),RMultiMap 等。
- 分布式队列和阻塞队列:例如 RQueue, RBlockingQueue, RDeque, RBlockingDeque, RDelayedQueue 筀。
- 分布式锁和同步器:例如 RLock, RFairLock, RReadWriteLock, RSemaphore, RPermitExpirableSemaphore, RCountDownLatch 等。
Redisson 实现分布式锁也是非常简单
- 引入 Maven 依赖
xml
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>最新版本</version>
</dependency>
- 创建客户端实例
arduino
// 配置 Redis 连接
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
// 创建 Redisson 客户端实例
RedissonClient redisson = Redisson.create(config);
- 使用 Redisson
csharp
// 创建互斥锁,可以理解为分布式的 ReentrantLock
RLock lock = redisson.getRLock("xiaoyao");
// 支持自动续期
lock.lock();
// 10 秒后自动到期
// lock.lock(10,TimeUnit.SECONDS);
try{
// 业务逻辑
}finally{
lock.unlock();
}
文末安利一波: 欢迎关注我的公众号📢📢:【程序员逍遥】,我会持续更新优质内容,等你呦!😉😉