1. Redis主从复制的原理
【主从复制的原理】
- 同步:从节点向主节点发送
psync
命令进行同步,从节点保存主节点返回的runid
和offset
- 全量复制:如果是第一次连接或者连接失败且
repl_backlog_buffer
缓存区不包含slave_repl_offset
, 则生成主节点的数据快照(RDB文件)发给从节点 - 增量复制:全量复制完毕后,主从节点之间会保持长连接。如果连接没有断开或者
slave_repl_offset
仍然在repl_backlog_buffer
中,则将后续的写操作传递给从节点,让数据保持一致。
【全量复制细节】
全量复制的过程是基于TCP长连接的,主要流程如下
- 从节点发送
psync ? -1
表示需要建立连接进行同步,主节点返回主节点IDrunid
和 复制进度offset
(第一次同步用 -1 表示)。从节点接受之后,保存主节点的信息。 - 主节点执行
bgsave
命令生成数据快照RDB文件,然后将RDB文件发送给从节点。从节点接受文件后,清除现有的所有数据,然后加载RDB文件 - 如果在制作数据快照RDB文件的过程当中,主节点接收到了新的写操作,主节点会将其记录在
repl buffer
里面。然后将repl buffer
当中的写操作发给从节点,让其数据保持一致。
【增量复制细节】
如果主从节点意外断开连接,为了保持数据的一致性,必须重新同步数据。如果使用全量复制来保持一致性的话,开销太大,所以采用增量复制。
增量复制的具体流程如下:
- 连接恢复后,从节点会发送
psync {runid} {offset}
, 其中主节点IDrunid
和 复制进度offset
用于标识是哪一个服务器主机和复制进度。 - 主节点收到
psync
命令之后,会用conitnue
响应告知从节点,采用增量复制同步数据 - 最后,主节点根据
offset
查找对应的进度,将断线期间未同步的写命令,发送给从节点。同时,主节点将所有的写命令写入repl_backlog_buffer
, 用于后续判断是采用增量复制还是全量复制。
【注意】从节点 psync
携带的 offset
为 slave_repl_offset
。如果 repl_backlog_buffer
包含slave_repl_offset
对应的部分,则采用增量复制,否则采用全量复制。repl_backlog_buffer
的默认缓冲区大小为1M
【为什么要主从复制】
- 备份数据:主从复制实现了数据的热备份,是持久化之外的数据冗余方式
- 故障恢复:当主节点宕机之后,可以采用从节点提供服务。
- 负载均衡: 主从复制实现了读写分离,只有主节点支持读写操作,从节点只有读操作。在读多写少的场景下,可以提高Redis服务器的并发量。
2. Redis集群的实现原理是什么?
【Redis集群基本知识】
- 定义: Redis集群由多个实例组成,每个实例存储部分数据 (每个实例之间的数据不重复) 。
【注】集群和主从节点不是一个东西,集群的某一个实例当中可能包含一个主节点 + 多个从节点
- 为什么用
问题 | 解决方案 |
---|---|
容量不足 | 数据分片,将数据分散不存到不同的主节点 |
高并发写入 | 数据分片,将写入请求分摊到多个主节点 |
主机宕机问题 | 自动切换主从节点,避免影响服务, 不需要手动修改客户端配置 |
- 节点通信协议:Redis集群采用Gossip协议, 支持分布式信息传播、延迟低、效率高。采用去中心化思想,任意实例(主节点)都可以作为请求入口,节点间相互通信。
- 分片原理: 采用哈希槽(Hash Slot)机制来分配数据,整个空间可以划分为16384 (16 * 1024)个槽。 每个Redis负责一定范围的哈希槽,数据的key经过哈希函数计算之后对16384取余可定位到对应的节点。
【集群节点之间的交互协议】
- 为什么用Gossip协议
- 分布式信息传播:每个节点定期向其他节点传播状态信息,确保所有节点对集群的状态有一致视图 (采用
ping
发送 和pong
接受,就像检查心跳一样 ) - 低延迟、高效率:轻量级通信方式,传递信息很快
- 去中心化:没有中心节点,任意实例(主节点)都可以作为请求入口,节点间相互通信。
- Gossip协议工作原理
- 状态报告和信息更新:特定时间间隔内,向随机的其他节点报告自身情况 (主从关系、槽位分布)。其他节点接收到之后,会相应的更新对应的节点状态信息
- 节点检测:通过周期性交换状态信息,可以检测到其他节点的存活状态。预定时间内未响应,则标记为故障节点。
- 容错处理:如果某个节点故障之后,集群中的其他节点可以重新分配槽位,保持系统的可用性
【哈希槽的相关机制】
假定集群中有三个节点,Node1 (0 - 5460)、Node2(5461-10922)、Node3(10923-16383)
集群使用哈希槽的流程如下:
- 计算哈希槽
- 使用CRC16哈希算法计算
user:0001
的CRC16的值 - 将CRC16的值对16384进行取余 (哈希槽 = CRC16 % 16383)
- 假如CRC16为12345,哈希槽 = 12345 % 16383 = 12345
- 确定目标节点 :查询到12345为Node3的存储的键,向该节点发送请求
- 当前非对应节点 :假设当前连接的节点为Node1,Node1将返回
MOVED
错误到客户端,并让客户端根据MOVED
携带的Node3的信息(ip
和端口)重新进行连接,最后从新发送GET user:0001
请求,获得结果。
3. Redis的哨兵机制(Sentinel)是什么?
【哨兵作用】
- 监控:哨兵不断监控主从节点的运行状态,定时发送ping进行检测
- 故障转移: 当主节点发生故障时, 哨兵会先踢出所有失效的节点, 然后选择一个有效的从节点作为新的主节点, 并通知客户端更新主节点的地址
- 通知: 哨兵可以发送服务各个节点的状态通知,方便观察Redis实例的状态变化。(比如主节点g了,已经更换为新的主节点)
【哨兵机制的主观下线和客观下线】
-
主观下线:哨兵在监控的过程中,每隔1s会发送
ping
命令给所有的节点。如果哨兵超过down-after-milliseconds
所配置的时间,没有收到pong
的响应,就会认为节点主观下线。 -
客观下线:某个哨兵发现节点主线下线后,不能确认节点是否真的下线了(可能是网络不稳定),就询问其他的哨兵是否主观下线了。等待其他哨兵的确认,进行投票,如果超过半数+1 (总哨兵数/2 + 1),就认定为客观下线。
【注】客观下线只对主节点适用,因为从节点也没必要这样子判断,g了就g了呗。
【哨兵leader如何选举】
哨兵leader是采用分布式算法raft选出来的。具体流程如下:
- 候选人:当哨兵判断为主观下线,则可以当选候选人
- 投票:每个哨兵都可以投票,但是只能投一票。候选者会优先投给自己。
- 选举:选取投票结果半数以上的候选人作为leader (哨兵一般设置为奇数,防止平票)
【主节点如何选举】
哨兵判断主节点客观下线之后,会踢出所有下线的节点,然后从有效的从节点选新的主节点。选取依据如下:
- 优先级:按照从节点的优先级
slave-priority
,优先级的值越小越高。 - 主从复制offset值:如果优先级相同,则判断主从复制的offset值哪一个大,表明其同步的数据越多,优先级就越高。
- 从节点ID:如果上述条件均相同,则选取ID较小的从节点作为主节点。
4. Redis Cluster 集群模式与 Sentinel 哨兵模式的区别是什么?
- Cluster集群模式:集群模式用于对数据进行分片,主要用于解决大数据、高吞吐量的场景。将数据自动分不到多个Redis实例上,支持自动故障转移(如果某个实例失效,集群会自动重写配置和平衡,不需要手动进行调整,因为内置了哨兵逻辑)
- Sentinel哨兵模式: 哨兵模式用于保证主从节点的高可用,读写分离场景。如果主节点宕机,哨兵会将从节点升为主节点。
5. Redis 在生成 RDB 文件时如何处理请求?
首先,Redis生成RDB文件的操作是异步的,由fork
子线程进行,主线程用于处理客户端的请求。下面具体说明生成RDB文件的流程
【生成RDB文件原理】
- 使用
bgsave
命令,开启fork
子线程进行操作 fork
子线程会复制主线程对应的页表(里面包含了需要操作数据的物理地址)- 如果过程中,主线程接收到写命令,需要修改数据。主线程会将对应数据的所在页面复制一份,子线程仍然指向老的页面。(老的数据才叫数据快照)
【注意事项】
RDB处理的时间比较长,过程中会发生大量的磁盘I/O和CPU负载。如果RDB生成的时间过长,并且Redis的写并发高,就可能出现系统抖动的现象,应该选取Redis使用频率较低的时间段生成RDB文件。
[补充] 5. Redis的持久化机制有哪些?
Redis的持久化机制分为三种,RDB
、AOF
和 混合持久化这三种方式。不过 RDB
和 AOF
各有优缺点,所以一般不会单独使用,而是采用混合持久化机制。
持久化方案 | 说明 | 优缺点 | 适用场景 |
---|---|---|---|
RDB 数据快照 | 将内存当中的数据定期保存为dump.rdb , 记录某个时刻的数据快照 |
文件小,性能高,恢复快。但是数据丢失风险高,fork 会阻塞主进程。 |
适合低频备份的场景,比如冷备份,灾难恢复,全量数据加载(主从复制) |
AOF 追加日志 | 将每个写操作记录到日志文件appendonly.aof , 通过重放日志文件恢复数据 |
数据更安全,文件可读性强。但是文件体积大,恢复速度慢,性能开销大 | 适合对持久化实时性要求高的场景,例如金融交易,用户数据保存等。 |
混合持久化 | 结合RDB 和 AOF 的优点,先生成RDB 快照文件,再记录快照之后的写操作到日志文件当中。 |
适合需要快速恢复且尽量保证减少数据丢失的场景,一般用于生产环境 |
下面具体说一下不同持久化机制的执行过程
【RDB 持久化】
- 定时生成
RDB
:Redis定期根据配置触发RDB
快照 (或者主动用bgsave
命令手动触发) fork
子进程: 主进程判断是否有正在执行的子进程,如果有,直接返回。如果没有,则fork
创建一个新的子进程用于持久化数据 (fork
的过程,主进程是阻塞等待的)- 子进程更新
RDB
文件: 子进程将数据异步写入临时RDB
文件,完成后替换旧的RDB
文件。同时发信号给主进程,主进程更新一下RDB
数据快照的统计消息
【注意】 采用bgsave
而 不采用save
命令的原因是,save
命令在生成 RDB
文件的过程中,会阻塞Redis执行其他操作。
那么子进程在生成RDB
临时文件的过程中,如果客户端对Redis发起新的写操作。Redis同样可以处理这些命令, 这种方式就是写时复制技术。
【写时复制技术】
Redis在执行bgsave
命令的时候,会通过fork
创建子进程。为了节约内存,父子进程是共享同一片内存数据的。创建子进程的时候,会复制父进程的也表,但是页表指向的物理内存还是同一个。当客户端向Redis发起新的写操作时,物理内存会被复制一份。子进程仍然指向之前的内存地址 (数据快照),主进程指向复制的物理内存地址,并完成写操作。
【优缺点】
- 优点:写时复制技术可以减少子进程的内存消耗,加快创建速度(
fork
子进程,会阻塞父进程)。由于子进程共享内存当中的数据,创建后可以直接读取主进程中的内存做数据,然后把数据写入RDB
文件。 - 缺点:客户端在写时复制操作的时候,不会把新的数据记录到RDB文件中。如果Redis在生成
RDB
文件后,马上宕机,那么主进程新写入的这些数据都丢失了。另外,如果数据被修改,每次复制的过程都会制造两份内存,内存占用就是之前的两倍了。
【AOF 日志文件生成过程】
AOF
是通过把Redis的每个写操作追加到日志文件 appendonly.aof
实现持久化的方式。Redis每次重启时,会重放日志文件的命令来恢复数据。口诀:先写内存,再写日志, 过大重写。
- 先写内存:每次写操作都会被写入内存的
AOF
缓冲当中 - 再写日志:然后从
AOF
缓冲中同步到磁盘 (三种写回策略) - 过大重写:当
AOF
文件过大的时候,Redis会触发AOF
重写,将冗余命令合并,生成新的AOF
文件
【注意】 先写内存,再存日志可以避免额外的检查开销 (只存执行成功的指令),而且不会阻塞当前操作,指令只想成功后,才将命令记录到AOF
日志文件。但是如果还没写完AOF
文件就宕机了,会导致数据丢失。执行写命令和记录到日志都是主线程操作,可能会造成阻塞风险。
写会策略配置 | 写回时机 | 作用 |
---|---|---|
always |
同步写回 | 每次都fsync 同步数据到磁盘,性能最低。如果写入AOF 文件期间Redis宕机,则无法通过AOF 进行恢复 |
everysec |
每秒写回 | 每秒调用一次fsync 写回磁盘,安全和性能居中,Redis最多丢1s 的数据 |
no |
操作系统决定写回时间 | 性能高,安全性低 |
【AOF重写机制】 当Redis检测到AOF
文件过大的时候,会触发AOF
重写机制
- 创建子进程:Redis通过
BGREWRITEAOF
命令创建一个子进程来进行AOF
重写 - 生成新
AOF
文件:子进程基于当前数据库状态,将每个键的最新值转换为写命令,并写入AOF
文件 - 处理新写入的命令:重写期间,把客户端新的写操作同时追加到现有的
AOF
文件和缓存区的AOF
重写缓冲里面 - 合并新的写入指令:子进程完成
AOF
文件重写之后,需要确保AOF
文件当中的写操作都是最新的。 - 替换旧的
AOF
文件: 最后用新的AOF
文件替换旧的AOF
文件
**【MP-AOF】**Redis 7.0 采用 Multi-Part Append Only File
解决 AOF
当中的内存开销大(AOF
缓存和AOF
重写缓存包含大量重复数据)、CPU开销大(主进程需要耗时将数据写入AOF
重写缓存,然后传给子进程,子进程要耗时把AOF
重写缓存写入新的AOF
文件)、磁盘开销大(同一份数据会被写入两次,一次写入当前AOF
文件,另一次写入新AOF
文件)。其处理过程如下:
将一个AOF
文件拆分成多个文件
- 一个基础文件
basefile
, 代表数据的初始快照 - 一个增量文件
incremental files
,记录自基础文件创建以来的所有写操作, 可以有多个该文件 - 基础文件和增量文会放到一个单独的目录中,并且由一个清单文件
manifest file
进行统一跟踪和管理
该方案可以避免写入多个和AOF
相关的缓存,子进程独立写基础AOF
文件,进程之间无交互,不用切换上下文。
【为什么Redis需要持久化】 Redis是基于内存的数据库,所有数据存储在内存里面。如果Redis发生了宕机事件,内存中的数据就会全部丢失。为了保证数据的安全,Redis采用持久化机制,让数据保存在磁盘中,方便宕机后进行恢复。如果没有持久化机制,Redis需要从数据库(MySQL)当中恢复数据, 可能会出现下面的问题:
- **性能瓶颈 + 恢复缓慢 **:后端数据库无法向Redis一样快速提供数据。如果数据量比较大,恢复就会变得非常缓慢。
- 系统压力:恢复的过程比较久,就会给数据库带来很大压力,影响其他的业务。
6. Redis集群会出现脑裂问题吗?
-
脑裂定义: 在网络分区的情况下,Redis同一个集群的实例当中出现多个主节点,导致数据不一致。
-
脑裂发生的原因:比如当前集群实例是一主+两从的模式,当网络发送分区,分为A区和B区。主节点(原)被分到A区,其他节点和哨兵集群都在B区。哨兵机制无法检测到A区的原主节点, 只能重新选取新的主节点(新)。此时,集群当中就有两个主节点,A区的主节点(原)被写入的新数据不会同步到B区的节点上。会出现数据不一致的情况。
-
如何避免脑裂:
min-slaves-to-write
主节点写操作所要求有效从节点个数、min-slaves-max-lag
从节点的最大延迟。比如min-slaves-to-write = 3
和min-slaves-max-lag = 10
表明需要至少3个延时低于10s的从节点才可以接受写操作。【注意】脑裂并不能够完全避免,比如说在选举主节点的过程中,主节点(原)突然恢复了,然后发现主节点和从节点的延迟都不超过10s,客户端正常在主节点(原)进行写操作。等选举完毕,选出新的主节点,让主节点(原) slaveof 为从节点。选举时间写入的数据会被覆盖,就出现了数据不一致的现象。
7. Redis如何实现分布式锁?
-
分布式锁原理:Redis分布式锁由
set ex nx
和lua
脚本组成,分别对应加锁和解锁操作 -
为什么用
set ex nx
:某个进程对指定key执行set nx
之后, 返回值为1,其他进程想要对相同的key获取锁,会发现key已存在,返回值为0。这样就是实现了上锁的操作。但是,如果A进程上完锁突然挂了,其他进程就永远不可能拿到锁。所以,设置一个ex
过期时间,让其不要一直占用着锁。【注意】
set ex nx
设置value的时候,必须采用唯一值,比如uuid
。 不然可能出现如下情况:- A进程正常申请锁,值设为1。
- A进程上锁后, 执行过程时间比较长, 以至于锁已经过期了, A进程还没执行完.
- 此时,B进程申请锁,值也设为1. 同时,A进程执行完毕, 使用
lua
脚本把锁删除了 - B进程此时还在执行程序,一脸懵逼。(不是,哥们儿,我锁呢?谁偷了我的锁!!!)
-
为什么用
lua
进行解锁:如上述注意事项所说的一样,A进程执行完毕之后, 会删除锁. 假如他们的值都采用了uuid
保证了唯一性。可能会出现下面的情况- A进程先判断key和其值是否为对应的
uuid
,然后再删除锁. - A进程准备删除锁之前, 锁过期了. B进程同时获取了锁
- A进程再删除了该锁 (B进程申请的锁),发生了误删的现象
所以需要用
lua
脚本保证解锁的原子性,就可以避免上述问题 - A进程先判断key和其值是否为对应的
8. Redis的Red Lock是什么?你了解吗?
- Red Lock定义: 一种分布式锁的实现方案,主要用于解决分布式环境中使用Redis分布式锁的安全性问题
- 为什么用Red Lock: 假如我们采用一主+两从+哨兵方式部署Redis,如果有A进程在使用分布式锁的过程当中,主节点发送了主从更换,但是原主节点的锁信息不一定同步到新主节点上。所以当前新主节点可能没有锁信息,此时另外的B进程去获取锁,发现锁没被占,成功拿到锁并执行业务逻辑。此时两个竞争者(A和B进程)会同时操作临界资源,会出现数据不一致的情况。
- Red Lock实现原理 : 假如当前有五个实例,不需要部署从节点和哨兵,只需要主节点。注意当前的五个实例之间没有任何关系,不进行任何的信息交互 (不同于Redis Cluster集群模式)。对五个实例依次申请上锁,如果最终申请成功的数量超过半数(大于总数/2 + 1),则表明红锁申请成功。按照下面的流程进行操作:
- 客户端获取当前时间
t1
- 客户端依次对五个实例进行
set ex nx
操作,锁的过期时间为t_lock
(远小于锁的总过期事件)。如果当前节点请求超时,则立马请求下一个节点。 - 当获取的锁超过半数,则获取当前的时间
t2
。获取锁的过程总耗时t = t2 - t1
。如果t
小于锁的过期时间t_lock
,则可以判断为加锁成功,否则加锁失败。 - 加锁成功,则执行业务逻辑。若加锁失败,则依次释放所有节点的锁。
- 客户端获取当前时间
-
Red Lock是否安全:先说结论,不一定安全
当前有两个客户端(
Client1
和Client2
),首先Client1
正常获取锁,然后突然被GC
执行垃圾回收机制了。在GC
的过程当中,Client1
的锁超时释放了,Client2
开始申请并获得锁。然后Client2
写入数据并释放锁。 后面Client1
在GC
结束之后又写入数据, 此时就出现了数据不一致的情况。
9. 说说 Redisson 分布式锁的原理?
【Redisson分布式锁定义】
Redisson分布式锁是一种基于Redis实现的分布式锁,利用Redis的原子性操作来确保多线程、多进程或多节点系统中,只有一个线程能够获得锁。避免并发操作导致的数据不一致问题。
主要可以分为四个部分来讲:锁的获取、锁的续期、可重入锁、锁的释放
【锁的获取】
- 执行
exist
,判断锁是否存在- 若存在 ,判断唯一标识是否对应。若唯一标识相同 -> 第 3 步 ; 若不同,说明当前锁别其他进程占用 -> 第2 步
- 若不存在 ,直接
tryLock()
上锁 -> 第 3 步
- 使用
pttl
查询锁剩余的过期时间,后续可以再次获取 - 执行
hincrby
,设置重入计数为1
(可重入锁才有这一步操作) - 执行
pexpire
, 设置锁的过期时间 (为了防止任务还没执行完,锁就过期了。Redisson实现了用看门狗机制来为锁进行自动续期)
【可重入锁】
一般是在线程已经获取锁的基础上,为了后续还能拿到锁。因为假如increment()
和anotherMethod
都需要用到Counter
锁。当increment()
拿到锁之后,又调用anotherMethod()
又需要获取锁。如果不能二次获取锁,那就陷入死锁了。所以,Redisson才搞了可重入锁
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
anotherMethod();
}
public synchronized void anotherMethod() {
// 可以再次获取相同的锁
count++;
}
}
可重入锁是在获取锁的基础上,多了一层逻辑。具体实现如下:
- 实现锁的获取的所有功能
- 执行
hexist
判断是否锁已经存在,且唯一标识匹配(线程id相关),所以不能直接用exist
判断锁是否存在 - 如果自己的锁存在,用
hincrby
把重入次数加一 - 用
pexpire
,设置锁的过期时间 - 如果没有获取成功锁,就和上面一样,用
pttl
查询锁的过期剩余时间
【锁的释放】
- 用
hexist
判断线程自己的锁是否存在,需要判断唯一标识- 如果存在 -> 第2步
- 如果不存在 -> 直接返回,不需要做解锁操作,因为是别人的锁
- 用
hincry
减少一次锁的可重入次数 (增加-1
就是减少一次) - 判断锁的可重入次数是否大于
0
- 如果大于
0
, 说明还有函数在使用这个锁,则重新设置过期时间 - 如果等于
0
-> 第4步
- 如果大于
- 用
del
删除对应的key - 用
publish
广播通知其他等待锁的进程,此时释放锁了
【Redisson锁的类型】
- 公平锁:和可重入锁类似,确保多个线程按请求顺序获得锁
- 读写锁: 支持读写分离,多个线程同时获得读锁,而且锁是独占的
- 信号量与可数锁: 允许多个线程同时持有锁,适用于资源的限流和控制。
10. Redisson 看门狗(watch dog)机制了解吗?
【为什么用看门狗机制】
因为如果进程获取锁之后,用户的业务逻辑还没有执行完成,锁就过期了。此时,其他进程抢占临界资源,会导致数据不一致的问题。
【看门口机制的执行流程】
- 判断用户是否设置过期时间 (判断
leaseTime > 0
,默认leaseTime
为-1
)- 如果设置了过期时间,不启用看门狗机制,等到指定的过期时间,锁自动释放。
- 如果没有设置过期时间 -> 第2步
- Redssion会启动一个定时任务,用于自动续期锁的过期时间。
- 定时任务中,设置锁的超时时间默认为
30s
, 每间隔总时长的1/3
,也就是10s
。定时任务会自动锁进行续期,续期时间为30s
- 当客户端主动释放锁,那么Redisson就会取消看门狗机制。
【注意】 如果客户端主动释放锁之前,服务器突然宕机了,定时任务没法儿继续执行。等看门狗机制设置的过期时间到了,锁就自动释放了。所以,不会出现一直占用锁的情况。
11. Redis 实现分布式锁时可能遇到的问题有哪些?
-
业务未执行完,锁提前到期:用户的业务逻辑还没执行完毕,锁提前过期了。被其他的进程获取了锁,同时抢占临界资源,可能出现数据不一致的情况。
【解决方法】
通常要保证用户的业务逻辑需要在锁过期之前执行完,因此需要把锁的过期时间稍微设大一些。也不能太大,这样其他程序就拿不到锁,就会降低系统的整体性能。或者使用Redisson分布式锁,会自动调用看门狗机制,定时续期锁,直到任务执行完毕,就不续期锁了。
-
单点故障问题:如果只部署了一个Redis节点,当实例宕机或者不可用的时候。整个分布式锁服务将无法完成工作,阻塞业务的正常执行。
【解决方法】
可以利用Redis Cluster集群机制,部署多个Redis实例,采用一主+两从的哨兵机制。当某个实例宕机时, 哨兵会自动选举新的有效节点作为主节点。
-
主从同步但锁未同步问题:主从复制的过程是异步实现的,如果Redis主节点获取到锁,但是还没同步到从节点。此时,主节点突然宕机,然后哨兵选择新的主节点。但是,由于主从同步没有完成,现在其他客户端可以正常获取锁。就会导致多个应用同时获取锁,会出现数据不一致的问题。
-
网络分区问题:在网络不稳定的情况下,客户端和Redis可能会中断再重连。如果没有设置锁的过期时间,那么可能导致锁无法正常释放。如果有多个锁,可能还会引发死锁的现象。
【多锁死锁现象】
-
有两个资源A和B,分别由锁LockA和LockB保护。
-
客户端1先获取LockA,然后尝试获取LockB。
-
客户端2先获取LockB,然后尝试获取LockA
如果客户端1拿到了LockA,客户端2拿到了LockB。突然网络不稳定,锁无法正常释放。然后客户端1等待LockB,客户端2等待LockA,就会形成死锁。
-
-
时钟漂移问题:因为Redis分布式锁依赖锁的过期时间来判断是否过期,如果出现时钟漂移,很可能导致锁直接失效。
【解决方法】
让所有节点的系统时钟从NTP服务进行同步,减少时钟漂移的影响。
-
可重入问题:某个进程可能有多次调用锁,如果锁不能重入的话。当进程获取到锁后,再次申请获取锁,获取不到就死锁了。
12. Redis为什么这么快?
- 基于内存: Redis存储的所有数据都存在内存里面,内存的访问速度比硬盘快,提升了读写速度
- 单线程模型 + I/O多路复用: Redis采用单线程+I/O多路复用的方式,避免了线程上下文切换和竞争条件,提高了并发处理效率
- 高效数据结构:提供
String
、List
、Hash
、Set
、Sorted Set
五种数据结构,他们的操作复杂度大部分为O(1) - 异步持久化: 持久化操作由子线程异步完成,减少了持久化对主线程的影响,提升了整体性能。
【注意】Redis从6.0开始对网络处理引入了多线程机制,提高I/O性能。网络请求可以并发处理,减少网络I/O等待的影响。但是,Redis 仍然保持了核心命令处理逻辑的单线程特性。
【I/O多路复用技术】
- Linux多路复用技术允许多个进程的I/O注册到同一管道和内核交互,准备好数据之后再copy到用户空间,实现一个线程处理多个I/O流。
- Linux下I/O多路复用有
select
、poll
、epoll
三种,功能类似,细节不同
13. 为什么 Redis 设计为单线程?6.0 版本为何引入多线程?
【Redis采用单线程的原因】
- 基于内存操作,Redis的瓶颈主要是内存,多数操作的性能瓶颈不是CPU带来的 (增加多线程也没啥用)
- 单线程模型的代码简单,可以减少线程上下文切换的性能开销。
- 单线程结合I/O多路复用模型,能提高I/O利用率
【注意】 Redis的单线程是指网络请求模块和数据操作模块是单线程的, 但是持久化存储模块和集群支撑模块是多线程的。
【为什么引入多线程】
随着数据规模和请求量的增加,执行瓶颈主要在网络I/O部分。引入多线程可以提高网络I/O的速度。但是,Redis内核去还是保持单线程处理,比如读写命令部分还是单线程,所以线程安全问题就不存在了。
【Redis多线程I/O场景下的结构】
14. 如何使用Redis快速实现布隆过滤器?
Redis可以使用位图Bitmap
或者用Redis模块RedisBloom
来实现布隆过滤器
- 位图
bitmap
实现- bitmap 本质是一个位数组,提供了
setbit
和getbit
来设置和获取某个值,可以用来标识某个元素是否存在 - 对应给定的key,可以用哈希函数来计算位置索引。如果位图中的值为
1
, 表示该元素可能存在
- bitmap 本质是一个位数组,提供了
RedisBloom
模块实现:封装了哈希函数和位图大小,可以直接用于创建和管理布隆过滤器
【布隆过滤器原理】
布隆过滤器是由一个位数组+k个独立的哈希函数组成。每次验证某个key对应的数据是否存在的时候,需要k个哈希函数都对其进行运算,如果位数组中的值都为1
,说明该key对应的数据可能存在。只要有一个位置不为1
, 就说明key对应的数据一定不存在。
为什么k个函数查到的值都为1
, 也不能说明key对应的数据一定存在呢?
因为可能存在哈希冲突,比如key
和 key1
的k个hash函数的值都为1
。但是key
对应的数据在数据库里面,但是key1
的数据不在数据库里面。
15. Redis 中常见的数据类型有哪些?
【Redis常见的五种数据结构】
数据结构名称 | 底层 | 特性 | 适用场景 |
---|---|---|---|
String | SDS 简单动态字符串 |
String字符串 | 1.缓存数据:缓存Session、Token、序列化后的对象 2. 分布式锁: set ex nx 3.计数:用户单位时间访问次数,页面单位时间访问次数 |
List | ListPack / QuickList / ZipList / LinkedList |
双向有序链表,各节点都包含字符串 | 1. 信息流展示:历史记录、更新文章、更新动态 2.消息队列:不推荐,缺陷多 |
Hash | Dict / ZipList |
无序散列表,存储键值对 | 存储信息:用户、商品、文章、购物车信息 |
Set | Dict / Intset |
无序去重集合,包含不同的字符串 | 1.不重复数据:点赞次数、下单次数 2.共同资源:共同好友、统统粉丝、共同关注 (交集、并集) 3.随机抽取: 抽奖系统、随机点名 |
ZSet | ZipList / SkipList 跳表 + HashTable 哈希表 |
有序集合,value 包含member 成员和score 分数,按照score 进行排序 |
1.各类排行榜:点赞排行版、热门话题排行榜 2. 优先级/重要程度: 优先级队列 |
【其他数据结构】
数据结构名称 | 特性 | 适用场景 |
---|---|---|
BitMap | 存储二进制数据,0 和1 |
1. 布隆过滤器: 防止缓存穿透 2. 签到统计: 每日签到用 1 标记,未签到用0 标记,可以快速统计某日签到人数和连续签到天数 |
HyperLogLog | 基于概率算法实现,存储海量数据进行计数统计 | 一般用于页面的页面浏览量PV 和独立访客数UV , 快速估算访问量 |
GEO | 存储地理位置信息,经纬度坐标和位置名称 | 一般用于计算不同位置的距离,比如外卖单中计算配送距离 |
Stream | 能够生成全局唯一消息id的消息队列 | 用于可靠消息传递、异步任务处理的场景 |
16. Redis 中如何保证缓存与数据库的数据一致性?
为了保证缓存和数据库的数据一致性,有这么几种方案:
-
先修改缓存,再修改数据库
- 事务A准备修改指定id的
name
为小张
,先修改缓存 - 事务B准备修改指定id的
name
为小王
,先修改缓存, 然后修改数据库为小王
- 事务A修改数据库为
小张
(网络延迟), 此时出现数据不一致的情况
- 事务A准备修改指定id的
-
先修改数据库,再修改缓存
- 事务A准备修改指定id的
name
为小张
,先修改数据库 - 事务B准备修改指定id的
name
为小王
,先修改数据库, 然后修改缓存为小王
- 事务A修改缓存为
小张
(网络延迟), 此时出现数据不一致的情况
- 事务A准备修改指定id的
-
先删除缓存,再修改数据库
-
事务B读取指定id的
name
, 发现找不到缓存,读取数据库中的数据为小王
-
事务A准备修改指定id的
name
为小张
,先删除缓存,然后修改数据库为小张
-
事务B修改缓存为
小王
(读到空数据,返回来写),此时出现数据不一致的情况
-
-
先修改数据库,再删除缓存:基本不会出现问题 (除非删除缓存的请求失败)
-
延迟双删,先删除缓存,再修改数据库,再删除缓存: 难以评定休眠时间
如果要保证数据库和缓存的强一致性怎么办?
- 用消息队列:把写策略里面的删除缓存操作加入到消息队列中,让消费者来操作数据。如果删除失败,则可以冲消息队列中重新读取,在一定重试次数下删除成功的话,将该消息删除。 (确保删除缓存成功)
binlog
+Canal
: 模仿MySQL主从同步的方式,结合Canal
订阅MySQL的binlog
。其实就是等MySQL写入数据库, 然后去删除缓存。
如果需要避免缓存失效 (比如热点Key), 如何设计呢?
- 分布式锁:同一时间只允许一个请求更新缓存,确保缓存和数据库一致。但是,可能会降低写性能
- 添加短暂过期时间:在先修改数据库再修改缓存的基础上,给缓存加一个短暂的过期时间,确保缓存不一致的情况时间比较少。
17. Redis 中跳表的实现原理是什么?
跳表是由多层链表组成的,它是Redis中 ZSet
的底层结构。最底层存所有的元素,上层是下层的子集 (可以理解成一种索引)。跳表的插入、删除、查找操作,实现方式如下:
- 查找:从最高层开始,通过范围确定位置,逐层向下查找,时间复杂度为
O(log n)
- 插入:从最高层开始,先逐层向下找到存放位置,然后随机确定新节点层数,插入并更新指针
- 删除:从最高层开始,通过范围确定位置,在各层更新指针保持结构
【Redis跳表结构】
Redis的跳表相对于普通的跳表多了一个回退指针, 而且 score
是可以重复的。
首先,我们可以看一下跳表的节点实现的原理
typedef struct zskiplistNode {
//Zset 对象的元素值
sds ele; // 采用Redis字符串底层实现sds,用于存储数据
//元素权重值
double score;
//后退指针
struct zskiplistNode *backward; // 用于指向前一个节点
//节点的level数组,保存每层上的前向指针和跨度
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span; // 当前层的跨度值
} level[];
} zskiplistNode;
上面的图片看起来比较抽象,可以按照下面的图片进行理解。上述的查找、删除、插入操作,其实都是先从level[0]
开始进行遍历,然后找到合适的位置。再往下进入level[1]
进行遍历,再找到合适的位置。一直重复这个操作,直到进入最底层,然后就可以确定位置了。
然后,我们来看一下跳表的底层实现原理
typedef struct zskiplist{
struct zskiplistNode *header, *tail, // 头节点和尾节点
unsigned long length, // 跳表长度
int level; // 跳表的最大层数
} zskiplist;
【注意】跳表的头节点、尾节点、跳表长度、跳表的最大层数都可以在o(1)时间复杂度进行访问
【跳表查询过程细节】
- 从头节点的最高层开始,逐一遍历每一层
- 遍历某一层节点时,根据节点的
SDS
类型元素和元素权重进行判断- 如果当前节点权重
<
要查找的权重, 继续向前遍历 - 如果当前节点权重
=
要找的权重 && 当前节点的SDS
类型数据<
要查找的数据,继续向前遍历 - 如果上面两个条件都不满足或者下一个节点为空,则跳到下一层
level
数组里面,继遍历续查找 - 如果当前当前节点权重
=
要找的权重 && 当前节点的SDS
类型数据=
要查找的数据, 返回当前节点值,查询结束
- 如果当前节点权重
【跳表的插入细节】
- 参数检查和初始化:检查要插入的节点的
score
是否为NaN
,初始化遍历指针x
指向跳表的头节点,定义update
数组记录每层查找的最右节点 (后续要修改它的指针),rank
数组记录每层跨越的节点数。 - 查找插入位置:和上面查找过程一样,从最高层开始,逐层往下查询。每一层中,把满足条件的最右节点记录在
update
数组中,并更新rank
数组记录跨越的节点数。 - 生成新节点层数:调用
zsRandomLevel
函数生新节点的随机层数。如果新节点层数>
当前跳表总层数,则更新跳表最大层数,并初始化新增层的update
和rank
数组数据。 - 创建并插入新节点:创建新节点,根据
update
和rank
数组信息,在每一层插入节点,设置forward
指针 和span
跨度值 - 更新其他节点的span值:对于没有触及到的层,更新
update
节点的span
值 - 设置前后指针:设置新节点的
backward
指针,指向下一个节点。如果下一个节点为空,则更新跳表的tail
指针。 - 更新跳表的长度:跳标的节点数 + 1, 返回插入的新节点指针。
【为什么ZSet要用跳表不用哈希表和平衡树】
主要有三个原因:
- 内存更少:跳表相比B树可以占用更少的内存,主要取决于如何设置节点层数的概率参数
- 局部性良好:跳表在执行
ZRANGE
和ZREVRANGE
等操作时,其缓存局部性表现良好,不比其他平衡树差 - 实现简单:跳表的代码更简单和易于调试
18. Redis Zset 的实现原理是什么?
ZSet
的实现方式有两种,第一种是压缩列表 Ziplist
/ 紧凑列表 Listpack
,另一种是跳表 skiplist
+ 哈希表 HashTable
。主要判断条件如下:
- 元素数量 <
zset-max-ziplist-entries
zset压缩列表最大键值对个数 (默认是128) - 每个元素大小(
key
和value
的长度) <zset-max-ziplist-value
(默认为64)
【ZSet压缩列表结构】
ZSet
的 压缩列表结构和数组很相似,用一段连续的内存空间存储数据。每个节点都占用相邻的一小段内存,节点之间通过内存偏移量而非指针记录相对位置。
【注意】压缩列表比传统的链表更加节省内存,但是压缩列表也有明显的缺点,它的修改成本高。
- 倒序遍历都需要依赖上一个节点的长度
prevlen
,如果当前节点有修改,后续节点就需要修改prevlen
- 当
prevlen
> 当前节点编码类型的最大大小时,就需要改变编码类型,重新分配内存 - 后继节点重新分配内存后,其他后面的节点都会面临同样的情况,导致发生连锁更新。
压缩列表的头部分别有记录了三个重要属性:
- 列表大小
zlbytes
: 整段列表在内存中占用的字节数 - 尾节点位置
zltail
:从队列头到最后一个节点起始位置的内存偏移量。 - 节点数量
zllen
: 总共的节点个数
每个节点当中又可以化分为三个部分:
- 上一节点长度
prevlen
:用于倒序遍历时确认上一节点的位置 - 节点编码
encoding
:同时记录了长度和编码类型 - 数据
data
:节点中存放的数据
【ZSet紧凑列表结构】
紧凑列表的头部分别有记录两个重要属性:
- 列表大小
size
: 整段列表在内存中占用的字节数 - 列表元素数量
num
:总共的元素个数
每个节点当中又可以化分为三个部分:
- 节点编码
encoding
:同时记录了长度和编码类型 - 数据
data
:节点中存放的数据 - 节点长度
len
:节点编码encoding
+ 数据data
的总长度。正向或反向遍历都依赖它完成
紧凑列表相比压缩列表的优点:无需记录上一节点的长度,上一节点重新分配内存后,本身节点无需做任何修改。
【跳表 + 哈希表】
当ZSet
处理比较大的数据的时候,会选择跳表+哈希表的方式。其中,跳表的节点保存指向member
的指针和score
,哈希表保存member
和score
之间对应的关系,方便实现高效的随机查找和范围查找。
跳表的具体实现细节可以参考17. Redis Zset 的实现原理是什么?
19. Redis 的 hash 是什么?
Hash
是 Redis五大常规数据结构(String
、List
、Hash
、Set
、ZSet
)的一种,主要用于存储key-value 键值对集合。Hash
一般会用来存储商品的属性、用户的信息等等。
【Hash底层数据结构】
Hash
的底层数据结构要分为Redis 6.0
和 7.0
来看
- Redis
6.0
: 压缩列表zipList
+ 哈希表HashTable
- Redis
7.0
: 紧凑列表Listpack
+ 哈希表HashTable
当Hash
当中的数据达到指定的阈值的时候,就会从压缩列表zipList
/紧凑列表ListPack
转为哈希表HashTable
。当满足下面两个条件的时候才能用压缩列表zipList
/紧凑列表ListPack
- 哈希类型的个数 < 哈希紧凑列表最大键值对个数
hash-max-listpack-entries
(默认是512) - 哈希的
key
和value
的长度 <hash-max-ziplist-value
64
【为什么Hash会选择压缩列表 zipList
/紧凑列表 ListPack
呢?】
Hash
结构采用压缩列表 zipList
/紧凑列表 ListPack
的主要目的应该是基于省内存的角度去考虑。主要有两个原因吧:
- 内存占用少: 压缩列表
zipList
和 紧凑列表ListPack
都属于紧凑型的内存结构,没有哈希表那样存在额外的指针开销。哈希表为了维持快速查找的特性,内部才用了链表解决哈希冲突,每个哈希桶的内部都会带有指针,比较占用内存空间。另外的两个数据结构主要通过将数据存储在一块连续的内存,利用了计算机的局部性原理,从而使得内存占用最小。 - 时间复杂度:哈希表的访问速率是
O(1)
,但是如果冲突比较多,最坏也会降到(O(n)
)。因为冲突之后,就需要遍历链表或者查红黑树。但是 压缩列表zipList
和 紧凑列表ListPack
是连续数组存储,肯定能在O(1)
的时间找到这个元素
【Redis中HashTable的结构】
HashTable
就是由哈希表数组实现的,查询时间复杂度为O(1)
, 效率比较快。具体数据结构如下
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值 (index = hash(key) & sizemask), sizemask = size - 1
unsigned long sizemask;
//该哈希表已有的节点数量
unsigned long used;
} dictht;
哈希节点dictEntry
由三个key
、value
和 下一个哈希节点指针next
组成
typedef struct dictEntry {
//键值对中的键
void *key;
// 键值对中的值
union {
void *val; // 用于指向实际值的指针,比如存放string
uint64_t u64;
int64_t s64;
double d;
} v;
//指向下一个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
【渐进式扩容rehash】
Redis中的hash表结构会随着数据量的增大而扩容, 将数组的大小扩张为原来的两倍。在扩张的过程当中,由于容量的变化,会导致之前的节点,移动到新的位置,这个变化的过程就是 rehash
实现的。
rehash
扩容的过程可以分为一下三步:
- 增加哈希表2的空间:给哈希表2分配空间,一般是哈希表1的两倍。此时,
rehash
索引的值rehashidx
从-1
暂时变成0
。 - 迁移数据:将哈希表1的数据迁移到哈希表2 (迁移的过程,一般是在对指定节点做增删改查的时候,所以叫渐进扩容,有点类似
ConcurrentHashMap
的扩容机制),迁移之后,rehashidx + 1
。 迁移过程分为多次完成。 - 释放原哈希表1:迁移完成之后,哈希表1的空间会被释放,并且把哈希表2设置为哈希表1。然后,哈希表2再创建一个空白的哈希表。为下一次
rehash
做准备。
【注意】 rehash
的出发条件和其负载因子相关,负载因子 = 已存储的哈希表节点数量 / 哈希表总容量
。当达到下面的任一条件就可能触发。
负载因子 >= 1
, 资源相对紧张,如果Redis没有在执行bgsave
和bgrewriteAOF
命令 (生成RDB
文件和AOF
文件),就会触发负载因子 >= 5
,资源非常紧张,直接触发
20. Redis String 类型的底层实现是什么?(SDS)
Redis中的 String
类型的底层实现是简单动态字符串 SDS
, 结合 int
、embstr
、raw
等不同的编码方式进行优化存储。
【简单动态字符串 SDS
结构】
len
字符数组长度:表示整个SDS
字符串数组的长度,获取长度时直接返回该值 (时间复杂度o(1)
)alloc
分配内存: 表示已分配字符数组的存储空间大小,通过alloc - len
可以计算剩余空间。可用于判断是否满足修改要求,解决缓冲区溢出的问题。flags
SDS类型: 一共有sdshdr5
、sdshdr8
、sdshdr16
、sdshdr32
、sdshdr64
五种类型,后面的数字表示2的幂次方,能够灵活存储不同大小的字符串,节省内存空间buf
存储数据的字符数组: 用于保存字符串,二进制数据等
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
【Redis底层结构 redisObject
】
BTW, Redis的底层结构就是redisObject
。
redisObject
包含数据类型,编码类型和数据指针三个元素。其中编码类型,包含int
、embstr
、raw
等类型
struct redisObject {
unsigned type:4; // 数据类型(字符串、哈希等)
unsigned encoding:4; // 编码类型(int、embstr、raw等)
int64_t ptr; // 实际的数据指针,这里直接存储整数值
};
redisObject
的具体编码类型由下面几个条件决定:
- 如果字符串对象保存的整数值能用
long
类型表示,该对象会把整数值存储到ptr
数据指针指向的long
结构里面 (将void*
转为long
),并将编码设置为int
- 如果字符串对象保存的字符串长度 <=
32
个字节,会用 上面提到的sds
保存字符串,并且把对象编码设置为embstr
。 - 如果字符串对象保存的字符串长度 >
32
个字节,也会用上面提到的sds
保存字符串,并且把对象编码设置为raw
。
【注意】
- 上面
32
个字节是redis 2.0
版本,redis 5.0
版本是44
个字节 embstr
和raw
编码区别:embstr
只调用一次内存分配函数,分配一块连续内存保存redisObject
和SDS
。raw
调用两次内存分配函数,分别分配两块内存空间保存redisObject
和SDS
。
21. Redis 中的缓存击穿、缓存穿透和缓存雪崩是什么? (缓存三兄弟)
问题类型 | 说明 | 解决方案 |
---|---|---|
缓存穿透 | 查询的数据是不存在的,数据库和缓存都没有。所有的请求都会绕过缓存,直接打到数据库上,可能会遭受恶意攻击。 | 1.请求参数校验 2. 缓存空值 3. 布隆过滤器 |
缓存雪崩 | 大量的缓存同时失效或者Redis宕机了,导致请求直接打到数据库,可能造成系统崩溃。 | 1. 设置随机过期时间 2.Redis 高可用集群 3.服务熔断或限流 |
缓存击穿 | 某个热点数据缓存失效,大量并发请求直接访问数据库,导致数据库压力剧增,性能下降。 | 1. 互斥锁 2. 逻辑过期 |
【缓存穿透具体解决方案】
-
请求参数校验:如果请求参数含有非法字段,则直接返回错误,避免进一步查询缓存和数据库
public boolean validateRequest(String key) { if(key == null || key.isEmpty()) { return false; } }
-
缓存空值:如果查到不存在的数据,也将其存入缓存,
value
采用 ”null“ 字符串。后续查询,直接返回给用户。public Object getDataWithEmptyCache(String key) { //先从缓存中获取数据 String value = redisTemplate.opsForValue().get(key); //如果缓存为空 if (value == null) { Object databaseValue = queryFromDatabase(key); //从数据库中获取 if (databaseValue == null) { //缓存空值 redisTemplate.opsForValue().set(key, "null", 60, TimeUnit.SECONDS); return null; } else { redisTemplate.opsForValue().set(key, databaseValue, 3600, TimeUnit.SECONDS); return databaseValue; } } return "null".equals(value) ? null : value; // 如果是空值缓存,返回 null }
-
布隆过滤器:写入数据库时用布隆过滤器做一个标记,然后在用户请求的时候,确认缓存失效了。先通过布隆过滤器快速判断数据是否存在,如果不存在就直接返回。但是,布隆过滤器在一定程度上会出现误判。 因为可能会出现哈希冲突,导致一小部分请求穿透到数据库。 可以采用第三方工具类 Guava 实现布隆过滤器 )
public class TestBloomFilter { public static void main(String[] args) { /** * 构造: * 第二个参数: expectedInsertions 期望插入的元素数量 * 第三个参数: 预测错误率 传入 0.01 表示预测正确的概率是 99% * */ BloomFilter<Integer> filter = BloomFilter.create( Funnels.integerFunnel(), 500, 0.01 ); filter.put(1); filter.put(2); filter.put(3); Assert.assertTrue(filter.mightContain(1)); Assert.assertTrue(filter.mightContain(2)); Assert.assertTrue(filter.mightContain(3)); Assert.assertFalse(filter.mightContain(1000)); } /* 当我们设计布隆过滤器时,为预期的元素数量提供一个合理准确的值是很重要的。 否则,我们的过滤器将以比期望高得多的比率返回误报。 让我们看一个例子。 假设我们创建了一个具有 1% 期望误报概率和预期一些元素等于 5 的过滤器, 但随后我们插入了 100,000 个元素: */ @Test public void testOverSaturatedBloomFilter() { BloomFilter<Integer> filter = BloomFilter.create( Funnels.integerFunnel(), 5, 0.01); IntStream.range(0, 100_000).forEach(filter::put); Assert.assertTrue(filter.mightContain(1)); Assert.assertTrue(filter.mightContain(2)); Assert.assertTrue(filter.mightContain(3)); Assert.assertFalse(filter.mightContain(1000000)); //测试不通过 } }
【缓存雪崩具体解决方案】
缓存雪崩要分为两种不同的情况来解决:大量key同时过期 和 Redis宕机
【大量key同时过期】
- 设置随机的过期时间:写入缓存的时候,给其在基础时间上 + 一个随机的过期时间
- 互斥锁: 保证同一时间只有一个请求来构建缓存
- 后台更新缓存:后台采用
Scheduled
的方式检查缓存是否失效,如果失效了,就查询数据库更新缓存。
【Redis宕机】
-
服务熔断或者限流机制:暂定服务对于缓存服务的访问,直接返回错误。或者启用限流规则,只允许商家请求发送数据库进行处理,过多的请求就会拒接。一般会使用
Hystrix
或者Sentinel
实现熔断或者限流@HystrixCommand(fallbackMethod = "fallbackMethod") public String getDataFromCache(String key) { // 从 Redis 获取数据 return redisTemplate.opsForValue().get(key); } public String fallbackMethod(String key) { return "服务繁忙,请稍后重试!"; // 熔断处理逻辑 }
-
构建Redis缓存高可用集群: 如果单个缓存服务节点发生故障自动迁移访问流量到另外一个节点.
【缓存击穿具体解决方案】
-
互斥锁:同一时间只允许一个业务线程更新缓存。未获取互斥锁的请求,可以等待锁释放后读取缓存,或者返回空值/默认值。 (对数据一致性要求比较高)
-
逻辑过期:不给缓存设置过期时间,
value
采用hash
的方式,设置一个逻辑过期时间。每次判断数据是否过期,未过期直接返回数据。如果已经过期了,则获取互斥锁重建缓存,然后释放锁。如果获取互斥锁失败,则返回已过期缓存数据。 -
服务熔断或者限流机制
22.Redis 数据过期后的删除策略是什么?
【Redis过期删除策略】
Redis采用的是 定期删除
+ 惰性删除
的结合方式
策略 | 实现方式 | 优缺点 |
---|---|---|
定期删除 | Redis每个一段时间 (默认为100ms 随机检查 一定数量的键,非全部key),过期则删除 |
可以减少内存占用, 但是对CPU有一定消耗,且不能保证及时删除所有过期键 |
惰性删除 | 当客户端访问一个key时,Redis会检查是否过期,若过期则立即删除 | 对CPU友好,大量过期键未被访问时仍占用内存 |
【定期删除细节】
- Redis会周期性执行过期key检查,默认每
100ms
执行一次 - 每次检查会随机抽取部分key,默认每次
20
个, 判断是否过期 - 为了避免过多的CPU占用,Redis限制检查的执行时间 (默认为执行时间的
25%
,也就是25ms
) 和 过期键的比例 (默认只检查10%
) - 如果过期间比例超过限制,则会重复检查以提高清理效率
【为什么Redis删除不直接吧所有过期key都删除了?】
- 定期删除不能除所有过期key原因: 如果一次性清理所有过期间,可能会导致Redis长时间阻塞,影响性能。随机抽样和时间限制的方式能在清理内存和性能之间取得平衡。
- 惰性删除不能删除所有过期key原因:惰性删除旨在访问key的时候触发,如果没有被访问到,就可能一致存在,无法清理。
【如何优化大量key集中过期的情况 - 缓存雪崩】
- 设置随机过期时间:设置过期时间的时候,加上一个随机值
- 开启
lazy free
机制: 配置lazyfree-lazy-expire
, 让过期的key删除操作由后台线程异步执行,减少主线程的压力
23. 如何解决 Redis 中的热点 key 问题?
热点 key
是指访问频率显著高于其他 key
的键,通常表现为以下几种情况:
类别 | 特性 |
---|---|
QPS 集中 |
某个key 的QPS (每秒请求量) 占Redis总QPS的较大比例 |
带宽集中 | 某个key 的数据量较大(比如1MB 以上的hash 数据),被频繁请求 |
CPU消耗集中 | 某个key 的复杂操作(比如ZRANGE 查询较大的ZSet 数据) 占用Redis过多CPU时间 |
热点key
问题就是某个瞬间,大量的请求集中访问Redis里的同一个固定key
,假如热点key
过期,可能会导致缓存击穿,让大量的请求直接打到数据库里面。像热点新闻
、热点评论
、明星直播
这种读多写少的场景,就很容易出现热点key
问题。因为Redis的单节点查询性能一般在 2w QPS
, 一般超过 这个数值,可能就会宕机。
【热点key
的危害】
- 消耗CPU和带宽资源: 热点
key
可能占用Redis大部分资源,影响其他请求的处理时间 - Redis宕机风险: 如果超过Redis所能承载的最大
QPS
, 可能会导致Redis宕机。然后大量的请求转发到后端数据库,导致数据库崩溃。
【如何发现热点key
】
- 根据业务经验判断:比如像
明星八卦爆料
、重大新闻
、热点评论
都会能会导致热点key
。 好处是不需要消耗什么成本,坏处是无法预防突发情况。 - Redis进行集群监控: 查看哪个Redis出现了
QPS
倾斜,出现QPS
倾斜的实例有很大概率存在热点key
hotkey
监控:命令行执行redis-cli
的时候添加--hotkeys
参数,它是基于scan + object freq
扫描目标出现频率时间的。但是需要设置maxmemory-policy
参数,来采用不同的淘汰手段:volatile-lfu (least frequently used)
: 淘汰已经过期数据集中最不常用的数据allkeys-lfu
:当内存不足的时候,移除最不常用的key
monitor
命令: 集合一些Redis的日志和相关分析工具进行统计, 非常消耗性能, 单客户端会消耗50%
的性能- 代理层收集:利用有些服务在请求Redis前会先请求代理服务的特点, 在代理层统一收集Redis热
key
数据。比如采用 京东的JD-hotkey
、有赞透明多级缓存解决方案(TMC) - 客户端收集:在操作Redis前添加统计每个
key
的查询频次,将统计数据发送到聚合计算平台计算,之后查看结果。对性能消耗较低,但是成本比较大,需要介入聚合计算平台。
【如何解决热点key
】
-
多级缓存:结合使用一级缓存和二级缓存。一级缓存就是应用程序的本地缓存,比如JVM内存中的缓存,可采用Caffeine 、阿里巴巴jetcache )。 二级缓存是Redis缓存,当以及缓存中不存在的时候,再访问二级缓存。
针对热点
key
请求, 本地一级缓存可以将同一个key
的大量请求,根据网络层负载均衡到不同的机器节点上,避免全部打到单个Redis节点的情况,减少网络交互。但是需要耗费更多的经历去保证分布式缓存一致性,会增加系统复杂度。
- 热点key备份:在多个Redis节点上备份热
key
,避免固定key
总是访问同一个Redis节点。通过初始化时为key
拼接0~2n
(n
为集群数量) 之间的随机数,让其散落在各个姐电商。若有热点key
请求的时候,随机选一个备份的节点进行取值。可以有效减轻单个Redis节点的负担。 - 热点key拆分:将热点
key
拆分为多个带后缀名的key
,让其分散存储在多个实例当中。客户端请求的时候按照规则计算出固定key
,然后请求对应的Redis节点。比如“某音热搜某明星离婚”。可以拆分为多个带编号后缀的key
存储在不同的节点,用户查询时根据用户id
算出要访问的对应节点。虽然用户只能看到一部分数据,等待热点降温后再汇总数据,挑选优质内容重新推送给未收到的用户。
【注意】 热点key备份和热点key拆分的区别在于,热点key备份是同一份数据全量复制到其他节点,热点key拆分是把一份数据拆分成多份。
- Redis集群 + 读写分离: 增加Redis从节点, 分散读请求压力。然后利用集群,可以将热点
key
拆分或者备份到不同的Redis实例上。 - 限流和降级:采用限流策略,减少对Redis的请求,在必要的时候返回降级的数据或者空值。
24. Redis 中的 Big Key 问题是什么?如何解决?
Redis当中的 Big Key
(也可以叫big memory key
)是指某个key
对应的value
数据量过大,比如包含大量元素的List
、Hash
、Set
、ZSet
或超长字符串), 可能会导致性能瓶颈和系统不稳定。 一般来说,String
类型的value
超过 1MB
,或者符合类型当中的元素超过5000
个,就算big key
。
【Big Key 典型场景】
- String: 存储超大JSON文本、图片base64数据等
- Hash:存储海量的字段,比如用户的行为记录
- List / Set:存储百万个元素
- ZSet: 包含大量的排序元素
【Big Key 导致的问题】
- 性能问题:Redis是单线程处理机制,在处理
big key
的时候,需要更长的时间,阻塞工作流程,没法儿处理后面的命令。如果处理的时间过长,会导致客户端长时间未收到响应。另外,big key
占用的带宽过高,传输时间比较长,也容易导致阻塞。 - 内存问题:
big key
会导致Redis内存变得很大,增加内存碎片化风险。单次大对象内存分配失败,可能导致整个Redis服务崩溃。集群模式下,会出现数据和查询倾斜的情况,big key
的 Redis节点会占用较多的内存 - 持久化问题:如果
AOF
写回策略为always
,也就是说主线程执行完指令之后,把对应数据写入AOF
文件后,直接fsync
写入磁盘操作。如果是一个大key
, 阻塞的时间可能比较就,同步到硬盘的过程很耗时。
【如何找Big Key】
-
内置
--bigkeys
: 采用内置的--bigkeys
命令,基于scan
查找所有的big key
redis-cli --bigkeys
-
使用第三方工具
https://github.com/sripathikrishnan/redis-rdb-tools https://github.com/weiyanwei412/rdb_bigkeys
【如何处理Big Key问题】
Big Key
问题可以从下面说三个层面来解决:
-
开发层面:将数据压缩后再存; 将大JSON对象拆分为多个小字段; 将数据保存为更合理的数据结构 (利用
hash
替代大字符串); 避免会造成阻塞的命令 -
业务层面:调整存储策略,只存储必要的数据 (比如用户的收货地址等不常用信息不存储,只存储用户ID、姓名、头像等); 优化业务逻辑,使用更小的数据来满足业务要求; 规划好数据的生命
-
架构层面:采用Redis集群的方式进行Redis部署,然后将大Key拆分散落到不同的服务器上面, 加快响应速度