【自学笔记】Redis 飞快入门

前言

本文主要是以快速了解Redis、快速写出用Redis解决常见业务代码为主,不会涉及到太多的细节,即使不敲一行代码也能让你有所收获,适合面向快速上手Redis的小伙伴。

视频教程:黑马程序员Redis入门到实战教程,深度透析redis底层原理+redis分布式锁+企业解决方案+黑马点评实战项目_哔哩哔哩_bilibili

学习的源码:

本文主要是记录本人自学的过程,有参考其他笔记~

如果对你有帮助,可以看看我的主页,里面有很多个人总结的Redis的面试热题,喜欢的话点个赞或者关注贝~

一、基础篇

1.Redis是什么

RedisRemote Dictionary Server)是一个开源的、基于内存的键值存储系统(Key-Value Store),也是一种键值型的NoSql数据库。它通常被用作数据库缓存消息中间件流引擎

Redis的官方网站地址:Redis - The Real-time Data Platform

主要特点:

  • 键值(key-value)型,value支持多种不同数据结构,功能丰富

  • 单线程,每个命令具备原子性

  • 低延迟,速度快(基于内存.IO多路复用.良好的编码)。

  • 支持数据持久化

  • 支持主从集群.分片集群

  • 支持多语言客户端

2.Redis有什么用

  1. 缓存: 最常用场景,加速数据访问(如数据库查询结果、页面片段)。

  2. 会话存储: 存储用户会话信息(如登录状态)。

  3. 排行榜: 利用 Sorted Set(如点赞最早用户)。

  4. 计数器: 利用 INCR/DECR(如浏览量、点赞数)。

  5. 消息队列: 利用 List(简单队列)或 Streams(更复杂的消息队列,支持消费者组、消息确认)。

  6. 实时系统: 利用 Pub/Sub(如简单的聊天室、通知)。

  7. 地理位置应用: 利用 Geospatial Indexes。

  8. 标签系统/社交关系: 利用 Set(如共同好友、兴趣标签)。

  9. 分布式锁: 利用 SET key value NX PX milliseconds(或 SETNX + EXPIRE,需注意原子性问题)。

  10. 限流: 利用计数器 + 过期时间(如 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二进制安全的字符串SETGETINCRDECR缓存、计数器、分布式锁可存文本、数字(≤512MB)
Hash键值对集合(类似 Map)HSETHGETHGETALL存储对象(如用户信息、商品属性)适合存储结构化数据
List双向链表LPUSHRPOPLRANGE消息队列、最新消息列表、历史记录支持按索引操作,元素可重复
Set无序集合(元素唯一)SADDSMEMBERSSINTER标签系统、好友关系、抽奖(去重)支持交并差集运算
Sorted Set有序集合(元素唯一+分数排序)ZADDZRANGEZRANK排行榜、延迟队列、带权重的任务调度通过分数(score)自动排序

高级类型

数据类型用途示例命令场景案例
Bitmaps位操作(节省空间的布尔存储)SETBITGETBITBITCOUNT用户签到统计、实时活跃度分析
HyperLogLog近似去重计数(误差率 0.81%)PFADDPFCOUNT大规模 UV 统计(如每日访问用户数)
GEO地理位置存储与查询GEOADDGEORADIUS附近的人、商家定位
Stream消息流(支持消费者组)XADDXREADXGROUP类似 Kafka 的消息队列(Redis 5.0+)
Bloom Filter高效存在性判断(需模块扩展)BF.ADDBF.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)

  1. 接收请求:应用程序收到一个数据查询请求(例如,根据商品ID查询商品信息)。

  2. 查询缓存:应用程序首先尝试从 Redis 缓存中读取该数据。

  3. 缓存命中:如果数据在缓存中存在,则直接返回给客户端,流程结束。全程不访问数据库

  4. 缓存未命中:如果数据在缓存中不存在,应用程序会去查询数据库。

  5. 数据库查询:从数据库(如 MySQL)中取出数据。

  6. 回填缓存:应用程序将从数据库取出的数据,写入到 Redis 缓存中,并设置一个过期时间。

  7. 返回数据:最后将数据返回给客户端。

2)写请求流程

  1. 更新数据库:应用程序首先直接更新数据库。

  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(集群) 模式,实现主从切换和故障自动转移。即使个别节点宕机,整个缓存层依然能提供服务。

  • 给缓存业务添加降级限流策略:在应用系统中,引入 HystrixSentinel 等组件。当检测到数据库压力过大或大量请求超时时,启动服务降级策略。

  • 给业务添加多级缓存: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;

3.4.阻塞队列实现秒杀优化

4.基于Set实现点赞功能

5.基于ZSet实现点赞排行榜

6.基于Set实现用户共同关注

7.基于ZSet实现Feed流

8.基于GEO实现查看附近商户

9.基于BitMap实现用户签到统计

10.UV统计

posted on 2025-10-01 15:42  slgkaifa  阅读(34)  评论(0)    收藏  举报

导航