一、SDS如何防止缓冲区溢出?
Redis 的 String 类型通过 SDS(Simple Dynamic String)来防止缓冲区溢出,具体机制如下:
- Redis 的 String 类型底层采用 SDS 实现,即 Simple Dynamic String
- SDS 底层维护的数据结构包含多个关键部分:
- len:表示当前 SDS 字符串已经使用的字节数
- alloc:代表为 SDS 字符串预先分配的总字节数,alloc - len 即为空闲空间的大小
- flags:用于标识 SDS 的类型,不同类型的 SDS 在内存分配和存储方式上有所不同,以适应不同长度的字符串存储需求
- buf:实际存储字符串内容的字符数组,其长度为 alloc,并且遵循 C 字符串以 \0 结尾的惯例,不过 \0 不包含在 len 统计范围内
- 当对 SDS 字符串执行 append 等操作时,会先进行检查。计算添加新内容后所需的总字节数,如果该总字节数超过了当前 alloc 的字节数,就会触发扩容操作。扩容规则如下
- 如果当前 alloc 的字节数小于 1MB,采用两倍扩容策略,即将原来 alloc 的字节数乘以 2
- 如果当前 alloc 的字节数大于或等于 1MB,每次扩容 1MB,也就是在原来 alloc 的字节数基础上加上 1MB
二、跳表插入流程
(1)跳表的基本结构
跳表是在有序链表的基础上,通过增加多层索引来提高查找效率。每一层都是一个有序链表,并且上层链表是下层链表的子集。每个节点可能会跨越多个层级,节点的层级是在插入时随机确定的
(2)插入步骤演示
每次插入一个元素之前,采用抛硬币的方式记录层数,最开始的层数从 0 开始,每次抛硬币,如果正面向上,就加 1,如果反面向上,就不加,而且会立刻停止抛硬币。然后最终得到的层数,就是这个元素开始插入的层。从该层起,在每层找到最后一个比这个元素小的元素,把这个元素插入到该元素之后,接着依次向下,直到第 0 层都完成插入操作
三、渐进式Rehash的过程
(1)初始状态
- 电商网站使用 Redis 存储商品信息,每个商品信息以键值对形式存在,键为商品 ID,值为商品的详细信息(如商品名称、价格等)
- 初始时,Redis 中的哈希表 ht[0] 大小为 8,当前存储了 8 个商品信息,负载因子为 1(已使用桶数量 / 哈希桶总数 = 8 / 8 = 1),ht[1] 为空
ht[0]: | 0 | -> (1001, {name: "手机", price: 3999}) | 1 | -> (1002, {name: "电脑", price: 7999}) | 2 | -> (1003, {name: "相机", price: 5999}) | 3 | -> (1004, {name: "耳机", price: 299}) | 4 | -> (1005, {name: "手表", price: 1999}) | 5 | -> (1006, {name: "键盘", price: 499}) | 6 | -> (1007, {name: "鼠标", price: 199}) | 7 | -> (1008, {name: "音箱", price: 399})ht[1]: | 0 | -> NULL | 1 | -> NULL | 2 | -> NULL | 3 | -> NULL | 4 | -> NULL | 5 | -> NULL | 6 | -> NULL | 7 | -> NULLrehashidx = -1
(2)触发Rehash
- 当新添加商品 1009(商品 ID 为 1009,名称为 “路由器”,价格为 199)时,ht[0] 的负载因子变为 9 / 8 > 1,触发扩容操作
- Redis 为 ht[1] 分配新的内存空间,大小为 ht[0] 的 2 倍,即 16。同时,将 rehashidx 设置为 0,表示从 ht[0] 的第 0 个哈希桶开始进行 Rehash。由于处于 Rehash 过程中,新添加的商品 1009 会直接被添加到 ht[1] 中。假设商品 1009 计算得到的哈希值对 16 取模结果为 11,则:
ht[0]: | 0 | -> (1001, {name: "手机", price: 3999}) | 1 | -> (1002, {name: "电脑", price: 7999}) | 2 | -> (1003, {name: "相机", price: 5999}) | 3 | -> (1004, {name: "耳机", price: 299}) | 4 | -> (1005, {name: "手表", price: 1999}) | 5 | -> (1006, {name: "键盘", price: 499}) | 6 | -> (1007, {name: "鼠标", price: 199}) | 7 | -> (1008, {name: "音箱", price: 399})ht[1]: | 0 | -> NULL | 1 | -> NULL | 2 | -> NULL | 3 | -> NULL | 4 | -> NULL | 5 | -> NULL | 6 | -> NULL | 7 | -> NULL | 8 | -> NULL | 9 | -> NULL | 10 | -> NULL | 11 | -> (1009, {name: "路由器", price: 199}) | 12 | -> NULL | 13 | -> NULL | 14 | -> NULL | 15 | -> NULLrehashidx = 0
(3)渐进式迁移
3.3.1第一次 Rehash
- 当有用户查询商品 ID 为 1010 的商品信息时,由于处于 Rehash 过程中,会先将商品 1010 插入到 ht[1] 中。假设商品 1010 计算得到的哈希值对 16 取模结果为 4,插入后如下:
ht[0]: | 0 | -> (1001, {name: "手机", price: 3999}) | 1 | -> (1002, {name: "电脑", price: 7999}) | 2 | -> (1003, {name: "相机", price: 5999}) | 3 | -> (1004, {name: "耳机", price: 299}) | 4 | -> (1005, {name: "手表", price: 1999}) | 5 | -> (1006, {name: "键盘", price: 499}) | 6 | -> (1007, {name: "鼠标", price: 199}) | 7 | -> (1008, {name: "音箱", price: 399})ht[1]: | 0 | -> NULL | 1 | -> NULL | 2 | -> NULL | 3 | -> NULL | 4 | -> (1010, {name: "新商品", price: 888}) | 5 | -> NULL | 6 | -> NULL | 7 | -> NULL | 8 | -> NULL | 9 | -> NULL | 10 | -> NULL | 11 | -> (1009, {name: "路由器", price: 199}) | 12 | -> NULL | 13 | -> NULL | 14 | -> NULL | 15 | -> NULLrehashidx = 0
- 接着,系统会将 ht[0] 中索引为 0 的桶中的商品键值对 (1001, {name: "手机", price: 3999}) 重新计算哈希值。假设新的哈希值对 16 取模的结果为 3,则将该键值对插入到 ht[1] 的第 3 个哈希桶中,然后将 rehashidx 的值增 1,变为 1
ht[0]: | 0 | -> NULL | 1 | -> (1002, {name: "电脑", price: 7999}) | 2 | -> (1003, {name: "相机", price: 5999}) | 3 | -> (1004, {name: "耳机", price: 299}) | 4 | -> (1005, {name: "手表", price: 1999}) | 5 | -> (1006, {name: "键盘", price: 499}) | 6 | -> (1007, {name: "鼠标", price: 199}) | 7 | -> (1008, {name: "音箱", price: 399})ht[1]: | 0 | -> NULL | 1 | -> NULL | 2 | -> NULL | 3 | -> (1001, {name: "手机", price: 3999}) | 4 | -> (1010, {name: "新商品", price: 888}) | 5 | -> NULL | 6 | -> NULL | 7 | -> NULL | 8 | -> NULL | 9 | -> NULL | 10 | -> NULL | 11 | -> (1009, {name: "路由器", price: 199}) | 12 | -> NULL | 13 | -> NULL | 14 | -> NULL | 15 | -> NULLrehashidx = 1
3.3.2后续 Rehash
后续每次对商品信息执行操作(如查询、更新、删除等)时,都会顺带将 ht[0] 中 rehashidx 位置的键值对迁移到 ht[1] 中,并将 rehashidx 加 1。例如,当有用户更新商品 1002 的信息时,会把 ht[0] 中索引为 1 的 (1002, {name: "电脑", price: 7999}) 迁移到 ht[1] 中,假设新位置为 7:
ht[0]:
| 0 | -> NULL
| 1 | -> NULL
| 2 | -> (1003, {name: "相机", price: 5999})
| 3 | -> (1004, {name: "耳机", price: 299})
| 4 | -> (1005, {name: "手表", price: 1999})
| 5 | -> (1006, {name: "键盘", price: 499})
| 6 | -> (1007, {name: "鼠标", price: 199})
| 7 | -> (1008, {name: "音箱", price: 399})ht[1]:
| 0 | -> NULL
| 1 | -> NULL
| 2 | -> NULL
| 3 | -> (1001, {name: "手机", price: 3999})
| 4 | -> (1010, {name: "新商品", price: 888})
| 5 | -> NULL
| 6 | -> NULL
| 7 | -> (1002, {name: "电脑", price: 7999})
| 8 | -> NULL
| 9 | -> NULL
| 10 | -> NULL
| 11 | -> (1009, {name: "路由器", price: 199})
| 12 | -> NULL
| 13 | -> NULL
| 14 | -> NULL
| 15 | -> NULLrehashidx = 2
(4)完成 Rehash
当 rehashidx 变为 8 时,意味着 ht[0] 中的所有键值对都已迁移到 ht[1] 中。此时程序将 rehashidx 属性的值设为 -1,表示 Rehash 操作已完成。之后,电商网站对商品信息的操作就只在新的哈希表 ht[1] 上进行了
ht[0]:
| 0 | -> NULL
| 1 | -> NULL
| 2 | -> NULL
| 3 | -> NULL
| 4 | -> NULL
| 5 | -> NULL
| 6 | -> NULL
| 7 | -> NULLht[1]:
| 0 | -> (1008, {name: "音箱", price: 399})
| 1 | -> (1007, {name: "鼠标", price: 199})
| 2 | -> (1006, {name: "键盘", price: 499})
| 3 | -> (1001, {name: "手机", price: 3999})
| 4 | -> (1010, {name: "新商品", price: 888})
| 5 | -> (1005, {name: "手表", price: 1999})
| 6 | -> (1004, {name: "耳机", price: 299})
| 7 | -> (1002, {name: "电脑", price: 7999})
| 8 | -> (1003, {name: "相机", price: 5999})
| 9 | -> NULL
| 10 | -> NULL
| 11 | -> (1009, {name: "路由器", price: 199})
| 12 | -> NULL
| 13 | -> NULL
| 14 | -> NULL
| 15 | -> NULLrehashidx = -1
四、压缩列表
(1)压缩列表的基本结构
Redis 的压缩列表(ziplist)是一种为了节省内存而设计的顺序型数据结构,它被广泛应用于列表键和哈希键中,当列表元素较少或者列表元素都是小整数值或短字符串时,以及哈希键中键值对数量较少且键值都是小整数值或短字符串时,Redis 会使用压缩列表来存储数据
(2)压缩列表的结构
压缩列表是由一系列特殊编码的连续内存块组成的顺序型数据结构,其整体结构包含以下几个部分:
- zlbytes:
- 类型:4 字节无符号整数
- 作用:记录整个压缩列表所占用的内存字节数,通过这个字段,Redis 可以快速定位到压缩列表的末尾
- zltail:
- 类型:4 字节无符号整数
- 作用:记录压缩列表尾节点相对于压缩列表起始地址的偏移量,借助这个字段,Redis 能够在不遍历整个列表的情况下直接访问尾节点
- zllen:
- 类型:2 字节无符号整数
- 作用:记录压缩列表中节点的数量。不过,当节点数量超过 65535 时,这个字段的值会固定为 65535,此时需要遍历整个压缩列表才能获取准确的节点数量
- entryX:
- 类型:可变长度
- 作用:压缩列表中的节点,每个节点可以存储一个字节数组或者一个整数值
- zlend:
- 类型:1 字节特殊值
- 取值:固定为 0xFF(十进制的 255)
- 作用:标记压缩列表的结束
(3)节点的结构
每个压缩列表节点由以下三个部分组成:
- prevlen:
- 类型:长度可变,1 字节或者 5 字节
- 取值及作用:
- 如果前一个节点的长度小于 254 字节,那么 prevlen 用 1 字节来存储该长度
- 如果前一个节点的长度大于等于 254 字节,prevlen 则用 5 字节来存储,其中第一个字节固定为 0xFE(十进制的 254),后面 4 个字节存储实际的长度值
- 通过这个字段,Redis 可以从后向前遍历压缩列表
- encoding:
- 类型:长度可变
- 作用:记录节点所保存的数据的类型以及长度。不同的编码方式会使用不同的字节数来表示数据类型和长度
- data:
- 类型:长度可变
- 作用:节点实际保存的数据
(4)示例说明
假设我们有一个简单的压缩列表,用于存储一个包含三个元素的列表:["apple", 100, "banana"]。下面是这个压缩列表的结构示例:
+--------+
| zlbytes| 29(4 字节无符号整数,记录整个压缩列表所占用的内存字节数)
+--------+
| zltail | 20(4 字节无符号整数,记录压缩列表尾节点相对于压缩列表起始地址的偏移量)
+--------+
| zllen | 3(2 字节无符号整数,记录压缩列表中节点的数量)
+--------+
| entry1 |
| | prevlen | 0(1 字节,表示前一个节点长度,因为是第一个节点,所以为 0)
| | encoding| 0b10000101(1 字节,高 2 位 10 表示字符串,低 6 位 000101 表示字符串 "apple" 的长度 5)
| | data | "apple"(5 字节,实际存储的字符串内容)
+--------+
| entry2 |
| | prevlen | 7(1 字节,前一个节点 "apple" 长度为 5 字节,加上 prevlen 和 encoding 各 1 字节,共 7 字节)
| | encoding| 0b00000011(1 字节,表示存储的是 1 字节整数)
| | data | 100(1 字节,实际存储的整数 100)
+--------+
| entry3 |
| | prevlen | 3(1 字节,前一个节点总长度为 3 字节,即 prevlen 1 字节 + encoding 1 字节 + data 1 字节)
| | encoding| 0b10000110(1 字节,高 2 位 10 表示字符串,低 6 位 000110 表示字符串 "banana" 的长度 6)
| | data | "banana"(6 字节,实际存储的字符串内容)
+--------+
| zlend | 0xFF(1 字节,固定值,标记压缩列表结束)
+--------+
五、Set数据结构
(1)存储小数据时采用的结构:整数集合(intset)
- 适用场景:当集合中的元素全部为整数,并且元素数量不超过 set-max-intset-entries(默认值为 512)时,Redis 会采用整数集合来存储集合元素
- 结构特点:
- 内存紧凑:整数集合是一块连续的内存区域,其存储效率较高,能有效节省内存空间
- 有序存储:集合中的元素按照从小到大的顺序排列,这样可以利用二分查找来快速定位元素,时间复杂度为 O(logn)
- 自动升级:整数集合支持不同的编码方式,如 INTSET_ENC_INT16、INTSET_ENC_INT32 和 INTSET_ENC_INT64。当插入的新元素无法用当前编码表示时,整数集合会自动升级编码方式,以适应新元素的存储需求
- 示例说明:假设集合中存储的元素为 {1, 2, 3, 4, 5},且元素数量未超过 set-max-intset-entries,Redis 会使用整数集合来存储这些元素。存储结构如下
+------------+-----------------+ | encoding | 表示当前的编码方式,如 INTSET_ENC_INT16 +------------+-----------------+ | length | 集合中元素的数量,这里为 5 +------------+-----------------+ | contents | 依次存储元素 1, 2, 3, 4, 5 +------------+-----------------+
(2)存储大数据时采用的结构:哈希表(hashtable)
- 适用场景:当集合中的元素不全部为整数,或者元素数量超过 set-max-intset-entries 时,Redis 会使用哈希表来存储集合元素
- 结构特点:
- 高效查找:哈希表的查找、插入和删除操作的平均时间复杂度为 O(1),这使得 Redis 在处理大量元素时能保持高效的性能
- 键值对存储:哈希表以键值对的形式存储元素,其中键为集合中的元素,值统一为 NULL。这样可以利用哈希表的特性来确保元素的唯一性
- 渐进式 rehash:当哈希表的负载因子过高时,Redis 会进行 rehash 操作,将元素从旧的哈希表迁移到新的哈希表中。为了避免一次性迁移大量元素导致的性能问题,Redis 采用了渐进式 rehash 的方式,在每次操作时逐步迁移一部分元素
- 示例说明:假设集合中存储的元素为 {"apple", "banana", "cherry", "date"},由于元素为字符串,Redis 会使用哈希表来存储这些元素。存储结构如下
+-----------------+-----------------+ | 哈希表数组 | 存储多个哈希桶 +-----------------+-----------------+ | 哈希桶 1 | 链表,存储键值对 ("apple", NULL) +-----------------+-----------------+ | 哈希桶 2 | 链表,存储键值对 ("banana", NULL) +-----------------+-----------------+ | 哈希桶 3 | 链表,存储键值对 ("cherry", NULL) +-----------------+-----------------+ | 哈希桶 4 | 链表,存储键值对 ("date", NULL) +-----------------+-----------------+
(3)结构转换
当集合从满足使用整数集合的条件转变为不满足时,Redis 会自动将整数集合转换为哈希表。例如,当向一个原本使用整数集合存储的集合中插入一个非整数元素,或者元素数量超过 set-max-intset-entries 时,Redis 会触发转换操作
六、Zset数据结构
(1)存储小数据时采用的结构:压缩列表(ziplist)
- 适用场景:当有序集合同时满足元素数量少于 zset-max-ziplist-entries(默认值为 128),且每个元素的成员长度和分数长度都小于 zset-max-ziplist-value(默认值为 64 字节)时,Redis 使用压缩列表存储 Zset 数据
- 结构特点:
- 内存紧凑,是连续的内存块,通过特殊编码存储数据节省内存
- 元素按分数从小到大顺序存储,每个元素的成员和分数依次存于压缩列表,一个元素由两个连续节点表示
- 示例说明:假设有序集合存储 {"apple": 1.0, "banana": 2.0, "cherry": 3.0} 且满足使用压缩列表条件,其存储结构如下
+--------+ | zlbytes| 记录整个压缩列表占用字节数 +--------+ | zltail | 尾节点相对于起始地址的偏移量 +--------+ | zllen | 压缩列表中节点数量 +--------+ | entry1 | | | prevlen | 前一节点长度,首节点为 0 | | encoding| 编码表示 "apple" 为字符串及长度 | | data | "apple" +--------+ | entry2 | | | prevlen | entry1 的长度 | | encoding| 编码表示 1.0 分数格式 | | data | 1.0 +--------+ | entry3 | | | prevlen | entry2 的长度 | | encoding| 编码表示 "banana" 为字符串及长度 | | data | "banana" +--------+ | entry4 | | | prevlen | entry3 的长度 | | encoding| 编码表示 2.0 分数格式 | | data | 2.0 +--------+ | entry5 | | | prevlen | entry4 的长度 | | encoding| 编码表示 "cherry" 为字符串及长度 | | data | "cherry" +--------+ | entry6 | | | prevlen | entry5 的长度 | | encoding| 编码表示 3.0 分数格式 | | data | 3.0 +--------+ | zlend | 固定值 0xFF 标记结束 +--------+
(2)存储大数据时采用的结构:跳跃表(skiplist)与哈希表(hashtable)结合
- 适用场景:当有序集合不满足使用压缩列表的条件时,采用跳跃表和哈希表的组合结构存储
- 结构特点:
- 跳跃表(skiplist):
- 能高效排序与查找,通过节点维护多个指向其他节点的指针,平均时间复杂度 O(logn) 完成插入、删除和查找
- 节点高度随机生成,可在不同数据分布下保持较好性能
- 哈希表(hashtable):
- 能快速查找元素,以元素成员为键、分数为值,平均时间复杂度 O(1) 完成查找
- 两者共享元素的成员和分数信息,操作时同时更新保证数据一致
- 跳跃表(skiplist):
- 示例说明:
- 跳跃表:
+--------+--------+--------+--------+--------+ Level 2 | Head | "cat" | "elephant" | | Tail |+--------+--------+--------+--------+--------+| | | | | || | | | | | Level 1 | Head | "cat" | "dog" | "elephant" | "fox" | Tail |+--------+--------+--------+--------+--------+--------+| | | | | | || | | | | | | Level 0 | Head | "cat" | "dog" | "elephant" | "fox" | Tail |+--------+--------+--------+--------+--------+--------+节点详情: - Head 节点:- 各层前进指针分别指向对应层的下一个节点 - "cat" 节点:- 成员: "cat"- 分数: 2.5- 后退指针: 无- 层数: 2- 第 0 层前进指针: 指向 "dog" 节点- 第 1 层前进指针: 指向 "dog" 节点- 第 2 层前进指针: 指向 "elephant" 节点 - "dog" 节点:- 成员: "dog"- 分数: 3.5- 后退指针: 指向 "cat" 节点- 层数: 1- 第 0 层前进指针: 指向 "elephant" 节点- 第 1 层前进指针: 指向 "elephant" 节点 - "elephant" 节点:- 成员: "elephant"- 分数: 4.5- 后退指针: 指向 "dog" 节点- 层数: 2- 第 0 层前进指针: 指向 "fox" 节点- 第 1 层前进指针: 指向 "fox" 节点- 第 2 层前进指针: 指向 Tail 节点 - "fox" 节点:- 成员: "fox"- 分数: 5.5- 后退指针: 指向 "elephant" 节点- 层数: 1- 第 0 层前进指针: 指向 Tail 节点- 第 1 层前进指针: 指向 Tail 节点 - Tail 节点:- 无成员和分数
- 哈希表:
+-----------------+ | 哈希表数组 | | 大小: 4 | +-----------------+ | 哈希桶 0 | | 链表: ("cat", 2.5) +-----------------+ | 哈希桶 1 | | 链表: ("dog", 3.5) +-----------------+ | 哈希桶 2 | | 链表: ("elephant", 4.5) +-----------------+ | 哈希桶 3 | | 链表: ("fox", 5.5) +-----------------+
- 跳跃表:
- 查找过程说明:
(3)结构转换
当有序集合元素数量或元素长度超出压缩列表使用条件,Redis 自动将压缩列表转换为跳跃表和哈希表的组合结构,且此转换不可逆
七、总结
Redis 一共有五种基本数据结构:
- String:键对应的值为 String 类型,其底层实现是简单动态字符串(SDS)。SDS 不仅能存储普通字符串、数字,还能存储二进制数据。存储的数据存放在 SDS 的 buf 数组部分,同时 SDS 通过额外的元数据(如长度信息等)来优化字符串操作性能,相比传统 C 字符串,SDS 在追加、修改等操作时能更高效地管理内存,避免缓冲区溢出等问题
- List:列表类型,底层实现有压缩列表和双向链表两种。当数据量较少且每个元素的长度较短时,Redis 会选择压缩列表来存储。每次向列表中添加一个数据,该数据就会成为压缩列表中的一个节点。压缩列表是紧凑的连续内存结构,适合从头部或尾部遍历,并且可以通过 zllen 字段快速获取元素数量。当数据量较多时,会采用双向链表存储。双向链表在插入和删除操作上具有优势,无需移动大量元素,时间复杂度为 O(1),但在内存占用和随机访问性能上不如压缩列表
- Hash:底层基于哈希表结构。当插入一个键值对时,先对键进行哈希计算,然后对哈希表的大小取模,得到对应的哈希桶索引,将键值对存储到该哈希桶中。哈希桶中可能存在多个键值对(通过链表解决哈希冲突)。随着数据量的增加,哈希表可能会出现负载因子过高的情况,此时会触发 rehash 操作,Redis 采用渐进式 rehash 来避免一次性大量数据迁移带来的性能问题,即在每次对哈希表进行操作时,顺带迁移一部分数据到新的哈希表中
- Set:对于小数据量且元素全为整数的情况,使用整数集合存储。整数集合是紧凑的有序结构,能有效节省内存。当数据量较大或元素类型不全为整数时,采用哈希表存储。向 Set 中添加数据时,如果使用哈希表存储,数据会以键值对形式存储在哈希桶中,其中键为数据本身,值统一为 NULL,利用哈希表的特性来保证元素的唯一性
- Zset:小数据量时采用压缩列表存储。压缩列表中,每个元素的成员和分数依次存储,元素按分数从小到大排列。当数据量较大时,采用跳跃表和哈希表结合的方式存储。跳跃表基于节点的多层指针结构,在范围查询(如按分数范围查找成员)上具有 O(logn) 的时间复杂度优势。哈希表则用于通过成员快速查找对应的分数,因为哈希表以成员为键、分数为值,查找时间复杂度平均为 O(1),二者结合能高效地满足 Zset 各种操作需求