Redis
# Redis
# 项目中使用 Redis 的地方
tag:
知乎、快手、美团、阿里、Meta App、数字马力、国遥新天地、软通、小米、超聚变、途虎养车、用友、格创东智、腾讯、闪送、中金、Fabrie、中通、传音、小天才、大智慧、新华三、顺丰、深信服、招银、4399、星环、京东、滴滴、喜马拉雅count:57
Redis 了解吗
Redis 熟悉使用还是底层
redis 作用
项目中 redis 使用,怎么设计的 key
项目中为什么引入 Redis 呢?和本地缓存有哪些区别?
为什么使用 Redis 进行热点数据缓存
你说你会使用 redis 存储令牌,那么你是如何设计这样子的一个过程呢?详细说说
为什么使用 redis?为什么不用 mcache?
redis 优劣势 (占内存,有可能数据丢失,大 key 容易阻塞)
redis 其他可用缓存,为什么用 redis
在项目中缓存和分布式锁都有使用到。
在我写的物流项目中就使用 redis 作为缓存,当然在业务中还是比较复杂的。
在物流信息查询模块使用中使用二级缓存,一级缓存使用的是 Caffeine,二级缓存就是使用 redis。
# 为什么要用 Redis?
1、访问速度更快
传统数据库数据保存在磁盘,而 Redis 基于内存,内存的访问速度比磁盘快很多。引入 Redis 之后,我们可以把一些高频访问的数据放到 Redis 中,这样下次就可以直接从内存中读取,速度可以提升几十倍甚至上百倍。
2、高并发
一般像 MySQL 这类的数据库的 QPS 大概都在 4k 左右(4 核 8g) ,但是使用 Redis 缓存之后很容易达到 5w+,甚至能达到 10w+(就单机 Redis 的情况,Redis 集群的话会更高)。
QPS(Query Per Second):服务器每秒可以执行的查询次数;
由此可见,直接操作缓存能够承受的数据库请求数量是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。进而,我们也就提高了系统整体的并发。
3、功能全面
Redis 除了可以用作缓存之外,还可以用于分布式锁、限流、消息队列、延时队列等场景,功能强大!
# Redis 和 Memcached 的区别和共同点
共同点:
- 都是基于内存的数据库,一般都用来当做缓存使用。
- 都有过期策略。
- 两者的性能都非常高。
区别:
- 数据类型:Redis 支持更丰富的数据类型(支持更复杂的应用场景)。Redis 不仅仅支持简单的 k/v 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。Memcached 只支持最简单的 k/v 数据类型。
- 数据持久化:Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 把数据全部存在内存之中。也就是说,Redis 有灾难恢复机制而 Memcached 没有。
- 集群模式支持:Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 Redis 自 3.0 版本起是原生支持集群模式的。
- 线程模型:Memcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线程的多路 IO 复用模型。 (Redis 6.0 针对网络数据的读写引入了多线程)
- 特性支持:Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。并且,Redis 支持更多的编程语言。
- 过期数据删除:Memcached 过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除。
# Redis 与 MySQL 的区别
tag:
数字马力、百度、神策数据、京东count:5
as:说一下 mysql,redis,mongodb 的区别
# Redis 常见的数据结构以及应用场景
tag:
携程、美团、万得、快手、腾讯、得物、小米、moka、数字马力、奇安信、滴滴、经纬恒润、shopee、苏小研、税友、富途、亚信、Fabrie、小红书、大智慧、移动、字节、哔哩哔哩、深信服、微派、云智、4399、饿了么、京东、用友、货拉拉、探探、万得、阿里、玄武科技count:79
一共 10 大类型,常用为前 5 种
- String (字符串):key -value 缓存应用,最常规的 set/get 操作,value 可以是 String 也可以是数字。一般做一些复杂的计数功能的缓存,常规计数、分布式锁、共享 session 信息等。
- Hash (哈希):field-value 映射表,存储用户信息和商品信息
- List (列表):list 分页查询,消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。
- Set (集合):聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。
- Sorted set/Zset (有序集合):排序场景,比如排行榜、电话姓名排序、用户列表,礼物排行榜,弹幕消息等
- Bitmaps(位图):(2.2 版新增)二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;实质 String
- HyperLogLog(基数统计):(2.8 版新增)海量数据基数统计的场景,比如百万级网页 UV 计数等;实质 String
- GEO(地理信息):(3.2 版新增)存储地理位置信息的场景,比如滴滴叫车;实质 Zset
- Stream(流):(5.0 版新增)消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息 ID,支持以消费组形式消费数据。实质 Stream
- BITFIELD(位域):一次性操作多个比特位域 (指的是连续的多个比特位),它会执行一系列操作并返回一个响应数组,这个数组中的元素对应参数列表中的相应操作的执行结果。

redis 是 key-value 存储系统,key 一般都是 String 类型的字符串对象,value 类型则为 redis 对象 (redisObject),value 可以是字符串对象,也可以是集合数据类型的对象,比如 List 对象、Hash 对象、Set 对象和 Zset 对象。
# redis 常用指令
tag:
携程、百度、微派、完美世界count:5
# 怎么设置过期时间
tag:
数字马力、饿了么、用友、探探count:6
as:如何对一个 key 延长有效期
如何删除过期 key
# redis 的原子性命令有哪些
tag:
作业帮count:1
as:
# 怎么实现消息队列?list?stream 了解吗?bitmap 了解吗?
tag:
美团、亚信、Fabrie、去哪儿、哔哩哔哩count:6
as:有没有了解过 Redis 的发布订阅模式
# 全局哈希表了解吗?
tag:
美团count:1
# Redis 的底层数据有哪些?
tag:
美团、字节、经纬恒润、shopee、苏小研、快手、得物、货拉拉、百度count:13
as:Redis 常见的数据结构有哪些?以及底层是如何实现的?
底层数据结构一共有 8 种,分别是
- SDS(简单动态字符串)
- 双向链表
- 压缩列表 ziplist
- 哈希表 hashtable
- 跳表 skiplis
- 整数集合 intset
- 快速列表 quicklist
- 紧凑列表 listpack
String 类型的底层实现只有一种数据结构,也就是简单动态字符串。而 List、Hash、Set 和 Sorted Set 这四种数据类型,都有两种底层实现结构。

左边是 Redis 3.0 版本的,右边是现在 Redis 7.0 版本的。

# 底层数据源码
底层实现

源码文件


Redis 定义了 redisObjec 结构体来表示 string、hash、list、set、zset 等数据类型,Redis 中每个对象都是一个 redisObject 结构,每个键值对都会有一个 dictEntry。


redisObject +Redis 数据类型 + Redis 所有编码方式(底层实现)三者之间的关系


每个键值对都会有一个 dictEntry
set hello word 为例,因为 Redis 是 KV 键值对的数据库,** 每个键值对都会有一个 dictEntry (源码位置:dict.h),** 里面指向了 key 和 value 的指针,next 指向下一个 dictEntry。
key 是字符串,但是 Redis 没有直接使用 C 的字符数组,而是存储在 redis 自定义的 SDS 中。value 既不是直接作为字符串存储,也不是直接存储在 SDS 中,而是存储在 redisObject 中。实际上五种常用的数据类型的任何一种,都是通过 redisObject 来存储的。


# 查看类型
type key
# 查看编码
object encoding key
2
3
4
RedisObject 各字段的含义

- 4 位的 type 表示具体的数据类型
- 4 位的 encoding 表示该类型的物理编码方式见下表,同一种数据类型可能有不同的编码方式。(比如 String 就提供了 3 种:int embstr raw)

- lru 字段表示当内存超限时采用 LRU 算法清除内存中的对象。
- refcount 表示对象的引用计数。
- ptr 指针指向真正的底层数据结构的指针。
拿 set age 17 为案例

| type | 类型 |
|---|---|
| encoding | 编码,此处是数字类型 |
| lru | 最近被访问的时间 |
| refcount | 等于 1,表示当前对象被引用的次数 |
| ptr | value 值是多少,当前就是 17 |
各个类型的数据结构的编码映射和定义

Redis Debug Object 命令是一个调试命令,它不应被客户端所使用。
debug object key
使用前需要在配置文件中进行配置
enable-debug-command local # 默认为 no 关闭此功能
再次使用

- Value at: 内存地址
- refcount: 引用次数
- encoding: 物理编码类型
- serializedlength: 序列化后的长度(注意这里的长度是序列化后的长度,保存为 rdb 文件时使用了该算法,不是真正存贮在内存的大小), 会对字串做一些可能的压缩以便底层优化
- lru:记录最近使用时间戳
- lru_seconds_idle:空闲时间
# String 类型内部实现
tag:
美团、小米、腾讯、用友、快手count:5
String 类型的底层的数据结构实现主要是 SDS(简单动态字符串)
- SDS 不仅可以保存文本数据,还可以保存二进制数据。 因为 SDS 使用 len 属性的值而不是空字符来判断字符串是否结束,并且 SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在 buf [] 数组里的数据。所以 SDS 不光能存放文本数据,而且能保存图片、音频、视频、压缩文件这样的二进制数据。
- SDS 获取字符串长度的时间复杂度是 O (1) 因为 C 语言的字符串并不记录自身长度,所以获取长度的复杂度为 O (n);而 SDS 结构里用 len 属性记录了字符串长度,所以复杂度为 O (1)。
- Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出。 因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题。
# 3 大物理编码方式
RedisObject 内部对应 3 大物理编码


# int
保存 long 型 (长整型) 的 64 位 (8 个字节) 有符号整数

上面数字最多 19 位,只有整数才会使用 int,如果是浮点数, Redis 内部其实先将浮点数转化为字符串值,然后再保存。
# embstr
代表 embstr 格式的 SDS (Simple Dynamic String 简单动态字符串), 保存长度小于 44 字节的字符串,EMBSTR 顾名思义即:embedded string,表示嵌入式的 String。
# raw
保存长度大于 44 字节的字符串
# 案例测试


# SDS 的展现
假如现在展现一个字符串:Redis

Redis 没有直接复用 C 语言的字符串,而是新建了属于自己的结构 -----SDS
在 Redis 数据库里,包含字符串值的键值对都是由 SDS 实现的 (Redis 中所有的键都是由字符串对象实现的即底层是由 SDS 实现,Redis 中所有的值对象中包含的字符串对象底层也是由 SDS 实现)。

sds.h 源码

- len 表示 SDS 的长度,使我们在获取字符串长度的时候可以在 O (1) 情况下拿到,而不是像 C 那样需要遍历一遍字符串。
- alloc 可以用来计算 free 就是字符串已经分配的未使用的空间,有了这个值就可以引入预分配空间的算法了,而不用去考虑内存分配的问题。
- buf 表示字符串数组,真存数据的。
Redis 中字符串的实现,SDS 有多种结构(sds.h):
sdshdr5、(

# Redis 为什么重新设计一个 SDS 数据结构?
C 语言没有 Java 里面的 String 类型,只能是靠自己的 char [] 来实现,字符串在 C 语言中的存储方式,想要获取 「Redis」的长度,需要从头开始遍历,直到遇到 '\0' 为止。所以,Redis 没有直接使用 C 语言传统的字符串标识,而是自己构建了一种名为简单动态字符串 SDS(simple dynamic string)的抽象类型,并将 SDS 作为 Redis 的默认字符串。
| C 语言 | SDS | |
|---|---|---|
| 字符串长度处理 | 需要从头开始遍历,直到遇到 '\0' 为止,时间复杂度 O (N) | 记录当前字符串的长度,直接读取即可,时间复杂度 O (1) |
| 内存重新分配 | 分配内存空间超过后,会导致数组下标越级或者内存分配溢出 | 空间预分配 SDS 修改后,len 长度小于 1M,那么将会额外分配与 len 相同长度的未使用空间。如果修改后长度大于 1M,那么将分配 1M 的使用空间。 惰性空间释放 有空间分配对应的就有空间释放。SDS 缩短时并不会回收多余的内存空间,而是使用 free 字段将多出来的空间记录下来。如果后续有变更操作,直接使用 free 中记录的空间,减少了内存的分配。 |
| 二进制安全 | 二进制数据并不是规则的字符串格式,可能会包含一些特殊的字符,比如 '\0' 等。前面提到过,C 中字符串遇到 '\0' 会结束,那 '\0' 之后的数据就读取不上了 | 根据 len 长度来判断字符串结束的,二进制安全的问题就解决了 |
# 源码分析
当客户端调用 set k1 v1 底层发生了什么?调用关系

# 3 大物理编码方式

# INT 编码格式
命令示例: set k1 123
当字符串键值的内容可以用一个 64 位有符号整形来表示时,Redis 会将键值转化为 long 型来进行存储,此时即对应 OBJ_ENCODING_INT 编码类型。内部的内存结构表示如下:

Redis 启动时会预先建立 10000 个分别存储 0~9999 的 redisObject 变量作为共享对象,这就意味着如果 set 字符串的键值在 0~10000 之间的话,则可以 直接指向共享对象 而不需要再建立新对象,此时键值不占空间!
set k1 123
set k2 123
2

server.h

redis6 源代码: object.c

redis7 源代码: object.c


# EMBSTR 编码格式
命令示例: set k1 abc
对于长度小于 44 的字符串,Redis 对键值采用 OBJ_ENCODING_EMBSTR 方式,EMBSTR 顾名思义即:embedded string,表示嵌入式的 String。从内存结构上来讲 即字符串 sds 结构体与其对应的 redisObject 对象分配在同一块连续的内存空间,字符串 sds 嵌入在 redisObject 对象之中一样。



进一步 createEmbeddedStringObject 方法
redis 源代码: object.c

# RAW 编码格式
命令示例: set k1 大于44长度的一个字符串,随便写

当字符串的键值为长度大于 44 的超长字符串时,Redis 则会将键值的内部编码方式改为 OBJ_ENCODING_RAW 格式,这与 OBJ_ENCODING_EMBSTR 编码方式的不同之处在于,此时动态字符串 sds 的内存与其依赖的 redisObject 的内存不再连续了

当我们修改 EMBSTR 后,明明没有超过阈值,为什么变成 raw 了

判断不出来,就取最大 Raw
# 总结

只有整数才会使用 int,如果是浮点数, Redis 内部其实先将浮点数转化为字符串值,然后再保存。
embstr 与 raw 类型底层的数据结构其实都是 SDS (简单动态字符串,Redis 内部定义 sdshdr 一种结构)。 那这两者的区别见下图:

| 类型 | 描述 |
|---|---|
| int | Long 类型整数时,RedisObject 中的 ptr 指针直接赋值为整数数据,不再额外的指针再指向整数了,节省了指针的空间开销。 |
| embstr | 当保存的是字符串数据且字符串小于等于 44 字节时,embstr 类型将会调用内存分配函数,只分配一块连续的内存空间,空间中依次包含 redisObject 与 sdshdr 两个数据结构,让元数据、指针和 SDS 是一块连续的内存区域,这样就可以避免内存碎片 |
| raw | 当字符串大于 44 字节时,SDS 的数据量变多变大了,SDS 和 RedisObject 布局分家各自过,会给 SDS 分配多的空间并用指针指向 SDS 结构,raw 类型将会调用两次内存分配函数,分配两块内存空间,一块用于包含 redisObject 结构,而另一块用于包含 sdshdr 结构 |
Redis 内部会根据用户给的不同键值而使用不同的编码格式,自适应地选择较优化的内部编码格式,而这一切对用户完全透明!
# List 类型内部实现
tag:
经纬恒润count:1
as:
List 类型的底层数据结构是由双向链表或压缩列表实现的:
- 如果列表的元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),Redis 会使用压缩列表作为 List 类型的底层数据结构;
- 如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;
但是在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。
# Redis 6
# 结构实现

ziplist 压缩配置: list-compress-depth 0
表示一个 quicklist 两端不被压缩的节点个数。这里的节点是指 quicklist 双向链表的节点,而不是指 ziplist 里面的数据项个数 参数 list-compress-depth 的取值含义如下:
- 0: 是个特殊值,表示都不压缩。这是 Redis 的默认值。
- 1: 表示 quicklist 两端各有 1 个节点不压缩,中间的节点压缩。
- 2: 表示 quicklist 两端各有 2 个节点不压缩,中间的节点压缩。
- 3: 表示 quicklist 两端各有 3 个节点不压缩,中间的节点压缩。
- 依此类推…
ziplist 中 entry 配置: list-max-ziplist-size -2
当取正值的时候,表示按照数据项个数来限定每个 quicklist 节点上的 ziplist 长度。比如,当这个参数配置成 5 的时候,表示每个 quicklist 节点的 ziplist 最多包含 5 个数据项。当取负值的时候,表示按照占用字节数来限定每个 quicklist 节点上的 ziplist 长度。这时,它只能取 - 1 到 - 5 这五个值,每个值含义如下:
- -5: 每个 quicklist 节点上的 ziplist 大小不能超过 64 Kb。(注:1kb => 1024 bytes)
- -4: 每个 quicklist 节点上的 ziplist 大小不能超过 32 Kb。
- -3: 每个 quicklist 节点上的 ziplist 大小不能超过 16 Kb。
- -2: 每个 quicklist 节点上的 ziplist 大小不能超过 8 Kb。(-2 是 Redis 给出的默认值)
- -1: 每个 quicklist 节点上的 ziplist 大小不能超过 4 Kb。
quicklist 是 Redis6 版本前的 List 的一种编码格式,list 用 quicklist 来存储,quicklist 存储了一个双向链表,每个节点都是一个 ziplist。

在 Redis3.0 之前,list 采用的底层数据结构是 ziplist 压缩列表 + linkedList 双向链表,然后在高版本的 Redis 中底层数据结构是 quicklist (替换了 ziplist+linkedList),而 quicklist 也用到了 ziplist。
结论:quicklist 就是「双向链表 + 压缩列表」组合,因为一个 quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表

quicklist 实际上是 zipList 和 linkedList 的混合体,它将 linkedList 按段切分,每一段使用 zipList 来紧凑存储,多个 zipList 之间使用双向指针串接起来。它其实是 ziplist 和 linkedlist 的结合体。

# 源码分析
quicklist.h ,head 和 tail 指向双向列表的表头和表尾
quicklist 结构


quicklistNode 结构


# Redis 7

listpack 紧凑列表是用来替代 ziplist 的新数据结构,在 7.0 版本已经没有 ziplist 的配置了(6.0 版本仅部分数据类型作为过渡阶段在使用)
# 源码分析
t_list.c

我们与 Redis6 相同的文件 t_list.c 进行对比

可以看见 ziplist 被 listpack 替换
object.c

list 用 quicklist 来存储,quicklist 存储了一个双向链表,每个节点都是一个 listpack,quicklist 其实是 listpack 和 linkedlist 的结合体。
# Hash 类型内部实现
tag:
百度、微派、快手count:4
as:redis 哈希怎么扩容?
Hash 类型的底层数据结构是由压缩列表或哈希表实现的:
- 如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构;
- 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的底层数据结构。
在 Redis 7.0 中,ziplist(压缩列表)数据结构已经废弃了,交由 listpack 数据结构来实现了。
# Redis 6
# 结构实现
- hash-max-ziplist-entries:使用压缩列表保存时哈希集合中的最大元素个数。
- hash-max-ziplist-value:使用压缩列表保存时哈希集合中单个元素的最大长度。
Hash 类型键的字段个数 小于 hash-max-ziplist-entries 并且每个字段名和字段值的长度 小于 hash-max-ziplist-value 时,Redis 才会使用 OBJ_ENCODING_ZIPLIST 来存储该键,前述条件任意一个不满足则会转换为 OBJ_ENCODING_HT 的编码方式


- 哈希对象保存的键值对数量小于 512 个;
- 所有的键值对的健和值的字符串长度都小于等于 64byte(一个英文字母一个字节) 时用 ziplist,反之用 hashtable
- ziplist 升级到 hashtable 可以,反过来降级不可以,一旦从压缩列表转为了哈希表,Hash 类型就会一直用哈希表进行保存而不会再转回压缩列表了。在节省内存空间方面哈希表就没有压缩列表高效了。
流程图

# 源码分析
t_hash.c
在 Redis 中,hashtable 被称为字典(dictionary),它是一个数组 + 链表的结构
OBJ_ENCODING_HT 这种编码方式内部才是真正的哈希表结构,或称为字典结构,其可以实现 O (1) 复杂度的读写操作,因此效率很高。
在 Redis 内部,从 OBJ_ENCODING_HT 类型到底层真正的散列表数据结构是一层层嵌套下去的,组织关系见下图:

dict.h

每个键值对都会有一个 dictEntry
# haset 命令解读

# 类型决定

ziplist.c
Ziplist 压缩列表是一种紧凑编码格式,总体思想是多花时间来换取节约空间,即以部分读写性能为代价,来换取极高的内存空间利用率, 因此只会用于 字段个数少,且字段值也较小 的场景。压缩列表内存利用率极高的原因与其连续内存的特性是分不开的。

类似 GC 垃圾回收机制:标记 -- 压缩算法
当一个 hash 对象 只包含少量键值对且每个键值对的键和值要么就是小整数要么就是长度比较短的字符串,那么它用 ziplist 作为底层实现
# ziplist 结构
ziplist.c
为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构,有点类似于数组 ziplist 是一个经过特殊编码的双向链表,它不存储指向前一个链表节点 prev 和指向下一个链表节点的指针 next 而是存储上一个节点长度和当前节点长度,通过牺牲部分读写性能,来换取高效的内存空间利用率,节约内存,是一种时间换空间的思想。只用在字段个数少,字段值小的场景里面



| 属性 | 类型 | 长度 | 用途 |
|---|---|---|---|
| zlbytes | uint32_t | 4 字节 | 记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配,或者计算 zlend 的位置时使用 |
| zltail | uint32_t | 4 字节 | 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节:通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址 |
| zllen | uint16_t | 2 字节 | 记录了压缩列表包含的节点数量:当这个属性的值小于 UINT_16MAX (65535) 时,这个属性的值就是压缩列表包含节点的数量:当这个值等于 UINT16_MAX 时,节点的真实数量需要遍历整个压缩列表才能计算得出 |
| entryX | 列表节点 | 不定 | 压缩列表包含的各个节点,节点的长度由节点保存的内容决定 |
| zlend | uint8_t | 1 字节 | 特殊值 0xFF(十进制 255),用于标记压缩列表的末端 |
# zlentry
zlentry,压缩列表节点的构成

zlentry 实体结构解析
ziplist.c

# ziplist 存取情况


- prevlen:记录了前一个节点的长度;
- encoding:记录了当前节点实际数据的类型以及长度
- data:记录了当前节点的实际数据
# zlentry 解析
压缩列表 zlentry 节点结构:每个 zlentry 由前一个节点的长度、encoding 和 entry-data 三部分组成

- 前节点:(前节点占用的内存字节数) 表示前 1 个 zlentry 的长度,privious_entry_length 有两种取值情况:1 字节或 5 字节。取值 1 字节时,表示上一个 entry 的长度小于 254 字节。虽然 1 字节的值能表示的数值范围是 0 到 255,但是压缩列表中 zlend 的取值默认是 255,因此,就默认用 255 表示整个压缩列表的结束,其他表示长度的地方就不能再用 255 这个值了。所以,当上一个 entry 长度小于 254 字节时,prev_len 取值为 1 字节,否则,就取值为 5 字节。记录长度的好处:占用内存小,1 或者 5 个字节
- enncoding:记录节点的 content 保存数据的类型和长度。
- content:保存实际数据内容

为什么 zlentry 这么设计?数组和链表数据结构对比
privious_entry_length,encoding 长度都可以根据编码方式推算,真正变化的是 content,而 content 长度记录在 encoding 里 ,因此 entry 的长度就知道了。entry 总长度 = privious_entry_length 字节数 + encoding 字节数 + content 字节数

为什么 entry 这么设计?记录前一个节点的长度? 链表在内存中,一般是不连续的,遍历相对比较慢,而 ziplist 可以很好的解决这个问题。如果知道了当前的起始地址,因为 entry 是连续的,entry 后一定是另一个 entry,想知道下一个 entry 的地址,只要将当前的起始地址加上当前 entry 总长度。如果还想遍历下一个 entry,只要继续同样的操作。
# 明明有链表了,为什么出来一个压缩链表?
- 普通的双向链表会有两个指针,在存储数据很小的情况下,我们存储的实际数据的大小可能还没有指针占用的内存大,得不偿失。ziplist 是一个特殊的双向链表没有维护双向指针:previous next;而是存储上一个 entry 的长度和当前 entry 的长度,通过长度推算下一个元素在什么地方。牺牲读取的性能,获得高效的存储空间,因为 (简短字符串的情况) 存储指针比存储 entry 长度更费内存。这是典型的 “时间换空间”。
- 链表在内存中一般是不连续的,遍历相对比较慢而 ziplist 可以很好的解决这个问题,普通数组的遍历是根据数组里存储的数据类型找到下一个元素的 (例如 int 类型的数组访问下一个元素时每次只需要移动一个 sizeof (int) 就行),但是 ziplist 的每个节点的长度是可以不一样的,而我们面对不同长度的节点又不可能直接 sizeof (entry),所以 ziplist 只好将一些必要的偏移量信息记录在了每一个节点里,使之能跳到上一个节点或下一个节点。 备注:sizeof 实际上是获取了数据在内存中所占用的存储空间,以字节为单位来计数。
- 头节点里有头节点里同时还有一个参数 len,和 string 类型提到的 SDS 类似,这里是用来记录链表长度的。因此获取链表长度时不用再遍历整个链表,直接拿到 len 值就可以了,这个时间复杂度是 O (1)
# 总结
ziplist 为了节省内存,采用了紧凑的连续存储。
ziplist 是一个双向链表,可以在时间复杂度为 O (1) 下从头部、尾部进行 pop 或 push。
新增或更新元素可能会出现连锁更新现象 (致命缺点导致被 listpack 替换)。
不能保存过多的元素,否则查询效率就会降低,数量小和内容小的情况下可以使用。
# Redis 7
# 结构实现
- hash-max-listpack-entries:使用压缩列表保存时哈希集合中的最大元素个数。
- hash-max-listpack-value:使用压缩列表保存时哈希集合中单个元素的最大长度。
Hash 类型键的字段个数 小于 hash-max-listpack-entries 且每个字段名和字段值的长度 小于 hash-max-listpack-value 时,Redis 才会使用 OBJ_ENCODING_LISTPACK 来存储该键,前述条件任意一个不满足则会转换为 OBJ_ENCODING_HT 的编码方式
- 哈希对象保存的键值对数量小于 512 个;
- 所有的键值对的健和值的字符串长度都小于等于 64byte(一个英文字母一个字节) 时用 listpack,反之用 hashtable
- listpack 升级到 hashtable 可以,反过来降级不可以
流程图

# 源码分析
object.c

listpack.c


lpNew 函数创建了一个空的 listpack,一开始分配的大小是 LP_HDR_SIZE 再加 1 个字节。LP_HDR_SIZE 宏定义是在 listpack.c 中,它默认是 6 个字节,其中 4 个字节是记录 listpack 的总字节数,2 个字节是记录 listpack 的元素数量。
此外,listpack 的最后一个字节是用来标识 listpack 的结束,其默认值是宏定义 LP_EOF。和 ziplist 列表项的结束标记一样,LP_EOF 的值也是 255
object.c

# 明明有 ziplist 了,为什么出来一个 listpack 紧凑列表?

压缩列表里的每个节点中的 prevlen 属性都记录了「前一个节点的长度」,而且 prevlen 属性的空间大小跟前一个节点长度值有关,比如:
- 如果前一个节点的长度小于 254 字节,那么 prevlen 属性需要用 1 字节的空间来保存这个长度值;
- 如果前一个节点的长度大于等于 254 字节,那么 prevlen 属性需要用 5 字节的空间来保存这个长度值;
ziplist 的连锁更新问题
压缩列表新增某个元素或修改某个元素时,如果空间不不够,压缩列表占用的内存空间就需要重新分配。而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。
案例说明:压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患
第一步:现在假设一个压缩列表中有多个连续的、长度在 250~253 之间的节点,如下图:

因为这些节点长度值小于 254 字节,所以 prevlen 属性需要用 1 字节的空间来保存这个长度值
第二步:这时,如果将一个长度大于等于 254 字节的新节点加入到压缩列表的表头节点,即新节点将成为 entry1 的前置节点,如下图:

因为 entry1 节点的 prevlen 属性只有 1 个字节大小,无法保存新节点的长度,此时就需要对压缩列表的空间重分配操作并将 entry1 节点的 prevlen 属性从原来的 1 字节大小扩展为 5 字节大小。
第三步:连续更新问题出现

entry1 节点原本的长度在 250~253 之间,因为刚才的扩展空间,此时 entry1 节点的长度就大于等于 254,因此原本 entry2 节点保存 entry1 节点的 prevlen 属性也必须从 1 字节扩展至 5 字节大小。entry1 节点影响 entry2 节点,entry2 节点影响 entry3 节点...... 一直持续到结尾。这种在特殊情况下产生的连续多次空间扩展操作就叫做「连锁更新」
所以 listpack 是 Redis 设计用来取代掉 ziplist 的数据结构,它通过每个节点记录自己的长度且放在节点的尾部,来彻底解决掉了 ziplist 存在的连锁更新的问题
# listpack 结构
https://github.com/antirez/listpack/blob/master/listpack.md
listpack 由 4 部分组成:total Bytes、Num Elem、Entry 以及 End

- Total Bytes:为整个 listpack 的空间大小,占用 4 个字节,每个 listpack 最多占用 4294967295Bytes。
- num-elements:为 listpack 中的元素个数,即 Entry 的个数占用 2 个字节
- element-1~element-N:为每个具体的元素
- listpack-end-byte:为 listpack 结束标志,占用 1 个字节,内容为 0xFF。

# entry 结构

- 当前元素的编码类型(entry-encoding)
- 元素数据 (entry-data)
- 以及编码类型和元素数据这两部分的长度 (entry-len)
- listpackEntry 结构定义:listpack.h

# ziplist 内存布局 VS listpack 内存布局

和 ziplist 列表项类似,listpack 列表项也包含了元数据信息和数据本身。不过,为了避免 ziplist 引起的连锁更新问题,listpack 中的每个列表项不再像 ziplist 列表项那样保存其前一个列表项的长度。

# Set 类型内部实现
tag:
经纬恒润、字节、富途、小红书、百度、阿里count:7
as:set 集合元素过多怎么办
Set 类型的底层数据结构是由哈希表或整数集合实现的:
- 如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries 配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构;
- 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。
Redis 用 intset 或 hashtable 存储 set。如果元素都是整数类型,就用 intset 存储。
如果不是整数类型,就用 hashtable(数组 + 链表的存来储结构)。key 就是元素的值,value 为 null。

默认情况下,Redis 会修改进程标题(如 “top” 和 “ps” 所示)以提供一些运行时信息。 可以通过修改配置将以下设置为 no 来禁用它并使进程名称保持为已执行状态。
set-proc-title yes
proc-title-template 在更改进程标题时,Redis 使用以下模板来构造修改后的标题。
proc-title-template "{title} {listen-addr} {server-mode}"
模板变量在大括号中指定。 支持以下变量:
- {title} 父进程执行的进程名称,或子进程的类型。
- {listen-addr} 绑定地址或 ‘*’ 后跟 TCP 或 TLS 端口侦听,或 Unix 套接字(如果可用)。
- {server-mode} 特殊模式,即 “[sentinel]” 或 “[cluster]”。
- {port} TCP 端口监听,或 0。
- {tls-port} TLS 端口监听,或 0。
- {unixsocket} 监听的 Unix 域套接字,或 “”。
- {config-file} 使用的配置文件的名称。
# Set 的两种编码格式
- intset
- hashtable
# 源码分析
t_set.c


# ZSet 类型内部实现
tag:
得物、快手、shopee、百度、得物、微派、美团、京东、滴滴、哔哩哔哩、阿里count:15
as:Redis 中 Zset 的结构是怎么样的,ZADD 命令时间复杂度是多少?
Zset 类型的底层数据结构是由压缩列表或跳表实现的:
- 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构;
- 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构;
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。
# 配置属性
# Redis 6
当有序集合中包含的元素数量超过服务器属性 server.zset_max_ziplist_entries 的值(默认值为 128 ),
或者有序集合中新添加元素的 member 的长度大于服务器属性 server.zset_max_ziplist_value 的值(默认值为 64 )时,redis 会使用跳跃表作为有序集合的底层实现。否则会使用 ziplist 作为有序集合的底层实现


# Redis 7


# ZSet 的两种编码格式
redis6
- ziplist
- skiplist
redis7
- listpack
- skiplist
# 源码分析
# Redis 6
t_zset.c


# Redis 7
t_zset.c

# 总结
# redis6 类型 - 物理编码 - 对应表

# redis6 数据类型对应的底层数据结构
- 字符串
- int:8 个字节的长整型。
- embstr: 小于等于 44 个字节的字符串。
- raw: 大于 44 个字节的字符串。
- Redis 会根据当前值的类型和长度决定使用哪种内部编码实现。
- 哈希
ziplist (压缩列表): 当哈希类型元素个数小于 hash-max-ziplist-entries 配置 (默认 512 个)、同时所有值都小于 hash-max-ziplist-value 配置 (默认 64 字节) 时, Redis 会使用 ziplist 作为哈希的内部实现,ziplist 使用更加紧凑的 结构实现多个元素的连续存储,所以在节省内存方面比 hashtable 更加优秀。
hashtable (哈希表): 当哈希类型无法满足 ziplist 的条件时,Redis 会使 用 hashtable 作为哈希的内部实现,因为此时 ziplist 的读写效率会下降,而 hashtable 的读写时间复杂度为 O (1)。
- 列表
- ziplist (压缩列表): 当列表的元素个数小于 list-max-ziplist-entries 配置 (默认 512 个),同时列表中每个元素的值都小于 list-max-ziplist-value 配置时 (默认 64 字节),Redis 会选用 ziplist 来作为列表的内部实现来减少内存的使 用。
- linkedlist (链表): 当列表类型无法满足 ziplist 的条件时,Redis 会使用 linkedlist 作为列表的内部实现。quicklist ziplist 和 linkedlist 的结合以 ziplist 为节点的链表 (linkedlist)
- 集合
- intset (整数集合): 当集合中的元素都是整数且元素个数小于 set-max-intset-entries 配置 (默认 512 个) 时,Redis 会用 intset 来作为集合的内部实现,从而减少内存的使用。
- hashtable (哈希表): 当集合类型无法满足 intset 的条件时,Redis 会使用 hashtable 作为集合的内部实现。
- 有序集合
- ziplist (压缩列表): 当有序集合的元素个数小于 zset-max-ziplist- entries 配置 (默认 128 个),同时每个元素的值都小于 zset-max-ziplist-value 配 置 (默认 64 字节) 时,Redis 会用 ziplist 来作为有序集合的内部实现,ziplist 可以有效减少内存的使用。
- skiplist (跳跃表): 当 ziplist 条件不满足时,有序集合会使用 skiplist 作 为内部实现,因为此时 ziplist 的读写效率会下降。
# redis6 数据类型以及数据结构的关系

# redis7 数据类型以及数据结构的关系

# redis 数据类型以及数据结构的时间复杂度

# Skiplist 跳表
tag:
字节、shopee、微派、美团、滴滴、货拉拉、作业帮count:9
as:为什么要有跳表?为什么跳表慢与 B + 树?
跳表和二叉树区别?
ZSet 的范围查询的时间复杂度是多少
redis 跳表的数据结构以及查询范围的时间复杂度
对于一个单链表来讲,即便链表中存储的数据是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度会很高 O (N)


解决方法:升维,也叫空间换时间。

从这个例子里,我们看出,加来一层索引之后,查找一个结点需要遍历的结点个数减少了,也就是说查找效率提高了。
画了一个包含 64 个结点的链表,按照前面讲的这种思路,建立了五级索引

skiplist 是一种以空间换取时间的结构。由于链表,无法进行二分查找,因此借鉴数据库索引的思想,提取出链表中关键节点(索引),先在关键节点上查找,再进入下层链表查找,提取多层关键节点,就形成了跳跃表。但是由于索引也要占据一定空间的,所以,索引添加的越多,空间占用的越多。总结来讲 跳表 = 链表 + 多级索引
# 跳表的时间复杂度
跳表查询的时间复杂度分析,如果链表里有 N 个结点,会有多少级索引呢?
按照我们前面讲的,两两取首。每两个结点会抽出一个结点作为上一级索引的结点,以此估算:
第一级索引的结点个数大约就是
假设索引有级,最高级的索引有 2 个结点。通过上面的公式,我们可以得到
时间复杂度是 O (logN)
# 跳表的空间复杂度
比起单纯的单链表,跳表需要存储多级索引,肯定要消耗更多的存储空间。那到底需要消耗多少额外的存储空间呢?
我们来分析一下跳表的空间复杂度。 第一步:首先原始链表长度为 n,
第二步:两两取首,每层索引的结点数:n/2, n/4, n/8 ... , 8, 4, 2 每上升一级就减少一半,直到剩下 2 个结点,以此类推;如果我们把每层索引的结点数写出来,就是一个等比数列。

这几级索引的结点总和就是 n/2+n/4+n/8…+8+4+2=n-2。所以,跳表的空间复杂度是 O (n) 。也就是说,如果将包含 n 个结点的单链表构造成跳表,我们需要额外再用接近 n 个结点的存储空间。
第三步:思考三三取首,每层索引的结点数:n/3, n/9, n/27 ... , 9, 3, 1 以此类推; 第一级索引需要大约 n/3 个结点,第二级索引需要大约 n/9 个结点。每往上一级,索引结点个数都除以 3。为了方便计算,我们假设最高一级的索 引结点个数是 1。我们把每级索引的结点个数都写下来,也是一个等比数列

通过等比数列求和公式,总的索引结点大约就是 n/3+n/9+n/27+…+9+3+1=n/2。尽管空间复杂度还是 O (n) ,但比上面的每两个结点抽一个结点的索引构建方法,要减少了一半的索引结点存储空间。 所以空间复杂度是 O (n);
# 优缺点
优点: 跳表是一个最典型的空间换时间解决方案,而且只有在数据量较大的情况下才能体现出来优势。而且应该是读多写少的情况下才能使用,所以它的适用范围应该还是比较有限的
缺点: 维护成本相对要高,单链表中,一旦定位好要插入的位置,插入结点的时间复杂度是很低的,就是 O (1)
但是新增或者删除时需要把所有索引都更新一遍,为了保证原始链表中数据的有序性,我们需要先找到要动作的位置,这个查找操作就会比较耗时最后在新增和删除的过程中的更新,时间复杂度也是 O (log n)
# 缓存雪崩
tag:
美团、百度、得物、苏小研、税友、腾讯、富途、中金、4399、哔哩哔哩、招行、快手、京东、捷运达、探探、亚信、建信金科count:32
缓存雪崩是指我们缓存多条数据时,采用了相同的过期时间,比如 00:00:00 过期,如果这个时刻缓存同时失效,而有大量请求进来了,因未缓存数据,所以都去查询数据库了,数据库压力增大,最终就会导致雪崩。缓存在同一时间大面积的失效,导致大量的请求都直接落到了数据库上,对数据库造成了巨大的压力。
即无视 redis 直接打穿 mysql 数据库进行查询。

带来的风险
尝试找到大量 key 同时过期的时间,在某时刻进行大量攻击,数据库压力增大,最终导致系统崩溃。
怎么发生的?
- redis 主机挂了,Redis 全盘崩溃,偏硬件运维
- redis 中有大量 key 同时过期大面积失效,偏软件开发
# 如何解决缓存雪崩?
- 将缓存失效时间随机打散:在缓存的时候给过期时间加上一个随机值,这样就会大幅度的减少缓存在同一时间过期,比如 1-5 分钟随机,降低缓存的过期时间的重复率,避免发生缓存集体实效。
- 设置缓存不过期: 我们可以通过后台服务来更新缓存数据,从而避免因为缓存失效造成的缓存雪崩,也可以在一定程度上避免缓存并发问题。
如何预防对于 “Redis 挂掉了,请求全部走数据库” 这种情况,我们可以有以下的思路:
- 事发前:实现 Redis 的高可用 (主从架构 + Sentinel 或者 Redis Cluster),尽量避免 Redis 挂掉这种情况发生。Redis 集群
- 事发中:设置本地缓存 (ehcache)+ 限流 (hystrix),尽量避免我们的数据库挂掉 (起码能保证我们的服务还是能正常工作的)。多级缓存
- 事发后:redis 持久化,重启后自动从磁盘上加载数据,快速恢复缓存数据。
缓存雪崩和缓存击穿比较像,但缓存雪崩导致的原因是缓存中的大量或者所有数据失效,缓存击穿导致的原因主要是某个热点数据不存在与缓存中(通常是因为缓存中的那份数据已经过期)。
# 缓存穿透
tag:
美团、软通、百度、得物、数字马力、苏小研、税友、万得、深信服、4399、招行、快手、京东、捷运达、亚信、好未来、万得、建信金科count:36
as:缓存穿透如果不是恶意请求,又该如何处理?
缓存穿透指一个一定不存在的数据,由于缓存未命中这条数据,就会去查询数据库,数据库也没有这条数据,所以返回结果是 null 。如果每次查询都走数据库,则缓存就失去了意义,就像穿透了缓存一样。既不在缓存中,也不在数据库中,这种现象我们称为缓存穿透,这个 redis 变成了一个摆设。本来无一物,两库都没有。既不在 Redis 缓存库,也不在 mysql,数据库存在被多次暴击风险。

带来的风险 利用不存在的数据进行攻击,数据库压力增大,最终导致系统崩溃。
# 为什么会产生缓存穿透


- 业务层误操作:缓存中的数据和数据库中的数据被误删除了,所以缓存和数据库中都没有数据;
- 恶意攻击:专门访问数据库中没有的数据。
# 解决方案
- 非法请求的限制:当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。
- 设置空值或者默认值:当我们线上业务发现缓存穿透的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。
- 使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在:我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在,即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。
# 空对象缓存或者缺省值
第一种解决方案,回写增强 如果发生了缓存穿透,我们可以针对要查询的数据,在 Redis 里存一个和业务部门商量后确定的缺省值 (比如,零、负数、defaultNull 等)。
比如,键 uid:abcdxxx,值 defaultNull 作为案例的 key 和 value,先去 redis 查键 uid:abcdxxx 没有,再去 mysql 查没有获得 ,这就发生了一次穿透现象。
但是可以增强回写机制,mysql 也查不到的话也让 redis 存入刚刚查不到的 key 并保护 mysql。第一次来查询 uid:abcdxxx,redis 和 mysql 都没有,返回 null 给调用者,但是增强回写后第二次来查 uid:abcdxxx,此时 redis 就有值了。可以直接从 Redis 中读取 default 缺省值返回给业务应用程序,避免了把大量请求发送给 mysql 处理,打爆 mysql。
但是,此方法架不住黑客的恶意攻击,有缺陷......,只能解决 key 相同的情况
黑客会对你的系统进行攻击,拿一个不存在的 id 去查询数据,会产生大量的请求到数据库去查询。可能会导致你的数据库由于压力过大而宕掉
- key 相同打你系统:第一次打到 mysql,空对象缓存后第二次就返回 defaultNull 缺省值,避免 mysql 被攻击,不用再到数据库中去走一圈了
- key 不同打你系统:由于存在空对象缓存和缓存回写 (看自己业务不限死),redis 中的无关紧要的 key 也会越写越多 **(记得设置 redis 过期时间)**
# Google 布隆过滤器 Guava 解决缓存穿透
Guava 中布隆过滤器的实现算是比较权威的,所以实际项目中我们可以直接使用 Guava 布隆过滤器
Guava’s BloomFilter 源码出处 (opens new window)
# 白名单使用
白名单架构说明

误判问题,但是概率小可以接受,不能从布隆过滤器删除,全部合法的 key 都需要放入 Guava 版布隆过滤器 + redis 里面,不然数据就是返回 null。
GuavaBloomFilterController
import com.atguigu.redis7.service.GuavaBloomFilterService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
/**
* @auther zzyy
* @create 2022-12-30 16:50
*/
@Api(tags = "google工具Guava处理布隆过滤器")
@RestController
@Slf4j
public class GuavaBloomFilterController
{
@Resource
private GuavaBloomFilterService guavaBloomFilterService;
@ApiOperation("guava布隆过滤器插入100万样本数据并额外10W测试是否存在")
@RequestMapping(value = "/guavafilter",method = RequestMethod.GET)
public void guavaBloomFilter()
{
guavaBloomFilterService.guavaBloomFilter();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
GuavaBloomFilterService
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
/**
* @auther zzyy
* @create 2022-12-30 16:50
*/
@Service
@Slf4j
public class GuavaBloomFilterService{
public static final int _1W = 10000;
//布隆过滤器里预计要插入多少数据
public static int size = 100 * _1W;
//误判率,它越小误判的个数也就越少(思考,是不是可以设置的无限小,没有误判岂不更好)
//fpp the desired false positive probability
public static double fpp = 0.03;
// 构建布隆过滤器
private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), size,fpp);
public void guavaBloomFilter(){
//1 先往布隆过滤器里面插入100万的样本数据
for (int i = 1; i <=size; i++) {
bloomFilter.put(i);
}
//故意取10万个不在过滤器里的值,看看有多少个会被认为在过滤器里
List<Integer> list = new ArrayList<>(10 * _1W);
for (int i = size+1; i <= size + (10 *_1W); i++) {
if (bloomFilter.mightContain(i)) {
log.info("被误判了:{}",i);
list.add(i);
}
}
log.info("误判的总数量::{}",list.size());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
取样本 100W 数据,查查不在 100W 范围内,其它 10W 数据是否存在,现在总共有 10 万数据是不存在的,误判了 3033 次, 原始样本:100W 不存在数据:1000001W---1100000W


# 黑名单使用
上述案例把布隆过滤器作为白名单使用,同样我们可以当做黑名单使用

# 缓存击穿
tag:
美团、用友、软通、小米、百度、得物、苏小研、税友、腾讯、深信服、4399、招行、快手、京东、建信金科count:32
as:缓存击穿如果访库时加锁,只是缓解了并发冲突,但仍然有多个请求打到 mysql,如何解决?
某个 key 设置了过期时间,但在正好失效的时候,有大量请求进来了,导致请求都到数据库查询了。就像把一面墙击穿了一个洞。简单说就是热点 key 突然失效了,暴打 mysql

可以发现缓存击穿跟缓存雪崩很相似,你可以认为缓存击穿是缓存雪崩的一个子集。 应对缓存击穿可以采取前面说到两种方案:

解决方案
不同场景下的解决方式可如下:
- 若缓存的数据是基本不会发生更新的,尝试将该热点数据设置为永不过期。
- 若缓存的数据更新不频繁,且缓存刷新的整个流程耗时较少的情况下,则可以采用基于 Redis、Zookeeper 等分布式中间件的分布式互斥锁,或者本地互斥锁以保证仅少量的请求能请求数据库并重新构建缓存,其余线程则在锁释放后能访问到新缓存。
- 若缓存的数据更新频繁或者在缓存刷新的流程耗时较长的情况下,可以利用定时线程在缓存过期前主动地重新构建缓存或者延后缓存的过期时间,以保证所有的请求能一直访问到对应的缓存。
# 双检加锁策略
多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个互斥锁来锁住它。 其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。 后面的线程进来发现已经有缓存了,就直接走缓存。
package com.atguigu.redis.service;
import com.atguigu.redis.entities.User;
import com.atguigu.redis.mapper.UserMapper;
import io.swagger.models.auth.In;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.PathVariable;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
/**
* @auther zzyy
* @create 2021-05-01 14:58
*/
@Service
@Slf4j
public class UserService {
public static final String CACHE_KEY_USER = "user:";
@Resource
private UserMapper userMapper;
@Resource
private RedisTemplate redisTemplate;
/**
* 业务逻辑没有写错,对于小厂中厂(QPS《=1000)可以使用,但是大厂不行
* @param id
* @return
*/
public User findUserById(Integer id)
{
User user = null;
String key = CACHE_KEY_USER+id;
//1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql
user = (User) redisTemplate.opsForValue().get(key);
if(user == null)
{
//2 redis里面无,继续查询mysql
user = userMapper.selectByPrimaryKey(id);
if(user == null)
{
//3.1 redis+mysql 都无数据
//你具体细化,防止多次穿透,我们业务规定,记录下导致穿透的这个key回写redis
return user;
}else{
//3.2 mysql有,需要将数据写回redis,保证下一次的缓存命中率
redisTemplate.opsForValue().set(key,user);
}
}
return user;
}
/**
* 加强补充,避免突然key失效了,打爆mysql,做一下预防,尽量不出现击穿的情况。
* @param id
* @return
*/
public User findUserById2(Integer id)
{
User user = null;
String key = CACHE_KEY_USER+id;
//1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql,
// 第1次查询redis,加锁前
user = (User) redisTemplate.opsForValue().get(key);
if(user == null) {
//2 大厂用,对于高QPS的优化,进来就先加锁,保证一个请求操作,让外面的redis等待一下,避免击穿mysql
synchronized (UserService.class){
//第2次查询redis,加锁后
user = (User) redisTemplate.opsForValue().get(key);
//3 二次查redis还是null,可以去查mysql了(mysql默认有数据)
if (user == null) {
//4 查询mysql拿数据(mysql默认有数据)
user = userMapper.selectByPrimaryKey(id);
if (user == null) {
return null;
}else{
//5 mysql里面有数据的,需要回写redis,完成数据一致性的同步工作
redisTemplate.opsForValue().setIfAbsent(key,user,7L,TimeUnit.DAYS);
}
}
}
}
return user;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# 实现高并发的聚划算业务 V2
- 100% 高并发,绝对不可以用 mysql 实现
- 先把 mysql 里面参加活动的数据抽取进 redis,一般采用定时器扫描来决定上线活动还是下线取消。
- 支持分页功能,一页 20 条记录
我们使用 Redis 中的 List 数据类型实现该业务
entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@ApiModel(value = "聚划算活动producet信息")
public class Product
{
//产品ID
private Long id;
//产品名称
private String name;
//产品价格
private Integer price;
//产品详情
private String detail;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
JHSTaskService
采用定时器将参与聚划算活动的特价商品新增进入 redis 中
@Service
@Slf4j
public class JHSTaskService
{
public static final String JHS_KEY="jhs";
public static final String JHS_KEY_A="jhs:a";
public static final String JHS_KEY_B="jhs:b";
@Autowired
private RedisTemplate redisTemplate;
/**
* 偷个懒不加mybatis了,模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
* @return
*/
private List<Product> getProductsFromMysql() {
List<Product> list=new ArrayList<>();
for (int i = 1; i <=20; i++) {
Random rand = new Random();
int id= rand.nextInt(10000);
Product obj=new Product((long) id,"product"+i,i,"detail");
list.add(obj);
}
return list;
}
@PostConstruct
public void initJHS(){
log.info("启动定时器淘宝聚划算功能模拟.........."+ DateUtil.now());
new Thread(() -> {
//模拟定时器一个后台任务,定时把数据库的特价商品,刷新到redis中
while (true){
//模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
List<Product> list=this.getProductsFromMysql();
//采用redis list数据结构的lpush来实现存储
this.redisTemplate.delete(JHS_KEY);
//lpush命令
this.redisTemplate.opsForList().leftPushAll(JHS_KEY,list);
//间隔一分钟 执行一遍,模拟聚划算每3天刷新一批次参加活动
try { TimeUnit.MINUTES.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
log.info("runJhs定时刷新..............");
}
},"t1").start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
JHSProductController
@RestController
@Slf4j
@Api(tags = "聚划算商品列表接口")
public class JHSProductController
{
public static final String JHS_KEY="jhs";
public static final String JHS_KEY_A="jhs:a";
public static final String JHS_KEY_B="jhs:b";
@Autowired
private RedisTemplate redisTemplate;
/**
* 分页查询:在高并发的情况下,只能走redis查询,走db的话必定会把db打垮
* @param page
* @param size
* @return
*/
@RequestMapping(value = "/pruduct/find",method = RequestMethod.GET)
@ApiOperation("按照分页和每页显示容量,点击查看")
public List<Product> find(int page, int size) {
List<Product> list=null;
long start = (page - 1) * size;
long end = start + size - 1;
try {
//采用redis list数据结构的lrange命令实现分页查询
list = this.redisTemplate.opsForList().range(JHS_KEY, start, end);
if (CollectionUtils.isEmpty(list)) {
//TODO 走DB查询
}
log.info("查询结果:{}", list);
} catch (Exception ex) {
//这里的异常,一般是redis瘫痪 ,或 redis网络timeout
log.error("exception:", ex);
//TODO 走DB查询
}
return list;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
至此步骤,上述聚划算的功能算是完成,但在高并发下会以下经典生产问题

热点 key 突然失效导致可怕的缓存击穿


delete 命令执行的一瞬间有空隙,其它请求线程继续找 Redis 为 null,打到了 mysql,暴击......
2 条命令原子性还是其次,主要是防止热 key 突然失效暴击 mysql 打爆系统。
我们可以进一步升级加固案例
差异失效时间

JHSTaskService
@Service
@Slf4j
public class JHSTaskService
{
public static final String JHS_KEY="jhs";
public static final String JHS_KEY_A="jhs:a";
public static final String JHS_KEY_B="jhs:b";
@Autowired
private RedisTemplate redisTemplate;
/**
* 偷个懒不加mybatis了,模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
* @return
*/
private List<Product> getProductsFromMysql() {
List<Product> list=new ArrayList<>();
for (int i = 1; i <=20; i++) {
Random rand = new Random();
int id= rand.nextInt(10000);
Product obj=new Product((long) id,"product"+i,i,"detail");
list.add(obj);
}
return list;
}
//@PostConstruct
public void initJHS(){
log.info("启动定时器淘宝聚划算功能模拟.........."+ DateUtil.now());
new Thread(() -> {
//模拟定时器,定时把数据库的特价商品,刷新到redis中
while (true){
//模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
List<Product> list=this.getProductsFromMysql();
//采用redis list数据结构的lpush来实现存储
this.redisTemplate.delete(JHS_KEY);
//lpush命令
this.redisTemplate.opsForList().leftPushAll(JHS_KEY,list);
//间隔一分钟 执行一遍
try { TimeUnit.MINUTES.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
log.info("runJhs定时刷新..............");
}
},"t1").start();
}
@PostConstruct
public void initJHSAB(){
log.info("启动AB定时器计划任务淘宝聚划算功能模拟.........."+DateUtil.now());
new Thread(() -> {
//模拟定时器,定时把数据库的特价商品,刷新到redis中
while (true){
//模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
List<Product> list=this.getProductsFromMysql();
//先更新B缓存
this.redisTemplate.delete(JHS_KEY_B);
this.redisTemplate.opsForList().leftPushAll(JHS_KEY_B,list);
this.redisTemplate.expire(JHS_KEY_B,20L,TimeUnit.DAYS);
//再更新A缓存
this.redisTemplate.delete(JHS_KEY_A);
this.redisTemplate.opsForList().leftPushAll(JHS_KEY_A,list);
this.redisTemplate.expire(JHS_KEY_A,15L,TimeUnit.DAYS);
//间隔一分钟 执行一遍
try { TimeUnit.MINUTES.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
log.info("runJhs定时刷新双缓存AB两层..............");
}
},"t1").start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
JHSProductController
@RestController
@Slf4j
@Api(tags = "聚划算商品列表接口")
public class JHSProductController
{
public static final String JHS_KEY="jhs";
public static final String JHS_KEY_A="jhs:a";
public static final String JHS_KEY_B="jhs:b";
@Autowired
private RedisTemplate redisTemplate;
/**
* 分页查询:在高并发的情况下,只能走redis查询,走db的话必定会把db打垮
* @param page
* @param size
* @return
*/
@RequestMapping(value = "/pruduct/find",method = RequestMethod.GET)
@ApiOperation("按照分页和每页显示容量,点击查看")
public List<Product> find(int page, int size) {
List<Product> list=null;
long start = (page - 1) * size;
long end = start + size - 1;
try {
//采用redis list数据结构的lrange命令实现分页查询
list = this.redisTemplate.opsForList().range(JHS_KEY, start, end);
if (CollectionUtils.isEmpty(list)) {
//TODO 走DB查询
}
log.info("查询结果:{}", list);
} catch (Exception ex) {
//这里的异常,一般是redis瘫痪 ,或 redis网络timeout
log.error("exception:", ex);
//TODO 走DB查询
}
return list;
}
@RequestMapping(value = "/pruduct/findab",method = RequestMethod.GET)
@ApiOperation("防止热点key突然失效,AB双缓存架构")
public List<Product> findAB(int page, int size) {
List<Product> list=null;
long start = (page - 1) * size;
long end = start + size - 1;
try {
//采用redis list数据结构的lrange命令实现分页查询
list = this.redisTemplate.opsForList().range(JHS_KEY_A, start, end);
if (CollectionUtils.isEmpty(list)) {
log.info("=========A缓存已经失效了,记得人工修补,B缓存自动延续5天");
//用户先查询缓存A(上面的代码),如果缓存A查询不到(例如,更新缓存的时候删除了),再查询缓存B
this.redisTemplate.opsForList().range(JHS_KEY_B, start, end);
//TODO 走DB查询
}
log.info("查询结果:{}", list);
} catch (Exception ex) {
//这里的异常,一般是redis瘫痪 ,或 redis网络timeout
log.error("exception:", ex);
//TODO 走DB查询
}
return list;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# 为了保证缓存和数据库一致性,说说只读缓存的方案?
tag:
美团、知乎、字节、快手、阿里、哈啰、数字马力、百度、瑞幸、富途、北森、4399、小红书、竞技世界、大智慧、大华、好未来、顺丰、招行、饿了么、万得count:42
as:缓存一致性问题,并发场景下怎么优化
- 有数据新增时,会直接写入数据库;
- 有数据删改时,就需要把只读缓存中的数据标记为无效。这样一来,应用后续再访问这些增删改的数据时,因为缓存中没有相应的数据,就会发生缓存缺失。此时,应用再从数据库中把数据读入缓存,这样后续再访问数据时,就能够直接从缓存中读取了。
建议:优先使用先更新数据库再删除缓存的方法。
原因:先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力;如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。
总结见下图:

给缓存设置过期时间,定期清理缓存并回写,是保证最终一致性的解决方案。
我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存,达到一致性,切记,要以 mysql 的数据库写入库为准。
# 先更新数据库,再更新缓存
先更新数据库,再更新缓存会导致什么问题?假设我们有以下操作
- 先更新 mysql 的某商品的库存,当前商品的库存是 100,更新为 99 个。
- 先更新 mysql 修改为 99 成功,然后更新 redis。
- 此时假设异常出现,更新 redis 失败了,这导致 mysql 里面的库存是 99 而 redis 里面的还是 100 。
- 上述发生,会让数据库里面和缓存 redis 里面数据不一致,读到 redis 脏数据
在多线程下同样的异常问题!
A、B 两个线程发起调用,按正常逻辑来说执行顺序应该按顺序执行
1 A update mysql 100
2 A update redis 100
3 B update mysql 80
4 B update redis 80
2
3
4
但可能在多线程环境下,A、B 两个线程有快有慢,有前有后有并行
1 A update mysql 100
3 B update mysql 80
4 B update redis 80
2 A update redis 100
2
3
4
这就出现请求 A 更新缓存应该比请求 B 更新缓存早才对,但是因为网络等原因,B 却比 A 更早更新了缓存。这就导致了脏数据,因此不考虑。最终结果,mysql 和 redis 数据不一致
# 先更新缓存,再更新数据库
我们在业务上一般把 mysql 作为底单数据库,保证最后解释。这个方案基本不会提及。
同样会在多线程下同样的异常问题
A、B 两个线程发起调用,按正常逻辑来说执行顺序应该按顺序执行
1 A update mysql 100
2 A update redis 100
3 B update mysql 80
4 B update redis 80
2
3
4
但可能在多线程环境下,A、B 两个线程有快有慢,有前有后有并行
1 A update mysql 100
3 B update mysql 80
4 B update redis 80
2 A update redis 100
2
3
4
最终结果,mysql 和 redis 数据不一致
# 先删除缓存,再更新数据库
- A 线程先成功删除了 redis 里面的数据,然后去更新 mysql,此时 mysql 正在更新中,还没有结束。(比如网络延时)
- B 突然出现要来读取缓存数据。此时 redis 里面的数据是空的,B 线程来读取,先去读 redis 里数据 (已经被 A 线程 delete 掉了),此处出来 2 个问题:
- B 从 mysql 获得了旧值, B 线程发现 redis 里没有 (缓存缺失) 马上去 mysql 里面读取,从数据库里面读取来的是旧值。
- B 会把获得的旧值写回 redis,获得旧值数据后返回前台并回写进 redis (刚被 A 线程删除的旧数据有极大可能又被写回了)。
- A 线程更新完 mysql,发现 redis 里面的缓存是脏数据,两个并发操作,一个是更新操作,另一个是查询操作, A 删除缓存后,B 查询操作没有命中缓存,B 先把老数据读出来后放到缓存中,然后 A 更新操作更新了数据库。于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。
简化过程就是:
(1)请求 A 进行写操作,删除缓存 (2)请求 B 查询发现缓存不存在 (3)请求 B 去数据库查询得到旧值 (4)请求 B 将旧值写入缓存 (5)请求 A 将新值写入数据库
上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。
| 时间 | 线程 A | 线程 B | 出现的问题 |
|---|---|---|---|
| t1 | 请求 A 进行写操作,删除缓存成功后,工作正在 mysql 进行中...... | ||
| t2 | 1 缓存中读取不到,立刻读 mysql,由于 A 还没有对 mysql 更新完,读到的是旧值 | ||
| 2 还把从 mysql 读取的旧值,写回了 redis | 1 A 还没有更新完 mysql,导致 B 读到了旧值 | ||
| 2 线程 B 遵守回写机制,把旧值写回 redis,导致其它请求读取的还是旧值,A 白干了。 | |||
| t3 | A 更新完 mysql 数据库的值,over | redis 是被 B 写回的旧值, | |
| mysql 是被 A 更新的新值。 | |||
| 出现了,数据不一致问题。 |
- 请求 A 进行写操作,删除 redis 缓存后,工作正在进行中,更新 mysql......A 还么有彻底更新完 mysql,还没 commit
- 请求 B 开工查询,查询 redis 发现缓存不存在 (被 A 从 redis 中删除了)
- 请求 B 继续,去数据库查询得到了 mysql 中的旧值 (A 还没有更新完)
- 请求 B 将旧值写回 redis 缓存
- 请求 A 将新值写入 mysql 数据库
如果数据库更新失败或超时或返回不及时,导致 B 线程请求访问缓存时发现 redis 里面没数据,缓存缺失,B 再去读取 mysql 时,从数据库中读取到旧值,还写回 redis,导致 A 白干了
# 采用延时双删策略
public User deleteOrderDate(Order order)
{
try(Jedis jedis = RedisUtils.ggetJedis()){
// 线程A先成功删除redis缓存
jedis.del(order.getId()+"");
// 线程A再更新mysql
orderDao.update(order);
// 暂停2秒,其他业务逻辑导致耗时延迟
try{
TimeUnit.SECONDS.sleep(2);
}catch (Exception e){
e.printStackTrace();
}
// 线程A再次删除redis缓存,防止线程B从mysql中查询的旧值写入缓存
jedis.del();
}catch (Exception e){
e.printStackTrace();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
即:
(1)先淘汰缓存 (2)再写数据库(这两步和原来一样) (3)休眠 1 秒,再次淘汰缓存
加上 sleep 的这段时间,就是为了让线程 B 能够先从数据库读取数据,再把缺失的数据写入缓存,然后,线程 A 再进行删除。所以,线程 A sleep 的时间,就需要大于线程 B 读取数据再写入缓存的时间。
这样一来,其它线程读取数据时,会发现缓存缺失,所以会从数据库中读取最新值。因为这个方案会在第一次删除缓存值后,延迟一段时间再次进行删除,所以我们也把它叫做 “延迟双删”。
这个删除该休眠多久呢?
线程 A sleep 的时间,就需要大于线程 B 读取数据再写入缓存的时间。
第一种方法: 在业务程序运行的时候,统计下线程读数据和写缓存的操作时间,自行评估自己的项目的读数据业务逻辑的耗时, 以此为基础来进行估算。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上加百毫秒即可。
这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
第二种方法:
新启动一个后台监控程序,比如 WatchDog 监控程序
这种同步淘汰策略,吞吐量降低怎么办?
将第二层删除作为异步的,起一个线程,异步删除,这样写的请求就不用沉睡一段时间后,再返回,加大吞吐量。
如果第二次删除缓存失败,会再次出现缓存和数据库不一致的问题。只能采用另外一套方案先更新数据库,再删除缓存。
如果你用了 mysql 的读写分离架构怎么办?
还是使用双删延时策略。只是,睡眠时间修改为在主从同步的延时时间基础上,加几百 ms。
# 先更新数据库,再删除缓存
| 时间 | 线程 A | 线程 B | 出现的问题 |
|---|---|---|---|
| t1 | 更新数据库中的值...... | ||
| t2 | 缓存中立刻命中,此时 B 读取的是缓存旧值。 | A 还没有来得及删除缓存的值,导致 B 缓存命中读到旧值。 | |
| t3 | 更新缓存的数据,over |
先更新数据库,再删除缓存会导致假如缓存删除失败或者来不及,导致请求再次访问 redis 时缓存命中,读取到的是缓存旧值。

- 可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用 Kafka/RabbitMQ 等)。
- 当程序没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
- 如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了,否则还需要再次进行重试
- 如果重试超过的一定次数后还是没有成功,我们就需要向业务层发送报错信息了,通知运维人员。
# 如何选择方案?利弊如何
优先使用先更新数据库,再删除缓存的方案 (先更库→后删存)。
- 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力导致打满 mysql。
- 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。
如果业务层要求必须读取一致性的数据,那么我们就需要在更新数据库时,先在 Redis 缓存客户端暂停并发读求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性,这是理论可以达到的效果,但实际,不推荐,因为真实生产环境中,分布式下很难做到实时一致性,一般都是最终一致性。
# Redis 持久化
# Redis 如何实现数据不丢失?
tag:
美团、用友、腾讯、小米、知乎、字节、滴滴、shopee、快手、七牛云、富途、淘天、Fabrie、猫眼、卓望、神策数据、数字马力、得物、招行、饿了么、用友、货拉拉、百度、哔哩哔哩、tp-link、一嗨租车、玄武科技count:39
as:持久化机制讲讲?AOF 会影响主进程吗?
AOF 与 RDB 持久化方式的区别
redis 宕机怎么办
Redis 崩溃怎么办
介绍一下持久化中 rdb 和 aof,并且是否都会对其他线程造成影响
Redis 的读写操作都是在内存中,所以 Redis 性能才会高,但是当 Redis 重启后,内存中的数据就会丢失,那为了保证内存中的数据不会丢失,Redis 实现了数据持久化的机制,这个机制会把数据存储到磁盘,这样在 Redis 重启就能够从磁盘中恢复原有的数据。
Redis 共有三种数据持久化的方式:
- AOF 日志:每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里;
- RDB 快照:将某一时刻的内存数据,以二进制的方式写入磁盘;
- 混合持久化方式:Redis 4.0 新增的方式,集成了 AOF 和 RBD 的优点;
# AOF
AOF 和 RDB 不同,AOF 是通过保存 redis 服务器所执行的写命令来记录数据库状态的。 先执行命令后记录命令然后 Redis 重启时,会读取该文件记录的命令,然后逐一执行命令的方式来进行数据恢复。AOF 通过追加、写入、同步三个步骤来实现持久化机制。

我这里以「_set name xiaolin_」命令作为例子,Redis 执行了这条命令后,记录在 AOF 日志里的内容如下图:

「*3」表示当前命令有三个部分,每部分都是以「数字」开头,后面紧跟着具体的命令、键或值。然后,这里的「数字」表示这部分中的命令、键或值一共有多少字节。例如,「3 set」表示这部分有 3 个字节,也就是「set」命令这个字符串的长度。
# 为什么先执行命令,再把数据写入日志呢?
Reids 是先执行写操作命令后,才将该命令记录到 AOF 日志里的,这么做其实有两个好处。
- 避免额外的检查开销:因为如果先将写操作命令记录到 AOF 日志里,再执行该命令的话,如果当前的命令语法有问题,那么如果不进行命令语法检查,该错误的命令记录到 AOF 日志里后,Redis 在使用日志恢复数据时,就可能会出错。
- 不会阻塞当前写操作命令的执行:因为当写操作命令执行成功后,才会将命令记录到 AOF 日志。
当然,这样做也会带来风险:
- 数据可能会丢失: 执行写操作命令和记录日志是两个过程,那当 Redis 在还没来得及将命令写入到硬盘时,服务器发生宕机了,这个数据就会有丢失的风险。
- 可能阻塞其他操作: 由于写操作命令执行成功后才记录到 AOF 日志,所以不会阻塞当前命令的执行,但因为 AOF 日志也是在主线程中执行,所以当 Redis 把日志文件写入磁盘的时候,还是会阻塞后续的操作无法执行。
# AOF 写回策略有几种?
先来看看,Redis 写入 AOF 日志的过程,如下图:

- Redis 执行完写操作命令后,会将命令追加到 server.aof_buf 缓冲区;
- 然后通过 write () 系统调用,将 aof_buf 缓冲区的数据写入到 AOF 文件,此时数据并没有写入到硬盘,而是拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘;
- 具体内核缓冲区的数据什么时候写入到硬盘,由内核决定。
Redis 提供了 3 种写回硬盘的策略,控制的就是上面说的第三步的过程。 在 Redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填:
- Always,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;
- Everysec,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
- No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。

# AOF 日志过大,会触发什么机制?
AOF 日志是一个文件,随着执行的写操作命令越来越多,文件的大小会越来越大。 如果当 AOF 日志文件过大就会带来性能问题,比如重启 Redis 后,需要读 AOF 文件的内容以恢复数据,如果文件过大,整个恢复的过程就会很慢。
所以,Redis 为了避免 AOF 文件越写越大,提供了 AOF 重写机制,当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。
AOF 重写机制是在重写时,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到「新的 AOF 文件」,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。
举个例子,在没有使用重写机制前,假设前后执行了「_set name xiaolin_」和「_set name xiaolincoding_」这两个命令的话,就会将这两个命令记录到 AOF 文件。

但是在使用重写机制后,就会读取 name 最新的 value(键值对) ,然后用一条 「set name xiaolincoding」命令记录到新的 AOF 文件,之前的第一个命令就没有必要记录了,因为它属于「历史」命令,没有作用了。这样一来,一个键值对在重写日志中只用一条命令就行了。
重写工作完成后,就会将新的 AOF 文件覆盖现有的 AOF 文件,这就相当于压缩了 AOF 文件,使得 AOF 文件体积变小了。
# RDB
tag:
字节count:1
as:
Redis 持久化方案分为 RDB 和 AOF 两种。
RDB 持久化可以手动执行也可以根据配置定期执行,它的作用是将某个时间点上的数据库状态保存到 RDB 文件中,RDB 文件是一个压缩的二进制文件,通过它可以还原某个时刻数据库的状态。由于 RDB 文件是保存在硬盘上的,所以即使 redis 崩溃或者退出,只要 RDB 文件存在,就可以用它来恢复还原数据库的状态。
因此在 Redis 恢复数据时, RDB 恢复数据的效率会比 AOF 高些,因为直接将 RDB 文件读入内存就可以,不需要像 AOF 那样还需要额外执行操作命令的步骤才能恢复数据。
# RDB 做快照时会阻塞线程吗?
tag:
美团、字节count:2
as:执行 RDB 主线程写怎么办?
Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave,他们的区别就在于是否在「主线程」里执行:
- 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程;直到 RDB 文件生成完毕,在进程阻塞期间,redis 不能处理任何命令请 求,这显然是不合适的。
- 执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,父进程还可以继续处理命令请求,这样可以避免主线程的阻塞;
Redis 还可以通过配置文件的选项来实现每隔一段时间自动执行一次 bgsave 命令,默认会提供以下配置:
save 900 1 # 900 秒之内,对数据库进行了至少 1 次修改;
save 300 10 # 300 秒之内,对数据库进行了至少 10 次修改;
save 60 10000 # 60 秒之内,对数据库进行了至少 10000 次修改。
2
3
别看选项名叫 save,实际上执行的是 bgsave 命令,也就是会创建子进程来生成 RDB 快照文件。 只要满足上面条件的任意一个,就会执行 bgsave,它们的意思分别是:
这里提一点,Redis 的快照是全量快照,也就是说每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。所以执行快照是一个比较重的操作,如果频率太频繁,可能会对 Redis 性能产生影响。如果频率太低,服务器故障时,丢失的数据会更多。
bgsave 流程图如下所示

具体流程如下:
- redis 客户端执行 bgsave 命令或者自动触发 bgsave 命令;
- 主进程判断当前是否已经存在正在执行的子进程,如果存在,那么主进程直接返回;
- 如果不存在正在执行的子进程,那么就 fork 一个新的子进程进行持久化数据,fork 过程是阻塞的,fork 操作完成后主进程即可执行其他操作;
- 子进程先将数据写入到临时的 rdb 文件中,待快照数据写入完成后再原子替换旧的 rdb 文件;
- 同时发送信号给主进程,通知主进程 rdb 持久化完成,主进程更新相关的统计信息(info Persitence 下的 rdb_* 相关选项);
# RDB 在执行快照的时候,数据能修改吗?
tag:
字节、七牛云count:2
as:fork 创建子进程有哪些特点呢?
Copy On Write 技术介绍一下
fork 的工作原理能描述下吗
主进程挂掉后,子进程如果不挂的话会被谁托管
fork 子进程时,它们是什么时候开始 “分家”,在一个什么样的时机
可以的,执行 bgsave 过程中,Redis 依然可以继续处理操作命令的,也就是数据是能被修改的,关键的技术就在于写时复制技术(Copy-On-Write, COW)。
执行 bgsave 命令的时候,会通过 fork () 创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个,此时如果主线程执行读操作,则主线程和 bgsave 子进程互相不影响。

如果主线程执行写操作,则被修改的数据会复制一份副本,然后 bgsave 子进程会把该副本数据写入 RDB 文件,在这个过程中,主线程仍然可以直接修改原来的数据。

# 混合持久化
Redis 4.0 中提出了一个混合使用 AOF 日志和内存快照的方法。简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。
- RDB 优点是数据恢复速度快,但是快照的频率不好把握。频率太低,丢失的数据就会比较多,频率太高,就会影响性能。
- AOF 优点是丢失数据少,但是数据恢复不快。
为了集成了两者的优点, Redis 4.0 提出了混合使用 AOF 日志和内存快照,也叫混合持久化,既保证了 Redis 重启速度,又降低数据丢失风险。
混合持久化工作在 AOF 日志重写过程,当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。
也就是说,使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。

这样的好处在于,重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样加载的时候速度会很快。
加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得数据更少的丢失。
这样一来,快照不用很频繁地执行,这就避免了频繁 fork 对主线程的影响。而且,AOF 日志也只用记录两次快照间的操作,也就是说,不需要记录所有操作了,因此,就不会出现文件过大的情况了,也可以避免重写开销。
如下图所示,T1 和 T2 时刻的修改,用 AOF 日志记录,等到第二次做全量快照时,就可以清空 AOF 日志,因为此时的修改都已经记录到快照中了,恢复时就不再用日志了。

混合持久化优点:
- 混合持久化结合了 RDB 和 AOF 持久化的优点,开头为 RDB 的格式,使得 Redis 可以更快的启动,同时结合 AOF 的优点,有减低了大量数据丢失的风险。
混合持久化缺点:
- AOF 文件中添加了 RDB 格式的内容,使得 AOF 文件的可读性变得很差;
- 兼容性差,如果开启混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本了。
# Redis 如何实现事务?
tag:
快手count:1
as:
事务的执行过程包含三个步骤,Redis 提供了 MULTI、EXEC 两个命令来完成这三个步骤。
第一步,客户端要使用一个命令显式地表示一个事务的开启。在 Redis 中,这个命令就是 MULTI。
第二步,客户端把事务中本身要执行的具体操作(例如增删改数据)发送给服务器端。这些操作就是 Redis 本身提供的数据读写命令,例如 GET、SET 等。不过,这些命令虽然被客户端发送到了服务器端,但 Redis 实例只是把这些命令暂存到一个命令队列中,并不会立即执行。
第三步,客户端向服务器端发送提交事务的命令,让数据库实际执行第二步中发送的具体操作。Redis 提供的 EXEC 命令就是执行事务提交的。当服务器端收到 EXEC 命令后,才会实际执行命令队列中的所有命令。

# Redis 的 watch 机制的作用?
一个事务的 EXEC 命令还没有执行时,事务的命令操作是暂存在命令队列中的。此时,如果有其它的并发操作,我们就需要看事务是否使用了 WATCH 机制。
WATCH 机制的作用是,在事务执行前,监控一个或多个键的值变化情况,当事务调用 EXEC 命令执行时,WATCH 机制会先检查监控的键是否被其它客户端修改了。如果修改了,就放弃事务执行,避免事务的隔离性被破坏。然后,客户端可以再次执行事务,此时,如果没有并发修改事务数据的操作了,事务就能正常执行,隔离性也得到了保证。
WATCH 机制的具体实现是由 WATCH 命令实现的,如下图所示: 
# Redis 过期删除与内存淘汰
tag:
美团、知乎、携程、数字马力、字节、数字马力、快手、税友、腾讯、猫眼、金蝶、深信服count:18
# Redis 过期策略是怎么样的?
redis 是可以对 key 设置过期时间的,因此需要有相应的机制将已过期的键值对删除,而做这个工作的就是过期键值删除策略。
每当我们对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个过期字典(expires dict)中,也就是说「过期字典」保存了数据库中所有 key 的过期时间。
过期字典的键指向 Redis 数据库中的某个 key (键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。

过期字典是存储在 redisDb 这个结构里的:
typedef struct redisDb {
...
dict *dict; //数据库键空间,保存着数据库中所有键值对
dict *expires // 过期字典,保存着键的过期时间
...
} redisDb;
2
3
4
5
6
7
当我们查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中(时间复杂度为 O (1)):
- 如果不在,则正常读取键值;
- 如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判定该 key 已过期,过期直接删除 key 然后返回 null。
Redis 使用的过期删除策略是「惰性删除 + 定期删除」这两种策略配和使用。
# 惰性删除
概念:在获取某个 key 的时候,Redis 会检查下这个 key 是否过期了,如果过期了则删除,且不会返回任何东西。
优点:因为每次访问时,才会检查 key 是否过期,不会删除其他键,所以不会花费任何 CPU 时间在其他无关的过期键上。
缺点:如果一个 key 已经过期,而这个 key 又仍然保留在数据库中,那么只要这个过期 key 一直没有被访问,大量过期键未被访问,无法自动释放,造成数据积压,可以看作是内存泄漏。对 memory 不友好,用存储空间换取处理器性能(拿空间换时间)。
在使用惰性删除策略时,如果数据库中有非常多的过期键,而这些过期键又恰好没有被访问到的话,那么它们也许永远也不会被删除 (除非用户手动执行 FLUSHDB),我们甚至可以将这种情况看作是一种内存泄漏–无用的垃圾数据占用了大量的内存,而服务器却不会自己去释放它们,这对于运行状态非常依赖于内存的 Redis 服务器来说,肯定不是一个好消息
惰性删除策略的做法是,不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。
开启憜性淘汰,修改配置文件
lazyfree-lazy-eviction=yes
源码解析:
- db.c/expireIfNeeded 删除过期键,Redis 命令在执行之前都会调用这个函数对输入键进行检查。原理如下图所示:

- GET 命令,判断当键存在时,按照键存在的情况执行。当键不存在时,返回空。原理如下图所示:

# 定期删除
概念:每隔默认的 100 ms 随机抽取一些设置了过期时间的 key,检查是否过期,如果过期就删除。
优点:
- 通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响,同时也能删除一部分过期的数据减少了过期键对空间的无效占用。
缺点:
- 如果定时删除执行得太频繁,或者执行的时间太长,CPU 时间就会过多地消耗在删除过期键上面
- 如果删除操作执行得太少,或者执行的时间太短,则会出现和惰性删除一样的问题,内存浪费或数据积压。
惰性删除策略和定期删除策略都有各自的优点,所以 Redis 选择「惰性删除 + 定期删除」这两种策略配和使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。
Redis 的定期删除的流程:
- 从过期字典中随机抽取 20 个 key;
- 检查这 20 个 key 是否过期,并删除已过期的 key;
- 如果本轮检查的已过期 key 的数量,超过 5 个(20/4),也就是「已过期 key 的数量」占比「随机抽取 key 的数量」大于 25%,则继续重复步骤 1;如果已过期的 key 比例小于 25%,则停止继续删除过期 key,然后等待下一轮再检查。

Redis 为了保证定期删除不会出现循环过度,导致线程卡死现象,为此增加了定期删除循环流程的时间上限,默认不会超过 25ms。
源码解析:
- 每当 Redis 服务器的周期性操作 redis.c/serverCron 函数执行时,redis.c/activeExpireCycle 会被调用。
- activeExpireCycle 函数在规定的时间内,分多次遍历服务器中的各个数据库,从数据库的 expires 字典中随机检查一部分键的过期时间,并删除其中的过期键。
- current_db 记录当前检查的数据库,如果函数 activeExpireCycle 当前正在处理 2 号数据库,时间超限,返回后,下次检查时,会从 3 号数据库开始检查。所有数据库检查一遍后,current_db 重置为 0,然后再次开始一轮的检查工作。
# Redis 内存满了,会发生什么?
tag:
字节、小红书、滴滴、探探count:6
as:如果 Redis 数据超过内存限制,该如何处理
Redis 内存管理
项目 redis 内存设置多大,一般情况占用多少内存,怎么考虑
在 Redis 的运行内存达到了某个阀值,就会触发内存淘汰机制,这个阀值就是我们设置的最大运行内存,此值在 Redis 的配置文件中可以找到,配置项为 maxmemory 。
打开 redis 配置文件,设置 maxmemory 参数,maxmemory 是 bytes 字节类型,注意转换。

# redis 默认内存多少可以用?
注意,在 64bit 系统下,maxmemory 设置为 0 表示不限制 Redis 内存使用

# 一般生产上你如何配置?
一般推荐 Redis 设置内存为最大物理内存的四分之三
# 如何修改 redis 内存设置
通过修改文件配置,配置项为 maxmemory 。

通过命令修改
config set maxmemory 104857600
config get maxmemory
2
# 什么命令查看 redis 内存使用情况?
info memory
config get maxmemory
2
# Redis 的淘汰策略有哪几种?
源码在这里:redis.conf 文件

- noeviction(Redis3.0 之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,而是不再提供服务,直接返回错误。
- volatile-ttl 策略,在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期的越先被删除。
- volatile-random 策略,在设置了过期时间的键值对中,进行随机删除。
- volatile-lru 策略(Redis3.0 之前,默认的内存淘汰策略),会使用 LRU 算法筛选设置了过期时间的键值对。淘汰所有设置了过期时间的键值中,最久未使用的键值;
- volatile-lfu (Redis 4.0 后新增的内存淘汰策略)会使用 LFU 算法选择设置了过期时间的键值对。首先会筛选并淘汰访问次数少的数据,然后针对访问次数相同的数据,再筛选并淘汰访问时间最久远的数据。
- allkeys-random 策略,从所有键值对中随机选择并删除数据。
- allkeys-lru 策略,使用 LRU 算法在所有数据中进行筛选,淘汰整个键值中最久未使用的键值;
- allkeys-lfu 策略(Redis 4.0 后新增的内存淘汰策略),使用 LFU 算法在所有数据中进行筛选,淘汰整个键值中最少使用的键值。
默认情况下,Redis 在使用的内存空间超过 maxmemory 值时,并不会淘汰数据,也就是设定的 noeviction 策略。写满后再写会返回错误。

选择哪个一个?
- 在所有的 key 都是最近最经常使用,那么就需要选择 allkeys-lru 进行置换最近最不经常使用的 key, 如果你不确定使用哪种策略,那么推荐使用 allkeys-lru
- 如果所有的 key 的访问概率都是差不多的,那么可以选用 allkeys-random 策略去置换数据
- 如果对数据有足够的了解,能够为 key 指定 hint (通过 expire/ttl 指定),那么可以选择 volatiIe-tt 进行置换
# LRU
tag:
快手count:1
as:
LRU 全称是 Least Recently Used 翻译为最近最少使用,会选择淘汰最近最少使用的数据。
传统 LRU 算法的实现是基于「链表」结构,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可,因为链表尾部的元素就代表最久未被使用的元素。
Redis 并没有使用这样的方式实现 LRU 算法,因为传统的 LRU 算法存在两个问题:
- 需要用链表管理所有的缓存数据,这会带来额外的空间开销;
- 当有数据被访问时,需要在链表上把该数据移动到头端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。
# Redis 是如何实现 LRU 算法的?
Redis 实现的是一种近似 LRU 算法,目的是为了更好的节约内存,它的实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间。
当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后淘汰最久没有使用的那个。
Redis 实现的 LRU 算法的优点:
- 不用为所有的数据维护一个大链表,节省了空间占用;
- 不用在每次数据访问时都移动链表项,提升了缓存的性能;
但是 LRU 算法有一个问题,无法解决缓存污染问题,比如应用一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。
因此,在 Redis 4.0 之后引入了 LFU 算法来解决这个问题。
# LFU
LFU 全称是 Least Frequently Used 翻译为最近最不常用的,LFU 算法是根据数据访问次数来淘汰数据的,它的核心思想是 “如果数据过去被访问多次,那么将来被访问的频率也更高”。
所以, LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。这样就解决了偶尔被访问一次之后,数据留存在缓存中很长一段时间的问题,相比于 LRU 算法也更合理一些。
# Redis 是如何实现 LFU 算法的?
LFU 算法相比于 LRU 算法的实现,多记录了「数据的访问频次」的信息。Redis 对象的结构如下:
typedef struct redisObject {
...
// 24 bits,用于记录对象的访问信息
unsigned lru:24;
...
} robj;
2
3
4
5
6
7
Redis 对象头中的 lru 字段,在 LRU 算法下和 LFU 算法下使用方式并不相同。
在 LRU 算法中,Redis 对象头的 24 bits 的 lru 字段是用来记录 key 的访问时间戳,因此在 LRU 模式下,Redis 可以根据对象头中的 lru 字段记录的值,来比较最后一次 key 的访问时间长,从而淘汰最久未被使用的 key。
在 LFU 算法中,Redis 对象头的 24 bits 的 lru 字段被分成两段来存储,高 16bit 存储 ldt (Last Decrement Time),用来记录 key 的访问时间戳;低 8bit 存储 logc (Logistic Counter),用来记录 key 的访问频次。

# 为啥 LRU 不行?LFU 比 LRU 好在哪?
tag:
美团count:1
# Redis 线程模型
tag:
字节count:1
as:
# Redis 是单线程吗?
Redis 单线程指的是「接收客户端请求 -> 解析请求 -> 进行数据读写等操作 -> 发送数据给客户端」这个过程是由一个线程(主线程)来完成的,这也是我们常说 Redis 是单线程的原因。
但是,Redis 程序并不是单线程的,Redis 在启动的时候,是会启动后台线程(BIO)的:

- Redis 在 2.6 版本,会启动 2 个后台线程,分别处理关闭文件、AOF 刷盘这两个任务;
- Redis 在 4.0 版本之后,新增了一个新的后台线程,用来异步释放 Redis 内存,也就是 lazyfree 线程。例如执行 unlink key /flushdb async /flushall async 等命令,会把这些删除操作交给后台线程来执行,好处是不会导致 Redis 主线程卡顿。因此,当我们要删除一个大 key 的时候,不要使用 del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,因此我们应该使用 unlink 命令来异步删除大 key。
之所以 Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。
后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。

关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列:
- BIO_CLOSE_FILE,关闭文件任务队列:当队列有任务后,后台线程会调用 close (fd) ,将文件关闭;
- BIO_AOF_FSYNC,AOF 刷盘任务队列:当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用 fsync (fd),将 AOF 文件刷盘,
- BIO_LAZY_FREE,lazy free 任务队列:当队列有任务后,后台线程会 free (obj) 释放对象 /free (dict) 删除数据库所有对象 /free (skiplist) 释放跳表对象;
# Redis 单线程模式是怎样的?
Redis 6.0 版本之前的单线模式如下图:

图中的蓝色部分是一个事件循环,是由主线程负责的,可以看到网络 I/O 和命令处理都是单线程。 Redis 初始化的时候,会做下面这几件事情:
- 首先,调用 epoll_create () 创建一个 epoll 对象和调用 socket () 创建一个服务端 socket
- 然后,调用 bind () 绑定端口和调用 listen () 监听该 socket;
- 然后,将调用 epoll_ctl () 将 listen socket 加入到 epoll,同时注册「连接事件」处理函数。
初始化完后,主线程就进入到一个事件循环函数,主要会做以下事情:
首先,先调用处理发送队列函数,看是发送队列里是否有任务,如果有发送任务,则通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。
接着,调用 epoll_wait 函数等待事件的到来:
- 如果是连接事件到来,则会调用连接事件处理函数,该函数会做这些事情:调用 accpet 获取已连接的 socket -> 调用 epoll_ctl 将已连接的 socket 加入到 epoll -> 注册「读事件」处理函数;
- 如果是读事件到来,则会调用读事件处理函数,该函数会做这些事情:调用 read 获取客户端发送的数据 -> 解析命令 -> 处理命令 -> 将客户端对象添加到发送队列 -> 将执行结果写到发送缓存区等待发送;
- 如果是写事件到来,则会调用写事件处理函数,该函数会做这些事情:通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会继续注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。
Redis 是单线程主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,Redis 在处理客户端的请求时包括获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的主线程处理,这就是所谓的 “单线程”。这也是 Redis 对外提供键值存储服务的主要流程。

但 Redis 的其他功能,比如持久化 RDB、AOF、异步删除、集群数据同步等等,其实是由额外的线程执行的。 Redis 命令工作线程是单线程的,但是,整个 Redis 来说,是多线程的;
- 通过
bio_close_file后台线程来释放 AOF / RDB 等过程中产生的临时文件资源。 - 通过
bio_aof_fsync后台线程调用fsync函数将系统内核缓冲区还未同步到到磁盘的数据强制刷到磁盘( AOF 文件)。 - 通过
bio_lazy_free后台线程释放大对象(已删除)占用的内存空间.
在 bio.h 文件中有定义(Redis 6.0 版本,源码地址:https://github.com/redis/redis/blob/6.0/src/bio.hopen in new window (opens new window)):
#ifndef __BIO_H
#define __BIO_H
/* Exported API */
void bioInit(void);
void bioCreateBackgroundJob(int type, void *arg1, void *arg2, void *arg3);
unsigned long long bioPendingJobsOfType(int type);
unsigned long long bioWaitStepOfType(int type);
time_t bioOlderJobOfType(int type);
void bioKillThreads(void);
/* Background job opcodes */
#define BIO_CLOSE_FILE 0 /* Deferred close(2) syscall. */
#define BIO_AOF_FSYNC 1 /* Deferred AOF fsync. */
#define BIO_LAZY_FREE 2 /* Deferred objects freeing. */
#define BIO_NUM_OPS 3
#endif
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Redis 采用单线程为什么还这么快?
tag:
腾讯、快手、美团、途虎养车、字节、深信服、喜马拉雅、4399、饿了么count:17
as:redis 为什么快
redis 单线程模型介绍一下?
官方使用基准测试的结果是,单线程的 Redis 吞吐量可以达到 10W / 每秒
之所以 Redis 采用单线程(网络 I/O 和执行命令)那么快,有如下几个原因:
- 基于内存操作:Redis 的大部分操作都在内存中完成,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的内存或者网络带宽,而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了;
- 避免上下文切换:Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。
- 数据结构简单:Redis 的数据结构是专门设计的,而这些简单的数据结构的查找和操作的时间大部分复杂度都是 O (1),因此性能比较高;
- 多路复用和非阻塞 I/O:Redis 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求,IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。
那既然都这么快了,为什么不直接用 Redis 当主数据库呢?主要是因为内存成本太高且 Redis 提供的数据持久化仍然有数据丢失的风险。
# Redis 6.0 之前为什么使用单线程?
我们都知道单线程的程序是无法利用服务器的多核 CPU 的,那么早期 Redis 版本的主要工作(网络 I/O 和执行命令)为什么还要使用单线程呢?我们不妨先看一下 Redis 官方给出的 FAQ。

核心意思是:CPU 并不是制约 Redis 性能表现的瓶颈所在,更多情况下是受到内存大小和网络 I/O 的限制,所以 Redis 核心网络模型使用单线程并没有什么问题,如果你想要使用服务的多核 CPU,可以在一台服务器上启动多个节点或者采用分片集群的方式。
除了上面的官方回答,选择单线程的原因也有下面的考虑。
使用了单线程后,可维护性高,多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。
- 使用单线程模型是 Redis 的开发和维护更简单,因为单线程模型方便开发和调试;
- 即使使用单线程模型也并发的处理多客户端的请求,主要使用的是 IO 多路复用和非阻塞 IO;
- 对于 Redis 系统来说,主要的性能瓶颈是内存或者网络带宽而并非 CPU。
# Redis 6.0 之后为什么引入了多线程?
tag:
七牛云、美团、京东、快手count:5
as:Redis7 里面引入了多线程模式,你觉得这个适用于什么场景
redis 的单线程指的是什么
redis 为什么是单线程
单线程在多核机器里使用会不会浪费机器资源?执行命令还是单线程,那如何利用多核心来提升性能?
虽然 Redis 的主要工作(网络 I/O 和执行命令)一直是单线程模型,虽然有些命令操作可以用后台线程或子进程执行(比如数据删除、快照生成、AOF 重写)。但是,从网络 IO 处理到实际的读写命令处理,都是由单个线程完成的。在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求,这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。
随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 IO 的处理上,也就是说,单个主线程处理网络请求的速度跟不上底层网络硬件的速度,为了应对这个问题,采用多个 IO 线程来处理网络请求,提高网络请求处理的并行度,Redis6/7 就是采用的这种方法。
但是,Redis 的多 IO 线程只是用来处理网络请求的,对于读写操作命令 Redis 仍然使用单线程来处理。这是因为,Redis 处理请求时,网络处理经常是瓶颈,通过多个 IO 线程并行处理网络操作,可以提升实例的整体处理性能。而继续使用单线程执行命令操作,就不用为了保证 Lua 脚本、事务的原子性,额外开发多线程互斥加锁机制了 (不管加锁操作处理),这样一来,Redis 线程模型实现就简单了
所以为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理,所以大家不要误解 Redis 有多线程同时执行命令。
Redis 官方表示,Redis 6.0 版本引入的多线程 I/O 特性对性能提升至少是一倍以上。
Redis 6.0 版本支持的 I/O 多线程特性,默认情况下 I/O 多线程只针对发送响应数据(write client socket),并不会以多线程的方式处理读请求(read client socket)。要想开启多线程处理客户端读请求,就需要把 Redis.conf 配置文件中的 io-threads-do-reads 配置项设为 yes。
# 读请求也使用io多线程
io-threads-do-reads yes
2
同时, Redis.conf 配置文件中提供了 IO 多线程个数的配置项。
# io-threads N,表示启用 N-1 个 I/O 多线程(主线程也算一个 I/O 线程)
io-threads 4
2
关于线程数的设置,官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。
因此, Redis 6.0 版本之后,Redis 在启动的时候,默认情况下会额外创建 6 个线程(这里的线程数不包括主线程):
- Redis-server : Redis 的主线程,主要负责执行命令;
- bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF 刷盘任务、释放内存任务;
- io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4 ,所以会启动 3(4-1)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力。
# 主线程和 IO 线程是怎么协作完成请求处理的?

阶段一:服务端和客户端建立 Socketi 连接,并分配处理线程 首先,主线程负责接收建立连接请求。当有客户端清求和实例建立 Sockt 连接时,主线程会创健和客户端的连接,并把 Sockt 放入全局等待队列中。紧接着,主线程通过轮询方法把 Socket 连接分配给 IO 线程。
阶段二:IO 线程读取并解折请求 主线程一旦把 Sockt 份配给 IO 线程,就会进入阻塞状态,等待 IO 线程完成客户端请求读取和解析。因为有多个 IO 线程在并行处理,所以,这个过程很快就可以完成
阶段三:主线程执行请求操作 等到 IO 线程解析完请求,主线程还是会以单线程的方式执行这些命令操作。
阶段四:IO 线程回写 Socket 和主线程清空全局队列 当主线程执行完请求操作后,会把需要返回的结果写入缓冲区,然后,主线程会阻塞等待 IO 线程,把这些结果回写到 Sockt 中,并返回给客户端。和 IO 线程读取和解析请求一样,IO 线程回写 Socke 时,也是有多个线程在并发执行,所以回写 Socke 的速度也很快。等到 IO 线程回写 Sockt 完毕,主线程会清空全局队列,等待客户端的后续请求。

# Unix 网络编程中的五种 IO 模型
- Blocking IO - 阻塞 IO
- NoneBlocking IO - 非阻塞 IO
- IO multiplexing - IO 多路复用
- signal driven IO - 信号驱动 IO
- asynchronous IO - 异步 IO
# IO multiplexing - IO 多路复用
Linux 世界一切皆文件,文件描述符、简称 FD,句柄。
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,文件描述符这一概念往往只适用于 UNIX、Linux 这样的操作系统。

IO 多路复用是什么?
一种同步的 IO 模型,实现一个线程监视多个文件句柄,一旦某个文件句柄就绪就能够通知到对应应用程序进行相应的读写操作,没有文件句柄就绪时就会阻塞应用程序,从而释放 CPU 资源
- I/O :网络 I/O,尤其在操作系统层面指数据在内核态和用户态之间的读写操作
- 多路:多个客户端连接(连接就是套接字描述符,即 socket 或者 channel)
- 复用:复用一个或几个线程。
IO 多路复用也就是说一个或一组线程处理多个 TCP 连接,使用单进程就能够实现同时处理多个客户端的连接,无需创建或者维护过多的进程 / 线程。即一个服务端进程可以同时处理多个套接字描述符。
实现 IO 多路复用的模型有 3 种:可以分 select->poll->epoll 三个阶段来描述。
模拟一个 tcp 服务器处理 30 个客户 socket。 假设你是一个监考老师,让 30 个学生解答一道竞赛考题,然后负责验收学生答卷,你有下面几个选择:
- 第一种选择 (轮询):按顺序逐个验收,先验收 A,然后是 B,之后是 C、D。。。这中间如果有一个学生卡住,全班都会被耽误,你用循环挨个处理 socket,根本不具有并发能力。
- 第二种选择 (来一个 new 一个,1 对 1 服务):你创建 30 个分身线程,每个分身线程检查一个学生的答案是否正确。 这种类似于为每一个用户创建一个进程或者线程处理连接。
- 第三种选择 (响应式处理,1 对多服务),你站在讲台上等,谁解答完谁举手。这时 C、D 举手,表示他们解答问题完毕,你下去依次检查 C、D 的答案,然后继续回到讲台上等。此时 E、A 又举手,然后去处理 E 和 A。。。这种就是 IO 复用模型。Linux 下的 select、poll 和 epoll 就是干这个的。
将用户 socket 对应的文件描述符 (FileDescriptor) 注册进 epoll,然后 epoll 帮你监听哪些 socket 上有消息到达,这样就避免了大量的无用操作。此时的 socket 应该采用非阻塞模式。这样,整个过程只在调用 select、poll、epoll 这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是事件驱动,所谓的 reactor 反应模式。

在单个线程通过记录跟踪每一个 Sockek (I/O 流) 的状态来同时管理多个 I/O 流。 一个服务端进程可以同时处理多个套接字描述符。目的是尽量多的提高服务器的吞吐能力。
# 高性能设计之 epoll 和 IO 多路复用深度解析
tag:
字节count:1
as:
多路复用要解决的问题
并发多客户端连接,在多路复用之前最简单和典型的方案:同步阻塞网络 IO 模型 这种模式的特点就是用一个进程来处理一个网络连接 (一个用户请求),比如一段典型的示例代码如下。
直接调用 recv 函数从一个 socket 上读取数据。
int main()
{
...
recv(sock, ...) //从用户角度来看非常简单,一个recv一用,要接收的数据就到我们手里了。
}
2
3
4
5
我们来总结一下这种方式: 优点就是这种方式非常容易让人理解,写起代码来非常的自然,符合人的直线型思维。
缺点就是性能差,每个用户请求到来都得占用一个进程来处理,来一个请求就要分配一个进程跟进处理,类似一个学生配一个老师,一位患者配一个医生,可能吗?进程是一个很笨重的东西。一台服务器上创建不了多少个进程。
I/O 多路复用模型
- I/O :网络 I/O
- 多路:多个客户端连接(连接就是套接字描述符,即 socket 或者 channel),指的是多条 TCP 连接
- 复用:用一个进程来处理多条的连接,使用单进程就能够实现同时处理多个客户端的连接
一句话:实现了用一个进程来处理大量的用户连接
IO 多路复用类似一个规范和接口,落地实现,一般可以分 select->poll->epoll 三个阶段来描述。

Redis 单线程如何处理那么多并发客户端连接,为什么单线程,为什么快
Redis 的 IO 多路复用 Redis 利用 epoll 来实现 IO 多路复用,将连接信息和事件放到队列中,一次放到文件事件分派器,事件分派器将事件分发给事件处理器。

Redis 是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都是阻塞的,所以 I/O 操作在一般情况下往往不能直接返回,这会导致某一文件的 I/O 阻塞导致整个进程无法对其它客户提供服务,而 I/O 多路复用就是为了解决这个问题而出现
所谓 I/O 多路复用机制,就是说通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。这种机制的使用需要 select 、 poll 、 epoll 来配合。多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。
Redis 服务采用 Reactor 的方式来实现文件事件处理器(每一个网络连接其实都对应一个文件描述符
Redis 基于 Reactor 模式开发了网络事件处理器,这个处理器被称为文件事件处理器。它的组成结构为 4 部分:
- 多个套接字
- IO 多路复用程序
- 文件事件分派器
- 事件处理器
因为文件事件分派器队列的消费是单线程的,所以 Redis 才叫单线程模型
参考《Redis 设计与实现》

从 Redis6 开始,将网络数据读写、请求协议解析通过多个 IO 线程的来处理 ,对于真正的命令执行来说,仍然使用单线程操作,一举两得

# 同步和阻塞场景案例
上午开会,错过了公司食堂的饭点, 中午就和公司的首席架构师一起去楼下的米线店去吃米线。我们到了一看,果然很多人在排队。
架构师马上发话了:嚯,请求排队啊!你看这位收银点菜的,** 像不像 nginx 的反向代理?** 只收请求,不处理,把请求都发给后厨去处理。我们交了钱,拿着号离开了点餐收银台,找了个座位坐下等餐。
架构师:你看,这就是异步处理,我们下了单就可以离开等待,米线做好了会通过小喇叭 **“回调”** 我们去取餐;
如果同步处理,我们就得在收银台站着等餐,后面的请求无法处理,客户等不及肯定会离开了。
接下里架构师盯着手中的纸质号牌。
架构师:你看,这个纸质号牌在后厨 “服务器” 那里也有,这不就是表示会话的 ID 吗? 有了它就可以把大家给区分开,就不会把我的排骨米线送给别人了。过了一会, 排队的人越来越多,已经有人表示不满了,可是收银员已经满头大汗,忙到极致了。
架构师:你看他这个系统缺乏弹性扩容, 现在这么多人,应该增加收银台,可以没有其他收银设备,老板再着急也没用。老板看到在收银这里帮不了忙,后厨的订单也累积得越来越多, 赶紧跑到后厨亲自去做米线去了。
架构师又发话了:幸亏这个系统的后台有并行处理能力,可以随意地增加资源来处理请求(做米线)。 我说:他就这点儿资源了,除了老板没人再会做米线了。 不知不觉,我们等了 20 分钟, 但是米线还没上来。 架构师:你看,系统的处理能力达到极限,超时了吧。 这时候收银台前排队的人已经不多了,但是还有很多人在等米线。
老板跑过来让这个打扫卫生的去收银,让收银小妹也到后厨帮忙。打扫卫生的做收银也磕磕绊绊的,没有原来的小妹灵活。
架构师:这就叫服务降级,为了保证米线的服务,把别的服务都给关闭了。 又过了 20 分钟,后厨的厨师叫道:237 号, 您点的排骨米线没有排骨了,能换成番茄的吗? 架构师低声对我说:瞧瞧, 人太多, 系统异常了。然后他站了起来:不行,系统得进行补偿操作:退费。
说完,他拉着我,饿着肚子,头也不回地走了。
- 同步:调用者要一直等待调用结果的通知后才能进行后续的执行,现在就要,我可以等,等出结果为止
- 异步:指被调用方先返回应答让调用者先回去,然后再计算调用结果,计算完最终结果后再通知并返回给调用方
同步与异步的理解:同步、异步的讨论对象是被调用者 (服务提供者),重点在于获得调用结果的消息通知方式上
- 阻塞:调用方一直在等待而且别的事情什么都不做,当前进 / 线程会被挂起,啥都不干
- 非阻塞:调用在发出去后,调用方先去忙别的事情,不会阻塞当前进 / 线程,而会立即返回
阻塞与非阻塞的理解:阻塞、非阻塞的讨论对象是调用者 (服务请求者),重点在于等消息时候的行为,调用者是否能干其它事
4 种模式组合方式:
- 同步阻塞:服务员说快到你了,先别离开我后台看一眼马上通知你。客户在海底捞火锅前台干等着,啥都不干。
- 同步非阻塞:服务员说快到你了,先别离开。客户在海底捞火锅前台边刷抖音边等着叫号
- 异步阻塞:服务员说还要再等等,你先去逛逛,一会儿通知你。客户怕过号在海底捞火锅前台拿着排号小票啥都不干,一直等着店员通知
- 异步非阻塞:服务员说还要再等等,你先去逛逛,一会儿通知你。拿着排号小票 + 刷着抖音,等着店员通知
# Unix 网络编程中的五种 IO 模型
- Blocking IO - 阻塞 IO 又称 BIO
- NoneBlocking IO - 非阻塞 IO 又称 NIO
- IO multiplexing - IO 多路复用
- signal driven IO - 信号驱动 IO
- asynchronous IO - 异步 IO
# BIO
当用户进程调用了 recvfrom 这个系统调用,kernel 就开始了 IO 的第一个阶段:准备数据(对于网络 IO 来说,很多时候数据在一开始还没有到达。
比如,还没有收到一个完整的 UDP 包。这个时候 kernel 就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当 kernel 一直等到数据准备好了,它就会将数据从 kernel 中拷贝到用户内存,然后 kernel 返回结果,用户进程才解除 block 的状态,重新运行起来。所以,BIO 的特点就是在 IO 执行的两个阶段都被 block 了。


# accept 监听
RedisServer
package com.zzyy.study.iomultiplex.one;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
/**
* @auther zzyy
* @create 2020-12-06 10:14
*/
public class RedisServer
{
public static void main(String[] args) throws IOException
{
byte[] bytes = new byte[1024];
ServerSocket serverSocket = new ServerSocket(6379);
while(true)
{
System.out.println("-----111 等待连接");
Socket socket = serverSocket.accept();
System.out.println("-----222 成功连接");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
RedisClient01
package com.zzyy.study.iomultiplex.one;
import java.io.IOException;
import java.net.Socket;
import java.util.Scanner;
/**
* @auther zzyy
* @create 2020-12-06 10:20
*/
public class RedisClient01
{
public static void main(String[] args) throws IOException
{
System.out.println("------RedisClient01 start");
Socket socket = new Socket("127.0.0.1", 6379);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
RedisClient02
package com.zzyy.study.iomultiplex.one;
import java.io.IOException;
import java.net.Socket;
/**
* @auther zzyy
* @create 2020-12-06 10:20
*/
public class RedisClient02
{
public static void main(String[] args) throws IOException
{
System.out.println("------RedisClient02 start");
Socket socket = new Socket("127.0.0.1", 6379);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# read 读取
先启动 RedisServerBIO,再启动 RedisClient01 验证后再启动 2 号客户端
RedisServerBIO
package com.zzyy.study.iomultiplex.bio;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
/**
* @auther zzyy
* @create 2020-12-08 15:14
*/
public class RedisServerBIO
{
public static void main(String[] args) throws IOException
{
ServerSocket serverSocket = new ServerSocket(6379);
while(true)
{
System.out.println("-----111 等待连接");
Socket socket = serverSocket.accept();//阻塞1 ,等待客户端连接
System.out.println("-----222 成功连接");
InputStream inputStream = socket.getInputStream();
int length = -1;
byte[] bytes = new byte[1024];
System.out.println("-----333 等待读取");
while((length = inputStream.read(bytes)) != -1)//阻塞2 ,等待客户端发送数据
{
System.out.println("-----444 成功读取"+new String(bytes,0,length));
System.out.println("====================");
System.out.println();
}
inputStream.close();
socket.close();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
RedisClient01
package com.zzyy.study.iomultiplex.bio;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Scanner;
/**
* @auther zzyy
* @create 2020-12-08 15:21
*/
public class RedisClient01
{
public static void main(String[] args) throws IOException
{
Socket socket = new Socket("127.0.0.1",6379);
OutputStream outputStream = socket.getOutputStream();
//socket.getOutputStream().write("RedisClient01".getBytes());
while(true)
{
Scanner scanner = new Scanner(System.in);
String string = scanner.next();
if (string.equalsIgnoreCase("quit")) {
break;
}
socket.getOutputStream().write(string.getBytes());
System.out.println("------input quit keyword to finish......");
}
outputStream.close();
socket.close();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
RedisClient02
package com.zzyy.study.iomultiplex.bio;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Scanner;
/**
* @auther zzyy
* @create 2020-12-08 15:21
*/
public class RedisClient02
{
public static void main(String[] args) throws IOException
{
Socket socket = new Socket("127.0.0.1",6379);
OutputStream outputStream = socket.getOutputStream();
//socket.getOutputStream().write("RedisClient01".getBytes());
while(true)
{
Scanner scanner = new Scanner(System.in);
String string = scanner.next();
if (string.equalsIgnoreCase("quit")) {
break;
}
socket.getOutputStream().write(string.getBytes());
System.out.println("------input quit keyword to finish......");
}
outputStream.close();
socket.close();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
上面的模型存在很大的问题,如果客户端与服务端建立了连接,如果这个连接的客户端迟迟不发数据,程就会一直堵塞在 read () 方法上,这样其他客户端也不能进行连接,也就是一次只能处理一个客户端,对客户很不友好
# 多线程模式
利用多线程只要连接了一个 socket,操作系统分配一个线程来处理,这样 read () 方法堵塞在每个具体线程上而不堵塞主线程,就能操作多个 socket 了,哪个线程中的 socket 有数据,就读哪个 socket,各取所需,灵活统一。
程序服务端只负责监听是否有客户端连接,使用 accept () 阻塞 客户端 1 连接服务端,就开辟一个线程(thread1)来执行 read () 方法,程序服务端继续监听 客户端 2 连接服务端,也开辟一个线程(thread2)来执行 read () 方法,程序服务端继续监听 客户端 3 连接服务端,也开辟一个线程(thread3)来执行 read () 方法,程序服务端继续监听 .......
任何一个线程上的 socket 有数据发送过来,read () 就能立马读到,cpu 就能进行处理。
RedisServerBIOMultiThread
package com.zzyy.study.iomultiplex.bio;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
/**
* @auther zzyy
* @create 2020-12-08 16:13
*
*/
public class RedisServerBIOMultiThread
{
public static void main(String[] args) throws IOException
{
ServerSocket serverSocket = new ServerSocket(6379);
while(true)
{
//System.out.println("-----111 等待连接");
Socket socket = serverSocket.accept();//阻塞1 ,等待客户端连接
//System.out.println("-----222 成功连接");
new Thread(() -> {
try {
InputStream inputStream = socket.getInputStream();
int length = -1;
byte[] bytes = new byte[1024];
System.out.println("-----333 等待读取");
while((length = inputStream.read(bytes)) != -1)//阻塞2 ,等待客户端发送数据
{
System.out.println("-----444 成功读取"+new String(bytes,0,length));
System.out.println("====================");
System.out.println();
}
inputStream.close();
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
},Thread.currentThread().getName()).start();
System.out.println(Thread.currentThread().getName());
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
RedisClient01
package com.zzyy.study.iomultiplex.bio;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Scanner;
/**
* @auther zzyy
* @create 2020-12-08 15:21
*/
public class RedisClient01
{
public static void main(String[] args) throws IOException
{
Socket socket = new Socket("127.0.0.1",6379);
OutputStream outputStream = socket.getOutputStream();
//socket.getOutputStream().write("RedisClient01".getBytes());
while(true)
{
Scanner scanner = new Scanner(System.in);
String string = scanner.next();
if (string.equalsIgnoreCase("quit")) {
break;
}
socket.getOutputStream().write(string.getBytes());
System.out.println("------input quit keyword to finish......");
}
outputStream.close();
socket.close();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
RedisClient02
package com.zzyy.study.iomultiplex.bio;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Scanner;
/**
* @auther zzyy
* @create 2020-12-08 15:21
*/
public class RedisClient02
{
public static void main(String[] args) throws IOException
{
Socket socket = new Socket("127.0.0.1",6379);
OutputStream outputStream = socket.getOutputStream();
//socket.getOutputStream().write("RedisClient01".getBytes());
while(true)
{
Scanner scanner = new Scanner(System.in);
String string = scanner.next();
if (string.equalsIgnoreCase("quit")) {
break;
}
socket.getOutputStream().write(string.getBytes());
System.out.println("------input quit keyword to finish......");
}
outputStream.close();
socket.close();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
多线程模型每来一个客户端,就要开辟一个线程,如果来 1 万个客户端,那就要开辟 1 万个线程。在操作系统中用户态不能直接开辟线程,需要调用内核来创建的一个线程,这其中还涉及到用户状态的切换(上下文的切换),十分耗资源。
第一个办法:使用线程池
这个在客户端连接少的情况下可以使用,但是用户量大的情况下,你不知道线程池要多大,太大了内存可能不够,也不可行。
第二个办法:NIO(非阻塞式 IO)方式 因为 read () 方法堵塞了,所有要开辟多个线程,如果什么方法能使 read () 方法不堵塞,这样就不用开辟多个线程了,这就用到了另一个 IO 模型,NIO(非阻塞式 IO)
tomcat7 之前就是用 BIO 多线程来解决多连接
在阻塞式 I/O 模型中,应用程序在从调用 recvfrom 开始到它返回有数据报准备好这段时间是阻塞的,recvfrom 返回成功后,应用进程才能开始处理数据报。

# NIO
在 NIO 模式中,一切都是非阻塞的:
accept () 方法是非阻塞的,如果没有客户端连接,就返回无连接标识 read () 方法是非阻塞的,如果 read () 方法读取不到数据就返回空闲中标识,如果读取到数据时只阻塞 read () 方法读数据的时间
在 NIO 模式中,只有一个线程: 当一个客户端与服务端进行连接,这个 socket 就会加入到一个数组中,隔一段时间遍历一次, 看这个 socket 的 read () 方法能否读到数据,这样一个线程就能处理多个客户端的连接和读取了
上述以前的 socket 是阻塞的,另外开发一套 API,JDK 提供了 ServerSocketChannel

RedisServerNIO
package com.zzyy.study.iomultiplex.nio;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
/**
* @auther zzyy
* @create 2020-12-06 11:40
*/
public class RedisServerNIO
{
static ArrayList<SocketChannel> socketList = new ArrayList<>();
static ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
public static void main(String[] args) throws IOException
{
System.out.println("---------RedisServerNIO 启动等待中......");
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.bind(new InetSocketAddress("127.0.0.1",6379));
serverSocket.configureBlocking(false);//设置为非阻塞模式
while (true)
{
for (SocketChannel element : socketList)
{
int read = element.read(byteBuffer);
if(read > 0)
{
System.out.println("-----读取数据: "+read);
byteBuffer.flip();
byte[] bytes = new byte[read];
byteBuffer.get(bytes);
System.out.println(new String(bytes));
byteBuffer.clear();
}
}
SocketChannel socketChannel = serverSocket.accept();
if(socketChannel != null)
{
System.out.println("-----成功连接: ");
socketChannel.configureBlocking(false);//设置为非阻塞模式
socketList.add(socketChannel);
System.out.println("-----socketList size: "+socketList.size());
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
RedisClient01
package com.zzyy.study.iomultiplex.nio;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Scanner;
/**
* @auther zzyy
* @create 2020-12-06 10:20
*/
public class RedisClient01
{
public static void main(String[] args) throws IOException
{
System.out.println("------RedisClient01 start");
Socket socket = new Socket("127.0.0.1",6379);
OutputStream outputStream = socket.getOutputStream();
while(true)
{
Scanner scanner = new Scanner(System.in);
String string = scanner.next();
if (string.equalsIgnoreCase("quit")) {
break;
}
socket.getOutputStream().write(string.getBytes());
System.out.println("------input quit keyword to finish......");
}
outputStream.close();
socket.close();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
RedisClient02
package com.zzyy.study.iomultiplex.nio;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Scanner;
/**
* @auther zzyy
* @create 2020-12-06 10:2asds7
*/
public class RedisClient02
{
public static void main(String[] args) throws IOException
{
System.out.println("------RedisClient02 start");
Socket socket = new Socket("127.0.0.1",6379);
OutputStream outputStream = socket.getOutputStream();
while(true)
{
Scanner scanner = new Scanner(System.in);
String string = scanner.next();
if (string.equalsIgnoreCase("quit")) {
break;
}
socket.getOutputStream().write(string.getBytes());
System.out.println("------input quit keyword to finish......");
}
outputStream.close();
socket.close();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
NIO 成功的解决了 BIO 需要开启多线程的问题,NIO 中一个线程就能解决多个 socket,但是还存在 2 个问题。
问题一: 这个模型在客户端少的时候十分好用,但是客户端如果很多,比如有 1 万个客户端进行连接,那么每次循环就要遍历 1 万个 socket,如果一万个 socket 中只有 10 个 socket 有数据,也会遍历一万个 socket,就会做很多无用功,每次遍历遇到 read 返回 -1 时仍然是一次浪费资源的系统调用。
问题二: 而且这个遍历过程是在用户态进行的,用户态判断 socket 是否有数据还是调用内核的 read () 方法实现的,这就涉及到用户态和内核态的切换,每遍历一个就要切换一次,开销很大因为这些问题的存在。
优点:不会阻塞在内核的等待数据过程,每次发起的 I/O 请求可以立即返回,不用阻塞等待,实时性较好。 缺点:轮询将会不断地询问内核,这将占用大量的 CPU 时间,系统资源利用率较低,所以一般 Web 服务器不使用这种 I/O 模型。 结论:让 Linux 内核搞定上述需求,我们将一批文件描述符通过一次系统调用传给内核由内核层去遍历,才能真正解决这个问题。IO 多路复用应运而生,也即将上述工作直接放进 Linux 内核,不再两态转换而是直接从内核获得结果,因为内核是非阻塞的。

# IO 多路复用
I/O 多路复用在英文中其实叫 I/O multiplexing

多个 Socket 复用一根网线这个功能是在内核+驱动层实现的
I/O multiplexing 这里面的 multiplexing 指的其实是在单个线程通过记录跟踪每一个 Sock (I/O 流) 的状态来同时管理多个 I/O 流。目的是尽量多的提高服务器的吞吐能力。

大家都用过 nginx,nginx 使用 epoll 接收请求,ngnix 会有很多链接进来, epoll 会把他们都监视起来,然后像拨开关一样,谁有数据就拨向谁,然后调用相应的代码处理。redis 类似同理
Linux 世界一切皆文件,文件描述符、简称 FD,句柄。
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,文件描述符这一概念往往只适用于 UNIX、Linux 这样的操作系统。

IO multiplexing 就是我们说的 select,poll,epoll,有些技术书籍也称这种 IO 方式为 event driven IO 事件驱动 IO。就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。可以基于一个阻塞对象并同时在多个描述符上等待就绪,而不是使用多个线程 (每个文件描述符一个线程,每次 new 一个线程),这样可以大大节省系统资源。所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select,poll,epoll 等函数就可以返回。

模拟一个 tcp 服务器处理 30 个客户 socket,一个监考老师监考多个学生,谁举手就应答谁。
假设你是一个监考老师,让 30 个学生解答一道竞赛考题,然后负责验收学生答卷,你有下面几个选择: 第一种选择:按顺序逐个验收,先验收 A,然后是 B,之后是 C、D。。。这中间如果有一个学生卡住,全班都会被耽误,你用循环挨个处理 socket,根本不具有并发能力。
第二种选择:你创建 30 个分身线程,每个分身线程检查一个学生的答案是否正确。 这种类似于为每一个用户创建一个进程或者线程处理连接。
第三种选择,你站在讲台上等,谁解答完谁举手。这时 C、D 举手,表示他们解答问题完毕,你下去依次检查 C、D 的答案,然后继续回到讲台上等。此时 E、A 又举手,然后去处理 E 和 A。。。这种就是 IO 复用模型。Linux 下的 select、poll 和 epoll 就是干这个的。
将用户 socket 对应的 fd 注册进 epoll,然后 epoll 帮你监听哪些 socket 上有消息到达,这样就避免了大量的无用操作。此时的 socket 应该采用非阻塞模式。这样,整个过程只在调用 select、poll、epoll 这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是事件驱动,所谓的 reactor 反应模式。
Redis 利用 epoll 来实现 IO 多路复用,将连接信息和事件放到队列中,依次放到事件分派器,事件分派器将事件分发给事件处理器。

Redis 服务采用 Reactor 的方式来实现文件事件处理器(每一个网络连接其实都对应一个文件描述符) 所谓 I/O 多路复用机制,就是说通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。这种机制的使用需要 select 、 poll 、 epoll 来配合。多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。
所谓 I/O 多路复用机制,就是说通过一种考试监考机制,一个老师可以监视多个考生,一旦某个考生举手想要交卷了,能够通知监考老师进行相应的收卷子或批改检查操作。所以这种机制需要调用班主任 (select/poll/epoll) 来配合。多个考生被同一个班主任监考,收完一个考试的卷子再处理其它人,无需等待所有考生,谁先举手就先响应谁,当又有考生举手要交卷,监考老师看到后从讲台走到考生位置,开始进行收卷处理。
# Reactor 设计模式
基于 I/O 复用模型:多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。
Reactor 模式,是指通过一个或多个输入同时传递给服务处理器的服务请求的事件驱动处理模式。服务端程序处理传入多路请求,并将它们同步分派给请求对应的处理线程,Reactor 模式也叫 Dispatcher 模式。即 I/O 多了复用统一监听事件,收到事件后分发 (Dispatch 给某进程),是编写高性能网络服务器的必备技术。

Reactor 模式中有 2 个关键组成:
- Reactor:Reactor 在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对 IO 事件做出反应。 它就像公司的电话接线员,它接听来自客户的电话并将线路转移到适当的联系人;
- Handlers:处理程序执行 I/O 事件要完成的实际事件,类似于客户想要与之交谈的公司中的实际办理人。Reactor 通过调度适当的处理程序来响应 I/O 事件,处理程序执行非阻塞操作。
每一个网络连接其实都对应一个文件描述符

Redis 服务采用 Reactor 的方式来实现文件事件处理器(每一个网络连接其实都对应一个文件描述符)
Redis 基于 Reactor 模式开发了网络事件处理器,这个处理器被称为文件事件处理器。 它的组成结构为 4 部分:
- 多个套接字
- IO 多路复用程序
- 文件事件分派器
- 事件处理器
因为文件事件分派器队列的消费是单线程的,所以 Redis 才叫单线程模型
# select 方法
使用 man 查看 select 方法描述
https://man7.org/linux/man-pages/man2/select.2.html

select 函数监视的文件描述符分 3 类,分别是 readfds、writefds 和 exceptfds,将用户传入的数组拷贝到内核空间
调用后 select 函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有 except)或超时(timeout 指定等待时间,如果立即返回设为 null 即可),函数返回。
当 select 函数返回后,可以通过遍历 fdset,来找到就绪的描述符。
用户态我们自己写的 java 代码思想

C 语言代码



# 优缺点
select 其实就是把 NIO 中用户态要遍历的 fd 数组 (我们的每一个 socket 链接,安装进 ArrayList 里面的那个) 拷贝到了内核态,让内核态来遍历,因为用户态判断 socket 是否有数据还是要调用内核态的,所有拷贝到内核态后,这样遍历判断的时候就不用一直用户态和内核态频繁切换了
从代码中可以看出,select 系统调用后,返回了一个置位后的 & rset,这样用户态只需进行很简单的二进制比较,就能很快知道哪些 socket 需要 read 数据,有效提高了效率

- bitmap 最大 1024 位,一个进程最多只能处理 1024 个客户端
- &rset 不可重用,每次 socket 有数据就相应的位会被置位
- 文件描述符数组拷贝到了内核态 (只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)),仍然有开销。select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
- select 并没有通知用户态哪一个 socket 有数据,仍然需要 O (n) 的遍历。select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
我们自己模拟写的是,RedisServerNIO.java, 只不过将它内核化了。
select 方式,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 select 的系统调用 + N 次就绪状态的文件描述符的 read 系统调用
# poll 方法
使用 man 查看 poll 方法 描述
https://man7.org/linux/man-pages/man2/poll.2.html



优点:
- poll 使用 pollfd 数组来代替 select 中的 bitmap,数组没有 1024 的限制,可以一次管理更多的 client。它和 select 的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制。
- 当 pollfds 数组中有事件发生,相应的 revents 置位为 1,遍历的时候又置位回零,实现了 pollfd 数组的重用
poll 解决了 select 缺点中的前两条,其本质原理还是 select 的方法,还存在 select 中原来的问题
- pollfds 数组拷贝到了内核态,仍然有开销
- poll 并没有通知用户态哪一个 socket 有数据,仍然需要 O (n) 的遍历
# epoll 方法
使用 man 查看 epoll 方法描述
https://man7.org/linux/man-pages/man7/epoll.7.html
三步调用
第一步创建一个 epoll 句柄 epoll_create

第二步向内核添加、修改或删除要监控的文件描述符 epoll_ctl

第三步类似发起了 select () 调用 epoll_wait

epol 川是非阻塞的是非阻塞的!!
epolle 的执行流程:
- 当有数据的时候,会把相应的文件描述符 " 置位”,但是 epool 没有 revent 标志位,所以并不是真正的置位。这时候会把有数据的文件描述符放到队首。
- epoll 会返回有数据的文件描述符的个数
- 根据返回的个数读取前 N 个文件描述符即可
- 读取、处理

多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符。 epoll 是现在最先进的 IO 多路复用器,Redis、Nginx,linux 中的 Java NIO 都使用的是 epoll。 这里 “多路” 指的是多个网络连接,“复用” 指的是复用同一个线程。
- 一个 socket 的生命周期中只有一次从用户态拷贝到内核态的过程,开销小
- 使用 event 事件通知机制,每次 socket 中有数据会主动通知内核,并加入到就绪链表中,不需要遍历所有的 socket
在多路复用 IO 模型中,会有一个内核线程不断地去轮询多个 socket 的状态,只有当真正读写事件发送时,才真正调用实际的 IO 读写操作。因为在多路复用 IO 模型中,只需要使用一个线程就可以管理多个 socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有真正有读写事件进行时,才会使用 IO 资源,所以它大大减少来资源占用。
多路 I/O 复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。 采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗),且 Redis 在内存中操作数据的速度非常快,也就是说内存内的操作不会成为影响 Redis 性能的瓶颈
# 三个方法对比

# 5 种 I/O 模型总结
多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符。
所谓 I/O 多路复用机制,就是说通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。这种机制的使用需要 select 、 poll 、 epoll 来配合。多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理;


# Redis 有慢查询怎么办?
tag:
淘米、瑞幸、京东count:3
as:如果 Redis 集群达到性能瓶颈了,但是有很多请求过来时你怎么解决?
Redis 慢查询统计的是命令执行这一步骤的耗时,慢查询命令也就是那些命令执行时间较长的命令。
比如我们可以看到 keys 命令的复杂度为 O (N)。如下图所示:

以下是常见的 O (n) 时间复杂度的命令
KEYS *:会返回所有符合规则的 key。HGETALL:会返回一个 Hash 中所有的键值对。LRANGE:会返回 List 中指定范围内的元素。SMEMBERS:返回 Set 中的所有元素。SINTER/SUNION/SDIFF:计算多个 Set 的交集 / 并集 / 差集。
由于这些命令时间复杂度是 O (n),有时候也会全表扫描,随着 n 的增大,执行耗时也会越长。不过, 这些命令并不是一定不能使用,但是需要明确 N 的值。另外,有遍历的需求可以使用 HSCAN 、 SSCAN 、 ZSCAN 代替。
除了这些 O (n) 时间复杂度的命令可能会导致慢查询之外, 还有一些时间复杂度可能在 O (N) 以上的命令,例如:
ZRANGE/ZREVRANGE:返回指定 Sorted Set 中指定排名范围内的所有元素。时间复杂度为 O (log (n)+m),n 为所有元素的数量, m 为返回的元素数量,当 m 和 n 相当大时,O (n) 的时间复杂度更小。ZREMRANGEBYRANK/ZREMRANGEBYSCORE:移除 Sorted Set 中指定排名范围 / 指定 score 范围内的所有元素。时间复杂度为 O (log (n)+m),n 为所有元素的数量, m 被删除元素的数量,当 m 和 n 相当大时,O (n) 的时间复杂度更小。
# 发现慢查询
当发现 Redis 性能变慢时,可以通过 Redis 日志,或者是 latency monitor 工具,查询变慢的请求,根据请求对应的具体命令以及官方文档,确认下是否采用了复杂度高的慢查询命令。
# 获取慢查询日志 日志包含四个部分:日志的标识 id、发生时间戳、命令耗时、执行命令和参数。
showlog get [N]
# 获取慢查询日志列表的当前的长度
showlog len
# 清空慢查询
showlog reset
2
3
4
5
6
在 redis.conf 文件中,我们可以使用 slowlog-log-slower-than 参数设置耗时命令的阈值,并使用 slowlog-max-len 参数设置耗时命令的最大记录条数。
当 Redis 服务器检测到执行时间超过 slowlog-log-slower-than 阈值的命令时,就会将该命令记录在慢查询日志 (slow log) 中,这点和 MySQL 记录慢查询语句类似。当慢查询日志超过设定的最大记录条数之后,Redis 会把最早的执行命令依次舍弃。
⚠️注意:由于慢查询日志会占用一定内存空间,如果设置最大记录条数过大,可能会导致内存占用过高的问题。
slowlog-log-slower-than 和 slowlog-max-len 的默认配置如下 (可以自行修改):
# The following time is expressed in microseconds, so 1000000 is equivalent
# to one second. Note that a negative number disables the slow log, while
# a value of zero forces the logging of every command.
slowlog-log-slower-than 10000
# There is no limit to this length. Just be aware that it will consume memory.
# You can reclaim memory used by the slow log with SLOWLOG RESET.
slowlog-max-len 128
2
3
4
5
6
7
8
除了修改配置文件之外,你也可以直接通过 CONFIG 命令直接设置:
# 命令执行耗时超过 10000 微妙(即10毫秒)就会被记录
CONFIG SET slowlog-log-slower-than 10000
# 只保留最近 128 条耗时命令
CONFIG SET slowlog-max-len 128
2
3
4
获取慢查询日志的内容很简单,直接使用 SLOWLOG GET 命令即可。
127.0.0.1:6379> SLOWLOG GET #慢日志查询
1) 1) (integer) 5
2) (integer) 1684326682
3) (integer) 12000
4) 1) "KEYS"
2) "*"
5) "172.17.0.1:61152"
6) ""
// ...
2
3
4
5
6
7
8
9
慢查询日志中的每个条目都由以下六个值组成:
- 唯一渐进的日志标识符。
- 处理记录命令的 Unix 时间戳。
- 执行所需的时间量,以微秒为单位。
- 组成命令参数的数组。
- 客户端 IP 地址和端口。
- 客户端名称。
SLOWLOG GET 命令默认返回最近 10 条的的慢查询命令,你也自己可以指定返回的慢查询命令的数量 SLOWLOG GET N 。
# 返回慢查询命令的数量
127.0.0.1:6379> SLOWLOG LEN
(integer) 128
2
3
# 用高效命令替换
如果的确有大量的慢查询命令,用其他高效命令代替。
- 当需要返回一个 SET 中的所有成员时,不要使用 SMEMBERS 命令,而是要使用 SSCAN 多次迭代返回,避免一次返回大量数据,造成线程阻塞。
- 需要执行排序、交集、并集操作时,可以在客户端完成,而不要用 SORT、SUNION、SINTER 这些命令,以免拖慢 Redis 实例。
KEYS 命令需要遍历存储的键值对,所以操作延时高。KEYS 命令一般不被建议用于生产环境中。
# 大 key
tag:
阿里云、美团、京东、滴滴count:4
as:大 key 怎么解决 (监测调用耗时,scan 扫描 bigkey,拆分大 string 为 hash,链表大的话散列)
如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。具体多大才算大呢?有一个不是特别精确的参考标准:
- String 类型的 value 超过 1MB
- 复合类型(List、Hash、Set、Sorted Set 等)的 value 包含的元素超过 5000 个(不过,对于复合类型的 value 来说,不一定包含的元素越多,占用的内存就越多)。

bigkey 通常是由于下面这些原因产生的:
- 程序设计不当,比如直接使用 String 类型存储较大的文件对应的二进制数据。
- 对于业务的数据规模考虑不周到,比如使用集合类型的时候没有考虑到数据量的快速增长。
- 未及时清理垃圾数据,比如哈希中冗余了大量的无用键值对。
bigkey 除了会消耗更多的内存空间和带宽,还会对性能造成比较大的影响。
大 key 还会造成阻塞问题。具体来说,主要体现在下面三个方面:
- 客户端超时阻塞:由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
- 网络阻塞:每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
- 工作线程阻塞:如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
大 key 造成的阻塞问题还会进一步影响到主从同步和集群扩容。
综上,大 key 带来的潜在问题是非常多的,我们应该尽量避免 Redis 中存在 bigkey。
# 发现大 key
使用 Redis 自带的 --bigkeys 参数来查找。
# redis-cli -p 6379 --bigkeys
# Scanning the entire keyspace to find biggest keys as well as
# average sizes per key type. You can use -i 0.1 to sleep 0.1 sec
# per 100 SCAN commands (not usually needed).
[00.00%] Biggest string found so far '"ballcat:oauth:refresh_auth:f6cdb384-9a9d-4f2f-af01-dc3f28057c20"' with 4437 bytes
[00.00%] Biggest list found so far '"my-list"' with 17 items
-------- summary -------
Sampled 5 keys in the keyspace!
Total key length in bytes is 264 (avg len 52.80)
Biggest list found '"my-list"' has 17 items
Biggest string found '"ballcat:oauth:refresh_auth:f6cdb384-9a9d-4f2f-af01-dc3f28057c20"' has 4437 bytes
1 lists with 17 items (20.00% of keys, avg size 17.00)
0 hashs with 0 fields (00.00% of keys, avg size 0.00)
4 strings with 4831 bytes (80.00% of keys, avg size 1207.75)
0 streams with 0 entries (00.00% of keys, avg size 0.00)
0 sets with 0 members (00.00% of keys, avg size 0.00)
0 zsets with 0 members (00.00% of keys, avg size 0.00
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
这个命令会扫描 (Scan) Redis 中的所有 key ,会对 Redis 的性能有一点影响。并且,这种方式只能找出每种数据结构 top 1 bigkey(占用内存最大的 String 数据类型,包含元素最多的复合数据类型)。然而,一个 key 的元素多并不代表占用内存也多,需要我们根据具体的业务情况来进一步判断。
在线上执行该命令时,为了降低对 Redis 的影响,需要指定 -i 参数控制扫描的频率。 redis-cli -p 6379 --bigkeys -i 3 表示扫描过程中每次扫描后休息的时间间隔为 3 秒。
使用 Redis 自带的 SCAN 命令
SCAN 命令可以按照一定的模式和数量返回匹配的 key。获取了 key 之后,可以利用 STRLEN 、 HLEN 、 LLEN 等命令返回其长度或成员数量。
| 数据结构 | 命令 | 复杂度 | 结果(对应 key) |
|---|---|---|---|
| String | STRLEN | O(1) | 字符串值的长度 |
| Hash | HLEN | O(1) | 哈希表中字段的数量 |
| List | LLEN | O(1) | 列表元素数量 |
| Set | SCARD | O(1) | 集合元素数量 |
| Sorted Set | ZCARD | O(1) | 有序集合的元素数量 |
对于集合类型还可以使用 MEMORY USAGE 命令(Redis 4.0+),这个命令会返回键值对占用的内存空间。
借助开源工具分析 RDB 文件。
通过分析 RDB 文件来找出 big key。这种方案的前提是你的 Redis 采用的是 RDB 持久化。
网上有现成的代码 / 工具可以直接拿来使用:
- redis-rdb-toolsopen in new window (opens new window):Python 语言写的用来分析 Redis 的 RDB 快照文件用的工具
- rdb_bigkeysopen in new window (opens new window) : Go 语言写的用来分析 Redis 的 RDB 快照文件用的工具,性能更好。
借助公有云的 Redis 分析服务。
如果你用的是公有云的 Redis 服务的话,可以看看其是否提供了 key 分析功能(一般都提供了)。
这里以阿里云 Redis 为例说明,它支持 bigkey 实时分析、发现,文档地址:https://www.alibabacloud.com/help/zh/apsaradb-for-redis/latest/use-the-real-time-key-statistics-featureopen in new window (opens new window) 。

# 处理大 key
bigkey 的常见处理以及优化办法如下(这些方法可以配合起来使用):
- 分割 bigkey:将一个 bigkey 分割为多个小 key。例如,将一个含有上万字段数量的 Hash 按照一定策略(比如二次哈希)拆分为多个 Hash。
- 手动清理:Redis 4.0+ 可以使用
UNLINK命令来异步删除一个或多个指定的 key。Redis 4.0 以下可以考虑使用SCAN命令结合DEL命令来分批次删除。 - 采用合适的数据结构:例如,文件二进制数据不使用 String 保存、使用 HyperLogLog 统计页面 UV、Bitmap 保存状态信息(0/1)。
- 开启 lazy-free(惰性删除 / 延迟释放) :lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。
# 热 key
tag:
滴滴、哔哩哔哩count:2
as:redis 热点问题,请求过多,redis 无法承载如何解决?
如果一个 key 的访问次数比较多且明显多于其他 key 的话,那这个 key 就可以看作是 hotkey(热 Key)。例如在 Redis 实例的每秒处理请求达到 5000 次,而其中某个 key 的每秒访问量就高达 2000 次,那这个 key 就可以看作是 hotkey。
hotkey 出现的原因主要是某个热点数据访问量暴增,如重大的热搜事件、参与秒杀的商品。
处理 hotkey 会占用大量的 CPU 和带宽,可能会影响 Redis 实例对其他请求的正常处理。此外,如果突然访问 hotkey 的请求超出了 Redis 的处理能力,Redis 就会直接宕机。这种情况下,大量请求将落到后面的数据库上,可能会导致数据库崩溃。
因此,hotkey 很可能成为系统性能的瓶颈点,需要单独对其进行优化,以确保系统的高可用性和稳定性。
# 发现热 key
使用 Redis 自带的 --hotkeys 参数来查找。
Redis 4.0.3 版本中新增了 hotkeys 参数,该参数能够返回所有 key 的被访问次数。
使用该方案的前提条件是 Redis Server 的 maxmemory-policy 参数设置为 LFU 算法,不然就会出现如下所示的错误。
# redis-cli -p 6379 --hotkeys
# Scanning the entire keyspace to find hot keys as well as
# average sizes per key type. You can use -i 0.1 to sleep 0.1 sec
# per 100 SCAN commands (not usually needed).
Error: ERR An LFU maxmemory policy is not selected, access frequency not tracked. Please note that when switching between policies at runtime LRU and LFU data will take some time to adjust.
2
3
4
5
6
7
Redis 中有两种 LFU 算法:
- volatile-lfu(least frequently used):从已设置过期时间的数据集(
server.db[i].expires)中挑选最不经常使用的数据淘汰。 - allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key。
以下是配置文件 redis.conf 中的示例:
# 使用 volatile-lfu 策略
maxmemory-policy volatile-lfu
# 或者使用 allkeys-lfu 策略
maxmemory-policy allkeys-lfu
2
3
4
5
需要注意的是, hotkeys 参数命令也会增加 Redis 实例的 CPU 和内存消耗(全局扫描),因此需要谨慎使用。
使用 MONITOR 命令。
MONITOR 命令是 Redis 提供的一种实时查看 Redis 的所有操作的方式,可以用于临时监控 Redis 实例的操作情况,包括读写、删除等操作。
由于该命令对 Redis 性能的影响比较大,因此禁止长时间开启 MONITOR (生产环境中建议谨慎使用该命令)。
# redis-cli
127.0.0.1:6379> MONITOR
OK
1683638260.637378 [0 172.17.0.1:61516] "ping"
1683638267.144236 [0 172.17.0.1:61518] "smembers" "mySet"
1683638268.941863 [0 172.17.0.1:61518] "smembers" "mySet"
1683638269.551671 [0 172.17.0.1:61518] "smembers" "mySet"
1683638270.646256 [0 172.17.0.1:61516] "ping"
1683638270.849551 [0 172.17.0.1:61518] "smembers" "mySet"
1683638271.926945 [0 172.17.0.1:61518] "smembers" "mySet"
1683638274.276599 [0 172.17.0.1:61518] "smembers" "mySet2"
1683638276.327234 [0 172.17.0.1:61518] "smembers" "mySet"
2
3
4
5
6
7
8
9
10
11
12
在发生紧急情况时,我们可以选择在合适的时机短暂执行 MONITOR 命令并将输出重定向至文件,在关闭 MONITOR 命令后通过对文件中请求进行归类分析即可找出这段时间中的 hotkey。
借助开源项目。
京东零售的 hotkeyopen in new window (opens new window) 这个项目不光支持 hotkey 的发现,还支持 hotkey 的处理。

根据业务情况提前预估。
可以根据业务情况来预估一些 hotkey,比如参与秒杀活动的商品数据等。不过,我们无法预估所有 hotkey 的出现,比如突发的热点新闻事件等。
业务代码中记录分析。
在业务代码中添加相应的逻辑对 key 的访问情况进行记录分析。不过,这种方式会让业务代码的复杂性增加,一般也不会采用。
借助公有云的 Redis 分析服务。
如果你用的是公有云的 Redis 服务的话,可以看看其是否提供了 key 分析功能(一般都提供了)。
这里以阿里云 Redis 为例说明,它支持 hotkey 实时分析、发现,文档地址:https://www.alibabacloud.com/help/zh/apsaradb-for-redis/latest/use-the-real-time-key-statistics-featureopen in new window (opens new window) 。

# 处理热 key
hotkey 的常见处理以及优化办法如下(这些方法可以配合起来使用):
- 读写分离:主节点处理写请求,从节点处理读请求。
- 使用 Redis Cluster:将热点数据分散存储在多个 Redis 节点上。
- 二级缓存:hotkey 采用二级缓存的方式进行处理,将 hotkey 存放一份到 JVM 本地内存中(可以用 Caffeine)。
除了这些方法之外,如果你使用的公有云的 Redis 服务话,还可以留意其提供的开箱即用的解决方案。
这里以阿里云 Redis 为例说明,它支持通过代理查询缓存功能(Proxy Query Cache)优化热点 Key 问题。

# 布隆过滤器 BloomFilter
tag:
美团、腾讯、深信服、百度、快手count:7
as:说一下布隆过滤器,然后如何减少误判。
布隆过滤器怎么提供准确度
由一个初值都为零的 bit 数组和多个哈希函数构成,用来快速判断集合中是否存在某个元素

目的为了减少内存占用,它不保存数据信息,只是在内存中做一个是否存在的标记 flag,本质就是判断具体数据是否存在于一个大的集合中。
布隆过滤器(英语:Bloom Filter)是 1970 年由布隆提出的。 它实际上是一个很长的二进制数组 (00000000)+ 一系列随机 hash 算法映射函数,主要用于判断一个元素是否在集合中。
布隆过滤器是一种类似 set 的数据结构,只是统计结果在巨量数据下有点小瑕疵,不够完美

通常我们会遇到很多要判断一个元素是否在某个集合中的业务场景,一般想到的是将集合中所有元素保存起来,然后通过比较确定。 链表、树、哈希表等等数据结构都是这种思路。但是随着集合中元素的增加,我们需要的存储空间也会呈现线性增长,最终达到瓶颈。同时检索速度也越来越慢,上述三种结构的检索时间复杂度分别为 O (n),O (logn),O (1)。这个时候,布隆过滤器(Bloom Filter)就应运而生。
一个元素如果判断结果:存在时,元素不一定存在,但是判断结果为不存在时,则一定不存在。
布隆过滤器可以添加元素,但是不能删除元素,由于涉及 hashcode 判断依据,删掉元素会导致误判率增加。
# 实现原理和数据结构
布隆过滤器 (Bloom Filter) 是一种专门用来解决去重问题的高级数据结构。
实质就是一个大型位数组和几个不同的无偏 hash 函数 (无偏表示分布均匀)。由一个初值都为零的 bit 数组和多个个哈希函数构成,用来快速判断某个数据是否存在。但是跟 HyperLogLog 一样,它也一样有那么一点点不精确,也存在一定的误判概率。
- 添加 key 时:使用多个 hash 函数对 key 进行 hash 运算得到一个整数索引值,对位数组长度进行取模运算得到一个位置,每个 hash 函数都会得到一个不同的位置,将这几个位置都置 1 就完成了 add 操作。
- 查询 key 时:只要有其中一位是零就表示这个 key 不存在,但如果都是 1,则不一定存在对应的 key。
hash 冲突导致数据不精准
当有变量被加入集合时,通过 N 个映射函数将这个变量映射成位图中的 N 个点,把它们置为 1(假定有两个变量都通过 3 个映射函数)。

查询某个变量的时候我们只要看看这些点是不是都是 1, 就可以大概率知道集合中有没有它了。
如果这些点,有任何一个为零则被查询变量一定不在,如果都是 1,则被查询变量很可能存在
为什么说是可能存在,而不是一定存在呢?
那是因为映射函数本身就是散列函数,散列函数是会有碰撞的。(见上图 3 号坑两个对象都 1)
正是基于布隆过滤器的快速检测特性,我们可以在把数据写入数据库时,使用布隆过滤器做个标记。当缓存缺失后,应用查询数据库时,可以通过查询布隆过滤器快速判断数据是否存在。如果不存在,就不用再去数据库中查询了。这样一来,即使发生缓存穿透了,大量请求只会查询 Rdis 和布隆过滤器,而不会积压到数据库,也就不会影响数据库的正常运行。布隆过滤器可以使用 Rdis 实现,本身就能承担较大的并发访问压。
# 哈希函数
哈希函数的概念是:将任意大小的输入数据转换成特定大小的输出数据的函数,转换后的数据称为哈希值或哈希编码,也叫散列值

如果两个散列值是不相同的(根据同一函数)那么这两个散列值的原始输入也是不相同的。
这个特性是散列函数具有确定性的结果,具有这种性质的散列函数称为单向散列函数。
散列函数的输入和输出不是唯一对应关系的,如果两个散列值相同,两个输入值很可能是相同的,但也可能不同,这种情况称为 “散列碰撞(collision)”。
用 hash 表存储大数据量时,空间效率还是很低,当只有一个 hash 函数时,还很容易发生哈希碰撞。
# 使用操作
布隆过滤器最初所有的值均设置为 0,当我们向布隆过滤器中添加数据时,为了尽量地址不冲突,会使用多个 hash 函数对 key 进行运算,算得一个下标索引值,然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就完成了 add 操作。
例如,我们添加一个字符串 wmyskxz,对字符串进行多次 hash (key) → 取模运行→ 得到坑位

向布隆过滤器查询某个 key 是否存在时,先把这个 key 通过相同的多个 hash 函数进行运算,查看对应的位置是否都为 1,** 只要有一个位为零,那么说明布隆过滤器中这个 key 不存在;如果这几个位置全都是 1,那么说明极有可能存在;** 因为这些位置的 1 可能是因为其他的 key 存在导致的,也就是前面说过的 hash 冲突。。。。。

布隆过滤器的误判是指多个输入经过哈希之后在相同的 bit 位置 1 了,这样就无法判断究竟是哪个输入产生的,因此误判的根源在于相同的 bit 位被多次映射且置 1。
这种情况也造成了布隆过滤器的删除问题,因为布隆过滤器的每一个 bit 并不是独占的,很有可能多个元素共享了某一位。 如果我们直接删除这一位的话,会影响其他的元素。
使用时最好不要让实际元素数量远大于初始化数量,一次给够避免扩容,当实际元素数量超过初始化数量时,应该对布隆过滤器进行重建,重新分配一个 size 更大的过滤器,再将所有的历史元素批量 add 进行。
# 使用场景
解决缓存穿透的问题,和 redis 结合 bitmap 使用
把已存在数据的 key 存在布隆过滤器中,相当于 redis 前面挡着一个布隆过滤器。
当有新的请求时,先到布隆过滤器中查询是否存在: 如果布隆过滤器中不存在该条数据则直接返回; 如果布隆过滤器中已存在,才去查询缓存 redis,如果 redis 里没查询到则再查询 Mysql 数据库

黑名单校验,识别垃圾邮件
发现存在黑名单中的,就执行特定操作。比如:识别垃圾邮件,只要是邮箱在黑名单中的邮件,就识别为垃圾邮件。
假设黑名单的数量是数以亿计的,存放起来就是非常耗费存储空间的,布隆过滤器则是一个较好的解决方案。把所有黑名单都放在布隆过滤器中,在收到邮件时,判断邮件地址是否在布隆过滤器中即可。
# 实现一个简单的布隆过滤器

可以使用 bitmap 这个数据结构来实现简单布隆过滤器

setBit 的构建过程
@PostConstruct 初始化白名单数据
计算元素的 hash 值
通过上一步 hash 值算出对应的二进制数组的坑位
将对应坑位的值的修改为数字 1,表示存在
getBit 查询是否存在
- 计算元素的 hash 值
- 通过上一步 hash 值算出对应的二进制数组的坑位
- 返回对应坑位的值,零表示无,1 表示存在
t_customer 用户表 SQL
CREATE TABLE `t_customer` (
`id` int(20) NOT NULL AUTO_INCREMENT,
`cname` varchar(50) NOT NULL,
`age` int(10) NOT NULL,
`phone` varchar(20) NOT NULL,
`sex` tinyint(4) NOT NULL,
`birth` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_cname` (`cname`)
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4;
2
3
4
5
6
7
8
9
10
entity
@Table(name = "t_customer")
@Data
public class Customer implements Serializable
{
@Id
@GeneratedValue(generator = "JDBC")
private Integer id;
private String cname;
private Integer age;
private String phone;
private Byte sex;
private Date birth;
public Customer()
{
}
public Customer(Integer id, String cname)
{
this.id = id;
this.cname = cname;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
CustomerController
@Api(tags = "客户Customer接口+布隆过滤器讲解")
@RestController
@Slf4j
public class CustomerController{
@Resource private CustomerSerivce customerSerivce;
@ApiOperation("数据库初始化2条Customer数据")
@RequestMapping(value = "/customer/add", method = RequestMethod.POST)
public void addCustomer() {
for (int i = 0; i < 2; i++) {
Customer customer = new Customer();
customer.setCname("customer"+i);
customer.setAge(new Random().nextInt(30)+1);
customer.setPhone("1381111xxxx");
customer.setSex((byte) new Random().nextInt(2));
customer.setBirth(Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant()));
customerSerivce.addCustomer(customer);
}
}
@ApiOperation("单个用户查询,按customerid查用户信息")
@RequestMapping(value = "/customer/{id}", method = RequestMethod.GET)
public Customer findCustomerById(@PathVariable int id) {
return customerSerivce.findCustomerById(id);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
CustomerMapper
public interface CustomerMapper extends Mapper<Customer> {
}
2
CustomerSerivce
@Service
@Slf4j
public class CustomerSerivce
{
public static final String CACHE_KEY_CUSTOMER = "customer:";
@Resource
private CustomerMapper customerMapper;
@Resource
private RedisTemplate redisTemplate;
public void addCustomer(Customer customer){
int i = customerMapper.insertSelective(customer);
if(i > 0)
{
//到数据库里面,重新捞出新数据出来,做缓存
customer=customerMapper.selectByPrimaryKey(customer.getId());
//缓存key
String key=CACHE_KEY_CUSTOMER+customer.getId();
//往mysql里面插入成功随后再从mysql查询出来,再插入redis
redisTemplate.opsForValue().set(key,customer);
}
}
public Customer findCustomerById(Integer customerId){
Customer customer = null;
//缓存key的名称
String key=CACHE_KEY_CUSTOMER+customerId;
//1 查询redis
customer = (Customer) redisTemplate.opsForValue().get(key);
//redis无,进一步查询mysql
if(customer==null){
//2 从mysql查出来customer
customer=customerMapper.selectByPrimaryKey(customerId);
// mysql有,redis无
if (customer != null) {
//3 把mysql捞到的数据写入redis,方便下次查询能redis命中。
redisTemplate.opsForValue().set(key,customer);
}
}
return customer;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
BloomFilterInit (白名单)
package com.atguigu.redis7.filter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
/**
* 布隆过滤器白名单初始化工具类,一开始就设置一部分数据为白名单所有,
* 白名单业务默认规定:布隆过滤器有,redis也有。
*/
@Component
@Slf4j
public class BloomFilterInit
{
@Resource
private RedisTemplate redisTemplate;
@PostConstruct//初始化白名单数据,故意差异化数据演示效果......
public void init()
{
//白名单客户预加载到布隆过滤器
String uid = "customer:12";
//1 计算hashcode,由于可能有负数,直接取绝对值
int hashValue = Math.abs(uid.hashCode());
//2 通过hashValue和2的32次方取余后,获得对应的下标坑位
long index = (long) (hashValue % Math.pow(2, 32));
log.info(uid+" 对应------坑位index:{}",index);
//3 设置redis里面bitmap对应坑位,该有值设置为1
redisTemplate.opsForValue().setBit("whitelistCustomer",index,true);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
CheckUtils
package com.atguigu.redis7.utils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
@Component
@Slf4j
public class CheckUtils
{
@Resource
private RedisTemplate redisTemplate;
public boolean checkWithBloomFilter(String checkItem,String key)
{
int hashValue = Math.abs(key.hashCode());
long index = (long) (hashValue % Math.pow(2, 32));
boolean existOK = redisTemplate.opsForValue().getBit(checkItem, index);
log.info("----->key:"+key+"\t对应坑位index:"+index+"\t是否存在:"+existOK);
return existOK;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
CustomerSerivce
@Service
@Slf4j
public class CustomerSerivce
{
public static final String CACHE_KEY_CUSTOMER = "customer:";
@Resource
private CustomerMapper customerMapper;
@Resource
private RedisTemplate redisTemplate;
@Resource
private CheckUtils checkUtils;
public void addCustomer(Customer customer){
int i = customerMapper.insertSelective(customer);
if(i > 0)
{
//到数据库里面,重新捞出新数据出来,做缓存
customer=customerMapper.selectByPrimaryKey(customer.getId());
//缓存key
String key=CACHE_KEY_CUSTOMER+customer.getId();
//往mysql里面插入成功随后再从mysql查询出来,再插入redis
redisTemplate.opsForValue().set(key,customer);
}
}
public Customer findCustomerById(Integer customerId){
Customer customer = null;
//缓存key的名称
String key=CACHE_KEY_CUSTOMER+customerId;
//1 查询redis
customer = (Customer) redisTemplate.opsForValue().get(key);
//redis无,进一步查询mysql
if(customer==null)
{
//2 从mysql查出来customer
customer=customerMapper.selectByPrimaryKey(customerId);
// mysql有,redis无
if (customer != null) {
//3 把mysql捞到的数据写入redis,方便下次查询能redis命中。
redisTemplate.opsForValue().set(key,customer);
}
}
return customer;
}
/**
* BloomFilter → redis → mysql
* 白名单:whitelistCustomer
* @param customerId
* @return
*/
@Resource
private CheckUtils checkUtils;
public Customer findCustomerByIdWithBloomFilter (Integer customerId)
{
Customer customer = null;
//缓存key的名称
String key = CACHE_KEY_CUSTOMER + customerId;
//布隆过滤器check,无是绝对无,有是可能有
//===============================================
if(!checkUtils.checkWithBloomFilter("whitelistCustomer",key))
{
log.info("白名单无此顾客信息:{}",key);
return null;
}
//===============================================
//1 查询redis
customer = (Customer) redisTemplate.opsForValue().get(key);
//redis无,进一步查询mysql
if (customer == null) {
//2 从mysql查出来customer
customer = customerMapper.selectByPrimaryKey(customerId);
// mysql有,redis无
if (customer != null) {
//3 把mysql捞到的数据写入redis,方便下次查询能redis命中。
redisTemplate.opsForValue().set(key, customer);
}
}
return customer;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
CustomerController
@Api(tags = "客户Customer接口+布隆过滤器讲解")
@RestController
@Slf4j
public class CustomerController
{
@Resource private CustomerSerivce customerSerivce;
@ApiOperation("数据库初始化2条Customer数据")
@RequestMapping(value = "/customer/add", method = RequestMethod.POST)
public void addCustomer() {
for (int i = 0; i < 2; i++) {
Customer customer = new Customer();
customer.setCname("customer"+i);
customer.setAge(new Random().nextInt(30)+1);
customer.setPhone("1381111xxxx");
customer.setSex((byte) new Random().nextInt(2));
customer.setBirth(Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant()));
customerSerivce.addCustomer(customer);
}
}
@ApiOperation("单个用户查询,按customerid查用户信息")
@RequestMapping(value = "/customer/{id}", method = RequestMethod.GET)
public Customer findCustomerById(@PathVariable int id) {
return customerSerivce.findCustomerById(id);
}
@ApiOperation("BloomFilter案例讲解")
@RequestMapping(value = "/customerbloomfilter/{id}", method = RequestMethod.GET)
public Customer findCustomerByIdWithBloomFilter(@PathVariable int id) throws ExecutionException, InterruptedException
{
return customerSerivce.findCustomerByIdWithBloomFilter(id);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
布隆过滤器有,redis 有;布隆过滤器有,redis 无
布隆过滤器无,直接返回,不再继续走下去


# 优缺点
- 优点:高效地插入和查询,内存占用 bit 空间少
- 缺点
- 不能删除元素。因为删掉元素会导致误判率增加,因为 hash 冲突同一个位置可能存的东西是多个共有的,你删除一个元素的同时可能也把其它的删除了。
- 存在误判,不能精准过滤。有,是很可能有;无,是肯定无,100% 无;
布谷鸟过滤器
为了解决布隆过滤器不能删除元素的问题,布谷鸟过滤器横空出世。
论文《Cuckoo Filter:Better Than Bloom》 (opens new window)
# Redis 集群
# 主从复制
tag:
小米、字节、亚信、快手、明朝万达、北森、中通、百度、腾讯、携程、得物、招行、饿了么、哔哩哔哩、tp-link、平安、一嗨租车count:33
as:Redis 的集群模式都了解哪些,说只了解主从和 Clauster,没有设计过 HA 方案,Clauster 的优势和需要注意的点,一个节点故障要如何去实现 Rebalance
redis 内部原理,一个 key 在怎么确定在 redis 集群的哪个节点上
选主过程中本质上的算法原理是类似什么 分布式一致性算法 paxos/raft
redis 一般集群机制是怎么样的
redis 集群新的从 redis 是如何加入的
redis 主从同步 slave 重连再次同步通过哪种机制
redis 集群中怎么加入或删除一个节点?
redis 集群和主从的优缺点?
哨兵模式中,主节点宕机怎么保证数据是最新的?
主从节点的复制过程?
redis 的主库崩溃了会怎么样,redis 切片集群中,数据量多了,加实例还是加内存,为什么,加实例要注意什么问题
对于主从和哨兵机制,哨兵如何进行监控的?主从如何保持一致性的?
Redis 集群容错
多 redis 的场景,使用情况,如何解决热点问题
哨兵如何实现通信,结点宕机如何处理
redis 的集群模式用到一致性哈希了吗
主从同步出了一些问题,怎么保证最终一致性
主从复制是 Redis 高可用服务的最基础的保证,实现方案就是将从前的一台 Redis 服务器,同步数据到多台从 Redis 服务器上,即一主多从的模式,且主从服务器之间采用的是 **「读写分离」** 的方式。
主服务器可以进行读写操作,当发生写操作时自动将写操作同步给从服务器,而从服务器一般是只读,并接受主服务器同步过来写操作命令,然后执行这条命令。

也就是说,所有的数据修改只在主服务器上进行,然后将最新的数据同步给从服务器,这样就使得主从服务器的数据是一致的。
注意,主从服务器之间的命令复制是异步进行的。
具体来说,在主从服务器命令传播阶段,主服务器收到新的写命令后,会发送给从服务器。但是,主服务器并不会等到从服务器实际执行完命令后,再把结果返回给客户端,而是主服务器自己在本地执行完命令后,就会向客户端返回结果了。如果从服务器还没有执行主服务器同步过来的命令,主从服务器间的数据就不一致了。
所以,无法实现强一致性保证(主从数据时时刻刻保持一致),数据不一致是难以避免的。
主从复制主要分为:全量同步和增量同步。
# 全量复制
在 salve 请求数据同步的时候会携带 application Id 和 offset,如果 master 判断出 applid 和自己的不一样,就认为 slave 是第一次进行同步,所以会进行全量同步。
master 会执行 bgsave 生成 RDB 文件给 slave,slave 进行同步,在此过程中 master 可能会进行新的指令,master 会将这些指令存储到日志文件,在加载 RDB 完后再将日志文件传给 slave 进行最终的同步,master 同步 applid 和 offset 给 slave。

# 增量同步
在 master 判断出 slave 中的 applid 和自己一样就认为不是第一次同步,直接进行增量同步,从日志文件中获取到 offset 的位置,将 offset 之后的数据发送给 slave,进行数据的同步。
# 哨兵模式
在使用 Redis 主从服务的时候,会有一个问题,就是当 Redis 的主从服务器出现故障宕机时,需要手动进行恢复。
为了解决这个问题,Redis 增加了哨兵模式(Redis Sentinel),因为哨兵模式做到了可以监控主从服务器,并且提供主从节点故障转移的功能。

哨兵模式过程:
通过 sentinel 的心跳机制去监测 master 的状态,当然为了保证高可用,我们也需要对 sentinel 搭建集群。sentinel 每隔 1 秒就会向集群的节点发送 ping 指令,当 master 失效后会就选择出新的 master。
在 sentinel 中有两种概念:主观下线和客观下线。
- 主观下线:当有一个 sentinel 发现 redis 节点没有返回响应就认为其为主观下线。
- 客观下线:当有一半以上的 sentinel 节点发现 redis 节点没有返回响应就认为其为客观下线。
当 master 发生客观下线就会筛选 slave 作为新的 master。
筛选新 master 的优先级为下:
1. 判断 master 与 slave 断开的时长,如果时长超过指指定值则直接排除。
2. 判断 slave 的权重,如果权重越小优先级就越高。
3. 如果权重相同的话,就比较 slave 的 offst 值也就是偏移量,如果 offset 越大优先级就越高。
4. 判断 slave 运行 id 的大小,如果运行 id 越小则优先级越高。
# 切片集群模式
当 Redis 缓存数据量大到一台服务器无法缓存时,就需要使用 Redis 切片集群(Redis Cluster )方案,它将数据分布在不同的服务器上,以此来降低系统对单主节点的依赖,从而提高 Redis 服务的读写性能。
Redis Cluster 方案采用哈希槽(Hash Slot),来处理数据和节点之间的映射关系。在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中,具体执行过程分为两大步:
- 根据键值对的 key,按照 CRC16 算法计算一个 16 bit 的值。
- 再用 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。
接下来的问题就是,这些哈希槽怎么被映射到具体的 Redis 节点上的呢?有两种方案:
- 平均分配: 在使用 cluster create 命令创建 Redis 集群时,Redis 会自动把所有哈希槽平均分布到集群节点上。比如集群中有 9 个节点,则每个节点上槽的个数为 16384/9 个。
- 手动分配: 可以使用 cluster meet 命令手动建立节点间的连接,组成集群,再使用 cluster addslots 命令,指定每个节点上的哈希槽个数。
我们通过一张图来解释数据、哈希槽,以及节点三者的映射分布关系。

上图中的切片集群一共有 2 个节点,假设有 4 个哈希槽(Slot 0~Slot 3)时,我们就可以通过命令手动分配哈希槽,比如节点 1 保存哈希槽 0 和 1,节点 2 保存哈希槽 2 和 3。
redis-cli -h 192.168.1.10 –p 6379 cluster addslots 0,1
redis-cli -h 192.168.1.11 –p 6379 cluster addslots 2,3
2
然后在集群运行的过程中,key1 和 key2 计算完 CRC16 值后,对哈希槽总个数 4 进行取模,再根据各自的模数结果,就可以被映射到哈希槽 1(对应节点 1) 和 哈希槽 2(对应节点 2)。
需要注意的是,在手动分配哈希槽时,需要把 16384 个槽都分配完,否则 Redis 集群无法正常工作。
# 什么是脑裂?
tag:
用友count:1
as:
先来理解集群的脑裂现象,这就好比一个人有两个大脑,那么到底受谁控制呢?
简单来说出现两个主节点,而从节点无法区分听从哪个主节点。
在 Redis 主从架构中,部署方式一般是「一主多从」,主节点提供写操作,从节点提供读操作。 如果主节点的网络突然发生了问题,它与所有的从节点都失联了,但是此时的主节点和客户端的网络是正常的,这个客户端并不知道 Redis 内部已经出现了问题,还在照样的向这个失联的主节点写数据(过程 A),此时这些数据被旧主节点缓存到了缓冲区里,因为主从节点之间的网络问题,这些数据都是无法同步给从节点的。
这时,哨兵也发现主节点失联了,它就认为主节点挂了(但实际上主节点正常运行,只是网络出问题了),于是哨兵就会在「从节点」中选举出一个 leader 作为主节点,这时集群就有两个主节点了 —— 脑裂出现了。
然后,网络突然好了,哨兵因为之前已经选举出一个新主节点了,它就会把旧主节点降级为从节点(A),然后从节点(A)会向新主节点请求数据同步,因为第一次同步是全量同步的方式,此时的从节点(A)会清空掉自己本地的数据,然后再做全量同步。所以,之前客户端在过程 A 写入的数据就会丢失了,也就是集群产生脑裂数据丢失的问题。
总结一句话就是:由于网络问题,集群节点之间失去联系。主从数据不同步;重新平衡选举,产生两个主服务。等网络恢复,旧主节点会降级为从节点,再与新主节点进行同步复制的时候,由于会从节点会清空自己的缓冲区,所以导致之前客户端写入的数据丢失了。
# 如何解决脑裂
当主节点发现从节点下线或者通信超时的总数量小于阈值时,那么禁止主节点进行写数据,直接把错误返回给客户端。
在 Redis 的配置文件中有两个参数我们可以设置:
min-slaves-to-write x,主节点必须要有至少 x 个从节点连接,如果小于这个数,主节点会禁止写数据。min-slaves-max-lag x,主从数据复制和同步的延迟不能超过 x 秒,如果超过,主节点会禁止写数据。
我们可以把 min-slaves-to-write 和 min-slaves-max-lag 这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为 N 和 T。
这两个配置项组合后的要求是,主库连接的从库中至少有 N 个从库,和主库进行数据复制时的 ACK 消息延迟不能超过 T 秒,否则,主库就不会再接收客户端的写请求了。
即使原主库是假故障,它在假故障期间也无法响应哨兵心跳,也不能和从库进行同步,自然也就无法和从库进行 ACK 确认了。这样一来,min-slaves-to-write 和 min-slaves-max-lag 的组合要求就无法得到满足,原主库就会被限制接收客户端写请求,客户端也就不能在原主库中写入新数据了。
等到新主库上线时,就只有新主库能接收和处理客户端请求,此时,新写的数据会被直接写到新主库中。而原主库会被哨兵降为从库,即使它的数据被清空了,也不会有新数据丢失。
再来举个例子。
假设我们将 min-slaves-to-write 设置为 1,把 min-slaves-max-lag 设置为 12s,把哨兵的 down-after-milliseconds 设置为 10s,主库因为某些原因卡住了 15s,导致哨兵判断主库客观下线,开始进行主从切换。
同时,因为原主库卡住了 15s,没有一个从库能和原主库在 12s 内进行数据复制,原主库也无法接收客户端请求了。
这样一来,主从切换完成后,也只有新主库能接收请求,不会发生脑裂,也就不会发生数据丢失的问题了。
# 如何用 Redis 实现分布式锁的?
tag:
美团、腾讯、字节、小米、小药药、momenta、来未来、京东、万得、中金、明朝万达、数字马力、汇川、大智慧、得物、满帮、TCL、阿里、快手、携程、招行、4399、星环、百度、捷运达、哔哩哔哩、、万得、喜马拉雅count:51
redis 分布式锁优缺点
Redisson 看门狗可以自动续期。看门狗还有多少时间会续期
redission 的原理是什么怎么实现
redission 加锁原理,为什么解锁需要判断是否是当前线程加的锁,举例子
如果 redis 锁(多机)机器挂了会怎么办?
redis 设置锁怎么保证原子性,lua 一定是原子性吗
redis 的锁是 cap 中的 cp 锁还是 ap 锁
没有可能 redis 因为主从哨兵,两个线程拿到同一个锁的情况,怎么办
分布式锁是用于分布式环境下并发控制的一种机制,用于控制某个资源在同一时刻只能被一个应用所使用。如下图所示:

Redis 本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁,而且 Redis 的读写性能高,可以应对高并发的锁操作场景。
Redis 的 SET 命令有个 NX 参数可以实现 key 不存在才插入,所以可以用它来实现分布式锁:
set key value [EX seconds] [PX milliseconds] [NX|XX]
EX:key 在多少秒之后过期
PX:key 在多少毫秒之后过期
NX:当 key 不存在的时候,才创建 key, 效果等同于 setnx
如果 key 不存在,则显示插入成功,可以用来表示加锁成功;
如果 key 存在,则会显示插入失败,可以用来表示加锁失败。
XX:当 key 存在的时候,覆盖 key
基于 Redis 节点实现分布式锁时,对于加锁操作,我们需要满足三个条件。
- 加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用 SET 命令带上 NX 选项来实现加锁;
- 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在 SET 命令执行时加上 EX/PX 选项,设置其过期时间;
- 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端;
SET lock_key unique_value NX PX 10000
- lock_key 就是 key 键;
- unique_value 是客户端生成的唯一的标识,区分来自不同客户端的锁操作;
- NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;
- PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁。
而解锁的过程就是将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。
可以看到,解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。
-- 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
2
3
4
5
6
这样一来,就通过使用 SET 命令和 Lua 脚本在 Redis 单节点上完成了分布式锁的加锁和解锁。
# 为什么需要 Redis 分布式锁
tag:
得物count:1
as:分布式锁怎么用的,为啥要用分布式锁
- 单机版同一个 JVM 虚拟机内,synchronized 或者 Lock 接口
- 分布式多个不同 JVM 虚拟机,单机的线程锁机制不再起作用,资源类在不同的服务器之间共享了。
此时可以使用 Redis 实现分布式锁
一个靠谱分布式锁需要具备的条件和刚需
- 独占性:OnlyOne,任何时刻只能有且仅有一个线程持有
- 高可用:若 redis 集群环境下,不能因为某一个节点挂了而出现获取锁和释放锁失败的情况,高并发请求下,依旧性能 OK 好使
- 防死锁:杜绝死锁,必须有超时控制机制或者撤销操作,有个兜底终止跳出方案
- 不乱抢:防止张冠李戴,不能私下 unlock 别人的锁,只能自己加锁自己释放,自己约的锁含着泪也要自己解
- 重入性:同一个节点的同一个线程如果获得锁之后,它也可以再次获取这个锁。
# 简单单机案例
Swagger2Config
@Configuration
@EnableSwagger2
public class Swagger2Config
{
@Value("${swagger2.enabled}")
private Boolean enabled;
@Bean
public Docket createRestApi() {
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo())
.enable(enabled)
.select()
.apis(RequestHandlerSelectors.basePackage("com.atguigu.redislock")) //你自己的package
.paths(PathSelectors.any())
.build();
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("springboot利用swagger2构建api接口文档 "+"\t"+ DateTimeFormatter.ofPattern("yyyy-MM-dd").format(LocalDateTime.now()))
.description("springboot+redis整合")
.version("1.0")
.termsOfServiceUrl("https://www.baidu.com/")
.build();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
RedisConfig
@Configuration
public class RedisConfig
{
@Bean
public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory)
{
RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(lettuceConnectionFactory);
//设置key序列化方式string
redisTemplate.setKeySerializer(new StringRedisSerializer());
//设置value的序列化方式json
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
InventoryService
@Service
@Slf4j
public class InventoryService
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String port;
private Lock lock = new ReentrantLock();
public String sale()
{
String retMessage = "";
lock.lock();
try
{
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber;
System.out.println(retMessage);
}else{
retMessage = "商品卖完了,o(╥﹏╥)o";
}
}finally {
lock.unlock();
}
return retMessage+"\t"+"服务端口号:"+port;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
InventoryController
@RestController
@Api(tags = "redis分布式锁测试")
public class InventoryController
{
@Autowired
private InventoryService inventoryService;
@ApiOperation("扣减库存,一次卖一个")
@GetMapping(value = "/inventory/sale")
public String sale()
{
return inventoryService.sale();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
访问 swagger 进行 http://localhost:7777/swagger-ui.html#/ 该接口
# 手写分布式锁
InventoryService
@Service
@Slf4j
public class InventoryService
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String port;
private Lock lock = new ReentrantLock();
public String sale()
{
String retMessage = "";
lock.lock();
try
{
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber;
System.out.println(retMessage);
}else{
retMessage = "商品卖完了,o(╥﹏╥)o";
}
}finally {
lock.unlock();
}
return retMessage+"\t"+"服务端口号:"+port;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
nginx 分布式微服务架构
上面代码分布式部署后,单机锁还是出现超卖现象,需要分布式锁

nginx/conf/nginx.config
将 server 改为 spring boot 的地址与端口

启动 nginx
./nginx -c /usr/local/nginx/conf/nginx.conf
上面我们简单案例启动的是 7777 端口,第二个服务器模块我们设置为 8888 端口
InventoryService
@Service
@Slf4j
public class InventoryService
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String port;
private Lock lock = new ReentrantLock();
public String sale()
{
String retMessage = "";
lock.lock();
try
{
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber;
System.out.println(retMessage);
}else{
retMessage = "商品卖完了,o(╥﹏╥)o";
}
}finally {
lock.unlock();
}
return retMessage+"\t"+"服务端口号:"+port;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
通过 Nginx 访问,你的 Linux 服务器地址 IP,反向代理 + 负载均衡 http://192.168.111.185/inventory/sale
使用 jmeter 进行压测 200 个线程和 100 个商品




76 号商品被卖出 2 次,出现超卖故障现象
为什么加了 synchronized 或者 Lock 还是没有控制住?
在单机环境下,可以使用 synchronized 或 Lock 来实现。
但是在分布式系统中,因为竞争的线程可能不在同一个节点上(同一个 jvm 中), 所以需要一个让所有进程都能访问到的锁来实现 **(比如 redis 或者 zookeeper 来构建)**
不同进程 jvm 层面的锁就不管用了,那么可以利用第三方的一个组件,来获取锁,未获取到锁,则阻塞当前想要运行的线程
这时候我们上 redis 分布式锁 setnx
# 递归重试
修改 InventoryService 通过递归重试的方式
@Service
@Slf4j
public class InventoryService
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String port;
private Lock lock = new ReentrantLock();
public String sale()
{
String retMessage = "";
String key = "zzyyRedisLock";
String uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue);
if(!flag){
//暂停20毫秒后递归调用
try { TimeUnit.MILLISECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
sale();
}else{
try{
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber;
System.out.println(retMessage);
}else{
retMessage = "商品卖完了,o(╥﹏╥)o";
}
}finally {
stringRedisTemplate.delete(key);
}
}
return retMessage+"\t"+"服务端口号:"+port;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
测试手工 OK,测试 Jmeter 压测 5000OK,递归是一种思想没错,但是容易导致 StackOverflowError,不太推荐,进一步完善
# 宕机与过期 + 防止死锁
进一步完善上述代码,部署了微服务的 Java 程序机器挂了,代码层面根本没有走到 finally 这块,没办法保证解锁 (无过期时间该 key 一直存在),这个 key 没有被删除,需要加入一个过期时间限定 key。
InventoryService
上述代码设置 key + 过期时间分开了,必须要合并成一行具备原子性
@Service
@Slf4j
public class InventoryService
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String port;
private Lock lock = new ReentrantLock();
public String sale()
{
String retMessage = "";
String key = "zzyyRedisLock";
String uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();
while(!stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue)){
//暂停20毫秒,类似我们的CAS
try { TimeUnit.MILLISECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
}
stringRedisTemplate.expire(key,10L,TimeUnit.SECONDS);
try{
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber;
System.out.println(retMessage);
}else{
retMessage = "商品卖完了,o(╥﹏╥)o";
}
}finally {
stringRedisTemplate.delete(key);
}
return retMessage+"\t"+"服务端口号:"+port;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
Jmeter 压测 OK

结论:加锁和过期时间设置必须同一行,保证原子性
# 防止误删 key 的问题
进一步完善上述代码,当实际业务处理时间如果超过了默认设置 key 的过期时间?
张冠李戴,删除了别人的锁

解决方案:只能自己删除自己的,不许动别人的
InventoryService
@Service
@Slf4j
public class InventoryService
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String port;
private Lock lock = new ReentrantLock();
public String sale()
{
String retMessage = "";
String key = "zzyyRedisLock";
String uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();
while(!stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue,30L,TimeUnit.SECONDS))
{
//暂停毫秒
try { TimeUnit.MILLISECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
}
try
{
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber+"\t"+uuidValue;
System.out.println(retMessage);
}else{
retMessage = "商品卖完了,o(╥﹏╥)o";
}
}finally {
// v5.0判断加锁与解锁是不是同一个客户端,同一个才行,自己只能删除自己的锁,不误删他人的
if(stringRedisTemplate.opsForValue().get(key).equalsIgnoreCase(uuidValue)){
stringRedisTemplate.delete(key);
}
}
return retMessage+"\t"+"服务端口号:"+port;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# Lua 保证原子性
tag:
工行count:1
as:简单讲一下 lua 脚本,那在并发情况下,怎么保证 lua 脚本的原子性呢?
进一步完善上述代码,finally 块的判断 + del 删除操作不是原子性的
启用 lua 脚本编写 redis 分布式锁判断 + 删除判断代码
官方给出案例 https://redis.io/docs/reference/patterns/distributed-locks/
-- 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
2
3
4
5
6
Redis 调用 Lua 脚本通过 eval 命令保证代码执行的原子性,直接用 return 返回脚本执行后的结果值
eval luascript numkeys [key [key ...]] [arg [arg ...]]
调用案例
# 直接返回
EVAL "return 'hello lua'" 0 # "hello lua"
# 调用redis中的set 和 get 方法
EVAL "redis.call('set', 'k1', 'v1') return redis.call('get', 'k1')" 0 # v1
# 调用redis中的mset 方法
EVAL "return redis.call('mset', KEYS[1], ARGV[1], KEYS[2], ARGV[2])" 2 k1 k2 11 12
get k1 # 11
get k2 # 12
2
3
4
5
6
7
8
条件判断语法
-- 语法
if(布尔条件) then
业务代码
elseif(布尔条件) then
业务代码
else
业务代码
end
2
3
4
5
6
7
8
案例
eval "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 zzyyRedisLock 1111-2222-3333 # 1
eval "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 zzyyRedisLock 1111-2222-3333 # 0
2

修改 InventoryService 调用 lua 脚本
@Service
@Slf4j
public class InventoryService
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String port;
private Lock lock = new ReentrantLock();
public String sale()
{
String retMessage = "";
String key = "zzyyRedisLock";
String uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();
while(!stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue,30L,TimeUnit.SECONDS))
{
//暂停毫秒
try { TimeUnit.MILLISECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
}
try
{
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber+"\t"+uuidValue;
System.out.println(retMessage);
}else{
retMessage = "商品卖完了,o(╥﹏╥)o";
}
}finally {
//V6.0 将判断+删除自己的合并为lua脚本保证原子性
String luaScript =
"if (redis.call('get',KEYS[1]) == ARGV[1]) then " +
"return redis.call('del',KEYS[1]) " +
"else " +
"return 0 " +
"end";
stringRedisTemplate.execute(new DefaultRedisScript<>(luaScript, Boolean.class), Arrays.asList(key), uuidValue);
}
return retMessage+"\t"+"服务端口号:"+port;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
使用 stringRedisTemplate.execute( 方法时需要注意代返回类型构造
stringRedisTemplate.execute(new DefaultRedisScript<>(script), Arrays.asList(key),value);
stringRedisTemplate.execute(new DefaultRedisScript<>(script,Long.class), Arrays.asList(key),value); //使用该构造方法,不然报错
2


# 可重入锁 + 设计模式
进一步完善上述代码,while 判断并自旋重试获取锁 + setnx 含自然过期时间 + Lua 脚本官网删除锁命令
可重入锁又名递归锁是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁 (前提,锁对象得是同一个对象),不会因为之前已经获取过还没释放而阻塞。
如果是 1 个有 synchronized 修饰的递归调用方法,程序第 2 次进入被自己阻塞了岂不是天大的笑话,出现了作茧自缚。所以 Java 中 ReentrantLock 和 synchronized 都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
即可以再次进入同步锁中的同步域(即同步代码块 / 方法或显式锁锁定的代码)
一个线程中的多个流程可以获取同一把锁,持有这把同步锁可以再次进入。自己可以获取自己的内部锁
# 可重入锁种类
# 隐式锁
隐式锁(即 synchronized 关键字使用的锁)默认是可重入锁
可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁,这样的锁就叫做可重入锁。 简单的来说就是:在一个 synchronized 修饰的方法或代码块的内部调用本类的其他 synchronized 修饰的方法或代码块时,是永远可以得到锁的
与可重入锁相反,不可重入锁不可递归调用,递归调用就发生死锁。
同步块
public class ReEntryLockDemo
{
public static void main(String[] args)
{
final Object objectLockA = new Object();
new Thread(() -> {
synchronized (objectLockA)
{
System.out.println("-----外层调用");
synchronized (objectLockA)
{
System.out.println("-----中层调用");
synchronized (objectLockA)
{
System.out.println("-----内层调用");
}
}
}
},"a").start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
同步方法
public class ReEntryLockDemo
{
public synchronized void m1()
{
System.out.println("-----m1");
m2();
}
public synchronized void m2()
{
System.out.println("-----m2");
m3();
}
public synchronized void m3()
{
System.out.println("-----m3");
}
public static void main(String[] args)
{
ReEntryLockDemo reEntryLockDemo = new ReEntryLockDemo();
reEntryLockDemo.m1();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Synchronized 的重入的实现机理
每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。
当执行 monitorenter 时,如果目标锁对象的计数器为零,那么说明它没有被其他线程所持有,Java 虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加 1。
在目标锁对象的计数器不为零的情况下,如果锁对象的持有线程是当前线程,那么 Java 虚拟机可以将其计数器加 1,否则需要等待,直至持有线程释放该锁。
当执行 monitorexit 时,Java 虚拟机则需将锁对象的计数器减 1。计数器为零代表锁已被释放。
# 显式锁
显式锁(即 Lock)也有 ReentrantLock 这样的可重入锁。
public class ReEntryLockDemo
{
static Lock lock = new ReentrantLock();
public static void main(String[] args)
{
new Thread(() -> {
lock.lock();
try
{
System.out.println("----外层调用lock");
lock.lock();
try
{
System.out.println("----内层调用lock");
}finally {
// 这里故意注释,实现加锁次数和释放次数不一样
// 由于加锁次数和释放次数不一样,第二个线程始终无法获取到锁,导致一直在等待。
lock.unlock(); // 正常情况,加锁几次就要解锁几次
}
}finally {
lock.unlock();
}
},"a").start();
new Thread(() -> {
lock.lock();
try
{
System.out.println("b thread----外层调用lock");
}finally {
lock.unlock();
}
},"b").start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
上述可重入锁计数问题,redis 中那个数据类型可以代替,我们可以使 hset 来实现
# hset key field value
hset redis锁名字(zzyyRedisLock) 某个请求线程的UUID+ThreadID 加锁的次数
2
- setnx,只能解决有无的问题,够用但是不完美
- hset,不但解决有无,还解决可重入问题
# lua 脚本
redis 命令过程分析

# 加锁 lua 脚本 lock
先判断 redis 分布式锁这个 key 是否存在
EXISTS key
返回零说明不存在,hset 新建当前线程属于自己的锁 BY UUID:ThreadID
HSET zzyyRedisLock 0c90d37cb6ec42268861b3d739f8b3a8:1 1
#HSET key value=UUID:ThreadID 次数
2
返回 1 说明已经有锁,需进一步判断是不是当前线程自己的
HEXISTS key uuid:ThreadID
返回 0 说明不是自己的,返回 1 说明是自己的锁,自增 1 次表示重入
# HINCRBY key field increment
HINCRBY zzyyRedisLock 0c90d37cb6ec42268861b3d739f8b3a8:1 1
2
将上述设计修改为 Lua 脚本
if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then
redis.call('hincrby',KEYS[1],ARGV[1],1)
redis.call('expire',KEYS[1],ARGV[2])
return 1
else
return 0
end
2
3
4
5
6
7
进 redis 测试脚本
EVAL "if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then redis.call('hincrby',KEYS[1],ARGV[1],1) redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end" 1 zzyyRedisLock 0c90d37cb6ec42268861b3d739f8b3a8:1 30 # 1 说明是自己的锁,自增1次表示重入
HGET zzyyRedisLock 0c90d37cb6ec42268861b3d739f8b3a8:1 # 3
2
# 解锁 lua 脚本 unlock
设计思路:有锁且还是自己的锁
# HEXISTS key uuid:ThreadID
HEXISTS zzyyRedisLock 0c90d37cb6ec42268861b3d739f8b3a8:1 # 0
2
- 返回 0,说明根本没有锁,程序块返回 nil
- 不是 0,说明有锁且是自己的锁,直接调用 HINCRBY -1 表示每次减个一,解锁一次。直到它变为 0 表示可以删除该锁 Key,del 锁 key

if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then
return nil
elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then
return redis.call('del',KEYS[1])
else
return 0
end
2
3
4
5
6
7
进 redis 测试
eval "if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then return nil elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then return redis.call('del',KEYS[1]) else return 0 end" 1 zzyyRedisLock 2f586ae740a94736894ab9d51880ed9d:1

# 将上述 lua 脚本整合进入微服务 Java 程序
复原程序为初始无锁版
InventoryService
@Service
@Slf4j
public class InventoryService
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String port;
public String sale()
{
String retMessage = "";
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber+"\t";
System.out.println(retMessage);
}else{
retMessage = "商品卖完了,o(╥﹏╥)o";
}
return retMessage+"\t"+"服务端口号:"+port;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
新建 RedisDistributedLock 类并实现 JUC 里面的 Lock 接口,满足 JUC 里面 AQS 对 Lock 锁的接口规范定义来进行实现落地代码,结合设计模式开发属于自己的 Redis 分布式锁工具类。
工厂设计模式引入,通过实现 JUC 里面的 Lock 接口,实现 Redis 分布式锁 RedisDistributedLock 。
import cn.hutool.core.util.IdUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.support.collections.DefaultRedisList;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
/**
* @auther zzyy
* @create 2022-10-18 18:32
*/
//@Component 引入DistributedLockFactory工厂模式,从工厂获得而不再从spring拿到
public class RedisDistributedLock implements Lock
{
private StringRedisTemplate stringRedisTemplate;
private String lockName;//KEYS[1]
private String uuidValue;//ARGV[1]
private long expireTime;//ARGV[2]
public RedisDistributedLock(StringRedisTemplate stringRedisTemplate, String lockName)
{
this.stringRedisTemplate = stringRedisTemplate;
this.lockName = lockName;
this.uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();//UUID:ThreadID
this.expireTime = 30L;
}
@Override
public void lock()
{
tryLock();
}
@Override
public boolean tryLock()
{
try {tryLock(-1L,TimeUnit.SECONDS);} catch (InterruptedException e) {e.printStackTrace();}
return false;
}
/**
* 干活的,实现加锁功能,实现这一个干活的就OK,全盘通用
* @param time
* @param unit
* @return
* @throws InterruptedException
*/
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException{
if(time != -1L){
this.expireTime = unit.toSeconds(time);
}
String script =
"if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then " +
"redis.call('hincrby',KEYS[1],ARGV[1],1) " +
"redis.call('expire',KEYS[1],ARGV[2]) " +
"return 1 " +
"else " +
"return 0 " +
"end";
System.out.println("script: "+script);
System.out.println("lockName: "+lockName);
System.out.println("uuidValue: "+uuidValue);
System.out.println("expireTime: "+expireTime);
while (!stringRedisTemplate.execute(new DefaultRedisScript<>(script,Boolean.class), Arrays.asList(lockName),uuidValue,String.valueOf(expireTime))) {
TimeUnit.MILLISECONDS.sleep(50);
}
return true;
}
/**
*干活的,实现解锁功能
*/
@Override
public void unlock()
{
String script =
"if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then " +
" return nil " +
"elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then " +
" return redis.call('del',KEYS[1]) " +
"else " +
" return 0 " +
"end";
// nil = false 1 = true 0 = false
System.out.println("lockName: "+lockName);
System.out.println("uuidValue: "+uuidValue);
System.out.println("expireTime: "+expireTime);
Long flag = stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName),uuidValue,String.valueOf(expireTime));
if(flag == null)
{
throw new RuntimeException("This lock doesn't EXIST");
}
}
//===下面的redis分布式锁暂时用不到=======================================
//===下面的redis分布式锁暂时用不到=======================================
//===下面的redis分布式锁暂时用不到=======================================
@Override
public void lockInterruptibly() throws InterruptedException
{
}
@Override
public Condition newCondition()
{
return null;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
加入工厂模式是为了考虑扩展,本次是 redis 实现分布式锁,以后 zookeeper、mysql 实现那
DistributedLockFactory
@Component
public class DistributedLockFactory
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
private String lockName;
public Lock getDistributedLock(String lockType)
{
if(lockType == null) return null;
if(lockType.equalsIgnoreCase("REDIS")){
lockName = "zzyyRedisLock";
return new RedisDistributedLock(stringRedisTemplate,lockName);
} else if(lockType.equalsIgnoreCase("ZOOKEEPER")){
//TODO zookeeper版本的分布式锁实现
return new ZookeeperDistributedLock();
} else if(lockType.equalsIgnoreCase("MYSQL")){
//TODO mysql版本的分布式锁实现
return null;
}
return null;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
RedisDistributedLock
/**
* @auther zzyy
* @create 2022-10-18 18:32
*/
//@Component 引入DistributedLockFactory工厂模式,从工厂获得而不再从spring拿到
public class RedisDistributedLock implements Lock
{
private StringRedisTemplate stringRedisTemplate;
private String lockName;//KEYS[1]
private String uuidValue;//ARGV[1]
private long expireTime;//ARGV[2]
public RedisDistributedLock(StringRedisTemplate stringRedisTemplate, String lockName){
this.stringRedisTemplate = stringRedisTemplate;
this.lockName = lockName;
this.uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();//UUID:ThreadID
this.expireTime = 30L;
}
@Override
public void lock(){
tryLock();
}
@Override
public boolean tryLock(){
try {tryLock(-1L,TimeUnit.SECONDS);} catch (InterruptedException e) {e.printStackTrace();}
return false;
}
/**
* 干活的,实现加锁功能,实现这一个干活的就OK,全盘通用
* @param time
* @param unit
* @return
* @throws InterruptedException
*/
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException{
if(time != -1L){
this.expireTime = unit.toSeconds(time);
}
String script =
"if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then " +
"redis.call('hincrby',KEYS[1],ARGV[1],1) " +
"redis.call('expire',KEYS[1],ARGV[2]) " +
"return 1 " +
"else " +
"return 0 " +
"end";
System.out.println("script: "+script);
System.out.println("lockName: "+lockName);
System.out.println("uuidValue: "+uuidValue);
System.out.println("expireTime: "+expireTime);
while (!stringRedisTemplate.execute(new DefaultRedisScript<>(script,Boolean.class), Arrays.asList(lockName),uuidValue,String.valueOf(expireTime))) {
TimeUnit.MILLISECONDS.sleep(50);
}
return true;
}
/**
*干活的,实现解锁功能
*/
@Override
public void unlock()
{
String script =
"if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then " +
" return nil " +
"elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then " +
" return redis.call('del',KEYS[1]) " +
"else " +
" return 0 " +
"end";
// nil = false 1 = true 0 = false
System.out.println("lockName: "+lockName);
System.out.println("uuidValue: "+uuidValue);
System.out.println("expireTime: "+expireTime);
Long flag = stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName),uuidValue,String.valueOf(expireTime));
if(flag == null)
{
throw new RuntimeException("This lock doesn't EXIST");
}
}
//===下面的redis分布式锁暂时用不到=======================================
//===下面的redis分布式锁暂时用不到=======================================
//===下面的redis分布式锁暂时用不到=======================================
@Override
public void lockInterruptibly() throws InterruptedException
{
}
@Override
public Condition newCondition()
{
return null;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
InventoryService 使用工厂模式版
import ch.qos.logback.core.joran.conditional.ThenAction;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.atguigu.redislock.mylock.DistributedLockFactory;
import com.atguigu.redislock.mylock.RedisDistributedLock;
import lombok.extern.slf4j.Slf4j;
import org.omg.IOP.TAG_RMI_CUSTOM_MAX_STREAM_FORMAT;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @auther zzyy
* @create 2022-10-12 17:04
*/
@Service
@Slf4j
public class InventoryService
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String port;
@Autowired
private DistributedLockFactory distributedLockFactory;
public String sale()
{
String retMessage = "";
Lock redisLock = distributedLockFactory.getDistributedLock("redis");
redisLock.lock();
try
{
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0)
{
inventoryNumber = inventoryNumber - 1;
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber+"\t服务端口:" +port;
System.out.println(retMessage);
return retMessage;
}
retMessage = "商品卖完了,o(╥﹏╥)o"+"\t服务端口:" +port;
}catch (Exception e){
e.printStackTrace();
}finally {
redisLock.unlock();
}
return retMessage;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# 可重入性测试
InventoryService 类新增可重入测试方法
import com.atguigu.redislock.mylock.DistributedLockFactory;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.concurrent.locks.Lock;
/**
* @auther zzyy
* @create 2022-10-30 12:28
*/
@Service
@Slf4j
public class InventoryService
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String port;
@Autowired
private DistributedLockFactory distributedLockFactory;
public String sale()
{
String retMessage = "";
Lock redisLock = distributedLockFactory.getDistributedLock("redis");
redisLock.lock();
try
{
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber+"\t";
System.out.println(retMessage);
testReEnter();
}else{
retMessage = "商品卖完了,o(╥﹏╥)o";
}
}catch (Exception e){
e.printStackTrace();
}finally {
redisLock.unlock();
}
return retMessage+"\t"+"服务端口号:"+port;
}
private void testReEnter()
{
Lock redisLock = distributedLockFactory.getDistributedLock("redis");
redisLock.lock();
try
{
System.out.println("################测试可重入锁#######");
}finally {
redisLock.unlock();
}
}
}
/**
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber+"\t";
System.out.println(retMessage);
}else{
retMessage = "商品卖完了,o(╥﹏╥)o";
}
*/
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
进行测试 http://localhost:7777/inventory/sale

ThreadID 一致了但是 UUID 不 OK
继续改造工厂模式
DistributedLockFactory
import cn.hutool.core.util.IdUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.locks.Lock;
/**
* @auther zzyy
* @create 2022-10-23 22:40
*/
@Component
public class DistributedLockFactory
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
private String lockName;
private String uuidValue;
public DistributedLockFactory()
{
this.uuidValue = IdUtil.simpleUUID();//UUID
}
public Lock getDistributedLock(String lockType)
{
if(lockType == null) return null;
if(lockType.equalsIgnoreCase("REDIS")){
lockName = "zzyyRedisLock";
return new RedisDistributedLock(stringRedisTemplate,lockName,uuidValue);
} else if(lockType.equalsIgnoreCase("ZOOKEEPER")){
//TODO zookeeper版本的分布式锁实现
return new ZookeeperDistributedLock();
} else if(lockType.equalsIgnoreCase("MYSQL")){
//TODO mysql版本的分布式锁实现
return null;
}
return null;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
RedisDistributedLock
import cn.hutool.core.util.IdUtil;
import lombok.SneakyThrows;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Arrays;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
/**
* @auther zzyy
* @create 2022-10-23 22:36
*/
public class RedisDistributedLock implements Lock
{
private StringRedisTemplate stringRedisTemplate;
private String lockName;
private String uuidValue;
private long expireTime;
public RedisDistributedLock(StringRedisTemplate stringRedisTemplate, String lockName,String uuidValue)
{
this.stringRedisTemplate = stringRedisTemplate;
this.lockName = lockName;
this.uuidValue = uuidValue+":"+Thread.currentThread().getId();
this.expireTime = 30L;
}
@Override
public void lock()
{
this.tryLock();
}
@Override
public boolean tryLock()
{
try
{
return this.tryLock(-1L,TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
return false;
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException
{
if(time != -1L)
{
expireTime = unit.toSeconds(time);
}
String script =
"if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then " +
"redis.call('hincrby',KEYS[1],ARGV[1],1) " +
"redis.call('expire',KEYS[1],ARGV[2]) " +
"return 1 " +
"else " +
"return 0 " +
"end";
System.out.println("lockName: "+lockName+"\t"+"uuidValue: "+uuidValue);
while (!stringRedisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), uuidValue, String.valueOf(expireTime)))
{
try { TimeUnit.MILLISECONDS.sleep(60); } catch (InterruptedException e) { e.printStackTrace(); }
}
return true;
}
@Override
public void unlock()
{
String script =
"if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then " +
"return nil " +
"elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then " +
"return redis.call('del',KEYS[1]) " +
"else " +
"return 0 " +
"end";
System.out.println("lockName: "+lockName+"\t"+"uuidValue: "+uuidValue);
Long flag = stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName), uuidValue, String.valueOf(expireTime));
if(flag == null)
{
throw new RuntimeException("没有这个锁,HEXISTS查询无");
}
}
//=========================================================
@Override
public void lockInterruptibly() throws InterruptedException
{
}
@Override
public Condition newCondition()
{
return null;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
InventoryService 新增可重入测试方法
import cn.hutool.core.util.IdUtil;
import com.atguigu.redislock.mylock.DistributedLockFactory;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @auther zzyy
* @create 2022-10-22 15:14
*/
@Service
@Slf4j
public class InventoryService
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String port;
@Autowired
private DistributedLockFactory distributedLockFactory;
public String sale()
{
String retMessage = "";
Lock redisLock = distributedLockFactory.getDistributedLock("redis");
redisLock.lock();
try
{
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber;
System.out.println(retMessage);
this.testReEnter();
}else{
retMessage = "商品卖完了,o(╥﹏╥)o";
}
}catch (Exception e){
e.printStackTrace();
}finally {
redisLock.unlock();
}
return retMessage+"\t"+"服务端口号:"+port;
}
private void testReEnter()
{
Lock redisLock = distributedLockFactory.getDistributedLock("redis");
redisLock.lock();
try
{
System.out.println("################测试可重入锁####################################");
}finally {
redisLock.unlock();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
单机 + 并发 + 可重入性,测试通过
# 自动续期
确保 redisLock 过期时间大于业务执行时间的问题,Redis 分布式锁如何续期?
# CAP
Redis 集群是 AP
AP:redis 异步复制造成的锁丢失,比如:主节点没来的及把刚刚 set 进来这条数据给从节点,master 就挂了,从机上位但从机上无该数据
Zookeeper 集群是 CP

当 leader 重启或者网络故障下,整个 ZK 集群会重新选举新老大,选举期间 client 不可以注册,即 ZK 不可用,所以牺牲了可用性 A。只有选举出新老大后,系统才恢复注册。故 ZK 为了保证数据一致性牺牲了可靠性。由于在大型分布式系统中故障难以避免,leader 出故障可能性很高,所以很多大型系统都不会选择 ZK 的原因。

Eureka 集群是 AP

Nacos 集群是 AP
| 服务注册与发现框架 | CAP 模型 | 控制台管理 | 社区活跃度 |
|---|---|---|---|
| Eureka | AP | 支持 | 低 (2.× 版本闭源) |
| Zookeeper | CP | 不支持 | 中 |
| Consul | CP | 支持 | 高 |
| Nacos | AP | 支持 | 高 |
我们可以使用 lua 脚本加时
-- ==============自动续期
if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 1 then
return redis.call('expire',KEYS[1],ARGV[2])
else
return 0
end
2
3
4
5
6
在 redis 中 del 掉之前的 lockName zzyyRedisLock
srem zzyyRedisLock
RedisDistributedLock
import cn.hutool.core.util.IdUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.support.collections.DefaultRedisList;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
/**
* @auther zzyy
* @create 2022-10-18 18:32
*/
public class RedisDistributedLock implements Lock
{
private StringRedisTemplate stringRedisTemplate;
private String lockName;//KEYS[1]
private String uuidValue;//ARGV[1]
private long expireTime;//ARGV[2]
public RedisDistributedLock(StringRedisTemplate stringRedisTemplate,String lockName,String uuidValue)
{
this.stringRedisTemplate = stringRedisTemplate;
this.lockName = lockName;
this.uuidValue = uuidValue+":"+Thread.currentThread().getId();
this.expireTime = 30L;
}
@Override
public void lock()
{
tryLock();
}
@Override
public boolean tryLock()
{
try {tryLock(-1L,TimeUnit.SECONDS);} catch (InterruptedException e) {e.printStackTrace();}
return false;
}
/**
* 干活的,实现加锁功能,实现这一个干活的就OK,全盘通用
* @param time
* @param unit
* @return
* @throws InterruptedException
*/
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException
{
if(time != -1L)
{
this.expireTime = unit.toSeconds(time);
}
String script =
"if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then " +
"redis.call('hincrby',KEYS[1],ARGV[1],1) " +
"redis.call('expire',KEYS[1],ARGV[2]) " +
"return 1 " +
"else " +
"return 0 " +
"end";
System.out.println("script: "+script);
System.out.println("lockName: "+lockName);
System.out.println("uuidValue: "+uuidValue);
System.out.println("expireTime: "+expireTime);
while (!stringRedisTemplate.execute(new DefaultRedisScript<>(script,Boolean.class), Arrays.asList(lockName),uuidValue,String.valueOf(expireTime))) {
TimeUnit.MILLISECONDS.sleep(50);
}
this.renewExpire();
return true;
}
/**
*干活的,实现解锁功能
*/
@Override
public void unlock()
{
String script =
"if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then " +
" return nil " +
"elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then " +
" return redis.call('del',KEYS[1]) " +
"else " +
" return 0 " +
"end";
// nil = false 1 = true 0 = false
System.out.println("lockName: "+lockName);
System.out.println("uuidValue: "+uuidValue);
System.out.println("expireTime: "+expireTime);
Long flag = stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName),uuidValue,String.valueOf(expireTime));
if(flag == null)
{
throw new RuntimeException("This lock doesn't EXIST");
}
}
private void renewExpire()
{
String script =
"if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 1 then " +
"return redis.call('expire',KEYS[1],ARGV[2]) " +
"else " +
"return 0 " +
"end";
new Timer().schedule(new TimerTask()
{
@Override
public void run()
{
if (stringRedisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName),uuidValue,String.valueOf(expireTime))) {
renewExpire();
}
}
},(this.expireTime * 1000)/3);
}
//===下面的redis分布式锁暂时用不到=======================================
//===下面的redis分布式锁暂时用不到=======================================
//===下面的redis分布式锁暂时用不到=======================================
@Override
public void lockInterruptibly() throws InterruptedException
{
}
@Override
public Condition newCondition()
{
return null;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
InventoryService 记得去掉可重入测试 testReEnter (),InventoryService 业务逻辑里面故意 sleep 一段时间测试自动续期
import cn.hutool.core.util.IdUtil;
import com.atguigu.redislock.mylock.DistributedLockFactory;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @auther zzyy
* @create 2022-10-22 15:14
*/
@Service
@Slf4j
public class InventoryService
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String port;
@Autowired
private DistributedLockFactory distributedLockFactory;
public String sale()
{
String retMessage = "";
Lock redisLock = distributedLockFactory.getDistributedLock("redis");
redisLock.lock();
try
{
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber;
System.out.println(retMessage);
//暂停几秒钟线程,为了测试自动续期
try { TimeUnit.SECONDS.sleep(120); } catch (InterruptedException e) { e.printStackTrace(); }
}else{
retMessage = "商品卖完了,o(╥﹏╥)o";
}
}catch (Exception e){
e.printStackTrace();
}finally {
redisLock.unlock();
}
return retMessage+"\t"+"服务端口号:"+port;
}
private void testReEnter()
{
Lock redisLock = distributedLockFactory.getDistributedLock("redis");
redisLock.lock();
try
{
System.out.println("################测试可重入锁####################################");
}finally {
redisLock.unlock();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# 总结
- synchronized 单机版 OK,上分布式死翘翘
- nginx 分布式微服务单机锁不行
- 取消单机锁,上 redis 分布式锁 setnx
- 只加了锁,没有释放锁,出异常的话,可能无法释放锁,必须要在代码层面 finally 释放锁
- 宕机了,部署了微服务代码层面根本没有走到 finally 这块,没办法保证解锁,这个 key 没有被删除,需要有 lockKey 的过期时间设定
- 为 redis 的分布式锁 key,增加过期时间此外,还必须要 setnx + 过期时间必须同一行
- 必须规定只能自己删除自己的锁,你不能把别人的锁删除了,防止张冠李戴,1 删 2,2 删 3
- unlock 变为 Lua 脚本保证,锁重入,hset 替代 setnx+lock 变为 Lua 脚本保证,自动续期
自研一把分布式锁,面试中回答的主要考点
- 按照 JUC 里面 java.util.concurrent.locks.Lock 接口规范编写
- lock () 加锁关键逻辑
- 加锁:加锁实际上就是在 redis 中,给 Key 键设置一个值,为避免死锁,并给定一个过期时间,加锁的 Lua 脚本,通过 redis 里面的 hash 数据模型,加锁和可重入性都要保证。
- 自旋:加锁不成,需要 while 进行重试并自旋
- 续期:自动续期,加个钟
- unlock 解锁关键逻辑:将 Key 键删除。但也不能乱删,不能说客户端 1 的请求将客户端 2 的锁给删除掉,只能自己删除自己的锁
上面自研的 redis 锁对于一般中小公司,不是特别高并发场景足够用了,单机 redis 小业务也撑得住
# Redlock 红锁算法
Redlock 红锁算法 Distributed locks with Redis
官方说明 https://redis.io/docs/manual/patterns/distributed-locks/

之前我们手写的分布式锁有什么缺点?


线程 1 首先获取锁成功,将键值对写入 redis 的 master 节点,在 redis 将该键值对同步到 slave 节点之前,master 发生了故障;redis 触发故障转移,其中一个 slave 升级为新的 master,此时新上位的 master 并不包含线程 1 写入的键值对,因此线程 2 尝试获取锁也可以成功拿到锁,此时相当于有两个线程获取到了锁,可能会导致各种预期之外的情况发生,例如最常见的脏数据。
我们加的是排它独占锁,同一时间只能有一个建 redis 锁成功并持有锁,严禁出现 2 个以上的请求线程拿到锁。危险的
Redis 也提供了 Redlock 算法,用来实现基于多个实例的分布式锁。
锁变量由多个实例维护,即使有实例发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。Redlock 算法是实现高可靠分布式锁的一种有效解决方案,可以在实际开发中使用

该方案也是基于(set 加锁、Lua 脚本解锁)进行改良的,所以 redis 之父 antirez 只描述了差异的地方,大致方案如下。假设我们有 N 个 Redis 主节点,例如 N = 5 这些节点是完全独立的,我们不使用复制或任何其他隐式协调系统, 为了取到锁客户端执行以下操作:
- 获取当前时间,以毫秒为单位;
- 依次尝试从 5 个实例,使用相同的 key 和随机值(例如 UUID)获取锁。当向 Redis 请求获取锁时,客户端应该设置一个超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为 10 秒,则超时时间应该在 5-50 毫秒之间。这样可以防止客户端在试图与一个宕机的 Redis 节点对话时长时间处于阻塞状态。如果一个实例不可用,客户端应该尽快尝试去另外一个 Redis 实例请求获取锁;
- 客户端通过当前时间减去步骤 1 记录的时间来计算获取锁使用的时间。当且仅当从大多数(N/2+1,这里是 3 个节点)的 Redis 节点都取到锁,并且获取锁使用的时间小于锁失效时间时,锁才算获取成功;
- 如果取到了锁,其真正有效时间等于初始有效时间减去获取锁所使用的时间(步骤 3 计算的结果)。
- 如果由于某些原因未能获得锁(无法在至少 N/2 + 1 个 Redis 实例获取锁、或获取锁的时间超过了有效时间),客户端应该在所有的 Redis 实例上进行解锁(即便某些 Redis 实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。
该方案为了解决数据不一致的问题,直接舍弃了异步复制只使用 master 节点,同时由于舍弃了 slave,为了保证可用性,引入了 N 个节点,官方建议是 5。
客户端只有在满足下面的这两个条件时,才能认为是加锁成功。
- 条件 1:客户端从超过半数(大于等于 N/2+1)的 Redis 实例上成功获取到了锁;
- 条件 2:客户端获取锁的总耗时没有超过锁的有效时间。
该算法使用解决方案的

为什么是奇数? $N = 2X + 1 $ (N 是最终部署机器数,X 是容错机器数)
什么是容错?
失败了多少个机器实例后我还是可以容忍的,所谓的容忍就是数据一致性还是可以 Ok 的,CP 数据一致性还是可以满足
加入在集群环境中,redis 失败 1 台,可接受。
为什么是奇数?
最少的机器,最多的产出效果
加入在集群环境中,redis 失败 1 台,可接受。
加入在集群环境中,redis 失败 2 台,可接受。
(RedLock) 的理念必然有落地的实现 (Redisson)
Redisson 是 java 的 redis 客户端之一,提供了一些 api 方便操作 redis
官方 https://redisson.org/
github https://github.com/redisson/redisson/wiki/%E7%9B%AE%E5%BD%95
解决分布式锁 https://github.com/redisson/redisson/wiki/8.-Distributed-locks-and-synchronizers
# 使用 Redisson 进行改造
我们进一步再继续改造我们的手写分布式锁的编码
https://github.com/redisson/redisson/wiki/8.-%E5%88%86%E5%B8%83%E5%BC%8F%E9%94%81%E5%92%8C%E5%90%8C%E6%AD%A5%E5%99%A8#81-%E5%8F%AF%E9%87%8D%E5%85%A5%E9%94%81reentrant-lock

引入 redisson 依赖
<!--redisson-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.4</version>
</dependency>
2
3
4
5
6
RedisConfig
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* @auther zzyy
* @create 2022-10-22 15:14
*/
@Configuration
public class RedisConfig
{
@Bean
public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory)
{
RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(lettuceConnectionFactory);
//设置key序列化方式string
redisTemplate.setKeySerializer(new StringRedisSerializer());
//设置value的序列化方式json
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
//单Redis节点模式
@Bean
public Redisson redisson()
{
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.111.175:6379").setDatabase(0).setPassword("111111");
return (Redisson) Redisson.create(config);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
InventoryController
import com.atguigu.redislock.service.InventoryService;
import com.atguigu.redislock.service.InventoryService2;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @auther zzyy
* @create 2022-10-22 15:23
*/
@RestController
@Api(tags = "redis分布式锁测试")
public class InventoryController
{
@Autowired
private InventoryService inventoryService;
@ApiOperation("扣减库存,一次卖一个")
@GetMapping(value = "/inventory/sale")
public String sale()
{
return inventoryService.sale();
}
@ApiOperation("扣减库存saleByRedisson,一次卖一个")
@GetMapping(value = "/inventory/saleByRedisson")
public String saleByRedisson()
{
return inventoryService.saleByRedisson();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
InventoryService 从现在开始不再用我们自己手写的锁了
import cn.hutool.core.util.IdUtil;
import com.atguigu.redislock.mylock.DistributedLockFactory;
import com.atguigu.redislock.mylock.RedisDistributedLock;
import lombok.extern.slf4j.Slf4j;
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
/**
* @auther zzyy
* @create 2022-10-25 16:07
*/
@Service
@Slf4j
public class InventoryService2
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String port;
@Autowired
private DistributedLockFactory distributedLockFactory;
@Autowired
private Redisson redisson;
public String saleByRedisson()
{
String retMessage = "";
String key = "zzyyRedisLock";
RLock redissonLock = redisson.getLock(key);
redissonLock.lock();
try
{
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber;
System.out.println(retMessage);
}else{
retMessage = "商品卖完了,o(╥﹏╥)o";
}
}finally {
redissonLock.unlock();
}
return retMessage+"\t"+"服务端口号:"+port;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
使用 JMeter 测试,发现出现错误

修改 InventoryService
import cn.hutool.core.util.IdUtil;
import com.atguigu.redislock.mylock.DistributedLockFactory;
import com.atguigu.redislock.mylock.RedisDistributedLock;
import lombok.extern.slf4j.Slf4j;
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
/**
* @auther zzyy
* @create 2022-10-25 16:07
*/
@Service
@Slf4j
public class InventoryService
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String port;
@Autowired
private DistributedLockFactory distributedLockFactory;
@Autowired
private Redisson redisson;
public String saleByRedisson()
{
String retMessage = "";
String key = "zzyyRedisLock";
RLock redissonLock = redisson.getLock(key);
redissonLock.lock();
try
{
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber;
System.out.println(retMessage);
}else{
retMessage = "商品卖完了,o(╥﹏╥)o";
}
}finally {
if(redissonLock.isLocked() && redissonLock.isHeldByCurrentThread())
{
redissonLock.unlock();
}
}
return retMessage+"\t"+"服务端口号:"+port;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# Redisson 源码解析
守护线程 “续命”,额外起一个线程,定期检查线程是否还持有锁,如果有则延长过期时间。
Redisson 里面就实现了这个方案,使用 “看门狗” 定期检查(每 1/3 的锁时间检查 1 次),如果线程还持有锁,则刷新过期时间;
在获取锁成功后,给锁加一个 watchdog,watchdog 会起一个定时任务,在锁没有被释放且快要过期的时候会续期。

通过 redisson 新建出来的锁 key,默认是 30 秒


RedissonLock.java lock() ---> tryAcquire() ---> tryAcquireAsync()


流程解释:
- 通过 exists 判断,如果锁不存在,则设置值和过期时间,加锁成功
- 通过 hexists 判断,如果锁已存在,并且锁的是当前线程,则证明是重入锁,加锁成功
- 如果锁已存在,但锁的不是当前线程,则证明有其他线程持有锁。返回当前锁的过期时间 (代表了锁 key 的剩余生存时间),加锁失败
scheduleExpirationRenewal()

这里面初始化了一个定时器,dely 的时间是 internalLockLeaseTime/3。 在 Redisson 中,internalLockLeaseTime 是 30s,也就是每隔 10s 续期一次,每次 30s。

watch dog 自动延期机制,客户端 A 加锁成功,就会启动一个 watch dog 看门狗,他是一个后台线程,会每隔 10 秒检查一下,如果客户端 A 还持有锁 key,那么就会不断的延长锁 key 的生存时间,默认每次续命又从 30 秒新开始

自动续期 lua 脚本分析

解锁

这个锁的算法实现了多 redis 实例的情况,相对于单 redis 节点来说,优点在于 防止了 单节点故障造成整个服务停止运行的情况且在多节点中锁的设计,及多节点同时崩溃等各种意外情况有自己独特的设计方法。 Redisson 分布式锁支持 MultiLock 机制可以将多个锁合并为一个大锁,对一个大锁进行统一的申请加锁以及释放锁。
最低保证分布式锁的有效性及安全性的要求如下:
- 互斥;任何时刻只能有一个 client 获取锁
- 释放死锁;即使锁定资源的服务崩溃或者分区,仍然能释放锁
- 容错性;只要多数 redis 节点(一半以上)在使用,client 就可以获取和释放锁
网上讲的基于故障转移实现的 redis 主从无法真正实现 Redlock:
因为 redis 在进行主从复制时是异步完成的,比如在 clientA 获取锁后,主 redis 复制数据到从 redis 过程中崩溃了,导致没有复制到从 redis 中,然后从 redis 选举出一个升级为主 redis, 造成新的主 redis 没有 clientA 设置的锁,这是 clientB 尝试获取锁,并且能够成功获取锁,导致互斥失效;
# 多机案例
docker 走起 3 台 redis 的 master 机器,本次设置 3 台 master 各自独立无从属关系
docker run -p 6381:6379 --name redis-master-1 -d redis
docker run -p 6382:6379 --name redis-master-2 -d redis
docker run -p 6383:6379 --name redis-master-3 -d redis
2
3
4
5
进入上一步刚启动的 redis 容器实例
docker exec -it redis-master-1 /bin/bash # 或者 docker exec -it redis-master-1 redis-cli
docker exec -it redis-master-2 /bin/bash # 或者 docker exec -it redis-master-2 redis-cli
docker exec -it redis-master-3 /bin/bash # 或者 docker exec -it redis-master-3 redis-cli
2
3
4
5
建 Module redis_redlock
修改 POM
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.10.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.atguigu.redis.redlock</groupId>
<artifactId>redis_redlock</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.19.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.8</version>
</dependency>
<!--swagger-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.9.2</version>
</dependency>
<!--swagger-ui-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.9.2</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.4</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.11</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
application.properties
server.port=9090
spring.application.name=redlock
spring.swagger2.enabled=true
spring.redis.database=0
spring.redis.password=
spring.redis.timeout=3000
spring.redis.mode=single
spring.redis.pool.conn-timeout=3000
spring.redis.pool.so-timeout=3000
spring.redis.pool.size=10
spring.redis.single.address1=192.168.111.185:6381
spring.redis.single.address2=192.168.111.185:6382
spring.redis.single.address3=192.168.111.185:6383
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
启动类 RedisRedlockApplication
package com.atguigu.redis.redlock;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class RedisRedlockApplication
{
public static void main(String[] args)
{
SpringApplication.run(RedisRedlockApplication.class, args);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CacheConfiguration
package com.atguigu.redis.redlock.config;
import org.apache.commons.lang3.StringUtils;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@Configuration
@EnableConfigurationProperties(RedisProperties.class)
public class CacheConfiguration {
@Autowired
RedisProperties redisProperties;
@Bean
RedissonClient redissonClient1() {
Config config = new Config();
String node = redisProperties.getSingle().getAddress1();
node = node.startsWith("redis://") ? node : "redis://" + node;
SingleServerConfig serverConfig = config.useSingleServer()
.setAddress(node)
.setTimeout(redisProperties.getPool().getConnTimeout())
.setConnectionPoolSize(redisProperties.getPool().getSize())
.setConnectionMinimumIdleSize(redisProperties.getPool().getMinIdle());
if (StringUtils.isNotBlank(redisProperties.getPassword())) {
serverConfig.setPassword(redisProperties.getPassword());
}
return Redisson.create(config);
}
@Bean
RedissonClient redissonClient2() {
Config config = new Config();
String node = redisProperties.getSingle().getAddress2();
node = node.startsWith("redis://") ? node : "redis://" + node;
SingleServerConfig serverConfig = config.useSingleServer()
.setAddress(node)
.setTimeout(redisProperties.getPool().getConnTimeout())
.setConnectionPoolSize(redisProperties.getPool().getSize())
.setConnectionMinimumIdleSize(redisProperties.getPool().getMinIdle());
if (StringUtils.isNotBlank(redisProperties.getPassword())) {
serverConfig.setPassword(redisProperties.getPassword());
}
return Redisson.create(config);
}
@Bean
RedissonClient redissonClient3() {
Config config = new Config();
String node = redisProperties.getSingle().getAddress3();
node = node.startsWith("redis://") ? node : "redis://" + node;
SingleServerConfig serverConfig = config.useSingleServer()
.setAddress(node)
.setTimeout(redisProperties.getPool().getConnTimeout())
.setConnectionPoolSize(redisProperties.getPool().getSize())
.setConnectionMinimumIdleSize(redisProperties.getPool().getMinIdle());
if (StringUtils.isNotBlank(redisProperties.getPassword())) {
serverConfig.setPassword(redisProperties.getPassword());
}
return Redisson.create(config);
}
/**
* 单机
* @return
*/
/*@Bean
public Redisson redisson()
{
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.111.147:6379").setDatabase(0);
return (Redisson) Redisson.create(config);
}*/
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
RedisPoolProperties
package com.atguigu.redis.redlock.config;
import lombok.Data;
@Data
public class RedisPoolProperties {
private int maxIdle;
private int minIdle;
private int maxActive;
private int maxWait;
private int connTimeout;
private int soTimeout;
/**
* 池大小
*/
private int size;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
RedisProperties
package com.atguigu.redis.redlock.config;
import lombok.Data;
import lombok.ToString;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "spring.redis", ignoreUnknownFields = false)
@Data
public class RedisProperties {
private int database;
/**
* 等待节点回复命令的时间。该时间从命令发送成功时开始计时
*/
private int timeout;
private String password;
private String mode;
/**
* 池配置
*/
private RedisPoolProperties pool;
/**
* 单机信息配置
*/
private RedisSingleProperties single;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
RedisSingleProperties
package com.atguigu.redis.redlock.config;
import lombok.Data;
@Data
public class RedisSingleProperties {
private String address1;
private String address2;
private String address3;
}
2
3
4
5
6
7
8
9
10
11
RedLockController
package com.atguigu.redis.redlock.controller;
import cn.hutool.core.util.IdUtil;
import lombok.extern.slf4j.Slf4j;
import org.redisson.Redisson;
import org.redisson.RedissonMultiLock;
import org.redisson.RedissonRedLock;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@RestController
@Slf4j
public class RedLockController {
public static final String CACHE_KEY_REDLOCK = "ATGUIGU_REDLOCK";
@Autowired
RedissonClient redissonClient1;
@Autowired
RedissonClient redissonClient2;
@Autowired
RedissonClient redissonClient3;
boolean isLockBoolean;
@GetMapping(value = "/multiLock")
public String getMultiLock() throws InterruptedException
{
String uuid = IdUtil.simpleUUID();
String uuidValue = uuid+":"+Thread.currentThread().getId();
RLock lock1 = redissonClient1.getLock(CACHE_KEY_REDLOCK);
RLock lock2 = redissonClient2.getLock(CACHE_KEY_REDLOCK);
RLock lock3 = redissonClient3.getLock(CACHE_KEY_REDLOCK);
RedissonMultiLock redLock = new RedissonMultiLock(lock1, lock2, lock3);
redLock.lock();
try
{
System.out.println(uuidValue+"\t"+"---come in biz multiLock");
try { TimeUnit.SECONDS.sleep(30); } catch (InterruptedException e) { e.printStackTrace(); }
System.out.println(uuidValue+"\t"+"---task is over multiLock");
} catch (Exception e) {
e.printStackTrace();
log.error("multiLock exception ",e);
} finally {
redLock.unlock();
log.info("释放分布式锁成功key:{}", CACHE_KEY_REDLOCK);
}
return "multiLock task is over "+uuidValue;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
测试 http://localhost:9090/multilock
进入客户端
docker start redis-master-1
docker exec -it redis-master-1 redis-cli
ttl ATGUIGU_REDLOCK
HGETALL ATGUIGU_REDLOCK
shutdown
2
3
4
5
