文章目录
在构建分布式系统时, 分布式锁 是确保资源独占访问的关键工具。在高并发场景下,合理实现分布式锁能够有效避免资源竞争,从而提升系统的稳定性。本文将深入讲解如何在 Redis 中使用
setnx
命令以及其他策略来构建一个可靠的分布式锁解决方案,并探讨如何处理可能的并发问题和故障场景。
1. 基于 setnx
实现基本的分布式锁
Redis 提供的 setnx
(set if not exists
)命令可以帮助我们实现最基本的分布式锁。该命令仅在指定的键不存在时进行设置,确保多个客户端不会同时获得锁。
基本实现
以下是通过 setnx
实现分布式锁的代码示例:
@Override
public void testLock() {
// 1. 尝试获取锁
Boolean ifAbsent = redisTemplate.opsForValue().setIfAbsent("lock", "lock");
// 2. 判断是否成功获取到锁
if (ifAbsent) {
// 3. 从 Redis 中获取当前的数值
String value = redisTemplate.opsForValue().get("num");
if (StringUtils.isBlank(value)) {
return;
}
// 4. 将值加一并存回 Redis
int num = Integer.parseInt(value);
redisTemplate.opsForValue().set("num", String.valueOf(++num));
// 5. 释放锁
redisTemplate.delete("lock");
} else {
// 未获取到锁,稍后重试
try {
Thread.sleep(100);
this.testLock(); // 递归调用重试获取锁
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
问题分析
这种简单的实现有一个严重问题:如果在业务逻辑执行过程中出现异常或者系统崩溃,锁将不会被释放,导致 死锁。为了避免这种情况,需要为锁设置一个 过期时间,确保锁能够自动释放。
2. 为锁设置过期时间
为了解决锁无法释放的问题,我们可以在设置锁时同时指定一个过期时间,确保即使发生故障,锁也会在一段时间后自动释放。
// 设置锁并指定过期时间
Boolean ifAbsent = redisTemplate.opsForValue()
.setIfAbsent("lock", "lock", 10, TimeUnit.SECONDS);
这样一来,锁会在 10 秒后自动过期,避免了因异常导致的死锁问题。
3. 使用 UUID 防止锁误删
虽然设置了过期时间可以防止锁永远不被释放,但仍然可能存在 锁误删 的问题。假设一个请求持有锁并执行耗时操作,超过了锁的过期时间,锁被自动释放并且另一个请求成功获取了锁。如果此时第一个请求继续执行并在完成后删除锁,将导致第二个请求的锁被误删。
使用唯一标识防止误删
为了解决这个问题,可以在设置锁时为锁的值设置一个 唯一标识(如 UUID),并在释放锁时通过校验该标识符是否匹配来确保锁只会被正确的客户端释放:
@Override
public void testLock() {
String uuid = UUID.randomUUID().toString();
Boolean ifAbsent = redisTemplate.opsForValue()
.setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS);
if (ifAbsent) {
String value = redisTemplate.opsForValue().get("num");
if (StringUtils.isBlank(value)) {
return;
}
int num = Integer.parseInt(value);
redisTemplate.opsForValue().set("num", String.valueOf(++num));
// 释放锁时,先检查锁的标识符是否匹配
String redisUuid = redisTemplate.opsForValue().get("lock");
if (uuid.equals(redisUuid)) {
redisTemplate.delete("lock");
}
} else {
try {
Thread.sleep(100);
this.testLock(); // 递归重试
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
4. 使用 LUA 脚本确保操作的原子性
尽管 UUID 防止了锁的误删问题,但获取锁和释放锁的操作本质上是两个独立的步骤,在高并发环境下可能并不具备 原子性。为了解决这个问题,我们可以使用 Redis 的 LUA脚本,将获取和释放锁的操作合并为一个原子操作。
LUA 脚本允许我们在 Redis 中执行一系列指令,而不被其他操作打断,确保操作的完整性。
@Override
public void testLock() {
String uuid = UUID.randomUUID().toString();
Boolean ifAbsent = redisTemplate.opsForValue()
.setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS);
if (ifAbsent) {
String value = redisTemplate.opsForValue().get("num");
if (StringUtils.isBlank(value)) {
return;
}
int num = Integer.parseInt(value);
redisTemplate.opsForValue().set("num", String.valueOf(++num));
// 使用 LUA 脚本来释放锁,确保操作的原子性
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
String script = "if redis.call(\"get\",KEYS[1]) == ARGV[1] then return redis.call(\"del\",KEYS[1]) else return 0 end";
redisScript.setScriptText(script);
redisScript.setResultType(Long.class);
redisTemplate.execute(redisScript, Arrays.asList("lock"), uuid);
} else {
try {
Thread.sleep(100);
this.testLock(); // 递归重试
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
通过这种方式,我们确保了释放锁操作的原子性,避免了并发条件下可能出现的竞态问题。
5. 总结与最佳实践
通过 Redis 实现分布式锁是解决资源竞争问题的有效手段。在实际应用中,我们需要确保锁的实现满足以下几个关键条件:
- 互斥性:任何时刻,只有一个客户端能持有锁,确保资源独占访问。
- 死锁预防:通过设置过期时间,确保锁即使因客户端异常也能被释放。
- 解锁准确性:加锁和解锁必须由同一个客户端完成,防止误删他人的锁。
- 操作原子性:通过 LUA 脚本将获取锁和释放锁的逻辑封装为原子操作,避免竞态条件。
通过 setnx
命令配合 过期时间、UUID 标识 以及 LUA 脚本,我们可以实现一个高效可靠的分布式锁机制,适用于多种高并发场景。使用这些策略,能够有效避免资源竞争,确保系统在分布式环境中的稳定性。地展示 Redis 分布式锁的实现方法,同时帮助开发者更好地掌握并应用这些技术。