Redis 学习笔记 5:分布式锁

news2025/5/20 18:10:56

Redis 学习笔记 5:分布式锁

在前文中学习了如何基于 Redis 创建一个简单的分布式锁。虽然在大多数情况下这个锁已经可以满足需要,但其依然存在以下缺陷:

image-20250504110349891

事实上一般而言,我们可以直接使用 Redisson 提供的分布式锁而非自己创建。

Redisson

添加 Redisson 依赖:

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

创建 Redisson 客户端实例:

@Configuration
public class RedisConfig {
    @Autowired
    RedisProperties redisProperties;
    @Bean
    public RedissonClient redissonClient(){
        Config config = new Config();
        String address = String.format("redis://%s:%s", redisProperties.getHost(), redisProperties.getPort());
        config.useSingleServer()
                .setAddress(address)
                .setPassword(redisProperties.getPassword());
        return Redisson.create(config);
    }
}

这里的RedisProperties是 Spring-data-redis 自带的一个配置类,可以借助其直接从配置文件中读取 redis 相关配置信息。

也可以通过修改配置文件的方式配置 Redisson 客户端,但缺点是会变更 spring-data-redis 对 Redis 客户端的默认配置,所以不建议那样做。

使用 Redisson 提供的分布式锁限制优惠券抢购:

// ...
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
	// ...
    @Autowired
    private RedissonClient redissonClient;

	// ...
    @Override
    public Result createOrder(Long voucherId) {
        // ...
        // 使用用户标识进行加锁
        String lockName = "lock:voucher-order:" + userId.toString();
        RLock lock = redissonClient.getLock(lockName);
        boolean isLock = lock.tryLock();
        if (!isLock) {
            return Result.fail("同一用户不能重复抢购");
        }
        try {
            return proxy.doCreateOrder(voucherId);
        } finally {
            lock.unlock();
        }
    }
	// ...
}

Redisson 提供的分布式锁RLock有多种加锁方式,这里展示的tryLock()是非阻塞式的加锁,如果获取锁失败,会立即返回。如果需要阻塞式获取锁(获取锁失败时等待并尝试获取),可以:

isLock = lock.tryLock(1,10, TimeUnit.SECONDS);

这里第一个参数是等待时长,第二个参数是 Redis 锁的过期时长。

可重入锁原理

Redisson 提供的分布式锁是可重入的,其原理和 JDK 提供的用于处理并发的可重入锁ReentrantLock是类似的。即在锁内部使用一个计数器,当一个线程多次获取同一个锁时,将计数器自增以记录已经重复获取锁的次数。在释放锁的时候将计数器减1,当计数器为0时才真正释放锁。

下面通过改造前文的 Redis 分布式锁,让其支持再入以说明 Redisson 锁可再入的实现原理。

在改造 Redis 锁前,先编写一个测试用例来证明目前的 Redis 锁不支持重入:

// ...
@Log4j2
@SpringBootTest
public class RedisLockTests {
    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 测试 Redis 锁是否可以再入
     */
    @Test
    public void test() {
        SimpleRedisLock lock = new SimpleRedisLock(redisTemplate, "test");
        boolean tryLock = lock.tryLock(2000);
        if (!tryLock) {
            log.error("test获取锁失败");
            return;
        }
        try {
            log.info("test获取锁成功");
            log.info("test执行业务代码");
            test2(lock);
        } finally {
            lock.unlock();
            log.info("test释放锁");
        }
    }

    private void test2(SimpleRedisLock lock) {
        boolean tryLock = lock.tryLock(2000);
        if (!tryLock) {
            log.error("test2获取锁失败");
            return;
        }
        try {
            log.info("test2获取锁成功");
            log.info("test2执行业务代码");
        } finally {
            lock.unlock();
            log.info("test2释放锁");
        }
    }
}

因为像前面说的,重入锁需要有一个计数器,同时还需要持有一个线程 ID 以检查是否当前线程持有的锁,因此不能再使用 Redis 中的 key-value 结构作为锁,改为使用 Hash 来同时保存这两个信息:

[外链图片转存中…(img-otcOkwkN-1747619754081)]

这里将线程 ID 直接作为字典的 key 以节省存储空间。

现在的问题就是在获取锁和释放锁的部分加入计数器维护的逻辑。但就像在前文引入 Lua 脚本时讨论的那样,显然这些操作不能通过 Java 实现,因为那样做不能保证操作的原子性,因此需要用 Lua 脚本来实现:

--[[
    @描述: Redis 锁获取脚本(支持再入)
    @版本: 1.0.0
]] --    
local key = KEYS[1] -- Redis 锁的对应的 key
local threadId = ARGV[1] -- 持有锁的线程标识
local timeoutSec = ARGV[2] -- 锁的自动过期时长(单位秒)
-- 检查锁是否已经存在
local exists = redis.call('exists', key)
if (exists == 0) then
    -- 如果锁不存在,添加(正常获取到锁)
    redis.call('hset', key, threadId, 1)
    -- 更新锁的过期时间
    redis.call('expire', key, timeoutSec)
    return 1
end
-- 如果锁存在,检查是否当前线程的锁
if (redis.call('HEXISTS', key, threadId) == 0) then
    -- 如果不是当前线程的锁,返回错误信息(互斥,没有获取到锁)
    return 0
end
-- 是当前线程的锁(再入)
-- 计数器+1
redis.call('HINCRBY', key, threadId, 1)
-- 更新过期时长
redis.call('expire', key, timeoutSec)
return 1
--[[
    @描述:Redis 锁释放(支持再入)
]] --
local key = KEYS[1] -- Redis 锁的对应的 key
local threadId = ARGV[1] -- 持有锁的线程标识
local timeoutSec = ARGV[2] -- 锁的自动过期时长(单位秒)
-- 检查锁是否已经存在
if (redis.call('exists', key) == 0) then
    -- 锁不存在,返回错误信息
    return 0
end
-- 锁存在,检查是否当前线程持有的锁
if (redis.call('HEXISTS', key, threadId) == 0) then
    -- 不是当前线程持有的锁,返回错误信息
    return 0
end
-- 是当前线程持有的锁,计数器-1
redis.call('HINCRBY', key, threadId, -1)
-- 如果计数器小于等于0,删除锁
if (tonumber(redis.call('HGET', key, threadId)) <= 0) then
    redis.call('del', key)
    return 1
end
-- 如果计数器还未归0,更新锁的有效时长
redis.call('expire', key, timeoutSec)
return 1

需要注意的是,与之前不同的是,再次获取锁和释放锁的时候都需要更新锁的有效时长,以确保之后的业务能在锁生效期内正常执行完毕。

修改锁的实现类,用 Lua 脚本获取和释放锁:

// ...
public class SimpleRedisLock implements ILock {
    // ...
    // Redis 锁获取脚本
    private static final DefaultRedisScript<Long> LOCK_SCRIPT;

    static {
        LOCK_SCRIPT = new DefaultRedisScript<>();
        // 指定脚本的位置
        LOCK_SCRIPT.setLocation(new ClassPathResource("reentrant-lock.lua"));
        // 指定脚本的返回值类型
        LOCK_SCRIPT.setResultType(Long.class);
    }

    // Redis 锁释放脚本
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        // 指定脚本的位置
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("reentrant-unlock.lua"));
        // 指定脚本的返回值类型
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

	// ...

    @Override
    public boolean tryLock(long timeoutSec) {
        final String jvmThreadId = getJvmThreadId();
        Long res = stringRedisTemplate.execute(LOCK_SCRIPT,
                Collections.singletonList(redisKey),
                jvmThreadId,
                Long.toString(timeoutSec));
        return res != null && res > 0;
    }
 	// ...

    @Override
    public void unlock(long timeoutSec) {
        // 使用 lua 脚本删除 Redis 锁
        stringRedisTemplate.execute(UNLOCK_SCRIPT,
                Collections.singletonList(redisKey),
                getJvmThreadId(),
                Long.toString(timeoutSec));
    }

    @Override
    public void unlock() {
        unlock(200);
    }
}

查看 Redisson 源码可以发现,其实现方式和上文描述的是类似的。

重试机制

Redisson 提供的分布式锁具备获取锁失败后进行重试的机制,且这种机制是基于 Redis 订阅和信号量的方式实现的,会有效避免 CPU 计算资源的浪费。此外,调用 API 时如果指定锁的过期时长为 -1,Redisson 会将锁的在 Redis 中的有效时长设置一个默认值(30秒),并启动一个守护进程(WatchDog)来定期重新刷新其有效时长,以保证该锁的长期有效。在释放锁的时候,该守护进程会被终止。

整个过程可以用下图表示:

image-20250504162053935

这里的ttl指获取锁的 Lua 脚本的返回值,如果锁成功获取,会返回 null,获取锁失败,会返回锁的剩余有效时长。

详细的源码分析和说明可以参考这个视频。

联锁

如果 Redisson 实现的锁是基于单个 Redis 的,那么是没有问题的,反之,如果是主从同步的集群,之前所使用的锁就会存在问题:

image-20250504172734538

如果从主节点获取锁成功,但还未将锁同步到其他从节点时主节点宕机,锁就会“丢失”。

这个问题可以用联锁来解决:

image-20250504172915751

即不使用主从,而是使用多台独立的 Redis 获取锁,只有从所有 Redis 获取锁成功,才算是成功,否则视为获取锁失败。在这种情况下,任意 Redis 宕机都不会导致锁失效。

为了演示,额外启动两个 Redis 实例:

docker run --name my-redis -p 6380:6379 -d redis
docker run --name my-redis2 -p 6381:6379 -d redis

关于如何用 Docker 部署 Redis 可以参考这里。

在配置为文件中添加两个 Redis 服务的配置信息:

my-config:
  redis1:
    host: 192.168.0.88
    port: 6380
  redis2:
    host: 192.168.0.88
    port: 6381

创建配置类读取该信息:

@Configuration
@ConfigurationProperties(prefix = "my-config")
@Data
public class MyConfigProperties {
    @Data
    public static class RedisConfig{
        private String host;
        private String port;
    }
    private RedisConfig redis1;
    private RedisConfig redis2;
}

创建对应的 Redisson 客户端:

@Configuration
public class RedisConfig {
    // ...
    @Autowired
    MyConfigProperties myConfig;
    
    // ...

    @Bean
    public RedissonClient redissonClient2(){
        Config config = new Config();
        String address = String.format("redis://%s:%s",
                myConfig.getRedis1().getHost(),
                myConfig.getRedis1().getPort());
        config.useSingleServer()
                .setAddress(address)
                .setPassword(redisProperties.getPassword());
        return Redisson.create(config);
    }

    @Bean
    public RedissonClient redissonClient3(){
        Config config = new Config();
        String address = String.format("redis://%s:%s",
                myConfig.getRedis2().getHost(),
                myConfig.getRedis2().getPort());
        config.useSingleServer()
                .setAddress(address)
                .setPassword(redisProperties.getPassword());
        return Redisson.create(config);
    }
}

修改测试用例,使用联锁:

// ...
@Log4j2
@SpringBootTest
public class RedisLockTests {
    // ...
    @Resource
    private RedissonClient redissonClient;
    @Resource
    private RedissonClient redissonClient2;
    @Resource
    private RedissonClient redissonClient3;

    /**
     * 测试 Redis 锁是否可以再入
     */
    @Test
    public void test() throws InterruptedException {
        // 创建 Redisson 联锁
        String lockName = "lock:test";
        RLock lock1 = redissonClient.getLock(lockName);
        RLock lock2 = redissonClient2.getLock(lockName);
        RLock lock3 = redissonClient3.getLock(lockName);
        RLock lock = redissonClient.getMultiLock(lock1, lock2, lock3);
        boolean tryLock = lock.tryLock(10, TimeUnit.SECONDS);
        // ...
    }

    private void test2(RLock lock) throws InterruptedException {
        // ...
    }
}

和单个锁类似,联锁同样可以指定等待时间以进行重试。

关于 Redisson 联锁的源码分析可以看这里。

本文的所有示例代码可以从这里获取。

The End.

参考资料

  • 黑马程序员Redis入门到实战教程

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2380155.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

游戏开发实战(一):Python复刻「崩坏星穹铁道」嗷呜嗷呜事务所---源码级解析该小游戏背后的算法与设计模式【纯原创】

文章目录 奇美拉项目游戏规则奇美拉(Chimeras)档案领队成员 结果展示&#xff1a; 奇美拉项目 由于项目工程较大&#xff0c;并且我打算把我的思考过程和实现过程中踩过的坑都分享一下&#xff0c;因此会分3-4篇博文详细讲解本项目。本文首先介绍下游戏规则并给出奇美拉档案。…

02- 浏览器运行原理

文章目录 1. 网页的解析过程浏览器内核 2. 浏览器渲染流程2.1 解析html2.2 生成css规则2.3 构建render tree2.4 布局(Layout)2.5 绘制(Paint) 3. 回流和重绘3.1 回流reflow&#xff08;1&#xff09;理解&#xff1a;&#xff08;2&#xff09;出现情况 3.2 重绘repaint&#x…

移除链表元素数据结构oj题(力扣题206)

目录 题目描述&#xff1a; 题目解读&#xff08;分析&#xff09; 解决代码 题目描述&#xff1a; 给你一个链表的头节点 head 和一个整数 val &#xff0c;请你删除链表中所有满足 Node.val val 的节点&#xff0c;并返回 新的头节点 。 题目解读&#xff08;分析&#…

OpenTelemetry 从入门到精通

快速入门 OpenTelemetry 是一个可观测性框架和工具包&#xff0c; 旨在创建和管理遥测数据&#xff0c;如链路、 指标和日志。 重要的是&#xff0c;OpenTelemetry 是供应商和工具无关的&#xff0c;这意味着它可以与各种可观测性后端一起使用&#xff0c; 包括 Jaeger 和 Pro…

【免杀】C2免杀技术(六)进程镂空(傀儡进程)

一、技术定位与核心思想 进程镂空&#xff08;Process Hollowing&#xff09;属于 MITRE ATT&CK 中 T1055.012 子技术&#xff1a;先创建一个合法进程并挂起&#xff0c;随后把其主模块从内存“掏空”并替换为恶意映像&#xff0c;最后恢复线程执行&#xff0c;从而让…

Eclipse Java 开发调优:如何让 Eclipse 运行更快?

Eclipse Java 开发调优&#xff1a;如何让 Eclipse 运行更快&#xff1f; 在 Java 开发领域&#xff0c;Eclipse 是一款被广泛使用的集成开发环境&#xff08;IDE&#xff09;。然而&#xff0c;随着项目的日益庞大和复杂&#xff0c;Eclipse 的运行速度可能会逐渐变慢&#x…

彻底理解事件循环(Event Loop):从单线程到异步世界的桥梁

关于事件循环被问了很多次&#xff0c;也遇到过很多次&#xff0c;一直没有系统整理&#xff0c;网上搜的&#xff0c;基本明白但总感觉不够透彻&#xff0c;最后&#xff0c;自己动手&#xff0c;丰衣足食&#xff0c;哈哈 一、为什么需要事件循环&#xff1f;—— 单线程的困…

Linux(2)——shell原理及Linux中的权限

目录 一、shell的运行原理 二、Linux中权限的问题 1.权限的概念 2.如何进行用户的切换 1&#xff09;从普通用户切到超级用户 2&#xff09;从root用户切到普通用户 3.如何实现提权操作 4.如何将普通用户添加到信用列表&#xff08;sudoers&#xff09; ​编辑5.Lin…

如何在线免费压缩PDF文档?

PDF文件太大&#xff0c;通常是因为内部嵌入字体和图片。怎么才能将文件大小减减肥呢&#xff0c;主要有降低图片清晰度和去除相关字体两个方向来实现文档效果。接下来介绍三个免费压缩PDF实用工具。 &#xff08;一&#xff09;iLoveOFD在线转换工具 iLoveOFD在线转换工具&a…

汽车装配又又又升级,ethernetip转profinet进阶跃迁指南

1. 场景描述&#xff1a;汽车装配线中&#xff0c;使用EtherNet/IP协议的机器人与使用PROFINET协议的PLC进行数据交互。 2. 连接设备&#xff1a;EtherNet/IP机器人控制器&#xff08;如ABB、FANUC&#xff09;与PROFINET PLC&#xff08;如西门子S7-1500&#xff09;。 3. 连…

css:无限滚动波浪线

以上是需要实现的效果&#xff0c;一条无限滚动波浪线&#xff0c;可以用来做区块的分割线。 要形成上下交替的圆形&#xff0c;思路是给div加圆角边框&#xff0c;第一个只有上边框&#xff0c;第二个只有下边框。 循环了100个div&#xff0c;这个数量根据自己容器宽度调整&…

w~自动驾驶~合集3

我自己的原文哦~ https://blog.51cto.com/whaosoft/13269720 #FastOcc 推理更快、部署友好Occ算法来啦&#xff01; 在自动驾驶系统当中&#xff0c;感知任务是整个自驾系统中至关重要的组成部分。感知任务的主要目标是使自动驾驶车辆能够理解和感知周围的环境元素&…

山东大学计算机图形学期末复习整理5——CG10上

CG10上 Frenet-Serret框架 空间中一条曲线可以写成参数形式&#xff1a; C ( u ) ( x ( u ) , y ( u ) , z ( u ) ) \mathbf{C}(u) (x(u), y(u), z(u)) C(u)(x(u),y(u),z(u)) 这表示&#xff1a;当参数 u u u 变化时&#xff0c;曲线在三维空间中移动&#xff0c;生成一条轨…

STM32移植LVGL8.3 (保姆级图文教程)

目录 前言设备清单2.8寸TFT-LCD屏原理与应用1️⃣基本参数2️⃣引脚说明3️⃣程序移植4️⃣硬件接线 LVGL8.3 移植流程1️⃣硬件及平台要求2️⃣版本说明3️⃣源码下载4️⃣源码移植 工程配置修改配置文件1️⃣lvgl_config.h2️⃣适配屏幕驱动3️⃣配置输入设备(触摸功能) 提供…

虚幻引擎5-Unreal Engine笔记之Default Pawn与GamMode、Camera的关系

虚幻引擎5-Unreal Engine笔记之Default Pawn与GamMode、Camera的关系 code review! 文章目录 虚幻引擎5-Unreal Engine笔记之Default Pawn与GamMode、Camera的关系1.Default Pawn与Camera的关系1.1. Default Pawn 是什么&#xff1f;1.2. Default Pawn 的主要组件1.3. Default…

C++多态的详细讲解

【本节目标】 1. 多态的概念 2. 多态的定义及实现 3. 抽象类 4. 多态的原理 5. 单继承和多继承关系中的虚函数表 前言 需要声明的&#xff0c;本博客中的代码及解释都是在 vs2013 下的 x86 程序中&#xff0c;涉及的指针都是 4bytes 。 如果要其他平台下&#xff0c;部…

vue项目启动报错

vue项目启动报错 一、问题二、解决 一、问题 从vue2更换到vue3之后&#xff0c;需要将node进行版本升级&#xff0c;之后启动项目出现了下面的问题。 Uncaught Error: A route named “PageNotFound” has been added as a child of a route with the same name. Route names …

免费私有化部署! PawSQL社区版,超越EverSQL的企业级SQL优化工具面向个人开发者开放使用了

1. 概览 1.1 快速了解 PawSQL PawSQL是专注于数据库性能优化的企业级工具&#xff0c;解决方案覆盖SQL开发、测试、运维的整个流程&#xff0c;提供智能SQL审核、查询重写优化及自动化巡检功能&#xff0c;支持MySQL、PostgreSQL、Oracle、SQL Server等主流数据库及达梦、金仓…

SecureCRT 使用指南:安装、设置与高效操作

目录 一、SecureCRT 简介 1.1 什么是 SecureCRT&#xff1f; 1.2 核心功能亮点 1.3 软件特点 二、SecureCRT 安装与激活 2.1 安装步骤&#xff08;Windows 系统&#xff09; 2.2 激活与破解&#xff08;仅供学习参考&#xff09; 三、基础配置与优化 3.1 界面与编码设…

Tomcat多应用部署与静态资源路径问题全解指南

&#x1f9d1; 博主简介&#xff1a;CSDN博客专家、CSDN平台优质创作者&#xff0c;高级开发工程师&#xff0c;数学专业&#xff0c;10年以上C/C, C#, Java等多种编程语言开发经验&#xff0c;拥有高级工程师证书&#xff1b;擅长C/C、C#等开发语言&#xff0c;熟悉Java常用开…