Press "Enter" to skip to content

使用 Redis SETNX 实现分布式锁

在当今这个微服务架构盛行的时代,分布式系统中的并发控制显得尤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.propertiesapplication.yml 文件中配置 Redis 的连接信息:

spring:
  redis:
    host: localhost
    port: 6379

3. 编写分布式锁服务

接下来,我们将创建一个 DistributedLockService 类来封装锁的获取和释放逻辑。

一个基础但有缺陷的实现:

一个简单的想法是直接使用 SETNXDEL 命令:

// 这是一个有缺陷的实现,请勿直接在生产环境使用
@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;
}

新的问题:非原子性操作

这种改进虽然引入了过期时间,但 setIfAbsentexpire 是两个独立的操作,它们之间不是原子的。如果在执行完 setIfAbsent 后,在执行 expire 之前,客户端发生宕机,死锁问题依然会发生。

正确的原子操作姿势

幸运的是,Redis 提供了可以将设置值和设置过期时间合并为一步的原子操作。我们可以使用 StringRedisTemplateset 方法,并传入额外的参数来实现:

@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. 确保锁的归属与安全释放

解决了死锁问题后,我们还需要考虑另一个问题:锁的误删

想象一个场景:

  1. 客户端 A 获取了锁,过期时间为 30 秒。
  2. 客户端 A 的业务逻辑执行时间超过了 30 秒,锁自动过期被释放。
  3. 客户端 B 此时获取了同一个锁。
  4. 客户端 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 的分布式锁。在构建分布式系统时,正确地使用锁机制是保障系统稳定性和数据一致性的关键一步。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注