分布式锁的几种实现方式
- 悲观锁和乐观锁
- [分布式锁的实现要求 -- 互斥性、避免死锁、可重入性、高可用行、性能](#分布式锁的实现要求 -- 互斥性、避免死锁、可重入性、高可用行、性能)
- 主从一致性
- Redis分布式缓存实现--Redisson
-
- 使用代码
-
- [1. Maven 依赖导入](#1. Maven 依赖导入)
- [2. 配置文件参数配置(需要根据你的情况进行修改)](#2. 配置文件参数配置(需要根据你的情况进行修改))
- [3. 创建 Redisson 客户端](#3. 创建 Redisson 客户端)
- [4. 使用分布式锁](#4. 使用分布式锁)
- 方法说明
-
- [1. RLock.lock()](#1. RLock.lock())
- [2. RLock.unlock()](#2. RLock.unlock())
- [互斥性 -- SET lKey randId NX PX 30000](#互斥性 -- SET lKey randId NX PX 30000)
- 避免死锁
- 可重入性
- 性能
- 高可用性(主从一致性)
-
- 普通模式(前述方式)锁的问题
- [高可用环境下(sentinel或redis cluster)的锁实现--RedLock](#高可用环境下(sentinel或redis cluster)的锁实现--RedLock)
- Redis分布式锁参考资料
- zookeeper实现分布式锁
悲观锁和乐观锁
分布式锁的实现要求 -- 互斥性、避免死锁、可重入性、高可用行、性能
分布式锁应该具有:
互斥性:任意时刻只能有一个客户端持有锁
锁超时释放: 锁超时会自动释放,防止死锁
可重入性: 一个线程获取锁之后可以再次对请求加锁
高可用、高性能:加锁和解锁需要开销尽可能低,同时要保证高可用
安全性:锁只能被持有客户端删除,不能被其他客户端删除
互斥-只能有一个客户端持有锁 -- redis setnx
避免死锁
引入过期时间 -- redis ttl
比如TTL为5秒,进程A获得锁
问题是5秒内进程A并未释放锁,被系统自动释放,进程B获得锁
刚好第6秒时进程A执行完,又会释放锁,也就是进程A释放了进程B的锁
仅仅加个过期时间会设计到两个问题:锁过期和释放别人的锁问题
锁过期问题 -- 自动续期 -- redis
锁过期问题的出现,是我们对持有锁的时间不好进行预估,设置较短的话会有【提前过期】风险,但是过期时间设置过长,可能锁长时间得不到释放。
这种情况同样有处理方式,可以开启一个守护进程(watch dog),检测失效时间进行续租,比如Java技术栈可以用Redisson来处理。
释放别人锁问题 -- 锁附加唯一性 -- 给每个客户端设置唯一ID?
锁key的值附加唯一性:针对释放别人锁这种问题,我们可以给每个客户端进程设置【唯一ID】,作为锁(所有客户端锁key相同)的值,这样我们就可以在应用层就进行检查唯一ID。
可重入 redis+lua计数器
一个线程获取了锁,但是在执行时,又再次尝试获取锁会发生什么情况?
是的,导致了重复获取锁,占用了锁资源,造成了死锁问题。
我们了解下什么是【可重入】:指的是同一个线程在持有锁的情况下,可以多次获取该锁而不会造成死锁,也就是一个线程可以在获取锁之后再次获取同一个锁,而不需要等待锁释放。
解决方式:比如实现Redis分布式锁的可重入,在实现时,需要借助Redis的Lua脚本语言,并使用引用计数器技术,保证同一线程可重入锁的正确性。
容错
容错性是为了当部分节点(redis节点等)宕机时,客户端仍然能够获取锁和释放锁,一般来说会有以下两种处理方式:
- 一种像etcd/zookeeper这种作为锁服务能够自动进行故障切换,因为它本身就是个集群
- 另一种可以提供多个独立的锁服务,客户端向多个独立锁服务进行请求,某个锁服务故障时,也可以从其他服务获取到锁信息,但是这种缺点很明显,客户端需要去请求多个锁服务。
主从一致性
redis集群(包括redis
Redis分布式缓存实现--Redisson
使用代码
1. Maven 依赖导入
2. 配置文件参数配置(需要根据你的情况进行修改)
3. 创建 Redisson 客户端
4. 使用分布式锁
在需要使用分布式锁的地方注入RedissonClient实例,并使用getLock方法创建一个分布式锁对象(RLock)。
方法说明
1. RLock.lock()
使用 Rlock.lock() 方法时 ,如果当前没有其他线程或进程持有该锁,那么调用线程将立即获得锁定,并继续执行后续的代码。如果其他线程或进程已经持有了该锁,那么调用线程将被阻塞,直到该锁被释放为止。
此外,Rlock.lock() 方法还具有以下特点:
- 可重入性: 同一个线程可以多次调用 Rlock.lock() 方法而不会造成死锁,只需确保每次 lock() 调用都有相应的 unlock() 调用与之匹配。
- 超时机制: 可以通过 lock() 方法中的参数设置等待锁的超时时间,避免因为无法获得锁而一直等待。
- 自动续期: 当线程持有锁的时间超过设置的锁的过期时间时,Redisson 会自动延长锁的有效期,避免因为业务执行时间过长而导致锁过期。
- 防止死锁: Redisson 通过唯一标识锁的 ID 来区分不同的锁,防止发生死锁。
2. RLock.unlock()
RLock.unlock()方法用于释放由Redission分布式锁所保护的资源。它允许持有锁的线程主动释放锁,从而允许其他线程获取该锁并访问共享资源。
注意事项:
- RLock.unlock()方法应该在保护的临界区代码执行完毕后进行调用,以确保锁的及时释放。
- 在多线程环境下,释放锁的顺序应该与获取锁的顺序相对应,以避免死锁或资源争用的问题。
- 如果当前线程没有持有锁,调用RLock.unlock()方法不会抛出异常,也不会影响其他线程。
- 如果Redisson客户端刚加锁成功,并且未指定leaseTime,后台会启动一个定时任务watchdog, 每隔10s检查key,key如果存在就为它⾃动续命到30s后;在watchdog定时任务存在的情况下,如果不是主动释放锁,那么key将会⼀直的被watchdog这个定时任务维持加锁。但是如果客户端宕机了,定时任务watchdog也就没了,也就没有锁续约机制了,那么过完30s之后,key会⾃动被删除、key对应的锁也自动被释放了。
互斥性 -- SET lKey randId NX PX 30000
不能用setnx lkey lvalue expire lockKey 30
//保证原子性执行命令
SET lKey randId NX PX 30000
Redission实现-lua脚本:
redis锁数据结构(数据类型为HSet):<lockKey, <线程key, 可重入计数器>>
线程key=GUID+当前线程ID,value为可重入计数器
解决了以下问题:
- 互斥性:用lua脚本保证操作的原子性进而保证了互斥性
- 锁过期问题:设置超期时间+看门狗:看门狗默认将锁的过期时间设置为30秒,并且每10秒重新设置过期时间为30秒,直到线程终止后看门狗才不再运行。
- 释放别人锁(线程key唯一标识当前线程,释放锁时,先判断redis中存储的线程key是不是当前线程,若是才释放锁)
- 可重入性:用value作为可重用计数器,每次重新加(解)锁,可重入计数器便加(减)1
java
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
避免死锁
见上方解释。
可重入性
见上方
性能
最高,高于zookeeper
高可用性(主从一致性)
普通模式(前述方式)锁的问题
事实上上面的实现琐的方式的最大的缺点就是它加锁时只作用在一个Redis节点上,即使Redis通过sentinel保证高可用,如果这个master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况:
- 在Redis的master节点上拿到了锁;
- 但是这个加锁的key还没有同步到slave节点;
- master故障,发生故障转移,slave节点升级为master节点;
- 导致锁丢失。
高可用环境下(sentinel或redis cluster)的锁实现--RedLock
RedLock实现思路
正因为如此,Redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock。笔者认为,Redlock也是Redis所有分布式锁实现方式中唯一能让面试官高潮的方式。
antirez提出的redlock算法大概是这样的:
在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。现在我们假设有5个Redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。
为了取到锁,客户端应该执行以下操作:
- 获取当前Unix时间,以毫秒为单位。
- 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
- 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
- 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
- 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。
RedLock使用代码
java
Config config1 = new Config();
config1.useSingleServer().setAddress("redis://172.29.1.180:5378")
.setPassword("a123456").setDatabase(0);
RedissonClient redissonClient1 = Redisson.create(config1);
Config config2 = new Config();
config2.useSingleServer().setAddress("redis://172.29.1.180:5379")
.setPassword("a123456").setDatabase(0);
RedissonClient redissonClient2 = Redisson.create(config2);
Config config3 = new Config();
config3.useSingleServer().setAddress("redis://172.29.1.180:5380")
.setPassword("a123456").setDatabase(0);
RedissonClient redissonClient3 = Redisson.create(config3);
String resourceName = "REDLOCK";
RLock lock1 = redissonClient1.getLock(resourceName);
RLock lock2 = redissonClient2.getLock(resourceName);
RLock lock3 = redissonClient3.getLock(resourceName);
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
boolean isLock;
try {
isLock = redLock.tryLock(500, 30000, TimeUnit.MILLISECONDS);
System.out.println("isLock = "+isLock);
if (isLock) {
//TODO if get lock success, do something;
Thread.sleep(30000);
}
} catch (Exception e) {
} finally {
// 无论如何, 最后都要解锁
System.out.println("");
redLock.unlock();
}
最核心的变化就是RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);,因为我这里是以三个节点为例。
那么如果是哨兵模式呢?需要搭建3个,或者5个sentinel模式集群(具体多少个,取决于你)。
那么如果是集群模式呢?需要搭建3个,或者5个cluster模式集群(具体多少个,取决于你)。
Redisson实现源码
既然核心变化是使用了RedissonRedLock,那么我们看一下它的源码有什么不同。这个类是RedissonMultiLock的子类,所以调用tryLock方法时,事实上调用了RedissonMultiLock的tryLock方法,精简源码如下:
java
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
// 实现要点之允许加锁失败节点限制(N-(N/2+1))
int failedLocksLimit = failedLocksLimit();
List<RLock> acquiredLocks = new ArrayList<RLock>(locks.size());
// 实现要点之遍历所有节点通过EVAL命令执行lua加锁
for (ListIterator<RLock> iterator = locks.listIterator(); iterator.hasNext();) {
RLock lock = iterator.next();
boolean lockAcquired;
try {
// 对节点尝试加锁
lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
} catch (RedisConnectionClosedException|RedisResponseTimeoutException e) {
// 如果抛出这类异常,为了防止加锁成功,但是响应失败,需要解锁
unlockInner(Arrays.asList(lock));
lockAcquired = false;
} catch (Exception e) {
// 抛出异常表示获取锁失败
lockAcquired = false;
}
if (lockAcquired) {
// 成功获取锁集合
acquiredLocks.add(lock);
} else {
// 如果达到了允许加锁失败节点限制,那么break,即此次Redlock加锁失败
if (locks.size() - acquiredLocks.size() == failedLocksLimit()) {
break;
}
}
}
return true;
}
- Redisson锁能解决主从数据一致的问题吗
不能解决,但是可以使用redisson提供的红锁来解决,但是这样的话,性能就太低了,如果业务中非要保证数据的强一致性,建议采用zookeeper实现的分布式锁
Redis分布式锁参考资料
- Redis如何实现分布式锁,单机Redis与集群Redis问题解决方案
- 看门狗"机制---分布式锁
- 从原理到实践,五分钟时间带你了解 Redisson 分布式锁的实现方案
-Redlock:Redis分布式锁的实现 - Redisson实现Redis分布式锁的N种姿势
- Redis常见面试题(二):redis分布式锁、redisson、主从一致性、Redlock红锁;Redis集群、主从复制,哨兵模式,分片集群;Redis为什么这么快,I/O多路复用模型
zookeeper实现分布式锁
Zookeeper底层是类似于文件系统那样的树结构(称为ZNode),这种树状结构和基于ZNode的数据模型使得ZooKeeper非常适合用于实现分布式协调和同步的场景,例如分布式锁、选举算法等。客户端可以通过创建、读取、更新和删除ZNode来实现对共享数据和协调状态的访问和操作。
下面是ZooKeeper实现分布式锁的基本原理和步骤:
- 客户端在ZooKeeper指定的目录下创建一个有序临时节点,代表自己的请求。
- 客户端获取目录下所有的子节点,并按节点名称的顺序进行排序。
- 客户端判断自己创建的节点是否是当前最小的节点,如果是,则认为获取到了锁;如果不是,则监听比自己小的上一个节点的删除事件。
- 如果监听的节点被删除,客户端回到第2步重新判断自己是否获得了锁。
- 获取到锁的客户端在完成任务后,删除自己创建的节点,释放锁。
至于ZooKeeper为什么能够实现分布式锁的原因有以下几点:
- 有序节点:ZooKeeper的节点是有序的,可以根据节点名称的顺序来实现竞争顺序。通过对节点名称进行排序,客户端可以判断自己是否是当前最小的节点,从而决定是否获得锁。
- 临时节点:ZooKeeper的临时节点是会话级别的,当创建节点的客户端会话结束时,该节点会被自动删除。利用临时节点,可以实现锁的自动释放,避免锁被长时间占用。
- Watch机制:ZooKeeper提供了Watch机制,客户端可以对节点的变化进行监听。通过监听比自己小的节点的删除事件,可以实现客户端之间的协调,确保只有最小节点的客户端获得锁。
总体来说,ZooKeeper通过有序节点和临时节点的特性,结合Watch机制,提供了一种简单而可靠的机制来实现分布式锁。客户端通过竞争创建有序临时节点,并监听前一个节点的删除事件,从而实现分布式环境下的互斥访问和协调操作。
基于zookeeper的分布式锁实现 --- Curator
实际项目中,推荐使用 Curator 来实现 ZooKeeper 分布式锁。Curator 是 Netflix 公司开源的一套 ZooKeeper Java 客户端框架,相比于 ZooKeeper 自带的客户端 zookeeper 来说,Curator的封装更加完善,各种API都可以比较方便地使用。Curator主要实现了下面四种锁:
InterProcessMutex:分布式可重入排它锁
InterProcessSemaphoreMutex:分布式不可重入排它锁
InterProcessReadWriteLock:分布式读写锁
InterProcessMultiLock:将多个锁作为单个实体管理的容器,获取锁的时候获取所有锁,释放锁也会释放所有锁资源(忽略释放失败的锁)。
以下是使用Curator的部分代码示例
- 添加依赖到你的pom.xml:
xml
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-framework</artifactId>
<version>5.6.0</version>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>5.6.0</version>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-client</artifactId>
<version>5.6.0</version>
</dependency>
- 配置Curator Framework并创建InterProcessMutex实例:
java
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ZookeeperConfig {
@Bean
public CuratorFramework curatorFramework() {
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
CuratorFramework client = CuratorFrameworkFactory.newClient("localhost:2181", retryPolicy);
client.start();
return client;
}
@Bean
public InterProcessMutex interProcessMutex(CuratorFramework client) {
return new InterProcessMutex(client, "/lock");
}
}
- 使用InterProcessMutex获取锁和释放锁:
java
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class LockController {
@Autowired
private InterProcessMutex lock;
@GetMapping("/lock")
public String lock() {
try {
lock.acquire();
// 这里执行需要互斥的代码
return "Lock acquired";
} catch (Exception e) {
return "Error: " + e.getMessage();
} finally {
if (lock.isAcquiredInThisProcess()) {
lock.release();
}
}
}
}
互斥性+避免死锁
有序临时节点+监听机制,保证互斥性,并避免死锁
避免死锁
可重入性
为每一个获取锁的客户端维护一个可重入计数器,每次上锁时计数器+1,释放锁时计数器-1。
高可用行
性能
仅次于redis,但zookeeper能保证可靠性,可靠性zk>redis