使用redis的数据结构做操作的时候,应该时刻注意操作数据结构的方法的时间复杂度
redis 的命令行使用可以参考菜鸟教程,redis 默认接口是6379
底层数据结构是 redis 实现各种功能的方式,它们大多被封装成各个功能
key 与 value 的底层数据结构一般都是简单动态字符串(SDS,Simple Dynamic String)实现的,而不是c语言实现的字符串,SDS 类似 java 中的 ArrayList,当值的空间存满了的时候这个字符串会进行自动扩容(1M之下会扩容2倍,1M以上会每次加1M)
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; // 表示该数组总共占用多少空间
uint8_t alloc; // 表示该数组的空闲空间有多少
unsigned char flags;
char buf[]; // 存放数据的数组
};
SDS 一共有五种结构,方便是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,用 flags 来表示
SDS 有以下特性:
ziplist 压缩列表是列表键与哈希键的底层实现之一,所有的节点在物理空间中被紧紧压在一起,以减少碎片空间增加空间利用率。在逻辑上对应 java 的 ArrayList
压缩列表主要关注列表的构成、列表节点的构成以及压缩列表可能造成的连锁更新问题。Redis 使用字节数组表示一个压缩列表,字节数组逻辑划分为多个字段,先来看看列表的结构
以下是压缩列表节点的构成:
previous_entry_length:这个属性记录了压缩列表前一个节点的长度,该属性根据前一个节点的大小不同可以是1个字节或者5个字节。这个特性是连锁更新的罪魁祸首之一,但是该特性也是实现从表尾遍历到表头的原理
如果前一个节点的长度小于254个字节,那么previous_entry_length的大小为1个字节,即前一个节点的长度可以使用1个字节表示
如果前一个节点的长度大于等于254个字节,那么previous_entry_length的大小为5个字节,第一个字节会被设置为0xFE(十进制的254,这也是特殊标记,redis 读到这个254时就知道该节点是5字节的了),之后的四个字节则用于保存前一个节点的长度
encoding:通过一些特定的编码方式来表示该节点记录的是字节数组还是整数
content:该属性负责保存节点的值,节点值可以是一个字节数组或者一个整数
连锁更新在一般的业务情况下不太影响性能,因为高时间复杂度的连锁更新操作出现的条件极其严格
当数组中都是253字节的节点时,向列表头部添加一个大于254字节的节点,此时下一个节点的previous_entry_length更新为5字节,此时下一个节点也大于254字节了,因此造成了连锁反应
在最坏情况下,会从插入位置一直连锁更新到末尾,即执行了N次空间重分配, 而每次空间重分配的最坏复杂度为 O(N) , 所以连锁更新的最坏复杂度为 O(N^2)。注意,是最坏
单键多值,是一个链表数据结构,链表键的底层实现之一
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
很简单对不对,但是单个的链表节点是无法形成链表的
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;
这是一个正常的链表结构,为了方便用户包含了头尾指针以及节点个数,还添加了节点操作函数
字典这种数据结构在 redis 中被大量使用,除了用来表示数据库之外,字典还是hash 键的底层实现之一,当一个 hash 键包含的键值对比较多,又或者键值对中的元素是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现
字典由哈希表、字典与哈希表节点构成,哈希表结构几乎和 java 中的 hashmap 一模一样,通过一个数组来存放节点,同时为了管理方便,在中间添加了大小、装了多少数据等属性
typedef struct dictht{
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
unsigned long sizemask;
//该hash表已有节点数量
unsigned long used;
}
在节点中使用拉链法来解决哈希冲突,键值对中的值可以是一个整数,也可以是一个指针,而键则是一个指针
typedef struct dictEntry{
void *key;
// 值
union{
void *val;
uint_64_tu64;
int64_ts64;
} v;
// 指向下一个哈希节点
struct dictEntry *next;
}dictEntry;
但是就靠这两种结构是无法组成哈希的,哈希表需要解决扩容问题,因此 redis 又将表封装了一层
typedef struct dict{
//类型特定函数
dictType *type;
//私有数据
void *privdata;
//哈希表
dictht ht[2];
//rehash索引
//当rehash不在进行时,值为-1
int trehashidex
}
type 属性和 privdata 是针对不同类型的键值对,为创建多态准备的
ht 与 trehashidex 则是为了实现扩容准备的,ht 表示两个哈希表,rehash 时可以从一个表将数据复制到另外一张表中,同时 redis 中采用渐进式 rehash,trehashidex 是一个计数器。拓展和收缩哈希表的工作可以通过执行 rehash 操作来完成,进行 rehash 的步骤如下:
1,为字典的ht[1]哈希表分配空间,这个哈希表空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也即是ht[0].used属性的值)
2,将保持在ht[0]中所有键值对慢慢的rehash到ht[1]上面,rehash指的是重新计算hash值和索引值,然后将键值对放置到ht[1]哈希表指定位置上。这个慢慢的是指 redis 中可能存放过多数据,不可能一次性 rehash,那样时间复杂度太大。因此每次删改查操作是都会将一个位置的数据 rehash 到ht[2]上
3,当ht[0]包含的所有键值对都迁移到ht[1]之后,释放ht[0],当ht[1]设置为ht[0],并在ht[1]新创建空白哈希表,为下一次hash做准备
渐进式 rehash 有以下特点:
跳表是已经给数据排序并且可以快速查找的链表,它的实现是在原来的链表上加了多级索引,每个索引节点包含两个指针,一个向下,一个向右,最低层包含所有的元素。跳表是有序集合
跳表在插入数据时会根据一个随机函数得到该数据的层数,并且搜索到对应位置进行插入,插入时小于该分值的上一个节点都需要进行修改
跳表其实类似给链表建立索引,它的查找时间复杂度近似平衡二叉树
查找时从头节点的最高层开始查找,如果当前层数的下一个数据比需要查找的数据大,进入该节点下一层尝试进行查找,循环反复直到找到
redis 用两个结构来实现跳表,一个是 zskiplist。指向的头节点必定为32层,并且不包含数据, 最大节点数则方便确定头节点应该在哪一层开始查找
typedef struct zskiplist {
// 头节点,尾节点
struct zskiplistNode *header, *tail;
// 节点数量
unsigned long length;
// 目前表内节点的最大层数
int level;
} zskiplist;
zskiplistNode 就是实现跳表的节点。每个节点都有一个分值来确定该节点的大小,如果大小一样的分值则使用成员对象的值来确定大小
typedef struct zskiplistNode {
// member 对象
robj *obj;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 这个层跨越的节点数量
unsigned int span;
} level[];
} zskiplistNode;
整数集合是集合键的实现之一,当一个集合只有整数元素,并且元素数量不多是就会使用整数集合,其实现相当简单,只有一个结构体
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
整数集合的每个元素都是 contents 数组的一个数组项(item), 各个项在数组中按值的大小从小到大有序地排列, 并且数组中不包含任何重复项
唯一需要着重说的是整数集合的升级优化。encoding 编码方式决定了数组存放了什么数据,每当我们要将一个新元素添加到整数集合里面, 需要比较新元素的类型是否比整数集合现有所有元素的类型都要长,如果是,则整数集合需要先进行升级(upgrade), 然后才能将新元素添加到整数集合里面。升级就是将原来数组中的所有占用低字节的数据转换为多字节的数据。比如将32位转为64位
整数集合越升级就能存放范围越大的数据,同时,因为新插入数据的范围大,因此插入的不是数组的最左边就是数组的最右边。在插入时不会新建一个数组来存放数据,而是会在该数组后面新增一块储存空间并且使用尾插法转换
整数集合不支持降级操作
quicklist依赖于ziplist和linkedlist来实现,它是两个结构的结合。它将ziplist来进行分段存储,也就是分成一个个的quicklistNode节点来进行存储。每个quicklistNode指向一个ziplist,然后quicklistNode之间是通过双向指针来进行连接的
quicklist一般两边结点为ziplist,中间结点叫quicklistZF,中间部分节点是ziplist进一步压缩形成的
redis是使用c语言编写的,在了解具体数据结构之前,先来看看它大体的储存数据结构
typedef struct redisDb {
int id; //id是数据库序号,为0-15(默认Redis有16个数据库)
long avg_ttl; //存储的数据库对象的平均ttl(time to live),用于统计
dict *dict; //存储数据库所有的key-value
dict *expires; //存储key的过期时间
dict *blocking_keys;//blpop 存储阻塞key和客户端对象
dict *ready_keys;//阻塞后push 响应阻塞客户端 存储阻塞后push的key和客户端对象 dict *watched_keys;//存储watch监控的的key和客户端对象
} redisDb;
Redis 默认会创建 16 个数据库,每个数据库互不影响
dict 用来维护一个 Redis 数据库中包含的所有 Key-Value 键值对,它就是传说中的键空间。它的键是字符串,它的每个值可以是字符串对象、列表对象、哈希表对象、集合对象和有序集合对象中的任意一种 Redis 对象
我们通过命令可以直接调用以下的对象
底层数据结构是双向链表,类似 java 中的,如果在数据量比较少的情况下,会分配一个连续的内存空间 ziplist(减少内存碎片)ziplist 被设计为各个数据项挨在一起组成连续的内存空间,这种结构并不擅长做修改操作
数据量较多时,会将多个 ziplist 连接成一个 quicklist
使用场景:发布订阅(这是一种消息的通信模式,服务器可以发布多条消息,客户端可以订阅多个频道,使用命令来实现)
普通的hash,hash特别适合用于存储对象
在数据量较少的时候使用ziplist,数据量较多的时候使用字典dict(因为此时使用ziplist读写效率会降低),有些人将其叫做hashtable,它的原理和java中的HashMap差不多
使用场景:储存用户信息,存储对象
键为对象,值为true或false
一byte有8字节,而bitmap的值只有一字节,使用这个极大提高了内存利用率
应用场景:用户是否签到
无序不可重复集合
关于底层数据结构的实现,当数据量较少(小于512个),并且存放的都是整数的时候,它会使用整数集合(intset)来储存,整数集合中使用数组来存放数据,数据没有重复
当不同时满足这两个条件的时候,Redis 就使用dict(字典)来存储集合中的数据,具体实现也和java中HashSet实现一样,字典的每个键都是一个字符串对象,同时每个值都被设成了 null
应用场景:储存无序不可重复的数据时使用
可以排序的set集合,又称有序集合,保证了内部 value 的唯一性,另方面它可以给每个 value 赋予一个score,代表这个value的排序权重
当有序集合保存的元素个数小于128个,且所有元素成员长度都小于64字节时,使用 ziplist
有序集合会使用压缩列表作为底层实现,每个集合元素使用两个紧挨着一起的两个压缩列表节点表示,第一个节点保存元素的成员(member),第二个节点保存元素的分值(score)
否则,使用skiplist+字典dict的方式实现(字典的键为元素,值为score),其中字典用来根据数据查找对应的分数,而跳表用来根据分数查询数据(由于多个数据会有相同的分数,因此可能是范围查找)
应用场景:直播间礼物排行榜