【自学笔记】Redis 飞快入门
前言
本文主要是以快速了解Redis、快速写出用Redis解决常见业务代码为主,不会涉及到太多的细节,即使不敲一行代码也能让你有所收获,适合面向快速上手Redis的小伙伴。
视频教程:黑马程序员Redis入门到实战教程,深度透析redis底层原理+redis分布式锁+企业解决方案+黑马点评实战项目_哔哩哔哩_bilibili
学习的源码:
本文主要是记录本人自学的过程,有参考其他笔记~
如果对你有帮助,可以看看我的主页,里面有很多个人总结的Redis的面试热题,喜欢的话点个赞或者关注贝~
一、基础篇
1.Redis是什么
Redis(Remote Dictionary Server)是一个开源的、基于内存的键值存储系统(Key-Value Store),也是一种键值型的NoSql数据库。它通常被用作数据库、缓存、消息中间件和流引擎。
Redis的官方网站地址:Redis - The Real-time Data Platform
主要特点:
键值(key-value)型,value支持多种不同数据结构,功能丰富
单线程,每个命令具备原子性
低延迟,速度快(基于内存.IO多路复用.良好的编码)。
支持数据持久化
支持主从集群.分片集群
支持多语言客户端
2.Redis有什么用
缓存: 最常用场景,加速数据访问(如数据库查询结果、页面片段)。
会话存储: 存储用户会话信息(如登录状态)。
排行榜: 利用 Sorted Set(如点赞最早用户)。
计数器: 利用
INCR/DECR(如浏览量、点赞数)。消息队列: 利用 List(简单队列)或 Streams(更复杂的消息队列,支持消费者组、消息确认)。
实时系统: 利用 Pub/Sub(如简单的聊天室、通知)。
地理位置应用: 利用 Geospatial Indexes。
标签系统/社交关系: 利用 Set(如共同好友、兴趣标签)。
分布式锁: 利用
SET key value NX PX milliseconds(或SETNX+EXPIRE,需注意原子性问题)。限流: 利用计数器 + 过期时间(如
INCR+EXPIRE)或更复杂的算法(如滑动窗口,可用 ZSet 实现)。
3.Redis怎么用
3.1.安装Redis
这里是基于linux服务器来部署的,因为在linux服务器里才能发挥Redis的最大性能。
1)安装依赖库
Redis是基于C语言编写的,因此首先需要安装Redis所需要的gcc依赖:
yum install -y gcc tcl
注意:现在需要对yum换源才能安装成功,参考视频:CentOS更换yum源哔哩哔哩bilibili
2)上传安装包并解压

我放到了/usr/local/src 目录,选择该文件src用终端打开,然后输入命令解压:
tar -xzf redis-6.2.6.tar.gz
选择解压后的文件redis-6.2.6用终端打开,然后输入命令运行编译:
make && make install
默认的安装路径是在 /usr/local/bin目录下,该目录已经默认配置到环境变量,因此可以在任意目录下运行命令。
redis-cli:是redis提供的命令行客户端
redis-server:是redis的服务端启动脚本(默认启动)
redis-sentinel:是redis的哨兵启动脚本

3)相关配置
要让Redis可以后台启动,则必须修改Redis配置文件,就在我们之前解压的redis安装包下(/usr/local/src/redis-6.2.6),名字叫redis.conf:
我们先将这个配置文件备份一份:
cp redis.conf redis.conf.bck
然后修改redis.conf文件中的一些配置:(重要)
# 允许访问的地址,默认是127.0.0.1,会导致只能在本地访问,外界访问都是拒绝。修改为0.0.0.0则可以在任意IP访问,生产环境不要设置为0.0.0.0(已修改)
bind 0.0.0.0
# 守护进程,修改为yes后即可后台运行(已修改)
daemonize yes
# 密码,设置后访问Redis必须输入密码(已修改)
requirepass 123456
4)开机自启
首先,新建一个系统服务文件:
vi /etc/systemd/system/redis.service
内容如下:
[Unit]
Description=redis-server
After=network.target
[Service]
Type=forking
ExecStart=/usr/local/bin/redis-server /usr/local/src/redis-6.2.6/redis.conf
PrivateTmp=true
[Install]
WantedBy=multi-user.target
然后重载系统服务:
systemctl daemon-reload
执行下面的命令,可以让redis开机自启:
systemctl enable redis
5)idea连接Redis
点击右侧的database,点击“+”号

输入你的Host(IP地址)和Password

点击测试连接

3.2.Redis数据结构
Redis是一个key-value的数据库,key一般是String类型,不过value的类型多种多样:

基本类型
| 数据类型 | 存储结构 | 常用命令 | 典型应用场景 | 特性说明 |
|---|---|---|---|---|
| String | 二进制安全的字符串 | SET, GET, INCR, DECR | 缓存、计数器、分布式锁 | 可存文本、数字(≤512MB) |
| Hash | 键值对集合(类似 Map) | HSET, HGET, HGETALL | 存储对象(如用户信息、商品属性) | 适合存储结构化数据 |
| List | 双向链表 | LPUSH, RPOP, LRANGE | 消息队列、最新消息列表、历史记录 | 支持按索引操作,元素可重复 |
| Set | 无序集合(元素唯一) | SADD, SMEMBERS, SINTER | 标签系统、好友关系、抽奖(去重) | 支持交并差集运算 |
| Sorted Set | 有序集合(元素唯一+分数排序) | ZADD, ZRANGE, ZRANK | 排行榜、延迟队列、带权重的任务调度 | 通过分数(score)自动排序 |
高级类型
| 数据类型 | 用途 | 示例命令 | 场景案例 |
|---|---|---|---|
| Bitmaps | 位操作(节省空间的布尔存储) | SETBIT, GETBIT, BITCOUNT | 用户签到统计、实时活跃度分析 |
| HyperLogLog | 近似去重计数(误差率 0.81%) | PFADD, PFCOUNT | 大规模 UV 统计(如每日访问用户数) |
| GEO | 地理位置存储与查询 | GEOADD, GEORADIUS | 附近的人、商家定位 |
| Stream | 消息流(支持消费者组) | XADD, XREAD, XGROUP | 类似 Kafka 的消息队列(Redis 5.0+) |
| Bloom Filter | 高效存在性判断(需模块扩展) | BF.ADD, BF.EXISTS | 防止缓存穿透、垃圾邮件过滤 |
3.3.Redis常见命令(了解)
1)通用命令:
KEYS:查看符合模板的所有key
DEL:删除一个指定的key
EXISTS:判断key是否存在
EXPIRE:给一个key设置有效期,有效期到期时该key会被自动删除
TTL:查看一个KEY的剩余有效期
2)String的常见命令有:
SET:添加或者修改已经存在的一个String类型的键值对
GET:根据key获取String类型的value
MSET:批量添加多个String类型的键值对
MGET:根据多个key获取多个String类型的value
INCR:让一个整型的key自增1
INCRBY:让一个整型的key自增并指定步长,例如:incrby num 2 让num值自增2
INCRBYFLOAT:让一个浮点类型的数字自增并指定步长
SETNX:添加一个String类型的键值对,前提是这个key不存在,否则不执行
SETEX:添加一个String类型的键值对,并且指定有效期
3)Hash类型的常见命令
HSET key field value:添加或者修改hash类型key的field的值
HGET key field:获取一个hash类型key的field的值
HMSET:批量添加多个hash类型key的field的值
HMGET:批量获取多个hash类型key的field的值
HGETALL:获取一个hash类型的key中的所有的field和value
HKEYS:获取一个hash类型的key中的所有的field
HINCRBY:让一个hash类型key的字段值自增并指定步长
HSETNX:添加一个hash类型的key的field值,前提是这个field不存在,否则不执行
4)List的常见命令有:
LPUSH key element ... :向列表左侧插入一个或多个元素
LPOP key:移除并返回列表左侧的第一个元素,没有则返回nil
RPUSH key element ... :向列表右侧插入一个或多个元素
RPOP key:移除并返回列表右侧的第一个元素
LRANGE key star end:返回一段角标范围内的所有元素
BLPOP和BRPOP:与LPOP和RPOP类似,只不过在没有元素时等待指定时间,而不是直接返回nil
5)Set类型的常见命令
SADD key member ... :向set中添加一个或多个元素
SREM key member ... : 移除set中的指定元素
SCARD key: 返回set中元素的个数
SISMEMBER key member:判断一个元素是否存在于set中
SMEMBERS:获取set中的所有元素
SINTER key1 key2 ... :求key1与key2的交集
SDIFF key1 key2 ... :求key1与key2的差集
SUNION key1 key2 ..:求key1和key2的并集
6)SortedSet的常见命令有:
ZADD key score member:添加一个或多个元素到sorted set ,如果已经存在则更新其score值
ZREM key member:删除sorted set中的一个指定元素
ZSCORE key member : 获取sorted set中的指定元素的score值
ZRANK key member:获取sorted set 中的指定元素的排名
ZCARD key:获取sorted set中的元素个数
ZCOUNT key min max:统计score值在给定范围内的所有元素的个数
ZINCRBY key increment member:让sorted set中的指定元素自增,步长为指定的increment值
ZRANGE key min max:按照score排序后,获取指定排名范围内的元素
ZRANGEBYSCORE key min max:按照score排序后,获取指定score范围内的元素
ZDIFF.ZINTER.ZUNION:求差集.交集.并集
3.4.Redis的Java客户端
在Redis官网中提供了各种语言的客户端,地址:https://redis.io/docs/clients/
标记为❤的就是推荐使用的java客户端,包括:
Jedis和Lettuce:这两个主要是提供了Redis命令对应的API,方便我们操作Redis,而SpringDataRedis又对这两种做了抽象和封装,因此我们后期会直接以SpringDataRedis来学习。
Redisson:是在Redis基础上实现了分布式的可伸缩的java数据结构,例如Map.Queue等,而且支持跨进程的同步机制:Lock.Semaphore等待,比较适合用来实现特殊的功能需求。
二、实战篇
这部分主要是以黑马点评项目的后端为主,一边做项目一边学习Redis的核心知识,并且利用Redis解决常见的问题。
1.基于Redis实现短信登录
1.1.基于session实现登录
关于使用session校验登录状态:
用户在请求时候,会从cookie中携带者JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到threadLocal中,并且放行。
UserServiceImpl:
发送验证码:
用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号。如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号格式
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码到 session
session.setAttribute("code",code);
// 5.发送验证码
log.debug("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
}
登录/注册:
用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if(cacheCode == null || !cacheCode.toString().equals(code)){
//3.不一致,报错
return Result.fail("验证码错误");
}
//一致,根据手机号查询用户
User user = query().eq("phone", phone).one();
//5.判断用户是否存在
if(user == null){
//不存在,则创建
user = createUserWithPhone(phone);
}
//6.保存用户信息到session中
session.setAttribute("user", BeanUtils.copyProperties(user,UserDTO.class));
return Result.ok();
}
1.2.Redis代替session实现
redis数据本身就是共享的(主从复制/搭建redis集群/哨兵模式) ,使用Redis解决session不能共享的问题。由于存入的数据比较简单,我们可以考虑使用String,或者是使用哈希。
使用string结构来存储验证码
使用hash结构存储登录用户状态信息

需求分析:
1)发送验证码
用户点击发送验证码时,后端会先校验手机号格式,格式正确则随机生成验证码code,以前缀+手机号作为key,以string格式存储验证码code作为value,保存在redis中,最后记得添加过期时间。
2)用户登录
当注册完成后,用户去登录会去校验用户提交的手机号和验证码,是否一致,如果一致,则根据手机号查询用户信息,不存在则新建用户,并且以前缀+token作为key,以对象转map的hash格式作为value,最后将用户数据保存到redis。
当我们校验用户是否登录时,会去携带着token进行访问,从redis中取出token对应的value,判断是否存在这个数据,如果没有则拦截(拦截这块就不细讲了),如果存在则将其保存到threadLocal中,并且放行。

代码:
修改UserServiceImpl:
发送短信:
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码到 redis
//加前缀,和其他业务区分开
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
// 5.发送验证码
log.debug("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
}
登录/注册:
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.从redis获取验证码并校验
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
// 不一致,报错
return Result.fail("验证码错误");
}
// 4.一致,根据手机号查询用户 select * from tb_user where phone = ?
User user = query().eq("phone", phone).one();
// 5.判断用户是否存在
if (user == null) {
// 6.不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
// 7.保存用户信息到 redis中
// 7.1.随机生成token,作为登录令牌
String token = UUID.randomUUID().toString(true);
// 7.2.将User对象转为HashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
// 7.3.存储
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 7.4.设置token有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.返回token
return Result.ok(token);
}
2.基于缓存实现查询商户
2.1.关于缓存
Redis 缓存就是介于应用程序和持久化数据库(如 MySQL、PostgreSQL)之间的一层高速内存存储。它的目的是减少应用程序直接访问慢速数据库的次数,从而显著提升系统的响应速度和吞吐量。
简单工作流程如下(最简单、最实用的旁路缓存模式):
1)读请求流程(Cache-Aside)
接收请求:应用程序收到一个数据查询请求(例如,根据商品ID查询商品信息)。
查询缓存:应用程序首先尝试从 Redis 缓存中读取该数据。
缓存命中:如果数据在缓存中存在,则直接返回给客户端,流程结束。全程不访问数据库。
缓存未命中:如果数据在缓存中不存在,应用程序会去查询数据库。
数据库查询:从数据库(如 MySQL)中取出数据。
回填缓存:应用程序将从数据库取出的数据,写入到 Redis 缓存中,并设置一个过期时间。
返回数据:最后将数据返回给客户端。
2)写请求流程
更新数据库:应用程序首先直接更新数据库。
删除缓存:然后,删除 Redis 中对应的缓存数据。
项目例子:
添加商户缓存:
@Override
public Result queryById(Long id) {
String key ="cache:shop:"+id;
//1.先从redis查询店铺缓存
String shopJson=stringRedisTemplate.opsForValue().get(key);
//2.判断是否命中
if(StrUtil.isNotBlank(shopJson)){
//3.命中,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//4.不存在,则跟据id查询数据库
Shop shop=getById(id);
//5.依然不存在。返回错误
if(shop==null){
return Result.fail("店铺不存在!");
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
//7.返回数据
return Result.ok(shop);
}
2.2.缓存更新策略
1)内存淘汰:redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)
2)超时剔除:当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存(推荐)
3)主动更新:我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题(推荐)

2.3.解决缓存一致性
由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在。
缓存一致性问题主要出现在写操作,有以下几个解决方案:
方案1:先更新数据库,再删除缓存(Cache-Aside)(推荐)
方案2:延迟双删(对抗并发不一致)
方案3:订阅数据库 Binlog 异步删除(最终一致)
方案4:强一致性方案:分布式锁
如果是要考虑实时一致性的话,先写 MySQL,再删除 Redis 应该是较为优的方案,虽然短期内数据可能不一致(且发生概率较小),不过其能尽量保证数据的一致性。接下来着重讲讲Cache-Aside
采用方案一:Cache-Aside
1)为什么采用?
如果采用第一个方案,那么假设我们每次操作数据库后,都操作缓存,但是中间如果没有人查询,那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来

2)为什么是“删除”缓存而不是“更新”缓存?
这是一种保守但安全的设计。如果先更新缓存再更新数据库,或者两个操作非原子性,在并发环境下可能导致数据不一致(缓存中是的新数据,但数据库还是旧数据)。直接删除缓存,迫使下次读请求时从数据库加载最新数据(即执行上面的“读请求流程”),虽然可能有一次缓存未命中,但保证了数据的最终一致性。
3)为什么是先更新数据库再删除缓存?
原因在于,如果你选择第一种方案,在两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程1再执行更新动作时,实际上写入的就是旧的数据,新的数据被旧数据覆盖了。
(注意这里右边的图,也是失败的情况,对应下面缺点的图,是一种小概率事件)

4)缺点?
Cache-Aside 的不一致场景:

但是发生条件苛刻:需要满足(1)缓存刚好失效(2)读操作比写操作慢(数据库压力大或网络延迟)两个条件。概率较低。
回到项目
需求:
使用旁路缓存实现商铺和缓存与数据库双写一致
1)跟据id查询商户:根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
2)更新商品信息:根据id修改店铺时,先修改数据库,再删除缓存
代码:
修改ShopServiceImp :
@Override
public Result queryById(Long id) {
//...
//存在,写入redis,30分钟过期
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),30L, TimeUnit.MINUTES);
//...
}
@Override
@Transactional
public Result update(Shop shop) {
Long id=shop.getId();
if(id==null){
return Result.fail("店铺id不能为空!");
}
//1.先修改数据库
updateById(shop);
//2.再删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY+id);
return Result.ok();
}
2.4.解决缓存穿透
缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
解决方案:
方案一:缓存空对象(推荐)
即使从数据库没查到,也把一个空值(如 null)或特殊标记写入缓存,并设置一个较短的过期时间。后续请求就会命中这个空值,防止直接打到数据库。
优点:实现简单
缺点:额外的内存消耗
方案二:布隆过滤器
隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断可能存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中;假设布隆过滤器判断这个数据不存在,则直接返回null。
优点:节约内存空间
缺点:存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突

回到项目
需求:修改跟据id查询商户的业务,使用互斥锁/逻辑过期来解决返缓存穿透问题
方案一:缓存空对象解决缓存穿透

代码:
/**
* 缓存空对象解决缓存穿透
*/
public R queryWithPassThrough(
String keyPrefix, ID id, Class type, Function dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(json)) {
// 3.存在,直接返回
return JSONUtil.toBean(json, type);
}
// 判断命中的是否是空值
if (json != null) {
// 返回一个错误信息
return null;
}
// 4.不存在,根据id查询数据库
R r = dbFallback.apply(id);
// 5.不存在,返回错误(缓存空对象)
if (r == null) {
// 将空值写入redis
this.set(key, "", CACHE_NULL_TTL, TimeUnit.SECONDS);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
return r;
}
2.5.解决缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key(热key)突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
解决方案:
方案一:互斥锁
当缓存失效时,不是所有请求都去访问数据库。而是让第一个请求去查询数据库并重建缓存,其他请求则等待或重试,等待第一个请求完成后再从缓存中读取。
可以使用 Redis 的 SETNX(SET if Not eXists)命令来实现分布式锁。

方案二:逻辑过期
不设置 Redis 的物理过期时间。而是在缓存的值对象中,额外存储一个逻辑过期时间字段。
请求命中缓存后,检查值中的逻辑过期时间。如果数据未逻辑过期,直接返回。如果数据已逻辑过期,则尝试获取互斥锁,然后由一个线程异步地去更新缓存,其他线程先返回旧的、过期的数据。

方案对比:

回到项目
需求:修改跟据id查询商户的业务,使用互斥锁/逻辑过期来解决返缓存击穿问题
方案一:利用互斥锁解决缓存击穿问题
代码:
/**
* 互斥锁解决缓存击穿
*/
public R queryWithMutex(String keyPrefix, ID id, Class type, Function dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
//1.先从redis查询缓存
String json = stringRedisTemplate.opsForValue().get(key);
//2.判断是否命中
if (StrUtil.isNotBlank(json)) {
//3.命中,直接返回
return JSONUtil.toBean(json, type);
}
//判断命中的值是否是空值
if (json != null) {
//返回一个错误信息
return null;
}
//4.不存在,实现缓存重构
//4.1获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
R r = null;
try {
boolean isLock = tryLock(lockKey);
//4.2没拿到锁,则休眠重试(递归重试)
if (!isLock) {
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
}
//4.3拿到锁,跟据id查询数据库
r = dbFallback.apply(id);
//5.不存在,返回错误,同时将控制写入redis(防止缓存穿透?),2分钟有效期
if (r == null) {
this.set(key, "", CACHE_NULL_TTL, TimeUnit.SECONDS);
return null;
}
//6.将数据写入redis
this.set(key, r, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//7.释放互斥锁
unlock(lockKey);
}
return r;
}
方案二:利用逻辑过期事件解决缓存击穿问题

代码:
本质上这个热key永远都没有物理过期,只有不断更新逻辑过期时间(小疑问:为什么R newR = dbFallback.apply(id);后面不需要判空?)
/**
* 利用逻辑过期时间解决缓存击穿
*/
public R queryWithLogicalExpire(
String keyPrefix, ID id, Class type, Function dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否命中
if (StrUtil.isBlank(json)) {
// 3.不命中,直接返回null
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return r;
}
// 6.已过期,缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock) {
// 6.3.成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R newR = dbFallback.apply(id);
// 重建缓存
this.setWithLogicalExpire(key, newR, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return r;
}
2.6.解决缓存雪崩
缓存雪崩:是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
随机过期时间:在设置 Key 的过期时间时,增加一个随机值(例如,基础时间 + 一个几分钟的随机数)。
利用Redis集群提高服务的可用性:使用 Redis Sentinel(哨兵) 或 Redis Cluster(集群) 模式,实现主从切换和故障自动转移。即使个别节点宕机,整个缓存层依然能提供服务。
给缓存业务添加降级限流策略:在应用系统中,引入 Hystrix、Sentinel 等组件。当检测到数据库压力过大或大量请求超时时,启动服务降级策略。
给业务添加多级缓存:Redis + Caffeine
总解决方案
因为后期不止查询商户需要用到缓存,其他业务也可能用到,因此将缓存穿透、击穿解决方案封装成一个工具类CacheClient
1)新建一个实体类,用于封装逻辑过期数据
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
2)基于StringRedisTemplate封装一个缓存工具类 CacheClient(核心代码将上面的放进去即可)
/**
* 缓存工具类
*/
@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
//创建一个固定大小的线程池,主要用于缓存重建等后台任务
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
//构造器注入可以避免循环依赖
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
//存储数据到redis,设置过期时间
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
//存储数据到redis,不设置过期时间,但是有逻辑过期时间
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
// 设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 写入/更新Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
//---------------------------------------------------------------------------------------------------------------------
/**
* 缓存空对象解决缓存穿透
*/
public R queryWithPassThrough(
String keyPrefix, ID id, Class type, Function dbFallback, Long time, TimeUnit unit) {
//...
}
/**
* 互斥锁解决缓存击穿
*/
public R queryWithMutex(String keyPrefix, ID id, Class type, Function dbFallback, Long time, TimeUnit unit) {
//...
}
/**
* 利用逻辑过期时间解决缓存击穿
*/
public R queryWithLogicalExpire(
String keyPrefix, ID id, Class type, Function dbFallback, Long time, TimeUnit unit) {
//...
}
//---------------------------------------------------------------------------------------------------------------------
private boolean tryLock(String key) {
//如果键不存在则新增,存在则不改变已经有的值。同时,缓存命中返回 false,不命中返回 true。
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
//防止拆箱
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}
在ShopServiceImpl中应用:
@Override
public Result queryById(Long id) {
//以下选择一个场景即可
//1.解决缓存穿透
Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById,
CACHE_SHOP_TTL, TimeUnit.MINUTES);
//2.解决缓存击穿(互斥锁)
// Shop shop1 = cacheClient.queryWithMutex(CACHE_SHOP_KEY, id, Shop.class, this::getById,
// CACHE_SHOP_TTL, TimeUnit.MINUTES);
//3.解决缓存击穿(设置逻辑过期时间)
// Shop shop2 = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById,
// 20L, TimeUnit.SECONDS);
if (shop == null) {
return Result.fail("店铺不存在!");
}
return Result.ok(shop);
}
3.优惠券秒杀(重点)
3.1.Redis实现全局唯一id
需求:
用户抢购秒杀券时,会生成订单保存到数据库中,需要保证生成的订单id规律性不能太明显,同时不能受到表单容量影响,即id不能自增长
解决方案:
我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:

ID的组成部分:符号位:1bit,永远为0
时间戳:31bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
代码:
全局id生成器,自增长(Redis Incr 命令将 key 中储存的数字值增一。)
@Component
public class RedisIdWorker {
//开始时间戳
private static final long BEGIN_TIMESTAMP = 1640995200L;
//序列号的位数
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public long nextId(String keyPrefix) {
//1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
//2.生成序列号
//2.1.获取当前日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
//2.2.自增长(Redis Incr 命令将 key 中储存的数字值增一。)
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
//3.拼接时间戳和序列号
return timestamp << COUNT_BITS | count;
}
}
测试:
使用线程池异步测试,需要保证所有分线程全部走完之后,主线程再走。最后统计总时间
Countdownlatch名为信号枪:主要的作用是同步协调在多线程的等待于唤醒问题,有两个重要方法:countDown和await。
使用await可以让main线程阻塞,那么什么时候main线程不再阻塞呢?当CountDownLatch 内部维护的 变量变为0时,就不再阻塞,直接放行,那么什么时候CountDownLatch 维护的变量变为0 呢,我们只需要调用一次countDown ,内部变量就减少1,我们让分线程和变量绑定, 执行完一个分线程就减少一个变量,当分线程全部走完,CountDownLatch 维护的变量就是0,此时await就不再阻塞,统计出来的时间也就是所有分线程执行完后的时间。
private ExecutorService es = Executors.newFixedThreadPool(500);
@Test
void testIdWorker() throws InterruptedException {
//当CountDownLatch 内部维护的 变量变为0时,就不再阻塞
CountDownLatch latch = new CountDownLatch(300);
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id = " + id);
}
//调用一次countDown ,内部变量就减少1
latch.countDown();
};
long begin = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
es.submit(task);
}
//这里的await是主线程等待上面的线程结束(异步),上面的for里面的不会等待task执行完才跳下一次
latch.await();
long end = System.currentTimeMillis();
System.out.println("time = " + (end - begin));
}
实现秒杀下单
需求:
当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件。比如时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单id,如果有一个条件不满足则直接结束。

代码:
注意,现在这个需求会衍生出一系列问题,这是最开始的实现代码,我们会逐步修改优化
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
//5,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update();
if (!success) {
//扣减库存
return Result.fail("库存不足!");
}
//6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2.用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}
3.2.乐观锁解决超卖问题
问题:
假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。
解决方案:
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁,而对于加锁,我们通常有两种解决方案:

采用乐观锁来解决:
假设现在版本号version 是1,在减少库存操作时,对版本号进行+1 操作,且要求version 如果是1 的情况下,才能操作,第一个线程来了,他自己满足version=1,因此能顺利执行,数据库的version变为2,此时即使第二个线程来了也需要加上条件version =1,但是现在由于线程1已经操作过了,所以线程2,操作时就不满足version=1 的条件了,所以线程2无法执行成功。

代码:
createVoucherOrder,结合业务,我们修改一下方案,将version+1去掉,且version条件改成stock大于0,即每次操作前都要找出对应券的id并且库存需要大于0才能修改!
注意,这里并不是又回到了最开始的超卖问题,最开始的是先判断id和stock>0,然后再修改,而现在是在准备修改之前再判断id和stock>0,是可以大幅减少失败的情况
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId)
.gt("stock",0)
.update(); //where id = ? and stock > 0
3.3.分布式锁实现一人一单
3.3.1.引出一系列问题
问题:
目前的情况是,一个人可以无限制的抢这个优惠卷,所以我们应当增加一层逻辑,让一个用户只能下一个单,而不是让一个用户下多个单,但是这样会出现并发过来,查询数据库,都不存在订单
解决方案:
针对上面的问题,我们可以将seckillVoucher方法的查询订单、扣减库存、创建订单的操作额外封装成一个方法createVoucherOrder,因为现在是要确保线程安全和插入数据完整,要同时添加@Transactional注解和synchronized关键字
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
//查询订单
//扣减库存
//创建订单
}
衍生问题:
如果你在方法外部加锁,则锁粒度太粗,会导致每个线程进来都会锁住,根本区分不了是不是同一个用户的操作
如果你在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放也会导致问题(当前方法被spring的事务@Transactional控制 )
解决方案:
我们要保证同一个用户操作需要加锁,而不同的用户之间不需要加锁,因此要针对用户id来进行加锁,我们选择将当前方法整体包裹起来,确保事务不会出现问题。
要注意seckillVoucher调用createVoucherOrder方法的时候,其实是this.的方式调用的,事务不能生效,所以需要获得原始的事务对象, 来操作事务(获取代理对象,才能使得事务生效!)
//intern() 这个方法是从常量池中拿到数据,
//如果我们直接使用userId.toString() 他拿到的对象实际上是不同的对象,而是new出来的对象,
//我们使用锁必须保证锁必须是同一把,所以我们需要使用intern()方法
synchronized (userId.toString().intern()) {
//获取代理对象,才能使得事务生效!(不能直接使用this)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
衍生问题:
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。比如现在我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,那么假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的,但是如果现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥,这就是 集群环境下,syn锁失效的原因,

解决方案:
使用分布式锁,也就是接下来要讲的
3.3.2.Redis分布式锁
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路
分布式锁种类:
我们选择第二种

核心思路:
我们利用redis 的setNx 方法,当有多个线程进入时,我们就利用该方法,第一个线程进入时,redis 中就有这个key 了,返回了1,如果结果是1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁的哥们,等待一定时间后重试即可
3.3.3.实现分布式锁版本一
问题:
1)锁误删操作(即锁已过期自动释放了但是依旧执行释放锁操作)
2)释放锁时恰好锁过期释放了但是卡顿过后依然执行释放操作(即无效比锁操作)
解决方案:
1)使用前缀+用户id作为key,使用随机uuid+线程id作为value,进行加锁,释放锁时,也需要比较key才能释放。
2)修改释放锁的操作,需要通过执行lua脚本来比较是否是是同一个锁,是则释放,否则返回0。即比索、删锁是一个原子性操作。
(关于lua脚本:https://www.runoob.com/lua/lua-tutorial.html)
代码:
写个接口ILock,将抢锁和释放锁写成一个工具类
public interface ILock {
//获取锁
boolean tryLock(long timeoutSec);
//释放锁
void unlock();
}
实现类
/**
* setnx实现分布式锁
*/
public class SimpleRedisLock implements ILock {
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
//Spring Data Redis提供的类,用于封装Redis Lua脚本
private static final DefaultRedisScript UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));//设置Lua脚本文件位置
UNLOCK_SCRIPT.setResultType(Long.class);//指定脚本返回结果类型为Long
}
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@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);
}
@Override
public void unlock() {
//调用lua脚本
//参数分别是:要执行的Lua脚本、Redis键列表(这里只有一个键)、脚本参数(这里是线程ID)
stringRedisTemplate.execute(
UNLOCK_SCRIPT,//Lua脚本
Collections.singletonList(KEY_PREFIX+name),// KEYS[1]:锁的键
ID_PREFIX+Thread.currentThread().getId()// ARGV[1]:线程标识
);
}
}
lua脚本:
-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁 del key
return redis.call('del', KEYS[1])
end
return 0
修改seckillVoucher代码
@Override
public Result seckillVoucher(Long voucherId) {
//...
//创建锁对象(新增代码)
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁对象
boolean isLock = lock.tryLock(1200);
//加锁失败
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}
3.3.4.Redission分布式锁
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
同时Redisson 的分布式锁(RLock)是 Redis 分布式锁的生产级实现,它解决了手动实现中的诸多痛点(如锁续期、可重入、原子性等)
简单使用:
1)引入依赖
org.redisson
redisson
3.13.6
2)配置Redission客户端
注意这里的地址要填自己的ip,我这是linux的ip
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
// 配置
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.111.131:6379").setPassword("123456");
// 创建RedissonClient对象
return Redisson.create(config);
}
}
3)测试
@Resource
private RedissionClient redissonClient;
@Test
void testRedisson() throws Exception{
//获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
//尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
boolean isLock = lock.tryLock(1,10,TimeUnit.SECONDS);
//判断获取锁成功
if(isLock){
try{
System.out.println("执行业务");
}finally{
//释放锁
lock.unlock();
}
}
}
3.3.4.实现分布式锁版本二
问题:
上面我们解决了锁误删问题、拿锁比锁删锁原子性问题,现在还有四个问题
1)重入问题:重入问题是指 获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。
2)不可重试:是指目前的分布式只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁。
3)超时释放:我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患
4)主从一致性: 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。
解决方案:
我们依次解决
1)Redission解决重入问题
在juc的Lock锁中,他是借助于底层的一个voaltile的一个state变量来记录重入的状态的,比如当前没有人持有这把锁,那么state=0,假如有人持有这把锁,那么state=1,如果持有这把锁的人再次持有这把锁,那么state就会+1
在redission中,我们的也支持可重入锁,他采用hash结构用来存储锁,其中大key表示表示这把锁是否存在,用小key(Field )表示当前这把锁被哪个线程持有,Value 为重入计数器,同一线程多次加锁时计数器递增,解锁时递减至 0 才释放锁。

示例代码:
RLock lock = redisson.getLock("my_lock");
lock.lock(); // 第一次加锁:Hash 中 my_lock 的 "uuid:threadId" = 1
try {
lock.lock(); // 第二次加锁(重入):"uuid:threadId" = 2
// ...
} finally {
lock.unlock(); // 第一次解锁:"uuid:threadId" = 1
lock.unlock(); // 第二次解锁:计数归零 → 删除锁
}
lua脚本:
-- KEYS[1] = 锁名称(如 my_lock)
-- ARGV[1] = 锁超时时间(毫秒)
-- ARGV[2] = 客户端ID + 线程ID(如 b5a5e582-...:1)
-- 1. 检查锁是否存在
if (redis.call('exists', KEYS[1]) == 0) then
-- 不存在:创建 Hash 并设置重入次数=1
redis.call('hincrby', KEYS[1], ARGV[2], 1);
-- 设置锁的超时时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
-- 2. 锁已存在,检查当前线程是否持有锁
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 是持有者:重入次数+1
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]);
2)Redission解决不可重试问题
3)Redission解决超时释放问题
加锁成功后(未显式指定超时时间时),启动后台守护线程,默认每 10 秒检查锁持有状态。若业务未完成,自动将锁过期时间续期至默认 30 秒,避免业务执行超时导致锁意外释放。
-- KEYS[1] = 锁名称
-- ARGV[1] = 续期时间(默认 30s)
-- ARGV[2] = 客户端ID + 线程ID
-- 检查当前线程是否仍持有锁
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 持有锁:刷新超时时间为 30s
redis.call('pexpire', KEYS[1], ARGV[1]);
return 1;
end;
return 0;
浙公网安备 33010602011771号