在当今这个微服务架构盛行的时代,分布式系统中的并发控制显得尤y为重要。当多个服务实例需要同时访问共享资源时,如何保证数据的一致性和操作的原子性,就成了一个亟待解决的难题。分布式锁便是应对这一挑战的有效工具之一。本文将深入探讨如何利用 Redis 的 SETNX 命令,并结合 Spring Boot的一份分布式锁实现指南。
什么是分布式锁?
在单体应用中,我们通常使用 synchronized 关键字或 ReentrantLock 等工具来控制多线程对共享资源的访问。然而,在分布式环境下,服务被部署在不同的 JVM 实例中,这些传统的锁机制便失去了效力。分布式锁是一种跨进程、跨机器的同步机制,它能够确保在分布式系统中的多个节点或进程中,只有一个能够访问特定的共享资源。
Redis 因其高性能、单线程的原子操作特性,成为了实现分布式锁的热门选择。
Redis SETNX 命令:分布式锁的基石
SETNX 是 "SET if Not eXists" 的缩写。 SETNX key value 命令的含义是,当且仅当键 key 不存在时,才会将 key 的值设置为 value。如果 key 已存在,则 SETNX 不执行任何操作。这一特性使得 SETNX 天然适合用来实现锁的获取功能:
- 获取锁:客户端尝试使用
SETNX命令在 Redis 中设置一个特定的键。如果命令返回 1,表示键创建成功,客户端成功获取锁。 - 锁的占用:如果另一个客户端此时也尝试使用
SETNX设置同一个键,命令将返回 0,表示键已存在,获取锁失败。
基于 Spring Boot 的分布式锁实现
现在,让我们通过一个 Spring Boot 项目,一步步地实现基于 Redis SETNX 的分布式锁。
1. 添加依赖
首先,在您的 pom.xml 文件中添加 Spring Data Redis 的依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2. 配置 Redis 连接
在 application.properties 或 application.yml 文件中配置 Redis 的连接信息:
spring:
redis:
host: localhost
port: 6379
3. 编写分布式锁服务
接下来,我们将创建一个 DistributedLockService 类来封装锁的获取和释放逻辑。
一个基础但有缺陷的实现:
一个简单的想法是直接使用 SETNX 和 DEL 命令:
// 这是一个有缺陷的实现,请勿直接在生产环境使用
@Service
public class SimpleDistributedLock {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String LOCK_KEY = "my_distributed_lock";
public boolean acquireLock() {
return redisTemplate.opsForValue().setIfAbsent(LOCK_KEY, "locked");
}
public void releaseLock() {
redisTemplate.delete(LOCK_KEY);
}
}
为什么说这个实现有缺陷?
这个简单的实现存在一个致命的问题:死锁。如果一个客户端在获取锁之后,业务逻辑执行期间发生宕机,那么它将永远无法执行 releaseLock() 方法。这个锁将一直被占用,导致其他客户端永远无法获取锁。
4. 引入过期时间防止死锁
为了解决死锁问题,我们必须为锁设置一个过期时间(TTL)。 这样,即使持有锁的客户端崩溃,锁也会在一段时间后自动释放。
改进方案:SETNX + EXPIRE
// 这是一个有缺陷的实现,请勿直接在生产环境使用
public boolean acquireLockWithTimeout(long expireTime, TimeUnit timeUnit) {
Boolean success = redisTemplate.opsForValue().setIfAbsent(LOCK_KEY, "locked");
if (Boolean.TRUE.equals(success)) {
redisTemplate.expire(LOCK_KEY, expireTime, timeUnit);
return true;
}
return false;
}
新的问题:非原子性操作
这种改进虽然引入了过期时间,但 setIfAbsent 和 expire 是两个独立的操作,它们之间不是原子的。如果在执行完 setIfAbsent 后,在执行 expire 之前,客户端发生宕机,死锁问题依然会发生。
正确的原子操作姿势
幸运的是,Redis 提供了可以将设置值和设置过期时间合并为一步的原子操作。我们可以使用 StringRedisTemplate 的 set 方法,并传入额外的参数来实现:
@Service
public class RedisLockService {
@Autowired
private StringRedisTemplate redisTemplate;
public boolean acquireLock(String lockKey, String lockValue, long expireTime, TimeUnit timeUnit) {
return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, expireTime, timeUnit));
}
}
5. 确保锁的归属与安全释放
解决了死锁问题后,我们还需要考虑另一个问题:锁的误删。
想象一个场景:
- 客户端 A 获取了锁,过期时间为 30 秒。
- 客户端 A 的业务逻辑执行时间超过了 30 秒,锁自动过期被释放。
- 客户端 B 此时获取了同一个锁。
- 客户端 A 的业务逻辑执行完毕,执行
releaseLock()操作,结果把客户端 B 的锁给释放了。
为了避免这种情况,我们必须在锁的值中存入一个唯一的标识(例如 UUID),在释放锁时进行校验。
安全的锁释放逻辑
释放锁的操作需要包含“获取锁的值”和“删除锁”两个步骤,为了保证原子性,我们通常使用 Lua 脚本 来实现。
@Service
public class DistributedLockService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String RELEASE_LOCK_LUA_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
/**
* 获取锁
* @param lockKey 锁的键
* @param lockValue 锁的值,必须唯一
* @param expireTime 过期时间
* @param timeUnit 时间单位
* @return 是否获取成功
*/
public boolean acquireLock(String lockKey, String lockValue, long expireTime, TimeUnit timeUnit) {
return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, expireTime, timeUnit));
}
/**
* 释放锁
* @param lockKey 锁的键
* @param lockValue 锁的值,必须和获取锁时一致
* @return 是否释放成功
*/
public boolean releaseLock(String lockKey, String lockValue) {
DefaultRedisScript redisScript = new DefaultRedisScript(RELEASE_LOCK_LUA_SCRIPT, Long.class);
Object result = redisTemplate.execute(redisScript, Collections.singletonList(lockKey), lockValue);
return Long.valueOf(1L).equals(result);
}
}
6. 使用示例
现在,我们可以在业务代码中使用我们创建的分布式锁服务了。
@RestController
public class TestController {
@Autowired
private DistributedLockService lockService;
@GetMapping("/do-something")
public String doSomething() {
String lockKey = "my_task_lock";
String lockValue = UUID.randomUUID().toString(); // 确保每次请求的锁值唯一
if (lockService.acquireLock(lockKey, lockValue, 30, TimeUnit.SECONDS)) {
try {
// 执行需要同步的业务逻辑
System.out.println("执行业务逻辑...");
Thread.sleep(10000); // 模拟业务执行
return "业务执行完成";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "业务执行被中断";
} finally {
lockService.releaseLock(lockKey, lockValue);
}
} else {
return "未能获取到锁,请稍后再试";
}
}
}
总结与最佳实践
通过上述步骤,我们成功地基于 Spring Boot 和 Redis 的 SETNX 实现了一个相对健壮的分布式锁。在实际应用中,还应遵循以下最佳实践:
- 锁的粒度:尽量减小锁的粒度,只对必要的代码块进行加锁,以提高系统的并发能力。
- 合理的过期时间:锁的过期时间需要根据业务场景仔细评估,不宜过长或过短。
- 失败重试:获取锁失败后,可以根据业务需求引入重试机制,但要注意避免造成过大的系统压力。
- 使用成熟的开源库:对于复杂的业务场景或对可用性要求极高的系统,可以考虑使用像 Redisson 这样功能更完善、经过生产环境检验的开源库。 Redisson 提供了可重入锁、公平锁、读写锁等多种复杂的分布式锁实现。
希望这篇博客能帮助您更好地理解和应用基于 Redis 的分布式锁。在构建分布式系统时,正确地使用锁机制是保障系统稳定性和数据一致性的关键一步。