Redis实现分布式可重入锁——CAS操作

文章介绍了如何利用Redis实现可重入的分布式锁,参考了Java的ReentrantLock,提出基于Redis事务和Lua脚本的两种解决方案。通过Redis事务的WATCH命令监控key变化,或者使用Lua脚本确保原子性操作,以解决并发问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一、前言

Redis实现的分布式锁被大家广泛用于解决在分布式环境下的并发问题——使用set NX EX,当某一个key存在时,返回失败,当key不存在时,设置新值和过期时间,返回成功。

那么如何通过Redis实现一个可重入的分布式锁呢?

二、解决思路

我们可以参考一下Java中ReentrantLock的实现,在持有锁时记录下线程信息,获取锁时检查线程id是否相同,那么在Redis中也可参考相同实现:

  • 1、获取锁信息;
  • 2、比较持有线程ID;
  • 3、更新锁信息。

此时就出现了并发问题,需要先比较在更新,Redis并未提供CAS原子性命令,需要借助Redis的其它特性解决,下面为大家介绍两种方法。

三、基于Redis事务

1、简单介绍

redis支持了简单的事务,提供了以下几个命令:

  • WATCH:监控某些键值对;
  • MULTI:用于开启一个事务;
  • EXEC:执行事务;
  • DISCARD:取消事务;
  • UNWATCH:取消监控。

通过watch命令,实现对某些Key的监听,当一个事务提交时,会优先检测监听的key是否发生改变,如果已发生改变,取消事务,若并未改变,执行事务。

2、jedis实现

public boolean tryLock(String key, int timeout, String threadId){
		Transaction multi = null;
         try {
             // 监控key
             jedis.watch(key);
             // 获取锁信息
             String lock = jedis.get(key);
             // 已持有锁且不是当前线程,获取锁失败
             if (StringUtils.isNotEmpty(lock) && !lock.equals(threadId)) {
                 return false;
             }
             // 开启事务
             multi = jedis.multi();
             // 添加命令
             multi.setex(key, timeout, threadId);
             // 执行事务
             multi.exec();
             return true;
         } catch (Exception e) {
             if (Objects.nonNull(multi)) {
                 multi.discard();
             }
             return false;
         } finally {
             jedis.unwatch();
         }
}

注意:

  • redis提供的事务,中间一条命令执行失败,并不会导致前面已经执行的指令回滚,也不会造成后续的指令不做
  • WATCH监视了一个带过期时间的键,那么即使这个键过期了,事务仍然可以正常执行;
  • WATCH机制不存在ABA问题。

3、WATCH机制原理

redis中存在一个字典,用于保存所有被监视的key和其对应监视的客户端列表,字典的键是被监视的key,而值则是监视其的客户端链表。
在这里插入图片描述

  • 当某一个客户端通过watch添加对key的监视时,会在字典中检索出对应的链表,将其添加到末尾,保存下监视状态;
  • 当redis对任何key执行修改命令之后,都会检查当前key是否在watch字典中处于被监视状态,若存在,则将监视其的客户端节点中的状态标记为已修改;
  • 当客户端提交事务时,通过查询watch字典中监视的key,其对应的客户端节点状态是否修改,决定是否需要执行事务。

四、基于LUA

1、简单介绍

Lua是一种小巧的脚本语言,redis提供了对lua脚本执行的能力。通过将多个请求通过脚本的形式一次发送,redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。

  • EVAL
EVAL script numkeys key [key …] arg [arg …]

向redis发送具体的脚本内容和参数,完成脚本的执行。

  • SCRIPTLOCAD 和 EVALSHA
// 预加载脚本,返回其对应的sha1值
SCRIPTLOCAD script
// 提交脚本对应的sha1值和参数,执行脚本
EVALSHA sha1 numkeys key [key …] arg [arg …]

redis具有缓存能力,提前将脚本语句在redis缓存,后续只需发送脚本对应的sha1值,有效的减少带宽的消耗。

2、jedis实现

public class QueueStateOperateClient {
    private static final Object LOCK_OBJECT = new Object();

    private static volatile String lockSha;

    private static volatile String unLockSha;

    public static Response lock(Pipeline pipeline, String key) {
    	// 脚本检查
        preCheck();
        return pipeline.evalsha(lockSha, 2, new String[]{setSuffix(key), setSuffix(QueueConstant.LOCAL_HOST)});
    }

    public static Response unlock(Pipeline pipeline, String key) {
        preCheck();
        return pipeline.evalsha(unLockSha, 2, new String[]{setSuffix(key), setSuffix(QueueConstant.LOCAL_HOST)});
    }

    public static void preCheck() {
    	// 脚本是否已经加载
        if (StringUtils.isNotBlank(lockSha) && StringUtils.isNotBlank(unLockSha)) {
            return;
        }
        synchronized (LOCK_OBJECT) {
            if (StringUtils.isNotBlank(lockSha) && StringUtils.isNotBlank(unLockSha)) {
                return;
            }
            // 加载script
            SuishenRedisTemplate queueRedisTemplate = (SuishenRedisTemplate) SourceEventQueueManager
                    .getApplicationContext().getBean("queueRedisTemplate");
            new SuishenRedisExecutor<Boolean>().exe(jedis -> {
                lockSha = jedis.scriptLoad(getLockScript());
                unLockSha = jedis.scriptLoad(getUnLockScript());
                return true;
            }, queueRedisTemplate);
        }
    }

    private static String getLockScript() {
        return "local ip = redis.call(\"get\",KEYS[1]);" +
                "if (not ip) or ip==KEYS[2] " +
                "then " +
                "   return redis.call(\"setex\",KEYS[1],10,KEYS[2]);" +
                "else " +
                "   return \"FAIL\"" +
                "end ";
    }

    private static String getUnLockScript() {
        return "local ip = redis.call(\"get\",KEYS[1]);" +
                "if ip and ip==KEYS[2] " +
                "then " +
                "  redis.call(\"del\",KEYS[1]);" +
                "end " +
                "return \"OK\"";
    }

    /**
     * 集群redis设置分片规则
     *
     * @param key
     * @return
     */
    private static String setSuffix(String key) {
        return key + "{queue}";
    }
}

3、扩展知识

a、分布式redis

对于分布式redis,执行lua时,需要保证所有的key均在同一分片下才可正确的执行,当key中存在{}时,分布式redis只会对{}中的字符进行分片规则计算,通过这种方式,可以保证不同的key均在同一分片下。

b、redis.clients.jedis.exceptions.JedisDataException: NOSCRIPT No matching script. Please use EVAL异常

使用EVALSHA时,如果当前sha1对应的脚本在redis中不存在时,会抛出此异常,常见的场景:

  • redis因为某种原因重启;
  • 分布式redis出现扩容,导致新的节点未缓存脚本;

当发现此异常时,需要重新SCRIPTLOAD脚本,加入redis缓存。

### Redis 分布式源码实现分析 #### 1. 分布式的概念 分布式是一种用于控制分布式系统中资源访问的机制,确保同一时间只有一个客户端能够操作共享资源。这种通常依赖于集中式的存储服务来实现一致性[^1]。 --- #### 2. 基于 Redis分布式实现原理 Redis 提供了单线程执行命令的能力以及丰富的数据结构支持,因此非常适合用来实现分布式。以下是基于 Redis 实现分布式的核心要点: - **唯一标识符 (UUID)** 客户端在尝试获取时,会生成一个唯一的 UUID 并将其作为值存入 Redis 中。这样可以区分不同的客户端请求并防止误删其他客户端持有的[^5]。 - **SETNX 和 EXPIRE 结合** 使用 `SETNX` 命令设置键值对,只有当键不存在时才会成功创建;同时配合 `EXPIRE` 设置过期时间,避免死的发生。 - **Lua 脚本保证原子性** Redis 支持通过 Lua 脚本来批量执行多条命令,并且这些命令会在服务器内部一次性完成,从而保证了多个操作之间的原子性[^4]。 --- #### 3. Redisson 实现分布式的源码解析 Redisson 是一种流行的 Java 库,提供了多种高级功能,其中包括强大的分布式实现。其核心逻辑如下所示: ##### (1)初始化对象 (`RedissonLock`) ```java public class RedissonLock extends RedissonExpirable implements RLock { private final String lockName; public RedissonLock(String name) { this.lockName = name; } } ``` 上述代码片段展示了如何定义一个分布式实例。每个都有一个对应的名称字符串,在实际应用过程中可以通过该名称定位具体的资源[^2]。 ##### (2)获取 (`tryLock`) 方法 ```java @Override public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException { Long threadId = Thread.currentThread().getId(); RBucket<Long> bucket = getBucket(lockName); while (!bucket.trySet(threadId)) { // 尝试设置当前线程ID至指定key位置上 if (waitTime <= 0L || System.currentTimeMillis() >= endTimeNanos()) break; // 如果超时则退出循环 LockSupport.parkNanos(UNIT.toNanos(sleepInterval)); } setExpireAsync(bucket.getName(), leaseTime); // 成功加后立即设定租约期限 return true; } ``` 此方法实现了带等待时间和自动解特性的可重入行为。它允许调用方自定义最大阻塞时限与定持续周期参数。值得注意的是,这里采用了乐观策略并通过 CAS(Compare And Swap)算法保障并发安全性[^3]。 ##### (3)释放过程中的校验机制 为了防止因网络分区等原因造成非法删除他人所拥有的情况发生,Redisson 在解除之前都会再次确认目标确实由当前进程占有才行。 ```java if ("${threadId}".equals(redis.get("${lockKey}"))) { redis.del("${lockKey}"); } ``` 这段伪代码体现了释放前的身份验证环节的重要性。 --- #### 4. 高可用场景下的 RedLock 算法扩展 对于高可用需求较高的业务环境来说,仅仅依靠单一节点上的 Redis 可能无法满足可靠性要求。为此提出了 RedLock 方案——即跨多个独立运行的 Redis 实例构建全局一致性的互斥信号量体系架构。 具体流程包括但不限于以下几个方面: 1. 获取所有候选 Redis Server 列表; 2. 对每一个 server 执行标准 SETNX 加动作; 3. 记录整个流程耗时 T_total 和有效响应数量 N_success; 4. 当且仅当超过半数以上 servers 返回 TRUE 同意授予权限时才算真正取得所有权; 5. 总体有效期取最小剩余存活秒数减去一定缓冲区间后的结果作为最终生效时间段长度。 --- ### 注意事项 尽管 Redis 自身具备高性能特点,但在某些极端条件下仍可能出现异常状况比如主从切换期间短暂丢失持久化状态等问题。所以建议开发者们结合实际情况权衡利弊后再决定是否采用此类解决方案。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值