一、 Redis实现集群的原理是什么
Redis 集群(Redis cluster)是通过多个 Redis 实例组成的,每个实例存储部分的数据(即每个实例之间的数据是不重复的)。
具体是采用哈希槽(Hash Slot)机制来分配数据,将整个键空间划分为 16384个槽(slots)。每个Redis 实例负责一定范围的哈希槽,数据的 key 经过哈希函数计算后对 16384 取余即可定位到对应的节点。
客户端在发送请求时,会通过集群的任意节点进行连接,如果该节点存储了对应的数据则直接返回,反之该节点会根据请求的键值计算哈希槽并路由到正确的节点。简单来说,集群就是通过多台机器分担单台机器上的压力。

Redis 集群中节点之间的信息如何同步?
Redis 集群内每个节点都会保存集群的完整拓扑信息,包括每个节点的 ID、IP 地址、端口、负责的哈希槽范围等。
节点之间使用 Gossip 协议进行状态交换,以保持集群的一致性和故障检测。每个节点会周期性地发送PING 和 PONG 消息,交换集群信息,使得集群信息得以同步。
Redis集群分片原理
Redis 集群会将数据分散到 16384(2^14)个哈希槽中,集群中的每个节点负责一定范围的哈希槽。在 Redis 集群中,使用 CRC16 哈希算法计算键的哈希槽,以确定该键应存储在哪个节点。
每个节点会拥有一部分的槽位,然后对应的键值会根据其本身的 key,映射到一个哈希槽中,其主要流程如下:
1、根据键值的 key,按照 CRC 16 算法计算一个 16 bit 的值,然后将 16 bit 的值对 16384 进行取余运算,最后得到一个对应的哈希槽编号。
2、根据每个节点分配的哈希槽区间,对应编号的数据落在对应的区间上,就能找到对应的分片实例
Redis 集群中存储 key 示例
第一步:假设我们有一个 Redis 集群,包含三个主节点(Node1、Node2、Node3),它们分别负责以下哈希槽.
- Node1:哈希槽 0-5460
- Node2:哈希槽 5461-10922
- Node3:哈希槽 10923-16383
现在要存储一个键为 user:1001 的数据。
第二步:计算哈希槽
1、使用 CRC16 哈希算法计算 user:1001 的 CRC16 值。
2、假设计算结果为 12345.
3、然后,计算该值对应的哈希槽! 哈希槽 = 12345 % 16384 = 12345。
第三步、确定目标节点
12345 落在 Node3 的负责范围(10923-16383),因此,会被存储在 Node3 中user:1001
Redis 集群中请求 key 示例(客户端直接连接的并不是对应 key 的节点),如果客户端连接的是集群的 Node1,但需要访问存储在 Node3 的键 user:1081 ,查询过程如下
1、计算哈希槽:
客户端使用 CRC16 算法计算 user:1001 的哈希值(假设为 12345)
计算哈希槽:12345%16384=12345.
2、查询请求:
因为客户端连接的是集群中的 node1,所以客户端发送查询命令 GET user:1001 到 Node1。
3、Node1 响应:
Node1 检测到请求的键 user:1001 属于 Node3,返回一个 MOVED 错误,指示客户端请求的键在另一个节点上。 MOVED 错误会中返回目标节点的信息(例如,Node3 的IP 和端口)
4、重新连接:
客户端根据返回的目标节点信息,建立与 Node3 的连接。
5、再次发送查询请求:
客户端向 Node3 发送 GET user:1001 。
6、获取结果:
Node3 查询到 user:1001 的值(假设为{"name":"面试鸭","age":18}),并返回结果。
为什么 Redis 哈希槽节点的数目是 16384 呢?
1、首先是消息大小的考虑。
正常的心跳包需要带上节点完整配置数据,心跳还是比较频繁的,所以需要考虑数据包的大小,如果使用16384 数据包只要 2k,如果用了 65535 则需要 8k。
2、集群规模的考虑
集群不太可能会扩展超过 1000 个节点,16384 够用且使得每个分片下的槽又不会太少
二、 Redis会出现脑裂问题吗?
脑裂是指在分布式系统中,由于网络分区或其他问题导致系统中的多个节点(特别是主节点)误以为自己是唯一的主节点。这种情况会导致多个主节点同时提供写入服务,从而引起数据不一致。分布式系统就像一个团队在干活,如果发生了脑裂,就好比这个团队突然因为某些原因,比如通信出了问题,分成了几个小团体。
每个小团体都以为自己是整个团队,都在按自己的方式工作,各自为政,对同一件事有不同的决策和做法,就像有的说要这么干,有的说要那么干。这样一来,整个系统就乱套了,数据也可能变得不一致,服务也变得不正常了,这就是分布式系统中的脑裂。
导致脑裂出现原因主要是网络分区。
Redis 中如何避免脑裂问题的发生呢?
这里需要了解两个参数:
1、min-slaves-to-write :设置主节点在至少有指定数量的从节点确认写操作的情况下才执行写操作
2、min-salves-max-lag:设置从节点的最大延迟(以秒为单位),如果从节点的延迟超过这个值,则该从节点不会被计入 min-slaves-to-write 的计数中
举个例子:当 min-slaves-to-write设置为2,min-slaves-max-lag设置为 10 秒时,主节点只有在至少有 2 个从节点延迟不超过 10 秒的情况下才会接受写操作。
这两个参数就使得发生脑裂的时候,如果某个主节点跟随的从节点数量不够或延迟较大,就无法被写入,这样就能避免脑裂导致的数据不一致。
建议集群部署奇数个节点,例如集群数为5,那么可以设置 min-slaves-to-write为3,min-slaves-max-lag 为 5-10 秒。
🚨重点:脑裂是针对某个分片(某个主节点对应的数据子集)的主从切换混乱,而不是整个集群就一个主节点写入。
换句话说,脑裂是分片级别的主节点冲突。整个集群是多个分片,每个分片都可能独立脑裂。
三、 Redis如何实现分布式锁?
通过 set ex nx 命令 + lua 脚本组合使用。确保多个客户端不会获得同一个资源锁的同时,也保证了安全解锁和意外情况下锁的自动释放。
1\] 加锁:SET lock_key uniqueValue EX expire_time NX (SET 锁的key 唯一标识 EX 过期时间 NX) \[2\] 解锁:使用 lua 脚本,先通过 get 获取 key 的 value 判断锁是否是自己加的,如果是则 del。 首先锁需要有过期机制。假设某个客户端加了锁之后宕机了,锁没有设置过期机制,会使得其他客户端都无法抢到锁。 Ex expire_time 就是设置了锁的过期,单位是秒。还一个PX,也是过期时间,单位是毫秒。 再解释下什么是 uniquevalue,翻译过来就是一个唯一的值。之所以要设置唯一值是为了防止被别的客户端给释放了。我们来看下这个场景: 1.客户端1 加锁成功,然后执行业务逻辑,但执行的时间超过了锁的过期时间 2.此时锁已经过期被释放了,客户端 2 加锁成功 3.客户端 2 执行业务逻辑 4.客户端 1执行完了,执行释放锁的逻辑,即删除锁。 客户端 2一脸懵,我还在执行着呢,怎么锁被人释放了?所以每个客户端加锁(客户端可能是每个线程),需要是设置一个唯一标识,比如一个 uuid,防止锁被别的客户端误释放了。 具体而言: UUID 是客户端(或者线程)生成的唯一标识,每次加锁都带着自己的 UUID 去申请锁。 加锁成功后,Redis 里这个锁的 value 就是这个 UUID。 释放锁的时候,你先比对 value(Redis 里存的 UUID)和自己线程的 UUID,如果一致,再删;否则啥都不干,别误伤别人。 因为需要先判断锁的值和唯一标识是否一致,一致后再删除释放锁,这里就涉及到两步操作,只有使用了lua 脚本才能保证原子性,这也是为什么释放锁需要用 lua 脚本的原因。 单点故障问题 单台 Redis 实现分布式锁存在单点故障问题,如果采用主从读写分离架构,如果一个客户端在主节点上锁成功,但是主节点突然宕机,由于主从延迟导致从节点还未同步到这个锁,此时可能有另一个客户端抢到新晋升的主节点,此时会导致两个客户端抢到锁,产生了数据不一致。基于这个情况,Redis 推出了 Redlock。 Redlock 红锁 Redlock 是 Redis 官方推荐的一种实现分布式锁的算法,适用于集群环境下。 Redlock 的基本思想: * 部署多个 Redis 实例(通常为 5 个). * 客户端在大多数实例(如至少3个)上请求锁,并在一定时间内获得成功,表示加锁成功。 * 使用 Redlock 可以提供更高的容错性,即使部分 Redis 实例故障,仍然可以获得锁。 Redlock 的实现流程: * 客户端尝试在每个 Redis 实例上加锁,必须在有限时间内(通常为锁的过期时间)完成所有实例的加锁。 * 如果大多数实例(N/2+1)加锁成功,则表示加锁成功。 * 否则,客户端将释放所有已经加锁的实例,重新尝试。 Redlock 的缺点包括 * 复杂性:实现 Redlock 需要多个 Redis 实例,增加了系统的复杂性和维护成本。 * 时间同步依赖:Redlock 依赖于多个节点的系统时间的一致性。如果节点之间的时间不同步(例如发生时间回拨),可能导致锁的有效性受到影响。 ## 四、 说说Redission 分布式锁的原理 Redlock 是算法,Redisson 是工具库 Redisson 是基于 Redis 实现的分布式锁,实际上是使用 Redis 的原子操作来确保多线程、多进程或多节点系统中,只有一个线程能获得锁,避免并发操作导致的数据不一致问题。 1、锁的获取: Redisson 使用 Lua 脚本,利用 exists + hexists + hincrby 命令来保证只有一个线程能成功设置键(表示获得锁)。 同时,Redisson 会通过 pexpire 命令为锁设置过期时间,防止因宕机等原因导致锁无法释放(即死锁问题)。. 2、锁的续期: 为了防止锁在持有过程中过期导致其他线程抢占锁,Redisson 实现了锁自动续期的功能。持有锁的线程会定期续期,即更新锁的过期时间,确保任务没有完成时锁不会失效。 3、锁的释放: 锁释放时,Redisson 也是通过 Lua 脚本保证释放操作的原子性。利用 hexists + del 确保只有持有锁的线程才能释放锁,防止误释放锁的情况。 Lua 脚本同时利用 publish 命令,广播唤醒其它等待的线程 4)可重入锁: Redisson 支持可重入锁,持有锁的线程可以多次获取同一把锁而不会被阻塞。具体是利用 Redis 中的哈希结构,哈希中的 key 为线程 ID,如果重入则 value + 1,如果释放则 value - 1,减到 0 说明锁被释放了,则 del 锁。 Redisson 的锁类型: * 公平锁:与可重入锁类似,公平锁确保多个线程按请求锁的顺序获得锁。 * 读写锁:支持读写分离。多个线程可以同时获得读锁,而写锁是独占的, * 信号量与可数锁:允许多个线程同时持有锁,适用于资源的限流和控制。 Redission的看门狗机制是什么? Redisson 的看门狗(watchdog)主要用来避免 Redis 中的锁在超时后业务逻辑还未执行完毕,锁却被自动释放的情况。它通过定期刷新锁的过期时间来实现自动续期。 主要原理: * 定时刷新:如果当前分布式锁未设置过期时间,Redisson 基于 Netty 时间轮启动一个定时任务,定期向 Redis发送命令更新锁的过期时间,默认每 10s 发送一次请求,每次续期 30s。 * 释放锁:当客户端主动释放锁时,Redisson 会取消看门狗刷新操作。如果客户端宕机了,定时任务锁也会自动释放。自然也就无法执行了,此时等超时时间到了。 ## 五、 Redis实现分布式锁时可能遇到的问题有哪些? 1、业务未执行完,锁已到期 为了避免持有锁的客户端崩溃或因网络问题断开连接时,锁无法被正常释放,需要给锁设置过期时间。那么就有可能出现业务还在执行,锁已到期的情况。 可以设置一种续约机制(Redisson 中的看门狗机制),线程a在执行的时候,设置一个超时时间,并且启动一个守护线程,守护线程每隔一段时间就去判断线程a的执行情况,如果a还没有执行完毕并且a的时间快过期了,就重新设置一下超时时间,即继续续约。锁的过期时间的设置也需要好好评估一下。 2、单点故障问题 如果 Redis 单机部署,当实例宕机或不可用,整个分布式锁服务将无法正常工作,阻塞业务的正常执行。 3、主从问题 如果线上 Redis 是主从 + 哨兵部署的,则分布式锁可能会有问题。因为 Redis 的主从复制过程是异步实现的,如果 Redis 主节点获取到锁之后,还没同步到其他的从节点,此时 Redis 主节点发生宕机了,这个时候新的主节点上没锁的数据,因此其他客户端可以获取锁,就会导致多个应用服务同时获取锁。 4、网络分区 在网络不稳定的情况下,客户端与 Redis 之间的连接可能中断,如果未设置锁的过期时间,可能会导致锁无法正常释放。如果有多个锁,还可能引发锁的死锁情况。 5、时钟漂移 因为 Redis 分布式锁依赖于实例的时间来判断是否过期,如果时钟出现漂移,很可能导致锁直接失效。 可以让所有节点的系统时钟通过 NTP 服务进行同步,减少时钟漂移的影响。 ## 六、 如何使用Redis快速实现排行榜? 使用 Redis 实现排行榜的方式主要利用 Sorted Set(有序集合),它可以高效地存储、更新、以及获取排名数据。 实现排行榜的主要步骤 1)使用 Sorted Set 存储分数和成员 使用 Redis 的 ZADD 命令,将用户和对应的分数添加到有序集合中。例如:ZADD leaderboard 1000 user1,将用户 user1 的分数设置为 1000。 2)获取排名 使用 ZRANK 命令获取某个用户的排名。例如:ZRANK leaderboard user1,返回用户 user1 的排名(从0开始)。 3)获取前 N 名 使用 ZREVRANGE 命令获取分数最高的前N名。例如:ZREVRANGE leaderboard 89 WITHSCORES,获取排行榜前 10 名用户及其分数。 4)更新分数 如果用户的分数需要更新,可以使用 ZINCRBY 命令对其分数进行加减操作。例如:ZINCRBYleaderboard 580 user1,将用户 user1 的分数增加 500。 Sorted Set 的特点 * Sorted Set 是 Redis 中的一个数据结构,内部使用 跳表(Skip List)来实现,提供按分数排序的功能。每个成员有一个唯一的 score(分数),根据分数进行排序。 * Redis 的 Sorted Set 通过 O(logN)的时间复杂度进行插入、更新和删除操作,且可以通过范围查找快速获取指定区间的数据。 * 使用 Sorted Set 可以确保成员唯一性,因为 Redis 的 Sorted Set 中每个成员都是唯一的。如果添加相同的成员,ZADD 将更新其分数而不是重复插入。 ## 七、 Redis如何保证缓存与数据库的一致性? 1、先更新数据库,再删除缓存,后续等查询把数据库的数据回种到缓存中 2、缓存双删策略。更新数据库之前,删除一次缓存;更新完数据库后,再进行一次延迟删除 3、使用 Binlog 异步更新缓存,监听数据库的 Binlog 变化,通过异步方式更新 Redis 缓存 * 如果是要考虑实时一致性的话,先写 MySQL,再删除 Redis 应该是较为优的方案,虽然短期内数据可能不一致,不过其能尽量保证数据的一致性。 * 如果考虑最终一致性的话,推荐的是使用 binlog + 消息队列的方式,这个方案其有重试和顺序消费,能够最大限度地保证缓存与数据库的最终一致性。 强一致性补充 可以使用分布式读写锁实现强一致性。读写锁中读和读操作不互斥,读写斥,写写斥。 写操作流程: * 获取写锁。 * 更新数据库。 * 删除缓存。 * 释放写锁。 读操作流程: * 获取读锁。 * 读取数据库,并查询缓存:如果命中缓存,释放读锁,返回结果。如果缓存未命中,将数据更新到缓存。 * 释放读锁。 ## 八、 Redis为什么这么快? 主要有 3 个方面的原因,分别是存储方式、优秀的线程模型以及 IO 模型、高效的数据结构。 * Redis 将数据存储在内存中,提供快速的读写速度,相比于传统的磁盘数据库,内存访问速度快得多。 * Redis 使用单线程事件驱动模型结合 I/0 多路复用,避免了多线程上下文切换和竞争条件,提高了并发处理效率。 * Redis 提供多种高效的数据结构(如字符串、哈希、列表、集合等),这些结构经过优化,能够快速完成各种操作。 什么是IO多路复用 I/O 多路复用(I/O multiplexing)就是通过一个线程或进程,监听多个 I/O 通道(比如多个 socket),一旦哪个通道有数据,就去处理它。 1、多个 socket(Socket1, Socket2, Socket3) 2、I/O 多路复用(用 select / poll / epoll 等机制)盯着所有 socket,看哪个就绪 3、就绪了就放进 事件循环 (Event Loop) 4、事件循环把任务放进 任务队列 (Task Queue) 5、交给 事件调度器 (Event Dispatcher) 派发给具体处理器 (Event Processors) 🌟 多路复用 = 多 socket 复用一个线程去读写 🌟 事件循环 + 多路复用 = 单线程高效处理成千上万连接! 多 socket → I/O 多路复用 (epoll/select/poll) → Event Loop(单线程轮询)→ 任务队列 → 事件分发器 → 多线程处理任务 流程核心: 👉 I/O 多路复用(如 epoll/select/poll)本质上不是你在用户态写个 while 死循环去 poll socket。 👉 它是通过 一次系统调用,把所有要监听的 socket 列表交给内核,挂在内核里的等待队列上,内核负责帮你盯着这些 socket 状态变化。 👉 当有就绪事件发生时,内核会唤醒你(比如唤醒 epoll_wait 阻塞的线程),你再去处理。 以 epoll 为例: 1、你的程序调用 epoll_create() 建立 epoll 实例(内核数据结构) 2、调用 epoll_ctl() 把多个 socket 注册到 epoll 的 interest list(关心的事件列表) 3、调用 epoll_wait() 阻塞在那(进入内核态,挂在内核的等待队列里) 重点:此时你的线程处于睡眠状态,不占 CPU。是内核在帮你盯着所有 socket 的状态变化。 ## 九、 Redis快速实现布隆过滤器 可以通过使用 位图(Bitmap) 或使用 Redis 模块 RedisBloom。 1)使用位图实现布隆过滤器 * 使用 Redis 的位图结构 SETBIT 和 GETBIT 操作来实现布隆过滤器。位图本质上是一个比特数组,用于标识元素是否存在。 * 对于给定的数据,通过多个 哈希函数 计算位置索引,将位图中的相应位置设置为 1,表示该元素可能存在。 2)使用 RedisBloom 模块 Redis 提供了一个官方模块 RedisBloom,封装了哈希函数、位图大小等操作,可以直接用于创建和管理布隆过滤器 使用 BF.ADD 来向布隆过滤器添加元素,使用 BF.EXISTS 来检查某个元素是否可能存在。 布隆过滤器优缺点 1)优点 * 高效性:插入和查询操作都非常高效,时间复杂度为 O(k),k为哈希函数的数量。 * 节省空间:相比于直接存储所有元素,布隆过滤器大幅度减少了内存使用。 * 可扩展性:可以根据需要调整位数组的大小和哈希函数的数量来平衡时间和空间效率。 2)缺点 * 误判率:可能会误认为不存在的元素在集合中,但不会漏报(不存在的元素不会被认为存在) * 不可删除:一旦插入元素,不能删除,因为无法确定哪些哈希值是由哪个元素设置的。 * 需要多个哈希函数:选择合适的哈希函数并保证它们独立性并不容易 ## 十、 为什么 Redis 设计为单线程?6.0 版本为何引入多线程? 单线程设计原因: 1、 Redis 的操作是基于内存的,其大多数操作的性能瓶颈主要不是 CPU 导致的 2、使用单线程模型,代码简便的同时也减少了线程上下文切换带来的性能开销 3、Redis 在单线程的情况下,使用I/O 多路复用模型就可以提高 Redis 的 I/O 利用率了。 ## 十一、Redis常见的数据类型是什么? String 字符串是Redis中最基本的数据类型,可以存储任何类型的数据,个包括文本、数字和二进制数据。它的最大长度为512MB。 使用场景: * 缓存:存储临时数据,如用户会话、页面缓存。 * 计数器:用于统计访问量、点赞数等,通过原子操作增加或减少。 Hash 哈希是一个键值对集合,适合存储对象的属性。Redis内部使用哈希表实现,适合小规模数据。 使用场景 * 商品详情:存储商品的各个属性,方便快速检索。 List 列表是有序的字符串集合,支持从两端推入和弹出元素,底层实现为双向链表 使用场景: * 消息队列:用于简单任务调度、消息传递等场景,通过 LPUSH 和 RPOP 操作实现生产者消费者模式. * 历史记录:存储用户操作的历史记录,便于快速访问。 Set 集合是无序且不重复的字符串集合,使用哈希表实现,支持快速查找和去重操作 使用场景: * 标签系统:存储用户的兴趣标签,避免重复, * 唯一用户集合:记录访问过某个页面的唯一用户,方便进行分析。 Sorted Set 有序集合类似于集合,但每个元素都有一个分数(score),用于排序。底层使用跳表实现,支持快速的范围查询。 使用场景: * 排行榜:存储用户分数,实现实时排行榜。 * 任务调度:根据任务的优先级进行排序,方便调度执行。 ## 十二、Redis中跳表的实现原理是什么? 跳表主要是通过多层链表来实现,底层链表保存所有元素,而每一层链表都是下一层的子集。 * 插入时,首先从最高层开始查找插入位置,然后随机决定新节点的层数,最后在相应的层中插入节点并更新指针。 * 删除时,同样从最高层开始查找要删除的节点,并在各层中更新指针,以保持跳表的结构。 * 查找时,从最高层开始,逐层向下,直到找到目标元素或确定元素不存在。查找效率高,时间复杂度为 O(logn)2 为什么 Redis 跳表实现多了个回退指针(前驱指针) 回退指针主要是为了提高跳表的操作效率和灵活性。例如删除节点时,通过前驱指针可以在一次遍历中找到并记录所有关联的前驱节点,无需在变更指针时再次查找前驱节点。这种设计避免了重复查找过程,简化了操作逻辑,大幅提高了删除的执行效率。 ## 十三、Redis性能瓶颈时如何处理? 如果 Redis 无法承受当前的负载的话,可以考虑从以下几个解决方法去解决: 首先想到的是扩容,比如增加 Redis 的配置,容纳更多的内存等。如果超过单机配置了,那么可以上 redis 主从,通过从服务分担读取数据的压力,利用哨兵自动进行故障转移。 还可以利用 redis 集群进行数据分片,比如 Redis Cluster。 也可以增加本地内存,通过多级缓存分担 redis 的压力。 ## 十四、Redis的hash是什么? Hash 底层实现解析 Hash 是 Redis 中的一种数据基础数据结构,类似于数据结构中的哈希表,一个 Hash 可以存储 2 的 32 次方 -1 个键值对(约 40 亿)。底层结构需要分成两个情况。 Redis 6 及之前,Hash 的底层是压缩列表加上哈希表的数据结构(ziplist + hashtable) Redis7之后,Hash 的底层是紧凑列表(Listpack)加上哈希表的数据结构(Listpack+hashtable) ziplist 和 listpack 查找 key 的效率是类似的,时间复杂度都是 O(n),其主要区别就在于 listpack 解决了 ziplist 的级联更新问题。(ziplist 的级联更新 = 改一条数据,后面一大串数据跟着挪窝、改 prevlen,浪费时间还容易内存抖动。) 那么 hash 在什么时候使用 ziplist 和 listpack,什么时候使用 Hashtable 呢? Redis 内有两个值,分别是 hash-max-ziplist-entries(hash-max-listpack-entries)及 hash-max-ziplist-value(hash-max-listpack-value),即 Hash 类型键的字段个数(默认512)以及每个字段名和字段值的长度(默认64) * 512: field 的数量(也就是 key-value 对的个数)上限。 * 64: 每个 field 或 value 的长度上限(单位:字节)。 当 hash 小于这两个值的时候,会使用 listpack 或者 ziplist 进行存储。当大于这两个值的时候会使用 hashtable 进行存储。这里需要注意一个点,在使用 hashtable 结构之后,就不会再退化成 ziplist 或 listpack,之后都是使用 hashtable 进行存储。 ## 十五、Redis数据过期后的删除策略是什么? Redis 数据过期主要有两种删除策略,分别为定期删除、惰性删除两种: 定期删除:Redis 每隔一定时间(默认是 100 毫秒)会随机检査一定数量的键,如果发现过期键,则将其删除。这种方式能够在后台持续清理过期数据,防止内存膨胀。 惰性删除:在每次访问键时,Redis 检查该键是否已过期,如果已过期,则将其删除这种策略保证了在使用过程中只删除不再需要的数据,但在不访问过期键时不会立即清除。 ## 十六、内存回收机制 实际上,除了这两个删除,还有一个机制也会淘汰 key,即当 Redis 内存使用达到设置的maxmemory 限制时,会触发内存回收机制。此时会主动删除一些过期键和其他不需要的键,以释放内存。具体的删除策略有以下几种: ·volatile-lru: 从设置了过期时间的键中使用 LRU(Least Recently Used,最近最少使用)算法删除键。 ·allkeys-lru: 从所有键中使用 LRU 算法删除键 ·volatile-lfu: 从设置了过期时间的键中使用 LFU(Least Frequently Used,最少使用频率)算法删除键。 ·allkeys-lfu: 从所有键中使用 LFU 算法删除键。 ·volatile-random:从设置了过期时间的键中随机删除键,·allkeys-random:从所有键中随机删除键。 ·volatile-ttl: 从设置了过期时间的键中根据 TTL(Time to Live,存活时间)删除键优先删除存活时间短的键。 ·noeviction:不删除键,只是拒绝写入新的数据。 不同淘汰策略适用场景 noeviction:适用于所有数据都被持久化或避免丢失任何数据的场景。 allkeys-lru:适用于希望保证最常访问的数据在内存中的场景。如电商促销活动中某些商品会在短时间内被频繁访问,此时不会被淘汰。 allkeys-lfu: 适用于希望保证访问频率最高的数据在内存中的场景。如社交媒体或直播类应用中,某些热点内容访问次数高,不能被淘汰。 allkeys-random:适用于对数据没有严格的优先级需求。 volatile-lru:同 allkeys-lru ,仅只关心设置了过期时间的键 volatile-random:同allkeys-random ,仅只关心设置了过期时间的键 volatile-ttl:适用于对实时性较敏感的场景,比如存储用户会话等。 volatile-lfu:同 allkeys-lfu ,仅只关心设置了过期时间的键 ## 十七、Redis的HashTable dictht 一共有 4 个字段: ·table:哈希表实现存储元素的结构,可以看成是哈希节点(dictEntry)组成的数组。 ·size:表示哈希表的大小。 ·sizemask:这个是指哈希表大小的掩码,它的值永远等于 size - 1,这个属性和哈希值一起约定了哈希节点所处的哈希表的位置,索引的值index=hash(哈希值) \& sizemask. ·used:表示已经使用的节点数量。 我们再看下哈希节点(dictEntry)的组成,它主要由三个部分组成,分别为 key,value以及指向下一个哈希节点的指针。 渐进Rehash dict 有两个 dictht 组成,为什么需要 2 个哈希表呢?主要原因就是为了实现渐进式在平时,插入数据的时候,所有的数据都会写入 ht\[0\]即哈希表1,ht\[1\]哈希表2 此时就是一张没有分配空间的空表。 但是随着数据越来越多,当 dict 的空间不够的时候,就会触发扩容条件,其扩容流程主要分为三步: 1、首先,为哈希表2即分配空间。新表的大小是第一个大于等于原表2倍 used 的2次方幂。举个例子,如果原表即哈希表1的值是 1024,那个其扩容之后的新表大小就是2048。 分配好空间之后,此时 dict 就有了两个哈希表了,然后此时字典的 rehashidx 即 rehash索引的值从 -1 暂时变成0,然后便开始数据转移操作。 2、数据开始实现转移。每次对 hash 进行增删改查操作,都会将当前 rehashidx 的数据从在哈希表1迁移到2上,然后rehashidx+1,所以迁移的过程是分多次、渐进式地完成。(例如哈希表 1有8 个槽位,当前rehashidx = 0,执行一次增删改查操作,把哈希表1的第 0 号槽位的数据整个搬到新表,rehashidx++,变成 1,再来一个操作,搬迁哈希表1的第1号槽位) 3、随着操作不断执行,最终哈希表1的数据都会被迁移到 2 中,这时候进行指针对象进行互换,即哈希表 2 变成新的哈希表1,而原先的哈希表1变成哈希表 2并且设置为空表,最后将 rehashidx 的值设置为 -1。就这样,渐进式 rehash 的过程就完成了 ## 十八、Redis 的Big Key问题是什么?如何解决? Redis 中的"big Key"是指一个内存空间占用比较大的键(Key),它有什么危害呢? 内存分布不均。在集群模式下,不同 slot 分配到不同实例中,如果大 key 都映射一个实例,则分布不均,查询效率也会受到影响。 由于 Redis 单线程执行命令,操作大 Key 时耗时较长,从而导致 Redis 出现其它命令阻塞的问题。 大 Key 对资源的占用巨大,在你进行网络传输的时候,导致你获取过程中产生的网络流量较大,从而产生网络传输时间延长甚至网络传输发现阻塞的现象,例如一个key 2MB,请求个 1000 次 2000 MB. 客户端超时。因为操作大 Key 时耗时较长,可能导致客户端等待超时。 HSET product:123456 desc "\<这里塞了一个几兆的大描述,比如几万行HTML代码或者几兆的富文本内容\>" 如何解决? 开发方面 ·对要存储的数据进行压缩,压缩之后再进行存储 ·大化小,即把大对象拆分成小对象,即将一个大 Key 拆分成若干个小 Key,降低单个Key 的内存大小 ·使用合适的数据结构进行存储,比如一些用 String 存储的场景,可以考使用Hash、Set 等结构进行优化 业务层面 ·可以根据实际情况,调整存储策略,只存一些必要的数据。比如用户的不常用信息(地址等)不存储,仅存储用户ID、姓名、头像等。 ·优化业务逻辑,从根源上避免大 Key 的产生。比如一些可以不展示的信息,直接移除等。 ## 十九、如何解决Redis中的热点Key问题? Redis 中的热点 Key 问题是指某些 Key 被频繁访问,导致 Redis 的压力过大,进而影响整体性能甚至导致集群节点故障。 解决热点 Key 问题的主要方法包括 * 热点 key 拆分:将热点数据分散到多个 Key 中,例如通过引入随机前缀,使不同用户请求分散到多个 Key,多个 key分布在多实例中,避免集中访问单一Key。 * 多级缓存:在 Redis 前增加其他缓存层(如 CDN、本地缓存),以分担 Redis的访问压力。 * 读写分离:通过 Redis 主从复制,将读请求分发到多个从节点,从而减轻单节点压力。 * 限流和降级:在热点 Key 访问过高时,应用限流策略,减少对 Redis 的请求,或者在必要时返回降级的数据或空值。 ## 二十、Redis 的持久化机制 RDB (Redis Database)快照: * RDB 是通过生成某一时刻的数据快照来实现持久化的,可以在特定时间间隔内保存数据的快照。 * 适合灾难恢复和备份,能生成紧凑的二进制文件,但可能会在崩溃时丢失最后一次快照之后的数据。 AOF(Append Only File)日志 * AOF 通过将每个写操作追加到日志文件中实现持久化,支持将所有写操作记录下来以便恢复。 * 数据恢复更为精确,但文件体积较大,重写时可能会消耗更多资源。 RDB 持久化命令: * save:在主线程生成 RDB 文件,因此生成其间,主进程无法执行正常的读写命令,需要等待 RDB 结束。 * bgsave:利用 Fork 操作得到子进程,在子进程执行 RDB 生成,不会阻塞主进程,默认使用 bgsave。 bgsave 流程(重点) * 检查子进程(检查是否存在 AOF/RDB 的子进程正在进行),如果有返回错误 * 触发持久化,调用 rdbSaveBackground * 开始 fork,子进程执行 rdb 操作,同时主进程响应其他操作。 * RDB 完成后,替换原来的旧 RDB 文件,子进程退出。 注意事项(重点) * Fork 操作会产生短暂的阻塞,微秒级别操作过后,不会阻塞主进程,整个过程不是完全的非阻塞 * RDB 由于是快照备份所有数据,而不是像AOF 一样存写命令,因为 Redis 实例重启后恢复数据的速度可以得到保证,大数据量下比AOF 会快很多。 * Fork采用写时复制。类似于CopyOnWriteArrayList。 它不会直接将主进程地址空间全部复制,而是共享同一个内存。在子进程创建时,"之后如果任意一个进程需要对内存进行修改操作,内存会重新复制一份提供给修改的进程单独使用。 写时复制 (COW) 到底会复制啥? 👉 它只会复制被修改的那一部分内存页,不会复制整个地址空间。 当你调用 fork(): • 子进程和父进程的虚拟地址空间完全一样(看上去像复制了一份,但实际上两个进程共享同一块物理内存)。 • 操作系统会标记这些物理内存页为只读 + 引用计数。 💥 当父子进程中有一个试图写某个页: • 操作系统检测到写入触发页保护异常(Page Fault)。 • 然后 只复制当前要修改的这个内存页(通常是 4KB 大小),分配一个新物理页。 • 让修改的进程用新的物理页,另一个进程继续用原来的页。 什么是写时复制? 写时复制是一种保证数据一致性和线程安全的技术,核心思想是在进行写操作时,不直接修改原来的数据结构,而是先复制一份副本,在副本上进行修改,然后将修改后的副本替换原来的数据结构。在 copyonwriteArrayList 中,底层数据结构是一个 volatile 修饰的数组,当对列表进行写操作时,操作步骤如下: 1、读取当前数组:首先读取当前的数组,这个数组是copyonwriteArrayList当前持有的数组. 2、复制数组:创建一个当前数组的副本(新的数组),这个副本会拷贝当前数组中的所有元素 3、在副本上进行修改:在副本数组上进行写操作(如添加、删除元素)。 4、用新数组替换旧数组:将修改后的副本数组设置为copyonwriteArrayList 持有的数组,旧数组将不再使用. 4、读操作读取最新数组:所有读操作都可以无锁地直接读取 copyonwriteArrayList当前持有的数组,因为这个数组在读操作期间不会被修改, * Redis 的写时复制(操作系统级):只在真正写内存页时复制,并不是在逻辑上主动复制整个数据结构。写了哪个内存页,只复制那一页(按内存页粒度),粒度小。 * Java CopyOnWriteArrayList 的写时复制:每次写都得主动复制整个数组,粒度大,写成本高。 AOF 持久化详解 AOF 持久化机制是指将 Redis 写命令以追加的形式写入到磁盘中的 AOF 文件,AOF 文件记录了 Redis 在内存中的操作过程,只要在 Redis 重启后重新执行 AOF 文件中的写命令即可将数据恢复到内存中。 AOF 机制的优点: AOF 机制比 RDB 机制更加可靠,因为 AOF 文件记录了 Redis 执行的所有写命令,可以在每次写操作命令执行完毕后都落盘存储。 AOF 机制的缺点: AOF 机制生成的 AOF 文件比 RDB 文件更大,当数据集比较大时,AOF 文件会比 RDB 文件占用更多的磁盘空间。 AOF 机制对于数据恢复的时间比 RDB 机制更加耗时,因为要重新执行 AOF 文件中的所有操作命令。 AOF 重写机制 AOF 文件随着写操作的增加会不断变大,过大的 AOF 文件会导致恢复速度变慢,并消耗大量磁盘空间。所以,Redis 提供了 AOF重写机制,即对 AOF 文件进行压缩,通过最少的命令来重新生成一个等效的 AOF 文件。拿 key A举个例子,AOF 记录了每次写命令如 set A 1、实际上前set A 2、set A3。前面的 set A1、set A2 是历史值,我们仅关心最新的值,因此 AOF 重写就是仅记录数据的最终值即可,即 set A3,这样 AOF 文件就"瘦身"了。注意:AOF 重写并不是对现有的 AOF 文件进行修改,而是根据当前每个键的最新值转换为对应的写命令,写入新的 AOF 文件,形成一个新文件。注意:后台 AOF 重写,也是需要 fork 子线程,因此也有写时复制的机制。 ## 二十一、Redis中的缓存击穿、缓存穿透和缓存雪崩 缓存击穿:指某个热点数据在缓存中失效,导致大量请求直接访问数据库。此时,由于瞬间的高并发,可能导致数据库崩溃, 缓存穿透:指查询一个不存在的数据,缓存中没有相应的记录,每次请求都会去数据库查询,造成数据库负担加重。 缓存雪崩:指多个缓存数据在同一时间过期,导致大量请求同时访问数据库,从而造成数据库瞬间负载激增。 缓存击穿: * 使用互斥锁,确保同一时间只有一个请求可以去数据库查询并更新缓存。 * 热点数据永不过期。 缓存穿透 * 使用布隆过滤器,过滤掉不存在的请求,避免直接访问数据库。 * 对查询结果进行缓存,即使是不存在的数据,也可以缓存一个标识,以减少对数据库的请求。 缓存雪崩 * 采用随机过期时间策略,避免多个数据同时过期。 * 使用双缓存策略,将数据同时存储在两层缓存中,减少数据库直接请求。 ## 二十二、哨兵机制 Redis 的哨兵机制(Sentinel)是一种高可用性解决方案,用于监控 Redis 主从集群,自动完成主从切换,以实现故障自动恢复和通知。主要功能包括: * 监控:哨兵不断监控 Redis 主节点和从节点的运行状态,定期发送 PING 请求检查节点是否正常, * 自动故障转移:当主节点发生故障时,哨兵会选举一个从节点提升为新的主节点,并通知客户端更新主节点的地址,从而实现高可用。 * 通知:哨兵可以向系统管理员或其他服务发送通知,以便快速处理 Redis 实例的状态变化。 主观下线和客观下线 哨兵是如何判断 Redis 中主节点挂了的呢?主要涉及到了两个机制:主观下线以及客观下线、 1、主观下线:Sentinel 每隔 1s 会发送 ping 命令给所有的节点。如果 Sentinel 超过一段时间还未收到对应节点的 pong 回复,就会认为这个节点主观下线。 2、客观下线:假设目前有个主节点被一个 sentinel的判断主观下线了,但可能主节点并没问题,只是因为网络抖动导致了一台哨兵的误判。所以此时哨兵需要问问它的队友,来确定这个主节点是不是真的出了问题! 因此,它会向其他哨兵发起投票,其他哨兵会判断主节点的状态进行投票,可以投赞成或反对。 注意,只有主节点才有客观下线,从节点没有。 如果认为下线的总投票数大于 quorum(一般为集群总数 / 2 + 1,假设哨兵集群有 3 台实例,那么3 / 2 + 1 = 2),则判定该主节点客观下线,此时就需要进行主从切换,而只有哨兵的 leader 才能操作主从切换。 Sentinel leader 是如何选举出来的? Sentinel leader 节点的选举实际上涉及到分布式算法 raft,感兴趣的同学可以深入去了解一下,这里主要简单说一下哨兵集群选择 leader 的方式:判断主节点主观下线的 sentinel 就是候选者,此时它想成为 leader。如果同时有两个sentinel 判断主观下线,那么它们都是候选人,一起竞争成为leader。候选者们会先投自己一票,然后向其他 sentinel发送命令让它们给自己投票。每个哨兵手里只有一票,投了一个之后就不能投别人了。最后,如果某个候选者拿到哨兵集群半数及以上的赞成票,就会成为leader。这里有一个注意的点,为了保证 sentinel 选举的时候尽量避免出现平票的情况,sentinel 的节点个数一般都会是奇数,比如 3,5,7 这样。 Redis 主节点选举 选出哨兵 leader 之后,需要选出 Redis 主从集群中的新 master 节点。首先需要把一些已经下线的节点全部剔除,然后从正常的从节点中选择主节点,其主要经过以下三个流程: 1、根据从节点的优先级进行选择,优先选择优先级的值比较小的节点(优先级的值越小优先级越高,优先级可通过slave-priority 配置) 2、如果节点的优先级相同,则査看进行主从复制的 offset 的值,即复制的偏移量,偏移量越大则表示其同步的数据越多,优先级越高。 3、如果 offset 也相同了,那只能比较 ID 号,选择 ID 号比较小的那个作为主节点(每个实例 ID 不同)。 选好主节点之后,哨兵 leader 会让其他从节点全部成为新 master 节点的 slave 节点。最后利用 redis 的发布/订阅机制,把新主节点的 IP 和端口信息推送给客户端,此时主从切换就结束了。 旧主节点恢复了怎么办?实际上哨兵会继续监视旧的主节点,如果它上线了,哨兵集群会向它发送 slaveof 命令,让它成为新主节点的从节点。