Redis详解-基础篇

Redis详解-基础篇

数据模型

redis是KV的数据库,最外层采用了hashtable进行实现。(我们把这个最外层的哈希)。每一个键值对都是一个dictEntry,通过指针指向key的存储结构和value的存储结构,而且next存储了指向下一个键值对的指针。

typedef struct dictEntry{
    void *key;/*key关键词定义*/
    union{
        void *val;/*value定义*/
        uint64_t u64;
        int64_t s64;
        double d;
    }v;
    struct dictEntry *next;/*指向下一个键值对节点*/
}dictEntry;

最外层的是redisDb,redisDb里面存放的是指向所有键值对的dict指针,指向设置了过期时间的键值对的expires指针等等。

以指令set hello world为例:

image-20211012141050513

因为key为hello,是一个字符串,所以会有一个dictEntry其中的key指向一个值为hello的SDS结构。对于value,同样是一个字符串,redis并没有直接使用SDS存储,而是存储在redisObject中,其中的指向对象实际的数据结构的指针ptr指向了一个值为world的SDS结构。实际上5种常用的数据类型的任何一种的value,都是通过redisObject来存储的。

image-20211012141348372

redisObject结构为:

typedef struct redisObject{
unsigned type:4;/*对象的类型*/
unsigned encoding:4;/*具体的数据结构*/
unsigned lru:LRU_BITS;/*24位,对象最后一次被命令程序访问的时间,与内存回收有关*/
int refcount;/*引用计数,当值为0时,表示该对象已经不被任何对象引用,则可以进行垃圾回收了*/
void *ptr;/*指向对象实际的数据结构*/
}robj;

那为什么要多加一层redisObject进行包装呢?

redis中的存储都是根据存储的不同内容选择不同的存储方式,这样可以实现根据对象的类型动态地选择存储结构和可以使用的命令,并且尽量地节省内存空间和提升查询速度。


String 类型

使用String类型的命令,虽然对外都是String,但是在内部拥有三种不同类型的编码。(从上图种也可以看到redisObject中的编码为:OBJ_ENCODING_RAW。

  • int,存储8个字节的长整型(long,2^63-1)
  • embstr,代表embstr格式的SDS,存储小于44个字节的字符串
  • raw,存储大于44个字节的字符串

embstr的使用只分配一次内存空间(因为RedisObject和SDS是连续的),而raw需要分配两次内存空间(分别为RedisObject和SDS分配空间)。因此embstr的好处在于创建时比raw少分配一次空间,删除时比raw少释放一次空间,以及对象的所有数据连在一起,寻找方便。

image-20211012151916995

而embstr的弊端也很明显,如果字符串的长度增加需要重新分配内存时,整个RedisObject和SDS都需要重新分配空间,因此Redis中的embstr实现为只读(这种编码的内容是不能修改的)。

int、embstr、raw相互转换的情况:

  1. int数据不再是整数,会转成raw
  2. int大小超过了long的范围(2^63-1),会转成embstr
  3. embstr长度超过了44个字节,会转成raw
  4. 修改embstr时,由于它的实现是只读的,所以会先转成raw再进行修改。因此只要是修改embstr对象,修改后一定是raw的,无论是否达到了44个字节

当编码转换后,整个过程不可逆,只能从小内存编码向大内存编码进行转换,但是不包括重新set

SDS

redis中字符串的实现,Simple Dynamic String 简单动态字符串。

结构为:

struct_attribute_((_packed_))sdshdr8{
uint8_t len;/*当前字符串长度*/
uint8_t alloc;/*当前字符数组总共分配的内存大小*/
uint8_t free;/*记录未使用字节的数量*/
unsigned char flags;/*当前字符数组的属性,用来标识到底是sdshdr8还是sdshdr16等*/
char buf[];/*字符串真正的值*/
};

其本质还是字符数组,SDS具有多种结构:sdshdr5,sdshdr8,sdshdr16......用于存储不同长度的字符串,分别代表25=32byte,28=256byte......

为什么要使用SDS实现字符串:

因为C语言本身没有字符串类型,只能用字符数组char[]实现。

  • 使用字符数组必须献给目标变量分配足够的空间,否则可能会溢出
  • 如果要获取字符长度,必须遍历字符数组,时间复杂度是O(n)
  • C字符串长度的变更会对字符数组做内存重分配
  • 通过从字符串开始到结尾碰到的第一个'\0'来标记字符串的结束,因此不饿能保存图片,音频,视频,压缩文件等二进制bytes保存的内容,二进制不安全。

那么使用了SDS实现字符串后具有的优势为:(SDS的特点)

  • 不用担心内存溢出的问题,因为如果有需要就会对SDS进行扩容
  • 获取字符串长度时间复杂度为O(1),因为在SDS结构中定义了len属性来实时记录字符串长度。
  • 通过“空间预分配”和“惰性空间释放”,防止多次重分配内存
  • 判断是否结束的标志是len属性,可以包含'\0'(它同样以'\0'结尾是因为这样就可以使用C语言中函数库操作字符串的函数了)

空间预分配:空间预分配用于优化 SDS 的字符串增长操作: 当 SDS 的 API 对一个 SDS 进行修改, 并且需要对 SDS 进行空间扩展的时候, 程序不仅会为 SDS 分配修改所必须要的空间, 还会为 SDS 分配额外的未使用空间。当修改SDS之后,其len属性值小于1MB,那么就是分配和len属性同样大小的未使用空间,这时len与free属性的值相同。SDS的buf数组的实际长度将变成len+free+1 (额外的一字节用于保存空字符)。当修改SDS之后,其len属性值大于等于1MB,那么程序会分配1MB的未使用空间,buf数组的长度会变成len+1MB+1byte(额外的一字节用于保存空字符)。这样做可以减少连续执行字符串增长操作所需的内存重分配次数。

惰性空间释放:惰性空间释放用于优化 SDS 的字符串缩短操作: 当 SDS 的 API 需要缩短 SDS 保存的字符串时, 程序并不立即使用内存重分配来回收缩短后多出来的字节, 而是使用 free 属性将这些字节的数量记录起来, 并等待将来使用。

使用场景

  1. 缓存:缓存热点数据,如网站首页,报表数据等等,提升热点数据的访问速度。
  2. 分布式数据共享:例如:分布式Session,分布式锁,全局id(使用int编码类型,INCRBY,利用原子性,incrby userid 1000,分库分表的场景,一次性拿一段)
  3. 计数器:int类型编码,INCR方法,例如:文章的阅读量,微博点赞数。允许一定的延迟,先写入redis再定时同步到数据库
  4. 限流:int编码类型,INCR方法,例如:以访问者的ip和其他信息作为key,访问一次增加一次计数,超过次数则返回false

Hash 类型

Hash用来存储多个无序的键值对,最大存储数量为2^32-1(40亿左右)。

结构为:

image-20211012154244923

前面所说的使用dictEntry实现的为外层hash,现在讲的是内层的hash

Hash的value只能是字符串,不能嵌套其他类型,比如hash或者list。

Hash类型与String类型的主要区别:

  1. 把所有相关的值全部聚集到一个key中,节省内存空间
  2. 只使用一个key,减少key冲突
  3. 当需要批量获取值的时候,只需要使用一个命令,减少内尺寸/io/cpu的消耗
  4. hash中key不能设置单独的过期时间
  5. 需要考虑数据量分布的问题(key数量特别多的时候,无法分布到多个节点)

存储实现原理:

内层Hash底层可以使用两种数据结构实现:

ziplist:OBJ_ENCODING_ZIPLIST(压缩列表)

hashtable:OBJ_ENCODING_HT(哈希表)

ziplist压缩列表

是一个经过特殊编码的,有连续内存组成的双向链表。存储上一个节点长度和当前节点长度。虽然读写会慢一些,因为要去计算长度,但是可以节省内存,是时间换空间的思想。

image-20211012202230809

其中的entry结构为:

typedef struct zlentry{
unsigned int prevawlensize;/*存储上一个链表节点的长度数值所需要的字节数*/
unsigned int prevrawlen;/*上一个链表节点占用的长度*/
unsigned int lensize;/*存储当前链表节点长度数值所需要的字节数*/
unsigned int len;/*当前链表节点占用的长度*/
unsigned int headersize;/*当前链表节点的头部大小(prevrawlensize+lensize),即非数据域的大小*/
unsigned char encoding;/*编码方式*/
unsigned char *p;/*压缩链表以字符串的形式保存,该指针指向当前节点起始位置*/
}zlentry;

结构图:

image-20211012202930014

编码种类:

  1. #define ZIP_STR_06B(0<<6) 长度小于等于63字节
  2. #define ZIP_STR_14B(1<<6) 长度小于等于16383字节
  3. #define ZIP_STR_32B(2<<6) 长度小于等于4294967295字节

使用条件:

同时满足以下条件:

  • 保存对象的键值对数量 < 512个
  • 所有的键值对的健和值的字符串长度都 < 64byte(一个英文字母一个字节)

在src/redis.conf中可配置:

image-20211012203727049

如果超过这两个阈值中的任何一个,存储结构就会转换成hashtable

hashtable(dict)

在Redis中,hashtable被称为字典(dictionary),在hashtable中,又对dictEntry进行了多层封装。

底层结构:

dictEntry:

image-20211012203959630

然后hashtable将这个dictEntry放置到了dictht(hashtable里面):

image-20211012204102499

最后将dictht放到了dict中:

typedef struct dict{
dictType *type;/*字典类型*/
void *privdata;/*私有数据*/
dictht ht[2];/*一个字典有两个哈希表*/
long rehashidx;/*rehash索引*/
unsigned long iterators;/*当前正在使用的迭代器数量*/
}dict;

小结一下:从最底层到最高层为:dictEntry - dictht - dict。所以hashtable是一个数组+链表的结构。

image-20211012204450718

为什么要定义两个哈希表(两个dictht),其中一个不用呢?

redis的hash默认使用的是ht[0],而ht[1]不会初始化和分配空间。dictht是用连地址法来解决碰撞问题的,这种情况下,哈希表的性能取决于它的大小(size)和它所保存的节点数量(used)之间的比率:

  • 如果为1:1,也就是每个 dictEntry[]中只存一个dictEntry时,性能最好。
  • 如果节点数量比哈希表的大小要大很多的话,那么哈希表就会退化成多个链表,哈希表本身的优势就不存在(比例使用ratio表示,一个哈希表中存入的节点数是哈希表长度的5倍时就会退化,根据扩容因子决定:static unsigned int dict_force_resize_ratio = 5)。

如果单个哈希表的节点数量过多,哈希表的大小就需要扩容,这种操作就叫rehash。因子小于0.1时,程序开始对哈希表执行收缩操作。

渐进式rehash:

扩展或收缩哈希表需要将ht[0]里面的所有键值对rehash到ht[1] 里面,但是,这个rehash动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的。

Redis对字典的哈希表执行rehash的步骤如下:

  • 为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以 及ht[0]当前包含的键值对数量(也即是ht[0].used属性的值):

​ 1、如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2

的2"(2的n次方幂)。比如已经使用5000,那就是16384。

​ 2、如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2"(2的n次方幂)。

  • 将保存在ht[0]中的所有键值对rehash到ht[1]上面: rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]晗希表的指定位置上。

  • 当ht[0]包含的所有键值对都迁移到了ht[1)之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次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的好处在于它采取分而治之的方式,将 rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式 rehash而带来的庞大计算量。

因为在进行渐进式rehash的过程中,字典会同时使用ht[0]和ht[l]两个哈希表,所以在渐进式rehash进行期间,字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行。例如,要在字典里面查找一个键的话,程序会先在ht[0]里面进行查找,如果没找到的话,就会继续到ht[l]里面进行查找,诸如此类。

另外,在渐进式rehash执行期间,新添加到字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表。

使用场景

  1. 购物车:key:id,field:商品id,value:商品数量

    +1:hincr -1:hdecr 删除:hincrby key field -1

    全选:hgetall 商品数:hlen


List 类型

用来存储有序的字符串(从左到右),元素可以重复。最大存储数量2^32-1(40亿左右)

结构为:

image-20211012225036206

存储实现原理:

版本3.2之前,数据量较小的时候使用ziplist存储(压缩列表,有特殊编码的双向链表),到达临界值时转换为linkedlist进行存储,分别对应OBJ_ENCODING_ZIPLISTOBJ_ENCODING_LINKEDLIST

考虑到链表的附加空间相对太高,prev 和 next 指针就要占去 16 个字节 (64bit 系统的指针是 8 个字节),另外每个节点的内存都是单独分配,会加剧内存的碎片化,影响内存管理效率。版本3.2之后,统一使用quicklist来存储。quicklist存储了一个双向链表,每个节点都是一个ziplist,所以是ziplist和linkedlist的结合体。

quicklist

总体结构:(本质上是数组+链表)

image-20211012230001034

image-20211012230100216

image-20211012230403268

image-20211012230955021

插入操作:

  • 当插入位置所在的ziplist大小没有超过限制时,直接插入到ziplist中就好了;
  • 当插入位置所在的ziplist大小超过了限制,但插入的位置位于ziplist两端,并且相邻的quicklist链表节点的ziplist大小没有超过限制,那么就转而插入到相邻的那个quicklist链表节点的ziplist中;
  • 当插入位置所在的ziplist大小超过了限制,但插入的位置位于ziplist两端,并且相邻的quicklist链表节点的ziplist大小也超过限制,这时需要新创建一个quicklist链表节点插入。
  • 对于插入位置所在的ziplist大小超过了限制的其它情况(主要对应于在ziplist中间插入数据的情况),则需要把当前ziplist分裂为两个节点,然后再其中一个节点上插入数据。

应用场景

List主要用在存储有序内容的场景。

  1. 列表:用户的消息列表,网站的公告列表,活动列表,评论列表等
  2. 队列/栈:List可以当作分布式环境的队列/栈使用。List提供了两个阻塞的弹出操作:BLPOP/BRPOP,可以设置超出时间(单位:秒)

BLPOP: BLPOP key1 timeout 移出并获取列表的第一个元素,如果列表没有元素会阻塞列表知道等待超时或发现可弹出元素为止。

BRPOP:BRPOP key1 timeout移出并获取列表的最后一个元素,如果列表没有元素会阻塞直到等待超时或发现可弹出元素为止。


Set 类型

存储String类型的无序集合,最大存储数量为2^32-1(40亿左右)。

存储实现原理:

redis使用intset或hashtable存储set。如果所有元素都是整数类型,就用intset存储。

intset源码:

typedef struct intset{
uint32_t encoding;//编码类型int16_t,int32_t,int64_t
uint32_t length;//长度 最大长度2^32
int8_t contents[];//用来存储成员的动态数组
}intset;

如果不是整数类型或者元素个数超过512个,就用hashtable进行存储。(数组+链表,key用来存数据,value存null)这个元素个数与设定的配置:set_max_intset_entries 512有关。

应用场景

  1. 抽奖:随机获取元素:spop xxx
  2. 点赞、签到、打卡:微博id是t1001,用户id是u3001

点赞了这条微博:sadd like:t1001 u3001

取消点赞:srem like:t1001 u3001

是否点赞:sismember like:t1001 u3001

点赞的所有用户:smembers like:t1001

点赞数:scard like:t1001

  1. 商品标签
  2. 商品筛选
//获取差集
sdiff set1 set2
//获取交集
sinter set1 set2
//获取并集
sunion set1 set2
  1. 用户关注、推荐模型

ZSet 有序集合

存储有序的元素,每个元素有个score,按照score从小到大排名。score相同时,按照key的ASCII码顺序排序。

存储实现原理:

默认使用ziplist编码,内部按照score排序递增来存储。插入的时候要移动之后的数据。如果元素数量大于等于128个,或者任何一个member长度大于等于64字节则使用skiplist+dict存储。其个数与配置相关:

zset-max-ziplist-entries 128
zset-max-ziplist-value 64

SkipList 跳表

源码为:

typedef struct zskiplistNode{
	sds ele;/*zset的元素*/
    double score;/*分值*/
    struct zskiplistNode *backward;/*后退指针*/
    struct zskiplistLevel{
        struct zskiplistNode *forward;/*前进指针,对应level的下一个节点*/
        unsigned long span;/*从当前节点到下一个节点的跨度(跨越的节点数)*/
    }level[];/*层*/
}zskiplistNode;

typedef struct zskiplist{
    struct zskiplistNode *header,*tail;/*指向跳表头尾的节点*/
    unsigned long length;/*跳跃表的节点数*/
    int level;/*最大的层数*/
}zskiplist;

typedef struct zset{
    dict *dict;
    zskiplist *zst;
}zset;

插入一个元素的时候,决定要放到哪一层,取决于一个算法:

int zslRandomLevel{
	int level =1;
	while((random()&0xFFFF)<(ZSKIPLIST_P * 0xFFFF))
		level+=1;
	return (level<ZSKIPLIST_MAXLEVEL)?level:ZSKIPLIST_MAXLEVEL;
}

查找数据时,可以先沿着这个新链表进行查找,当碰到比待查数据大的节点时,再到下一层进行查找。

image-20211013125718861

为什么不用AVL树或者红黑树?

因为使用skiplist更加简洁。

应用场景

顺序会动态变化的列表。

  • 排行榜:例如百度热榜等

id为6001的新闻点击数+1:zincrby hotNews:20251111 1 n6001

获取今天点击最多的15条:zrevrange hotNews:20251111 0 15 withscores


BitMaps 类型

在字符串类型上面定义的位操作。

image-20211013130221037

应用场景

因为bit十分节省空间,所以可以用来做大数据量的统计。

  • 在线用户统计
  • 留存用户统计

Hyperloglogs 类型

提供了一种不太精确的基数统计方法,用来统计一个集合中不重复的元素个数,比如统计网站的UV,或者应用的日活,月活,存在一定的误差。

在Redis中实现的HyperLogLog,只需要使用12k内存就可以统计2^64个数据。


Geo 类型

增加地址位置信息、获取地址位置信息、计算两个位置的距离、获取指定范围内的地理位置集合等等


Streams 类型

5.0版本推出的数据类型,支持多播的可持久化的消息队列,用于实现发布订阅功能,借鉴了kafka的设计。

posted @ 2021-10-13 13:21  会编程的老六  阅读(106)  评论(0编辑  收藏  举报