手把手带你了解Redis分布式锁

手把手带你了解Redis分布式锁

一、情境:一场注定“超卖”的抢购

在电商系统中,搞了一场火爆的限时抢购活动,一群吃瓜群众们在疯狂抢购苹果和梨子,但是水果的数量有限,用户每次只能买一件水果,简单的DB表如下:

  • 商品: 苹果 ,梨子各有10个库存
  • 用户: 张三、李四、王二麻
    在这里插入图片描述
    抢购时间到了,用户们开始疯狂下单了,让我们看看会发生什么?
 /**
     * 用户下单接口
     * @param userId: 用户ID
     * @param goodsId: 商品ID
     */
    @PostMapping("/sub_stock/{userId}/{goodsId}")
    public void subStock(@PathVariable("userId") String userId, @PathVariable("goodsId") String goodsId) {
        testBiz.subStock(userId, goodsId);
    }

1. 单机版无锁抢购

代码如下:

public void subStock(String userId, String goodsId) {
    Goods goods = goodsService.selectById(goodsId);
    if(goods != null) {
        String totalCount = goods.getTotalCount();
        // 判断库存是否大于0
        if(Integer.parseInt(totalCount) > 0) {
            // 创建订单
            Orders orders = new Orders();
            orders.setId(UUID.randomUUID().toString());
            orders.setUserId(userId);
            orders.setGoodsId(goodsId);
            orders.setCount("1");
            ordersService.save(orders);

            // 扣减库存
            totalCount = String.valueOf(Integer.parseInt(totalCount) - 1);
            goods.setTotalCount(totalCount);
            goodsService.update(goods);

            System.out.println("用户:" + userId + " 购买了:" + goodsId);
        }
    }
}

使用Jmeter同时调用接口,线程数为10,循环3次

/test/sub_stock/1/1
/test/sub_stock/2/1
/test/sub_stock/3/1
/test/sub_stock/1/2
/test/sub_stock/2/2
/test/sub_stock/3/2

查下DB,看下库存情况和订单情况:
在这里插入图片描述
这里可以看到,库存清空了,但是两个商品都超卖了,显然是有问题的。

于是,我们给单机版的加锁,来应对超卖问题。

2. 单机版加锁抢购

代码如下:

public void subStock(String userId, String goodsId) {
        synchronized (this) {
            Goods goods = goodsService.selectById(goodsId);
            if(goods != null) {
                String totalCount = goods.getTotalCount();
                if(Integer.parseInt(totalCount) > 0) {
                    Orders orders = new Orders();
                    orders.setId(UUID.randomUUID().toString());
                    orders.setUserId(userId);
                    orders.setGoodsId(goodsId);
                    orders.setCount("1");
                    ordersService.save(orders);

                    totalCount = String.valueOf(Integer.parseInt(totalCount) - 1);
                    goods.setTotalCount(totalCount);
                    goodsService.update(goods);

                    System.out.println("用户:" + userId + " 购买了:" + goodsId);
                }
            }
        }
    }

代码很简单,就是加了synchronized锁,保证同一时刻只有一个线程可以进行抢购的操作。使用Jmeter后,再来查询下订单表的情况,如下图所示。从图中可以看出,两个商品都正好卖完了,并没有存在超卖的情况,符合我们的预期。
在这里插入图片描述

3. 分布式+synchronized抢购

3.1 尝试抢购

但是单机版的性能是有限的,无法很好的应对高并发的场景,于是,我们为了应对高并发流量,这个服务被部署在了三台服务器上:WebApplication1, WebApplication2, WebApplication3
![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/3820c57a0d94427496bd1f2605c3a313.png
代码如下:

public void subStock(String userId, String goodsId) {
        synchronized (this) {
            Goods goods = goodsService.selectById(goodsId);
            if(goods != null) {
                String totalCount = goods.getTotalCount();
                if(Integer.parseInt(totalCount) > 0) {
                    Orders orders = new Orders();
                    orders.setId(UUID.randomUUID().toString());
                    orders.setUserId(userId);
                    orders.setGoodsId(goodsId);
                    orders.setCount("1");
                    ordersService.save(orders);

                    totalCount = String.valueOf(Integer.parseInt(totalCount) - 1);
                    goods.setTotalCount(totalCount);
                    goodsService.update(goods);

                    System.out.println("用户:" + userId + " 购买了:" + goodsId);
                }
            }
        }
    }

从代码中可以看出,并没有什么变化,唯一的区别就是由单机变成了3台服务同时处理请求,那看下抢购的情况吧~
在这里插入图片描述

3.2 依然超卖

从控制台的输出可以看出,3台服务都处理了一些请求,即使加了synchronized 锁,但是商品依旧超卖了。
假设苹果只剩最后1个。张三的请求到达 Server-A,李四的请求到达 Server-B,王二麻的请求到达 Server-C

  1. 几乎同时,三台服务器都执行了 goodsService.selectById("goods_id"),它们都查询到库存 totalCount = 1
  2. 判断都通过 (1 > 0),于是都开始创建订单、扣减库存。
  3. 三台服务器分别计算:1 - 1 = 0,并将库存 0 更新回数据库。
  4. 最终结果是:生成了3个订单,但库存只扣减了1次。苹果被超卖了!公司血亏,活动失败。
    在这里插入图片描述
3.3 为什么synchronized 在分布式的场景中不起效果呢?
3.3.1 核心原因:作用域不同

synchronized 是单机锁(线程锁),而分布式锁是全局锁(进程锁)。
synchronized 的作用域是 单个Java虚拟机(JVM)进程。它只能保证在这个JVM内部,多个线程之间互斥地访问共享资源。
分布式锁 的作用域是 所有服务实例。它保证在跨机器、跨进程的整个分布式系统中,只有一个进程的线程能访问共享资源。
可以把它们想象成:
synchronized 就像一栋楼里每个房间的门锁。它只能防止同一个房间(JVM)里的其他人(线程)进来,但管不了其他楼(其他服务器)里的人也进来拿东西。
分布式锁 就像这整个小区的唯一的通行证。无论你来自哪栋楼,只有拿到这个通行证的人,才能进入房间拿东西。

3.3.2 分析执行过程:

独立的 JVM 进程:
Server-A 启动,是一个独立的JVM进程。它加载 SecKillService 类,并在内存中有一个该类的实例。
Server-B 启动,是另一个完全独立的JVM进程。它同样加载 SecKillService 类,但在自己的内存中有另一个独立的实例。
Server-C 同理。
锁的是不同的对象:
synchronized 实例方法锁的是当前对象实例(this)。
当张三的请求进入 Server-A,它会获取 Server-A 的JVM中那个 SecKillService 实例的锁。
当李四的请求进入 Server-B,它会去获取 Server-B 的JVM中那个完全不同的 SecKillService 实例的锁。这个锁和 Server-A 的锁毫无关系。
王二麻的请求在 Server-C 也是同样的情况。
并行执行,而非互斥:
因为三把锁锁的是三个不同的对象,存在于三个不同的内存空间中,所以获取锁这个动作不存在任何竞争。
张三、李四、王二麻的请求在各自的服务器上都成功地获取到了自己服务器的锁,然后并行地执行查询库存、判断、下单的逻辑。
它们都会查询到库存为1,然后都成功下单,最终导致超卖。
在这里插入图片描述

3.3.3 问题根源

在分布式(多实例部署)环境下,传统的单机锁(如Java的synchronized)完全失效,因为它们只能锁住自己所在的JVM进程。我们需要一个所有服务实例都能访问和认可的全局锁来协调资源。

二、救世主登场:什么是分布式锁?

分布式锁是分布式系统或集群环境中,用于控制不同进程互斥访问共享资源的一种协调技术。

它的核心思想是:在一个所有进程都能访问的地方,设置一个标志(锁),大家一起来抢这个标志,谁抢到了,谁就有权执行后续操作。

实现分布式锁的方式有很多,如基于数据库、ZooKeeper、Etcd等。而 Redis 因其高性能、原子操作和丰富的数据结构,成为了实现分布式锁的最热门选择之一。

三、Redis(Redisson)分布式锁核心实现

1. Redisson分布式锁代码实现

这里直接使用了Redisson作为分布式锁,下面看下代码的实现,也是比较简单粗暴。

  @Autowired
    RedissonClient redissonClient;

    public void subStock(String userId, String goodsId) {
        RLock lock = redissonClient.getLock(goodsId);
        lock.lock(60, TimeUnit.SECONDS);
        try {
            Goods goods = goodsService.selectById(goodsId);
            if(goods != null) {
                String totalCount = goods.getTotalCount();
                if(Integer.parseInt(totalCount) > 0) {
                    Orders orders = new Orders();
                    orders.setId(UUID.randomUUID().toString());
                    orders.setUserId(userId);
                    orders.setGoodsId(goodsId);
                    orders.setCount("1");
                    ordersService.save(orders);

                    totalCount = String.valueOf(Integer.parseInt(totalCount) - 1);
                    goods.setTotalCount(totalCount);
                    goodsService.update(goods);

                    System.out.println("用户:" + userId + " 购买了:" + goodsId);
                }
            }
        }catch (Exception e) {

        }finally {
            lock.unlock();
        }
    }

2. Redisson原理

我们来深入剖析一下 Redisson 实现分布式锁的原理。它远比我们自己用 SETNX 实现的简单锁要强大和复杂得多。

Redisson 的分布式锁(RLock)实现了 java.util.concurrent.locks.Lock 接口,这意味着它的使用方式和Java自带的可重入锁(ReentrantLock)非常相似,但背后却是一套完善的分布式协调机制。

其核心原理可以概括为:Lua脚本 + 看门狗机制 + Pub/Sub发布订阅


3. 核心加锁逻辑

当我们调用 lock.lock() 时,Redisson 在背后做了以下事情:
使用 Lua 脚本保证原子性,加锁的核心操作是通过一个 Lua 脚本完成的,这确保了多个操作的原子性,是安全性的基石。脚本的 key 是锁的名称(如 my_lock),value 是一个 UUID + 线程ID 的唯一标识(如 b983c153-ae1c-4d2a-8a3a-f6a6d8d6c7a1:1)。

Lua 脚本逻辑如下:

-- KEYS[1]: 锁的key (e.g., 'my_lock')
-- ARGV[1]: 锁的过期时间 (毫秒) (e.g., 30000)
-- ARGV[2]: 唯一客户端标识 (UUID + threadId) (e.g., 'b983c153-ae1c-4d2a-8a3a-f6a6d8d6c7a1:1')

-- 1. 判断锁是否存在
if (redis.call('exists', KEYS[1]) == 0) then
    -- 2. 不存在,直接加锁。创建一个哈希结构,并设置重入次数为1。
    redis.call('hset', KEYS[1], ARGV[2], 1);
    -- 3. 设置锁的过期时间
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil; -- 加锁成功,返回nil
end;

-- 4. 锁已存在,检查是否是当前线程持有的
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    -- 5. 是当前线程,重入次数+1
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    -- 6. 重置过期时间
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil; -- 重入成功,返回nil
end;

-- 7. 锁被其他线程持有,返回该锁的剩余生存时间(毫秒)
return redis.call('pttl', KEYS[1]);

这个脚本一次性完成了所有关键操作:

  • 互斥性exists 命令判断锁是否存在。
  • 可重入性:使用 Hash 结构,key 是锁名,field 是客户端唯一ID,value 是重入次数。hexistshincrby 实现了可重入计数。
  • 防死锁pexpire 设置了锁的过期时间。

如果加锁成功,当前线程就获得了锁,可以执行业务代码。


4、看门狗机制(Watchdog)—— 自动续期

这是 Redisson 的一大亮点,解决了锁过期时间设置难题。

  • 问题:如果设置过期时间太短,业务没执行完锁就释放了,会导致数据混乱。如果设置太长,客户端崩溃后恢复时间又太长。
  • 解决方案看门狗
  1. 默认行为:如果你没有指定 leaseTime(锁的租约时间)而直接调用 lock(),看门狗就会启动。
  2. 工作机制:锁默认的过期时间是 30秒。看门狗是一个后台定时任务,它会每隔 10秒(过期时间的1/3)检查一下当前线程是否还持有锁。
  3. 自动续期:如果业务还在执行(即线程还持有锁),看门狗就会再次通过 Lua 脚本将锁的过期时间重置为30秒
  4. 释放停止:一旦业务执行完毕,调用 lock.unlock() 释放了锁,看门狗任务就会自动取消。

这样就实现了“只要业务还在运行,锁就不会过期”的理想状态,完美避免了业务执行时间超过锁超时时间的问题。

注意:如果你调用 lock.lock(10, TimeUnit.SECONDS) 显式指定了租约时间,看门狗就不会生效,Redis 会在 10 秒后自动删除 key。


5、阻塞与竞争锁的机制(Pub/Sub)

如果锁已经被其他客户端持有,当前客户端加锁会失败。Redisson 如何处理竞争呢?它没有采用低效的循环重试(自旋),而是使用了高效的 发布订阅(Pub/Sub) 模型。

  1. 订阅频道:当尝试加锁失败后,客户端并不会立即重试,而是会订阅 Redis 中的一个特定频道(Channel),这个频道的名字与锁的名称相关(例如 redisson_lock__channel:{my_lock})。
  2. 进入等待:线程进入等待状态,不消耗CPU资源。
  3. 接收通知:当其他线程释放锁时(通过 DEL 命令),Redisson 会向这个频道发布一条消息:“锁被释放了!”
  4. 唤醒重试:所有订阅了这个频道的客户端都会收到消息,然后被唤醒,重新尝试去获取锁

这个过程极大地减少了不必要的 Redis 请求,避免了网络和CPU资源的浪费,尤其是在高并发抢锁的场景下优势非常明显。


6、解锁逻辑

解锁操作 lock.unlock() 同样通过 Lua 脚本来保证原子性。

Lua 脚本逻辑如下:

-- KEYS[1]: 锁的key
-- KEYS[2]: 发布订阅的channel name
-- ARGV[1]: 发布的消息 (一般是0, 代表解锁消息)
-- ARGV[2]: 锁的过期时间
-- ARGV[3]: 唯一客户端标识 (UUID + threadId)

-- 1. 检查锁是否存在,且持有者是不是当前线程
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
    return nil; -- 锁不存在或不是自己的锁,直接返回(可能已过期)
end;

-- 2. 是自己的锁,重入次数减1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
    -- 3. 重入次数减1后还大于0,说明是重入锁,尚未完全释放。重置过期时间。
    redis.call('pexpire', KEYS[1], ARGV[2]);
    return 0;
else
    -- 4. 重入次数已为0,可以删除锁了
    redis.call('del', KEYS[1]);
    -- 5. 向发布订阅频道发送消息,通知其他等待的客户端“锁已释放”
    redis.call('publish', KEYS[2], ARGV[1]);
    return 1; -- 完全释放成功
end;

return nil;

这个脚本确保了:

  • 安全性:只有锁的持有者才能释放锁。
  • 可重入性:必须等重入次数减到0才会真正删除锁。
  • 高效性:通过发布消息,立即通知等待的客户端,而不是让他们盲目重试。

总结:Redisson 分布式锁的优势

特性简单 SETNX 实现Redisson 实现
可重入性不支持,同一线程重复获取会死锁支持,通过 Hash 结构记录重入次数
锁续期不支持,需业务方预估超时时间支持 (看门狗),自动续期,防止业务未执行完锁过期
等待机制自旋,循环重试,浪费资源Pub/Sub,订阅通知,高效节能
原子性多个命令,非绝对原子Lua 脚本,所有操作原子执行
易用性低,需要自己处理所有细节,实现了 Java Lock 接口,用法和本地锁一致

因此,在生产环境中,强烈推荐使用 Redisson 这类成熟框架来实现分布式锁,而不是自己重复造轮子,因为它提供了更完善的安全性、可靠性和易用性保障。

四、最终效果:秩序重归

让我们回到张三、李四、王二麻抢最后一个苹果的场景:

  1. 三个请求到达三台服务器。
  2. 它们同时尝试执行 lock.lock(60, TimeUnit.SECONDS);
  3. 由于Redis的单线程特性,只有一个请求(假设是Server-A上的张三)的 SET 命令会成功执行,获取到锁。李四和王二麻的请求获取失败,返回 false
  4. 只有张三的请求可以执行查询库存、创建订单、扣减库存的核心逻辑。
  5. 执行完毕后,张三的请求在 finally 块中安全地释放了锁。
  6. 此时,锁被释放,其他正在等待的请求(如果有重试机制)可以再次尝试获取锁,但此时库存已是0。

结果:只产生了一个订单,库存正确地扣减为0。超卖问题被完美解决!

五、总结

我们实现了一个基础且健壮的Redis分布式锁,它具备了:

  • 互斥性:利用 SET NX 保证只有一个客户端能持有锁。
  • 防死锁:通过设置过期时间 PX,即使客户端崩溃,锁也会自动释放。
  • 安全性:使用Lua脚本和唯一Value,保证只有锁的持有者才能释放锁,避免误解锁。

希望这篇博客能帮助你彻底理解并上手Redis分布式锁,让你的分布式系统在高并发下也能保持数据的一致性!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值