redis 基本数据结构及其实现

193 阅读8分钟

String

String 类型是 Redis 中最常使用的类型,内部的实现是通过 SDS(Simple Dynamic String )即简单动态字符串来存储的。SDS 类似于 Java 中的 ArrayList,可以通过预分配冗余空间的方式来减少内存的频繁分配。SDS结构如图所示,遵循最后一个字符为'\0'

O(1)获取字符串长度

杜绝缓冲区溢出

使用SDS会自动对字符串的空间大小进行检查并进行扩容

减少修改字符串带来的内存重分配次数

传统的C语言

  • 增长字符串的操作,比如拼接操作(append),通过重新分配内存来扩展底层数组的空间大小
  • 缩短字符串的操作,比如截断操作(trim),通过重新分配内存来释放字符串不再使用的空间

SDS实现空间预分配和惰性空间释放两种优化策略

  • 空间预分配 空间预分配有利于SDS字符串增长操作,进行扩展操作时,会判断SDS的未使用空间大小是否足够,当不足够时,除了分配所需要的的空间,还会为SDS分配额外未使用空间
    • 当SDS长度小于1MB时,修改之后,free大小与len大小一致
    • 当SDS长度大于1MB时,修改之后,分配1MB未使用空间
  • 惰性空间释放 惰性空间释放用户优化SDS的字符串缩短操作,当需要缩短SDS保存的字符串时,程序不立即使用内存重分配来回收缩短后多出来的字节,而是用free属性保留这些字节的数量

二进制安全

C语言限制字符串必须符合除了字符串的末尾之外,字符串里面不能包含空字符,否则程序读入的空字符串会被误认为是字符串结尾,如下图用C语言所使用的函数只能识别出其中的"Redis"

而SDS是通过len属性进行读取而不是通过空字符串来判断字符串是否结束

List

由链表或者压缩列表(zipList)实现 除了链表建之外,发布与订阅,慢查询,监视器等功能也用到了链表,redis服务器本身还使用链表来保存多个客户端状态信息

链表和链表节点的实现

每个链表节点使用一个adlist.h/listNode结构来表示:

使用adlist.h/list链表来持有链表的话,操作起来会更方便

  • 通过lrange命令读取某个闭区间的元素
  • 当作消息队列

字典(哈希)

字典使用哈希表或者压缩列表作为底层实现 哈希表由dict.h/dict结构表示

一般情况下,字典只使用ht[0]哈希表,ht[1]只会在对ht[0]哈希表进行rehash时使用 trehashidx记录了rehash目前进度,当没有进行rehash操作时,该值为-1

rehash

  • 为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量
    • 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的 2n2^n
    • 如果执行的是收缩操作那么ht[1]的大小为第一个大于等于ht[0].used的 2n2^n
  • 将保存在ht[0]中的所有键值对进行重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上
  • 当ht[0]所有键值对都迁移到ht[1]之后,释放ht[0],将ht[1]设置为ht[0],ht[1]则新创建一个空白哈希表,为下一次rehash做准备

哈希表的扩展与收缩

  • 当服务器没有执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1
  • 当服务器执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5
  • 当哈希表的负载因子小于0.1时,程序自动开始对哈希表进行收缩操作

渐进式rehash

上次所说的rehash不是一次性,集中性的完成的,而是分多次,渐进地将ht[0]里面的键值对慢慢地rehash到ht[1](因为如果键值对数量是几百万,上千万个的话,一次性全部rehash到ht[1],如此大的计算量可能会导致服务器在一段时间内停止服务) 渐进式rehash步骤

  • 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
  • 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始
  • 在rehash进行期间,每次对字典进行操作时,都会将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增1
  • 随着字典操作的不断进行,最终在某个时间点上,ht[0]的所有键值对都rehash到ht[1],此时rehashidx属性的值设为-1,表示rehash操作已完成。
  • 在渐进式rehash执行期间,所有新添加到字典的键值对都保存到ht[1]里面,而ht[0]不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量只增不减。

set

set是无序集合,会自动去重的那种

有序集合

有序集合是由跳跃表实现的

如上图是一个跳跃表示例。

  • header :指向跳跃表的表头节点
  • tail: 指向跳跃表的表尾节点
  • level :记录目前跳跃表内层数最大的那个节点的层数
  • length: 记录跳跃表目前包含节点的数量
  • 层: 节点中L1,L2,L3等字样标记节点的各个层,L1代表第一层,每个层都带有两个属性:前进指针和跨度
  • BW:后退指针,在程序从表尾向表头遍历时使用
  • 分值:各个节点中的1.0,2.0,3.0是节点中保存的分值,节点依照各自所保存的分值从小到大排序
  • 成员对象:各个节点中的o1,o2,o3是节点所保存的成员对象。

跳跃表节点

  • 程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快
  • 每次创建一个新跳跃表节点的时候,程序都根据幂次定律随机生成一个结余1和32之间的值[1,32]作为level数组的大小
跨度
  • 通过跨度,将沿途所有层的跨度累计起来,得到的结果就是 目标节点在跳跃表中的排位

作用

  • 去重且可以进行排序

压缩列表

压缩列表是列表键和哈希键的底层实现之一,当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值或者是长度比较短的字符串,redis就会用压缩列表来做列表键的底层实现 一个压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者一个整数值,压缩列表的组成部分如下图所示

压缩列表节点

  • previous_entry_length:记录了压缩列表中前一个节点的长度,占用大小可以是1字节或者5字节,(如果前一节点小于254字节,则该字段只占1字节,大于等于254字节,该字段则占5字节)

所以我们可以通过当前节点起始地址的指针C减去当前节点的previous_entry_length属性的值,就可以得出一个指向前一个节点起始地址的指针P。

  • encoding:记录了节点的content属性所保存数据的类型以及长度
  • content:节点值可以是一个字节数组或者整数

连锁更新

当连续多个节点所占的字节数为250-253之间,当第一个节点所占的字节数变为大于254字节,此时原来只占一个字节的previous_entry_length字段要升级为占用5个字节, 那么这连续多个节点都需要重新扩展,程序需要不断地对压缩列表执行空间重分配操作。 连锁更新最坏复杂度为O(N^2^),但造成的性能问题几率是很低的。

  • 压缩列表里要恰好有多个连续的,长度介于250字节至253字节之间的节点,连锁更新才有可能被引发,在实际中这情况并不多见

  • 就算出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响

  • [1] [Redis设计与实现第二版]