参考博客/图片来源列表:
Redis是是一个保存key-value的非关系型数据库,其使用一个哈希表
保存所有键值对。
哈希表(Hash Table)
- Redis 使用哈希表(也叫字典,dictionary)来存储键值对数据。这是其高效的存储结构,使得获取和设置键值对的时间复杂度保持在 O(1)。
- 类似于hashmap的底层,哈希表的底层实现是一个数组,每个数组元素称为
哈希桶
(hash bucket),哈希桶中存储的是指向键值对的指针(dictEntry*),这样可以通过哈希桶快速定位到特定的键值对
哈希桶(Hash Bucket):
- 每个哈希桶(即
dictEntry*
)保存了实际的键和值。 - 由于 Redis 的键和值都是对象,键和值的指针使用了
void *
类型,意味着它们可以指向任何类型的数据对象(比如字符串、集合、列表等)
键值对数据结构(dictEntry):
- 一个
dictEntry
通常包含以下几个字段:void * key
: 指向键的数据指针。void * value
: 指向值的数据指针,值可以是不同的数据类型,例如字符串、整数或集合类型等。- Redis 的这种设计保证了数据结构的灵活性,使得即使值是复杂的集合类型(如列表、集合、哈希、字符串等),也能通过 void * 指针访问。
对上图进行讲解:
-
整体结构是一个
redisDb
结构体,其中包含一个dict
结构体 -
dict
结构包含两个dictType
结构体,分别指向ht[0]
和ht[1]
。ht[0]
和ht[1]
是dictEntry
结构体的数组,用于存储键值对。- 当
ht[0]
的负载因子超过一定阈值时,会进行扩容操作,将ht[0]
中的数据重新哈希到ht[1]
中。 - 扩容操作通过
dictExpand
函数实现,确保哈希表的负载均衡。
-
dictEntry
:dictEntry
结构体包含三个主要部分:void* key
、void* value
和dictEntry** next
。next
指针用于解决哈希冲突,当多个键哈希到同一位置时,通过链表连接。void * key
和void * value
指针指向的是 Redis 对象,Redis 中的每个对象都由redisObject
结构表示,如下图:
这张图展示了 Redis 中对象结构及其底层数据结构的关系。
对象结构(redisObject)
在图的左侧,有一个名为redisObject
的对象结构,它由三个主要部分组成:
- type
- 表示对象的类型。在 Redis 中,对象类型可以是字符串、列表、集合、有序集合或哈希等。
- encoding
- 表示对象的编码方式。Redis 根据不同的情况会采用不同的编码方式来存储数据,以优化内存使用和性能。
- void * ptr
- 表示指向实际数据存储位置的指针。这个指针指向了底层数据结构中的实际数据。
一、string内部实现
String 是最基本的键值对结构,其中 key
是唯一标识符,value
是关联的具体数据。value
不仅可以是字符串,也可以是数字(整数或浮点数)。每个 value
最多可以容纳 512MB 的数据。
String 类型的底层的数据结构实现主要是 int 和SDS
(简单动态字符串)。
先说C语言字符串存在的缺陷:
char *c = "string"
| s | t | r | i | n | g | \0 | <- 字符串在内存中的表示
^
|
c -> 指向字符串的首地址
- 获取字符串长度的时间复杂度为O(n)
- 以\0结尾字符标识,字符串里面不能包含有 “\0” 字符,因此不能保存二进制数据
- 字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止;
SDS结构:
SDS 结构由四个主要部分组成:
- len
- 这是一个表示字符串长度的字段。它存储了当前字符串中字符的数量。获取字符串长度时间复杂度为
O(1)
- 这是一个表示字符串长度的字段。它存储了当前字符串中字符的数量。获取字符串长度时间复杂度为
- alloc
- 这个字段表示分配的空间长度,即当前为字符串分配的字节数。它通常大于或等于
len
,这样在修改字符串的时候,可以通过alloc - len
计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出的问题。
- 这个字段表示分配的空间长度,即当前为字符串分配的字节数。它通常大于或等于
- flags
- 这个字段用于表示 SDS 类型,即存储在 SDS 中的数据类型。一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。
- 这 5 种类型的主要区别就在于数据结构中的 len 和 alloc 成员变量的数据类型不同。比如 sdshdr16 和 sdshdr32 这两个类型,它们的定义分别如下:
struct __attribute__((packed)) sdshdr16 { uint16_t len; // 字符串的当前长度 uint16_t alloc; // 分配的内存长度 unsigned char flags; // 标志位(通常用于一些状态或属性的标识) char buf[]; // 动态长度的字符数组,实际存储字符串数据 }; struct __attribute__((packed)) sdshdr32 { uint32_t len; // 字符串的当前长度 uint32_t alloc; // 分配的内存长度 unsigned char flags; // 标志位 char buf[]; // 动态长度的字符数组,实际存储字符串数据 };
- 不同类型的结构体,能灵活保存不同大小的字符串,从而有效节省内存空间
- buf[]
- 这是一个字节数组,用于实际存储字符串的字符。字符以字节的形式存储在这个数组中,不仅可以保存字符串,也可以保存二进制数据。
- SDS 不需要用 “\0” 字符来标识字符串结尾了,而是有个专门的 len 成员变量来记录长度,可存储包含 “\0” 的数据。但是 SDS 为了兼容部分 C 语言标准库的函数, SDS 字符串结尾还是会加上 “\0” 字符;
string对象:
字符串对象的内部编码(encoding)有 3 种 :int、raw和 embstr。
-
若字符串对象保存的是一个可以使用
long
类型来表示的整数值,则字符串对象整数值保存在ptr0、
属性里(void *
转成long
),将encoding
设置为int
-
若字符串对象保存的是一个字符串,则使用一个SDS保存这个字符串,
- 若这个字符串长度小于32字节,
encoding
设置为embstr
(一种保存短字符串的优化编码方式)- embstr会通过一次内存分配函数来分配一块连续的内存空间来保存redisObject和SDS(释放和分配内存次数降低为1次)
- embstr会通过一次内存分配函数来分配一块连续的内存空间来保存redisObject和SDS(释放和分配内存次数降低为1次)
- 若这个字符串长度大于32字节,
encoding
设置为raw
raw
编码会通过调用两次内存分配函数来分别分配两块空间来保存redisObject
和SDS
>
- 若这个字符串长度小于32字节,
embstr 编码和 raw 编码的边界在 redis 不同版本中是不一样的:
- redis 2.+ 是 32 字节
- redis 3.0-4.0 是 39 字节
- redis 5.0 是 44 字节
二、List
List可按照插入顺序排序,可以从头部或尾部向List列表添加元素
List 类型的底层数据结构是由双向链表或压缩列表实现的;Redis 3.2以后,List数据类型底层就只由quicklist实现,代替了双向链表和压缩列表。
双向链表
C 语言本身没有链表这个数据结构的,所以 Redis 自己设计了一个链表数据结构。
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
typedef struct list {
// 链表头节点
listNode head;
// 链表尾节点
listNode *tail; // tail 应该是指针类型,因为它指向链表的尾节点
// 节点值复制函数
void *(*dup)(void *ptr);
// 节点值释放函数
void (*free)(void *ptr);
// 节点值比较函数
int (*match)(void *ptr, void *key);
// 链表节点数量
unsigned long len;
} list;
链表的优势:
- 快速访问前后节点
- 获取链表头尾节点的高效性
- 常数时间获取节点数量
- 支持多种数据类型
- 链表中的节点值使用 void * 类型的指针存储,因此可以存储任何类型的数据。
- 通过提供 dup、free 和 match 函数指针,链表节点可以针对不同类型的数据实现特定的操作(如复制、释放、比较)
链表的缺陷:
- 内存非连续,无法利用 CPU 缓存
- 节点内存开销较大
- 链表节点都需要额外的内存空间来存储 prev 和 next 指针
压缩列表:
当链表数据比较少的时候,会采用压缩列表进行存储了。
压缩列表将链表设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。
时间换空间
压缩列表在表头有三个字段:
-
zlbytes:记录整个压缩列表占用对内存字节数;
-
zltail:记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;
-
zllen:记录压缩列表包含的节点数量;
-
zlend:标记压缩列表的结束点,固定值 0xFF(十进制255)。
压缩列表节点包含三部分内容:
-
prevlen:记录了「前一个节点」的长度;
- 这个字段的存在使得压缩列表可以从尾部向头部遍历,便于高效删除元素。
- 为了节省内存,prevlen 并不是每个节点都固定大小,而是根据节点的大小动态调整。
- 如果前一个节点的长度小于 254 字节,那么 prevlen 只需要使用一个字节来存储。
- 如果前一个节点的长度超过 254 字节,那么 prevlen 会使用两个字节来存储,从而避免浪费空间。
-
encoding:指示当前节点的数据类型和存储方式。
-
压缩列表会根据存储的数据类型(例如字符串或整数)使用不同的编码方式,从而节省内存。
-
不同的编码方式对应不同的数据类型和大小。
-
Redis 在压缩列表中使用不同的编码方式来存储数据,根据数据类型和数据大小来选择合适的编码方式。这样可以减少内存开销。
-
常见的编码方式包括:
-
整数编码:
对于小范围的整数,Redis 会使用整数编码(int8、int16、int32、int64)来存储,节省空间。例如,如果一个整数的值在 int8 范围内(即 -128 到 127),则只需使用一个字节存储它。
这比使用普通的字符串表示整数要节省内存。 -
字符串编码:
对于字符串,Redis 会根据字符串的长度选择不同的编码方式:
- 小字符串:如果字符串较短(通常是少于 64 字节),Redis 会直接在压缩列表节点中存储该字符串的内容。
- 长字符串:对于较长的字符串(超过一定长度),Redis 会使用特殊的编码格式(如 raw 编码)来优化存储,尽量减少内存占用。
-
指针编码:
对于更复杂的数据结构或对象,Redis 会使用指针编码来优化存储。这可以避免将整个数据结构拷贝到压缩列表中,而是通过指针引用外部内存,减少冗余。
-
-
-
data:记录了当前节点的实际数据
压缩列表在特定情况下会出现非常消耗性能的连锁更新问题:
在一个压缩列表中,有多个连续的,长度介于250字节到253字节之间的节点:
在entry1节点前加入一个前置节点entry0,大小为258字节。如图所示:
由于entry1保存前置节点的prevlen仅有1字节,无法存储,需要扩展到2字节存储,但是这样就会使entry1的字节长度变化到253+2=255字节长度,这样,entry2的prevlen又无法存储entry1的字节长度,又需要扩展到2字节存储,这样以此类推,程序需要不断的执行空间重分配操作。
当然发生这种情况的机率很低,首先得列表里恰好要有多个连续的,长度介于250-253字节之间的节点,其次只要被更新的节点数量不多,就不会对性能造成影响
删除节点时也会导致连锁更新
应用场景:
压缩列表只会用于保存的节点数量不多的场景,只要节点数量足够小,即使发⽣连锁更新,也是能接受的。
quicklist
在 Redis 3.0 之前,List 对象的底层数据结构是双向链表或者压缩列表。然后在 Redis 3.2 的时候,List 对象的底层改由 quicklist 数据结构实现。
quicklist = 「双向链表 + 压缩列表」
即一个 quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表
压缩列表的缺点:连锁更新
quicklist想法:
通过控制每个链表节点中的压缩列表的大小或元素个数,quicklist 能够有效规避连锁更新问题。在较小的压缩列表节点中,每次更新的影响会相对较小,从而减少了性能损失,并提高了访问效率。这种设计策略有效提高了 Redis 列表类型操作的性能,尤其是在面对频繁插入、删除操作时。
结构设计:
quicklist 的结构体跟链表的结构体类似,都包含了表头和表尾,区别在于 quicklist 的节点是 quicklistNode。
typedef struct quicklistNode {
// 快速列表节点中的压缩列表
unsigned char * zl; // 指向压缩列表(ziplist)
struct quicklistNode * prev; // 指向前一个节点
struct quicklistNode * next; // 指向下一个节点
unsigned long count; // 该节点中压缩列表的元素数量
} quicklistNode;
typedef struct quicklist {
quicklistNode * head; // quicklist的链表头节点
quicklistNode * tail; // quicklist的链表尾节点
unsigned long count; // 所有压缩列表中的总元素个数
unsigned long len; // quicklist节点的个数
} quicklist;
【待完善】
三、Hash
Hash是一个键值对(key -value)集合,其中value的形式如:
value= [{field1,value1}, ... {fieldN, valueN}]
。Hash 特别适合用于存储对象。
Hash 类型的底层数据结构是由压缩列表或哈希表实现的:
- 如果哈希类型元素个数小于512 个(默认值,可由hash-max- ziplist-entries配置), 所有值小
于64字节(默认值,可由hash-max-ziplist-value 配置)的话,Redis会使用压缩列表作为
Hash类型的底层数据结构; - 如果哈希类型元素不满足上面条件, Redis会使用哈希表作为Hash类型的底层数据结构。
在Redis 7.0中,压缩列表数据结构已经废弃了,郊listpack数据结构实现了。
Redis 的哈希表结构如下:
typedef struct dictht {
// 哈希表数组,存储字典的节点
dictEntry *table;
// 哈希表的大小
unsigned long size;
// 哈希表的大小掩码,用于计算哈希值的索引
unsigned long sizemask;
// 哈希表中已有的节点数量
unsigned long used;
} dictht;
哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针。
有点类似于hashmap的底层
哈希表节点:
typedef struct dictEntry {
// 键,指向键值对中的键,可以是任何数据类型
void *key;
// 值部分,使用联合体(union)来支持不同类型的值
union {
void *val; // 指向值的指针,可以是任何类型
uint64_t u64; // 64位无符号整数类型
int64_t s64; // 64位有符号整数类型
double d; // 双精度浮点数
};
// 指向下一个哈希表节点,形成链表,用于解决哈希冲突
struct dictEntry *next;
} dictEntry;
union:
val 是一个联合体,提供了多种类型的值存储方式。每次只能使用联合体中的一个字段来存储值,具体存储哪一种类型的数据取决于应用场景。
具体的联合体字段包括:
- void *val:这是一个指向任意类型数据的指针,通常用于存储动态类型的值。
- uint64_t u64:这是一个 64 位的无符号整数,用于存储整数类型的值。
- int64_t s64:这是一个 64 位的有符号整数,用于存储整数类型的值。
- double d:这是一个双精度浮点数,用于存储浮点类型的值。
这么做的好处是可以节省内存空间,因为当「值」是整数或浮点数时,就可以将值的数据内嵌在 dictEntry 结构里,无需再用一个指针指向实际的值,从而节省了内存空间。
redis定义了一个dict
结构体,这个结构体里定义了两个哈希表(ht[2]
):
typedef struct dict {
// 两个哈希表,交替使用,用于 rehash 操作
dictht ht[2];
} dict;
在rehash
的时候,需要用上 2 个哈希表。
rehash解决链表过长问题:
- 给哈希表2分配空间,一般比哈希表1大一倍
- 将哈希表1数据迁移到哈希表2
- 释放哈希表1空间,将哈希表2设置为哈希表1
- 在哈希表2创建一个新的哈希表
为例减少数据迁移过程种,影响redis性能,一般使用渐进式rehash:
- 每次对哈希表元素进行增删改查时,redis处理进行相应操作,也会将顺序将哈希表1的key-value迁移到哈希表2上
- 随着客户端发起的哈希表操作数量越多,最终在某个时间点会完成数据迁移
- 新增会添加到哈希表2,查询先找哈希表1,再找哈希表2
rehash 的触发条件跟负载因子(load factor)有关系:
负载因子
=
哈希表已保存节点数量
/
哈希表大小
负载因子 = 哈希表已保存节点数量/哈希表大小
负载因子=哈希表已保存节点数量/哈希表大小
触发条件:
- 当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作。
- 当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作。
四、Set
Set最多可以存储 2 32 − 1 2^{32-1} 232−1个元素,支持集合内的增删改查,还可以进行交集、并集、差集等。
Set类型的底层数据结构是由哈希表或者整数集合
- 若集合元素都是整数且元素个数小于512个,Redis会用整数集合作为底层的数据结构
- 不满足则使用哈希表作为底层数据结构
整数集合:
整数集合本质是一块连续的内存空间。结构定义如下:
typedef struct intset {
// 编码方式,表示当前整数集合的编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存整数元素的数组,长度不固定
int8_t contents[];
} intset;
-
uint32_t encoding
:- 该字段表示集合中整数的编码方式。不同的编码方式用于优化内存使用和访问效率。根据整数的大小,
intset
会使用不同的编码方式来存储元素:int8_t
:用于存储 8 位整数(当集合中的元素值较小时)。int16_t
:用于存储 16 位整数(当元素值在较小范围内时)。int32_t
:用于存储 32 位整数(当元素值较大时)。- 这个编码方式允许
intset
结构在内存和性能之间做出权衡,根据实际元素大小选择最合适的存储方式。
- 该字段表示集合中整数的编码方式。不同的编码方式用于优化内存使用和访问效率。根据整数的大小,
-
uint32_t length
:- 该字段表示集合中当前包含的元素数量。它记录集合中元素的个数,通常用于边界检查、插入操作、遍历等。
-
int8_t contents[]
:- 该字段是一个动态大小的数组,用于存储实际的整数元素。
contents
数组的类型是int8_t
,表示它存储的是字节数组。 - 由于该数组是灵活的(未指定固定大小),其实际大小会根据
encoding
字段的值和集合中的元素个数动态调整。 - 如果
encoding
是int8_t
,那么contents
就是一个**int8_t
**类型的数组。
- 该字段是一个动态大小的数组,用于存储实际的整数元素。
整数集合的升级操作;
当一个新元素加入到整数集合,新元素比现有所有元素都要长,需要进行升级,即按新元素的类型扩展contents
数组空间大小,然后将新元素加到整数集合里,升级过程也要维持整数集合的有序性。
注:
不支持降级操作。
整数集合 结构的设计目标之一是通过根据元素值的大小来选择不同的编码方式,从而节省内存。比如,如果集合中的所有元素都很小,那么使用 int8_t 编码比使用 int32_t 编码更节省内存。
五、BitMap
Redis 的 Bitmap(位图)底层数据结构是基于字符串(String)实现的。在 Redis 中,字符串是一种简单的字节数组。
- 对于 Bitmap 来说,它将字符串的每个字节(8 位)看作是一个可以进行位操作的单元。例如,一个长度为 1 字节(8 位)的字符串可以表示一个范围从 0 到 7 的位图。
- 当执行 SETBIT 命令(用于设置位图中某一位的值)或 GETBIT 命令(用于获取位图中某一位的值)时,Redis 会通过计算位偏移量来定位到对应的字节中的位。
- 假设要设置位偏移量为 10 的位,Redis 会先计算出这个位位于第几个字节(10/8 = 1 余 2,所以位于第 2 个字节),然后在这个字节中定位到具体的位(余数 2 就是在字节中的偏移量)。
5.1 具体应用-布隆过滤器
- Redis 中的布隆过滤器底层是基于位数组(bit array)和多个哈希函数来实现的。位数组是一个二进制数组,用于存储元素经过哈希函数映射后的结果。每个位初始值为 0。
- 例如,假设有一个长度为 100 的位数组,它可以表示 100 个位的状态。当有元素加入布隆过滤器时,通过哈希函数将元素映射到这个位数组中的某些位。
工作原理:
- 布隆过滤器由一个长度为 m 的
位数组
(bit array,也就是二进制数组,初始值全为 0)和 k 个相互独立的哈希函数
组成
- 当某个元素要加入到布隆过滤器:
- 通过k个哈希函数计算得到k个不同的哈希值
- 得到的哈希值分别对应位数组中的某些位置,就把这些位置的位设置为 1
- 当要判断一个元素是否在集合中时:
- 用这 k 个哈希函数对该元素进行计算,得到 k 个哈希值对应的位数组中的位
- 这 k 个位都为 1,那么就认为这个元素很可能在集合中
- 而只要有一个位为 0,那这个元素肯定不在集合中。
优点
- 空间效率高:
- 它只需要占用很小的内存空间就能表示大量的元素集合,相比于直接用其他数据结构(如哈希表等)来存储集合元素,在存储海量元素时能极大节省内存。
- 例如,要存储大量的 URL 来判断某个 URL 是否已经被访问过,使用布隆过滤器可以用很少的内存来实现初步的快速判断。
- 查询速度快:
- 判断元素是否存在的操作主要就是进行几次哈希计算以及对位数组对应位的检查,时间复杂度是常数级别的,所以查询速度非常快,适合用在对性能要求较高的场景中。
- 比如缓存穿透场景下判断请求的数据是否在缓存中存在(即使不存在,通过布隆过滤器快速排除也能避免大量无意义的数据库查询)。
缺点
- 存在误判率:
- 这是布隆过滤器比较重要的一个特性,也就是所谓的 “假阳性”(False Positive)。
- 因为不同元素经过哈希函数计算后可能会使位数组中的相同位置被置为 1,所以当判断一个元素时,即使它原本不在集合中,但如果对应的位恰好都被其他元素设置为 1 了,就会误判它在集合中。
- 不过可以通过合理设置位数组大小、哈希函数数量等参数来控制误判率在可接受范围内。
- 不支持删除元素:
- 一旦元素被添加到布隆过滤器中,就很难准确地将其从过滤器中删除。
- 因为删除某个元素对应的位可能会影响到其他元素的判断结果,可能导致本来存在的其他元素被误判为不存在了。