第一篇:Redis数据结构底层——String、List、Hash、Set、ZSet各自用什么实现的?

  1. 第一篇:Redis数据结构底层------String、List、Hash、Set、ZSet各自用什么实现的?
  2. 第二篇:Redis的过期删除与内存淘汰------数据过期了怎么删?内存满了怎么办?

前言

你可能每天都在用Redis的String、List、Hash、Set、ZSet,但面试官追问到底层时,很多人就答不上来了:

"Redis的String和Java的String是一回事吗?"

"List底层是链表还是数组?"

"ZSet怎么实现O(log n)排序的?"

"为什么Redis不直接复用C语言的原生数据结构?"

这些问题考察的不是"会用",而是"理解设计意图"。本文从底层实现的角度,逐个拆解Redis五大基本数据结构的内部原理。

本文核心问题:

  1. Redis的String底层是什么?SDS和C字符串有什么区别?
  2. List的底层经历了怎样的演进?ziplist、linkedlist、quicklist分别是什么?
  3. Hash的底层有两种实现?什么时候用ziplist,什么时候用hashtable?
  4. Set和ZSet各自的底层结构是什么?为什么ZSet能排序?
  5. 为什么Redis要自己实现一套数据结构,而不是复用C语言自带的?

读完本文,你将对Redis数据结构拥有从使用到实现的完整理解。


一、Redis为什么自建数据结构?

疑问:C语言有原生的字符串和数组,Redis为什么不直接用?

回答:C语言的原生数据结构有两个致命缺陷------字符串操作不安全、内存管理不灵活。Redis自己实现的数据结构,第一优先级是快,第二优先级是省内存。

C语言的字符串本质是一个char[]数组,以空字符\0结尾。获取长度需要遍历整个数组,这是一个O(n)操作------Redis每次获取键长都要遍历一遍,不可接受。C字符串存二进制数据时,遇到\0就会被截断------Redis的网络协议传输的是二进制安全的批量数据,不能依赖C字符串。

C的数组不记录实际元素数量,每次检查长度又是O(n)。更重要的是,C结构体的内存布局是对齐的,Redis需要紧凑的存储结构来节省内存------数百万个小Key如果用标准结构体,内存碎片和对齐开销会让实际内存占用是实际数据的数倍。

Redis的解决思路:为每种数据结构设计专门的底层实现,所有结构都额外记录一个当前长度字段------获取长度变O(1),二进制安全也能保证。这就是SDS、ziplist、skiplist等专用数据结构的来源。


二、String------SDS(Simple Dynamic String)

疑问:Redis的String到底存了什么?和Java的String有什么不同?

回答:Redis的String底层是SDS------一个自描述的动态字符串结构,它在C字符串的基础上增加了长度记录、缓冲区预分配、二进制安全三个关键能力。

SDS的结构如下:

c 复制代码
struct sdshdr {
    int len;      // 已使用的字节数
    int free;     // 未使用的字节数(预分配空间)
    char buf[];   // 实际的字节数组
};

SDS和C字符串的核心区别

能力 C字符串 SDS
获取长度 O(n)遍历 O(1)直接读len字段
缓冲区溢出 可能发生(不检查边界) 自动扩容,不会溢出
内存分配 每次修改都要重新分配 预分配空间,减少分配次数
二进制安全 不安全(遇到\0截断) 二进制安全(使用len字段确定长度)

为什么需要二进制安全?

Redis存储的不仅仅是文本。一个序列化后的Java对象可能包含多个内部\0字节。C字符串读到第一个\0就停了------后面的数据全部丢失。SDS用len字段指明数据的实际长度,不依赖空字符终止符,所以能够存储包括图片、序列化对象在内的任意二进制数据。

预分配空间------减少内存分配次数

Append操作在C字符串中是灾难。每次追加worldhello后面,都要重新分配内存(新长度+1)。如果反复追加100次,就要分配100次内存。

SDS的预分配策略:当增后长度小于1MB时,分配2倍的空间(free = len + 1),下次追加大概率不需要再分配。当增后长度大于等于1MB时,每次分配多预留1MB。这是用有限的内存换取更少的分配次数------Redis是内存数据库,省掉分配带来的时间收益,远超多用的少量内存。


三、List------从linkedlist到quicklist的演进

疑问:List底层是链表还是数组?为什么Redis的List可以两端插入?

回答:Redis的List底层经历了三次演进------早期用linkedlist或ziplist互斥选择,后来用quicklist统一两者优势。

linkedlist版本(早期)

标准的双向链表,每个节点独立分配内存。两端插入和删除的时间复杂度是O(1),但每个节点都需要前驱和后继指针,内存开销大------一个只存10字节数据的节点,两个指针占16字节,内存使用率还不到40%。此外,链表的节点在内存中不连续存储,CPU缓存在顺序遍历时大量miss,性能不支持日常中大规模的顺序读取。

ziplist版本(省内存)

ziplist是一种连续内存块的压缩结构------把所有元素紧凑排列在一整块连续内存中,没有任何额外的指针。它依赖"当前元素长度"字段自动步进到下一个元素。内存利用率大幅高于链表,适合小数据量场景,但中间插入或删除一个元素,后面的所有元素都要整体往后移动------这个特质决定了它只适合元素较少且操作仅在两端的场景。

quicklist版本(Redis 3.2+)

quicklist将linkedlist和ziplist结合------它是一个双端链表,但每个节点内部不再是一个元素,而是一段ziplist。两端操作快速定位到head或tail节点,中间操作可在这段ziplist内完成。元素较多时,一段ziplist被限制在一定大小内,中间插入的影响只局限于这段子列表,不会波及整个列表。

复制代码
quicklist结构:
  [head] ←→ [Node(ziplist)] ←→ [Node(ziplist)] ←→ [tail]
               ↓                      ↓
        连续内存存3-4个元素      连续内存存3-4个元素

选择逻辑

  • 少量元素 → ziplist节点,省内存,两端操作也快
  • 大量元素或中间插入频繁 → quicklist,兼顾内存和效率

四、Hash------两种底层实现

疑问:Hash底层是什么结构?和Java的HashMap一样吗?

回答:Redis的Hash有两种底层实现------字段较少时用小而紧凑的ziplist,字段较多时转为标准的hashtable。这个阈值可以通过配置文件调整。

小Hash:用ziplist

当Hash中的字段数 < 512且每个字段的值长度 < 64字节时,Hash用ziplist存储。使用方式和List类似,键和值被压缩排列在连续内存中,没有任何指针开销。

复制代码
ziplist存Hash:
[field1][value1][field2][value2][field3][value3]...

大Hash:用hashtable

当字段数量或值的长度超过阈值时,Hash自动升级为hashtable------类似Java的HashMap,是一个数组+链表的拉链结构。但Redis的hashtable使用了渐进式rehash------一次性rehash大数据量时可能阻塞其他命令,所以Redis会把rehash过程分散到后续的多次操作中,每次处理一部分,逐渐完成迁移,同时不影响正常命令的响应时间。

为什么需要两种实现?

一个用户对象可能只有3个字段(name、age、email),用标准hashtable存储时数组和指针的开销比数据本身还大。ziplist把三个字段紧凑排在一块内存中,省掉所有指针和元数据。

一个购物车Hash可能有几千个商品ID和数量,ziplist的线性查找和插入性能在元素过多时变差,降级为hashtable保证操作性能。

这是Redis"用空间换时间,或用时间换空间"选择的具体体现。 小数据量时内存优先(ziplist),大数据量时性能优先(hashtable)。阈值的选择是两者成本和受益的平衡点------512个字段×64字节≈32KB,这是现代CPU缓存的舒适区。


五、Set------有序还是无序?

疑问:Set的底层是什么?为什么Set能快速判断元素是否存在?

回答:Set有两种底层实现------当所有元素都是整数时用intset(有序整数集),否则用hashtable(存键不存值)。

intset

当Set中全部元素都是整数且数量不超过512时,用intset存储。intset是一个有序的、不重复的整数数组------查找可以用二分查找(O(log n)),不需要hashtable的完整桶结构。

hashtable

当Set中包含非整数元素或整数数量超过阈值时,升级为hashtable。每个Set元素是hashtable中的一个键,值全为NULL------只使用hashtable的键结构,不存储实际值。键去重的特性天然满足Set元素不重复的约束。


六、ZSet------有序集合的排序秘密

疑问:ZSet是怎么做到O(log n)排序的?为什么能同时按score排序和按member查找?

回答:ZSet同时使用两种数据结构------skiplist(跳表)按score排序,hashtable按member快速查找。两种结构各自解决一个问题,ZSet把两者组合使用。

skiplist------概率型数据结构

复制代码
level 3:  1 ──────────────────→ 9
level 2:  1 ──────→ 5 ──────→ 9 ──→ 12
level 1:  1 → 3 → 5 → 7 → 9 → 10 → 12
          ↑
        高层指针跨越多级,低层精确步进

skiplist是一个多层链表:底层是完整的所有元素的链表,每往上一层只保留部分元素,但跨越的元素更多。查找时从高层开始粗略定位,逐步下到低层精确命中------平均时间复杂度O(log n),与平衡树相同,但代码比平衡树简单得多。

这是概率型数据结构------每层保留哪些元素在插入时用随机数决定。不需要平衡树那么多的旋转和再平衡操作,也不需要递归遍历或栈来定位前驱后继。

为什么不用红黑树?

红黑树的O(log n)同样高效。但在Redis中,ZSet需要频繁的范围查询(ZRANGE score1 score2)------skiplist的链表结构天生适合"找到起点,顺序遍历",红黑树遍历一段区间需要反复访问父指针回到上层。范围查询是ZSet的核心功能,skiplist的遍历模式比红黑树更适合这个操作模式。


七、底层选择速查表

数据类型 小数据量 大数据量 选择逻辑
String --- SDS动态扩容 预分配空间,二进制安全
List ziplist quicklist 内存优先→性能优先
Hash ziplist hashtable 字段<512且值<64B→ziplist
Set intset hashtable 全部整数→intset,否则→hashtable
ZSet ziplist skiplist+hashtable 元素<128且长度<64B→ziplist,否则→跳表

总结

  • Redis自己实现数据结构,是为了O(1)获取长度、二进制安全、灵活的内存管理------这些C语言原生结构做不到
  • SDS用len记录实际长度、用free做预分配,获取长度O(1),追加时减少内存分配次数
  • List从linkedlist+ziplist互斥演化到quicklist统一两者的优势------链表级快速定位到某一节点,ziplist级在该节点内紧凑存储元素
  • Hash和ZSet都有"小数据用ziplist、大数据用专用结构"的双实现------这是空间和时间的动态平衡
  • ZSet的核心是skiplist------概率型数据结构,实现比红黑树简单,范围查询比红黑树的父指针回溯遍历模式更高效
  • 所有底层选择背后都是同一个权衡:数据量小时优先内存效率,数据量变大时优先操作速度

下一篇预告:Redis原理(二)------过期删除与内存淘汰:数据过期了怎么删、内存满了怎么办。拆解惰性删除+定期删除的组合策略,以及8种内存淘汰策略的适用场景和选择逻辑。

相关推荐
qq_296553271 小时前
[特殊字符] 数组中的递增三元组:O(n) 时间高效查找,面试必考!
数据结构·算法·面试·职场和发展·组合模式·柔性数组
今儿敲了吗1 小时前
链表篇(一)——合并两个有序链表
数据结构·笔记·算法·链表
y = xⁿ1 小时前
20天速通LeetCodeday11:二叉树进阶
数据结构·算法
大都督会赢的2 小时前
数据结构(1)--顺序表
c语言·数据结构·学习·指针
牢姐与蒯2 小时前
c++数据结构之AVL树
数据结构
Devin~Y2 小时前
大厂Java面试:Spring Boot + Redis/Kafka + Spring Cloud + JVM + RAG/向量检索(小Y翻车实录)
java·jvm·spring boot·redis·spring cloud·kafka·mybatis
博界IT精灵2 小时前
图的存储结构(哈喜老师版本)
数据结构·考研
游乐码2 小时前
c#插入排序
数据结构·算法·排序算法
大迪deblog2 小时前
系统架构设计-Redis设计-缓存穿透、缓存击穿、缓存雪崩
数据库·redis·系统架构