【redis实战篇】第五天

摘要:

        本文介绍了基于Redis实现分布式锁的多种方案及其优化策略。主要内容包括:1) 使用Redis的SETNX命令实现基础分布式锁,通过线程ID标识解决误删问题,并采用Lua脚本保证原子性;2) Redisson重入锁的实现原理,利用Hash结构记录重入次数;

        3) Redisson的锁重试机制,通过订阅/发布模式优化性能;4) 自动续期机制(看门狗)保障长任务执行;5) Redis集群环境下的分布式锁实现,重点说明多节点锁获取与时间同步策略。文章详细阐述了各种方案的实现细节和源码分析,为分布式系统开发提供了可靠的锁解决方案。

一,基于redis实现分布式锁

1,常见的分布式锁方案

(1)mysql数据库锁

(2)redis锁(setnx)

(3)zookeeper组件

2,redis实现分布式锁原理

set lock thread1 nx ex 20(过期时间)        #获取锁
del lock        #释放锁

3,redis分布式锁初级实现

(1)定义ILock接口及其实现类SimpleRedisLock,实现tryLock(),unLock()方法。

(2)以线程id拼接前缀作为value,锁前缀lock:+业务名作为key(指定锁范围)

    @Override
    public boolean tryLock(long timeoutSec) {
        String threadId = ID_PREFIX + Thread.currentThread().threadId();
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        //如果直接返回success(万一为null),因为自动拆箱,可能会出现空指针异常
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unLock() {
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }

4,redis分布式锁优化,解决误删问题

因为业务功能复杂,线程1阻塞时间较长,超过了过期时间,锁被自动释放而被其他线程拿到,等到第一个线程完成删除其他线程的锁的情况。

解决方案:在删除锁前增加判断,通过线程标识来对比确认当前锁是否属于当前线程,如果是才会执行删除操作 

    private static final String ID_PREFIX = UUID.randomUUID().toString(true)+"-";
    
    @Override
    public void unLock() {
        String currentThreadId = ID_PREFIX + Thread.currentThread().threadId();
        //判断是否是当前线程,避免误删
        String lockThreadId = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        //如果当前线程持有锁,则释放锁
        if (currentThreadId.equals(lockThreadId)) {
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }

5,lua脚本保证释放锁的原子性

线程在释放锁时通过了线程标识的判断,但是在最后一步删除操作前发生阻塞,阻塞时间超过设置的过期时间,锁被自动释放导致其他线程拿到锁,原来线程苏醒删除锁的情况。

解决方案:问题出现在线程判断和删除这一系列操作无法保证原子性,所以采用lua脚本来原子地执行这个比较和删除逻辑。

(1)java的stringRedisTemplate调用lua文件

    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        //可以通过构造函数传入文件字符串
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        //采用spring的读取文件资源的ClassPathResource
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }
    
    //基于Lua脚本实现原子操作
    @Override
    public void unLock() {
        //调用lua脚本,本质就是redis的Eval命令
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + Thread.currentThread().threadId());
    }

(2)lua文件编写比较删除逻辑(KEYS,ARGV都是不定参数)

if(redis.call('get', KEYS[1]) == ARGV[1]) then
    ---释放锁
    return redis.call('del', KEYS[1])
end
return 0

二,redisson配置

1,配置依赖

<dependency>
   <groupId>org.redisson</groupId>
   <artifactId>redisson</artifactId>
   <version>3.15.6</version>
</dependency>

2,定义配置类注册RedissonClient

@Configuration
public class RedisConfig {

    @Bean
    public RedissonClient redisClient(){
        //配置
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.xxx.xxx:6379")
            .setPassword("xxxxx").setDatabase(1);
        //创建RedissonClient对象
        return Redisson.create(config);
    }
}

3,注入RedissonClient,调用getLock()方法拿到锁

RLock lock = redissonClient.getLock(LOCK_ORDER_KEY + userId);
boolean isLock = lock.tryLock();
............
lock.unlock();

 

三,redisson重入锁原理(使用redis的Hash类型实现)(源码)

key--锁的名字,field--线程标识,value--线程重入次数,通过lua脚本保证redis操作原子性

1,tryLock() 获取锁

tryLock底层KEYS[1]--key         ARGV[1]--过期时间          ARGV[2]--field(线程id)

(1)判断锁是否存在,如果不存在,创建一个Hash并且使value增加1然后设置有效期,返回null

(2)如果锁已经存在,通过线程标识看锁是否是当前线程的,如果是,同样使value增加1,更新有效期,最后返回空

(3)如果不是当前线程,说明锁已经被占用,返回锁的剩余有效期

"if (redis.call('exists', KEYS[1]) == 0) then " +
     "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
     "redis.call('pexpire', KEYS[1], ARGV[1]); " +
     "return nil; " +
     "end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
     "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
     "redis.call('pexpire', KEYS[1], ARGV[1]); " +
     "return nil; " +
     "end; " +
     "return redis.call('pttl', KEYS[1]);"

2,unLock()释放锁

unlock底层KEYS[1]--key     ARGV[1]--消息      ARGV[2]--过期时间     ARGV[3]--线程id

(1)判断锁是否属于当前线程,不属于返回空

(2)如果属于就把锁的value即重入次数减1,并且记录value的值

(3)如果值还大于0,说明线程还持有锁,重置有效期

(4)否则说明线程已释放锁,那么删除key并且发布一个消息

"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
     "return nil;" +
     "end; " +
     "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
     "if (counter > 0) then " +
     "redis.call('pexpire', KEYS[1], ARGV[2]); " +
     "return 0; " +
     "else " +
     "redis.call('del', KEYS[1]); " +
     "redis.call('publish', KEYS[2], ARGV[1]); " +
     "return 1; " +
     "end; " +
     "return nil;",

四,redisson重试机制(源码)

getLock()可传入三个参数,分别是最大尝试时间(重试的最大时间)、过期时间和时间单位

(1)返回锁的剩余过期时间,如果为空说明成功拿到锁,如果在拿锁返回时最大尝试时间已经到期,返回false

Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
if (ttl == null) {
    return true;
}   
time -= System.currentTimeMillis() - current;
if (time <= 0) {
    acquireFailed(waitTime, unit, threadId);
    return false;
}

(2)如果没有指定过期时间leaseTime等于-1,leaseTime默认看门狗超时--30s

        RFuture<Long> ttlRemainingFuture;
        if (leaseTime != -1) {
            ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
        } else {
            ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
                    TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
        }

(3) 如果时间还有剩余,准备重试,但是不是立即重试,因为刚刚获取锁失败,大概率也获取不到,反而增加cpu负担,所以调用订阅方法subscribe(threadId),订阅其他线程释放锁的信号(最终释放锁的lua脚本里有一个publish命令就是表示发布一个信息通知)

current = System.currentTimeMillis();
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);

(4)如果这个future(subscribeFuture)在指定时间完成返回true,否则false,time--剩余等待时间,未完成取消订阅返回失败

if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
    if (!subscribeFuture.cancel(false)) {
         subscribeFuture.onComplete((res, e) -> {
             if (e == null) {
                  unsubscribe(subscribeFuture, threadId);
             }
         });
    }
     acquireFailed(waitTime, unit, threadId);
     return false;
}

 (5)如果等待成功,先计算剩余尝试时间,如果时间充足,准备加入while循环进行不断重试,先再次获取锁。

            while (true) {
                long currentTime = System.currentTimeMillis();
                ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
                // lock acquired
                if (ttl == null) {
                    return true;
                }

                time -= System.currentTimeMillis() - currentTime;
                if (time <= 0) {
                    acquireFailed(waitTime, unit, threadId);
                    return false;
                }
            }

(6)如果获取失败并且时间还有剩余,再次重试,依旧采用类似订阅的方法,获取信号后执行,等待时间取ttl或者time(谁更小取谁)如果剩余重试时间不足退出循环返回失败,否则继续循环

// 再次重试
currentTime = System.currentTimeMillis();
//过期时间小于剩余时间,说明时间还没到就已经释放
if (ttl >= 0 && ttl < time) {
// 使用future尝试获取锁,这里类似订阅,获取信号后执行,最大等待时间取ttl
subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
//  剩余时间更长,最大等待时间取剩余时间
subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
    //  如果剩余时间不足,返回失败
    cquireFailed(waitTime, unit, threadId);
    return false;
}
// 如果还有时间,再次循环,直到时间不足返回失败

 总结:使用订阅机制避免无效重试,对时间的判断非常严谨,保证最大重试时间。

五,超时释放(源码)

(1)当future完成以后,就会执行{}内容(ttlRemaining剩余有效期,e异常)获取锁成功,开启自动更新续期方法;如果自己指定了过期时间就不会开启按指定的时间执行

        ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
            if (e != null) {
                return;
            }
            if (ttlRemaining == null) {
                if (leaseTime != -1) {
                    internalLockLeaseTime = unit.toMillis(leaseTime);
                } else {
                    scheduleExpirationRenewal(threadId);
                }
            }
        });

(2)创建一个ExpirationEntry,将新的Entry放入静态全局常量EXPIRATION_RENEWAL_MAP中,所有RedissonLock实例都共享这个map,每个锁都会有唯一的EntryName和ExpirationEntry,putIfAbsent只有不存在才会添加,所以重试(同一个线程)时不会向里面添加键值对

protected void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
// getEntryName()=连接id+“:”+锁名字
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
   ........
}

(3)如果一个新的线程拿到锁,旧的entry肯定为空,entry中加入线程id并且调用开启刷新有效期任务的方法renewExpiration(),所以entry里封装了线程id和这个重复刷新有效期的定时任务

        if (oldEntry != null) {
            oldEntry.addThreadId(threadId);
        } else {
            entry.addThreadId(threadId);
            renewExpiration();
        }
    }

(4)开启定时任务,三个参数:任务本身,任务延时(这个时间到了以后才会执行任务),时间单位,任务延时为看门狗时间/3即10s,每隔10s执行这个定时任务刷新有效期,将任务加入到entry中

    private void renewExpiration() {
        ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
        if (ee == null) {
            return;
        }
        Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
            @Override
            public void run(Timeout timeout) throws Exception {
                ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
                if (ent == null) {
                    return;
                }
                Long threadId = ent.getFirstThreadId();
                if (threadId == null) {
                    return;
                }
                ..........
            }
        }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
        ee.setTimeout(task);
    }

(5)异步刷新有效期renewExpirationAsync方法(就是lua脚本),因为参数返回时间不确定所以用Future接收,拿到参数时执行onComplete{....},没有异常再次调用方法本身

RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
     if (e != null) {
        log.error("Can't update lock "+ getRawName() + " expiration", e);
        EXPIRATION_RENEWAL_MAP.remove(getEntryName());
        return;
     }
     if (res) {
        // reschedule itself
        renewExpiration();
     }
});

(6)使用lua脚本刷新有效期

protected RFuture<Boolean> renewExpirationAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
//更新有效期
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;",
Collections.singletonList(getRawName()),internalLockLeaseTime, getLockName(threadId));
}

(7)这个定时任务不断刷新,什么时候结束呢?答案就在释放锁时取消定时刷新有效期任务,移除线程id,取消任务并且在map中移除entry

//释放锁时结束无限的定时任务的方法
protected void cancelExpirationRenewal(Long threadId) {
    ExpirationEntry task = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    // task == null说明肯定没有这个锁的任务
    if (task == null) {
        return;
    }
    //移除id
    if (threadId != null) {
        task.removeThreadId(threadId);
    }
    if (threadId == null || task.hasNoThreads()) {
        //取出定时任务
        Timeout timeout = task.getTimeout();
        if (timeout != null) {
            //取消任务
            timeout.cancel();
        }
        //移除entry
        EXPIRATION_RENEWAL_MAP.remove(getEntryName());
    }
}

总结:利用watchDog,每隔一段时间重置超时时间;entry中封装id和定时任务,并将entry放入静态全局常量map中,实现超时续约。

 

六,redis集群(多个redis节点)(源码)

将数据自动分布到多个redis节点,在每个redis节点获取到锁才算是真正获取到锁,同时支持水平扩展,增加节点来处理更多数据。

 1,集群的好处

  1. 性能提升:通过并行处理和分布式计算,提升处理能力和响应速度。
  2. 容错能力:节点间互相监控和支持,保障系统稳定运行。
  3. 负载均衡:将工作负载分布到多个节点,避免单点过载。
  4. 高可用性:通过冗余和故障转移,确保系统在节点故障时依然可用。
  5. 扩展性:支持通过添加更多节点来处理更大的数据量和更高的吞吐量。

2,集群部署的原理(源码)

(1)如果自己指定锁释放时间,没有指定最大重试时间--新的释放时间就等于指定的释放时间:如果指定了最大重试时间,那么新的释放时间等于最大重试时间X2,因为集群部署,要重试获取多把锁花费时间较长,所以要求设置的释放时间不能太短,避免锁重试未到时间锁已经释放。

    long newLeaseTime = -1;
    if (leaseTime != -1) {
        if (waitTime == -1) {
            newLeaseTime = unit.toMillis(leaseTime);
        } else {
            newLeaseTime = unit.toMillis(waitTime)*2;
        }
    }

 (2)这一段主要指定:剩余时间remainTime、lockWaitTime、failedLocksLimit和acquiredLocks

    long time = System.currentTimeMillis();
    //剩余时间
    long remainTime = -1;
    if (waitTime != -1) {
        //如果指定了重试时间,那么剩余时间就是最大重试时间
        remainTime = unit.toMillis(waitTime);
    }
    //不需要重试,锁等待时间为-1,需要重试则为指定的重试时间
    long lockWaitTime = calcLockWaitTime(remainTime);
    //失败的锁的上限为0
    int failedLocksLimit = failedLocksLimit();
    //获取到的锁的集合,初始为空
    List<RLock> acquiredLocks = new ArrayList<>(locks.size());

(3)进入循环,执行获取锁的代码和重试机制;如果不需要重试且没有指定锁释放时间--执行普通的无参的获取锁的方法,如果需要重试或者指定了锁释放时间,有等待时间就选等待时间,没有取默认-1不重试,执行有参获取锁的方法。

(4)未获取到锁,只有当总的锁个数-获取到锁的个数=失败的上限0,才跳出循环;释放获取到的锁的集合里的锁,如果不需要重试就直接返回false;需要重试,清空获取到锁的集合,将迭代器移动到集合的起始位置,循环前移使整个循环重新开始

for (ListIterator<RLock> iterator = locks.listIterator(); iterator.hasNext();) {
    //拿到locks中的锁
    RLock lock = iterator.next();
    boolean lockAcquired;
    try {
        //如果不需要重试且没有指定锁释放时间
        if (waitTime == -1 && leaseTime == -1) {
            //执行普通的无参的获取锁的方法
            lockAcquired = lock.tryLock();
        } else {
        //如果需要重试或者指定了锁释放时间
            //如果指定重试时间,那么就等于重试时间,否则就是默认的-1就不重试
            long awaitTime = Math.min(lockWaitTime, remainTime);
            lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
        }
    } catch (RedisResponseTimeoutException e) {
        //如果获取锁出现redis超时异常,释放锁集合
        unlockInner(Arrays.asList(lock));
        //将获取到锁的标志置为false
        lockAcquired = false;
    } catch (Exception e) {
        lockAcquired = false;
    }
    //是否获取到锁
    if (lockAcquired) {
        //拿到锁就将锁添加到获取到锁的集合中
        acquiredLocks.add(lock);
    } else {
        //未获取到锁,当总的锁个数-获取到锁的个数=失败的上限0,就跳出循环
        if (locks.size() - acquiredLocks.size() == failedLocksLimit()) {
            break;
        }
        //获取失败的锁的上限为0
        if (failedLocksLimit == 0) {
            //释放获取到的锁的集合
            unlockInner(acquiredLocks);
            //不需要重试,直接返回false
            if (waitTime == -1) {
                return false;
            }
            //需要重试
            failedLocksLimit = failedLocksLimit();
            //清空获取到锁的集合
            acquiredLocks.clear();
            //将迭代器移动到集合的起始位置,循环前移使整个循环重新开始
            while (iterator.hasPrevious()) {
                iterator.previous();
            }
        } else {
            failedLocksLimit--;
            }
        }

(5)这一段主要是检查获取到每一个节点的锁后剩余的等待时间,如果小于0直接返回false,最后再次循环获取下一个节点的锁。

//如果指定了重试时间,表示要重试
if (remainTime != -1) {
    //看看现在的剩余时间
    remainTime -= System.currentTimeMillis() - time;
    time = System.currentTimeMillis();
    if (remainTime <= 0) {
        //如果剩余时间小于等于0,表示重试时间已经到了,释放获取到锁的集合
        unlockInner(acquiredLocks);
        return false;
    }
}
//第一个锁获取完成,循环进行其余锁获取
}

(6)如果指定了锁释放时间,为每个lock重置有效期,因为在获取到第一个锁时,它的有效期倒计时已经开启,到获取到最后一个锁时,导致几个锁之间有效期不同步,所以这里统一进行同步

if (leaseTime != -1) {
    //使用forEach操作来同步执行每个expireAsync方法的结果--重置有效期
    //syncUninterruptibly()方法用于确保异步操作能够被同步执行,即使线程被中断。
    acquiredLocks.stream()
            .map(l -> (RedissonLock) l)
            .map(l -> l.expireAsync(unit.toMillis(leaseTime), TimeUnit.MILLISECONDS))
            .forEach(f -> f.syncUninterruptibly());
}

总结:使用迭代器循环获取每一个节点的锁,使用forEach操作来同步执行每个expireAsync方法的结果--同时重置各个锁的有效期。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值