掘金 后端 ( ) • 2024-04-02 22:39

theme: awesome-green highlight: a11y-dark

👈👈👈 欢迎点赞收藏关注哟

首先分享之前的所有文章 >>>> 😜😜😜
文章合集 : 🎁 https://juejin.cn/post/6941642435189538824
Github : 👉 https://github.com/black-ant
CASE 备份 : 👉 https://gitee.com/antblack/case

一. 前言

分布式锁方案很多,但是重在了解其设计的原则,知道原则后,具体使用什么媒介都没大问题。

👉 本文目标确定分布式锁应该满足什么特性,了解常见的思路。

二. 用问题的形式来思考这个领域

2.1 分布式锁需要什么介质?

  • ✔️ : 需要一个多个微服务节点都能访问的存储介质,需要能保存锁信息
  • ✔️ : 该工具上锁时要能保证原子操作, 能处理并发,且能对结果进行感知
  • ✔️ : 节点具有强一致性,不论几个节点,客户端最终结果一致
  • ✔️ : 老生常谈的 高可用高性能

结合这几点来看一下市面上的常用方案 :

  • Redis : 高性能简单的分布式锁方案
    • 本身的单线程方式保证了操作的并发性。
    • 通过 EVAL 命令可以保证操作的原子性
    • 虽然没有达到强一致,但是多节点时可以保证最终一致性
  • Zookeeper : 性能略差,但是一致性高
    • 临时节点事件监听 机制 ,创建临时有序节点 , 判断是否是最小从而获取到锁
    • 通过事件监听等待锁的释放
    • 解锁则删除节点
  • MySQL : 有方案,但是从来没用过
    • MySQL 的事务机制和锁机制足够满足上述的基本需求
    • 只不过性能不够理想

2.2 分布式锁需要实现哪些功能 ?

  • 锁的基本要求 :锁最大的要求就是互斥和可重入。
    • 不同的对象不能拿到同一个锁,同一个对象可以再次访问该锁
  • 需要避免死锁 :死锁四大条件里面,只要破坏一个就可以避免死锁
    • 互斥条件 : 资源是排他的,一个资源一次只能被一个对象获取
    • 请求与保持条件 :当前对象持有这个资源的是时候会请求其他的资源,并且不会放弃持有当前资源
    • 不可剥夺条件 : 不可以强行从一个对象手中剥夺资源
    • 循环等待条件 : 当前对象需要去等待其他对象的资源,其他对象也要等待当前对象的资源
    • 解决方案超时退出最简单
  • 锁对象独占 : 能拿到锁 ,能校验锁 ,也能解除锁,保证锁的独占性
  • 尝试获取时间 / 超时自动释放 : 一个是尝试获取锁时,多久超时失败。 一个是拿到锁后 ,多久自动释放。
  • 高并发,高可用 : 除了锁介质需要满足这些,实现锁的方案上也有满足。

三. 业务扩展

3.1 分布式锁有哪些业务场景 ?

场景一 :限制资源写入

资源访问限制是一个很宽泛的领域,来细化一下就是 API 的访问数据库的访问等等场景都可以通过分布式锁来控制。

而往业务场景去偏移 ,包括超卖问题重复消费问题 ,等等也都在分布式锁的解决范围之内。

同时可以在一定程度上避免数据库级别的锁竞争问题。避免同时的数据写入和修改。

场景二 : 限制资源的产生

这种最常见的场景在于缓存过期的问题上 ,当并发到来的时候 ,如果缓存服务器即将过期 ,可能会基于缓存的特性限制缓存的重复读取和写入。 避免查询重复的数据

再就例如分布式ID的场景下 ,也会通过分布式锁类似的方式,来获取一个粗粒度的 ID 范围 ,用于后续ID的细分。

场景三 : 限制触发的频率

这种体现在 Job 定时任务的执行上。 不过如果使用的是类似于 XXL-JOB 这类外部的 Job 组件 ,可能这个特性就用不上上了。

但是如果是单个服务内置的 Job 组件 ,微服务之间没有互相通信 ,那么就需要分布式锁来限制任务触发的频率

对应的还包括 API 的访问频率,也可以在分布式锁的基础上进行扩展(主要就是要求原子性的计数)。

场景四 : 维护资源的一致性

由于分布式场景的特性 ,可能在单机上面被视为原子对象的资源 ,在分布式场景下就变成了多个资源。

分布式锁并不能改变这种状态,但是可以增强一致性 ,维护他们的统一状态

常见的场景包括分布式事务

四. 常见的设计方式

4.1 关于系统介质的选择

以上的几种实现方式里面 ,我用的最多的还是 Redis

  • 为什么我 ZK 用的少 ?
    • ZK 需要额外的部署,有些项目并没有使用 ZK 的场景 。
    • ZK 在性能上比 Redis 要差
    • 对一致性的要求没想象那么高小概率事件, Redis 基本上可以满足
    • 非要强一致 , Redis 也有替代的方案 , 比如 RedissonRedLock
  • 为什么从来没使用过数据库 ?
    • 在锁的处理上 ,数据库算是性能最差的 ,占用资源最多
    • 通常用上分布式锁的时候 ,系统已经比较大了,这个时候大概率已经分库分表了,增加了复杂度
    • 对于一些复杂的功能,数据库实现不了(解锁,判断锁)
    • 用它做分布式锁还不如让它作为乐观锁

4.2 关于锁的实现要点

  • 要实现锁的等待,首先要有个明确的等待时间,然后在业务代码里面等待(比如自旋,Java的锁)
  • 锁的主键 :一般情况下我们实现的时候都是通过 类 + 方法 + 参数 + 值
  • 锁的重入 :使用 redisson 的情况下 ,它是通过线程ID来实现的重入(如果同一个应用线程相同,就可能存在问题

4.3 关于实现的思路

  • 👉 Zookeeper 有提供完整功能的第三方包,例如 Curator ,所以就不细述了
  • 👍 Redis 使用更加简单,这一篇会详述 :

基于 Redis 的方案我使用过两种 :

  • 基于 LUA 脚本自定义分布式锁
  • 基于 redisson 的分布式锁 (其实本质上还是 LUA 脚本,只不过它帮你封装了)

一般情况下,没有必要重复造轮子,除非迫不得已。 由于 Redission 本身就是基于 LUA ,这里就看一看其实现原理 ,真要造轮子,抄一抄也能用。

S1 : Redisson 支持的方法和使用

  • getLock : 获取普通锁
  • getMultiLock : 获取组合锁
  • getFairLock :获取公平锁
  • getRedLock : 获取读锁
  • getReadWriteLock : 获取读写锁

而当获取到 Lock 的时候 ,返回的是一个 RLock 对象 ,该对象可以进行如下操作 :

  • boolean tryLock :尝试获取锁, 支持时间设置
  • boolean isLocked :判断资源是否被锁
  • void unlock() :解除锁

使用起来也很简单, getLock 获取到锁 ,执行完成后 unLock 解锁就行

// S1 : 获取到对应的 RLock
this.redissonClient.getLock(getLockKey("LOCK:" + name + group));

// S2 : 尝试获取锁 
- (此时如果已经解锁或者锁过期了,就会抛出一个异常,需要注意)
rLock.tryLock(0, second, TimeUnit.SECONDS);

// S3 : 获取锁失败可以直接报错或者执行相关业务
略

// S4 : 使用完成后释放锁 
((RLock) cacheLock).unlock()

S2 : 深入 Redisson 的加锁原理

之前说了 ,Redis 主要通过 LUA 脚本实现的分布式锁,而 Redisson 相当于直接进行了封装,来看下代码 :

  • 来看看 Java 侧的处理代码 :核心代码都在 RedissonLock
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {

    // 👉  S1 : 获取锁的基本消息
    //      -  注意 ThreadID , 这意味着同一个 ID 的情况下 ,锁是可重入的
    long time = unit.toMillis(waitTime);
    long current = System.currentTimeMillis();
    long threadId = Thread.currentThread().getId();
    
    // 👉 S2 : 核心调用 LUA 脚本进行锁的判断
    //     - 如果返回的时间为 null , 则成功获取到锁
    Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
    if (ttl == null) {
    
        return true;
    } 
    
    // 👉 S3 : 这里是得到一个剩余等待时间 (此处会减去执行的时间)
    //     - 时间不够则获取锁失败
    time -= System.currentTimeMillis() - current;
    if (time <= 0L) {
        this.acquireFailed(waitTime, unit, threadId);
        return false;
    } 
    
    
    // 👉 S4 : 核心的订阅逻辑,等待锁的释放
    //     - 4-1 : 通过 await 等待时间来判断是否超过最大等待时间
    RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
    if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
        // 省略具体的订阅逻辑
    }
    
    
    // 👉 S5 : 订阅成功后地处理 , 其中有几个核心点
    //     - while 循环中拿锁 (PS : 以为可能其他的对象也在竞争)
    //     - 要么拿到锁 ,要么超过超时时间,符合一个都会退出
    while (true) {
            
        // 尝试拿锁,拿到就退出,没拿到就循环    
        ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
    
        // - 核心点一 : 不断地执行和 S3 类似的逻辑 ,从而判断是否超过最大等待时间
        time -= System.currentTimeMillis() - currentTime;
        if (time <= 0) {
            acquireFailed(waitTime, unit, threadId);
            return false;
        }
        
        
        // - 核心点二 : getLatch 拿到了一个 Semaphore ,通过 tryAcquire 尝试拿到锁,会等待超时
        subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
    }
}
  • 上述 LUA 最终会调用到 RedissonLock # tryLockInnerAsync 中 ,最后会执行 LUA 脚本

this.internalLockLeaseTime = unit.toMillis(leaseTime);
// 执行 LUA 脚本命令 (EVAL 命令执行)
return this.evalWriteAsync(
    this.getName(), 
    LongCodec.INSTANCE,
    command, 
    "LUA 脚本如下",
    Collections.singletonList(this.getName()),
    this.internalLockLeaseTime, 
    this.getLockName(threadId)
);

// 实际执行的脚本
// 1. 先判断 Key (被锁的资源)是否存在,如果不存在则直接认为获取资源
// - KEYS[1] :锁的主键 Key (业务主键)
// - ARGV[1] :过期时间
// - ARGV[2] : 拿锁的线程
if (redis.call('exists', KEYS[1]) == 0) then     // 检查名为 KEYS[1] 的键是否存在于 Redis 数据库中
    redis.call('hincrby', KEYS[1], ARGV[2], 1);  // 给其中的线程递增1,标识当前线程获取到锁
    redis.call('pexpire', KEYS[1], ARGV[1]);     // 设置名为 KEYS[1] 的哈希的到期时间
    return nil;
    end; 
// 2. 再判断对应的线程是否获取到锁(判断 KEY 中的字段是否存在)- 这里其实是锁的可重入性
// - 如果锁已经存在,但是线程是一样的,则可以再次获取锁
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)  then  // 检查名为 KEYS[1] 的哈希中是否存在名为 ARGV[2] 的字段
    redis.call('hincrby', KEYS[1], ARGV[2], 1); 
    redis.call('pexpire', KEYS[1], ARGV[1]); 
    return nil; 
    end; 
// 3. 没拿到锁则返回过期时间
return redis.call('pttl', KEYS[1]); // 获取键 KEYS[1] 的剩余过期时间


// 最终执行的代码就不看了,往下要翻很多层    

  • 来看一下最终执行的参数

image.png

image.png

S4 : 简单看一下解锁原理

核心就是加锁,涉及解锁的地方粗略的看一看就完事了 :


// 如果锁不存在,则直接返回
 if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then 
     return nil;
end;

// 若锁存在,且唯一标识(线程ID)匹配:则先将锁重入计数减1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then   // 如果锁的持有数还是大于 0 ,则不可以删除锁,只是设置时间
    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 的情况下, 知识点还挺多,这一篇就不深入了。

参考文档

https://blog.csdn.net/asd051377305/article/details/108384490

https://redis.io/docs/manual/patterns/distributed-locks/