飞道的博客

Redis中的SDS、字典和跳表以及它们的应用

399人阅读  评论(0)

Redis底层数结构

1 SDS

在Redis中使用简单动态字符串(SDS)来表示字符串

sdshdr8的结构:


各属性为:

len: 已使用的长度 不包括'\0'
alloc:当前字符数组分配的长度 不包括'\0'
flag: 表示字符数组的类型 
    // flags值定义
    #define SDS_TYPE_5  0  
    #define SDS_TYPE_8  1
    #define SDS_TYPE_16 2
    #define SDS_TYPE_32 3
    #define SDS_TYPE_64 4
buf: 保存的字符串内容,以'\0'结束

Redis3.2后共有5中类型的sds结构体
即 sdshdr sdshdr8 sdshdr16 sdshdr32 sdshdr64

// 3.0
struct sdshdr {
// 记录buf数组中已使用字节的数量,即SDS所保存字符串的长度
unsigned int len;
// 记录buf数据中未使用的字节数量
unsigned int free;
// 字节数组,用于保存字符串
char buf[];
};

// 3.2
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 {...};
struct __attribute__ ((__packed__)) sdshdr32 {...};
struct __attribute__ ((__packed__)) sdshdr64 {...};

这些结构体的区别主要是记录长度的变量的数据类型不同
分别是unsigned unit8_t unit16_t unit32_t unit64_t

其实xxx_t是某种数据类型的别名
/* exact-width unsigned integer types */
typedef unsigned char          uint8_t;   0 ~ 255
typedef unsigned short int     uint16_t;
typedef unsigned int           uint32_t;  0 ~ 2^32-1
typedef unsigned long int      uint64_t;  0 ~ 2^32-1

所以Redis会根据字符串的长度来选择合适的结构体

char与unsigned char的区别:

都是8bit
char最高位是符号位,范围是-128 ~ 127
而后者没有符号位 0 ~ 255
如果有符号位,在把它赋值给int、long时,进行符号扩展
如果没有符号位,就不用扩展
符号扩展:将扩展后的数据的高(32-n)位置置为原来的符号位
无符号位扩展:将扩展后的数据的高(32-n)位置置为0

SDS对比C语言字符串的优势:

1 快速获取字符串长度:
c以‘\0’来判断字符串是否结束,所以获取字符串长度需要O(n),
但是sds采用len属性记录当前的长度,所以是O(1)
sds为了兼容c字符串函数,仍以‘\0’结束
2 减少内存重新分配的次数
c每次增加或缩短字符串长度,都需要重新分配内存,
如果append时不重新分配内存,容易缓存区溢出
如果截断tirm操作后不重新分配内存来释放不用的空间,容易产生内存泄漏
但是SDS记录了已使用的空间和总空间大小,实现了空间预分配和惰性空间释放
解决了字符串拼接后额截取时的空间问题
3 二进制安全,可存放二进制图片
c的字符串的除了末尾以外,不能有空字符串,即‘\0’,否者认为字符串结束了
所以c字符串只能保存文本
sds以二进制的形式来处理存放在buf数组中的内容,以len属性来判断是否结束
所以SDS可以存放图像的二进制数据

2 字典:

SET msg "hello world

字典主要由两个哈希表实现,哈希表又是由数组+链表构成,
数组中每个元素指向一个链表
链表中每个节点是一个kv键值对
Redis使用字典来实现键值对和散列表(Hash)

结构图如下:

dict是字典最顶层的结构体,dicht就是表示哈希表的结构体
dicht结构体中的table属性,就是哈希表中的桶数组,指向了一个dictEntry数组
dictEntry就是哈希表中链表的节点,即kv键值对

1 dict

dict结构体是字典中最顶层的结构体,用于指向两个哈希表,
两个哈希表是为了在扩容时使用

typedef struct dict {
    // 和类型相关的处理函数
    dictType *type;
    // 私有数据
    void *privdata;
    // 哈希表
    dictht ht[2];
    // rehash 索引,当rehash不再进行时,值为-1
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    // 迭代器数量
    unsigned long iterators; /* number of iterators currently running */
} dict;

2 dicht

相当于哈希表中的桶数组

typedef struct dictht {
    // 哈希表数组
    dictEntry **table;
    // 哈希表大小
    unsigned long size;
    // 哈希表大小掩码,用于计算索引值,等于size-1
    unsigned long sizemask;
    // 哈希表已有节点的数量
    unsigned long used;
} dictht;

3 dictEntry

链表中的kv键值对

typedef struct dictEntry {
    // 键
    void *key;
    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    // 指向下一个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;
hash扩容:

1 将第二个哈希表即ht[1]的长度= len(ht[1]) * 2
2 将ht[0]上的数据rehash到ht[1]上,即重新计算哈希地址
3 释放ht[0],将ht[1]设置为ht[0],然后创建新的ht[1]

渐进式扩容:

当字典中数据到达百万级别时,上述扩容方式中的一次性rehash就无法运行,
而是多次将ht[0]中的数据rehash到ht[1],即每次只rehash一条链表上的数据
1 为ht[1]分配空间,并初始化一个索引计数器,初始值=0
2 每当redis访问或修改索引计算器对应链表时,就会把该链表上所有的键值对rehash到ht[1]中
然后将索引计数器+1,表示下一次要rehash的链表
3 经过一段时间后,ht[0]中的所有数据都转移到ht[1]上了,再把索引计数器赋值为-1

3 链表

链表的特点:

1 双向链表,获取头尾的复杂度为O(1)
2 无环
3 用len来记录长度,获取长度的复杂度为O(1)
4 多态:用void指针指向节点保存的值,可以保存任何类型的值

链表的定义:

是一个带头尾节点的双向链表

typedef struct listNode {
    // 前置节点
    struct listNode *prev;
    // 后置节点
    struct listNode *next;
    // 节点值
    void *value;
} listNode;


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;

链表结构图:

4 跳表:

调表的结构:
由多层链表构成,每层之间部分节点相连,且每层链表的数据都是有序的
最底层的链表是双向的,且包含所有元素,可实现类似二分查找,
在调表中的查找次数接近层数,时间复杂度为O(logn)
用随机化来决定哪些节点需要上浮

如下图中的L1是最原始的跳表,我们通过抛硬币的方式让部分节点上浮到L2中,
来构成一个新链表,再用同样的方式来生成新链表

跳表中的查找:

类似二分查找,从最顶层开始查找,大数向右查找,小数向左查找
如查找17,查找路径为 13 -> 46 -> 22 -> 17
查找35 13 -> 46 -> 22 -> 46 -> 35
查找 54 13 -> 46 -> 54

跳表中的插入:

1 向在最底层链表中找到合适的位置,并插入
2 然后用抛硬币的方式决定它是否要要上浮
加入此时链表共3层,需要抛两次硬币,来决定它上浮几次

跳表的实现:

1 使用zskipListNode来保存跳表中每个节点

/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
    // 成员对象 (robj *obj;) 指向一个sds
    sds ele;
    // 分值 用于实现zset
    double score;
    // 后退指针 指向前一个节点
    struct zskiplistNode *backward;
    // 层
    struct zskiplistLevel {
        // 前进指针
        struct zskiplistNode *forward;
        // 跨度
        // 跨度实际上是用来计算元素排名(rank)的,在查找某个节点的过程中,将沿途访过的所有层的跨度累积起来,得到的结果就是目标节点在跳跃表中的排位
        unsigned long span;
    } level[];
} zskiplistNode;

2 使用zskiplist结构体来保存当前调表的整体信息

typedef struct zskiplist {
    // 表头节点和表尾节点
    struct zskiplistNode *header, *tail;
    // 表中节点的数量
    unsigned long length;
    // 表中层数最大的节点的层数
    int level;
} zskiplist;

跳表与平衡树、哈希表的比较

1 skiplist和各种平衡树(如AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。
因此,在哈希表上只能做单个key的查找,不适宜做范围查找。所谓范围查找,指的是查找那些大小在指定的两个值之间的所有节点。
2 在做范围查找的时候,平衡树比skiplist操作要复杂。
在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。
如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。
而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。
3 平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,
而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。
4 从内存占用上来说,skiplist比平衡树更灵活一些。
一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。
5 查找单个key,skiplist和平衡树的时间复杂度都为O(log n),大体相当;
而哈希表在保持较低的哈希值冲突概率的前提下,查找时间复杂度接近O(1),性能更高一些。
所以我们平常使用的各种Map或dictionary结构,大都是基于哈希表实现的。
从算法实现难度上来比较,skiplist比平衡树要简单得多。

Redis中的数据类型

1 string

基于SDS实现
用于计数:粉丝数
还可用于保存用户的session,前提是保证redis的高可用

增加/减小数字 incr/decr/incrby/decrby
获取指定区间范围内的值  getrange start end  闭区间
给指定位置或范围复制 setrange 
当key不存在时才富新值 setnx key value
批量赋值和取值  mset/mget/msetnx
  注意:在msetnx中,只要有一个key有value,整个赋值操作都不会被执行
先获取旧值再赋新值  getset

2 list:

基于双向链表实现,修改list中间的数据时,时间复杂度较高
适用于粉丝列表 消息列表

     可以使用lrange key start index来实现高性能分页
可以用于实现消息队列 brpop阻塞式地从表尾取出数据
从左/右边插入数据 lpush  rpush
从左边/右边出一个数据  lpop / rpop  
从左到右遍历数据 lrange 0 -1

根据索引取值 lindex key index
根据索引赋值 lset key index value  (索引不能超出范围))
在指定元素的前后插入元素 linsert key before/after pivot value
阻塞式地从表尾取出数据 brpop key

求长度 llen
删除多个相同的value:  lrem key count element
截取list 闭区间  ltrim key start end
从源list的末尾删除一个元素并插入目标list的头部  rpoplpush source target

3 set

基于字典实现
功能与list类似,但可以自动去重 ,但无序
可以用set来求交集,如共同关注、共同粉丝、共同喜好
使用srandommember来获取随机数
相对于jvm的HashSet,redis的set可以实现全局去重

创建一个set: sadd values
遍历set   smembers  key
判断某个values是否存在 sismemeber key value

获取set中元素个数 scard key
删除set中的指定元素 srm key value
从set中随机提取指定个数的元素 srandmemeber key number
随机出栈元素 spop key
移动元素到其它set  smove source target value

差集/交集/并集 sdiee/sinter/sunion
保存两个set的交集到key1  sinterstore key1 key2 key3
差集就是存在第一个set但不在第二个set中的元素的集合

4 Hash

基于字典实现
适用于存储对象,前提是这个对象没有嵌套其它对象

给对象的属性赋值 hset key field value
获取对象中的某个属性 hget key field
批量赋值/取值 hmset/hmget

删除对象属性 hdel key field

对象的属性个数 hlen key
判对象中是否在某个属性 hexists key fiels 
获取对象的所有属性以及对应的value hgetall key
获取对象的所有属性 hkeys key

获取对象的所有value  hvals key
增长整型或浮点型value   hincrby/hincrbyfloat key value number
给不存在的属性赋值 hseetnx key field value

5 zset:

基于跳表实现
无重复值且有序的集合,默认按分数递增排序
每个value由两部分构成 真实值和对应的分数
适用于各种排行榜信息,如文章访问量排行榜
带权重的列表

添加内容 zadd key score1 member1 score2 member2
遍历zset   zrange key  withscores
           zrange key 0 -1 -withscores
指定分数范围来遍历zset  zrangebyscore key min max   在分数前加"("表示开区间,默认为闭区间
逆序遍历指定范围scoer的元素zrevrangebyscore key max min
提取排名前n的元素 zrevrange key 0 n-1

删除某个元素 zrem key member
获取元素个数 zcard key
获取分数区间内的元素个数 zcount key min max

获取memeber对应的排名 zrank key member
逆序获取memeber对应的排名 zrevrank key meember
获取memeber对应的分数  zscore key
使用Redis实现文章访问量排序:
把文章id和访问量存入zset  zadd paper 10000 p1  8000 p2    9000 93
按访问量从高到低排序  zrevrange paper 0 -1 withscores
查看指定访问量的文章并排序  ZREVRANGEBYSCORE paper 10000 9000

转载:https://blog.csdn.net/u012712556/article/details/106178568
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场