八股-Redis
Redis 相关面试题
使用场景
缓存(穿透、击穿、雪崩、双写一致、持久化、数据过期、淘汰策略)
分布式锁(setnx+lua、redisson)
穿透无中生有 key,布隆过滤 null 隔离
缓存击穿过期 key,锁与非期解难题
雪崩大量过期 key,过期时间要随机
面试必考三兄弟,可用限流来保底
======= 数据结构 =======
Redis 底层数据结构
Redis 提供了丰富的数据类型,常见的有五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)

结构类型 | 结构存储的值 | 结构的读写能力 |
---|---|---|
String字符串 | 可以是字符串、整数或浮点数 | 对整个字符串或字符串的一部分进行操作;对整数或浮点数进行自增或自减操作; |
List列表 | 一个链表,链表上的每个节点都包含一个字符串 | 对链表的两端进行push和pop操作,读取单个或多个元素;根据值查找或删除元素; |
Set集合 | 包含字符串的无序集合 | 字符串的集合,包含基础的方法有查看是否存在添加、获取、删除;还包含计算交集、并集、差集等 |
Hash散列 | 包含键值对的无序散列表 | 包含方法有添加、获取、删除单个元素 |
Zset有序集合 | 和散列一样,用于存储键值对 | 字符串成员与浮点数分数之间的有序映射;元素的排列顺序由分数的大小决定;包含方法有添加、获取、删除单个元素以及根据分值范围或成员来获取元素 |
随着 Redis 版本的更新,后面又支持了四种数据类型:BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增) 。Redis 五种数据类型的应用场景:
- String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等
- List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等
- Hash 类型:缓存对象、购物车等
- Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等
- Zset 类型:排序场景,比如排行榜、电话和姓名排序等
Redis 后续版本又支持四种数据类型,它们的应用场景如下:
- BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等
- HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等
- GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车
- Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据
Zset
使用
用过 zset 实现排行榜的功能
以博文点赞排名为例,小林发表了五篇博文,分别获得赞为 200、40、100、50、150
# arcticle:1 文章获得了200个赞
> ZADD user:xiaolin:ranking 200 arcticle:1
(integer) 1
# arcticle:2 文章获得了40个赞
> ZADD user:xiaolin:ranking 40 arcticle:2
(integer) 1
# arcticle:3 文章获得了100个赞
> ZADD user:xiaolin:ranking 100 arcticle:3
(integer) 1
# arcticle:4 文章获得了50个赞
> ZADD user:xiaolin:ranking 50 arcticle:4
(integer) 1
# arcticle:5 文章获得了150个赞
> ZADD user:xiaolin:ranking 150 arcticle:5
(integer) 1
文章 arcticle:4 新增一个赞,可以使用 ZINCRBY 命令(为有序集合key中元素member的分值加上increment):
> ZINCRBY user:xiaolin:ranking 1 arcticle:4
"51"
查看某篇文章的赞数,可以使用 ZSCORE 命令(返回有序集合key中元素个数):
> ZSCORE user:xiaolin:ranking arcticle:4
"50"
获取小林文章赞数最多的 3 篇文章,可以使用 ZREVRANGE 命令(倒序获取有序集合 key 从start下标到stop下标的元素):
# WITHSCORES 表示把 score 也显示出来
> ZREVRANGE user:xiaolin:ranking 0 2 WITHSCORES
1) "arcticle:1"
2) "200"
3) "arcticle:5"
4) "150"
5) "arcticle:3"
6) "100"
获取小林 100 赞到 200 赞的文章,可以使用 ZRANGEBYSCORE 命令(返回有序集合中指定分数区间内的成员,分数由低到高排序):
> ZRANGEBYSCORE user:xiaolin:ranking 100 200 WITHSCORES
1) "arcticle:3"
2) "100"
3) "arcticle:5"
4) "150"
5) "arcticle:1"
6) "200"
底层实现
Zset 类型的底层数据结构是由压缩列表或跳表实现的:
- 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构
- 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了
跳表
原理/实现
链表在查找元素的时候,因为需要逐一查找,所以查询效率非常低,时间复杂度是O(N),于是就出现了跳表跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表,好处是能快速定位数据
下图展示了一个层级为 3 的跳表

图中头节点有 L0~L2 三个头指针,分别指向了不同层级的节点,然后每个层级的节点都通过指针连接起来:
- L0 层级共有 5 个节点,分别是节点1、2、3、4、5
- L1 层级共有 3 个节点,分别是节点 2、3、5
- L2 层级只有 1 个节点,也就是节点 3
如果我们要在链表中查找节点 4 这个元素,只能从头开始遍历链表,需要查找 4 次,而使用了跳表后,只需要查找 2 次就能定位到节点 4,因为可以在头节点直接从 L2 层级跳到节点 3,然后再往前遍历找到节点 4
可以看到,这个查找过程就是在多个层级上跳来跳去,最后定位到元素。当数据量很大时,跳表的查找复杂度就是 O(logN)
那跳表节点是怎么实现多层级的呢?这就需要看「跳表节点」的数据结构了,如下:
typedef struct zskiplistNode {
//Zset 对象的元素值
sds ele;
//元素权重值
double score;
//后向指针
struct zskiplistNode *backward;
//节点的level数组,保存每层上的前向指针和跨度
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
Zset 对象要同时保存「元素」和「元素的权重」,对应到跳表节点结构里就是 sds 类型的 ele 变量和 double 类型的 score 变量。每个跳表节点都有一个后向指针(struct zskiplistNode *backward),指向前一个节点,目的是为了方便从跳表的尾节点开始访问节点,这样倒序查找时很方便
跳表是一个带有层级关系的链表,而且每一层级可以包含多个节点,每一个节点通过指针连接起来,实现这一特性就是靠跳表节点结构体中的zskiplistLevel 结构体类型的 level 数组
level 数组中的每一个元素代表跳表的一层,也就是由 zskiplistLevel 结构体表示,比如 leve[0] 就表示第一层,leve[1] 就表示第二层。zskiplistLevel 结构体里定义了「指向下一个跳表节点的指针」和「跨度」,跨度时用来记录两个节点之间的距离
比如,下面这张图,展示了各个节点的跨度

第一眼看到跨度的时候,以为是遍历操作有关,实际上并没有任何关系,遍历操作只需要用前向指针(struct zskiplistNode *forward)就可以完成了
Redis 跳表在创建节点的时候,随机生成每个节点的层数,并没有严格维持相邻两层的节点数量比例为 2 : 1 的情况
具体的做法是,跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数
这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64
虽然前面讲解跳表的时候,图中的跳表的「头节点」都是 3 层高,但是其实如果层高最大限制是 64,那么在创建跳表「头节点」的时候,就会直接创建 64 层高的头节点
调表如何设置层高
跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数
为什么用跳表而不是 B+ 树
Redis 是内存数据库,跳表在实现简单性、写入性能、内存访问模式等方面的综合优势,使其成为更合适的选择
维度 | 跳表优势 | B+ 树劣势 |
---|---|---|
内存访问 | 符合CPU缓存局部性,指针跳转更高效 | 节点结构复杂,缓存不友好 |
实现复杂度 | 代码简洁,无复杂平衡操作 | 节点分裂/合并逻辑复杂,代码量大 |
写入性能 | 插入/删除仅需调整局部指针 | 插入可能触发递归节点分裂,成本高 |
内存占用 | 结构紧凑,无内部碎片 | 节点预分配可能浪费内存 |
Redis 选择使用跳表(Skip List)而不是 B+ 树来实现有序集合(Sorted Set)等数据结构,是经过多方面权衡后的结果。以下是详细的原因分析:
内存结构与访问模式的差异
B+ 树的特性
- 磁盘友好:B+ 树的设计目标是优化磁盘I/O,通过减少树的高度来降低磁盘寻道次数(例如,一个3层的B+树可以管理数百万数据)
- 节点填充率高:每个节点存储多个键值(Page/Block),适合批量读写
- 范围查询高效:叶子节点形成有序链表,范围查询(如
ZRANGE
)性能极佳
跳表的特性
- 内存友好:跳表基于链表,通过多级索引加速查询,内存访问模式更符合CPU缓存局部性(指针跳跃更少)
- 简单灵活:插入/删除时仅需调整局部指针,无需复杂的节点分裂与合并
- 概率平衡:通过随机层高实现近似平衡,避免了严格的平衡约束(如红黑树的旋转)
Redis 是内存数据库,数据完全存储在内存中,不需要优化磁盘I/O,因此 B+ 树的磁盘友好特性对 Redis 意义不大。而跳表的内存访问模式更优,更适合高频的内存操作
实现复杂度的对比
B+ 树的实现复杂度:
- 节点分裂与合并:插入/删除时可能触发节点分裂或合并,需要复杂的再平衡逻辑
- 锁竞争:在并发环境下,B+ 树的锁粒度较粗(如页锁),容易成为性能瓶颈
- 代码复杂度:B+ 树的实现需要处理大量边界条件(如最小填充因子、兄弟节点借用等)
跳表的实现复杂度:
- 无再平衡操作:插入时只需随机生成层高,删除时直接移除节点并调整指针
- 细粒度锁或无锁:跳表可以通过分段锁或无锁结构(如 CAS)实现高效并发
- 代码简洁:Redis 的跳表核心代码仅需约 200 行(B+ 树实现通常需要数千行)
对于 Redis 这种追求高性能和代码简洁性的项目,跳表的低实现复杂度更具吸引力,Redis作者Antirez曾表示,跳表的实现复杂度远低于平衡树,且性能相近,是更优选择
性能对比
查询性能
- 单点查询:跳表和 B+ 树的时间复杂度均为
O(log N)
,但跳表的实际常数更小(内存中指针跳转比磁盘块访问快得多) - 范围查询:B+ 树的叶子链表在范围查询时占优,但跳表通过双向链表也能高效支持
ZRANGE
操作
写入性能
- B+ 树:插入可能触发节点分裂,涉及父节点递归更新,成本较高
- 跳表:插入仅需修改相邻节点的指针,写入性能更优(Redis 的
ZADD
操作时间复杂度为O(log N)
)
实测数据:在内存中,跳表的插入速度比 B+ 树快 2-3 倍,查询速度相当
内存占用
- B+ 树:每个节点需要存储多个键值和子节点指针,存在内部碎片(节点未填满时)
- 跳表:每个节点只需存储键值、层高和多个前向指针,内存占用更紧凑
🚩压缩列表
压缩列表是Redis为节约内存而开发的数据结构,由连续内存块组成的顺序型数据结构,类似于数组

压缩列表的表头包含以下三个字段:
- zlbytes:记录整个压缩列表占用的内存字节数
- zltail:记录压缩列表尾部节点距离起始地址的字节偏移量,即列表尾的偏移量
- zllen:记录压缩列表包含的节点数量
- zlend:标记压缩列表的结束点,固定值为0xFF(十进制255)
在压缩列表中,查找第一个和最后一个元素可以通过表头三个字段(zllen)的长度直接定位,复杂度为O(1)。而查找其他元素则没有这么高效,只能逐个查找,此时的复杂度为O(N),因此压缩列表不适合保存过多的元素
压缩列表节点(entry)的构成如下:

压缩列表节点包含三部分内容:
- prevlen:记录了前一个节点的长度,目的是为了实现从后向前遍历
- encoding:记录了当前节点实际数据的类型和长度,类型主要有两种:字符串和整数
- data:记录了当前节点的实际数据,其类型和长度由encoding决定
当向压缩列表中插入数据时,根据数据类型(字符串或整数)和数据大小,压缩列表会使用不同空间大小的prevlen和encoding元素保存信息。这种根据数据大小和类型进行不同空间大小分配的设计思想,正是Redis为了节省内存而采用的
压缩列表的缺点是会发生连锁更新的问题,一旦发生连锁更新,就会导致压缩列表占用的内存空间多次重新分配,这会直接影响到压缩列表的访问性能
因此,尽管压缩列表紧凑型的内存布局能节省内存开销,但如果保存的元素数量增加或元素变大,会导致内存重新分配,最糟糕的是会出现“连锁更新”的问题
因此,压缩列表仅适用于保存节点数量不多的场景。只要节点数量足够小,即使发生连锁更新,也是可接受的
尽管如此,Redis针对压缩列表的设计不足,在后续版本中增加了两种数据结构:quicklist(Redis 3.2引入)和listpack(Redis 5.0引入)。这两种数据结构的设计目标是尽可能地保持压缩列表节省内存的优势,同时解决压缩列表的“连锁更新”问题
🚩Redis 中的 listpack
quicklist 虽然通过控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来减少连锁更新带来的性能影响,但是并没有完全解决连锁更新的问题
因为 quicklistNode 还是用了压缩列表来保存元素,压缩列表连锁更新的问题,来源于它的结构设计,所以要想彻底解决这个问题,需要设计一个新的数据结构
于是,Redis 在 5.0 新设计一个数据结构叫 listpack,目的是替代压缩列表,它最大特点是 listpack 中每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患
listpack 采用了压缩列表的很多优秀的设计,比如还是用一块连续的内存空间来紧凑地保存数据,并且为了节省内存的开销,listpack 节点会采用不同的编码方式保存不同大小的数据
我们先看看 listpack 结构:

listpack 头包含两个属性,分别记录了 listpack 总字节数和元素数量,然后 listpack 末尾也有个结尾标识。图中的 listpack entry 就是 listpack 的节点了
每个 listpack 节点结构如下:

主要包含三个方面内容:
- encoding,定义该元素的编码类型,会对不同长度的整数和字符串进行编码
- data,实际存放的数据
- len,encoding+data的总长度
可以看到,listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题
哈希表如何扩容
进行 rehash 的时候,用 2 个哈希表

在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间
随着数据逐步增多,触发了 rehash 操作,这个过程分为三步:
- 给「哈希表 2」 分配空间,一般会比「哈希表 1」 大 1 倍
- 将「哈希表 1 」的数据迁移到「哈希表 2」 中
- 迁移完成后,「哈希表 1 」的空间会被释放,并把「哈希表 2」 设置为「哈希表 1」,然后在「哈希表 2」 新创建一个空白的哈希表,为下次 rehash 做准备

这个过程看起来简单,但是其实第二步很有问题,如果「哈希表 1 」的数据量非常大,那么在迁移至「哈希表 2 」的时候,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求
为了避免 rehash 在数据迁移过程中,因拷贝数据的耗时,影响 Redis 性能的情况,所以 Redis 采用了渐进式 rehash,也就是将数据的迁移的工作不再是一次性迁移完成,而是分多次迁移
渐进式 rehash 步骤如下:
- 给「哈希表 2」 分配空间
- 在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将「哈希表 1 」中索引位置上的所有 key-value 迁移到「哈希表 2」 上
- 随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点会把「哈希表 1 」的所有 key-value 迁移到「哈希表 2」,从而完成 rehash 操作
这样就巧妙地把一次性大量数据迁移工作的开销,分摊到了多次处理请求的过程中,避免了一次性 rehash 的耗时操作
在进行渐进式 rehash 的过程中,会有两个哈希表,所以在渐进式 rehash 进行期间,哈希表元素的删除、查找、更新等操作都会在这两个哈希表进行。比如,查找一个 key 的值的话,先会在「哈希表 1」 里面进行查找,如果没找到,就会继续到哈希表 2 里面进行找到
另外,在渐进式 rehash 进行期间,新增一个 key-value 时,会被保存到「哈希表 2 」里面,而「哈希表 1」 则不再进行任何添加操作,这样保证了「哈希表 1 」的 key-value 数量只会减少,随着 rehash 操作的完成,最终「哈希表 1 」就会变成空表
String 如何存储,为什么不用 C 语言的字符串
Redis 的 String 字符串采用 SDS 数据结构进行存储
- Redis 5.0 SDS 的数据结构:

以下是结构中每个成员变量的详细介绍:
- len:记录了字符串的长度。因此,获取字符串长度时,只需返回此成员变量的值即可,时间复杂度为 O(1)
- alloc:分配给字符数组的空间长度。在修改字符串时,可以通过
alloc - len
计算出剩余空间的大小,从而判断空间是否满足修改需求。如果空间不足,SDS 会自动扩展至执行修改所需的大小,然后才执行实际的修改操作。因此,使用 SDS 既不需要手动调整 SDS 的空间大小,也不会出现缓冲区溢出的问题 - flags:用于表示不同类型的 SDS。共设计了 5 种类型:sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64
- buf[] :字符数组,用于保存实际数据。不仅可以保存字符串,也可以保存二进制数据
总的来说,Redis 的 SDS 结构在字符数组的基础上,增加了三个元数据:len、alloc、flags,以解决 C 语言字符串的缺陷
- O(1)复杂度获取字符串长度
C 语言的字符串长度获取函数 strlen
需要通过遍历的方式来统计字符串长度,时间复杂度为 O(N)
而 Redis 的 SDS 结构由于加入了 len
成员变量,因此获取字符串长度时,直接返回该成员变量的值,因此复杂度仅为 O(1)
- 二进制安全
由于 SDS 不需要使用 “\0” 字符来标识字符串结尾,而是使用专门的 len
成员变量来记录长度,因此可以存储包含 “\0” 的数据。但是为了兼容部分 C 语言标准库的函数,SDS 字符串结尾还是会加上 “\0” 字符
因此,SDS 的 API 都是以处理二进制的方式处理存储在 buf[]
中的数据,程序不会对其中的数据进行任何限制。数据写入时是什么样的,读取时就是什么样的
通过使用二进制安全的 SDS,而不是 C 字符串,Redis 不仅能够保存文本数据,也可以保存任意格式的二进制数据
- 不会发生缓冲区溢出
C 语言的字符串标准库提供的字符串操作函数,大多数(如 strcat
追加字符串函数)都是不安全的,因为这些函数将缓冲区大小是否满足操作需求的工作交由开发者来保证,程序内部并不会判断缓冲区大小是否足够用。当发生缓冲区溢出时,可能会造成程序异常结束
因此,Redis 的 SDS 结构中引入了 alloc
和 len
成员变量,通过 alloc - len
计算,可以确定剩余可用的空间大小。在对字符串进行修改操作时,程序内部会判断缓冲区大小是否足够用
而且,当判断出缓冲区大小不足时,Redis 会自动扩大 SDS 的空间大小,以满足修改所需的大小
======= 线程模型 =======
Redis 为什么快
Redis是纯内存操作,执行速度非常快
采用单线程,避免不必要的上下文切换可竞争条件,多线程还要考虑线程安全问题
使用I/O多路复用模型,非阻塞IO
- Redis 的大部分操作都在内存中完成,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的内存或者网络带宽,而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了
- Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题
- Redis 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求,IO 多路复用机制是指一个线程处理多个 IO 流,就是经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果
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 从 6.0 版本开始引入了多线程机制,但这是一种有限的多线程实现,主要用于处理网络 IO 操作,而非全面的多线程改造
Redis 多线程的实现范围
Redis 6.0+ 的多线程实现有以下特点:
仅用于网络 IO 处理
- 多线程仅处理客户端交互的 socket 读写和协议解析
- 命令执行依然是单线程的,核心数据结构的操作仍在主线程中进行
保留单线程执行命令的模式
- 这避免了复杂的锁机制和同步问题
- 保持了 Redis 简单高效的特性,不引入并发数据访问的复杂性
为什么采用这种有限的多线程设计?
解决网络 IO 瓶颈
- 在高并发、高负载场景下,网络 IO 常成为性能瓶颈
- 多线程处理网络 IO 可以充分利用多核 CPU 资源
避免锁竞争的复杂性
- 命令执行保持单线程,不需要处理复杂的锁机制
- 减少了线程切换和锁竞争带来的开销
兼顾性能与简单性
- 这种设计在提升性能的同时,保持了代码的简洁和可维护性
- 减少了引入全面多线程可能带来的 bug 和复杂性
多线程配置方法
Redis 6.0+ 的多线程默认是禁用的,需要在配置文件中启用:
# 启用 IO 多线程
io-threads-do-reads yes
# 设置 IO 线程数(建议设置为 CPU 核心数)
io-threads 4
注意事项:
- 线程数不应超过可用的 CPU 核心数
- 线程数过多可能导致线程切换开销增大
- 小型实例(QPS < 10000)可能不需要启用多线程
解释一下 I/O 多路复用模型
回答:
I/O多路复用,即通过单个线程同时监听多个Socket,并在某个Socket可读或可写时获得通知,以此避免无效等待,最大化利用CPU资源。当前,所有I/O多路复用均采用epoll模式实现。该模式在通知用户进程Socket就绪的同时,将已就绪的Socket直接写入用户空间,无需逐一遍历Socket以判断其是否就绪,从而提升了性能
Redis的网络模型正是通过结合I/O多路复用与事件处理器来应对多个Socket请求。例如,它提供了连接应答处理器、命令回复处理器以及命令请求处理器
自Redis 6.0版本起,为了进一步提升性能,在命令回复处理器中引入了多线程来处理回复事件。在命令请求处理器中,对命令的转换也采用了多线程,以加快命令转换速度。然而,在命令执行阶段,依然保持单线程模式
+----------------------------+
| 单线程(EventLoop) |
+------------+--------------+
|
epoll_wait/select/kqueue
|
+--------------+--------------+
| | |
客户端1 客户端2 客户端3 ...
fd1 fd2 fd3
Redis采用纯内存操作,执行速度极为迅速。其性能瓶颈主要在于网络延迟,而非执行速度。I/O多路复用模型主要实现了高效的网络请求。用户空间与内核空间。常见的IO模型包括:
- 阻塞IO(Blocking IO)
- 非阻塞IO(Non-blocking IO)
- IO多路复用(IO Multiplexing)
- Redis网络模型





Redis 网络模型
瓶颈是网络 IO,6.0 之后加入了多线程

Redis 网络模型
Redis 6.0 版本之前,是用的是单Reactor单线程的模式

单 Reactor 单进程的方案因为全部工作都在同一个进程内完成,所以实现起来比较简单,不需要考虑进程间通信,也不用担心多进程竞争
但是,这种方案存在 2 个缺点:
- 第一个缺点,因为只有一个进程,无法充分利用 多核 CPU 的性能
- 第二个缺点,Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,那么就造成响应的延迟
所以,单 Reactor 单进程的方案不适用计算机密集型的场景,只适用于业务处理非常快速的场景
Redis 是由 C 语言实现的,在 Redis 6.0 版本之前采用的正是「单 Reactor 单进程」的方案,因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的方案
到 Redis 6.0 之后,就将网络IO的处理改成多线程的方式了,目的是为了这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上
======= 事务 =======
如何实现redis 原子性
一条命令是单线程执行的,所以具有原子性
执行多条命令的原子性可以通过使用 lua 脚本,将多个操作写到一个 Lua 脚本中,Redis 会把整个 Lua 脚本作为一个整体执行,在执行的过程中不会被其他命令打断,从而保证了 Lua 脚本中操作的原子性
除了 lua 其他保证 redis 原子性的方法
redis 事务也可以保证多个操作的原子性
如果 redis 事务正常执行,没有发生任何错误,那么使用 MULTI 和 EXEC 配合使用,就可以保证多个操作都完成
但是,如果事务执行发生错误了,就没办法保证原子性了。比如说 2 个操作,第一个操作执行成果了,但是第二个操作执行的时候,命令出错了,那事务并不会回滚,因为Redis 中并没有提供回滚机制
======= 持久化/日志 =======
RDB (Redis Database Backup file)
Redis 数据备份文件,也叫 Redis 数据快照。把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据
手动执行
redis-cli
SAVE
或 BGSAVE
(SAVE
会影响主进程, BGSAVE
创建子进程执行,对主进程的影响可以忽略不计)
自动执行
在redis.conf
配置文件中
- Redis 在指定的时间间隔内,将内存中的数据集快照写入磁盘的二进制文件(默认为 dump.rdb)
- 可以通过配置
save
参数来自动触发 RDB 快照,如save 900 1
(900秒内有至少1个键被修改)
# 自动保存条件
save 900 1 # 900秒内有1个键被更改则触发保存
save 300 10 # 300秒内有10个键被更改则触发保存
save 60 10000 # 60秒内有10000个键被更改则触发保存
# RDB文件名
dbfilename dump.rdb
# RDB文件保存路径
dir ./
执行原理
BGSAVE
的子进程通过拷贝页表来获得和主进程相同的内存空间映射

如何避免脏数据:
fork采用的是copy-on-write
技术:
当主进程执行读操作时,访问共享内存
当主进程执行写操作时,则会拷贝一份数据,执行写操作

AOF (Append Only File)
AOF 持久化记录服务器执行的所有写操作命令,并在服务器启动时通过重新执行这些命令来恢复数据
默认关闭,需要修改redis.conf
配置文件开启
# 是否开启AOF功能,默认是no
appendonly yes
#AOF文件的名称
appendfilename "appendonly.aof"
# AOF 记录频率
# 表示每执行一次写命令,立即记录到AOF文件
appendfsync always
# 写命令执行完先放入AOF缓冲区,然后表示每隔1秒将缓冲区数据写到AOF文件,是默认方案
appendfsync everysec
# 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
appendfsync no
配置项 | 刷盘时机 | 优点 | 缺点 |
---|---|---|---|
Always | 同步刷盘 | 可靠性高,几乎不丢数据 | 性能影响大 |
everysec(默认) | 每秒刷盘 | 性能适中 | 最多丢失1秒数据 |
no | 操作系统控制 | 性能最好 | 可靠性较差,可能丢失大量数据 |
通过执行bgrewriteaof
命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果

Redis也会在触发阈值时自动去重写AOF文件。阈值也可以在redis.conf
中配置:
# AOF重写触发条件
auto-aof-rewrite-percentage 100 # AOF文件增长率
auto-aof-rewrite-min-size 64mb # AOF文件最小体积触发重写
特性 | RDB | AOF |
---|---|---|
持久化方式 | 定时对整个内存做快照 | 记录每一次执行的命令 |
数据完整性 | 不完整,两次备份之间会丢失 | 相对完整,取决于刷盘策略 |
文件大小 | 会有压缩,文件体积小 | 记录命令,文件体积很大 |
启动恢复速度 | 很快 | 慢 |
数据恢复优先级 | 低,因为数据完整性不如AOF | 高,因为数据完整性更高 |
系统资源占用 | 高,大量CPU和内存消耗 | 低,主要是磁盘IO资源, 但AOF重写时会占用大量CPU和内存资源 |
使用场景 | 可以容忍数分钟的数据丢失,追求更快的启动速度 | 对数据安全性要求较高常见 |
混合持久化(Redis 4.0+)
从 Redis 4.0 开始,Redis 提供了 RDB 和 AOF 的混合持久化模式
工作原理:
- 在 AOF 重写时,不再单纯使用 AOF 命令记录,而是将重写时的内存数据以 RDB 格式写在 AOF 文件开头
- 重写之后的增量命令继续以 AOF 方式追加到文件末尾
优点:
- 结合了 RDB 的快速加载和 AOF 的高安全性
- 启动时可以先加载 RDB 部分,然后执行剩余的 AOF 命令
# 启用混合持久化(Redis 4.0+)
aof-use-rdb-preamble yes
实际应用中的选择
只用 RDB:
- 对数据丢失不敏感的场景(如纯缓存使用)
- 需要高性能,能接受一定程度的数据丢失
只用 AOF:
- 数据安全性要求高的场景
- 写操作频繁但可以接受稍低的性能
混合使用(推荐):
- 在 Redis 4.0+ 版本中,启用混合持久化是一种较好的平衡:
# redis.conf
aof-use-rdb-preamble yes
不启用持久化:
- 将 Redis 严格用作缓存,不关心数据丢失
- 通过主从复制和集群实现高可用,而不依赖持久化
======= 缓存 =======
缓存穿透
缓存穿透 是指客户端请求查询一个 在缓存和数据库中都不存在 的数据。由于缓存中没有命中,请求会直接打到数据库层。如果这类请求量很大(例如恶意攻击者构造大量不存在的 key),就会导致数据库压力骤增,甚至可能宕机,这就好像缓存被“穿透”了,失去了保护后端数据源的作用
解决-缓存空值
方案描述: 当数据库查询不到数据时,仍然将一个特殊的值(比如
null
或者一个约定的空对象)缓存起来,并设置一个较短的过期时间(TTL)。这样,后续对同一个不存在 key 的请求就会直接从缓存命中这个“空值”,而不会再访问数据库优点:
- 实现简单,易于理解和部署
- 能够有效防止 同一个 不存在的 key 反复穿透缓存
缺点:
- 需要额外的缓存空间存储这些空值
- 缓存了空值后,如果数据库中后来又新增了这条数据,缓存层在过期之前无法感知到,可能导致短暂的数据不一致。需要较短的 TTL 来缓解
- 如果攻击者使用 大量不同 的不存在的 key 进行攻击,这种方法仍然会给数据库带来一定的压力(首次查询时),并且会占用大量缓存空间
⭐️解决-布隆过滤器 (Bloom Filter)
方案描述: 将所有 可能存在 的数据的 key 提前存储到一个布隆过滤器中。布隆过滤器是一种空间效率极高的概率型数据结构,它可以判断一个元素 一定不存在 或者 可能存在。在访问缓存之前,先通过布隆过滤器检查 key 是否存在。如果布隆过滤器判断 key 不存在,则直接拒绝请求或返回空,不再查询缓存和数据库。如果判断 key 可能存在,则继续后续的缓存和数据库查询流程
优点:
- 查询效率高,空间占用小
- 能够有效拦截 绝大部分 对不存在 key 的请求,保护后端数据源
缺点:
- 存在一定的 误判率 (False Positive Rate) :布隆过滤器判断“可能存在”的 key,实际上也可能不存在,这时请求仍然会穿透到缓存和数据库(但这种情况比没有过滤器好很多)。它不会误判“不存在”(False Negative)。(误判率可以通过参数设置,一般 5% 以内,占用空间越大,误判率越小)
- 实现相对复杂一些,需要维护布隆过滤器的数据(例如,数据新增时需要同步更新过滤器)
- 布隆过滤器 不支持删除 元素(标准实现)。如果数据删除,过滤器无法同步移除,可能导致后续查询仍认为它“可能存在”
📒 布隆过滤器
工作原理
布隆过滤器的基本原理如下:
- 创建一个m位的位数组,初始值都为0
- 选择k个不同的哈希函数,每个函数可以将元素映射到位数组中的某一位
- 添加元素时,用k个哈希函数计算出k个位置,并将这些位置的比特位设为1
- 查询元素时,同样用k个哈希函数计算出k个位置,如果这些位置的值都为1,则认为元素可能存在;如果有任意一个位为0,则元素一定不存在
优点
- 空间效率高:相比于存储元素本身,布隆过滤器只需要很小的空间
- 查询速度快:O(k)的时间复杂度,k为哈希函数个数
- 没有假阴性:如果布隆过滤器说元素不存在,那它一定不存在
缺点
- 有假阳性:当布隆过滤器说元素存在时,实际上元素可能不存在(有一定的误判率)
- 不能删除元素:标准的布隆过滤器不支持删除操作
- 不能获取已存储元素:无法从布隆过滤器中获取已添加的元素列表
应用场景
- 大数据集合的快速查询:如爬虫URL去重
- 缓存穿透问题:防止查询不存在的数据导致数据库压力过大
- 垃圾邮件过滤:快速判断邮件是否为已知的垃圾邮件
- 黑名单过滤:如网站访问IP黑名单检查
- 分布式系统的去重:如在分布式爬虫中进行URL去重
参数选择
位数组大小(m)和哈希函数个数(k)的选择:
- 当已知预期元素数量n和期望的误判率p时:
- 最优位数组大小:m = -n * ln(p) / (ln(2)^2)
- 最优哈希函数个数:k = ln(2) * m / n
布隆过滤器的变种
- 计数布隆过滤器:支持删除操作,用计数器替代位数组中的单个比特位
- 稳定布隆过滤器:在有限空间内处理流式数据
- 可扩展布隆过滤器:支持动态调整大小
布隆过滤器因其高效的空间使用率和快速的查询能力,在大数据、分布式系统、缓存系统等领域有着广泛的应用
解决-接口层校验
方案描述: 在应用接口层(如 Controller、API 网关)对请求参数进行校验。例如,用户 ID 必须是正整数、订单号必须符合特定格式等。对于不符合基本规则的请求,可以直接拦截,避免后续的缓存和数据库查询
优点:
- 实现简单,在请求入口处即可拦截非法请求
- 可以根据业务规则进行定制,非常灵活
- 几乎没有额外的存储开销
缺点:
- 只能拦截 格式明显不符 的非法请求。对于那些格式合法但数据库中确实不存在的 key(例如查询一个 ID 为 9999999 但实际不存在的用户),这种方法无效
- 适用范围有限,通常作为一种辅助手段
总结:
接口层校验 作为第一道防线,可以拦截明显非法的请求,但无法解决根本问题
缓存空值 简单易行,适合应对少量、重复的不存在 key 查询,但对大量不同 key 的攻击效果有限且有数据一致性风险
布隆过滤器 效果最好,能大幅减少无效查询,但实现稍复杂且有误判率和不支持删除的问题。它是应对缓存穿透的主流方案之一
在实际应用中,通常会将这些方案结合使用,比如同时使用接口层校验和布隆过滤器,或者接口层校验和缓存空值
缓存击穿
缓存击穿 指的是某一个热点 Key 在缓存中正好失效的瞬间,同时有大量并发请求访问这个 Key
由于缓存已失效,这些并发请求会全部穿透缓存,直接打到后端的数据库上,导致数据库压力瞬间剧增,甚至可能宕机
这和缓存穿透(查询不存在的数据)以及缓存雪崩(大量 Key 同时失效)有所不同,缓存击穿特指单个热点 Key 失效引发的问题
常见的解决方案有:
使用互斥锁 (Mutex Lock) / 分布式锁 (Distributed Lock)
方案描述: 当缓存未命中时(特别是针对热点 Key),并不是让所有请求都去查询数据库。而是先尝试获取一个锁(单机环境用
synchronized
或ReentrantLock
,分布式环境用 Redis 的SETNX
、Redisson 或 ZooKeeper 实现的分布式锁)- 只有成功获取到锁的那个请求(线程)才去查询数据库,并将查询结果写回缓存
- 其他未获取到锁的请求则可以选择等待一小段时间后重试(再次访问缓存),或者直接返回一个默认值/稍旧的数据(如果业务允许)
- 查询数据库并写回缓存的线程,在操作完成后需要释放锁
优点:
- 强一致性保证: 只允许一个请求访问数据库重建缓存,有效防止了大量请求同时冲击数据库
- 思路清晰,实现相对直接
缺点:
- 性能开销: 需要引入锁机制,无论是本地锁还是分布式锁,都会带来额外的性能开销和复杂度
- 阻塞等待: 未获取到锁的线程需要等待,增加了请求的响应时间。如果获取锁的线程执行数据库查询和缓存写入操作较慢,会影响吞吐量
- 死锁风险: 如果锁实现不当,可能存在死锁风险(尤其在分布式锁场景下,需要考虑锁超时、续约等问题)
image
设置"永不过期" / 逻辑过期 (Logical Expiration)
方案描述: 对于热点 Key,不设置物理上的过期时间 (TTL),或者设置一个非常长的 TTL。而是在缓存值中额外存储一个逻辑过期时间字段
当请求访问缓存时,获取到数据后,判断其逻辑过期时间是否已到
如果未过期: 直接返回数据
如果已逻辑过期:
- 不立即删除缓存
- 尝试获取一个锁(可以是轻量级的,甚至是非阻塞的尝试锁)
- 如果获取锁成功: 开启一个异步线程去执行数据库查询,更新缓存中的数据和新的逻辑过期时间。当前请求则仍然返回旧的(逻辑过期的)数据
- 如果获取锁失败: 说明已有其他线程在负责更新缓存,当前请求也直接返回旧的(逻辑过期的)数据
优点:
- 高可用性: 通过返回旧数据,避免了请求的长时间等待,保证了服务的可用性,用户体验较好
- 避免数据库冲击: 异步更新机制使得只有一个线程(或少数几个线程)会去访问数据库,有效保护了数据库
- 无锁等待: 大部分请求读取缓存时无需等待锁
缺点:
- 数据不一致: 在逻辑过期到缓存被异步更新完成的这段时间内,返回的是旧数据,存在短暂的数据不一致性。业务需要能容忍这种不一致
- 实现复杂度: 需要在缓存结构中增加逻辑时间戳,并实现异步刷新缓存的机制(如线程池、消息队列等),增加了系统的复杂度
- 需要额外的代码逻辑来处理“逻辑过期”的判断和异步更新的发起
image
总结:
- 互斥锁/分布式锁 方案更侧重于数据一致性,但会牺牲一定的可用性(等待)和性能(锁开销)
- 逻辑过期 方案更侧重于高可用性,能有效避免用户等待,但需要接受一定程度的数据不一致性,并且实现相对复杂
缓存雪崩
缓存雪崩 (Cache Avalanche) 是指在某个时间段内,缓存系统 大面积不可用 或者 大量缓存 Key 在同一时间集中过期失效,导致所有或大部分的请求都无法命中缓存,瞬间将巨大的流量直接打到后端的数据库上,造成数据库压力剧增,响应变慢,甚至宕机,从而引起整个系统应用的连锁反应,如同雪崩一样崩溃
缓存雪崩主要有两种触发情况:
- 缓存服务宕机或网络故障: 缓存中间件(如 Redis 集群)整体发生故障,无法提供服务
- 大量 Key 同时过期: 比如在系统启动预热时,将大量数据同时加载到缓存并设置了相同的过期时间;或者某些热点数据设置了相同的过期时间,在某一刻同时失效
针对缓存雪崩,常见的解决方案有以下几种:
过期时间添加随机值 (Randomize Expiration Time)
方案描述: 在设置缓存 Key 的过期时间 TTL 时,不是设定一个固定的值,而是在基础 TTL 上增加一个随机的时间偏移量(例如,
TTL = BaseTTL + random(0, 300)
秒)。这样可以避免大量 Key 在完全相同的时间点过期,将过期时间分散到一段时间内优点:
- 实现简单,对原有代码改动小
- 能有效避免因 Key 集中过期导致的雪崩
- 对缓存性能几乎无影响
缺点:
- 只是将过期时间点打散,并不能完全避免在某个较短时间窗口内仍有较多 Key 过期的情况(但已大大缓解)
- 无法解决因缓存服务本身宕机导致的雪崩
构建高可用的缓存集群 (High Availability Cache Cluster)
方案描述: 使用缓存中间件自身提供的高可用方案,如 Redis Sentinel(哨兵模式,主从复制+自动故障转移)或 Redis Cluster(分布式集群,数据分片+主从复制)。确保即使部分缓存节点宕机,整个缓存服务仍然可用
优点:
- 从根本上提高了缓存服务的可用性,能有效防止因单点故障或部分节点故障导致的雪崩
- 是生产环境中标准的缓存部署方式
缺点:
- 部署和维护复杂度增加,需要更多的硬件资源
- 无法解决因大量 Key 同时过期导致的雪崩
- 如果整个集群(例如所有主节点)都出现问题,仍然会发生雪崩
服务降级与熔断 (Service Degradation and Circuit Breaking)
方案描述: 在应用层面加入降级和熔断机制
- 降级: 当检测到缓存失效且数据库压力过大时,暂时牺牲一部分非核心功能或数据,让请求直接返回一个预设的默认值、静态页面、或者一个友好的错误提示,保证核心服务的可用性
- 熔断: 设置访问数据库的超时时间和错误率阈值。当请求数据库的失败率或超时次数达到阈值时,暂时“熔断”对数据库的访问,后续一段时间内的请求不再访问数据库,直接快速失败或执行降级逻辑,防止数据库被彻底压垮。待一段时间后(或半开启状态探测成功后)再恢复对数据库的调用。可以使用 Hystrix、Sentinel、Resilience4j 等熔断组件
- 限流 (Rate Limiting): 在网关层或应用层限制访问数据库的并发数或速率,避免瞬间涌入过多请求
优点:
- 是保护后端数据源(如数据库)的最后一道防线,无论雪崩原因是什么,都能在一定程度上保护下游系统
- 提高了系统的整体健壮性和容错能力
缺点:
- 会影响用户体验,因为部分请求会被拒绝或返回非实时/非精确数据
- 实现相对复杂,需要引入额外的组件和配置,并仔细调整降级策略和熔断阈值
添加多级缓存
- Guava 或 Caffeine,redis 作为二级缓存
缓存预热与后台续期 (Cache Warm-up and Background Refresh)
方案描述:
- 预热: 系统启动时,提前将热点数据加载到缓存中,并设置不同的过期时间(结合随机值)
- 后台续期/逻辑过期: 类似于解决缓存击穿的逻辑过期方案,对于热点数据不设置物理过期时间或设置很长,通过后台线程检测并异步更新,避免集中失效
优点:
- 预热可以减少系统启动初期的缓存穿透
- 后台续期可以避免热点 Key 集中失效,减少对数据库的冲击
缺点:
- 预热需要识别热点数据,并可能增加启动时间
- 后台续期实现复杂,可能存在短暂数据不一致
总结:
- 随机化过期时间 是最简单有效的防止 Key 集中过期的手段
- 高可用缓存集群 是保障缓存服务自身稳定性的基础
- 服务降级与熔断/限流 是保护后端系统、提高整体容错性的重要补充手段
- 缓存预热与后台续期 可以作为优化手段,减少冷启动和热点失效的影响
在实际生产环境中,通常会将以上多种方案组合使用,例如:部署高可用的 Redis Cluster + 给 Key 设置随机过期时间 + 在应用层配置合理的熔断、降级和限流策略,以最大限度地防止和减轻缓存雪崩带来的影响
如何构建高可用的缓存集群(llm)
好的,面试官您好。构建高可用的缓存集群主要是为了消除单点故障,确保即使部分缓存节点出现问题,整个缓存服务依然能够持续对外提供服务,从而有效防止因缓存服务宕机引发的缓存雪崩。以业界最常用的 Redis 为例,主要有两种主流的高可用集群方案:Redis Sentinel (哨兵模式) 和 Redis Cluster (集群模式)
1. Redis Sentinel (哨兵模式)
架构:
- 由一个 主节点 (Master) 和一个或多个 从节点 (Slave) 组成
- 写入操作只在主节点进行,然后主节点将数据变更同步给所有从节点(主从复制)
- 读取操作可以在主节点或从节点进行(取决于配置,可以分担读压力)
- 独立运行一组 哨兵 (Sentinel) 进程(通常建议至少 3 个哨兵节点以形成多数派,避免脑裂)。哨兵负责监控主从节点的状态
工作原理与高可用机制:
监控 (Monitoring): 哨兵进程会定期向所有主从节点发送 PING 命令,检测它们是否在线
故障检测 (Failure Detection): 如果一个主节点在规定时间内没有响应哨兵的 PING 命令(主观下线),哨兵会询问其他哨兵节点对该主节点的看法。如果足够数量的哨兵(达到法定数量 Quorum)都认为该主节点下线了,则标记其为客观下线
自动故障转移 (Automatic Failover):
- 当主节点被确认客观下线后,哨兵们会选举出一个领头哨兵 (Leader Sentinel)
- 领头哨兵负责执行故障转移:从该主节点下属的从节点中,按照一定规则(如优先级、复制偏移量、运行 ID 等)挑选一个最优的从节点
- 领头哨兵向被选中的从节点发送
SLAVEOF no one
命令,使其升级为新的主节点 - 然后,领头哨兵通知其他从节点,让它们转而复制新的主节点 (
SLAVEOF new_master_ip new_master_port
) - 同时,哨兵会更新内部记录的主节点信息
配置提供者 (Configuration Provider): 客户端连接 Redis 时,不是直接连接主节点地址,而是连接哨兵集群。哨兵会告知客户端当前主节点的地址。当发生故障转移后,哨兵会通知客户端新的主节点地址
优点:
- 实现了主节点的自动故障检测和转移,提高了可用性
- 架构相对 Redis Cluster 简单一些,易于理解和部署
- 客户端可以通过哨兵获取最新的主节点信息,对故障转移相对透明
缺点:
- 写性能瓶颈: 只有一个主节点处理写请求,写操作的吞吐量受限于单个主节点的性能
- 内存容量瓶颈: 整个数据集必须能存储在单个主节点的内存中
- 故障转移有短暂中断: 从主节点宕机到哨兵完成选举、提升新主、通知客户端,期间会有一小段时间服务不可用(通常是秒级)
- 哨兵本身也需要保证高可用,需要部署多个哨兵实例
2. Redis Cluster (集群模式)
架构:
- 采用去中心化的设计,没有像 Sentinel 那样的中心协调节点
- 数据通过分片 (Sharding) 的方式存储在多个主节点上。Redis Cluster 默认将整个数据集划分为 16384 个哈希槽 (Hash Slot)
- 每个主节点负责处理一部分哈希槽。例如,3 个主节点的集群,可能节点 A 负责 0-5460,节点 B 负责 5461-10922,节点 C 负责 10923-16383
- 每个主节点可以配置一个或多个从节点,用于备份主节点的数据和在主节点故障时进行替换(主从复制)
- 节点之间通过 Gossip 协议相互通信,交换状态信息(如节点存活、槽位分布等)
工作原理与高可用机制:
数据路由: 客户端连接集群中的任意一个节点。当客户端要操作某个 Key 时,会根据
CRC16(key) % 16384
计算出该 Key 属于哪个哈希槽- 如果该槽正好由当前连接的节点负责,则直接处理
- 如果该槽不由当前节点负责,节点会返回一个
MOVED
重定向错误,告诉客户端这个槽由哪个节点负责 (MOVED slot_number target_node_ip:port
) - Cluster-aware 的客户端(如 Jedis Cluster, Redisson)会自动处理
MOVED
指令,重新向正确的节点发送请求,并可能缓存槽位映射关系以优化后续请求
故障检测: 节点间通过 Gossip 协议 PING/PONG 消息检测其他节点状态。如果一个节点发现另一个节点长时间失联(
PFAIL
状态),它会通过 Gossip 协议询问其他节点,如果多数主节点都认为目标节点失联,则标记其为FAIL
状态(客观下线)自动故障转移:
- 当一个主节点被标记为
FAIL
后,其下属的从节点会等待一小段时间(与主节点失联时间、自身数据复制偏移量有关),然后尝试发起故障转移选举 - 它会向集群中其他所有主节点发送请求,请求投票
- 如果一个从节点获得了超过半数主节点的投票,它就赢得选举,升级为新的主节点
- 新主节点接管原主节点负责的哈希槽,并通过 Gossip 协议通知集群其他节点更新槽位信息
- 当一个主节点被标记为
优点:
- 水平扩展能力强: 可以通过增加主节点来扩展集群的存储容量和写吞吐量,突破单机瓶颈
- 高可用: 去中心化架构,部分主节点宕机(及其从节点也宕机)不会影响其他主节点负责的槽位的服务。每个分片内部实现了自动故障转移
- 分布式: 数据分散存储,负载更均衡
缺点:
- 实现更复杂: 部署、运维相对 Sentinel 更复杂
- 客户端要求: 客户端需要支持 Redis Cluster 协议(能处理
MOVED
和ASK
重定向) - 批量操作限制: 涉及多个 Key 的操作(如 MSET, MGET, 事务, Lua 脚本)如果这些 Key 分布在不同的槽位(即不同的主节点),则无法直接执行或需要特殊处理(如按节点分组执行),增加了使用的复杂性
- Gossip 协议信息交换有一定延迟
总结与选择:
- Redis Sentinel: 适合数据量不大(能放在单机内存)、写压力不是极端高的场景,对部署和运维复杂度要求相对较低的情况。它提供了基础的主从复制和自动故障转移能力
- Redis Cluster: 适合数据量巨大、需要水平扩展存储和写性能的场景。它提供了更彻底的分布式和高可用方案,但复杂性也更高
在构建高可用缓存集群时,除了选择合适的 Redis 模式,还需要考虑:
- 网络稳定性: 节点间的网络通信是高可用的基础
- 硬件资源: 足够的内存、CPU 和网络带宽
- 客户端配置: 客户端需要正确配置连接哨兵或集群节点,并能处理故障转移
- 监控与告警: 完善的监控体系能及时发现集群问题
- 云服务: 如果使用云服务商(如 AWS ElastiCache, Azure Cache for Redis, GCP Memorystore),它们通常提供了封装好的高可用选项,可以简化部署和管理
双写一致性(mysql 的数据如何与 redis 进行同步)
前提:介绍业务背景
一致性要求高 (Consistency, CP)
允许延迟一致 (Availability, AP)
双写一致性:指的是在更新数据时,同时更新数据库(如 MySQL)和缓存(如 Redis)这两个数据源,并保证它们之间数据状态最终一致的问题。这是一个在分布式系统中非常常见且重要的问题,因为直接进行双写操作很容易因为各种原因(如网络延迟、某个系统临时故障、并发冲突等)导致数据不一致
延迟双删

- 读操作:缓存命中,直接返回;缓存未命中查询数据库,写入缓存,设定 TTL
- 写操作:延迟双删

先删除缓存,再操作数据库
数据库值为 10
- 正常情况

- 特殊情况

先修改数据库,在删除缓存
- 正常情况

- 特殊情况

所以要删除两次缓存,减少脏数据出现的风险
为什么要延迟删除:因为数据库是主从模式,读写分离,需要等待主节点同步数据到从节点。但延迟时间不好控制,还是有脏数据的风险,做不到强一致性
互斥锁/分布式锁
使用读写锁,读加共享锁,写加排他锁
强一致
性能低

异步通知保证数据的最终一致性
消息队列

Canal

数据过期(删除)策略
Redis的过期删除策略:惰性删除+定期删除两种策略进行配合使用
Redis 内部使用一个字典(dict)记录所有设置了过期时间的键,用以定期检查过期情况
如何设置过期时间:
EXPIRE key seconds # 设置key在seconds秒后过期
EXPIREAT key timestamp # 设置key在指定的Unix时间戳过期
PEXPIRE key milliseconds # 以毫秒为单位设置过期时间
TTL key # 查看key剩余的过期时间(秒)
PERSIST key # 移除key的过期时间
惰性删除(Lazy Deletion)
工作原理:
- Redis 不主动删除过期的键
- 仅在访问键时检查其是否过期
- 如果过期,则删除并返回空;未过期则正常返回
优点:
- 节省 CPU 资源,只在必要时才检查过期
- 实现简单
缺点:
- 如果大量过期键未被访问,会一直占用内存空间
定期删除(Periodic Deletion)
工作原理:
Redis 会定期(默认每 100ms)随机抽样一部分设置了过期时间的键
删除其中已过期的键
具体流程:
- 随机测试 20 个带过期时间的键
- 删除所有发现的已过期键
- 如果已过期键比例超过 25%,重复步骤 1
两种模式:
SLOW模式是定时任务,执行频率默认为
10hz
,每次不超过25ms
,以通过修改配置文件redis.conf
的hz选项来调整这个次数FAST模式执行频率不固定,但两次间隔不低于
2ms
,每次耗时不超过1ms
优点:
- 通过限制删除操作的时长和频率,减少对 CPU 的影响
- 弥补惰性删除可能导致的内存浪费
缺点:
- 难以确定删除操作执行的频率和时长
- 仍可能存在未被抽中的过期键占用内存
数据淘汰策略/内存淘汰策略(Maxmemory Policies)
当 Redis 内存使用达到 maxmemory
配置的限制时,Redis 会根据 maxmemory-policy
配置使用以下策略选择要删除的键:
1. 处理所有键的策略
- noeviction:不删除任何数据,拒绝写入操作并返回错误(默认策略)
- allkeys-lru:使用 LRU 算法删除最近最少使用的键
- allkeys-lfu:使用 LFU 算法删除访问频率最少的键(Redis 4.0+)
- allkeys-random:随机删除任意键
2. 仅处理设置了过期时间的键的策略
- volatile-lru:使用 LRU 算法删除已设置过期时间的最近最少使用的键
- volatile-lfu:使用 LFU 算法删除已设置过期时间的访问频率最少的键(Redis 4.0+)
- volatile-random:随机删除已设置过期时间的键
- volatile-ttl:删除剩余过期时间最短的键(即最快要过期的键)
LRU 与 LFU 算法
1. LRU(Least Recently Used)
- 选择最近最少使用的键进行删除
- Redis 采用的是近似 LRU 算法:通过随机采样一小部分键,然后淘汰其中最久未使用的
2. LFU(Least Frequently Used)
选择访问频率最低的键进行删除
Redis 4.0+ 引入,比 LRU 更精准地反映键的热度
包含两个参数控制:
-
lfu-decay-time
:访问频率计数器的衰减时间 -
lfu-log-factor
:计数器对数增长的因子
-
实际应用建议
通用场景:
volatile-lru
是一个安全的默认选择,只淘汰带有过期时间的最少使用键纯缓存场景:
allkeys-lru
或allkeys-lfu
是较好的选择,能最大化缓存命中率对过期键要求严格场景:
volatile-ttl
确保最早过期的键先被删除高可用场景:
noeviction
可以避免意外删除重要数据,但需要确保内存足够混合数据类型场景:Redis 4.0+ 中,
volatile-lfu
或allkeys-lfu
通常比 LRU 策略表现更好使用内存占用检测命令:
-
INFO memory
查看内存使用情况 -
MEMORY USAGE key
查看单个键占用的内存
-
配置示例:
# 设置内存上限为2GB
maxmemory 2gb
# 设置内存淘汰策略
maxmemory-policy volatile-lru
问题 1
数据库有1000万数据,Redis只能缓存20w数据,如何保证Redis中的数据都是热点数据?
使用allkeys-lru(挑选最近最少使用的数据淘汰)淘汰策略,留下来的都是经常访问的热点数据
问题 2
Redis的内存用完了会发生什么?
主要看数据淘汰策略是什么?如果是默认的配置(noeviction),会直接报错
分布式锁
使用场景
抢券
无锁场景

单机加锁

集群分布式锁

实现原理
Redis 实现分布式锁主要利用Redis的setnx
命令
setnx
setnx
是SET if not exists
(如果不存在,则SET)的简写
获取锁:
# 添加锁,NX是互斥,EX是设置超时时间
# 一条命令保证原子性
SET lock value NX EX 10
释放锁:
# 释放锁,删除即可
DEL key
不设置有效期可能会死锁!!!
redisson
底层使用 lua 脚本

实现的是可重入的分布式锁(根据线程 id 标识作为判断依据、同 java 可重入锁)
使用 hash 结构记录线程 id 和重入次数

redisson 分布式锁的主从一致性
主节点宕机后,从节点作为新的主节点,但是之前锁的数据丢失了,导致两个线程同时获得锁

RedLock(红锁):不能只在一个redis实例上创建锁,应该在多个redis实例上创建锁(n/2+1)
,避免在一
个redis实例上加锁(实现复杂、性能差、运维繁琐、不建议使用)

如果非要保证数据强一致性(cp思想)建议使用 zookeeper
======= 集群方案 =======
单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离
一般都是一主多从,主节点负责写数据,从节点负责读数据
主从复制(Master-Slave Replication)
Replication Id(简称replid)
是数据集的标识符,ID相同即表明属于同一数据集。每个master节点都拥有唯一的replid,而slave节点则会继承master节点的replid
Offset(偏移量)
随着记录在repl baklog中的数据量逐渐增多而逐步增加。slave节点在完成同步时,也会记录当前同步的offset。若slave的offset小于master的offset,则表明slave的数据落后于master,需要进行更新
主从全量同步


id 一致,就不会生成 rdb 文件,而是只使用 baklog
主从增量同步(slave 重启或者后期数据变化)


完全同步
完全同步发生在以下几种情况:
- 初次同步:当一个从服务器(slave)首次连接到主服务器(master)时,会进行一次完全同步
- 从服务器数据丢失:如果从服务器数据由于某种原因(如断电)丢失,它会请求进行完全同步
- 主服务器数据发生变化:如果从服务器长时间未与主服务器同步,导致数据差异太大,也可能触发完全同步
主从服务器间的第一次同步的过程可分为三个阶段:
- 第一阶段是建立链接、协商同步
- 第二阶段是主服务器同步数据给从服务器
- 第三阶段是主服务器发送新写操作命令给从服务器

实现过程:
- 从服务器发送SYNC命令:从服务器向主服务器发送
SYNC
命令,请求开始同步 - 主服务器生成RDB快照:接收到
SYNC
命令后,主服务器会保存当前数据集的状态到一个临时文件,这个过程称为RDB(Redis Database)快照 - 传输RDB文件:主服务器将生成的RDB文件发送给从服务器
- 从服务器接收并应用RDB文件:从服务器接收RDB文件后,会清空当前的数据集,并载入RDB文件中的数据
- 主服务器记录写命令:在RDB文件生成和传输期间,主服务器会记录所有接收到的写命令到
replication backlog buffer
- 传输写命令:一旦RDB文件传输完成,主服务器会将
replication backlog buffer
中的命令发送给从服务器,从服务器会执行这些命令,以保证数据的一致性
增量同步
增量同步允许从服务器从断点处继续同步,而不是每次都进行完全同步。它基于PSYNC
命令,使用了运行ID(run ID)和复制偏移量(offset)的概念

主要有三个步骤:
- 从服务器在恢复网络后,会发送 psync 命令给主服务器,此时的 psync 命令里的 offset 参数不是 -1
- 主服务器收到该命令后,然后用 CONTINUE 响应命令告诉从服务器接下来采用增量复制的方式同步数据
- 然后主服务将主从服务器断线期间,所执行的写命令发送给从服务器,然后从服务器执行这些命令
那么关键的问题来了,主服务器怎么知道要将哪些增量数据发送给从服务器呢?
答案藏在这两个东西里:
- repl_backlog_buffer,是一个「环形」缓冲区,用于主从服务器断连后,从中找到差异的数据
- replication offset,标记上面那个缓冲区的同步进度,主从服务器都有各自的偏移量,主服务器使用 master_repl_offset 来记录自己「写」到的位置,从服务器使用 slave_repl_offset 来记录自己「读」到的位置
那 repl_backlog_buffer 缓冲区是什么时候写入的呢?
在主服务器进行命令传播时,不仅会将写命令发送给从服务器,还会将写命令写入到 repl_backlog_buffer 缓冲区里,因此 这个缓冲区里会保存着最近传播的写命令
网络断开后,当从服务器重新连上主服务器时,从服务器会通过 psync 命令将自己的复制偏移量 slave_repl_offset 发送给主服务器,主服务器根据自己的 master_repl_offset 和 slave_repl_offset 之间的差距,然后来决定对从服务器执行哪种同步操作:
- 如果判断出从服务器要读取的数据还在 repl_backlog_buffer 缓冲区里,那么主服务器将采用增量同步的方式
- 相反,如果判断出从服务器要读取的数据已经不存在 repl_backlog_buffer 缓冲区里,那么主服务器将采用全量同步的方式
当主服务器在 repl_backlog_buffer 中找到主从服务器差异(增量)的数据后,就会将增量的数据写入到 replication buffer 缓冲区,这个缓冲区我们前面也提到过,它是缓存将要传播给从服务器的命令

repl_backlog_buffer 缓行缓冲区的默认大小是 1M,并且由于它是一个环形缓冲区,所以当缓冲区写满后,主服务器继续写入的话,就会覆盖之前的数据。因此,当主服务器的写入速度远超于从服务器的读取速度,缓冲区的数据一下就会被覆盖
那么在网络恢复时,如果从服务器想读的数据已经被覆盖了,主服务器就会采用全量同步,这个方式比增量同步的性能损耗要大很多
因此,为了避免在网络恢复时,主服务器频繁地使用全量同步的方式,应该调整下 repl_backlog_buffer 缓冲区大小,尽可能的大一些,减少出现从服务器要读取的数据被覆盖的概率,从而使得主服务器采用增量同步的方式
哨兵模式(Sentinel)
Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复
一般部署至少 3 台哨兵
服务状态监控
监控:Sentinel会不断检查您的master和slave是否按预期工作
自动故障恢复:如果master故障,Sentinels会将一个slave提升为master。当故障实例恢复后也以新的master为主
通知:Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端

Sentine系统依托心跳机制对服务状态进行监测,每秒向集群内各实例发送ping命令:
- 主观下线:若某Sentinel节点发现某实例未在规定时间内响应,则判定该实例为主观下线
- 客观下线:当超过指定数量(quorum)的Sentinel节点均认为该实例为主观下线时,则该实例被视为客观下线。建议quorum值应超过Sentinel实例数量的一半

哨兵选主规则
首先,需评估主从节点断开时间的长短,若超过既定阈值,则淘汰该从节点
过滤故障的节点
其次,根据从节点的slave-priority
值确定优先级,数值越低,优先级越高
若slave-priority
值相同,则依据从节点的offset
值排序,数值越大,优先级越高(数据最完整)
最终,比较从节点的运行ID
大小,数值越小,优先级越高
脑裂
由于网路问题,哨兵无法访问主节点
哨兵会选择从节点作为新的主节点
但是客户端仍然连接主节点向主节点写入数据
网络恢复后,旧的主节点会作为新主节点的从节点,并清空数据与主节点做同步
但这时就造成了数据的丢失

通过两个配置来避免:
min-replicas-to-write 1
表示最少的slave节点为1个(主节点必须要有这么多从节点才能接受数据,否则直接拒绝请求)
min-replicas-max-lag 5
表示数据复制和同步的延迟不能超过5秒
redis主从和集群可以保证数据一致性吗
Redis主从和集群在CAP理论中均属于AP模型。即在遭遇网络分区时,选择保证可用性和分区容错性,而牺牲强一致性。这意味着在网络分区情况下,Redis主从复制和集群可继续提供服务,保持可用,但可能出现部分节点间数据不一致的情况
怎么保证 Redis 的高并发高可用
哨兵模式:实现主从集群的自动故障恢复(监控、自动故障恢复、通知)
使用的 redis 是单点还是集群,哪种集群
主从(1主1从)+哨兵就可以了。单节点不超过10G内存,如果Redis内存不足则可以给不同服务分配独立的Redis主从节点
redis 集群脑裂,如何解决
集群脑裂现象,源于主节点、从节点与sentinel分属不同网络分区,导致sentinel未能感知到主节点的心跳。为此,系统通过选举机制将某一从节点提升为主节点,从而形成两个master节点,如同大脑分裂。此状况将导致客户端持续向旧的主节点写入数据,而新节点因无法同步数据而陷入停滞。网络恢复后,sentinel会将旧主节点降级为从节点,进而从新master节点同步数据,极易引发数据丢失
解决方案:可通过调整Redis配置,设定最低从节点数量及缩短主从数据同步的延迟时间。若条件不满足,则拒绝请求,以此减少数据丢失的风险
分片集群 Redis Cluster(官方集群方案)
主从与哨兵机制能够有效应对高可用、高并发读的需求。然而,仍存在以下两个尚未解决的问题:
- 海量数据存储问题
- 高并发写问题
运用分片集群技术,可解决上述难题。分片集群具备以下特点:
集群中包含多个主节点(master),每个主节点存储着不同的数据
每个主节点均可配备多个从节点(slave)
主节点间通过ping命令监控彼此的健康状况
客户端请求可访问集群中的任意节点,请求最终都会被导向至相应的正确节点
数据读写
Redis分片集群引入了哈希槽这一概念。该集群共设有16384个哈希槽。每个键值通过CRC16校验,并对此16384进行取模运算,以确定其应放置的槽位。集群中的每个节点负责管理一部分哈希槽

redis 分片集群的作用
集群内分布着多个Master
节点,每个Master
负责存储独特的数据。
每个Master
节点均可配备多个Slave
节点。
Master
节点间通过ping
命令监测彼此的健康状况。
客户端的请求能够访问集群中的任何节点,并最终被导向至正确的处理节点
redis 分片集群中数据是怎么存储和读取的
Redis分片集群引入了哈希槽的概念,Redis集群包含16384个哈希槽。将16384个插槽分配至不同的实例。读写数据时,依据key的有效部分计算哈希值,对16384取余。有效部分,若key前有大括号,则大括号内的内容即为有效部分;若没有,则以key本身作为有效部分。余数作为插槽,进而寻找插槽所在的实例
======= 场景 =======
为什么用 redis
Redis 具备「高性能」和「高并发」两种特性
本地缓存与Redis缓存的区别?
本地缓存是指将数据存储在本地应用程序或服务器上,通常用于加速数据访问和提高响应速度。本地缓存通常使用内存作为存储介质,利用内存的高速读写特性来提升数据访问速度
本地缓存的优势:
- 访问速度快:由于本地缓存存储在本地内存中,因此访问速度极快,能够满足频繁访问和即时响应的需求
- 减轻网络压力:本地缓存能够减少对远程服务器的访问次数,从而减轻网络压力,提升系统的可用性和稳定性
- 低延迟:由于本地缓存位于本地设备上,因此能够提供低延迟的访问速度,适用于对实时性要求较高的应用场景
本地缓存的不足:
- 可扩展性有限:本地缓存的可扩展性受到硬件资源的限制,无法支持大规模的数据存储和访问
分布式缓存(Redis)是指将数据存储在多个分布式节点上,通过协同工作来提供高性能的数据访问服务。分布式缓存通常采用集群方式进行部署,利用多台服务器来分担数据存储和访问的压力
分布式缓存的优势:
- 可扩展性强:分布式缓存的节点可以动态扩展,能够满足大规模的数据存储和访问需求
- 数据一致性高:通过分布式一致性协议,分布式缓存能够确保数据在多个节点之间的一致性,减少数据不一致的问题
- 易于维护:分布式缓存通常采用自动化管理方式,能够降低维护成本和管理的复杂性
分布式缓存的不足:
- 访问速度相对较慢:相对于本地缓存,分布式缓存的访问速度相对较慢,因为数据需要从多个节点进行访问和协同
- 网络开销大:由于分布式缓存需要通过网络进行数据传输和协同操作,因此相对于本地缓存来说,网络开销较大
在选择使用本地缓存还是分布式缓存时,需根据具体的应用场景和需求进行权衡。以下是一些考虑因素:
- 数据大小:若数据量较小,且对实时性要求较高,本地缓存更适合;若数据量较大,且需要支持大规模的并发访问,分布式缓存更具优势
- 网络状况:若网络状况良好且稳定,分布式缓存能够更好地发挥其优势;若网络状况较差或不稳定,本地缓存的访问速度和稳定性可能更有优势
- 业务特点:对于实时性要求较高或数据一致性要求严格的应用,分布式缓存可能是更佳选择
redis 大 key 问题
是什么
Redis大key问题指的是某个key对应的value值所占的内存空间比较大,导致Redis的性能下降、内存不足、数据不均衡以及主从同步延迟等问题
没有固定的判别标准,通常认为字符串类型的key对应的value值占用空间大于1M,或者集合类型的k元素数量超过1万个,就算是大key
Redis大key问题的定义及评判准则并非一成不变,而应根据Redis的实际运用以及业务需求来综合评估
例如,在高并发且低延迟的场景中,仅10kb可能就已构成大key;然而在低并发、高容量的环境下,大key的界限可能在100kb
缺点
- 内存占用过高:大Key占用过多内存空间,可能导致可用内存不足,从而触发内存淘汰策略。在极端情况下,可能导致内存耗尽,Redis实例崩溃,影响系统稳定性
- 性能下降:大Key会占用大量内存空间,导致内存碎片增加,进而影响Redis性能。对于大Key的操作,如读取、写入、删除等,都会消耗更多CPU时间和内存资源,进一步降低系统性能
- 阻塞其他操作:某些对大Key的操作可能会导致Redis实例阻塞。例如,使用DEL命令删除一个大Key时,可能会导致Redis实例在一段时间内无法响应其他客户端请求,从而影响系统响应时间和吞吐量
- 网络拥塞:每次获取大Key产生的网络流量较大,可能造成机器或局域网带宽被打满,同时波及其他服务。例如:一个大Key占用空间1MB,每秒访问1000次,就有1000MB流量
- 主从同步延迟:当Redis实例配置了主从同步时,大Key可能导致主从同步延迟。由于大Key占用较多内存,同步过程中需要传输大量数据,这会导致主从之间的网络传输延迟增加,进而影响数据一致性
- 数据倾斜:在Redis集群模式中,某个数据分片的内存使用率远超其他数据分片,无法使数据分片的内存资源达到均衡。另外也可能造成Redis内存达到maxmemory参数定义的上限,导致重要的key被逐出,甚至引发内存溢出
解决
- 对大Key进行拆分。例如,将包含数万成员的一个HASH Key拆分为多个HASH Key,并确保每个Key的成员数量在合理范围内。在Redis集群架构中,拆分大Key能对数据分片间的内存平衡起到显著作用
- 对大Key进行清理。将不适用于Redis能力的数据存储至其他存储,并在Redis中删除此类数据。注意,应使用异步删除
- 监控Redis的内存水位。可以通过设置监控系统,设定合理的Redis内存报警阈值进行提醒,例如Redis内存使用率超过70%、Redis的内存在1小时内增长率超过20%等
- 对过期数据进行定期清理。堆积大量过期数据会造成大Key的产生,例如在HASH数据类型中以增量的形式不断写入大量数据而忽略了数据的时效性。可以通过定时任务的方式对失效数据进行清理
redis 热 key
是什么
通常以接收到的Key的请求频率来判定,例如:
- QPS集中在特定的Key:Redis实例的总QPS(每秒查询率)为10,000,而其中一个Key的每秒访问量达到了7,000
- 带宽使用率集中在特定的Key:对一个拥有上千个成员且总大小为1 MB的HASH Key每秒发送大量的
HGETALL
操作请求 - CPU使用时间占比集中在特定的Key:对一个拥有数万个成员的Key(ZSET类型)每秒发送大量的
ZRANGE
操作请求
如何解决
- 在Redis集群架构中,针对热Key进行复制。由于热Key的迁移粒度问题,无法将请求分散至其他数据分片,导致单个数据分片的压力无法降低。此时,可以将对应热Key进行复制并迁移至其他数据分片。例如,将热Key
foo
复制出三个内容完全一样的Key,分别命名为foo2
、foo3
、foo4
,并将这三个Key迁移到其他数据分片,以此缓解单个数据分片的热Key压力 - 采用读写分离架构。若热Key的产生源于读请求,可改造实例为读写分离架构以减轻每个数据分片的读请求压力,并持续增加从节点。然而,读写分离架构在增加业务代码复杂度的同时,也提升了Redis集群架构的复杂度。不仅需要为多个从节点提供转发层(如Proxy、LVS等)以实现负载均衡,还需考虑从节点数量显著增加后故障率上升的问题。Redis集群架构的变更将为监控、运维、故障处理带来更大的挑战
秒杀场景下高并发处理及超卖问题解决策略
数据库层面解决方案
- 在查询商品库存时加入排他锁,执行以下SQL语句:
SELECT * FROM goods WHERE goods_id = ? FOR UPDATE
线程A通过执行SELECT * FROM goods WHERE goods_id = ? FOR UPDATE
语句对特定goods_id
的数据行加锁。此时,其他线程可以使用SELECT语句读取数据,但如果也使用SELECT FOR UPDATE语句加锁或进行UPDATE、DELETE操作,则会阻塞,直到线程A的事务提交(或回滚),其他线程中的某个线程才能获取到锁
- 更新数据库以减少库存时,设置库存限制条件:
UPDATE goods SET stock = stock - 1 WHERE goods_id = ? AND stock > 0
此方案通过数据库加锁解决,但在高并发情况下,性能不佳,且可能因数据库连接获取失败或超时等待而报错
分布式锁应用
同一锁key同一时间只能被一个客户端获取,其他客户端会无限等待以尝试获取该锁。只有获取到锁的客户端才能执行后续的业务逻辑
该方案的缺点是,在多用户同时下单同一商品的情况下,会基于分布式锁串行化处理,导致无法同时处理大量下单请求
分布式锁与分段缓存结合
将数据分成多个段,每个段是一个单独的锁。因此,在多个线程并发修改数据时,可以并发修改不同段的数据
假设场景:假设商品有100个库存,在Redis中存放5个库存key,形如:
key1=goods-01,value=20;
key2=goods-02,value=20;
key3=goods-03,value=20
用户下单时,根据用户ID进行%5计算,确定落在哪个Redis的key上,然后获取该key对应的库存。这样每次可以处理5个并发请求
此方案可解决多用户同时下单同一商品的问题,但存在一个挑战:当某段锁的库存不足时,需要实现自动释放锁,并尝试获取下一个分段的库存。这种方案较为复杂
利用Redis的incr、decr原子性 + 异步队列
实现思路:
- 1、系统初始化时,将商品库存数量加载到Redis缓存中
- 2、接收到秒杀请求时,在Redis中进行预减库存(利用Redis的decr原子性)。若Redis中的库存不足,则直接返回秒杀失败;否则继续进行下一步
- 3、将请求放入异步队列中,返回正在排队信息
- 4、服务端异步队列处理请求,判断哪些请求可以出队(根据业务逻辑,如判断用户是否已秒杀过该商品,防止重复秒杀)。出队成功的请求可生成秒杀订单,并减少数据库库存(扣减库存的SQL如下,返回秒杀订单详情):
UPDATE goods SET stock = stock - 1 WHERE goods_id = ? AND stock > 0
- 5、用户在客户端提交秒杀请求后,进行轮询,检查是否秒杀成功。若秒杀成功,则进入秒杀订单详情;否则秒杀失败