一文读懂Redis常见对象类型的底层数据结构

合集下载
  1. 1、下载文档前请自行甄别文档内容的完整性,平台不提供额外的编辑、内容补充、找答案等附加服务。
  2. 2、"仅部分预览"的文档,不可在线预览部分如存在完整性等问题,可反馈申请退款(可完整预览的文档不适用该条件!)。
  3. 3、如文档侵犯您的权益,请联系客服反馈,我们会尽快为您处理(人工客服工作时间:9:00-18:30)。

⼀⽂读懂Redis常见对象类型的底层数据结构
Redis是⼀个基于内存中的数据结构存储系统,可以⽤作数据库、缓存和消息中间件。

Redis⽀持五种常见对象类型:字符串(String)、哈希(Hash)、列表(List)、集合(Set)以及有序集合(Zset),我们在⽇常⼯作中也会经常使⽤它们。

知其然,更要知其所以然,本⽂将会带你读懂这五种常见对象类型的底层数据结构。

本⽂主要内容参考⾃《Redis设计与实现》
对象类型和编码
Redis使⽤对象来存储键和值的,在Redis中,每个对象都由redisObject结构表⽰。

redisObject结构主要包含三个属性:type、encoding和ptr。

typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 底层数据结构的指针
void *ptr;
} robj;
其中type属性记录了对象的类型,对于Redis来说,键对象总是字符串类型,值对象可以是任意⽀持的类型。

因此,当我们说Redis键采⽤哪种对象类型的时候,指的是对应的值采⽤哪种对象类型。

类型常量对象类型名称
REDIS_STRING字符串对象
REDIS_LIST列表对象
REDIS_HASH哈希对象
REDIS_SET集合对象
REDIS_ZSET有序集合对象
*ptr属性指向了对象的底层数据结构,⽽这些数据结构由encoding属性决定。

编码常量编码对应的底层数据结构
REDIS_ENCODING_INT long类型的整数
REDIS_ENCODING_EMBSTR emstr编码的简单动态字符串
REDIS_ENCODING_RAW简单动态字符串
REDIS_ENCODING_HT字典
REDIS_ENCODING_LINKEDLIST双端链表
REDIS_ENCODING_ZIPLIST压缩列表
REDIS_ENCODING_INTSET整数集合
REDIS_ENCODING_SKIPLIST跳跃表和字典
之所以由encoding属性来决定对象的底层数据结构,是为了实现同⼀对象类型,⽀持不同的底层实现。

这样就能在不同场景下,使⽤不同的底层数据结构,进⽽极⼤提升Redis的灵活性和效率。

底层数据结构后⾯会详细讲解,这⾥简单看⼀下即可。

字符串对象
字符串是我们⽇常⼯作中⽤得最多的对象类型,它对应的编码可以是int、raw和embstr。

字符串对象相关命令可参考:。

如果⼀个字符串对象保存的是不超过long类型的整数值,此时编码类型即为int,其底层数据结构直接就是long类型。

例如执⾏set number 10086,就会创建int编码的字符串对象作
为number键的值。

如果字符串对象保存的是⼀个长度⼤于39字节的字符串,此时编码类型即为raw,其底层数据结构是简单动态字符串(SDS);如果长度⼩于等于39个字节,编码类型则为embstr,底层数据结构就是embstr编码SDS。

下⾯,我们详细理解下什么是简单动态字符串。

简单动态字符串
SDS定义
在Redis中,使⽤sdshdr数据结构表⽰SDS:
struct sdshdr {
// 字符串长度
int len;
// buf数组中未使⽤的字节数
int free;
// 字节数组,⽤于保存字符串
char buf[];
};
SDS遵循了C字符串以空字符结尾的惯例,保存空字符的1字节不会计算在len属性⾥⾯。

例如,Redis这个字符串在SDS⾥⾯的数据可能是如下形式:
SDS与C字符串的区别
C语⾔使⽤长度为N+1的字符数组来表⽰长度为N的字符串,并且字符串的最后⼀个元素是空字符\0。

Redis采⽤SDS相对于C字符串有如下⼏个优势:
1. 常数复杂度获取字符串长度
2. 杜绝缓冲区溢出
3. 减少修改字符串时带来的内存重分配次数
4. ⼆进制安全
常数复杂度获取字符串长度
因为C字符串并不记录⾃⾝的长度信息,所以为了获取字符串的长度,必须遍历整个字符串,时间复杂度是O(N);⽽SDS使⽤len属性记录了字符串的长度,因此获取SDS字符串长度的时间复杂度是O(1)。

杜绝缓冲区溢出
C字符串不记录⾃⾝长度带来的另⼀个问题是很容易造成缓存区溢出。

⽐如使⽤字符串拼接函数(stract)的时候,很容易覆盖掉字符数组原有的数据。

与C字符串不同,SDS的空间分配策略完全杜绝了发⽣缓存区溢出的可能性。

当SDS进⾏字符串扩充时,⾸先会检查当前的字节数组的长度是否⾜够,如果不够的话,会先进⾏⾃动扩容,然后再进⾏字符串操作。

减少修改字符串时带来的内存重分配次数
因为C字符串的长度和底层数据是紧密关联的,所以每次增长或者缩短⼀个字符串,程序都要对这个数组进⾏⼀次内存重分配:
如果是增长字符串操作,需要先通过内存重分配来扩展底层数组空间⼤⼩,不这么做就导致缓存区溢出。

如果是缩短字符串操作,需要先通过内存重分配来来回收不再使⽤的空间,不这么做就导致内存泄漏。

因为内存重分配涉及复杂的算法,并且可能需要执⾏系统调⽤,所以通常是个⽐较耗时的操作。

对于Redis来说,字符串修改是⼀个⼗分频繁的操作,如果每次都像C字符串那样进⾏内存重分配,对性能影响太⼤了,显然是⽆法接受的。

SDS通过空闲空间解除了字符串长度和底层数据之间的关联。

在SDS中,数组中可以包含未使⽤的字节,这些字节数量由free属性记录。

通过空闲空间,SDS实现了空间预分配和惰性空间释放两种优化策略。

1. 空间预分配
空间预分配是⽤于优化SDS字符串增长操作的,简单来说就是当字节数组空间不⾜触发重分配的时候,总是会预留⼀部分空闲空间。

这样的话,就能减少连续执⾏字符串增长操作时的内存重分配次数。

有两种预分配的策略:
1. len⼩于1MB时:每次重分配时会多分配同样⼤⼩的空闲空间;
2. len⼤于等于1MB时:每次重分配时会多分配1MB⼤⼩的空闲空间。

2. 惰性空间释放
惰性空间释放是⽤于优化SDS字符串缩短操作的,简单来说就是当字符串缩短时,并不⽴即使⽤内存重分配来回收多出来的字节,⽽是⽤free属性记录,等待将来使⽤。

SDS也提供直接释放未使⽤空间的API,在需要的时候,也能真正的释放掉多余的空间。

⼆进制安全
C字符串中的字符必须符合某种编码,并且除了字符串末尾之外,其它位置不允许出现空字符,这些限制使得C字符串只能保存⽂本数据。

但是对于Redis来说,不仅仅需要保存⽂本,还要⽀持保存⼆进制数据。

为了实现这⼀⽬标,SDS的API全部做到了⼆进制安全(binary-safe)。

raw和embstr编码的SDS区别
我们在前⾯讲过,长度⼤于39字节的字符串,编码类型为raw,底层数据结构是简单动态字符串(SDS)。

这个很好理解,⽐如当我们执⾏set story "Long, long, long ago there lived a king ..."(长度⼤于39)之后,Redis就会创建⼀个raw编码的String对象。

数据结构如下:
长度⼩于等于39个字节的字符串,编码类型为embstr,底层数据结构则是embstr编码SDS。

embstr编码是专门⽤来保存短字符串的,它和raw编码最⼤的不同在于:raw编码会调⽤两次内存分配分别创建redisObject结构和sdshdr结构,⽽embstr编码则是只调⽤⼀次内存分配,在⼀块连续的空间上同时包含redisObject结构和sdshdr结构。

编码转换
int编码和embstr编码的字符串对象在条件满⾜的情况下会⾃动转换为raw编码的字符串对象。

对于int编码来说,当我们修改这个字符串为不再是整数值的时候,此时字符串对象的编码就会从int变为raw;对于embstr编码来说,只要我们修改了字符串的值,此时字符串对象的编码就会从embstr变为raw。

embstr编码的字符串对象可以认为是只读的,因为Redis为其编写任何修改程序。

当我们要修改embstr编码字符串时,都是先将转换为raw编码,然后再进⾏修改。

列表对象
列表对象的编码可以是linkedlist或者ziplist,对应的底层数据结构是链表和压缩列表。

列表对象相关命令可参考:。

默认情况下,当列表对象保存的所有字符串元素的长度都⼩于64字节,且元素个数⼩于512个时,列表对象采⽤的是ziplist编码,否则使⽤linkedlist编码。

可以通过配置⽂件修改该上限值。

链表
链表是⼀种⾮常常见的数据结构,提供了⾼效的节点重排能⼒以及顺序性的节点访问⽅式。

在Redis中,每个链表节点使⽤listNode结构表⽰:
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点值
void *value;
} listNode
多个listNode通过prev和next指针组成双端链表,如下图所⽰:
为了操作起来⽐较⽅便,Redis使⽤了list结构持有链表。

typedef struct list {
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;
// 链表包含的节点数量
unsigned long len;
// 节点复制函数
void *(*dup)(void *ptr);
// 节点释放函数
void (*free)(void *ptr);
// 节点对⽐函数
int (*match)(void *ptr, void *key);
} list;
list结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len,⽽dup、free和match成员则是实现多态链表所需类型的特定函数。

Redis链表实现的特征总结如下:
1. 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(n)。

2. ⽆环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。

3. 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。

4. 带链表长度计数器:程序使⽤list结构的len属性来对list持有的节点进⾏计数,程序获取链表中节点数量的复杂度为O(1)。

5. 多态:链表节点使⽤void*指针来保存节点值,可以保存各种不同类型的值。

压缩列表
压缩列表(ziplist)是列表键和哈希键的底层实现之⼀。

压缩列表主要⽬的是为了节约内存,是由⼀系列特殊编码的连续内存块组成的顺序型数据结构。

⼀个压缩列表可以包含任意多个节点,每个节点可以保存⼀个字节数组或者⼀个整数值。

如上图所⽰,压缩列表记录了各组成部分的类型、长度以及⽤途。

属性类型长度⽤途
zlbytes uint_32_t4字节记录整个压缩列表占⽤的内存字节数
zltail uint_32_t4字节记录压缩列表表尾节点距离起始地址有多少字节,通过这个偏移量,程序⽆需遍历整个压缩列表就能确定表尾节点地址
zlen uint_16_t2字节记录压缩列表包含的节点数量
entryX列表节点不定压缩列表的各个节点,节点长度由保存的内容决定
zlend uint_8_t1字节特殊值(0xFFF),⽤于标记压缩列表末端
哈希对象
哈希对象的编码可以是ziplist或者hashtable。

hash-ziplist
ziplist底层使⽤的是压缩列表实现,上⽂已经详细介绍了压缩列表的实现原理。

每当有新的键值对要加⼊哈希对象时,先把保存了键的节点推⼊压缩列表表尾,然后再将保存了值的节点推⼊压缩列表表尾。

⽐如,我们执⾏如下三条HSET命令:
HSET profile name "tom"
HSET profile age 25
HSET profile career "Programmer"
如果此时使⽤ziplist编码,那么该Hash对象在内存中的结构如下:
hash-hashtable
hashtable编码的哈希对象使⽤字典作为底层实现。

字典是⼀种⽤于保存键值对的数据结构,Redis的字典使⽤哈希表作为底层实现,⼀个哈希表⾥⾯可以有多个哈希表节点,每个哈希表节点保存的就是⼀个键值对。

哈希表
Redis使⽤的哈希表由dictht结构定义:
typedef struct dictht{
// 哈希表数组
dictEntry **table;
// 哈希表⼤⼩
unsigned long size;
// 哈希表⼤⼩掩码,⽤于计算索引值
// 总是等于 size-1
unsigned long sizemask;
// 该哈希表已有节点数量
unsigned long used;
} dictht
table属性是⼀个数组,数组中的每个元素都是⼀个指向dictEntry结构的指针,每个dictEntry结构保存着⼀个键值对。

size属性记录了哈希表的⼤⼩,即table数组的⼤⼩。

used属性记录了哈希表⽬前已有节点数量。

sizemask总是等于size-1,这个值主要⽤于数组索引。

⽐如下图展⽰了⼀个⼤⼩为4的空哈希表。

哈希表节点
哈希表节点使⽤dictEntry结构表⽰,每个dictEntry结构都保存着⼀个键值对:
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
unit64_t u64;
nit64_t s64;
} v;
// 指向下⼀个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
key属性保存着键值对中的键,⽽v属性则保存了键值对中的值。

值可以是⼀个指针,⼀个uint64_t整数或者是int64_t整数。

next属性指向了另⼀个dictEntry节点,在数组桶位相同的情况下,将多个dictEntry节点串联成⼀个链表,以此来解决键冲突问题。

(链地址法)
字典
Redis字典由dict结构表⽰:
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
//rehash索引
// 当rehash不在进⾏时,值为-1
int rehashidx;
}
ht是⼤⼩为2,且每个元素都指向dictht哈希表。

⼀般情况下,字典只会使⽤ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进⾏rehash时使⽤。

rehashidx记录了rehash的进度,如果⽬前没有进⾏rehash,值为-1。

rehash
为了使hash表的负载因⼦(ht[0]).used/ht[0]).size)维持在⼀个合理范围,当哈希表保存的元素过多或者过少时,程序需要对hash表进⾏相应的扩展和收缩。

rehash(重新散列)操作就是⽤来完成hash表的扩展和收缩的。

rehash的步骤如下:
1. 为ht[1]哈希表分配空间
1. 如果是扩展操作,那么ht[1]的⼤⼩为第⼀个⼤于ht[0].used*2的2n。

⽐如`ht[0].used=5`,那么此时`ht[1]`的⼤⼩就为16。

(⼤于10的第⼀个2n的值是16)
2. 如果是收缩操作,那么ht[1]的⼤⼩为第⼀个⼤于ht[0].used的2n。

⽐如`ht[0].used=5`,那么此时`ht[1]`的⼤⼩就为8。

(⼤于5的第⼀个2n的值是8)
2. 将保存在ht[0]中的所有键值对rehash到ht[1]中。

3. 迁移完成之后,释放掉ht[0],并将现在的ht[1]设置为ht[0],在ht[1]新创建⼀个空⽩哈希表,为下⼀次rehash做准备。

哈希表的扩展和收缩时机:
1. 当服务器没有执⾏BGSAVE或者BGREWRITEAOF命令时,负载因⼦⼤于等于1触发哈希表的扩展操作。

2. 当服务器在执⾏BGSAVE或者BGREWRITEAOF命令,负载因⼦⼤于等于5触发哈希表的扩展操作。

3. 当哈希表负载因⼦⼩于0.1,触发哈希表的收缩操作。

渐进式rehash
前⾯讲过,扩展或者收缩需要将ht[0]⾥⾯的元素全部rehash到ht[1]中,如果ht[0]元素很多,显然⼀次性rehash成本会很⼤,从影响到Redis性能。

为了解决上述问题,Redis使⽤了渐进式rehash技术,具体来说就是分多次,渐进式地将ht[0]⾥⾯的元素慢慢地rehash到ht[1]中。

下⾯是渐进式rehash的详细步骤:
1. 为ht[1]分配空间。

2. 在字典中维持⼀个索引计数器变量rehashidx,并将它的值设置为0,表⽰rehash正式开始。

3. 在rehash进⾏期间,每次对字典执⾏添加、删除、查找或者更新时,除了会执⾏相应的操作之外,还会顺带将ht[0]在rehashidx索引位上的所有键值对rehash到ht[1]中,rehash
完成之后,rehashidx值加1。

4. 随着字典操作的不断进⾏,最终会在啊某个时刻迁移完成,此时将rehashidx值置为-1,表⽰rehash结束。

渐进式rehash⼀次迁移⼀个桶上所有的数据,设计上采⽤分⽽治之的思想,将原本集中式的操作分散到每个添加、删除、查找和更新操作上,从⽽避免集中式rehash带来的庞⼤计算。

因为在渐进式rehash时,字典会同时使⽤ht[0]和ht[1]两张表,所以此时对字典的删除、查找和更新操作都可能会在两个哈希表进⾏。

⽐如,如果要查找某个键时,先在ht[0]中查找,如果没找到,则继续到ht[1]中查找。

hash对象中的hashtable
HSET profile name "tom"
HSET profile age 25
HSET profile career "Programmer"
还是上述三条命令,保存数据到Redis的哈希对象中,如果采⽤hashtable编码保存的话,那么该Hash对象在内存中的结构如下:
当哈希对象保存的所有键值对的键和值的字符串长度都⼩于64个字节,并且数量⼩于512个时,使⽤ziplist编码,否则使⽤hashtable编码。

可以通过配置⽂件修改该上限值。

集合对象
集合对象的编码可以是intset或者hashtable。

当集合对象保存的元素都是整数,并且个数不超过512个时,使⽤intset编码,否则使⽤hashtable编码。

set-intset
intset编码的集合对象底层使⽤整数集合实现。

整数集合(intset)是Redis⽤于保存整数值的集合抽象数据结构,它可以保存类型为int16_t、int32_t或者int64_t的整数值,并且保证集合中的数据不会重复。

Redis使⽤intset结构表⽰⼀个整数集合。

typedef struct intset {
// 编码⽅式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
contents数组是整数集合的底层实现:整数集合的每个元素都是contents数组的⼀个数组项,各个项在数组中按值⼤⼩从⼩到⼤有序排列,并且数组中不包含重复项。


然contents属性声明为int8_t类型的数组,但实际上,contents数组不保存任何int8_t类型的值,数组中真正保存的值类型取决于encoding。

如果encoding属性值为INTSET_ENC_INT16,那么contents数组就是int16_t类型的数组,以此类推。

当新插⼊元素的类型⽐整数集合现有类型元素的类型⼤时,整数集合必须先升级,然后才能将新元素添加进来。

这个过程分以下三步进⾏。

1. 根据新元素类型,扩展整数集合底层数组空间⼤⼩。

2. 将底层数组现有所有元素都转换为与新元素相同的类型,并且维持底层数组的有序性。

3. 将新元素添加到底层数组⾥⾯。

还有⼀点需要注意的是,整数集合不⽀持降级,⼀旦对数组进⾏了升级,编码就会⼀直保持升级后的状态。

举个栗⼦,当我们执⾏SADD numbers 1 3 5向集合对象插⼊数据时,该集合对象在内存的结构如下:
set-hashtable
hashtable编码的集合对象使⽤字典作为底层实现,字典的每个键都是⼀个字符串对象,每个字符串对象对应⼀个集合元素,字典的值都是NULL。

当我们执⾏SADD fruits "apple" "banana" "cherry"向集合对象插⼊数据时,该集合对象在内存的结构如下:
有序集合对象
有序集合的编码可以是ziplist或者skiplist。

当有序集合保存的元素个数⼩于128个,且所有元素成员长度都⼩于64字节时,使⽤ziplist编码,否则,使⽤skiplist编码。

zset-ziplist
ziplist编码的有序集合使⽤压缩列表作为底层实现,每个集合元素使⽤两个紧挨着⼀起的两个压缩列表节点表⽰,第⼀个节点保存元素的成员(member),第⼆个节点保存元素的分值(score)。

压缩列表内的集合元素按照分值从⼩到⼤排列。

如果我们执⾏ZADD price 8.5 apple 5.0 banana 6.0 cherry命令,向有序集合插⼊元素,该有序集合在内存中的结构如下:
zset-skiplist
skiplist编码的有序集合对象使⽤zset结构作为底层实现,⼀个zset结构同时包含⼀个字典和⼀个跳跃表。

typedef struct zset {
zskiplist *zs1;
dict *dict;
}
继续介绍之前,我们先了解⼀下什么是跳跃表。

跳跃表
跳跃表(skiplist)是⼀种有序的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从⽽达到快速访问节点的⽬的。

Redis的跳跃表由zskiplistNode和zskiplist两个结构定义,zskiplistNode结构表⽰跳跃表节点,zskiplist保存跳跃表节点相关信息,⽐如节点的数量,以及指向表头和表尾节点的指针等。

跳跃表节点 zskiplistNode
跳跃表节点zskiplistNode结构定义如下:
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
下图是⼀个层⾼为5,包含4个跳跃表节点(1个表头节点和3个数据节点)组成的跳跃表:
每次创建⼀个新的跳跃表节点的时候,会根据幂次定律(越⼤的数出现的概率越低)随机⽣成⼀个1-32之间的值作为当前节点的"层⾼"。

每层元素都包含2个数据,前进指针和跨度。

1. 前进指针
每层都有⼀个指向表尾⽅向的前进指针,⽤于从表头向表尾⽅向访问节点。

2. 跨度
层的跨度⽤于记录两个节点之间的距离。

2. 后退指针(BW)
节点的后退指针⽤于从表尾向表头⽅向访问节点,每个节点只有⼀个后退指针,所以每次只能后退⼀个节点。

3. 分值和成员
节点的分值(score)是⼀个double类型的浮点数,跳跃表中所有节点都按分值从⼩到⼤排列。

节点的成员(obj)是⼀个指针,指向⼀个字符串对象。

在跳跃表中,各个节点保存的成员对象必须是唯⼀的,但是多个节点的分值确实可以相同。

需要注意的是,表头节点不存储真实数据,并且层⾼固定为32,从表头节点第⼀个不为NULL最⾼层开始,就能实现快速查找。

跳跃表 zskiplist
实际上,仅靠多个跳跃表节点就可以组成⼀个跳跃表,但是Redis使⽤了zskiplist结构来持有这些节点,这样就能够更⽅便地对整个跳跃表进⾏操作。

⽐如快速访问表头和表尾节点,获得跳跃表节点数量等等。

zskiplist结构定义如下:
typedef struct zskiplist {
// 表头节点和表尾节点
struct skiplistNode *header, *tail;
// 节点数量
unsigned long length;
// 最⼤层数
int level;
} zskiplist;
下图是⼀个完整的跳跃表结构⽰例:
有序集合对象的skiplist实现
前⾯讲过,skiplist编码的有序集合对象使⽤zset结构作为底层实现,⼀个zset结构同时包含⼀个字典和⼀个跳跃表。

typedef struct zset {
zskiplist *zs1;
dict *dict;
}
zset结构中的zs1跳跃表按分值从⼩到⼤保存了所有集合元素,每个跳跃表节点都保存了⼀个集合元素。

通过跳跃表,可以对有序集合进⾏基于score的快速范围查找。

zset结构中的dict字典为有序集合创建了从成员到分值的映射,字典的键保存了成员,字典的值保存了分值。

通过字典,可以⽤O(1)复杂度查找给定成员的分值。

假如还是执⾏ZADD price 8.5 apple 5.0 banana 6.0 cherry命令向zset保存数据,如果采⽤skiplist编码⽅式的话,该有序集合在内存中的结构如下:
总结
总的来说,Redis底层数据结构主要包括简单动态字符串(SDS)、链表、字典、跳跃表、整数集合和压缩列表六种类型,并且基于这些基础数据结构实现了字符串对象、列表对象、哈希对象、集合对象以及有序集合对象五种常见的对象类型。

每⼀种对象类型都⾄少采⽤了2种数据编码,不同的编码使⽤的底层数据结构也不同。

原创不易,觉得⽂章写得不错的⼩伙伴,点个赞 ⿎励⼀下吧~。

相关文档
最新文档