Redis 学习笔记 5:分布式锁
Redis 学习笔记 5:分布式锁
在前文中学习了如何基于 Redis 创建一个简单的分布式锁。虽然在大多数情况下这个锁已经可以满足需要,但其依然存在以下缺陷:
事实上一般而言,我们可以直接使用 Redisson 提供的分布式锁而非自己创建。
Redisson
添加 Redisson 依赖:
<dependency><groupId>org.redisson</groupId><artifactId>redisson</artifactId><version>3.13.6</version>
</dependency>
创建 Redisson 客户端实例:
@Configuration
public class RedisConfig {@AutowiredRedisProperties redisProperties;@Beanpublic 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 {// ...@Autowiredprivate RedissonClient redissonClient;// ...@Overridepublic 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 {@Autowiredprivate StringRedisTemplate redisTemplate;/*** 测试 Redis 锁是否可以再入*/@Testpublic 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) thenredis.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);}// ...@Overridepublic 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;}// ...@Overridepublic void unlock(long timeoutSec) {// 使用 lua 脚本删除 Redis 锁stringRedisTemplate.execute(UNLOCK_SCRIPT,Collections.singletonList(redisKey),getJvmThreadId(),Long.toString(timeoutSec));}@Overridepublic void unlock() {unlock(200);}
}
查看 Redisson 源码可以发现,其实现方式和上文描述的是类似的。
重试机制
Redisson 提供的分布式锁具备获取锁失败后进行重试的机制,且这种机制是基于 Redis 订阅和信号量的方式实现的,会有效避免 CPU 计算资源的浪费。此外,调用 API 时如果指定锁的过期时长为 -1,Redisson 会将锁的在 Redis 中的有效时长设置一个默认值(30秒),并启动一个守护进程(WatchDog)来定期重新刷新其有效时长,以保证该锁的长期有效。在释放锁的时候,该守护进程会被终止。
整个过程可以用下图表示:
这里的
ttl
指获取锁的 Lua 脚本的返回值,如果锁成功获取,会返回 null,获取锁失败,会返回锁的剩余有效时长。
详细的源码分析和说明可以参考这个视频。
联锁
如果 Redisson 实现的锁是基于单个 Redis 的,那么是没有问题的,反之,如果是主从同步的集群,之前所使用的锁就会存在问题:
如果从主节点获取锁成功,但还未将锁同步到其他从节点时主节点宕机,锁就会“丢失”。
这个问题可以用联锁来解决:
即不使用主从,而是使用多台独立的 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.88port: 6380redis2:host: 192.168.0.88port: 6381
创建配置类读取该信息:
@Configuration
@ConfigurationProperties(prefix = "my-config")
@Data
public class MyConfigProperties {@Datapublic static class RedisConfig{private String host;private String port;}private RedisConfig redis1;private RedisConfig redis2;
}
创建对应的 Redisson 客户端:
@Configuration
public class RedisConfig {// ...@AutowiredMyConfigProperties myConfig;// ...@Beanpublic 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);}@Beanpublic 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 {// ...@Resourceprivate RedissonClient redissonClient;@Resourceprivate RedissonClient redissonClient2;@Resourceprivate RedissonClient redissonClient3;/*** 测试 Redis 锁是否可以再入*/@Testpublic 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入门到实战教程
相关文章:

Redis 学习笔记 5:分布式锁
Redis 学习笔记 5:分布式锁 在前文中学习了如何基于 Redis 创建一个简单的分布式锁。虽然在大多数情况下这个锁已经可以满足需要,但其依然存在以下缺陷: 事实上一般而言,我们可以直接使用 Redisson 提供的分布式锁而非自己创建。…...

游戏开发实战(一):Python复刻「崩坏星穹铁道」嗷呜嗷呜事务所---源码级解析该小游戏背后的算法与设计模式【纯原创】
文章目录 奇美拉项目游戏规则奇美拉(Chimeras)档案领队成员 结果展示: 奇美拉项目 由于项目工程较大,并且我打算把我的思考过程和实现过程中踩过的坑都分享一下,因此会分3-4篇博文详细讲解本项目。本文首先介绍下游戏规则并给出奇美拉档案。…...
VS2017编译librdkafka 2.1.0
VS2017编译librdkafka 2.1.0 本篇是 Windows系统编译Qt使用的kafka(librdkafka)系列中的其中一篇,编译librdkafka整体步骤大家可以参考: Windows系统编译Qt使用的kafka(librdkafka) 由于项目需要,使用kafka,故自己编译了一次,编译的过程,踩了太多的坑了,特写了本篇…...

02- 浏览器运行原理
文章目录 1. 网页的解析过程浏览器内核 2. 浏览器渲染流程2.1 解析html2.2 生成css规则2.3 构建render tree2.4 布局(Layout)2.5 绘制(Paint) 3. 回流和重绘3.1 回流reflow(1)理解:(2)出现情况 3.2 重绘repaint&#x…...
Reactor模型详解与C++实现
Reactor模型详解与C实现 一、Reactor模型核心思想 Reactor模式是一种事件驱动的并发处理模型,核心通过同步I/O多路复用实现对多个I/O源的监听,当有事件触发时,派发给对应处理器进行非阻塞处理。 关键特征: 非阻塞I/Oÿ…...
人工智能重塑医疗健康:从辅助诊断到个性化治疗的全方位变革
人工智能正在以前所未有的速度改变着医疗健康领域,从影像诊断到药物研发,从医院管理到远程医疗,AI 技术已渗透到医疗服务的各个环节。本文将深入探讨人工智能如何赋能医疗健康产业,分析其在医学影像、临床决策、药物研发、个性化医…...

移除链表元素数据结构oj题(力扣题206)
目录 题目描述: 题目解读(分析) 解决代码 题目描述: 给你一个链表的头节点 head 和一个整数 val ,请你删除链表中所有满足 Node.val val 的节点,并返回 新的头节点 。 题目解读(分析&#…...
学习记录:DAY29
项目开发日志:技术实践与成长之路 前言 回顾这几天的状态,热情总是比我想象中更快被消耗完。比起茫然徘徊的小丑,我更希望自己是对着风车冲锋的疯子。 今天继续深入项目的实际业务。 状态好点的时候,再看自己EMO时写的东西&…...

OpenTelemetry 从入门到精通
快速入门 OpenTelemetry 是一个可观测性框架和工具包, 旨在创建和管理遥测数据,如链路、 指标和日志。 重要的是,OpenTelemetry 是供应商和工具无关的,这意味着它可以与各种可观测性后端一起使用, 包括 Jaeger 和 Pro…...
数学复习笔记 17
前言 复盘泰勒公式,极限四则运算,洛必达,拉格朗日。 1.27 因为是复习泰勒公式,所以就算有别的方法,我也硬是要用泰勒公式。就是为了记一下泰勒公式。泰勒公式确实是能做,但是做的我非常非常难受。公式确…...
C语言:在操作系统中,链表有什么应用?
在操作系统中,链表是一种重要的数据结构,凭借其灵活的内存管理和高效的插入/删除特性,被广泛应用于多个核心模块。以下是其主要应用场景及详细说明: 1. 内存管理:空闲内存块管理 应用场景:操作系统需要管…...
解锁MySQL性能调优:高级SQL技巧实战指南
高级SQL技巧:解锁MySQL性能调优的终极指南 开篇 当前,随着业务系统的复杂化和数据量的爆炸式增长,数据库性能调优成为了技术人员面临的核心挑战之一。尤其是在高并发、大数据量的场景下,SQL 查询的性能直接影响到整个系统的响应…...
裸金属服务器和云服务器之间的差别
裸金属服务器能够直接在硬件上运行,不需要额外的虚化层,让每个应用程序或者是服务都能够在实际的硬件上运行,不需要和其他虚拟服务器来共享资源;而云服务器作为一种虚拟服务器,是通过虚拟化技术为企业提供一个独立的计…...
WebSocket实时双向通信:从基础到实战
一、WebSocket 基础概念 1. 什么是 WebSocket? 双向通信协议:与 HTTP 的单向请求不同,WebSocket 支持服务端和客户端实时双向通信。 低延迟:适用于聊天室、实时数据推送、在线游戏等场景。 协议标识:ws://ÿ…...

【免杀】C2免杀技术(六)进程镂空(傀儡进程)
一、技术定位与核心思想 进程镂空(Process Hollowing)属于 MITRE ATT&CK 中 T1055.012 子技术:先创建一个合法进程并挂起,随后把其主模块从内存“掏空”并替换为恶意映像,最后恢复线程执行,从而让…...
ETL数据集成产品选型需要关注哪些方面?
ETL(Extract,Transform,Load)工具作为数据仓库和数据分析流程中的关键环节,其选型对于企业的数据战略实施有着深远的影响。谷云科技在 ETL 领域耕耘多年,通过自身产品的实践应用,对 ETL 产品选型…...

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

彻底理解事件循环(Event Loop):从单线程到异步世界的桥梁
关于事件循环被问了很多次,也遇到过很多次,一直没有系统整理,网上搜的,基本明白但总感觉不够透彻,最后,自己动手,丰衣足食,哈哈 一、为什么需要事件循环?—— 单线程的困…...
java加强 -stream流
Stream流是jdk8开始新增的一套api,可以用于操作集合或数组的内容。 Stream流大量的结合了Lambda的语法风格来编程,功能强大,性能高效,代码简洁,可读性好。 体验Stream流 把集合中所有以三开头并且三个字的元素存储到…...
Vue百日学习计划Day33-35天详细计划-Gemini版
总目标: 在 Day 33-35 理解 Vue 组件从创建到销毁的完整生命周期,熟练掌握 Composition API 中主要的生命周期钩子,并知道在不同阶段执行哪些操作。 所需资源: Vue 3 官方文档 (生命周期钩子): https://cn.vuejs.org/guide/essentials/lifecycle.html你…...

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

如何在线免费压缩PDF文档?
PDF文件太大,通常是因为内部嵌入字体和图片。怎么才能将文件大小减减肥呢,主要有降低图片清晰度和去除相关字体两个方向来实现文档效果。接下来介绍三个免费压缩PDF实用工具。 (一)iLoveOFD在线转换工具 iLoveOFD在线转换工具&a…...
EasyExcel动态表头
专家官方解答 : 在使用EasyExcel处理Excel动态表头的问题时,官方并不推荐使用includecolumnfieldnames方法。根据提供的知识内容,以下是如何实现动态表头的详细步骤和解释: 原因分析 动态表头的需求通常来源于希望根据用户的选…...

汽车装配又又又升级,ethernetip转profinet进阶跃迁指南
1. 场景描述:汽车装配线中,使用EtherNet/IP协议的机器人与使用PROFINET协议的PLC进行数据交互。 2. 连接设备:EtherNet/IP机器人控制器(如ABB、FANUC)与PROFINET PLC(如西门子S7-1500)。 3. 连…...

css:无限滚动波浪线
以上是需要实现的效果,一条无限滚动波浪线,可以用来做区块的分割线。 要形成上下交替的圆形,思路是给div加圆角边框,第一个只有上边框,第二个只有下边框。 循环了100个div,这个数量根据自己容器宽度调整&…...
显示器无法接受键盘/鼠标问题解决
我们将键盘、鼠标的u盘插到显示器上后,仍然无法通过键盘和鼠标操控显示器是因为我们的显示器和笔记本/主机之间的连接只有一个typec对typec,无法满足信号传输 我们需要一根上行线:一头 typec/usb 接到主机/笔记本,然后另一头是 m…...

w~自动驾驶~合集3
我自己的原文哦~ https://blog.51cto.com/whaosoft/13269720 #FastOcc 推理更快、部署友好Occ算法来啦! 在自动驾驶系统当中,感知任务是整个自驾系统中至关重要的组成部分。感知任务的主要目标是使自动驾驶车辆能够理解和感知周围的环境元素&…...
<C++> MFC自动关闭对话框(MessageBoxTimeout)
MFC自动关闭对话框(MessageBoxTimeout) 记录一下今天在界面开发中的解决方案。自动关闭对话框有两种方案: 1.使用定时器实现延迟关闭(DeepSeek方案) 提示框显示几秒后自动关闭,可以使用 SetTimer KillT…...

山东大学计算机图形学期末复习整理5——CG10上
CG10上 Frenet-Serret框架 空间中一条曲线可以写成参数形式: 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)) 这表示:当参数 u u u 变化时,曲线在三维空间中移动,生成一条轨…...

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