基于Redis的分布式锁
关于分布式锁的原理解析请查看:深入理解分布式锁
1 实现原理
利用 Redis 的原子操作特性
- Redis 是单线程处理命令的数据库,这使得它的一些操作具有原子性。在分布式锁的实现中,主要利用
SET
命令的原子性来实现锁的获取。 - 例如,使用
SET key value NX PX milliseconds
命令,其中NX
(Not eXists)参数表示只有当键key
不存在时才设置成功,PX
参数用于设置键的过期时间(以毫秒为单位)。这就保证了在多个客户端同时请求获取锁时,只有一个客户端能够成功设置键值对,从而实现了互斥性。
设置过期时间避免死锁
- 为了防止客户端在获取锁之后由于某种原因(如进程崩溃、网络故障等)无法释放锁,导致其他客户端永远无法获取锁的情况(死锁),在获取锁时会为锁设置一个过期时间。
- 当锁过期后,Redis 会自动删除这个键值对,使得其他客户端有机会获取锁。过期时间的合理设置非常重要,需要根据业务逻辑的执行时间来确定,一般要保证业务逻辑能够在过期时间内完成。
通过唯一标识验证锁的归属
- 每个客户端在获取锁时会生成一个唯一标识(如使用
UUID
)作为value
存储在 Redis 中。在释放锁时,需要验证当前锁对应的value
是否与自己当初设置的一致,只有一致时才能释放锁。 - 这是因为在分布式环境中,可能会出现锁过期后被其他客户端重新获取的情况,如果不进行验证,可能会导致一个客户端误删其他客户端获取的锁。
2 Redis分布式锁代码实现
通过 set key value px milliseconds nx
命令实现加锁, 通过Lua脚本实现解锁。
//获取锁(unique_value可以是UUID等)
SET resource_name unique_value NX PX 30000
//释放锁(lua脚本中,一定要比较value,防止误解锁)
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
- set 命令要用
set key value px milliseconds nx
,替代setnx + expire
需要分两次执行命令的方式,保证了原子性, - value 要具有唯一性,可以使用
UUID.randomUUID().toString()
方法生成,用来标识这把锁是属于哪个请求加的,在解锁的时候就可以有依据; - 释放锁时要验证 value 值,防止误解锁;
- 通过 Lua 脚本来避免 Check And Set 模型的并发问题,因为在释放锁的时候因为涉及到多个Redis操作 (利用了eval命令执行Lua脚本的原子性);
依赖
首先,我们需要引入Redis的客户端依赖。这里以Spring Data Redis为例:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
工具类
我们可以创建一个Redis工具类来封装锁的操作:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
public class RedisLock {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String LOCK_KEY_PREFIX = "lock:";
private static final String UNLOCK_LUA_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
public boolean tryLock(String lockKey, String requestId, long expireTime, TimeUnit timeUnit) {
String result = redisTemplate.opsForValue().setIfAbsent(LOCK_KEY_PREFIX + lockKey, requestId, expireTime, timeUnit);
return result != null;
}
public boolean unlock(String lockKey, String requestId) {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(UNLOCK_LUA_SCRIPT);
redisScript.setResultType(Long.class);
return redisTemplate.execute(redisScript, Collections.singletonList(LOCK_KEY_PREFIX + lockKey), requestId) == 1L;
}
}
锁的获取和释放
使用上述工具类,我们可以轻松地获取和释放锁:
public class RedisLockExample {
@Autowired
private RedisLock redisLock;
public void someMethod() {
String lockKey = "someLockKey";
String requestId = UUID.randomUUID().toString();
boolean locked = redisLock.tryLock(lockKey, requestId, 10, TimeUnit.SECONDS);
if (locked) {
try {
// 执行需要同步的代码
} finally {
redisLock.unlock(lockKey, requestId);
}
} else {
// 获取锁失败,执行其他逻辑
}
}
}
3 Redisson锁的续期
当使用上述的实现方法时,如果获取锁后,【业务没执行完,锁过期释放】,此时该如何解决?Redisson解决了这个问题。
3.1 Redisson原理
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还实现了可重入锁(Reentrant Lock)、公平锁(Fair Lock、联锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)等,还提供了许多分布式服务。
Redisson的分布式锁续期功能主要依赖于其内置的Watch Dog(看门狗)机制。
Watch Dog机制
当一个线程获取锁后,Redisson会启动一个定时任务(Watch Dog),该任务会定期延长锁的过期时间。这样做的好处是,即使业务处理时间超过了锁的初始过期时间,锁也不会被意外释放,因为Watch Dog会不断地更新锁的过期时间。
Redisson可重入锁加锁/释放锁底层原理图:
3.2 Redisson实现分布式锁的续期
要在Redisson中实现分布式锁的续期,你通常不需要做额外的编码工作。只需在获取锁时不要显式指定一个固定的超时时间即可,因为这样做会禁用Watch Dog机制。
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.18.1</version>
</dependency>
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import java.util.concurrent.TimeUnit;
public class RedissonLockRenewalDetailedExample {
public static void main(String[] args) {
// 1. 创建Redisson配置和客户端
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);
// 2. 获取分布式锁
RLock lock = redisson.getLock("myDistributedLock");
try {
// 3. 尝试获取锁,不传递超时时间参数以启用Watch Dog机制
// 注意:如果传递了超时时间参数,将会禁用Watch Dog机制
lock.lock();
// 4. 执行业务逻辑
System.out.println("Lock acquired, executing business logic...");
// 模拟长时间的业务处理
TimeUnit.SECONDS.sleep(90); // 假设业务处理需要90秒
// 5. 在业务处理期间,Watch Dog会自动续期锁,无需手动操作
// 但是,如果业务处理时间非常长(例如,由于某种原因导致的线程挂起),
// 仍然可能导致其他线程长时间无法获取锁。因此,建议设置合理的业务处理时间。
} catch (InterruptedException e) {
// 处理线程中断异常
Thread.currentThread().interrupt();
e.printStackTrace();
} finally {
// 6. 释放锁
lock.unlock();
// 7. 关闭Redisson客户端(在实际应用中,通常会在应用程序的生命周期结束时关闭)
redisson.shutdown();
}
}
}
注意事项
-
避免死锁:虽然Watch Dog机制能够自动续期锁,但如果锁的持有者长时间无法释放锁(例如,由于线程挂起或死循环),仍然可能导致其他线程长时间无法获取锁。因此,建议设置合理的业务处理时间和锁的超时时间(虽然在这个例子中我们没有显式设置超时时间以启用Watch Dog,但在某些情况下你可能需要这样做)。
-
性能考虑:Watch Dog机制会定期延长锁的过期时间,这可能会引入一些额外的性能开销。在高性能要求的场景中,需要权衡锁的续期时间和系统的性能。
-
正确释放锁:确保在业务处理完毕后正确释放锁,以避免资源泄露和潜在的死锁问题。在上面的示例中,我们在
finally
块中释放了锁,以确保即使在发生异常时也能正确释放锁。 -
Redisson配置:根据实际需求调整Redisson的配置,例如使用集群模式而不是单节点模式,以提高系统的可用性和容错性。
风险分析:
Redis通常以Cluster集群的模式存在,上述RedissonLock如果存储锁对应key的那个节点挂了的话,就可能存在丢失锁的风险,导致出现多个客户端持有锁的情况,这样就不能实现资源的独享了。
例如:
- 客户端A从master获取到锁
- 在master将锁同步到slave之前,master宕掉了(Redis的主从同步通常是异步的)。 主从切换,slave节点被晋级为master节点
- 客户端B取得了同一个资源被客户端A已经获取到的另外一个锁。导致存在同一时刻存不止一个线程获取到锁的情况。
Redisson 提供了实现了redlock算法的 RedissonRedLock,RedissonRedLock 解决了单点失败的问题。
4 Redlock
在分布式系统中,为了实现可靠的分布式锁,仅仅依靠单个 Redis 节点可能会存在单点故障的风险。Redlock 算法的目的是在多个独立的 Redis 节点上实现分布式锁,以提高系统的可靠性和容错性。
4.1 Redlock算法原理
- 多数派原则:假设有 N 个完全独立的 Redis 节点(通常 N 为奇数,如 N = 5 或 N = 7),客户端在获取锁时需要向这 N 个节点发送获取锁的请求。只有当大多数(大于等于 N/2 + 1)的节点成功获取锁时,才认为获取锁成功。
- 锁的有效期和随机性:每个节点上的锁都有一个有效期(通过设置过期时间来实现,和普通的 Redis 分布式锁类似),并且每个客户端获取锁的请求在每个节点上都带有一个唯一的标识(如 UUID),用于区分不同客户端的锁请求。同时,为了防止多个客户端同时请求锁时出现竞争冲突,锁的获取过程需要尽量保证随机性。
- 故障恢复和容错性:当客户端认为自己获取了锁并开始执行业务逻辑后,如果部分 Redis 节点出现故障(如网络分区、节点崩溃等),只要在剩余的正常节点中,之前成功获取锁的节点仍然占多数,那么这个锁仍然被认为是有效的。当故障节点恢复后,它们不会影响已经获取的锁的状态。
4.2 使用 Redisson 实现 Redlock 获取锁与释放锁
创建Redisson Config
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.ClusterServersConfig;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfiguration {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
ClusterServersConfig clusterServersConfig = config.useClusterServers()
.addNodeAddress("redis://127.0.0.1:7000", "redis://127.0.0.1:7001", "redis://127.0.0.1:7002")
.setPassword("your_password"); // 如果Redis设置了密码,则需要添加此行
return Redisson.create(config);
}
}
创建RedissonRedLock
import org.redisson.RedissonRedLock;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Service
public class RedissonRedLockService {
@Autowired
private RedissonClient redissonClient;
public void executeWithRedLock() {
// 获取多个RLock实例
List<RLock> locks = Arrays.asList(
redissonClient.getLock("lock"),
redissonClient.getLock("lock"),
redissonClient.getLock("lock")
);
RLock[] redLocks = locks.toArray(new RLock[locks.size()]);
// 使用RedissonRedLock来组合这些锁
RedissonRedLock redissonRedLock = new RedissonRedLock(redLocks);
try {
// 尝试获取锁,等待时间100ms,上锁以后10秒自动解锁
boolean isLocked = redissonRedLock.tryLock(100, 10, TimeUnit.SECONDS);
if (isLocked) {
try {
// 执行你的业务逻辑
System.out.println("RedLock acquired, executing business logic...");
} finally {
// 释放锁
redissonRedLock.unlock();
}
} else {
System.out.println("Could not acquire RedLock.");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
e.printStackTrace();
}
}
}
4.3 RedissonRedLock风险分析
1. 时钟同步问题
Redlock 算法依赖于各个 Redis 节点的时间相对同步,因为在计算获取锁的总耗时以及判断是否超时等操作中,都涉及到锁的过期时间与实际耗时的比较。如果节点间时钟不同步,可能会导致误判,例如总耗时在实际中未超过合理范围,但由于某个节点时间偏差较大,计算后认为超时了,从而错误地释放锁或者无法获取锁。所以在生产环境中,要确保 Redis 节点间的时钟通过网络时间协议(NTP)等方式尽量精准同步。
2. 节点故障处理
当部分 Redis 节点出现故障(如网络故障、节点崩溃等)时,虽然算法基于多数派原则可以容忍一定数量的节点故障,但需要考虑故障节点恢复后的情况,比如恢复后的节点上可能残留之前的锁信息等问题,可能需要额外的清理机制或者重新同步逻辑来保证分布式锁系统的正常运行。
3. 性能影响
由于 Redlock 算法需要与多个 Redis 节点进行交互,相比于基于单个 Redis 节点的分布式锁实现,它的性能开销会更大,会带来更高的网络延迟以及资源消耗。在实际应用中,需要根据业务场景对可靠性和性能的权衡来决定是否使用 Redlock 算法,如果对性能要求极高且能接受一定的单点故障风险,可能不太适合使用;但如果对系统可靠性、数据一致性要求非常高的关键业务场景,通过优化网络、硬件等资源来缓解性能压力后,Redlock 算法能提供更可靠的分布式锁解决方案。
总之,Redlock解决了Redis分布式锁的高可用性,但并没有保证锁的正确性。