欢迎光临
我们一直在努力

走向高级开发必须要看的一篇——高并发:分布式锁详解与搭建(Redis)

1:引言

        说到高并发,我们不得不谈到一些特殊的场景、比如说秒杀、抢购等一些场景。

        如果是单体项目,我们可能会使用Synchronized来应对高并发多线程交叉问题,但在分布式架构的模式下,这个方案往往是行不通的。

因为在单个jvm中只有一个锁监视器,而这个锁监视器仅仅只能监视所在的jvm的线程。

        我们根据一个场景来说:比如说现在有一个商品这个商品有十件、每人限购一件 、 现在我们有两个集群 、 A集群 、 B集群 , 每个集群中各有两个线程 ,也就是说我们一共有四个线程在运行 , 单个集群中有自己的jvm锁监视器 , 如果我们使用Synchronized的话 ,我们可以实现在单个jvm中达到互斥锁 ,A线程获取到锁 ,然后修改数据库数据 ,B线程进行等待 。 然后返回数据给前端 , C线程获取到锁 , D线程进入等待 ,C线程修改数据库数据 。 如果某一用户 ,在前端未限流的情况下进行多次点击 , 就会导致A线程和C线程同时请求 ,就会导致一个用户买到两件的情况发生。面对分布式的这种情况下,我们就不能再使用Synchronized来进行处理高并发线程交叉的问题了。而是要在外部统一找到一个锁监视器。让所有的jvm都去使用同一个锁监视器。


只让一个线程拿到锁!(非常重要)

我们在说一下分布式锁的定义吧:满足在分布式系统或者集群模式下多进程可见并且互斥的锁!

1. 用 MySQL 搞分布式锁

        原理:这招利用了数据库本身的事务和锁机制。比如咱们在数据库里搞个表,专门用来记录锁的状态。当某个事务开始操作(比如更新某条记录)时,MySQL 会自动给它加个排他锁(互斥锁),其他事务想来动这条记录?不行!得排队等着,必须等前一个人办完事(事务提交或回滚)释放了锁,才能轮到下一个。这就保证了同一时间只能有一个事务成功拿到锁。

        适合场景:适合那些本身就已经在用 MySQL,且对锁的精度要求没那么苛刻(比如允许一点点延迟)的场景。但要注意,数据库压力大时可能影响性能。


2. 用 Redis 搞分布式锁

        原理:这招主要是用 Redis 的 SETNX 命令(SET if Not eXists)。你可以把它想象成一个“抢钥匙”的操作:我往 Redis 里设置一个键值对(比如 lock_key),如果这个 key 不存在,那我就能设置成功,相当于抢到了锁;如果 key 已经存在(说明锁被别人占了),那我的设置就会失败。抢到锁后,为了防止锁被永远占用(比如程序挂了没释放),通常还会给这个 key 设置一个过期时间(EXPIRE),超时自动释放。

        适合场景:Redis 本身速度快,适合对性能要求高的场景。但要注意,实现时要小心处理过期时间、锁误删等问题(比如锁超时自动释放了,但原持有者还在执行任务),通常配合 Lua 脚本来保证原子性操作会更稳妥。


3. 用 Zookeeper 搞分布式锁

        原理:Zookeeper 本身是个分布式协调服务。它搞锁的方式很聪明:每个想抢锁的服务,都在 Zookeeper 的一个特定目录下创建一个临时有序节点(比如 /locks/lock_00000001)。Zookeeper 会保证这些节点的序号是递增且唯一的。规则很简单:当前序号最小的节点获得锁!如果某个服务创建的不是最小节点,那它就得“监听”(Watch)它前面的那个节点;一旦前面那个节点消失(锁被释放),Zookeeper 会通知它,它再检查自己是不是新的最小节点,是的话就成功上位拿到锁。而且因为是临时节点,如果服务挂了,节点自动删除,锁也就释放了,不会死锁。

        适合场景:适合对锁的可靠性和一致性要求极高的场景。Zookeeper 天生就是为分布式协调设计的,用起来很稳,但相比 Redis,它的部署和运维可能稍微复杂一点。


2 :我们在这里主要来讲基于Redis来实现分布式锁的思路

       

 2.1:实现分布式锁需要实现的两个方法。

  获取锁
  •         互斥,要保证只有一个线程获取到锁

# 添加锁、利用setnx的特性

SETNX lock thread1

# 添加锁、NX是互斥、EX是超时时间

SET lock thread1 NX EX 10

  释放锁
  •         手动释放
  •         超时释放:获取锁时添加一个超时时间

# 释放锁、删除即可

DEL key

public interface ILock {
/*
* 尝试获取锁
* @Param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功 , false代表获取锁失败
* */

boolean tryLock(Long timeoutSec);

/*
* 释放锁
* */
void unLock();
}

@Override
public boolean tryLock(Long timeoutSec) {
// 获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate
.opsForValue()
.setIfAbsent(KEY_PREFIX + name,threadId + "",timeoutSec,TimeUnit.SECONDS);

return Boolean.TRUE.equals(success);
}


这种锁在大部分情况下性能是可以达到要求的,但是我们也可能遇到一些极端的情况。

        我们来举个例子、现在有一个Redis锁 ,现在线程1出现了,它获取到了锁 、但是因为各种因素 ,它的业务发生了阻塞 , 导致超过了锁的过期时间 ,这个时候锁被释放了 ,就在它刚刚释放锁后,线程2来了,它尝试获取锁 ,来执行任务 ,然后他获取到了锁 ,这个时候线程1的事务执行完了,线程1开始尝试释放锁 、它直接把线程2的锁给释放了 ,这个时候线程3来了 ,它也尝试获取锁,也成功获取到了,然后开始执行业务 ,线程2任务也执行完了,然后释放了线程3的锁 ,这个场景下又一次出现了多线程交叉的问题

那我们怎么解决这个类问题?

答案:在尝试获取锁时,加入锁的标识,在释放锁之前,先看看是不是当前线程的锁,如果是自己的锁再进行释放!

@Override
public void unLock() {
// 获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁种的标识
String lockid = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断标识是否一致
if (threadId.equals(lockid)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}

}


当我们解决这个问题后,其实可能还会出现一种情况

        我们还是举一个场景的例子:线程1获取到了锁开始执行业务,当业务完成后,确认标识后开始释放锁、但是出现了阻塞、为什么会出现阻塞呢?这就得提起jvm的垃圾回收机制了,不过我们这次先不聊jvm,当线程1出现释放锁阻塞时,由于线程1已经确认了锁标识,这时线程2来了,它也获取到了锁,开始执行业务,当线程2执行业务到达一半时,线程1阻塞结束了,直接释放掉了线程2的锁,这个时候线程3来了,获取到了锁,开始执行业务 ,这样又一次出现了多线程交叉的问题。

我们怎么处理这个问题呢?

必须保证"获取锁标识并判断是否一致和释放锁这两个动作必须是一个原子性的操作"

那怎么保证这两个动作的原子性呢?

Redis的Lua脚本

Redis提供了Lua脚本功能、在一个脚本中编写多条Redis命令,确保多条命令执行的原子性

–比较线程中的标示和锁中的是否一致

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

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}

@Override
public void unLock() {
// 获取lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId()
);
}


         虽然基于 SETNX 命令实现的 Redis 分布式锁在基础功能上能够满足需求,但深入分析后会发现其仍存在若干局限性,这些潜在问题可能影响系统的稳定性和可靠性:

  • 不可重入性:当前实现无法支持同一线程多次获取同一把锁。这意味着如果一个线程在持有锁期间尝试再次获取该锁,操作将会失败,限制了代码的灵活性和设计模式(如递归调用)。
  • 缺乏重试机制:获取锁的操作通常是“一次尝试即判定结果”(尝试一次失败即返回 false)。这种机制在网络抖动或短暂竞争激烈时容易导致获取失败,缺乏自动重试机制来应对瞬时问题。
  • 超时释放的风险:为防止死锁而设置的锁超时释放机制本身是一把双刃剑。如果业务逻辑执行时间意外地超过了锁的过期时间,锁会被提前释放,此时其他线程可能获取到锁并操作共享资源,导致数据不一致或并发冲突,存在安全隐患。
  • 主从一致性问题:在 Redis 主从集群架构下,主节点与从节点之间的数据同步存在延迟(异步复制)。如果主节点在持有锁期间发生故障,且锁信息尚未同步到从节点,此时进行主从切换,新的主节点可能不包含这把锁的信息,导致多个客户端同时持有锁(锁失效),严重破坏互斥性。

  •         这里我们介绍一个用于开发这些功能的框架——Redisson

            Redisson是一个基于Redis实现的分布式Java工具库,封装了分布式锁、集合、对象存储等高级功能。

    3 :Redisson入门

            1 :引入依赖

            <!– Source: https://mvnrepository.com/artifact/org.redisson/redisson —        >
            <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>4.2.0</version>
            <scope>compile</scope>
            </dependency>

            2 :配置Redisson客户端

            @Configuration
            public class RedissonConfig {

            @Bean
            public RedissonClient redissonClient()
            {
            // 配置
            Config config = new Config();
            config.useSingleServer().setAddress("redis:xxxxx").setPassword("password        ");
            // 创建RedissonClient对象
            return Redisson.create(config);
            }
            }

            3 :使用Redisson的分布式锁

            RLock lock = redissonClient.getLock("lock:order" + id);

            // 释放锁
            lock.unlock();

            1: Redisson的可重入锁原理

            举一个例子:比如说我们现在有两个方法:方法一和方法二

            这就好比我们手里拿着一张只能证明“我是谁”的简单通行证(String类型),当我们只是独自进出大门时,它足够用了。但是,现在的剧情变了:当方法一获取锁开始执行事务时,它内部又调用了方法二。这时,方法二可不会傻傻地站在原地吃闭门羹,它会理直气壮地认为:“这也是我家,我得进去继续干活!” 这就是我们常说的可重入。
    在这种“自家方法调用自家方法”的场景下,原本简单的 String 类型就露怯了。如果用 String,Redis 里只存了一个简单的标识,方法二来敲门时,Redis 一看:“哎?锁已经有人占着了”,直接就给拒之门外了,方法二就只能在那傻等,造成死锁。
    所以,为了解决这个问题,我们不能再用 String 这种“一根筋”的结构,而是要升级换代,使用 Hash 结构。
    为什么非得是 Hash 呢?因为 Hash 结构就像是一个不仅记名字,还记次数的“高级登记簿”。在这个 Hash 结构里:
    Key(键):依然是我们的锁名称,比如 my-lock,这代表这把锁本身。
    Field(字段):我们不再只存一个简单的线程标识,而是存储 “线程 ID + 随机 UUID”。这就像是给每一个进门的线程发了一张独一无二的身份证,确保不会认错人。
    Value(值):这最关键,我们用它来存储 计数器(重入次数)。
    这样一来,当方法一进门时,Redisson 会在 Hash 里写下:{"thread-id-001": 1},表示“线程 001 进来了一次”。
    紧接着,方法二被调用,它拿着同样的身份证过来一看,Hash 里存的 Field 正是自己人!这时候,Redisson 不会把它拦在外面,而是直接把 Hash 里的 Value 加一,变成 {"thread-id-001": 2},意思是“线程 001 又进来了一次,现在它在里面有两层任务”。
    等里面的方法二执行完要退出,Redisson 就把计数减一,变回 1;直到最外层的方法一也执行完,计数归零,这把锁才真正释放。
    这就是 Redisson 可重入锁的奥秘:利用 Hash 结构,既记住了是谁拿到了锁,又记住了他“重入”了多少次,让内部调用不再傻傻等待,而是畅通无阻。

            这个实现过程更加复杂


    所以我们不再使用java来编写而是使用Lua脚本来编写

    1 . 加锁逻辑

            当方法一进来,或者方法二重入时,脚本逻辑如下:

    — KEYS[1]: 锁的名称 (比如 "my-lock")
    — ARGV[1]: 锁的过期时间 (比如 30000 毫秒)
    — ARGV[2]: 线程标识 (比如 "id:threadId")

    — 第一步:尝试获取锁
    — 看看这个锁存在吗?
    if (redis.call('exists', KEYS[1]) == 0) then
    — 锁不存在,太好了,我是第一个来的!
    — 使用 hset 命令,建立 Hash 结构
    — Key 是锁名,Field 是我的线程标识,Value 初始化为 1
    redis.call('hset', KEYS[1], ARGV[2], 1);

    — 设置过期时间,防止我挂了锁不释放
    redis.call('pexpire', KEYS[1], ARGV[1]);

    — 返回 nil 表示加锁成功
    return nil;
    end;

    — 第二步:锁已经存在,看看是不是我自己的(重入判断)
    — 判断 Hash 里是否存在当前线程的 Field
    if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    — 是我自己的锁!我不傻站着,我要重入!
    — 计数器加 1 (hincrby)
    redis.call('hincrby', KEYS[1], ARGV[2], 1);

    — 既然我又进来了,要给我重置一下过期时间,续命!
    redis.call('pexpire', KEYS[1], ARGV[1]);

    return nil;
    end;

    — 第三步:锁是别人的
    — 返回剩余过期时间,告诉调用者:锁被占了,你排队去吧
    return redis.call('pttl', KEYS[1]);

    逻辑解读:

    • 第一个 if:处理初次加锁,hset 命令完美契合了我们之前说的 Hash 结构,存下了线程标识和计数 1。
    • 第二个 if:处理重入。利用 hexists 判断是同一个人,直接 hincrby 累加 Value 值。这就是方法二能“直接干活”的底气。
    2. 解锁逻辑

            当方法二执行完,或者方法一执行完,需要释放锁时:

    — KEYS[1]: 锁的名称
    — KEYS[2]: 频道名称 (用于通知其他等待的线程,这里暂不展开)
    — ARGV[1]: 释放锁的消息 (比如 0)
    — ARGV[2]: 线程标识
    — ARGV[3]: 最新的过期时间 (看门狗续命时间)

    — 第一步:检查这把锁是不是我的
    if (redis.call('hexists', KEYS[1], ARGV[2]) == 0) then
    — 锁都不是我的,我瞎释放个什么劲?
    return nil;
    end;

    — 第二步:是自己的锁,计数器减 1
    local counter = redis.call('hincrby', KEYS[1], ARGV[2], -1);

    — 第三步:判断计数器是否归零
    if (counter > 0) then
    — 计数器还大于 0,说明里面还有方法没退出(比如方法二退了,方法一还在)
    — 那我不能删锁,只能重置一下过期时间
    redis.call('pexpire', KEYS[1], ARGV[3]);
    return 0;
    else
    — 计数器归 0 了,说明最外层的方法也退出了
    — 真的要删锁了!
    redis.call('del', KEYS[1]);

    — 发个消息告诉别人:锁释放了,你们快来抢!
    redis.call('publish', KEYS[2], ARGV[1]);
    return 1;
    end;

    return nil;

    逻辑解读:

    • 解锁不是简单的 del,而是先 hincrby -1 做减法。
    • 关键点:只有当 counter 变成 0 或负数时,才会真正执行 del 删除 Key。这保证了方法二退出时,锁不会丢失,依然由方法一持有。

    总结:

    你看,这两段 Lua 脚本配合得天衣无缝。加锁时,利用 hexists 识别自己人,利用 hincrby 记录重入次数;解锁时,利用 hincrby -1 逐层退出,直到计数归零才真正删除。

    正是这套基于 Hash 结构和 Lua 脚本的组合拳,让 Redisson 的锁不仅能辨别“谁在敲门”,还能记住“他进去了几次”,完美实现了锁的可重入。


    2 . 缺乏重试机制

            现在有一个 Redis 锁,线程 1 正在持有锁执行任务。这时候线程 2 来了,它尝试获取锁,结果发现锁被占了。在普通的锁机制下,线程 2 试了一次发现拿不到,立马就返回 false 走人了。结果它刚走,线程 1 的任务就执行完把锁释放了。线程 2 本来有机会拿锁的,却因为没再试一次而错过了。

    Redisson 解决这个问题的原理是:它让线程 2 “坐下等一会儿”。线程 2 获取锁失败后,不会立刻返回,而是通过 Redis 的发布订阅机制“订阅”了这把锁。一旦线程 1 释放锁发了个消息,线程 2 立马收到通知,冲上去再次尝试获取锁。这样就不怕瞬间的网络抖动或者短暂的竞争了。

    RLock lock = redisson.getLock("myLock");
    // 尝试加锁,等待100秒,锁自动过期时间10秒
    // 内部会自动订阅锁释放的消息,实现智能重试
    boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
    if (res) {
    try { /* 业务逻辑 */ } finally { lock.unlock(); }
    }


    3 . 超时释放的风险

            现在有一个 Redis 锁,线程 1 获取到了锁,过期时间设了 10 秒。但是因为业务逻辑太复杂,线程 1 执行了 15 秒还没跑完。在第 10 秒的时候,Redis 看时间到了,就把锁给释放了。这时候线程 2 来了,它顺利拿到了锁。紧接着,线程 1 终于在第 15 秒执行完了,它不管三七二十一,去执行释放锁的操作,结果把线程 2 刚拿到的锁给删了。这时候线程 3 来了,也成功获取到了锁。你看,线程 2 和线程 3 本来应该互斥,现在却出现了交叉执行,这就乱套了。

    Redisson 解决这个问题的原理是:引入了一个“看门狗”机制。当线程 1 拿到锁后,看门狗会每隔一段时间(比如 10 秒)检查一下:“哎,线程 1 你还在跑吗?”如果还在跑,看门狗就自动把锁的过期时间重置一下。只要线程 1 还活着,锁就不会过期,这就避免了业务没做完锁就被删掉的尴尬。

    RLock lock = redisson.getLock("myLock");
    lock.lock(); // 启动看门狗
    try {
    // 执行耗时业务,看门狗会自动续期,不用担心锁过期
    } finally {
    lock.unlock();
    }


    4 . 主从一致性问题

            现在有一个 Redis 主节点和一个从节点。客户端 A 在主节点上获取了锁,主节点刚准备把这个锁同步给从节点,突然主节点挂了。从节点升级成了新的主节点,但它肚子里根本没这把锁的数据。这时候客户端 B 来了,向新的主节点申请锁,一下子就成功了。现在好了,客户端 A 和客户端 B 都觉得自己拿到了锁,都在那执行业务,这就严重破坏了互斥性。

    Redisson 解决这个问题的原理是:放弃依赖单一的主从架构,采用 Redlock 算法。它搞了多个完全独立的 Redis 节点(比如 5 个)。客户端要想拿到锁,必须得在大多数节点上(比如 3 个)同时获取成功才行。这就好比去多个独立的部门盖章,只有盖够了半数以上的章,这锁才算生效。这样就算某一个节点挂了,剩下的节点依然能认得这把锁,保证了锁的唯一性。

    // 构建多个独立的 Redis 节点
    RLock lock1 = redisson1.getLock("lock");
    RLock lock2 = redisson2.getLock("lock");
    RLock lock3 = redisson3.getLock("lock");

    // 使用 Redlock 联锁
    RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
    redLock.lock();
    try {
    // 业务逻辑
    } finally {
    redLock.unlock();
    }

    到这里,分布式锁就要告一段落了,希望这篇文章会对你有所帮助!!!

    赞(0)
    未经允许不得转载:171主机测评 » 走向高级开发必须要看的一篇——高并发:分布式锁详解与搭建(Redis)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址