1. Redis的数据结构
Redis之所以高效,不仅在于它的内存操作,更在于巧妙的数据结构设计。本文带你揭开Redis底层数据结构的神秘面纱,包括动态字符串SDS如何解决C语言字符串问题、IntSet如何高效处理整数集合、Dict哈希字典如何快速实现键值对存储与扩容、ZipList如何节省空间但又引发"连锁更新"的问题、QuickList如何兼具空间高效与操作性能、SkipList跳表如何做到类似红黑树的查询效率,以及RedisObject如何统一不同类型的数据存储。在阅读完本文后,你将对Redis数据结构的设计思想与源码实现原理有更深入的理解,从而更有效地使用和优化Redis。
1.1 动态字符串 SDS
我们都知道Redis中保存的Key是字符串,value往往是字符串或者字符串的集合。可见字符串是Redis中最常用的一种数据结构。
不过Redis没有直接使用C语言中的字符串,因为C语言字符串存在很多问题:
- 获取字符串长度的需要通过运算
- 非二进制安全(如果字符中出现 '\0',会导致字符串提前结束)
- 不可修改
- Redis构建了一种新的字符串结构,称为简单动态字符串(Simple Dynamic String),简称SDS。
例如,我们执行命令set name Jack
,那么Redis 将在底层创建两个 SDS,其中一个是包含"name"的SDS,另一个是包含"Jack"的SDS。Redis是C语言实现的,其中SDS是一个结构体,源码如下:

例如,一个包含字符串"name"的 SDS 结构如下:

SDS 之所以叫做动态字符串,是因为它具备动态扩容的能力,例如一个内容为"hi"的 SDS:

假如我们要给SDS追加一段字符串",Amy",这里首先会申请新内存空间:
- 如果新字符串小于 1M,则新空间为扩展后字符串长度的两倍+1;加 1 是因为申请的时候需要预留 '\0' 位
- 如果新字符串大于 1M,则新空间为扩展后字符串长度+1M+1。称为**内存预分配**。申请内存这件事本省是耗时的,所以 Redis 采用的是预分配,减少内存申请的次数

源码如下:
c
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
SDS 的优点:
- 获取字符串的长度的时间复杂度是 O(1)
- 遍历字符串的时候是根据 len 来运算的,所以是二进制安全的
- 支持动态扩容
- 减少内存的分配次数
1.2 IntSet
IntSet 是 Redis 中 Set 集合的一种实现方式,基于整数数组来实现,并且具备长度可变、有序等特征

其中的 encoding 包含三种模式,表示存储的整数大小不同:

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

现在,数组中每个数字都在int16_t
的范围内,因此采用的编码方式是INTSET_ENC_INT16
,每部分占用的字节大小为:
- encoding:4 字节
- length:4 字节
- contents:2 字节 * 3 = 6 字节

我们向该其中添加一个数字:50000,这个数字超出了 int16_t 的范围,IntSet 会自动升级编码方式到合适的大小
以当前案例来说流程如下:
- 升级编码为 INTSET_ENC_INT32, 每个整数占 4 字节,并按照新的编码方式及元素个数扩容数组
- **倒序依次将数组中的元素拷贝**到扩容后的正确位置
- 将待添加的元素放入数组末尾
- 最后,将 IntSet 的 encoding 属性改为 INTSET_ENC_INT32,将 length 属性改为 4

源码如下:
c
intset *intsetAdd(intset *is, int64_t value, uint8_t *success) {
uint8_t valenc = _intsetValueEncoding(value); // 获取当前值编码(也就是获取value需要的字节数)
uint32_t pos; // 要插入的位置
if (success) *success = 1;
// 判断编码是否超出了当前 intset 的编码
if (valenc > intrev32ifbe(is->encoding)) { // 超出编码,需要升级
return intsetUpgradeAndAdd(is, value);
} else {
// 在当前 intset 中查找值与 value 一样的元素的角标 pos
if (intsetSearch(is, value, &pos)) {
if (success) *success = 0; // 如果找到了,则无需插入,直接结束并返回失败
return is;
}
// 数组扩容
is = intsetResize(is, intrev32ifbe(is->length) + 1);
// 移动数组中 pos 之后的元素到 pos + 1,给新元素腾出空间
if (pos < intrev32ifbe(is->length))
intsetMoveTail(is, pos, pos + 1);
// 插入新元素
_intsetSet(is, pos, value);
// 重新置元素长度
is->length = intrev32ifbe(intrev32ifbe(is->length) + 1);
return is;
}
}
c
static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
// 获取当前 intset 编码
uint8_t curenc = intrev32ifbe(is->encoding);
// 获取新编码
uint8_t newenc = _intsetValueEncoding(value);
// 获取元素个数
int length = intrev32ifbe(is->length);
// 判断新元素是大于0还是小于0,小于0插入队首,大于0插入队尾
int prepend = value < 0 ? 1 : 0;
// 重新编码为新编码
is->encoding = intrev32ifbe(newenc);
// 重新置数组大小
is = intsetResize(is, intrev32ifbe(is->length) + 1);
// 倒序遍历,逐个搬运元素到新位置,intsetGetEncoded 按照旧编码方式查找旧元素
while (length--)
_intsetSet(is, length + prepend, intsetGetEncoded(is, length, curenc));
/* 插入新元素,prepend 决定是队首还是队尾 */
if (prepend)
_intsetSet(is, 0, value);
else
_intsetSet(is, intrev32ifbe(is->length), value);
// 修改数组长度
is->length = intrev32ifbe(intrev32ifbe(is->length) + 1);
return is;
}
IntSet 可以看做是特殊的整数数组,具备一些特点:
- Redis 会确保 IntSet 中的元素唯一、有序
- 具备类型升级机制,可以节省内存空间
- 底层采用**二分查找方式来查询**
1.3 Dict
我们知道 Redis 是一个键值型(Key-Value Pair)的数据库,我们可以根据键实现快速的增删改查。而键与值的映射关系正是通过 Dict 来实现的
Dict 由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)

当我们向 Dict 添加键值对时,Redis 首先根据 Key 计算出** hash 值(h)**,然后利用 **h & sizemask **来计算元素应该存储到数组中的哪个索引位置
为什么这里采用是 h & sizemark,而不是 h % size 呢?
其实 h & sizemark 和 h % size 的效果是一样的,首先必须要保证 h 是 2 的 n 次幂。因为 sizemark = size - 1,在二进制的视角看 sizemark 就是除了最高位是 0 其余都是 1 的一串 bit,当进行 **h & sizemask **计算时,其实就是提取 h 中除了最高位其他位时 1 的 bit。例如 size 的 bit 是 100 (4),sizemark 的 bit 就是 011 ,那么当一个 hash 值的 bit 是 010(2)时,我们只需要得知它的后两位有几个 1 就行了,与运算之后得到 010 也就是 2,但 hash 中的 bit 时 100(4) 时,与运算得到的就是 000 也就是 0
DictEntry 的 key 指针指向的什么?
实际总是指向 SDS 结构(无论你加的是数字还是字符串),不会再封装为 RedisObject
DictEntry 发生冲突的时候,采用的链表结构是 ZipList 吗?
DictEntry 的 next 指针类型是 dictEntry,所以链表结构是一个普通类型的单向链表

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[0].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_resize || d->ht[0].used/d->ht[0].size > dict_force_resize_ratio)) {
// 扩容大小为 used + 1,底层会对扩容大小做判断,实际找的是第一个大于等于 used+1 的 2^n
return dictExpand(d, d->ht[0].used + 1);
}
return DICT_OK;
}
c
int htNeedsResize(dict *dict) {
long long size, used;
// 哈希表大小
size = dictSlots(dict);
// entry 数量
used = dictSize(dict);
// size > 4(哈希表初识大小)并且负载因子低于0.1
return (size > DICT_HT_INITIAL_SIZE && (used*100/size < HASHTABLE_MIN_FILL));
}
c
// t_hash.c # hashTypeDeleted()
if (dictDelete((dict*)o->ptr, field) == C_OK) {
deleted = 1;
// 删除成功后,检查是否需要重置 Dict 大小,如果需要则调用 dictResize 重置
/* Always check if the dictionary needs a resize after a delete. */
if (htNeedsResize(o->ptr)) dictResize(o->ptr);
}
c
int dictResize(dict *d) {
unsigned long minimal;
// 如果正在做 bgsave 或 bgwriteof 或 rehash,则返回错误
if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;
// 获取 used,也就是 entry 个数
minimal = d->ht[0].used;
// 如果 used 小于4,则重置为4
if (minimal < DICT_HT_INITIAL_SIZE)
minimal = DICT_HT_INITIAL_SIZE;
// 重置大小为 minimal,实际上是第一个大于等于 minimal 的 2^n
return dictExpand(d, minimal);
}
Dict 的 rehash:
不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的 size 和 sizemask 变化,而 key 的查询与 sizemask 有关。因此必须对哈希表中的每一个 key 重新计算索引,插入新的哈希表,这个过程称为 rehash。过程是这样的:
- 计算新 hash 表的 realeSize ,值取决于当前要做的是扩容还是收缩:
- 按照新的 realeSize 申请内存空间,创建 dictht,并赋值给 dict.ht[1]
- 设置 dict.rehashidx = 0 ,标示开始rehash
将 dict.ht[0] 中的每一个 dictEntry 都 rehash 到 dict.ht[1],将 dict.ht[1] 赋值给 dict.ht[0],给 dict.ht[1] 初始化为空哈希表,释放原来的 dict.ht[0] 的内存- 每次执行新增、查询、修改、删除操作时,都检查一下 dict.rehashidx 是否大于 -1,如果是则将dict.ht[0].table[rehashidx] 的 entry 链表 rehash 到 dict.ht[1],并且将 rehashidx++ 。直至dict.ht[0] 的所有数据都 rehash 到 dict.ht[1](判断依据是 rehashidx >= ht[0].size )
- 将 rehashidx 赋值为 -1,代表 rehash 结束
- 在 rehash 过程中,新增操作,则直接写入 ht[1],查询、修改和删除则会在 dict.ht[0] 和 dict.ht[1] 依次查找并执行。这样可以确保 ht[0] 的数据只减不增,随着 rehash 最终为空
整个过程可以描述成:

Dict 的结构:
- 类似 Java 的 HashTable,底层是数组加链表来解决哈希冲突
- Dict 包含两个哈希表,ht[0] 平常用,ht[1] 用来 rehash
Dict 的伸缩:
- 当 LoadFactor 大于 5 或者 LoadFactor 大于 1 并且没有子进程任务时,Dict 扩容
- 扩容大小为第一个大于等于 used + 1 的 2^n
- 当 LoadFactor 小于 0.1 时,Dict 收缩,收缩的最小容量是 4
- 收缩大小为第一个大于等于 used 的 2^n
- Dict 采用**渐进式 rehash**,每次访问 Dict 时执行一次 rehash
- rehash 时 ht[0] 只减不增,新增操作只在 ht[1] 执行,其它操作在两个哈希表
1.4 ZipList
DIct 因为大量使用指针**(一个指针占 8 字节)**,会浪费内存空间,并且也会造成内存碎片。进而有了 ZipList,ZipSet 是一种特殊的"双端链表" ,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作, 并且该操作的时间复杂度为 O(1)

属性 | 类型 | 长度 | 用途 |
---|---|---|---|
zlbytes | uint32_t | 4 字节 | 记录整个压缩列表占用的内存字节数 |
zltail | uint32_t | 4 字节 | 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过这个偏移量,可以确定表尾节点的地址。 |
zllen | uint16_t | 2 字节 | 记录了压缩列表包含的节点数量。 最大值为UINT16_MAX (65534),如果超过这个值,此处会记录为65535,但节点的真实数量需要遍历整个压缩列表才能计算得出。 |
entry | 列表节点 | 不定 | 压缩列表包含的各个节点,节点的长度由节点保存的内容决定。 |
zlend | uint8_t | 1 字节 | 特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。 |
ZipListEntry:ZipList 中的 Entry 并不像普通链表那样记录前后节点的指针,因为记录两个指针要占用 16 个字节,浪费内存。而是采用了下面的结构:
- previous_entry_length :前一节点的长度,占 1 个或 5 个字节
- 如果前一节点的长度小于 254 字节,则采用 1 个字节来保存这个长度值****
- 如果前一节点的长度大于 254 字节,则采用 5 字节来保存这个长度值,第一个字节为 0xFE(254)表示扩展标志 ,后四个字节才是真实长度数据
- encoding:编码属性,记录 content 的数据类型(字符串还是整数)以及长度,占用 1 个、2 个或 5个字节
- contents :负责保存节点的数据,可以是字符串或整数
为什么 zlend 中 0xFF 不会和
previous_entry_length
中的 0xFF 混稀?
- 因为在 entry 结构体的
previous_entry_length
字段里,只有在"用 5 字节编码"时才可能出现0xFF
,但它只是"长度数据"的一部分,不会被解析为结束符 ,也就是说 0xFF 出现在previous_entry_length
时不会单独解析 0xFF,而是会用后面 4 字节解码出完整长度。- 但是当解析下一个 entry 时,**一开始就遇到了 0xFF,**那就说明 ZipList 已经到结尾了
为什么 previous_entry_length 一个字节时只能表示 254 个字节,而不是 255 个字节?
- 是因为 **0xFE(254)**被规定为"扩展标志",一旦前面 entry 超过 253 字节,这 1 字节就写
0xFE
,然后后面追加 4 字节来存真正的长度,也就是下面的情况二使用 5 个字节表示长度
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"和 "bc"

最终采用 16 进制的小端字节序进行存放:

ZipListEntry 中的 encoding 编码分为字符串和整数两种:
- 整数:如果 encoding 是以**"11"开始,则证明 content 是 整数**,且 encoding 固定只占用 1 个字节
编码 | 编码长度 | 整数类型 |
---|---|---|
11000000 | 1 | int16_t(2 bytes) |
11010000 | 1 | int32_t(4 bytes) |
11100000 | 1 | int64_t(8 bytes) |
11110000 | 1 | 24 位有符整数(3 bytes) |
11111110 | 1 | 8 位有符整数(1 bytes) |
1111xxxx | 1 | 直接在 xxxx 位置保存数值,****范围从 0001~1101,减 1 后结果为实际值 |
例如,一个 ZipList 中包含两个数字:"2" 和 "5"


ZipList的连锁更新问题:
ZipList 的每个 Entry 都包含 previous_entry_length 来记录上一个节点的大小,长度是 1 个或 5 个字节:
如果前一节点的长度小于 254 字节,则采用 1 个字节来保存这个长度值
如果前一节点的长度大于等于 254 字节,则采用5个字节来保存这个长度值,第一个字节为 0xfe,后四个字节才是真实长度数据
现在,假设我们有N个连续的、长度为** 250~253 **字节之间的 entry,因此 entry 的 previous_entry_length 属性用 1 个字节即可表示,如图所示:

如果此时在 ZipList 的头部插入一个 Entry,且 Entry 的字节大小为 254 字节,那么整个 ZipLIst 都需要更新 previous_entry_length 属性,也就是说整个 ZipList 都需要做重新申请内存,然后做数据迁移

ZipList 这种特殊情况下产生的连续多次空间扩展操作称之为**连锁更新(Cascade Update)**。新增、删除都可能导致连锁更新的发生
ZipList特性:
- 压缩列表的可以看做一种连续内存空间的"双向链表"
- 列表的节点之间不是通过指针连接,而是记录上一节点和本节点长度来寻址,内存占用较低
- 如果列表数据过多,导致链表过长,可能影响查询性能
- 增或删较大数据时有可能发生连续更新问题
1.6 QuickList
问题1:ZipList 虽然节省内存,但申请内存必须是连续空间,如果内存占用较多,申请内存效率很低。怎么办?
为了缓解这个问题,我们必须限制 ZipList 的长度和 entry 大小
问题2:但是我们要存储大量数据,超出了 ZipList 最佳的上限该怎么办?
我们可以创建多个 ZipList 来分片存储数据**(分片思想)**
问题3:数据拆分后比较分散,不方便管理和查找,这多个 ZipList 如何建立联系?
Redis 在 3.2 版本引入了新的数据结构** QuickList**,它是一个**双端链表**,只不过链表中的每个节点都是一个 ZipList

为了避免 QuickList 中的每个 ZipList 中 entry 过多,Redis 提供了一个配置项:list-max-ziplist-size
list-max-ziplist-size
为正,则代表 ZipList 的允许的 entry 个数的最大值list-max-ziplist-size
为负,则代表 ZipList 的最大内存大小,分 5 种情况:- -1:每个 ZipList 的内存占用不能超过 4kb
- -2:每个 ZipList 的内存占用不能超过 8kb**(list-max-ziplist-size 默认值 为 -2)**
- -3:每个 ZipList 的内存占用不能超过 16kb
- -4:每个 ZipList 的内存占用不能超过 32kb
- -5:每个 ZipList 的内存占用不能超过 64kb
QuickList 的和 QuickListNode 的结构源码:

compress 参数有三种选择方式:0 代表不压缩;1 代表压缩首尾两个节点;2 代表压缩首尾四个节点;
流程图展示结构,除了首位节点,其他的 QucikListNode 节点都被压缩了:

QuickList 的特点:
- 是一个节点为 ZipList 的双端链表
- 节点采用 ZipList,解决了传统链表的内存占用问题
- 控制了 ZipList 大小,解决连续内存空间申请效率问题
- 中间节点可以压缩,进一步节省了内存
总而言之,QucikList 兼具了 ZipList 的优点,又解决了连续空间申请的效率问题
1.7 SkipList
ZipList 和 QuickList 查询首尾确实非常快,还节约内存,但是当查询的是中间节点的某一个元素时,查询性能就不是很好了
SkipList(跳表)也是一个链表结构,但与传统链表相比有几点差异:
- 元素按照升序排列存储**(根据 Score 值进行排序,节点存放的元素值为 ele,其实这就是 Zset 的底层结构)**
- 每一个节点可能包含多个指针,指针跨度不同

SkipList 和 SkipListNode 的结构源码:

SkipList 的特点:
- 跳跃表是一个双向链表,每个节点都包含 score 和 ele 值
- 节点按照 score 值排序,score 值一样则按照 ele 字典排序
- 每个节点都可以包含多层指针,层数是 1 到 32 之间的随机数(用一个算法来决定需要多少层)
- 一般来说高层的节点数是底层节点数的 1/2,最高层的节点最少为 2,所以有
,所以时间复杂度为 O(logN)
- 增删改查效率与红黑树基本一致,实现却更简单
- 一般来说高层的节点数是底层节点数的 1/2,最高层的节点最少为 2,所以有
- 不同层指针到下一个节点的跨度不同,层级越高,跨度越大
1.8 RedisObject
Redis中的任意数据类型的键和值都会被封装为一个RedisObject,也叫做 Redis 对象,源码如下:

RedisObject 可以看作时数据的对象头,一个 RedisObject 所占用的空间大小为** 16 字节**:
- type:4bit
- encoding:4bit
- LRU:24bit
- refcount:32bit
- ptr:8 字节
什么是 RedisObject?为什么 Redis 的数据需要被封装为 RedisObject?
从 Redis 的使用者的角度来看,⼀个 Redis 节点包含多个 database(非 cluster 模式下默认是16个,cluster 模式下只能是1个),而一个 database 维护了从 key space 到 object space 的映射关系。这个映射关系的 key 是string 类型,而 value 可以是多种数据类型,比如:string, list, hash、set、sorted set 等。我们可以看到,key 的类型固定是 string,而 value 可能的类型是多个\ 而从 Redis 内部实现的角度来看,database 内的这个映射关系是用⼀个 Dict 来维护的。Dict 的 Key 固定用⼀种数据结构来表达就够了,这就是动态字符串 SDS。而 value 则比较复杂,为了在同⼀个 Dict 内能够存储不同类型的 value,这就需要⼀个通⽤的数据结构,这个通用的数据结构就是 ROBJ,全名是 RedisObject
Redis中会根据存储的数据类型不同,选择不同的编码方式,共包含 11 种编码方式:
编号 | 编码方式 | 说明 |
---|---|---|
0 | OBJ_ENCODING_RAW | raw 编码动态字符串 |
1 | OBJ_ENCODING_INT | long 类型的整数的字符串 |
2 | OBJ_ENCODING_HT | hash 表(字典dict) |
3 | ||
4 | OBJ_ENCODING_LINKEDLIST | 双端链表 |
5 | OBJ_ENCODING_ZIPLIST | 压缩列表 |
6 | OBJ_ENCODING_INTSET | 整数集合 |
7 | OBJ_ENCODING_SKIPLIST | 跳表 |
8 | OBJ_ENCODING_EMBSTR | embstr 的动态字符串 |
9 | OBJ_ENCODING_QUICKLIST | 快速列表 |
10 | OBJ_ENCODING_STREAM | Stream流 |
Redis 中会根据存储的数据类型不同,选择不同的编码方式。**Redis 五种数据类型**的使用的编码方式如下:
数据类型 | 编码方式 |
---|---|
OBJ_STRING | int / embstr(SDS) / raw(SDS) |
OBJ_LIST | LinkedList + ZipList(3.2以前) / QuickList(3.2以后) |
OBJ_SET | IntSet / HT |
OBJ_ZSET | ZipList / HT+SkipList |
OBJ_HASH | ZipList / HT |
2. Redis 的数据类型
2.1 String
String 是 Redis 中最常见的数据存储类型,value 的编码方式有三种:
- 其**基本编码方式是 RAW**,基于简单动态字符串(SDS)实现,存储上限为 512MB
- 如果存储的字符串长度**小于 44 字节**,则会采用 embstr 编码,此时 RedisObject 的头信息与 SDS 是一段连续空间,申请内存时只需要调用一次内存分配函数,效率更高
- 如果**存储的是字符串是整数值且可转成 long long 范围**,例如 "255" 可以用整数 255 表示,则会采用 int 编码,此时 value 值由 ptr 指针进行存储,不再需要申请其他内存空间,因为 ptr 为 8 字节,所以 int 表示的范围是 -2^63 到 2^63 - 1
- 底层是通过
<font style="color:rgb(51, 51, 51);">string2ll</font>
函数进行判断的 - 如果存储的字符串是整数值,并且大小在 LONG_MAX 范围内,则会采用INT编码:直接将数据保存在RedisObject的 ptr 指针位置(刚好8字节),不再需要SDS了
- 底层是通过
c
int string2ll(const char *s, size_t slen, long long *value) {
const char *p = s;
size_t plen = 0;
int negative = 0;
unsigned long long v;
/* A zero length string is not a valid number. */
if (plen == slen)
return 0;
/* Special case: first and only digit is 0. */
if (slen == 1 && p[0] == '0') {
if (value != NULL) *value = 0;
return 1;
}
/* Handle negative numbers: just set a flag and continue like if it
* was a positive number. Later convert into negative. */
if (p[0] == '-') {
negative = 1;
p++; plen++;
/* Abort on only a negative sign. */
if (plen == slen)
return 0;
}
/* First digit should be 1-9, otherwise the string should just be 0. */
if (p[0] >= '1' && p[0] <= '9') {
v = p[0]-'0';
p++; plen++;
} else {
return 0;
}
/* Parse all the other digits, checking for overflow at every step. */
while (plen < slen && p[0] >= '0' && p[0] <= '9') {
if (v > (ULLONG_MAX / 10)) /* Overflow. */
return 0;
v *= 10;
if (v > (ULLONG_MAX - (p[0]-'0'))) /* Overflow. */
return 0;
v += p[0]-'0';
p++; plen++;
}
/* Return if not all bytes were used. */
if (plen < slen)
return 0;
/* Convert to negative if needed, and do the final overflow check when
* converting from unsigned long long to long long. */
if (negative) {
if (v > ((unsigned long long)(-(LLONG_MIN+1))+1)) /* Overflow. */
return 0;
if (value != NULL) *value = -v;
} else {
if (v > LLONG_MAX) /* Overflow. */
return 0;
if (value != NULL) *value = v;
}
return 1;
}
OBJ_ENCODING_INT
编码方式:

OBJ_ENCODING_EMBSTR
编码方式:

OBJ_ENCODING_RWA
编码方式:

string 的操作命令:
- 在对 string 进行
**<u><font style="color:#DF2A3F;">INCR</font></u>**
,**<u><font style="color:#DF2A3F;">DECR</font></u>**
等操作的时候
如果它内部是OBJ_ENCODING_INT
编码,那么可以直接行加减操作;如果它内部是OBJ_ENCODING_RAW
或OBJ_ENCODING_EMBSTR
编码,那么 Redis 会先试图把 SDS 存储的**字符串转成 long 型,如果能转成功,再进行加减操作**
那为什么执行
INCR
,DECR
时 Redis 还要尝试去转换?难道不是一开始就采用了OBJ_ENCODING_INT
的编码方式吗?
- 有些值虽然最初是数字,但被修改过(如后面
APPEND
setbit``getrange
变成非数字字符串)- 有些值一开始就不是数字(如 SET key abc)
- 还有些值通过其它命令写入(如 MSET、RESTORE、AOF 恢复、RDB 加载等)未必保证都是 INT 编码
所以 Redis 的编码方式时动态的,并不是死认"INT 编码"
- 对⼀个内部表示成 long 型的 string 执行
**<u><font style="color:#DF2A3F;">append</font></u>**
,**<u><font style="color:#DF2A3F;">setbit</font></u>**
,**<u><font style="color:#DF2A3F;">getrange</font></u>**
这些命令
如果它内部是OBJ_ENCODING_EMBSTR
或OBJ_ENCODING_ROW
编码,那么可以直接进行上述操作;针对的仍然是 string 的值 (即⼗进制表示的字符串),而不是针对内部表⽰的 long 型进⾏操作。比如字符串"32",如果按照字符数组来解释,它包含两个字符,它们的 ASCII 码分别是 0x33 和 0x32。当我们执行命令setbit key 7 0
的时候,相当于把字符 0x33 变成了 0x32,这样字符串的值就变成了 "22"。⽽如果将字符串 "32" 按照内部的 64 位 long 型来解释,那么它是 0x0000000000000020,在这个基础上执⾏setbit
位操作,结果就完全不对了。因此,在这些命令的实现中,会把 long 型先转成字符串再进行相应的操作
2.2 List
哪一个数据结构能满足LPUSH``RPUSH``LRANGE``LPOP``RPOP
等操作?
- LinkedList :普通链表,可以从双端访问 ,内存占用较高,内存碎片较多
- ZipList :压缩列表,可以从双端访问 ,内存占用低 ,存储上限低
- QuickList :LinkedList + ZipList ,可以从双端访问 ,内存占用较低 ,包含多个ZipList,存储上限高
Redis 的 List 结构类似一个双端链表,可以从首、尾操作列表中的元素。
在 3.2 版本之前,Redis 采用 ZipList 和 LinkedList 来实现 List,当元素数量小于 512 并且元素大小小于 64 字节时采用 ZipList 编码,超过则采用LinkedList 编码
在3.2版本之后,Redis 统一采用 QuickList 来实现 List
Redis 的 List 类型可以从首、尾操作列表中的元素,例如 LIst 的 Push 操作
java
// client:当前的客户端连接对象,包含命令参数等信息;where:决定插入头部还是尾部;int xx:是否只在 key 已存在时才推入元素
void pushGenericCommand(client *c, int where, int xx) {
int j;
// 命令参数第0个是命令名,第1个是 key,从第2个参数开始是要插入的元素
for (j = 2; j < c->argc; j++) {
if (sdslen(c->argv[j]->ptr) > LIST_MAX_ITEM_SIZE) {
addReplyError(c, "Element too large");
return;
}
}
// 获取 Key 对应的 List 对象
robj *lobj = lookupKeyWrite(c->db, c->argv[1]);
// 通过 RedisObject 中的 type 值,检查 lobj 是不是 OBJ_LIST
if (checkType(c,lobj,OBJ_LIST)) return;
// 检查是否为空
if (!lobj) {
if (xx) {
addReply(c, shared.czero);
return;
}
// 为空,创建一个新的 QucikList
lobj = createQuicklistObject();
// 设置 ZipList 的限制
quicklistSetOptions(lobj->ptr, server.list_max_ziplist_size,
server.list_compress_depth);
// 添加元素
dbAdd(c->db,c->argv[1],lobj);
}
for (j = 2; j < c->argc; j++) {
listTypePush(lobj,c->argv[j],where);
server.dirty++;
}
addReplyLongLong(c, listTypeLength(lobj));
char *event = (where == LIST_HEAD) ? "lpush" : "rpush";
signalModifiedKey(c,c->db,c->argv[1]);
notifyKeyspaceEvent(NOTIFY_LIST,event,c->argv[1],c->db->id);
}

java
robj *createQuicklistObject(void) {
quicklist *l = quicklistCreate();
robj *o = createObject(OBJ_LIST,l);
o->encoding = OBJ_ENCODING_QUICKLIST;
return o;
}

List 的内存结构图:

2.3 Set
Set 是 Redis 中的单列集合,满足下列特点:
- **不保证**有序性,说明在某种情况下,能出现有序的序列
- 保证元素唯一
- 求交集、并集、差集
常用命令为SADD
,SISMENMBER
,SINTER
,可以看出,Set 对查询元素的效率要求非常高,那什么样的数据结构可以满足?
- HashTable,也就是 Redis 中的 Dict ,不过 Dict 是双列集合(可以存键、值对),Java 中 HashSet 底层是通过 HashMap 实现的,只利用了 HashMap 的 Key,而 Value 为 null,所以我们也可以参考 Java 的实现思路。所以 Redis 的 Set 数据类型的实现方式之一就是通过 HT 编码(Dict)实现的,同时也保证了查询效率和唯一性。Dict 中的 Key 用来存储元素,Value 统一为 null。最终 Set集合不一定确保元素有序,但可以满足元素唯一、查询效率也高
- 当存储的所有数据都是整数,并且元素数量不超过
set-max-intset-entries
时,Set 会采用 IntSet 编码 ,以节省内存,当采用 IntSet 编码的时候,能够满足元素的有序性**,**但是 Set 集合对外还是要说明是不保证元素的有序性的****

结构如下,其中 DictEntry 中的 Key 指针指向的是 SDS 结构:

2.4 ZSET
ZSet 也就是 SortedSet,其中每一个元素都需要指定一个 score 值和 member 值:
- 可以根据 score 值**排序**
- member 必须**唯一**
- 可以根据 member** 查询分数**
ZSCORE
常用命令为ZADD
,ZRANK
,ZSCORE
,ZSet 底层数据结构必须满足**键值存储**、** 键必须唯一**、**可排序**这几个需求。之前学习的哪种编码结构可以满足?
- SkipList:可以排序,并且可以同时存储 score 和 ele 值(member)
- HT(Dict):可以键值存储,并且可以根据 key 找 value
ZSet 结合 SkipList 和 Dict 实现****:

内存结构图:

当元素数量不多时,HT 和 SkipList 的优势不明显,而且更耗内存。因此** ****ZSet 还会采用 ZipList 结构来节省内存**

不过需要同时满足两个条件:
- 元素数量小于
zset_max_ziplist_entries
,默认值 128 - 每个元素都小于
zset_max_ziplist_value
字节,默认值 64

ZipList 本身没有排序功能,而且没有键值对的概念,因此需要 ZSet 通过业务逻辑进行实现:
- ZipList 是连续内存,因此** score 和 element 是紧挨在一起的两个 entry: element 在前,score 在后**
- score 越小越接近队首,score 越大越接近队尾,按照 score 值升序排列
ZSet 的创建流程,以及 ZSet 添加元素的流程:

2.5 Hash
Hash 结构与 Redis 中的 ZSet 非常类似:
- 都是键值存储
- 都需求根据键获取值
- 键必须唯一
区别如下:
- ZSet 的键是 member,值是 score;Hash 的键和值都是任意值
- ZSet 要根据 score 排序;Hash 则无需排序
Hash 的常用命令HSET
,HGET
,HDEL
,HKEYS
,HVALS
ZipList 本来就设计为各个数据项挨在⼀起组成连续的内存空间,这种结构并不擅长做修改操作。⼀旦数据发⽣改动,就会引发内存 realloc,可能导致内存拷贝。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字节)
当满足上面两个条件其中之⼀的时候,Redis 就使⽤Dict 字典来实现 Hash。Redis 的 Hash 之所以这样设计,是因为当 ZipList 变得很⼤的时候,它有如下几个缺点:
- 每次插⼊或修改引发的 realloc 操作会有更⼤的概率造成内存拷贝,从而降低性能
- ⼀旦发生内存拷贝,内存拷贝的成本也相应增加,因为要拷贝更⼤的⼀块数据
- 当 ZipList 数据项过多的时候,在它上⾯查找指定的数据项就会性能变得很低,因为 ZipList 上的查找需要进行遍历
两种实现方式的内存结构体:

相关源码

