Redis学习笔记--核心源码
源码
redis的是kv键值对存储的,key一般都是String类型,value是Redis对象(redisObject)


0、当我们执行一个命令存储数据后redis内部会发生什么?
客户端发送 SET k1 v1
→ Redis 解析命令
→ 查找键空间字典
→ 键存在?更新值 : 新建键值对
→ 键不存在?创建值 : 新建键值对
→ 处理过期时间(如有)
→ 写入 AOF 缓冲区(如开启)
→ 传播到从节点(如主节点)
→ 返回 OK
1、String类型数据结构?
String类型的底层存储时通过动态字符串(SDS)来实现的,Redi是的key都是字符串类型的,因此也是SDS存储的在底层,它有三大物理编码方式int、embstr(嵌入式字符串)、raw。存入Redis后可以通过object encoding key命令查看具体在底层存储的是什么类型。
在存储的时候会先判断是不是int类型如果不是在判断字符长度小于44就存储为embstr如果大于44就存储为raw,判断优先级int->embstr->raw
int:保存长度小于20的整数类型,如果是float类型内部会将其转换成字符串进行存储,redis在启动的时候会在系统中初始化开辟出来10000的内存空间用于存储int类型的数据,如果存储的数值正好在这个范围内就直接存储无需重新开辟内存空间,如果两个不同的key的value是一样的,那么会把两个key的指针指向同一块内存空间来实现内存空间的共享,从而节省内存空间,保证内存使用的连续性。
embstr:嵌入式字符串SDS,保存长度在小于44位的字符串,如果存储的是长度小于44的字符串直接转换成embstr类型进行存储,如果首次存储为embstr然后通过append追加了字符即使长度没有超过44也会被当做raw处理,也就是说只有首次存储长度小于44的才会别当做embstr处理后续追加的都是raw,所以不建议使用append来追加字符,因为会改变在redis中存储的结构。
raw:原生,也是SDS(大号的embstr),保存长度大于44位的字符串,在redis中长度超过44的都视作长字符串,不做任何处理直接存储即可。
下图是查看String底层数据类型示例:

下图是三种不同类型的存储判断逻辑示例:

2、SDS简单动态字符串?
SDS是redis基于C语言的char[]数组基础上封装的动态字符串,SDS是用来替代char[]的,根据不同的字符长度分为5个,常用的是后四个,他们的长度都是2的N次方,第一个长度5是不用的。


3、为什么用SDS动态字符串替代C语言的char[]?
3.1、时间复杂度问题:
在C语言中字符串的存储是以\0结尾的,遍历时只有遇到\0才代表字符串结束了,通过strlen()获取字符串长度时时间复杂度O(n)。
在SDS的结构体中会通过len字段存储了当前字符串的长度,获取字符串长度时直接读取即可,时间复杂度是O(1)。
3.2、避免缓存移除和内存碎片:
在C语言中修改字符串长度时需要手动重新分配内存频繁的内存分配容易导致缓冲区溢出,影响性能
在SDS中通过预留空间分配策略和惰性释放来解决了C语言内存的问题:
预留分配策略:当SDS需要扩容时会分配额外空间(free字段记录剩余可用空间),减少内存分配次数。扩容规则:如果长度小于1M则分配双倍所需空间,如果超过1M则每次多分配1M。
惰性释放:缩短字符串时,SDS不会立即释放多余内存,而是对多余的空间打free标识进行保留,供后续可能的扩容操作使用
3.3、二进制安全性:
在C语言中是字符串通过\0标识结束,因此它无法存储包含\0的数据(音频、图片的二进制数据)
SDS通过len字符存储字符串的长度而不依赖\0标识符,因为可以存储任意的二进制数据(包含\0)
总结:
Redis通过SDS对不同长度的字符串使用不同的头部结构体(sdshdr8、sdshdr16 等),并在结构体内通过len字段表示长度来减少时间复杂度,通过free标识空间内存空间保证了内存使用的连续性避免内存碎片的产生。通过空间换时间和预留分配策略解决了C语言的固有缺陷,完美适配了Redis对字符串操作的各种要求。

4、Hash类型?
Hash类型分两种,在redis6及之前用的是ziplist(压缩列表)+hashtable而在redis7之后用的是listpack(紧凑列表)+hashtable ,下如实redis7的示例:

当存储hash的实体个数(键值对个数)小于等于512或者长度(键和值的长度)小于等于64的时候使用ziplist(redis6)或listpack(redis7),当512和64的任一限制被打破时使用hashtable,ziplist或listpack可以升级到hashtable,反过来降级是不可以的,有点类似embstr可以升级raw,反过来降级是不可以的,在节省空间方面hashtable不如ziplist或listpack,流程如下:

5、ziplist 是什么?
ziplist压缩列表是一种紧凑的编码格式,总体思想是用时间换空间,牺牲部分读写性能为代价换取更高的内部利用率,压缩列表内存利用率高的原因取决于它内存空间连续的特性,因此只会用于键值对个数少且键值对长度短的场景。
ziplist是一个特殊的双向链表,和普通的双向链表相比它的头结点和尾节点没有存储前后元素的位置信息而是存储了前一个节点和后一个节点的长度,所以牺牲了时间换取了空间,节约了内存。 存储结构如下图,包含了头部、尾部、节点三个部分


6、有双向链表了为什么还要用ziplist?
普通的双向链表在指针为存储的是上一个和下一个元素的位置信息,节点之间在内存中是离散分布的,它的写操作要优于读操作,在数据量小时存储的数据大小可能还没有指针位占用的空间大,这样会造成内存不连续和内存空间的浪费。而ziplist是一个特殊的双向链表,它在内存中是连续存储的且适用于存储数量和内容小的场景,它在指针位中存储了上一个元素的长度通过偏移量可以很快的找到元素的位置,虽然在修改数据时可能触发内存空间的重新分配引发级联更新(连锁更新)问题,可以看成像多米诺骨牌一样(被listpack取代的原因),但它在阈值大小内收益是高于双向链表的,而且ziplist头部会存放整个数组的len值,在获取ziplist长度时时间复杂度是O(1),当ziplist长度超过阈值时会自动转换成双向链表来保证写操作的稳定。
7、listpack?
紧凑列表,它是为了解决ziplist的一些问题而出现的。它的很多细节和ziplist是一样的,可以参考5中的内容。

8、为什么有ziplist了还要用listpack?
Redis引入listpack主要是为了解决ziplist的级联更新(连锁更新)问题。ziplist中每个entry存储前驱元素长度,导致中间插入可能引发连锁内存重分配。而listpack让每个entry自包含长度信息,消除前后依赖,是解耦思想的一种体现和应用,使得增删操作时间复杂度稳定并且支持逆向遍历。此外,listpack的编码更紧凑,内存利用率更高。
9、listpack不包含前后元素的长度了,是怎么找到上一个和下一个元素位置的?
listpack自包含自身元素的总长度,如果需要计算可以根据当前元素位置和前元素长度或自身长度来进行计算。这样的设计可以使listpack实现O(1)时间复杂度的双向遍历。
下一个元素位置 = 当前元素的位置 + 当前元素的总长度。
上一个元素位置 = 当前元素位置 - 当前元素位置-1解析前一个元素总长度。
10、为什么redis7中还保留着redis6中的ziplist?
在redis7中ziplist因为连锁更新导致内存重新分配问题被listpack取代了,但为了保证向下兼容所以redis7还保留了redis6中的ziplist。
11、ziplist和listpack优缺点总结?
ziplist:
优点:所有元素在内存中连续存储,非离散结构存储,无指针开销,内存利用率高,适合数据量小的场景,双向遍历高效,每个元素存储上一个元素的长度,通过计算长度即可找到前后元素。
缺点:连续更新问题,某个元素长度发生变化会导致后续元素发生多米诺骨牌式的连锁更新。频繁的修改会带来大量的内存碎片。
listpack:
优点:每个元素独立存储自身长度,修改不影响其它元素,时间复杂度O(1),内存更加稳定碎片率低于ziplist。设计简单,易于维护。
缺点:不能说是缺点,应该说是一种取舍,牺牲了部分反向遍历效率来解决了ziplist的连锁更新问题。
总结:
ziplist:牺牲了修改性能换取内存紧凑性,适合静态或低频修改的小数据,但因连锁更新问题逐渐被淘汰。
listpack:以略微牺牲反向遍历效率为代价,彻底解决连锁更新,成为 Redis 新一代紧凑数据结构的核心选择。

12、List类型?
Redis6:quickList=双向链表(LinkedList)+压缩列表(zipList),quicklist将LinkedList按段切分,每一段使用zipList来紧凑存储,多个zipList之间使用双向指针串接起来。
Redis7:quickList=双向链表(LinkedList)+紧凑列表(listpack),quicklist将LinkedList按段切分,每一段使用listpack来紧凑存储,多个listpack之间使用双向指针串接起来。

13、Set类型?
Redis中Set类型使用intset或hashtable来存储数据,如果元素都是整数类型且在512的长度内就用intset存储。如果不是整数或整数超过512的长度就用hashtable(数组+链表)存储数据。
14、ZSet类型?
当元素个数小于128、长度小于64使用ziplist或listpack,这两个条件任破其一则使用skiplist
Redis6:ZSet=ziplist+skiplist(跳表)
Redis7:ZSet=listpack+skiplist(跳表)
15、数据类型总结?
Redis中包含如下数据类型:

数据类型和数据结构关系图谱:


各数据类型时间复杂度:

16、跳表?
跳表是一种随机化的数据结构,可以看做是普通链表的升级版本,在普通链表的上层为了一多层索引,每层索引维护的数据数量是下一层索引的一半,这层索引的维护借鉴了二分查找法的思想,它通过多层索引来加快查找速度,平均查找时间复杂度是O(log n)和红黑树相当,举个例子:普通链表包含1000个元素,在查找的时候最坏的情况要遍历整个链表1000次,而使用跳表后通常只需要10左右的比较即可找到对应的数据。
17、Redis中Zset有序集合为什么选择跳表而不选择红黑树?
1、实现简单:跳表的实现代码比平衡树(红黑树)要简单的多,维护会更加容易。
2、内存占用灵活:跳表的内存布局比平衡树更加灵活
3、范围查找友好:跳表天然支持范围查询,满足有序集合的常见查询操作,且效率高于红黑树
4、插入、删除更简单:不需要复杂的树旋转操作来维持平衡,只需要局部调整即可
18、LRU、LFU?
LRU:淘汰最久未被访问的数据,采用近似LRU算法,通过随机采样选择最久未使用的数据进行淘汰,而非全局遍历。传统的LRU是通过一个双线链表维护所有节点的访问顺序,最近访问的节点会移动到链表的头部,尾部为最久未被访问的节点,通过哈希表来快速定位。若数据存在则将其从链表移除并插入到链表头部,若缓存已满则删除尾部节点将新节点插入到头部。传统的做法会因为每次数据变动都要调整链表顺序,不适用于高并发场景,在Redis中对传统的LRU进行了优化在实现方式上采用了随机取样,默认随机获取5个key然后淘汰其中最久未被访问的key,每个key的元数据中保存最近访问的时间戳。这样做可以带来更高的性能因为时间复杂度是O(1)但可能会误留冷数据或误删热点数据。
LFU:淘汰访问频率最低的数据,按照频率排序淘汰访问频率最低的key,传统的做法是将访问频率最低的key放到堆顶也就是维护最小堆,访问数据时频率+1并调整堆结构,若缓存已满则删除对顶元素,插入新数据(初始访问频率为1)这样做的缺点是在对调整数据时时间复杂度较高log n且旧数据因历史累计高频访问可能会被长期保留。Redis对其进行了优化通过概率递增+时间衰减来平衡存储与精度。在存储时会分为两部分:访问频率计数器+最后访问时间的时间戳,这样做可以优化传统做法的不足,通过补充最后访问时间的时间戳来避免旧数据长期占据内存。而且可以避免堆数据维护的开销且占用空间极小。LFU优点像LRU。
如何选择:
LRU:适合数据访问模式变化快的场景,需要快速响应突发流量,例如:微博热搜,新闻APP热点新闻等。
LFU:适合数据热度稳定,需要长期高频率访问的数据,例如: 首页爆款商品等。
两者差异:
|
维度
|
LRU
|
LFU
|
|
淘汰依据
|
最近访问时间
|
历史访问频率
|
|
热点识别
|
短期突发流量
|
长期稳定热点
|
|
实现开销
|
低(时间戳记录)
|
中(计数器+衰减计算)
|
|
适用场景
|
新闻、社交等突发访问场景
|
电商商品、视频热榜等长期高频场景
|
19、LRU和LFU的实现原理是什么?
LRU和LFU是两种经典缓存淘汰算法:
LRU基于最近访问时间,传统实现用双向链表+哈希表维护访问顺序,Redis优化为近似LRU(随机采样淘汰最久未访问Key),适合短期突发流量场景。
LFU基于历史访问频率,传统实现用最小堆维护频率,Redis采用8位计数器+概率递增+时间衰减,避免存储开销并解决旧数据残留问题,适合长期热点场景。
两者选择需结合业务特点:LRU对访问时间敏感,LFU对频率敏感,Redis的实现均通过优化平衡性能与精度。

浙公网安备 33010602011771号