这里是Redis相关的八股
Redis默认端口6379
1.基础
1.Redis为什么快?
内存操作:完全基于内存,绝大部分请求是纯粹的内存操作,非常快速
单线程模型:避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。
I/O多路复用模型:采用了 I/O 多路复用机制处理大量的客户端 Socket 请求,IO 多路复用机制是指一个线程处理多个 IO 流。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中, 同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。
2.Redis有哪些数据类型?常用的使用场景
- String:存储单个值,适用于缓存和键值存储,常用命令:SET用于设置值,GET用于获取值。
- 分布式锁、分布式Session、值缓存、浏览数、分库分表主键序列号
- List:有序、可重复的字符串集合,适用于消息队列和发布/订阅系统,常用命令:LPUSH用于从列表左侧添加元素,LRANGE用于获取指定范围的元素。
- 分布式Duque、消息队列、Push式信息流
- Set:无序、不可重复的字符串集合,适用于标签系统和好友关系等,常用命令:SADD用于向集合添加成员,SMEMBERS用于获取集合所有成员。
- 点赞、抽奖、集合运算
- Hash:包含键值对的无序散列表,适用于存储对象、缓存和计数器,常用命令:HSET用于设置字段值,HGETALL用于获取散列的所有字段和值。
- 购物车、对象存储
- Zset:也就是Sorted Set,有序的字符串集合,每个成员关联一个分数,适用于排行榜和按分数范围获取成员,常用命令:ZADD用于添加成员及其分数,ZRANGE用于获取指定范围的成员,
- 热搜、最近播放
3.String还是Hash存储对象更好呢?
性能
- String:适合存储和读取大对象,因为它是整体操作,性能较高。
- Hash:适合操作大量小字段,可以只处理需要的字段,减少不必要的数据传输。
内存
- String:存储大对象时内存开销可能较大,尤其是对象频繁序列化和反序列化。
- Hash:在存储大量小对象时更节省内存,因为字段共享同一个键名。
操作需求
- String:如果对象不可变,或者总是整体读写,String更简单。
- Hash:如果需要频繁访问或修改对象的某个字段,Hash更合适。
3.Redis可以用来做什么?
- 缓存
- 排行榜
- 分布式计数器
- 分布式锁
- 消息队列
- 延时队列
- 分布式 token
- 限流
2.持久化
什么是持久化?
大部分原因是为了之后重用数据(比如重启机器、机器故障之后恢复数据),或者是为了做数据同步(比如 Redis 集群的主从节点通过 RDB 文件同步数据)。 Redis 提供两种持久化机制 RDB(默认) 和 AOF 机制:
1.RDB
RDB:是缩写快照
RDB(Redis DataBase)是Redis默认的持久化方式。将某一时刻的内存数据,以二进制的方式写入磁盘; 对应产生的数据文件为dump.rdb。通过配置文件中的save参数来定义快照的周期。
因为 AOF 日志记录的是操作命令,不是实际的数据,所以用 AOF 方法做故障恢复时 ,需要全量把日志都执行一遍,一旦 AOF 日志非常多,势必会造成 Redis 的恢复操作缓慢。 为了解决这个问题,Redis 增加了 RDB 快照。 RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据
Redis 提供了两个命令来生成 RDB 快照文件:
save
:同步保存操作,会阻塞 Redis 主进程;bgsave
:fork 出一个子进程,子进程执行,不会阻塞 Redis 主线程,默认选项。
2.RDB写时复制(Copy-on-Write)
假如RDB持久化过程中数据发生了改变怎么办?
在 RDB 快照生成过程中,如果数据发生了改变,Redis 的处理机制可以保证快照的一致性,同时不会丢失后续的修改。具体来说,Redis 使用了一种叫做 Copy-on-Write(写时复制) 的技术来解决这个问题。
先回顾快照的生成过程:
- 当 Redis 触发 RDB 持久化时(比如通过 SAVE 或 BGSAVE 命令),它会创建一个子进程。
- 子进程负责将内存中的数据写入磁盘,生成 .rdb 文件。
- 主进程继续处理客户端的请求(比如读写操作)。
Copy-on-Write 机制 :
- 在子进程生成快照时,它会基于某个时间点(触发快照的瞬间)的内存数据进行操作。
- 如果主进程在这期间修改了数据,操作系统会利用 写时复制:
- 被修改的数据会被复制一份,主进程在新副本上操作。
- 子进程仍然使用原始数据(未修改时的内存快照)生成快照。
- 这样,主进程的修改不会影响子进程正在生成的快照,快照仍然是触发时刻的一致性数据。
数据丢失风险:快照生成后到下一次快照之间的修改,如果没来得及保存(比如 Redis 崩溃),会丢失。
3.AOF
AOF持久化(即Append Only File),Redis 在执行完一条写操作命令后,就会把该命令以追加的方式写入到一个文件里, 然后 Redis 重启时,会读取该文件记录的命令,然后逐一执行命令的方式来进行数据恢复。
AOF为什么是在执行完写命令才将该命令记录到AOF日志?
- 避免额外的检查开销,AOF 记录日志不会对命令进行语法检查;
- 在命令执行完之后再记录,不会阻塞当前的命令执行。
潜在风险
- 如果刚执行完命令 Redis 就宕机会导致对应的修改丢失;
- 可能会阻塞后续其他命令的执行(AOF 记录日志是在 Redis 主线程中进行的,也就是说这两个操作是同步的,如果在将日志内容写入到硬盘时,服务器的硬盘的 I/O 压力太大,就会导致写硬盘的速度很慢, 进而阻塞住了,也就会导致后续的命令无法执行。
- 优点:首先,AOF提供了更好的数据安全性,因为它默认每接收到一个写命令就会追加到文件末尾。 即使Redis服务器宕机,也只会丢失最后一次写入前的数据。
- 缺点:因为记录了每一个写操作,所以AOF文件通常比RDB文件更大,消耗更多的磁盘空间。
总结:RDB是Redis的快照持久化方式,通过周期性的快照将数据保存到硬盘,占用更少的磁盘空间和 CPU资源,适用于数据备份和恢复,但可能存在数据丢失的风险。AOF 是追加日志持久化方式,将每个写操作以追加的方式记录到日志文件中,确保了更高的数据完整性和持久性,但相对于RDB 消耗更多的磁盘空间和写入性能,适用于数据持久化和灾难恢复,且可以通过配置实现不同的同步频率。
4.如何选择
1.RDB
缺点:周期性保存快照,但如果下次快照前宕机,会丢失数据很多
优点:保存的是原始数据,恢复起来比AOF更快(因为AOF保存的是命令还要执行);文件小,适合做数据的备份,灾难恢复。
2.AOF
缺点:恢复慢
优点:数据更加完整(即使Redis服务器宕机,也只会丢失最后一次写入前的数据)
建议开启混合模式
3.Redis内存管理
1.过期键的删除策略有哪些
- 定时过期(CPU不友好,内存友好):每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。
- 惰性过期(CPU友好,内存不友好):只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。
- 定期过期(前两种的折中):每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。
Redis key的过期时间和永久有效分别怎么设置
expire命令和persist命令
2.内存淘汰粗略有哪些
MySQL里有2000w数据,redis中只存20w的数据,如何保证redis中的数据都是热点数据?
redis内存数据集大小上升到一定大小的时候,就会施行数据淘汰策略。
全局的键空间选择性移除
- noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。
- allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key。(这个是最常用的)
- allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个key。
设置过期时间的键空间选择性移除
- volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的key。
- volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个key。
- volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,挑选将要过期的数据淘汰。
4.缓存篇
1.缓存一致性方案
1、Cache Aside Pattern (旁路缓存模式,双写模式)
对于读:从缓存中读取,如果读不到,就从数据库中读取,并把这个数据写入缓存
对于写:先更新数据库,然后直接删除缓存
为什么不是采用更新缓存模式,而是采用删除缓存?
答:可能会造成无效写操作。如果更新的缓存很多,但这期间并没有查询操作,就造成了无效的写操作
为什么先更新数据库,而不是先删除缓存,再更新数据库?
答:因为数据不一致。写线程A删除了缓存,但还没来得及更新数据库,此时,读线程B未命中缓存,去数据库读取旧数据,并写入缓存返回了,然后线程A更新了数据库中的数据,此时就数据不一致了。在这个过程中,写线程更新数据库的时间都比读线程读数据库+写回Redis的时间长了,所以非常有可能发生数据不一致
采用先更新数据库,再删除缓存就没有问题了吗?
答:也有可能会有问题,但概率会小的多!只有这种恰巧的情况:读线程读的时候缓存失效了,而且就在它访问完数据库之后,准备写回缓存之前,这是写线程一口气执行完了更新数据库和删缓存这两个操作,然后读线程把旧的数据写回了缓存。但这种可能性很低,因为一般来说更新数据库是非常耗时的。
问题3如何解决?
答:延迟双删。在读线程把旧数据写回缓存后,然后写线程隔一小段时间再把这个缓存给删了,就是写线程要删除两次缓存。或者消息队列(TODO)
2、Read/Write Through Pattern(读写穿透,直写缓存模式 ,写穿)
在此模式下,所有写操作都会先更新缓存,然后再同步更新数据库。
对于读:从缓存中读取,如果读不到,从数据库中读取,然后写回cache后再返回(和双写模式一样)
对于写:先查缓存,缓存中没有就直接更新数据库,缓存中有,先更新缓存,再接着更新数据库。
3、Write behind Pattern(异步缓存写入,异步写)
写操作只更新缓存,不立即同步到数据库,而是延迟批量更新数据库。
对于读:和双写模式一样
对于写:只更新缓存,然后异步的更新数据库,可以将更新数据库的任务交给消息队列或者线程池
Read/Write Through 是同步更新 cache 和 db,而 Write Behind 则是只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db。
很明显,这种方式对数据一致性带来了更大的挑战,比如 cache 数据可能还没异步更新 db 的话,cache 服务可能就就挂掉了。
这种策略在我们平时开发过程中也非常非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 Innodb Buffer Pool 机制都用到了这种策略。
Write Behind Pattern 下 db 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。
三种模式比较
三者读性能一样的,主要区别就是写性能
旁路缓存模式:高并发场景可能存在短暂数据不一致。适合读操作频繁、写操作较少且数据一致性要求高的场景,例如用户信息、商品详情查询等。
写穿模式:写入最慢,一致性最好。适合数据一致性要求较高的场景,例如金融交易系统。
异步写模式:写入最快,一致性最差。适合写操作频繁、对一致性要求不高且容忍一定延迟的场景,例如日志系统、计数统计等。
2.缓存穿透
缓存和数据库中都没有用户要访问的数据,当有大量这样的请求到来时,数据库的压力骤增 ,造成数据库短时间内承受大量请求而崩掉。
解决方案:
- 非法请求的限制:当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在, 如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。
- 缓存空值或者默认值:当我们线上业务发现缓存穿透的现象时,可以针对查询的数据, 在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值, 返回给应用,而不会继续查询数据库。 这种方式可以解决请求的 key 变化不频繁的情况,如果黑客恶意攻击,每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。
- 布隆过滤器:我们可以在写入数据库数据时,使用布隆过滤器做个标记, 然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在, 如果不存在,就不用通过查询数据库来判断数据是否存在。即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行
3.布隆过滤器
布隆过滤器主要是为了解决海量数据的存在性问题。对于海量数据中判定某个数据是否存在且容忍轻微误差这一场景(比如缓存穿透、海量数据去重)来说,非常适合。
布隆过滤器由一个二进制数组和多个哈希函数组成
- 开始时,布隆过滤器的每个位都被设置为 0。
- 当一个元素被添加到过滤器中时,它会被 k 个哈希函数分别计算得到 k 个位置,然后将位数组中对应的位设置为 1。
- 当检查一个元素是否存在于过滤器中时,同样使用 k 个哈希函数计算位置,如果任一位置的位为 0,则该元素肯定不在过滤器中;如果所有位置的位都为 1,则该元素可能在过滤器中。
为什么会误判?
当布隆过滤器保存的元素越多,被置为 1 的 bit 位就会越多。假设元素 x 没有存储过,但其他元素的哈希函数映射到位数组的三个位刚好都为 1 且恰好覆盖了元素 x 映射的位置,那么对于布隆过滤器来讲,元素 x 这个值就是存在的,也就是说布隆过滤器存在一定的误判率。
布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。
4.缓存击穿
缓存击穿中,请求的 key 对应的是 热点数据 ,该数据 存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期)。这就可能会导致瞬时大量的请求直接打到了数据库上,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。
解决方案:
- 互斥锁方案(看情况):保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求, 要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
- 永不过期(不推荐):设置热点数据永不过期或者过期时间比较长。
- 提前预热(推荐):针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。
5.缓存雪崩
当大量缓存数据在同一时间过期(失效)或者 Redis 故障宕机时,如果此时有大量的用户请求, 都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增, 严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,
解决方案:
针对 Redis 服务不可用的情况:
- Redis 集群:采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。Redis Cluster 和 Redis Sentinel 是两种最常用的 Redis 集群实现方案
- 多级缓存:设置多级缓存,例如本地缓存+Redis 缓存的二级缓存组合,当 Redis 缓存出现问题时,还可以从本地缓存中获取到部分数据。
针对大量缓存同时失效的情况:
- 设置随机失效时间(可选):为缓存设置随机的失效时间, 这样可以避免大量缓存同时到期,从而减少缓存雪崩的风险。
- 提前预热(推荐):针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。
- 设置缓存锁:在缓存失效时,设置一个短暂的锁定时间,只允许一个请求查询数据库并刷新缓存,其他请求等待锁释放后再读取缓存。
6.大Key问题
什么是大key?
Redis大key问题指的是某个key对应的value值所占的内存空间比较大, bigkey 除了会消耗更多的内存空间和带宽,还会对性能造成比较大的影响。
bigkey 除了会消耗更多的内存空间和带宽,还会对性能造成比较大的影响。
如何找到大key?
1、使用 Redis 自带的 –bigkeys 参数来查找。
如何处理大key?
- 分割 bigkey:将一个 bigkey 分割为多个小 key。 例如,将一个含有上万字段数量的 Hash 按照一定策略(比如二次哈希)拆分为多个 Hash。
- 删除大 key:Redis 4.0以上可以使用
UNLINK
命令来异步删除一个或多个指定的 key。Redis 4.0 以下可以考虑使用SCAN
命令结合DEL
命令来分删除。
7.热Key
什么是热key?
如果一个 key 的访问次数比较多且明显多于其他 key 的话,那这个 key 就可以看作是 hotkey(热 Key)。例如在 Redis 实例的每秒处理请求达到 5000 次,而其中某个 key 的每秒访问量就高达 2000 次,那这个 key 就可以看作是 hotkey。
处理 hotkey 会占用大量的 CPU 和带宽,可能会影响 Redis 实例对其他请求的正常处理。 此外,如果突然访问 hotkey 的请求超出了 Redis 的处理能力,Redis 就会直接宕机。 这种情况下,大量请求将落到后面的数据库上,可能会导致数据库崩溃。
hotkey 出现的原因主要是某个热点数据访问量暴增,如重大的热搜事件、参与秒杀的商品。
如何找到热key?
使用 Redis 自带的 –hotkeys 参数来查找。
如何处理大key?
- 读写分离:主节点处理写请求,从节点处理读请求。
- 使用 Redis Cluster:将热点数据分散存储在多个 Redis 节点上。
8.缓存预热
缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!
常见的缓存预热方式有两种:
- 使用定时任务,比如 xxl-job,来定时触发缓存预热的逻辑,将数据库中的热点数据查询出来并存入缓存中。
- 使用消息队列,比如 Kafka,来异步地进行缓存预热,将数据库中的热点数据的主键或者 ID 发送到消息队列中,然后由缓存服务消费消息队列中的数据,根据主键或者 ID 查询数据库并更新缓存。
5.底层结构
String——动态字符串SDS
List——双向链表
Set——哈希表
主要是ZSet的底层数据结构实现——跳表
1.SDS
Redis 是用 C 语言实现的,但其String类型是采用了一个叫做简单动态字符串(simple dynamic string,SDS) 的数据结构来表示字符串。
为什么不用C语言中的字符串呢?
获取字符串长度的时间复杂度为 O(N);
字符串的结尾是以 “\0” 字符标识,这就要求字符串里面不能包含有 “\0” 字符, 因此不能保存二进制数据;
字符串操作函数不高效且不安全, 比如有缓冲区溢出的风险,有可能会造成程序运行终止;
例:strcat 函数是可以将两个字符串拼接在一起。 C 语言的字符串是不会记录自身的缓冲区大小的, 所以 strcat 函数假定程序员在执行这个函数时,已经为拼接的字符串分配了足够多的内存,而如果没有,就会发生溢出
SDS结构
引入了len,alloc,flags解决了C语言字符串的问题
- len,记录了字符串长度。 获取字符串长度 时间复杂度只需要 O(1)。
- alloc,分配给字符数组的空间长度。 这样在修改字符串的时候,可以通过
alloc - len
计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作, 所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出的问题。 - flags,用来表示不同类型的 SDS。 能灵活保存不同大小的字符串,从而有效节省内存空间。
- buf[],字节数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据。
2.双向链表
优点:
- listNode 链表节点的结构里带有 prev 和 next 指针, 获取某个节点的前置节点或后置节点的时间复杂度只需O(1),
- list 结构因为提供了表头指针 head 和表尾节点 tail, 所以**获取链表的表头节点和表尾节点的时间复杂度只需O(1)**;
- list 结构因为提供了链表节点数量 len,所以**获取链表中的节点数量的时间复杂度只需O(1)**;
缺点:
- 链表每个节点之间的内存都是不连续的,意味着无法很好利用 CPU 缓存。 能很好利用 CPU 缓存的数据结构就是数组, 因为数组的内存是连续的,这样就可以充分利用 CPU 缓存来加速访问。
3.压缩列表ZipList
压缩列表是 Redis 为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构, 有点类似于数组。
压缩列表在表头有三个字段:
- zlbytes,记录整个压缩列表占用的内存字节数;
- zltail,记录压缩列表「尾部」节点距离起始地址有多少字节, 也就是列表尾的偏移量;
- zllen,记录压缩列表包含的节点数量;
- zlend,标记压缩列表的结束点,固定值 0xFF(十进制255)。
压缩列表解决了双向列表内存碎片的问题,因为双向链表每个节点存放在内存中不连续的位置。另外,ziplist 为了在细节上节省内存,对于值的存储采用了 变长编码方式, 大概意思是说,对于大的整数,就多用一些字节来存储,而对于小的整数,就少用一些字节来存储。
压缩列表的缺点是会发生连锁更新的问题,因此连锁更新一旦发生, 就会导致压缩列表占用的内存空间要多次重新分配,这就会直接影响到压缩列表的访问性能。
因此,压缩列表只会用于保存的节点数量不多的场景, 只要节点数量足够小,即使发生连锁更新,也是能接受的。
4.哈希表
dictionary
字典 dict
哈希表结构,哈希冲突,链式哈希这些都不说了,比较熟悉了,下面是不太熟悉的
1.rehash
也就是哈希表的扩容(不过注意处理哈希冲突有个再哈希法,那个意思是再用另一个哈希函数计算要插入的位置),一图说明
- 原哈希表的数据迁移到新的哈希表(长度是原来的2倍)
- 迁移完成后,释放原哈希表的空间,并让新哈希表指向原哈希表的地址
这样有个问题,就是如果哈希表数据过多,在迁移的时候,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求。 所以引出了渐进式hash
2.渐进式hash
渐进式hash把一次性大量数据迁移工作的开销,分摊到了多次处理请求的过程中, 避免了一次性 rehash 的耗时操作。
为了支持渐进式重哈希,Redis 的 dict 结构包含两个哈希表:
- **ht[0]**:当前正在使用的哈希表。
- **ht[1]**:用于重哈希的目标哈希表(初始为空)。
渐进式 rehash 步骤如下:
哈希表元素的删除、查找、更新等操作都会在这两个哈希表进行。比如,查找一个 key 的值的话,先会在「哈希表 1」 里面进行查找, 如果没找到,就会继续到哈希表 2 里面进行找到。 在渐进式 rehash 进行期间,新增一个 key-value 时, 会被保存到「哈希表 2 」里面,而「哈希表 1」 则不再进行任何添加操作, 这样保证了「哈希表 1 」的 key-value 数量只会减少,随着 rehash 操作的完成, 最终「哈希表 1 」就会变成空表。
3.rehash触发条件
和负载因子有关。负载因子 = 哈希表已经保存的节点数/哈希表大小
- *当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 写的时候,就会进行 rehash 操作。
- 当负载因子大于等于 5 时,此时说明哈希冲突非常严重了, 不管有没有有在执行 RDB 快照或 AOF 重写, 都会强制进行 rehash 操作。
5.整数集合
intset 是一个由整数组成的 有序集合,从而便于在上面进行二分查找,用于快速地判断一个元素是否属于这个集合。 它在内存分配上与 ziplist 有些类似,是连续的一整块内存空间,而且对于大整数和小整数(按绝对值)采取了不同的编码,尽量对内存的使用进行了优化。
对于小集合使用 intset 来存储,主要的原因是节省内存。特别是当存储的元素个数较少的时候, dict 所带来的内存开销要大得多(包含两个哈希表、链表指针以及大量的其它元数据)。所以,当存储大量的小集合而且集合元素都是数字的时候,用 intset 能节省下一笔可观的内存空间。
实际上,从时间复杂度上比较, intset 的平均情况是没有 dict 性能高的。以查找为例,intset 是 OO(lgn) 的,而 dict 可以认为是 O(1) 的。但是,由于使用 intset 的时候集合元素个数比较少,所以这个影响不大。
6.跳表
链表在查找元素的时候,因为需要逐一查找,所以查询效率非常低,时间复杂度是O(N) ,于是就出现了跳表。跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表, 这样的好处是能快读定位数据。
6.Redis线程模型
1.单线程模型
如果仅仅聊Redis的核心业务部分(命令处理),答案是单线程
如果是聊整个Redis,那么答案就是多线程
Redis 在处理网络请求是使用单线程模型,并通过 IO 多路复用来提高并发。 但是在其他模块,比如:持久化,会使用多个线程。
Redis 内部使用文件事件处理器 file event handler
,这个文件事件处理器是单线程的,所以 Redis 才叫做单线程的模型。它采用 IO 多路复用机制同时监听多个 socket
,将产生事件的 socket
压入内存队列中,事件分派器根据 socket
上的事件类型来选择对应的事件处理器进行处理。
文件事件处理器如下:
文件事件处理器的结构包含 4 个部分:
- 多个 socket
- IO 多路复用程序
- 文件事件分派器
- 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)
2.为什么Redis单线程模型也能效率这么高?
抛开持久化不谈,Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升。
- 纯内存操作
- 避免多线程频繁的上下文切换开销,以及并发加锁开销
- IO多路复用模型,允许同时监听多个socket
7.高可用
1.主从复制
为了应对并发能力问题,可以搭建主从集群,实现读写分离,Redis大多都是读多写少的场景
多台服务器要保存同一份数据,这些服务器之间的数据如何保持一致性呢?数据的读写操作是否每台服务器都可以处理?
Redis 提供了主从复制模式,来避免上述的问题。
第一次同步——全量同步
如何确定主从关系?比如想让服务器B变成服务器A的从服务器
1 | # 服务器 B 执行这条命令 |
在介绍第一次同步的过程之前,需要先介绍两个参数
从服务器就会给主服务器发送 sync
命令,表示要进行数据同步。 sync 命令包含两个参数 ,可以判断从服务器是否第一次来同步数据
Replicationld:简称
replid
,每个 Redis 服务器在启动时都会自动生产一个随机的 ID 来唯一标识自己。 从服务器会继承主服务器的replid
offset:偏移量,表示复制的进度,随着记录在repl_baklog中的数据增多而逐渐增大。主节点用offset记录自己写的位置,从节点用offset记录自己读的位置。slave完成同步时也会记录当前同步的offset。
如果slave的offset小于master的offset,说明slave数据落后于master,需要更新。
全量同步的流程如下:
- slave节点发送sync请求,表示要进行数据同步
- master将执行 bgsave 命令(异步,不会阻塞主线程)生成RDB,发送RDB到slave。
- slave清空本地数据,加载master的RDB
- master将生成RDB期间、发送RBD期间的命令记录在
replication_backlog_buffer
,并持续将log中的命令发送给slave - slave执行接收到的命令,保持与master之间的同步
至此,主从服务器的第一次同步的工作就完成了。
断开后的同步——增量同步
从节点断开后重启,采用增量同步
repl_backlog_buffer,是一个「环形」缓冲区, 用于主从服务器断连后,从中找到差异的数据;
slave提交自己的offset到master,master获取repl_baklog中从offset之后的命令给slave
repl_baklog大小有上限,是一个环形区域,写满后会覆盖最早的数据。如果slave断开时间过久,导致尚未备份的数据被覆盖,则无法基于log做增量同步,只能再次全量同步。
因此,为了避免在网络恢复时,主服务器频繁地使用全量同步的方式,我们应该设置repl_backlog_buffer 缓冲区尽可能的大一些, 减少出现从服务器要读取的数据被覆盖的概率,从而使得主服务器采用增量同步的方式。
主从复制的作用
①、数据冗余: 主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。
②、故障恢复: 如果主节点挂掉了,可以将一个从节点提升为主节点,从而实现故障的快速恢复。
通常会使用 Sentinel 哨兵来实现自动故障转移,当主节点挂掉时,Sentinel 会自动将一个从节点升级为主节点,保证系统的可用性。 假如是从节点挂掉了,主节点不受影响,但应该尽快修复并重启挂掉的从节点,使其重新加入集群并从主节点同步数据。
③、负载均衡: 在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务 ,分担服务器负载。尤其是在读多写少的场景下,通过多个从节点分担读负载,可以大大提高 Redis 服务器的并发量。
④、高可用基石: 除了上述作用以外,主从复制还是哨兵和集群能够实施的 基础。
2.哨兵(Sentinel)机制
用于监控主节点和从节点的状态,实现自动故障转移。如果主节点发生故障,哨兵可以自动将一个从节点升级为新的主节点。
故障转移整体流程:监控——主观下线——客观下线——(哨兵集群)选举Leader——故障转移
为什么要有哨兵机制?
主节点如果要是挂了,就没办法接收写操作了。而哨兵本身是一个独立运行的进程,它能监控多个节点,发现主节点宕机后能进行自动切换。
哨兵的三个作用(功能)
- 集群监控:负责监控 Redis Master 和 Slave 进程是否正常工作
- 故障转移:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主
- 通知: Sentinel充当Redis客户端(比如RedisTemplate)的服务发现来源,当集群发生故障转移时,通知 client 客户端新的 Master 地址。
如何判断节点是否故障?
Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个实例发送ping命令
- 主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线。
- 客观下线:若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。当一个哨兵判断主节点为「主观下线」后,就会向其他哨兵发起命令, 其他哨兵收到这个命令后,就会根据自身和主节点的网络状况, 做出赞成投票或者拒绝投票的响应。
由哪个哨兵进行主从故障转移?哨兵选举Leader
每个哨兵都有可能成为leader,它会先向其他哨兵拉票,当一个哨兵收到一个投票时,如果还没有投票,就可以投出去一票,否则就拒绝投票。当哪个哨兵的票数超过了哨兵总数目/2 + 1,就是新的leader
选出新的主节点的规则?
首先要把网络状态不好的从节点给过滤掉。然后对所有从节点进行三轮考察:优先级、复制进度、ID 号。
- 优先级最高的从节点胜出
- 节点的offset值(反映了复制进度)越大说明数据越新,优先级越高
- 从节点的ID大小,越小优先级越高。
故障转移的过程?
- 选出一个新的主节点
- 让旧主节点的从节点成为新主节点的从节点,开始从新的主节点同步数据
- 将新主节点信息,通知给客户端(通过 Redis 的发布者/订阅者机制来实现)
- 继续监视旧主节点,当这个旧主节点重新上线时,将它设置为新主节点的从节点
TODO
- 脑裂Sentinel 可以防止脑裂吗?(在小林”主从复制是怎么实现的“那里最后有提到脑裂,可结合gpt看看)
3.分片集群(Cluster )
最少三主三从
Redis 集群通过分片的方式存储数据,每个节点存储数据的一部分,用户请求可以并行处理。集群模式支持自动分区、故障转移,并且可以在不停机的情况下进行节点增加或删除。 Redis Cluster 功能强大,直接集成了 主从复制 和 哨兵 的功能。
核心概念
- 数据分片: Redis集群将整个数据集划分为 16384个哈希槽(slots)。每个键通过哈希计算被分配到一个特定的槽位,而这些槽位由集群中的节点负责管理。这样的设计使得数据可以均匀分布在多个节点上。
- 重定向机制:客户端可以向集群中的任意节点发送请求。如果请求的键所在的槽位不由当前节点负责,该节点会返回一个重定向响应,告诉客户端应该联系哪个节点。这种机制确保客户端最终能访问到正确的节点。
- 主从复制与高可用性: Redis集群支持主从复制。每个槽位可以有一个主节点和多个从节点。主节点负责处理写请求。 从节点负责处理读请求,并在主节点故障时作为备份。 当主节点发生故障时,集群会自动从其从节点中选举一个新的主节点接管服务,从而保证高可用性。
- 故障转移 :Redis集群使用一种类似于Raft的共识算法来实现故障转移。当主节点不可用时,集群会自动检测并将某个从节点提升为主节点,确保服务的连续性。
- 动态伸缩 :Redis集群支持在线添加或删除节点,允许在不中断服务的情况下扩展存储容量和处理能力。
散列插槽
Redis集群将整个数据集划分为 16384个哈希槽(slots)。这些槽被Redis节点共同负责,每个节点负责一部分。对key计算 CRC16
值 (也就是哈希值),然后用这个值对16384取余,可以得到这个key对应的哈希槽,然后去对应的Redis节点找数据
如果请求的键所在的槽位不由当前节点负责,该节点会返回一个重定向响应,告诉客户端应该联系哪个节点。
集群伸缩
集群扩容和缩容的关键,在于槽和节点之间的对应关系。 当需要扩容时,新的节点被添加到集群中,集群会自动执行数据迁移,以重新分布哈希槽到新的节点。数据迁移的过程可以确保在扩容期间数据的正常访问和插入。 当数据正在迁移时,客户端请求可能被路由到原有节点或新节点。 如果请求被路由到正在迁移数据的哈希槽,Redis Cluster 会返回一个 MOVED 响应,指示客户端重新路由请求到正确的目标节点。这种机制也就保证了数据迁移过程中的最终一致性。 当需要缩容时,Redis 集群会将槽从要缩容的节点上迁移到其他节点上,然后将要缩容的节点从集群中移除。
Redis Cluster 中的节点是怎么进行通信的
Redis Cluster 支持重新分配哈希槽吗?
7.场景
1.分布式锁
一个最基本的分布式锁需要满足:
- 互斥:任意一个时刻,锁只能被一个线程持有。
- 高可用:锁服务是高可用的,当一个锁服务出现问题,能够自动切换到另外一个锁服务。并且,即使客户端的释放锁的代码逻辑出现问题,锁最终一定还是会被释放,不会影响其他线程对共享资源的访问。这一般是通过超时机制实现的。
- 可重入:一个节点获取了锁之后,还可以再次获取锁。
除了上面这三个基本条件之外,一个好的分布式锁还需要满足下面这些条件:
- 高性能:获取和释放锁的操作应该快速完成,并且不应该对整个系统的性能造成过大影响。
- 非阻塞:如果获取不到锁,不能无限期等待,避免对系统正常运行造成影响。
使用Redis命令设计的基础分布式锁
setnx ex
,这样可以设置一个简单的分布式锁,setnnx
是如果key不存在,则创建key,否则失败;而ex
则给这个key设置了过期时间,避免锁无法释放。
而且Redis的命令都是原子性的,这样就保持了获得锁和设置过期时间是一起操作的
对于自己设计的分布式锁,如果要保证判断该锁是否为自己的和释放锁这两个操作为原子操作,就需要用Lua脚本。释放锁的时候判断是否与获得锁的线程id一样
Redisson
Redisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,不仅仅包括多种分布式锁的实现。
而redisson不仅实现了上述功能,还更为强大,有以下功能:
- 自动续期
- 读写锁
- 公平锁
下面介绍redisson原理
加锁
key是锁的名称,value是个map,map的key是线程id,value是锁的重入次数,然后设置锁过期时间
- 如果加锁成功,锁的重入次数加一,这就实现了重入锁的功能;
- 加锁成功后,就会执行一个看门狗的机制。看门狗机制是为了防止业务还没执行完,但锁到期了的问题。看门狗就是一个定时任务,只要当前线程任务没有挂掉,且没有主动释放锁,就会隔一段时间给锁续期。默认情况下,每过 10 秒,看门狗就会执行续期操作,将锁的超时时间设置为 30 秒。
- 如果失败,返回锁的过期时间
- 加锁失败后,会进入一个循环中,此线程会被semaphore阻塞,当之前的线程释放锁后,会通过semaphore来唤醒此线程,然后获得锁后跳出此循环
- 释放锁时,如果该锁的线程id不是自己的,就无权释放;如果是,就将重入次数减一,如果减后的重入次数还是>0,就不能释放,更新锁到期时间,否则就释放锁,然后发送锁释放消息,唤醒被阻塞的线程
无论加锁成功或失败,都会有一个future结果器来接收加锁结果