2025-03-17
redis
00
请注意,本文编写于 146 天前,最后修改于 38 天前,其中某些信息可能已经过时。

目录

Redis底层数据结构全面详解
1.动态字符串SDS
实现原理
2.整数集合IntSet
IntSet升级
3.Dict字典
哈希表和哈希节点
字典
扩容机制
rehash
4.压缩列表ZipList
ZipListEntry
encoding编码
5.双端链表QuickList
6.跳表SkipList
实现原理
7.RedisObject
编码方式
8.五种数据类型
String
List
Set
ZSet
Hash

Redis底层数据结构全面详解


1.动态字符串SDS

我们都知道Redis中保存的Key是字符串,value往往是字符串或者字符串的集合,可见字符串是Redis中最常用的一种数据结构,虽然Redis是用C语言来实现的,但是它并没有沿用C语言中的字符串,因为C语言字符串存在很多问题:

  • 获取字符串长度的需要通过运算
  • 非二进制安全
  • 不可修改

因此Redis构建了一种新的字符串结构,称为简单动态字符串(Simple DynamicString),简称SDS

例如,我们执行命令:

SET name jack

那么Redis将在底层创建两个SDS,其中一个是包含“name”的SDS,另一个是包含jack”的SDS

实现原理

Redis是用C语言实现的,所以它的具体源码如下:

c
structattribute__((_packed_))sdshdr8 { uint8_t len; /* buf已保存的字符串字节数,不包含结束标示*/ uint8_t alloc; /* buf申请的总的字节数,不包含结束标示*/ unsigned char flags; /*不同SDS的头类型,用来控制SDS的头大小*/ char buf[]; /*保存字符串*/ };

以下就是一个SDS真正的结构:

len:4alloc:4flags:1jack\0

SDS之所以叫动态字符串,是因为它具备扩容能力,在扩容时他先会申请新的内存空间

  • 如果新字符串小于1M,则新空间为扩展后字符串长度的两倍+1
  • 如果新字符串大于1M,则新空间为扩展后字符串长度+1M+1 称为内存预分配

2.整数集合IntSet

IntSet是Redis中set集合的一种实现方式,基于整数数组来实现,并且具备长度可变、有序等特征

结构体如下:

c
typedef struct intset { uint32_t encoding; /*编码方式,支持存放16位、32位、64位整数*/ uint32_t length; /* 元素个数 */ int8_t contents[]; /*整数数组,保存集合数据*/ } intset;

对于encoding就决定了存储整数的大小,总共有三种类型:

c
/* Note that these encodings are ordered, so: INTSET ENC INT16 < INTSET ENC INT32 < INTSET _ENC INT64. */ #define INTSET_ENC_INT16(sizeof(int16_t))/* 2字节整数,范围类似java的shork*/ #define INTSET_ENC_INT32(sizeof(int32_t))/* 4字节整数,范围类似java的int */ #define INTSET_ENC INT64(sizeof(int64_t))/* 8字节整数,范围类似java的long */

为了方便查找Redis将所有整数按照升序依次保存在contents数组中,结构如下:

encoding = INTSET_ENC_INT16length = 351015

现在,数组中每个数字都在int16 t的范围内,因此采用的编码方式是INTSET ENC INT16每个数字都占两个字节,每部分占用的字节大小为:

  • encoding :4字节
  • length: 4字节
  • contents: 2字节 * 3 = 6字节

IntSet升级

我们向该其中添加一个数字:50000,这个数字超出了int16 t的范围,intset会自动升级编码方式到合适的大小。

以当前案例来说流程如下:

  1. 升级编码为合适大小,这里是INTSET_ENC_INT32,每个整数占4字节,并按照新的编码方式及元素个数扩容数组
  2. 倒序依次将数组中的元素拷贝到扩容后的正确位置
  3. 最终该表头信息encoding 改为INTSET_ENC_INT32,length改为4

IntSet只适合存储数据量较少的数据,不适合存储大量数据


3.Dict字典

我们知道Redis是一个键值型(Key-Value Pair)的数据库,我们可以根据键实现快速的增删改查,而键与值的映射关系正是通过Dict来实现的

Dict由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)

哈希表和哈希节点

其结构体如下:

c
typedef struct dictht { // 哈希表 // entry数组 //数组中保存的是指向entry的指针 dictEntry **table; //哈希装大小 unsigned long size; //哈希表大小的掩码,总等于size-1 unsigned long sizemask; // entry个数 unsigned long used; } dictht;
c
typedef struct dictEntry { // 哈希节点 void *key;//键 union { void *val; uint64_t u64; int64_t s64; double d; }v;//值 // 下一个Entry的指针 struct dictEntry *next; } dictEntry;

当我们向Dict添加键值对时,Redis首先根据key计算出hash值(h),然后利用h&sizemask来计算元素应该存储到数组中的哪个索引位置

由于规定哈希表大小size只能是2的n次方,二进制数字的末尾都是0,所以哈希表大小的掩码sizemask的末尾一定是连续的1,并且其他位置都是0,因为sizemask=size-1,因此为1的数长度正好组成了size的余数长度,此时再用得到的hash值hsizemask做与运算,而0和任何数相与都为0,1与任何数字相与都是它本身,最后相与的结果就剩下了hash值hsizemask长度相同的最后几位,这个数字刚好就是size的余数,因此得到的索引位置不会越界

当我们存储k1=v1,假设k1的哈希值h=1,则1&3 =1,因此k1=v1要存储到数组角标1位置

若是此时又添加了一个k2=v2,而k2的哈希值与掩码相与得到的值也是1,就出现了哈希冲突,此时会通过头插法形成一个链表:


字典

结构体如下:

c
typedef struct dict { dictType *type; // dict类型,内置不同的hash函数 void *privdata; //私有数据,在做特殊hash运算时用 dictht ht[2]; //一个Dict包含两个哈希表,其中一个是当前数据,另一个一般是空,rehash时使用 long rehashidx; //rehash的进度,-1表示未进行 int16_t pauserehash; // rehash是否暂停,1则暂停,0则继续 } dict;
  • dictType中包内置很多种hash函数,可以在不同场景下使用不同的hash函数

  • dictht中包含两个哈希表,其目的就是一个用来存数据,另一个用来充当rehash的中间存储变量


扩容机制

Dict中的HashTable就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低

Dict在每次新增键值对时都会检查负载因子(LoadFactor=used/size),满足以下两种情况时会触发哈希表扩容:

  • 哈希表的 LoadFactor >=1,并且服务器没有执行 BGSAVE 或者 BGREWRITEAOF 等后台进程
  • 哈希表的 LoadFactor>5

扩容源码:

c
static int dictExpandIfNeeded(dict *d){ //如果正在rehash,则返回ok if(dictIsRehashing(d)) return DICT OK; //如果哈希表为空,则初始化哈希表为默认大小:4 if(d->ht[0l.size == 0)return dictExpand(d, DICT HT INITIAL SIZE); //当负载因子(used/size)达到1以上,并且当前没有进行bgrewrite等子进程操作 //或者负载因子超过5,则进行 dictExpand ,也就是扩容 if(d->ht[0].used >= d->ht[0].size && (dict_can_resized->ht[0],used/d->ht[0].size > dict force_resize ratio){ //扩容大小为used + 1,底层会对扩容大小做判断,实际上找的是第一个大于等于 used+1 的 2^n return dictExpand(d,d->ht[0l.used +1); } return DICT OK; }

Dict除了扩容以外,每次删除元素时,也会对负载因子做检查,当LoadFactor<0.1 时,会做哈希表收缩(当size>4并且负载因子<0.1 )


rehash

不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key重新计算索引,插入新的哈希表,这个过程称为rehash ,过程是这样的:

  1. 计算新hash表的realeSize,值取决于当前要做的是扩容还是收缩:
    • 如果是扩容,则新size为第一个大于等于dict.ht[0].used+1的2的n次方
    • 如果是收缩,则新size为第一个大于等于dict.ht[0].used的2”(不得小于4)
  2. 按照新的realeSize申请内存空间,创建dictht,并赋值给dict.ht[1]
  3. 设置dict.rehashidx=0,标示开始rehash
  4. 每次执行新增、查询、修改、删除操作时,都检查一下dict.rehashidx是否大于-1,如果是则将dict.ht0l.tablelrehashidx]的entry链表rehash到dict.ht[1],并且将rehashidx++。直至dict.ht[0]的所有数据都rehash到dict.ht[1]
  5. 将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存

Dict的rehash并不是一次性完成的。试想一下,如果Dict中包含数百万的entry,要在一次rehash完成,极有可能导致主线程阻塞。因此Dict的rehash是分多次、渐进式的完成,因此称为渐进式rehash,其精髓就是第四步

Dict的特点是空间不连续,并且内部含有非常多的指针,指针也非常占用空间,因此这也是他的缺点


4.压缩列表ZipList

ZipList 是一种特殊的“双端链表”,但是没有使用指针,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作,并目该操作的时间复杂度为 0(1)

其结构如下:

属性类型长度用途
zlbytesuint32_t4字节记录整个压缩列表占用的内存字节数
zltailuint32_t4字节记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过这个偏移量,可以确定表尾节点的地址。
zllenuint16_t2字节记录了压缩列表包含的节点数量。最大值为UINT16MAX(65534),如果超过这个值,此处会记录为65535,但节点的真实数量需要遍历整个压缩列表才能计算得出
entry列表节点不定压缩列表包含的各个节点,节点的长度由节点保存的内容决定
zlenduint8_t1字节特殊值 0xFF(十进制 255),用于标记压缩列表的末端

这种不确定节点长度的数据结构,他的优点就是节省空间,不会出现空间浪费的情况,那么它没有指针,并且节点大小不确定,它又是如何完成遍历操作的呢?


ZipListEntry

ZipList 中的Entry并不像普通链表那样记录前后节点的指针,因为记录两个指针要占用16个字节,浪费内存。而是采用了下面的结构

previous entry lengthencodingcontents
  • previous entry length:前一节点的长度,占1个或5个字节。
    • 如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
    • 如果前一节点的长度大于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度
  • encoding:编码属性,记录content的数据类型(字符串还是整数)以及长度,占用1个、2个或5个字节
  • contents:负责保存节点的数据,可以是字符串或整数

也就是说previous entry length所占的长度,encoding所占的长度,并且encoding中保存的节点数据的长度,其总和就是该节点的总长度,如果知道了起始位置,那么加上该节点长度,就是下一个节点的所在位置的地址,从而就能正序实现遍历操作,反之如果知道末尾节点的位置地址,那么只需要减去previous entry length中存储的前一节点的长度,就能够得到前一个节点的位置地址,也能实现逆向遍历

ZipList中所有存储长度的数值均采用小端字节序,即低位字节在前,高位字节在后。例如:数值0x1234,采用小端字节序后实际存储值为:0x3412


encoding编码

ZipListEntry中的encoding编码分为字符串和整数两种:

  • 字符串类型:如果encoding是以“00”“01”或者“10”开头,则证明content是字符串

    编码编码长度字符串大小
    |00pppppp|1 bytes<= 63 bytes
    |01pppppp|qqqqqqqq|2 bytes<= 16383 bytes
    |10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt|5 bytes<= 4294967295 bytes

    例如保存字符串“ab”,ab只占两个字节,因此范围在63之内,1字节长度的encoding就行,因此他的encoding=00000010

  • 整数类型:如果encoding是以“11”开始,则证明content是整数,且encoding固定只占用1个字节

    编码编码长度整数类型
    110000001int16_t(2 bytes)
    110100001int32_t(4 bytes)
    111000001int64_t(8 bytes)
    11110000124位有符整数(3 bytes)
    1111111018位有符整数(1 bytes)
    1111xxxx1直接在xxxx位置保存数值,范围从0001~1101,减1后结果为实际值

ZipList所做的一切都是为了节省内存,所以在Redis内部使用的比较多,但是需要一次性申请比较大的连续空间,若需求内存较大则比较消耗时间


5.双端链表QuickList

zipList虽然节省内存,但申请内存必须是连续空间,如果内存占用较多,申请内存效率很低,为了缓解这个问题,我们必须限制ZipList的长度和entry大小,但是我们要存储大量数据,超出了ZipList最佳的上限该怎么办?我们可以创建多个ZipList来分片存储数据,数据拆分后比较分散,不方便管理和查找,这多个ZipList如何建立联系? Redis在3.2版本引入了新的数据结构QuickList,它是一个双端链表,只不过链表中的每个节点都是一个zipList

为了避免QuickList中的每个ZipList中entry过多,Redis提供了一个配置项:list-max-ziplist-size来限制

  • 如果值为正,则代表ZipList的允许的entry个数的最大值

  • 如果值为负,则代表ZipList的最大内存大小,分5种情况 ① -1:每个ZipList的内存占用不能超过4kb ② -2:每个ZipList的内存占用不能超过8kb ③ -3:每个ZipList的内存占用不能超过16kb ④ -4:每个ZipList的内存占用不能超过32kb ⑤ -5:每个ZipList的内存占用不能超过64kb

其默认值为-2

除了控制ZipList的大小,QuickList还可以对节点的ZipList做压缩。通过配置项list-compress-depth来控制。因为链表一般都是从首尾访问较多,所以首尾是不压缩的。这个参数是控制首尾不压缩的节点个数:

  • 0:特殊值,代表不压缩

  • 1:标示QuickList的首尾各有1个节点不压缩,中间节点压缩

  • 2:标示QuickList的首尾各有2个节点不压缩,中间节点压缩

  • 以此类推

默认值为0

这种数据结构即保留了节省空间的特性,也解决了内存申请困难的问题,但是这种数据结构有一个缺点,就是无法从中间查数据,只能顺序遍历


6.跳表SkipList

SkipList(跳表)首先是链表,但与传统链表相比有几点差异:

  • 元素按照升序排列存储
  • 节点可能包含多个指针,指针跨度不同:

查询性能与红黑树差不多

实现原理

其跳表和跳表节点结构体如下

c
// t zset.c typedef struct zskiplist { // 跳表 // 头尾节点指针 struct zskiplistNode *header,*tail; // 节点数量 unsigned long length; //最大的索引层级,默认是1 int level; } zskiplist;
c
// t zset.c typedef struct zskiplistNode { sds ele;//节点存储的值 double score;// 节点分数,排序、查找用 struct zskiplistNode *backward;//前一个节点指针 struct zskiplistLevel { struct zskiplistNode *forward;//下一个节点指针 unsigned long span;//索引跨度 } level[];//多级索引数组 } zskiplistNode;

zskiplistNode中有一个多级索引数组,其中的下一个节点指针并不一定是紧挨着的一个节点,而是与索引跨度有关

跳表特点:

  • 跳跃表是一个双向链表,每个节点都包含score和ele值

  • 节点按照score值排序,score值一样则按照ele字典排序

  • 每个节点都可以包含多层指针,层数是1到32之间的随机数

  • 不同层指针到下一个节点的跨度不同,层级越高,跨度越大

  • 增删改查效率与红黑树基本一致,实现却更简单


7.RedisObject

Redis中的任意数据类型的键和值都会被封装为一个RedisObject,也叫做Redis对象,源码如下:

c
typedef struct redisobject { unsigned type:4; unsigned encoding:4; unsigned lru:LRU_BITS;// LRU_BITS为24 int refcount; void *ptr; } robj;
  • type:对象类型,分别是string、hash、list、set和zset,占4个bit位
    • #define OBJ STRING O
    • #define OBJ LIST 1
    • #define OBJ SET 2
    • #define 0B] ZSET 3#
    • define OBJ HASH 4
  • encoding:底层编码方式,共有11种,占4个bit位
  • lru
    :lru表示该对象最后一次被访问的时间,其占用24个bit位。便于判断空闲时间太久的key
  • refcount:对象引用计数器,计数器为0则说明对象无人引用,可以被回收
  • ptr:指针,指向存放实际数据的空间

如果不包含ptr指向的数据大小,光RedisObject对象头大小就有16个字节

如果有10个字符串存储为string类型,那么就要消耗10个16字节的对象头,如果将10个字符串存为列表,则只需要生成一个对象头

编码方式

Redis中会根据存储的数据类型不同,选择不同的编码方式,共包含11种不同类型:

编号编码方式说明
0OBJ_ENCODING_RAWraw类型动态字符串
1OBJ_ENCODING_INTlong类型的整数的字符串
2OBJ_ENCODING_HThash表(字典dict)
3OBJ_ENCODING_ZIPMAP已废弃
4OBJ_ENCODING_LINKEDLIST双端链表
5OBJ_ENCODING_ZIPLIST压缩列表
6OBJ_ENCODING_INTSET整数集合
7OBJ_ENCODING_SKIPLIST跳表
8OBJ_ENCODING_EMBSTRembstr的动态字符串
9OBJ_ENCODING_QUICKLIST快速列表
10OBJ_ENCODING_STREAMStream流

Redis中会根据存储的数据类型不同,选择不同的编码方式。每种数据类型的使用的编码方式如下:

数据类型编码方式
OBJ_STRINGint、embstr、raw
OBJ_LISTLinkedList和ZipList(3.2以前)、QuickList(3.2以后)
OBJ_SETintset、HT
OBJ_ZSETZipList、HT、SkipList
OBJ_HASHZipList、HT

这里只有五种基本数据类型,而其他数据类型也是基于这五种实现的:

  • Bitmaps、HyperLogLog:基于OBJ_STRING这种编码格式
  • Geospatial:底层基于ZSET

8.五种数据类型

String

String是Redis中最常见的数据存储类型:

  • 其基本编码方式是RAW,基于简单动态字符串(SDS)实现,存储上限为512mb

  • 如果存储的SDS长度小于44字节,则会采用EMBSTR编码,此时obiect head与SDS是一段连续空间。申请内存时只需要调用一次内存分配函数,效率更高

    其原因是因为SDS的对象头占4个字节,RedisObject对象头占16个字节,如果SDS长度小于44字节,总长度就会小于64字节,恰好是Redis底层一个内存分片大小

  • 如果存储的字符串是整数值,并且大小在LONG MAX范围内,则会采用INT编码:直接将数据保存在RedisObject的ptr指针位置(刚好8字节),不再需要SDS了


List

Redis的List结构类似一个双端链表,可以从首、尾操作列表中的元素

  • 在3.2版本之前,Redis采用ZipListLinkedList来实现List,当元素数量小于512并且元素大小小于64字节时采用ZipList编码,超过则采用LinkedList编码

  • 在3.2版本之后,Redis统一采用OuickList来实现List:


Set

Set是Redis中的单列集合,满足下列特点:

  • 不保证有序性
  • 保证元素唯一(可以判断元素是否存在)
  • 可以求交集、并集、差集

Set的每一条命令都要先判断元素是否存在,所以他对查询的效率特别高

Set是Redis中的集合,不一定确保元素有序,可以满足元素唯一、查询效率要求极高

  • 为了查询效率和唯一性,set采用HT编码(Dict)。Dict中的key用来存储元素,value统一为null
  • 当存储的所有数据都是整数,并且元素数量不超过set-max-intset-entries时,Set会采用Intset编码,以节省内存

此时你一定想问:Set是无序的,可是IntSet明明是有序的?

Set 的「无序性」指的是 元素没有位置索引,而非底层物理存储的随机性,IntSet 内部使用有序数组存储整数(升序排列),但这只是 实现细节,Redis 在 对外接口层面 会主动破坏顺序性,返回元素的顺序是 不可预测 的(Redis 6.2 前是「伪随机」,6.2 后改为按插入顺序返回,但依然不承诺有序性)


ZSet

ZSet也就是SortedSet,其中每一个元素都需要指定一个score值和member值

  • 可以根据score值排序后
  • member必须唯一
  • 可以根据member查询分数

因此,zset底层数据结构必须满足键值存储、键必须唯一、可排序这几个需求

  • SkipList:可以排序,并且可以同时存储score和ele值(member),但是无法对键的唯一性校验

  • HT(Dict):可以键值存储,并且可以根据key找value,但是无法满足可排序要求

那么到底应该用哪种数据结构呢?

小孩子才做选择,我全都要

ZSet底层的结构体将两种数据结构都各自存储了一份数据,使用不同的功能到不同的结构中去操作:

c
// zset结构 typedef struct zset { // Dict指针 dict *dict; // SkipList指针 zskiplist *zsl; } zset;

这种方式非常浪费空间,所以Redis也进行了一些优化:

当元素数量不多时,HT和SkipList的优势不明显,而且更耗内存。因此zet还会采用ZipList结构来节省内存,不过需要同时满足两个条件:

  1. 元素数量小于zset max ziplist entries,默认值128
  2. 每个元素都小于zset max ziplist value字节,默认值64

ziplist本身没有排序功能,而且没有键值对的概念,因此需要有zset通过编码实现:

  • ZipList是连续内存,因此score和element是紧挨在一起的两个entry,element在前,score在后
  • score越小越接近队首,score越大越接近队尾,按照score值升序排列


Hash

Hash结构与Redis中的Zset非常类似,都是键值存储、都需求根据键获取值、键必须唯一,但是Hash并不需要排序操作

因此,Hash底层采用的编码与Zset也基本一致,只需要把排序有关的SkipList去掉即可

  • Hash结构默认采用ZipList编码,用以节省内存。ZipList中相邻的两个entry分别保存field和value
  • 当数据量较大时,Hash结构会转为HT编码,也就是Dict,触发条件有两个:
    • ZipList中的元素数量超过了hash-max-ziplist-entries(默认512)
    • ZipList中的任意entry大小超过了hash-max-ziplist-value(默认64字节)

本文作者:Gaomengsuanjia

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!