目录
[3.1String 字符串(最常用)](#3.1String 字符串(最常用))
[3.2、Hash 哈希(对象专用)](#3.2、Hash 哈希(对象专用))
[3.3、List 列表(双向链表)](#3.3、List 列表(双向链表))
[3.4、Set 集合(无序、不可重复)](#3.4、Set 集合(无序、不可重复))
[3.5、ZSet 有序集合(Sorted Set)](#3.5、ZSet 有序集合(Sorted Set))
2.13分布式锁-redission锁重试和WatchDog机制
2.14分布式锁-redission锁的MutiLock原理
[看门狗与 leaseTime](#看门狗与 leaseTime)
一、Redis入门
1.认识NoSql
Redis是一种键值型的NoSql数据库,这里有两个关键字:键值型、NoSql
那什么是NoSql呢?
NoSql 可以翻译做Not Only Sql(不仅仅是SQL),或者是No Sql(非Sql的)数据库。是相对于传统关系型数据库而言,有很大差异的一种特殊的数据库,因此也称之为非关系型数据库。
与传统的sql不同,传统的sql都是有一张表来存储对应的字段和内容

而NoSql则对数据库格式没有严格约束,往往形式松散,自由。
- **SQL(关系型)**表结构固定、有主键外键、事务强一致、适合订单 / 财务 / 用户数据。
- NoSQL(非关系型) 结构灵活、不用建表、高并发、高可用、易扩容,适合大数据、缓存、日志、社交动态。
2.认识Redis
Redis是一个基于内存的键值型NoSQL数据库。
特征:
键值(key-value)型,value支持多种不同数据结构,功能丰富
单线程,每个命令具备原子性
低延迟,速度快(基于内存、IO多路复用、良好的编码)。
支持数据持久化
支持主从集群、分片集群
支持多语言客户端
Redis的安装可以查看我的这一篇文章,这里就不重复了:
https://blog.csdn.net/2504_94294476/article/details/157583671
3.Redis的数据结构与常用命令
3.1String 字符串(最常用)
底层 :动态字符串,能存字符串、数字、二进制适用:缓存、计数器、分布式锁、session、配置
常用命令
set key value设置get key获取setnx key value不存在才设置(分布式锁核心)setex key 秒 value设置 + 过期时间incr key自增 1(点赞、浏览量)decr key自减 1mget k1 k2 k3批量获取mset k1 v1 k2 v2批量设置
3.2、Hash 哈希(对象专用)
底层 :类似 Map<field,value>适用:存用户信息、商品信息,不用拆多个 String
常用命令
hset key field value设置单个字段hget key field获取单个字段hgetall key获取全部字段和值hmset key f1 v1 f2 v2批量设hmget key f1 f2批量取hkeys key只拿所有字段hvals key只拿所有值hsetnx key f v字段不存在才设
3.3、List 列表(双向链表)
特点 :有序、可重复、两头进出适用:消息队列、栈、朋友圈时间线、任务队列
常用命令
lpush key v1 v2左边插入rpush key v1 v2右边插入lpop key左边弹出rpop key右边弹出lrange key 0 -1查看所有元素llen key获取列表长度lindex key 下标按下标取值
3.4、Set 集合(无序、不可重复)
特点 :无序、自动去重、支持集合运算适用:好友列表、点赞用户、共同好友、去重
常用命令
sadd key v1 v2添加元素smembers key查看所有元素sismember key v判断是否存在srem key v删除元素scard key获取元素个数sinter k1 k2交集(共同好友)sunion k1 k2并集sdiff k1 k2差集
3.5、ZSet 有序集合(Sorted Set)
特点 :不可重复、按score 分值 排序适用:排行榜、热搜、延时队列、带权重排序
常用命令
zadd key score 值添加带分值元素zrange key 0 -1正序查所有zrevrange key 0 -1倒序(排行榜常用)zscore key 值查某个元素分值zrank key 值查排名zcard key元素总数zrem key 值删除元素

具体更多详细命令可以参考官方文档:
4.Redis命令-Key的层级结构
如果同一个key名称代表不同的含义,比如id 可以代表用户的id也可以代表商品的id,那我们就要建立层级结构,Redis的key允许有多个单词形成层级结构,多个单词之间用':'隔开,格式如下:
项目名:业务:类型(数据结构类型或业务数据类型):id
数据结构类型直接用 Redis 的数据结构来命名,比如 string、list、hash、set、zset。示例:heima:user:hash:1001这个键名表明,它属于 heima 项目、user 业务模块,是一个 hash 结构,ID 是 1001。
业务数据类型 用业务上的实体名称来命名,比如 user、order、product、article。示例:heima:user:info:1001这个键名表明,它属于 heima 项目、user 业务模块,存储的是用户的 info(信息),ID 是 1001。
注意:heima:user:小明和小明不是同一个key

二、Redis黑马点评实战
1.项目介绍
黑马点评是一个用来学习怎么在实际开发中使用redis的一个项目,有秒杀场景,一人一单等经典问题,主要学习了以下几个方面的功能
- 短信登录
这一块我们会使用redis共享session来实现
- 商户查询缓存
通过本章节,我们会理解缓存击穿,缓存穿透,缓存雪崩等问题,让小伙伴的对于这些概念的理解不仅仅是停留在概念上,更是能在代码中看到对应的内容
- 优惠卷秒杀
通过本章节,我们可以学会Redis的计数器功能, 结合Lua完成高性能的redis操作,同时学会Redis分布式锁的原理,包括Redis的三种消息队列
- 打人探店
基于List来完成点赞列表的操作,同时基于SortedSet来完成点赞的排行榜功能
以上这些内容咱们统统都会给小伙伴们讲解清楚,让大家充分理解如何使用Redis
1.1数据结构表

1.2项目模型
客户端的请求由ngnix反向代理给不同的tomcat服务器,实现负载均衡,然后在tomcat服务器需要调用数据时会访问redis集群和mysql集群的节点,如果redis节点里面有缓存数据则可以减少mysql的压力,并且更快相应

一个标准请求流程示例
- 用户请求先到 Nginx
- Nginx 按策略转发给某一台 Tomcat
- Tomcat 先查 Redis:
- 如果缓存命中 → 直接返回数据
- 如果缓存没命中 → 去 MySQL 查数据,再写回 Redis
- Tomcat 把处理结果返回给 Nginx,再返回给用户
2.核心技术掌握
2.1Redis存储简单字段读写
想要利用redis将验证码存储并发送,就需要进行如下步骤
1.注入StringRedisTemplate,调用方法得到redis,进行校验
2.保存用户到redis(设计key和value,因为value是对象我们用Hash存储)
2.1key用uuid生成随机token
2.2把查询到的user封装成map
2.3存入redis

2.2添加商户缓存
缓存( Cache),就是数据交换的缓冲区 ,俗称的缓存就是缓冲区内的数据 ,一般从数据库中获取,存储于本地代码 并且缓存速度快,好用
缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力
实际开发过程中,企业的数据量,少则几十万,多则几千万,这么大数据量,如果没有缓存来作为"避震器",系统是几乎撑不住的,所以企业会大量运用到缓存技术;
但是缓存也会增加代码复杂度和运营的成本:

缓存模型
变化在于客户端从以前直接访问数据库改变成了访问了数据库之后会把数据存入redis缓存,下一次访问就可以直接在redis里调用,减少回调时间

2.3缓存更新策略
**内存淘汰:**redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)
**超时剔除:**当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存
**主动更新:**我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题

2.4数据库缓存不一致解决方案:
Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案
Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理

应该具体操作缓存还是操作数据库,我们应当是先操作数据库,再删除缓存,原因在于,如果你选择第一种方案,在两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程1再执行更新动作时,实际上写入的就是旧的数据,新的数据被旧数据覆盖了。

2.5缓存穿透、雪崩、击穿
**缓存穿透 :**缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
**缓存雪崩:**是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
**缓存击穿:**也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见解决方案:
缓存穿透:
常见的解决方案有两种:
缓存空对象
优点:实现简单,维护方便
缺点:额外的内存消耗;可能造成短期的不一致
布隆过滤
优点:内存占用较少,没有多余key
缺点:实现复杂;存在误判可能
缓存空对象思路分析: 当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,我们都知道数据库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了
**布隆过滤:**布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中

缓存雪崩
解决方案:
给不同的Key的TTL添加随机值
利用Redis集群提高服务的可用性
给缓存业务添加降级限流策略
给业务添加多级缓存

缓存击穿
常见的解决方案有两种:
互斥锁
逻辑过期
逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大

解决方案一、使用锁来解决:
因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用tryLock方法 + double check来解决这样的问题。
假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。

解决方案二、逻辑过期方案
方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。
我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。
这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。

**互斥锁方案:**由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响
逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦

2.6全局唯一ID
场景分析:如果我们的id具有太明显的规则,用户或者说商业对手很容易猜测出来我们的一些敏感信息,比如商城在一天时间内,卖出了多少单,这明显不合适。
场景分析二:随着我们商城规模越来越大,mysql的单表的容量不宜超过500W,数据量过大之后,我们要进行拆库拆表,但拆分表了之后,他们从逻辑上讲他们是同一张表,所以他们的id是不能一样的, 于是乎我们需要保证id的唯一性。
按照如下生成全局唯一ID
ID的组成部分:符号位:1bit,永远为0
时间戳:31bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

2.7库存超卖问题
假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。

常用方法便是加锁,这里有两种锁:乐观锁与悲观锁
悲观锁:
悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等
乐观锁:
乐观锁:会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过,当然乐观锁还有一些变种的处理方式比如cas
CAS是比较并变换,是乐观锁的一种核心实现机制,也是 Java 并发编程里 "无锁化" 的关键。它的逻辑只有三步,是一个原子操作(要么全部成功,要么全部失败,中间不会被打断):
- 读 :读取内存中当前变量的值,记为
旧值 V- 比 :比较当前内存里的值,和刚才读到的
旧值 V是否一样- 换 :如果一样,说明没人改过,就把变量更新为
新值 N;如果不一样,说明被别人改过了,更新失败
举例:
我们要实现 count++,不用 synchronized,用 CAS 来保证线程安全:
- 初始
count = 10 - 线程 A 读取到
count = 10(旧值 V=10),计算新值N=11 - 执行
CAS(V=10, N=11):- 如果此时内存里的
count还是 10,更新成功,count变成 11 - 如果内存里的
count已经被线程 B 改成了 11,更新失败,线程 A 会重新读取count=11,再算新值 12,重试 CAS
- 如果此时内存里的
LongAdder
在Java8里面专门为了解决 AtomicLong 在高并发场景下的性能瓶颈。
AtomicLong是靠 CAS + 自旋 实现的,所有线程都在抢同一个变量的更新权。- 并发量很高时,大量线程同时 CAS 同一个变量,会出现大量自旋重试,CPU 空转严重,性能下降。
LongAdder就是为了减少这种竞争,做了分段 CAS优化。
LongAdder把一个值拆成了两部分来维护:
base:基础值,低竞争时直接 CAS 更新这个值
Cell 数组:分段存储,每个线程竞争自己的 Cell 单元,减少冲突
低并发时:直接操作 base
线程直接对
base做 CAS 自增,和AtomicLong一样,没什么开销。
- 高并发时:竞争 base 失败,切换到 Cell 数组
当大量线程同时 CAS
base失败时,LongAdder会开启分段策略:
- 把线程分散到不同的
Cell单元上,每个线程只 CAS 自己对应的Cell- 比如线程 1 更新
cell[0],线程 2 更新cell[1],线程 3 更新cell[2]...- 这样每个 Cell 上的竞争大大减少,CAS 成功率极高,几乎没有自旋开销
当你调用 longValue() 获取最终结果时,LongAdder 会把 base 和所有 Cell 的值加起来:
最终值 = base + cell[0] + cell[1] + ... + cell[n-1]

| 对比项 | AtomicLong | LongAdder |
|---|---|---|
| 实现方式 | 单变量 CAS + 自旋 | base + Cell 分段 CAS |
| 高并发性能 | 差,大量自旋重试 | 好,竞争分散,冲突极少 |
| 内存占用 | 低(单个变量) | 略高(Cell 数组) |
| 适用场景 | 低并发计数、需要精确值 | 高并发计数(比如统计 UV、QPS) |
| 缺点 | 高并发下 CPU 空转严重 | 最终值是近似值(求和时可能有并发更新) |
2.8分布式锁
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路

那么分布式锁他应该满足一些什么样的条件呢?
可见性:多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思
互斥:互斥是分布式锁的最基本的条件,使得程序串行执行
高可用:程序不易崩溃,时时刻刻都保证较高的可用性
高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能
安全性:安全也是程序中必不可少的一环
Redis分布式锁的实现核心思路
实现分布式锁时需要实现的两个基本方法:
-
获取锁:
-
互斥:确保只能有一个线程获取锁
-
非阻塞:尝试一次,成功返回true,失败返回false
-
-
释放锁:
-
手动释放
-
超时释放:获取锁时添加一个超时时间
-
这里我们发现分布式锁用redis实现类似于一种跨进程、机器的一种悲观锁
但分布式锁并不是悲观锁,分布式锁是一种场景,而乐、悲观锁是一种思想
分布式锁在处理数据库版本号时就是一个跨机器乐观锁
redis中主要是利用setnx这一个方法,当有多个线程进入时,我们就利用该方法,第一个线程进入时,redis 中就有这个key 了,返回了1,如果结果是1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁的哥们,等待一定时间后重试即可

2.9Redis分布式锁误删情况
逻辑说明:
持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来 ,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况说明
解决方案:解决方案就是在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果属于自己,则不进行锁的删除,假设还是上边的情况,线程1卡顿,锁自动释放,线程2进入到锁的内部执行逻辑,此时线程1反应过来,然后删除锁,但是线程1,一看当前这把锁不是属于自己,于是不进行删除锁逻辑,当线程2走到删除锁逻辑时,如果没有卡过自动释放锁的时间点,则判断当前这把锁是属于自己的,于是删除这把锁。
核心逻辑:在存入锁时,放入自己线程的标识,在删除锁时,判断当前这把锁的标识是不是自己存入的,如果是,则进行删除,如果不是,则不进行删除。
java
//加锁
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@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);
}
//释放锁
public void unlock() {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁中的标示
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断标示是否一致
if(threadId.equals(id)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}

2.10分布式锁的原子性问题
更为极端的误删逻辑说明:
线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中,比如他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁,但是此时他的锁到期了,那么此时线程2进来,但是线程1他会接着往后执行,当他卡顿结束后,他直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比锁,删锁,实际上并不是原子性的,我们要防止刚才的情况发生,
2.11Lua脚本解决多条命令原子性问题
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。
Redis提供的调用函数
Lua
redis.call('命令名称', 'key', '其它参数', ...)
例如,我们要执行set name jack,则脚本是这样:
Lua
# 执行 set name jack
redis.call('set', 'name', 'jack')
如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:

Java调用Lua脚本方式,先加载Lua脚本创建DefaultRedisScript对象,再调用Lua脚本
java
public class RedisLock {
// 1. 定义脚本对象
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
// 2. 静态代码块:项目启动时就加载好脚本(只加载一次)
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
// 加载 lua 脚本
UNLOCK_SCRIPT.setLocation(new ClassPathResource("lua/unlock.lua"));
// 返回值类型 必须和 lua 里 return 的一致
UNLOCK_SCRIPT.setResultType(Long.class);
}
}
public void unlock() {
stringRedisTemplate.execute(
UNLOCK_SCRIPT, // 加载好的脚本
Collections.singletonList("lock:order123"), // KEYS[1]
"thread-12345" // ARGV[1]
);
}
基于Redis的分布式锁实现思路:
利用set nx ex获取锁,并设置过期时间,保存线程标示
释放锁时先判断线程标示是否与自己一致,一致则删除锁
特性:
利用set nx满足互斥性
利用set ex保证故障时锁依然能释放,避免死锁,提高安全性
利用Redis集群保证高可用和高并发特性
2.12分布式锁-redission
之前提到的setnx的方式实现分布式锁会出现诸多问题
重入问题:重入问题是指 获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。
不可重试:是指目前的分布式只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁。
**超时释放:**我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患
主从一致性: 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。

这里引入redission,Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。

2.13分布式锁-redission锁重试和WatchDog机制
redission锁重试:
- Redis 锁占用时,会用 发布订阅 Pub/Sub
- 没抢到锁的线程不会死循环空转(不像 CAS 疯狂自旋)
- 阻塞等待,订阅锁释放的消息
- 一旦持有锁的线程
unlock释放锁,会发消息- 等待的线程被唤醒,立刻再次尝试抢锁
优点
- 不是无脑自旋耗 CPU
- 基于事件通知,高效阻塞、唤醒重试
- 两种重试区别
lock():无限等待、一直重试,直到抢到锁tryLock(waitTime):只重试等待指定时间,超时直接返回false
为什么需要看门狗?
Redis 分布式锁必须设过期时间,防止:
业务宕机没解锁 → 锁永远死在 Redis 里,别人永远拿不到锁
看门狗工作原理
- 加锁成功后,后台起一个 定时任务
- 默认每隔 10 秒 检查一次
- 如果当前线程还持有这把锁 → 自动把锁的过期时间重新续回 30 秒
- 业务正常执行完、调用
unlock()→ 看门狗停止续期- 如果服务宕机,看门狗也挂了,锁不再续期,30 秒后自动释放,不会死锁
目的就是防止线程在lock.lock()时无法执行完成时就释放锁,看门狗可以帮助他把锁续期
2.14分布式锁-redission锁的MutiLock原理
MultiLock 是 Redisson 的联锁,将多个 RLock 组合成逻辑锁;加锁时顺序逐个尝试,全部成功才算成功,任一失败则全回滚;解锁逆序释放;支持看门狗与 leaseTime;用于多资源原子操作,区别于 RedLock(单资源多节点多数成功)。
这里举个例子比较直观
一个业务必须同时锁住多个独立资源,否则会数据不一致或死锁:
- 转账:同时锁 A 账户、B 账户
- 下单:同时锁用户余额、商品库存、优惠券
- 多 Redis 节点 / 分片:需在多个节点上同时加锁(防主从切换丢锁)
不用 MultiLock 会怎样?线程 A 锁库存 → 等账户锁;线程 B 锁账户 → 等库存锁 → 死锁。
创建方式,把几个锁创建成一个大锁
java
RLock lock1 = redisson.getLock("res1");
RLock lock2 = redisson.getLock("res2");
RLock lock3 = redisson.getLock("res3");
// 把多把锁合成一把联锁
RMultiLock multiLock = redisson.getMultiLock(lock1, lock2, lock3);
加锁流程(重点)
multiLock.lock() 内部逻辑:
- 按顺序逐个尝试获取子锁(默认每个最多等 1500ms)
- 一旦某一把锁失败 / 超时 :
- 立即逆序释放已拿到的所有锁(回滚)
- 抛出异常或返回失败
- 全部成功 → 加锁成功,对外表现为一把锁
java
List<RLock> acquired = new ArrayList<>();
try {
for (RLock lock : locks) {
lock.tryLock(1500, leaseTime, MILLIS); // 逐个加锁
acquired.add(lock);
}
} catch (Exception e) {
for (RLock lock : acquired) lock.unlock(); // 失败全回滚
throw e;
}
解锁流程
- 逆序释放所有子锁(避免嵌套锁问题)
- 只有持有者线程能解锁,别人解不了
看门狗与 leaseTime
- 若用无参
lock():所有子锁统一开启看门狗,默认 30s、每 10s 续期 - 若手动指定
leaseTime:所有子锁共用该过期时间,看门狗全关闭,不自动续期
三、总结
回顾完整个黑马点评的技术知识点,把对应的任务拆解成一个一个技术知识点来解析,会把这个项目的功能实现看的更透彻,从表层的功能实现,理解到了对应的技术实现,上一个制作了苍穹外卖的万字总结,对于一个项目的增删改查,一些简单的业务流程,插件使用更加熟悉,如果说苍穹外卖是业务的基础,那么黑马点评我认为是redis的使用策略项目,通过redis的分布式锁处理高并发,常用的缓存击穿、雪崩、穿透解决方法比较熟悉。用redis处理了一些场景高并发问题,对于redis业务思路的掌握有了更深的理解,内心感觉也比较充实。


