Redis数据结构
详解:mystudy/Redis/Redis数据结构详解.md · Zhang-HaoQi/Knowledge - 码云 - 开源中国 (gitee.com)
SDS
struct sds {
int len;// buf 中已占用字节数
int free;// buf 中剩余可用字节数
char buf[];// 数据空间
};
redis中常用的数据类型,并不是直接使用的C字符串,而是在其基础上进行了优化。sds与c字符串对比如下。
记录字符串长度
c字符串底层实现并没有记录字符串长度,如果想获取长度需要遍历整个字符串。获取字符串长度复杂度为O(n)
sds提供了len属性记录了字符串长度,获取字符串长度复杂度为O(1)
杜绝缓冲区溢出
strcat(s1, " Cluster"); 字符串你拼接,将Cluster拼接到s1上。 s1为dest,Cluster为src
C字符串不记录自身的长度,所以默认用户使用字符串拼接函数时,已经为dest(原始串)分配了足够多的内存,可以容纳src(拼接串)字符串中的所有内容,而一旦这个假定不成立时,就会产生缓冲区溢出。、
C字符串拼接字符串时,S1后方拼接S2,要保证S1的空间能够满足S2所需空间,否则会占用S1之后的连续空间,如果S1之后的连续空间有数据,那么S2会覆盖掉这部分数据。
SDS字符串进行数据修改时,如将S1修改成S2,S1占用的空间小于S2,那么此时SDS会主动申请空间来满足修改后S2的存储。申请空间时,如果S2所需空间小于1M,那么会多申请S2所占用的空间,并多加1字节的空间用来存储“/0”,如果S2的空间大于1M,则在S2的基础上,多申请1M的空间,并多加1字节的空间用来存储“/0”。缩容时,不会主动释放掉空闲的空间,可以调用API主动删除。
SDS这样既可以避免缓冲区溢出,又减少了内存空间的分配次数。
支持二进制
C字符串存储字符数据,因为只能存储文本
SDS存储字节数据,可存储二进制流,支持文本,视频,图片等格式数据。
兼容C字符串函数
C字符串以’/0’空字符结尾,C字符串的API(函数)总会将C字符串的末尾设置为空字符,并且总会在为buf数组分配空间时多分配一个字节来容纳这个空字符。
SDS为了能够使用这些函数,而非重写,在结构定义上也多分配了一字节来存储这个空字符,但并不是支持所有API。
Hash
//字典
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash索引
//当rehash不在进行时,值为-1
in trehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
//hashtable
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
//总是等于size-1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
//entru
typedef struct dictEntry {
// 键
void *key;
// 值
union{
void *val;
uint64_tu64;
int64_ts64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
redis的hash结构由数组和链表组成,数组存储的是key经过hash算法后得到的值,链表存储的是value对象。
添加元素
添加元素时,先对key进行hash运算,计算出hash值,再与sizemask进行&运算,计算出index。如果出现hash冲突,则新添加的元素作为第一个子元素添加到链表中。
Redis使用MurmurHash2算法来计算键的哈希值,该算法的优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快。
rehash
负载因子 = 哈希表已保存节点数量/ 哈希表大小。
增加/删除元素会造成负载因子变化,hash表的扩容和缩容受负责因子影响。
dict中存了两个hash表(dictht),ht[o]存放元素,ht[1]用于扩容和缩容操作。
扩容/缩容执行步骤:
- 为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也即是ht[0].used属性的值)
- 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的(2的n次方幂);
- 如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2的n次方幂);
- 将ht[0]中的所有键值对rehash到ht[1]上面:**rehash指的是重新计算键的哈希值和索引值,**然后将键值对放置到ht[1]哈希表的指定位置上。
- 迁移后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。
哈希表的扩展与收缩条件:
当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作:
-
服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。
Redis Bgsave 命令用于在后台异步保存当前数据库的数据到磁盘。
Redis Bgrewriteaof 命令用于异步执行一个 AOF(AppendOnly File) 文件重写操作。重写会创建一个当前 AOF 文件的体积优化版本。
-
服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。
根据BGSAVE命令或BGREWRITEAOF命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同。
这是因为在执行BGSAVE命令或BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存。
写时复制:子线程向磁盘写数据时,会读取主存中的数据,如果主存中的数据没有改变不影响,如果改变了,则拷贝一个副本,将副本写入磁盘。如果在BGSAVE过程中,发生了rehash,那么数据的内存空间发生变化,需要拷贝大量数据的副本,浪费内存空间。
另一方面,当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作。
渐进式rehash
如果redis存储数据量大,并且当前服务访问量比较大,如果直接进行rehash,一次性将所有的键值对移到新的hash表中,会严重影响redis性能。
redis使用渐进式hash,将rehash的操作均摊到每次的增删改查操作中。当执行操作时,会进行如下行为:
查找:先找ht[o],找到了将ht[0]的搬迁到ht[1],并删除ht[0]
删除:ht[o]有直接删除,没有则在ht[1]删除
增加:直接新增到ht[1]
更新:找到ht[o],进行删除,之后新增到ht[1]。
在渐进式rehash执行期间,新添加到字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表。
小整数集合
当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。
Redis Sadd 命令将一个或多个成员元素加入到集合中,已经存在于集合的成员元素将被忽略。
127.0.0.1:6379> SADD codeholes 1 2 3
(integer) 3
127.0.0.1:6379> OBJECT ENCODING codeholes
"intset"
如果添加的是字符串,不是整数,那么类型是hashtable
127.0.0.1:6379> sadd school zhangsan
(integer) 1
127.0.0.1:6379> OBJECT ENCODING school
"hashtable"
实现方式
struct intset<T> {
int32 encoding; // 决定整数位宽是 16 位、32 位还是 64 位
int32 length; // 元素个数
int<T> contents; // 整数数组,可以是 16 位、32 位和 64 位
}
·整数集合是集合键的底层实现之一。
·整数集合的底层实现为数组,这个数组以有序、无重复的方式保存集合元素,在有需要时,程序会根据新添加元素的类型,改变这个数组的类型。
·升级操作为整数集合带来了操作上的灵活性,并且尽可能地节约了内存。
·整数集合只支持升级操作,不支持降级操作。
链表
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;
typedef struct listNode {
// 前置节点
struct listNode * prev;
// 后置节点
struct listNode * next;
// 节点的值
void * value;
}listNode
Redis的链表实现的特性:
-
双向链表:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)。
-
带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。
-
带链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)。
压缩链表
普通链表是双向链表,存储了prev和next两个指针,占用了一定内存空间;链表指向的对象在内存存储中并不是连续的,导致内存空间碎片化。
当一个哈希键只包含少量键值对,比且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希键的底层实现。
127.0.0.1:6379> zadd programmings 1.0 go 2.0 python 3.0 java
(integer) 3
127.0.0.1:6379> OBJECT ENCODING programmings
"ziplist"
127.0.0.1:6379> HMSET runoobkey-one name "redis tutorial" description "redis basic commands for caching" likes 20 visitors 23000
OK
127.0.0.1:6379> OBJECT ENCODING runoobkey-one
"ziplist"
struct ziplist<T> {
int32 zlbytes; // 整个压缩列表占用字节数
int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
int16 zllength; // 元素个数
T[] entries; // 元素内容列表,挨个挨个紧凑存储
int8 zlend; // 标志压缩列表的结束,值恒为 0xFF
}
struct entry {
int<var> prevlen; // 前一个 entry 的字节长度
int<var> encoding; // 元素类型编码
optional byte[] content; // 元素内容
}
zltail_offset:快速定位到最后一个元素,prevlen记录了前一个元素的字节长度,这样可以实现从后往前遍历。
当元素的长度小于254时,prevlen使用1个字节表示,如果长度等于或大于254则使用5个字节表示。
添加元素
因为 ziplist 都是紧凑存储,没有冗余空间 (对比一下 Redis 的字符串结构)。意味着插入一个新的元素就需要调用 realloc 扩展内存。取决于内存分配器算法和当前的 ziplist 内存大小,realloc 可能会重新分配新的内存空间,并将之前的内容一次性拷贝到新的地址,也可能在原有的地址上进行扩展,这时就不需要进行旧内容的内存拷贝。
如果 ziplist 占据内存太大,重新分配内存和拷贝内存就会有很大的消耗。所以 ziplist 不适合存储大型字符串,存储的元素也不宜过多。
级联更新
每个 entry 都会有一个 prevlen 字段存储前一个 entry 的长度。如果内容小于254 字节,prevlen 用 1 字节存储,否则就是 5 字节。如果某个 entry 经过了修改
操作从 253 字节变成了 254 字节,那么它的下一个 entry 的 prevlen 字段就要更新,从 1 个字节扩展到 5 个字节;如果这个 entry 的长度本来也是 253 字节,那么后面 entry 的prevlen 字段还得继续更新。
如果 ziplist 里面每个 entry 恰好都存储了 253 字节的内容,那么第一个 entry 内容的修改就会导致后续所有 entry 的级联更新,这就是一个比较耗费计算资源的操作。
删除的时候,也有可能造成级联更新。
级联更新对性能影响:
级联更新在最坏情况下需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N 2)。
压缩列表里要恰好有多个连续的、长度介于250字节至253字节之间的节点,连锁更新才有可能被引发,在实际中,这种情况并不多见。
出现连锁更新时,但只要被更新的节点数量不多,就不会对性能造成任何影响,对三五个节点进行连锁更新是绝对不会影响性能的;
因此,实际使用不同担心级联更新影响性能,平均复杂度还是O(N);
问题
-
ziplist底层实现是数组,数组占的空间是连续的,如果存储数据过多,一方面会占用大片连续的空间,可能内存空间不够需要连续分配。另一方面可能造成ziplist转换成普通链表。
-
选择ziplist的时候:
列表对象保存的所有字符串元素的长度都小于64字节;
列表对象保存的元素量小于512个
上面的是选择ziplist作为底层实现所必须满足的条件,如果没满足的话就选用linkedlist作为其底层实现。
快速链表
在redis3.2版本之前,它使用的是ziplist和linkedlist编码作为列表键的底层实现,元素少时用 ziplist,元素多时用 linkedlist。3.2之后,就采用了一个叫做quicklist的数据结构来作其底层实现。quicklist主要解决ziplist可能产生的问题。
struct ziplist<T> {
int32 zlbytes; // 整个压缩列表占用字节数
int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
int16 zllength; // 元素个数
T[] entries; // 元素内容列表,挨个挨个紧凑存储
int8 zlend; // 标志压缩列表的结束,值恒为 0xFF
}
struct ziplist_compressed {
int32 size;
byte[] compressed_data;
}
struct quicklistNode {
quicklistNode* prev;
quicklistNode* next;
ziplist* zl; // 指向压缩列表
int32 size; // ziplist 的字节总数
int16 count; // ziplist 中的元素数量
int2 encoding; // 存储形式 2bit,原生字节数组还是 LZF 压缩存储
...
}
struct quicklist {
quicklistNode* head;
quicklistNode* tail;
long count; // 元素总数
int nodes; // ziplist 节点的个数
int compressDepth; // LZF 算法压缩深度
...
}
快速链表里面包含了多个ziplist,ziplist之间通过指针链接,这样既可以充分利用内存空间,又可以存储更多的元素。
quicklist 内部默认单个 ziplist 长度为 8k 字节,超出了这个字节数,就会新起一个ziplist。ziplist 的长度由配置参数 list-max-ziplist-size 决定。
再压缩
当链表很长的时候,最频繁访问的就是两端的数据,中间被访问的频率比较低,为了进一步节约空间,Redis 还会对ziplist 进行压缩存储,使用 LZF 算法压缩,可以选择压缩深度。
0:是个特殊值,表示都不压缩。这是redis的默认值
1:表示quicklist两端各有一个节点不被压缩,中间节点进行压缩
2:表示quicklist两端各有两个节点不被压缩,中间节点进行压缩
3:表示quicklist两端各有三个节点不被压缩,中间节点进行压缩 依次类推。
紧凑链表
Redis 5.0 引入的新的数据结构 listpack,它是对 ziplist 结构的改进,在存储空间上会更加节省,而且结构上也比 ziplist 要精简。
struct listpack<T> {
int32 total_bytes; // 占用的总字节数
int16 size; // 元素个数
T[] entries; // 紧凑排列的元素列表
int8 end; // 同 zlend 一样,恒为 0xFF
}
struct lpentry {
int<var> encoding;
optional byte[] content;
int<var> length;
}
listpack 跟 ziplist 的结构几乎一摸一样,只是少了一个 zltail_offset 字段。ziplist 通过这个字段来定位出最后一个元素的位置,用于逆序遍历。
listpack的entry中使用length记录当前entry的长度,ziplist使用
元素的结构和 ziplist 的元素结构也很类似,都是包含三个字段。不同的是长度字段放在了元素的尾部,而且存储的不是上一个元素的长度,是当前元素的长度。正是因为长度放在了尾部,所以可以省去了 zltail_offset 字段来标记最后一个元素的位置,这个位置可以通过total_bytes 字段和最后一个元素的长度字段计算出来。
长度字段使用 varint 进行编码,不同于 skiplist 元素长度的编码为 1 个字节或者 5 个字节,listpack 元素长度的编码可以是 1、2、3、4、5 个字节。
Redis 为了让 listpack 元素支持很多类型,它对 encoding 字段也进行了较为复杂的设计
级联更新
listpack 的设计彻底消灭了 ziplist 存在的级联更新行为,元素与元素之间完全独立,不会因为一个元素的长度变长就导致后续的元素内容会受到影响。
取代ziplist
listpack 的设计的目的是用来取代 ziplist,不过当下还没有做好替换 ziplist 的准备,因为有很多兼容性的问题需要考虑,ziplist 在 Redis 数据结构中使用太广泛了,替换起来复杂度会非常之高。它目前只使用在了新增加的 Stream 数据结构中。
跳表
链表+map
struct zsl {
zskiplistNode* header; // 跳跃列表头指针
int maxLevel; // 跳跃列表当前的最高层
map<string, node*> ht; // hash 结构的所有键值对
}
链表节点
typedef struct zskiplistNode {
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
} zskiplistNode;
typedef struct zskiplist {
// 表头节点和表尾节点
structz skiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
zset 的内部实现是一个 hash 字典加一个跳跃列表 (skiplist)。
Redis 的跳跃表共有 64 层,意味着最多可以容纳 2^64 次方个元素。
增删改查:
如果跳表只有一层,查询复杂度为O(n),如果有多层,则复杂度为O(lg(n))。
查询:定位到那个紫色的 kv,需要从 header 的最高层开始遍历找到第一个节点 (最后一个比「我」小的元素),然后从这个节点开始降一层再遍历找到第二个节点 (最
后一个比「我」小的元素),然后一直降到最底层进行遍历就找到了期望的节点 (最底层的最后一个比我「小」的元素)这样,就可以快速找到要查找的元素。
插入的时候,需要定位到位置,然后插入元素,但是新插入的节点,有多少层,需要使用算法分配。跳跃列表使用的是随机算法。
删除过程和插入过程类似,都需先把这个「搜索路径」找出来。然后对于每个层的相关节点都重排一下前向后向指针就可以了。同时还要注意更新一下最高层数 maxLevel。
更新策略:
- 假设这个新的score 值不会带来排序位置上的改变,那么就不需要调整位置,直接修改元素的 score 值就可以了。但是如果排序位置改变了,那就要调整位置。
- score发生改变,位置也改变,删除key,再新增。
在一个极端的情况下,zset 中所有的 score 值都是一样的,zset 的查找性能会退化为O(n),zset 的排序元素不只看 score 值,如果score 值相同还需要再比较 value 值 (字符串比较)
计算排名
redis Zrank 返回有序集中指定成员的排名。其中有序集成员按分数值递增(从小到大)顺序排列。
//添加元素
ZADD runkey 1 redis 2 mysql 3 java 4 go
//获取排名
ZRANK runkey redis
//获取所有排名a
ZRANGE runoobkey 0 -1 WITHSCORES
Redis 在 zskiplistNode的 forward 指针上进行了优化,给每一个 forward 指针都增加了 span 属性,span 是「跨度」的意思,表示从前一个节点沿着当前层的 forward 指针跳到当前这个节点中间会跳过多少个节点。Redis 在插入删除操作时会更新 span 值的大小。
当我们要计算一个元素的排名时,只需要将「搜索路径」上的经过的所有节点的跨度 span 值进行叠加就可以算出元素的最终 rank 值.
总结
各数据类型使用结构
- String:SDS
- List:
- 存储整数使用的是intset。
- 存储其他数据在3.2之前,如果是整数或者是小字符串,使用ziplist,如果数据多使用的是普通链表。3.2之后使用的是快速链表
- hash:数据+链表。链表规则和list一样
- set:底层实现是hash,和hash保持一致
- zset:hash+跳表
- stream:实现是紧凑链表
参考资料
- redis设计与闪现
- redis深度历险
- 部分网络博文,地址丢失,侵权删。