【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方法的结果--同时重置各个锁的有效期。

### 黑马程序员 Redis 实战教程中的商铺应用场景分析 Redis 是一种高性能的键值存储系统,在实际开发中广泛应用于缓存、会话管理以及实时数据处理等领域。对于商铺应用场景而言,Redis 的高效性和灵活性使其成为理想的选择。 #### 商铺应用中的短信登录功能 在商铺系统的用户登录模块中,短信验证码是一种常见的身份验证方式。通过集成 Redis 可以显著提升短信登录的功能效率和用户体验。具体来说,可以利用 Redis 存储用户的短信验证码及其过期时间[^1]。这种方式不仅减少了数据库的压力,还能够快速响应用户的请求并提供更安全的身份认证机制。 以下是基于 Session 和 Redis 实现短信登录的一个简单代码示例: ```python import redis # 初始化 Redis 客户端 r = redis.Redis(host='localhost', port=6379, db=0) def send_sms(phone_number, code): """发送短信验证码""" r.setex(f"sms:{phone_number}", 300, code) # 设置有效期为5分钟 def verify_code(phone_number, user_input_code): """验证短信验证码""" stored_code = r.get(f"sms:{phone_number}") if stored_code and stored_code.decode('utf-8') == user_input_code: return True return False ``` 此代码片段展示了如何使用 Redis 来保存短信验证码,并设置其有效期限以便后续验证操作。 #### 缓存击穿问题及解决方案 在商铺场景下,某些商品可能会因为促销活动而变成热点商品,从而引发大量的并发访问。如果这些热点商品的数据恰好从缓存中失效,则可能导致所谓的 **缓存击穿** 问题[^2]。这种情况下,大量请求直接打到后端数据库上,可能造成服务不可用或者性能下降。 针对这一现象,有几种常见解决方法: 1. 使用布隆过滤器提前判断是否存在该 key; 2. 对热点 key 进行加锁保护; 3. 配置永不过期或随机延长过期时间策略来平滑流量高峰。 下面是一个简单的 Python 脚本用于演示第二种方案——分布式互斥锁(Mutex Lock),防止多个线程同时尝试重新加载同一个缓存项: ```python import time from redis.lock import Lock lock_key = 'mutex_lock' with r.lock(lock_key, timeout=10): # 获取锁,超时时间为10秒 value = r.get('hot_item') if not value: # 如果未命中缓存则查询数据库并将结果写回缓存 data_from_db = fetch_data_from_database() r.setex('hot_item', 60 * 5, data_from_db) ``` 上述脚本确保只有第一个获取到锁的任务才会执行耗时较长的操作,其他等待中的任务可以直接返回最新更新后的缓存内容。 --- ###
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值