《深入浅出redis》

1 数据结构与对象

1.1 SDS(simple dynamic string)

redis使用SDS表示字符串、缓冲区(AOF缓冲区)以及客户端状态中的输入缓冲区。

  /*
   * 保存字符串对象的结构
   */
  struct sdshdr {

      // buf 中已占用空间的长度
      int len;

      // buf 中剩余可用空间的长度
      int free;

      // 数据空间
      char buf[];
  };
  • free属性的值为0,表示这个SDS没有分配任何未使用空间
  • len属性的值为5,表示这个SDS保存了一个5字节长的字符串
  • buf属性是一个char类型的数组,最后以’\0’结尾。空字符不计算在SDS的len中
1.1.2 SDS与C字符串的区别

C字符串不能满足Redis对字符串在安全性、效率以及功能方面的要求

1.1.2.1 常数复杂度获取字符串长度

C字符串不记录自身的长度信息,因此获取一个C字符串的长度,需要遍历整个字符串,时间复杂度为 O(n), 而SDS里有len字段保存了字符串本身的长度,时间复杂度为O(1),确保了获取字符串长度不会成为redis的性能瓶颈

1.1.2.2 杜绝缓冲区溢出

C字符串不记录自身自身长度,因此容易造成缓冲区溢出,如:

# 如果用户为dest分配的空间不够,就会产生溢出,可能会导致src的内容被覆盖
char *strcat(char *dest, const char *src);

当使用SDS的API对SDS进行修改时,API会首先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作

1.1.2.3 减小内存重分配次数

SDS为了避免每次修改都要进行一次内存分配,使用free字段(未使用空间)来解除字符串长度和底层数组长度之间的关联,实现了空间预分配和惰性空间释放两种优化策略。

  1. 空间预分配
  • 如果对SDS进行修改后,SDS的长度(len的值)小于 1MB,那么程序将分配2倍的len,即len和free的值相等,buf的实际长度为2len + 1
  • 否则(len>=1MB),程序将分配len+1MB,buf的实际长度为len+1MB+1
  1. 惰性空间删除
    当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重新分配来回多出来的空间,而是使用free属性将这些字节的数量记录起来,并等待将来使用
1.1.2.4 二进制安全

C字符串里不能包含空字符串(’\0’),而SDS使用len来判断字符串是否结束,因此SDS的buf可以存放二进制数据

1.1.2.5 兼容部分C字符串函数

SDS的API虽然都是二进制安全的,但是一样遵循C字符串以空字符串结尾的惯例。这些API总是为SDS保存的数据的末尾设置空字符,并且总会在为buf数组分配空间时多分配一个字节来容纳这个空字符。
好处:

  • redis不必自己专门写函数来对比SDS与C字符串了,可以直接使用string.h的函数库,如:
strcat(c_string, sds->buf);

1.2 链表

链表用于redis的链表键、发布与订阅、慢查询、监视器等等。

多个listnode可以通过prev和next指针组成双端链表。

/*
 * 双端链表节点
 */
typedef struct listNode {

    // 前置节点
    struct listNode *prev;

    // 后置节点
    struct listNode *next;

    // 节点的值
    void *value;

} listNode;

虽然仅仅使用多个listNode结果就可以组成链表,但使用adlist.h/list来持有链表的话,操作起来会更方便:

/*
 * 双端链表结构
 */
typedef struct list {

    // 表头节点
    listNode *head;

    // 表尾节点
    listNode *tail;

    // 节点值复制函数
    void *(*dup)(void *ptr);

    // 节点值释放函数
    void (*free)(void *ptr);

    // 节点值对比函数
    int (*match)(void *ptr, void *key);

    // 链表所包含的节点数量
    unsigned long len;

} list;
  • dup函数用于复制链表节点所保存的值
  • free函数用于释放链表节点所保存的值
  • match函数则用于对比链表节点所保存的值和另一个输入值是否相对

redis的链表实现特效:

  • 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都为O(1)
  • 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点
  • 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)
  • 代链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,获取节点数量的复杂度为O(1)
  • 多态:链表节点使用void*指针来保存节点的值,且可以通过dup、free、match为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值

1.3 字典

redis的数据库就是使用字典来作为底层实现的,对数据库的增、删、改、查操作是构建在对字典的操作之上的。

redis使用哈希表作为字典的底层实现,一个哈希表里可以有多个哈希表节点,而每个哈希节点就是保存了一个字典中的键值对。

1.3.1 字典的实现
  /* This is our hash table structure. Every dictionary has two of this as we
   * implement incremental rehashing, for the old to the new table. */
  /*
   * 哈希表
   *
   * 每个字典都使用两个哈希表,从而实现渐进式 rehash 。
   */
  typedef struct dictht {

      // 哈希表数组
      dictEntry **table;

      // 哈希表大小
      unsigned long size;

      // 哈希表大小掩码,用于计算索引值
      // 总是等于 size - 1
      unsigned long sizemask;

      // 该哈希表已有节点的数量
      unsigned long used;

  } dictht;
  • table是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry结构的指针,每个dictEntry结构保存着一个键值对
  • size记录了哈希表的大小,即table数组的大小
  • sizemask的值总是为size - 1,它和哈希值一起决定了一个键应该放到table数组的哪个索引上面
  • used为哈希表目前已有节点(键值对)的数量
1.3.2 哈希表节点
  /*
   * 哈希表节点
   */
  typedef struct dictEntry {

      // 键
      void *key;

      // 值
      union {
          void *val;
          uint64_t u64;
		  int64_t s64;
      } v;

      // 指向下个哈希表节点,形成链表
      struct dictEntry *next;

  } dictEntry;
  • key保存了键值对中的键
  • v保存了键值对中的值,即键值对中的值可以是一个指针或者uint64_t,或者int64_t
  • next指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,以此来解决键冲突的问题
1.3.3 字典

type

  /*
   * 字典
   */
  typedef struct dict {

      // 类型特定函数
      dictType *type;

      // 私有数据
      void *privdata;

      // 哈希表
      dictht ht[2];

      // rehash 索引
      // 当 rehash 不在进行时,值为 -1
      int rehashidx; /* rehashing not in progress if rehashidx == -1 */

      // 目前正在运行的安全迭代器的数量
      int iterators; /* number of iterators currently running */

  } dict;
  • type和private字段是针对不同类型的键值对,为创建多态字典而设置的
  • type字段是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,redis会为用途不同的字典设置不同的类型特定函数
  • private保存了需要传给那些类型特定函数的可选参数
  • ht是一个包含了2个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用
  • 除了ht[1]之外,另一个和rehash有关的字段就是rehashidx,它记录了rehash目前的进度。如果目前没有进行rehash,它的值为-1
1.3.4 哈希算法

添加一个新的键值对时,先根据键值对的键计算出哈希值和索引值,然后在根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。

# 使用字典设置的哈希函数,计算键key的哈希值
hash = dict->type->hashFunction(key);

# 使用哈希表的sizemask和哈希值,计算出索引值
# 根据情况不同,ht[x]可以是ht[0]或者ht[1]
index = hash & dicet->ht[x].sizemask;
  • redis使用murmurHash2算法来计算键的哈希值
  • murmurHash2算法的优点:即使输入的键是有规律,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快
1.3.5 解决键冲突
  • 冲突:有两个或以上的键被分配到了哈希表数组的同一个索引上面
  • redis的哈希表使用链地址法来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这样来解决冲突问题。
    注意:因为dictEntry节点组成得链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置(复杂度为O(1)),排在其他已有节点的前面
1.3.6 rehash

随着操作的不断进行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展后者收缩。
rehash步骤:

  1. 为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也即ht[0]。used属性的值):
  • 如果执行的是扩展操作,那么ht[1]的大小为第一个>=ht[0].used2的 2 n 2^{n} 2n(2的n次方),比如used为4, 42=8,恰好是第一个大于等于4的2的n次方。
  • 如果执行的是收缩操作,那么ht[1]的大小为第一个>=ht[0].used的 2 n 2^{n} 2n
  1. 将保存在ht[0]中的所有键值对rehash到ht[1]上面:rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上
  2. 当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。

哈希表的扩展和收缩
当以下条件中的任意一个被满足时,程序会自动开始对哈希表进行扩展操作:

  1. 服务器目前没有执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子>=1
  2. 服务器目前正则执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子>=5
    其中hash表的负载因子计算公式如下:
    l o a d _ f a c t o r = h t [ 0 ] . u s e d / h t [ 0 ] . s i z e load\_factor = ht[0].used / ht[0].size load_factor=ht[0].used/ht[0].size
  3. 当负载因子小于0.1时,程序自动开始对哈希表执行收缩操作

问题:
为啥在执行BGSAVE命令或者BGREWRITEAOF命令,负载因子要>=5,才执行扩展操作?
答:因为执行BGSAVE命令或者BGREWRITEAOF命令时,redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能避免在子进程进行哈希表扩展操作,这样可以避免不必要的内存写入操作,最大限度地节约内存

1.3.7 渐进式rehash

rehash动作并不是一次性的、集中式地完成的,而是分多次、渐进式地完成的。
哈希表渐进式rehash的详细步骤:

  1. 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
  2. 在字典中维护了一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始
  3. 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成后,程序将rehashidx属性的值增1
  4. 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设置为-1,表示rehash操作已完成。

注意:

  • rehash期间,字典的删除、查找、更新等操作会在2个哈希表(ht[0]和ht[1])上进行,如,要在字典里查找一个键的话,程序会现在ht[0]里面进行查找,如果没找到的话,就会继续到ht[1]里面进行查找
  • 在rehash期间,新添加到字典的键值对一律被保存到ht[1]里面,而ht[0]则不会进行任何操作,保证ht[0]包含的键值对数量只减不增,并随着rehash操作的执行最终变成空表。

好处:
采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的庞大计算量

1.4 跳跃表

redis使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员是比较长的字符串,redis会使用跳表来实现。

一种有序数据结构,通过在每个节点中维护多个指向其他节点的指针,从而达到快速访问节点的目的。大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来的更为简单,所以有不少程序都使用跳跃表来代替平衡树。
复杂度:
支持平均O(logN)、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理。

1.4.1 跳表的实现
/*
 * 跳跃表
 */
typedef struct zskiplist
{

    // 表头节点和表尾节点
    struct zskiplistNode *header, *tail;

    // 表中节点的数量
    unsigned long length;

    // 表中层数最大的节点的层数
    int level;

} zskiplist;

/*
 * 跳跃表节点
 */
typedef struct zskiplistNode
{

    // 成员对象
    robj *obj;

    // 分值
    double score;

    // 后退指针
    struct zskiplistNode *backward;

    // 层
    struct zskiplistLevel
    {

        // 前进指针
        struct zskiplistNode *forward;

        // 跨度
        unsigned int span;

    } level[];

} zskiplistNode;
  • header:指向跳跃表的表头节点
  • tail:指向跳跃表的表尾节点
  • level:记录目前跳跃表内,层数最大的节点的层数(表头节点的层数不计算在内)
  • length:记录跳跃表的长度,即跳跃表目前包含节点的数量(表头节点不计算在内)
  • level:节点中用L1、L2、L3等标记节点的各个层,L1代表第一层,L2代表第二层(层高都是1~32之间的随机数)。每个层带有2个属性:前进指针和跨度。前进指针:用于访问表尾方向的其他节点。跨度:记录了前进指针所指向节点和当前节点的距离,用来计算节点排位的,如在查找某个节点过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位
  • 后退指针:指向位于当前节点的前一个节点(没有多个,所以不会跳着指向,只能后退至前一个节点)。在程序从表尾向表头遍历时使用
  • 分值:各个节点时保存了各自的分值,节点按照各自保存的分值从小到大排列
  • 成员对象:各个节点所保存的成员对象

1.5 整数集合

整数集合是集合键的底层实现之一,当一个集合只包含整数值元素,且这个结合的元素数量不多时,redis就会使用整数集合作为集合键的底层实现

1.5.1 整数集合的实现
  typedef struct intset {

      // 编码方式
          uint32_t encoding;

      // 集合包含的元素数量
         uint32_t length;

      // 保存元素的数组
	  int8_t contents[];

  } intset;
  • encoding值为INSET_ENC_INT16时,那么contents就是一个int16_t类型的数组,encoding的可能值有INSET_ENC_INT32、INSET_ENC_INT64
  • length:记录了整数集合的元素数量,也就是contents数组的长度
  • contents:数组集合的每个元素都是contents数组的一个数组项,各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项,
  • 假设:encoding为INSET_ENC_INT16,length为5, contents的数组大小:sizeof(int16_t) * 5 = 16 * 5 = 80位
1.5.2 升级

当增加一个新元素到整数集合里,且新元素的类型比整数集合里现有所有元素的类型都要长时,整数集合需要先进行升级,然后才能将新元素添加到整数集合里
升级整数集合并且添加新元素分为3步:

  1. 根据新元素的类型,扩展整数集合底层数组空间大小,并为新元素分配空间
  2. 将底层数组现有的所有元素都转成为与新元素相同的类型,并将类型转换后的元素放置在正确位置上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变
  3. 将新元素添加到底层数组里面(新元素小于所有元素的情况下,会被放置在底层数组的索引0的位置上,新元素大于所有元素的情况下,会被放置在末尾,索引length-1)
1.5.3 升级的好处
  • 提升灵活性
    可以随意将int16_t、int32_t以及int64_t类型的整数添加到集合里,不必担心出现类型错误
  • 节约内存
    比如只有int16_t元素时,底层实现就会一直用int16_t类型的数组
1.5.4 降级

不支持降级操作,一旦对数据进行了升级,即使后续删除了类型大的整数,依然会用升级后的编码

1.6 压缩队列

压缩队列是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串时,那么redis就会使用压缩队列来做列表键的底层实现

1.6.1 压缩列表的构成

压缩队列是redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构,一个压缩队列可以包含任意多个节点,每个节点可以保存一个字节数组或一个整数值

  /*
  空白 ziplist 示例图

  area        |<---- ziplist header ---->|<-- end -->|

  size          4 bytes   4 bytes 2 bytes  1 byte
              +---------+--------+-------+-----------+
  component   | zlbytes | zltail | zllen | zlend     |
              |         |        |       |           |
  value       |  1011   |  1010  |   0   | 1111 1111 |
              +---------+--------+-------+-----------+
                                         ^
                                         |
                                 ZIPLIST_ENTRY_HEAD
                                         &
  address                        ZIPLIST_ENTRY_TAIL
                                         &
                                 ZIPLIST_ENTRY_END

  非空 ziplist 示例图

  area        |<---- ziplist header ---->|<----------- entries ------------->|<-end->|

  size          4 bytes  4 bytes  2 bytes    ?        ?        ?        ?     1 byte
              +---------+--------+-------+--------+--------+--------+--------+-------+
  component   | zlbytes | zltail | zllen | entry1 | entry2 |  ...   | entryN | zlend |
              +---------+--------+-------+--------+--------+--------+--------+-------+
                                         ^                          ^        ^
  address                                |                          |        |
                                  ZIPLIST_ENTRY_HEAD                |   ZIPLIST_ENTRY_END
                                                                    |
                                                          ZIPLIST_ENTRY_TAIL
  */
  • zlbytes (uint32_t) : 记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重新分配,或者计算zlend的位置时使用
  • zltail(uint32_t):记录压缩列表尾节点距离压缩队列的起始地址多少字节:通过这个偏移量,程序无须遍历整个压缩队列就可以确定表尾节点的地址
  • zllen(uint16_t):记录了压缩队列包含的节点数量
  • entryX: 压缩累表包含的各个节点,节点的长度有节点保存的内容决定
  • zlend(uint8_t): 特殊值0xff(十进制255),用于标记列表的末端
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值