redis五种数据类型的应用场景_redis底层数据结构和常见应用场景

    常用的Redis数据类型包含了String、Hash、Set、List、Zset这五种,自2.8.9增加了Hyperloglog,3.2增加了Geo、5.0又新增了Streams。本文重点讨论常用的五种数据类型底层的数据结构。

String的数据结构

Redis最基本也是最常用的数据类型就是String类型了,可以用来存储字符串、整数、浮点数。

Redis 通过 hashtable 实现的,所以每个键值对都会有一个 dictEntry,里面指向了 key 和 value 的指针。next 指向下一个 dictEntry。

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

Redis是基于C语音开发的K,V数据库,数据结构中的字符串并没有直接使用 C 的字符数组,使用自定义的 SDS存储(Simple Dynamic String) ,key就是使用SDS存储。value 则存储在 redisObject 中。前文所述的五种常用的数据类型的任何一种,都是通过 redisObject 进行存储的。

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

字符串类型的内部编码有三种:1、int,存储 8 个字节的长整型(long,2^63-1)。2、embstr, 代表 embstr 格式的 SDS,存储小于 44 (3.2 之前是 39 )个字节的字符串。3、raw,存储大于 44(3.2 之前是 39 ) 个字节的字符串。

为什么是39和44。

请看作者在提交中的说明

REDIS_ENCODING_EMBSTR_SIZE_LIMIT set to 39.The new value is the limit for the robj + SDS header + string +null-term to stay inside the 64 bytes Jemalloc arena in 64 bitssystems.

在3.2之后对sds进行了内存优化,将原来的sdshdr改成了sdshdr16,sdshdr32,sdshdr64,unsigned int 变成了uint8_t,uint16_t...(还加了一个char flags),针对短字符串的embstr使用最小的sdshdr8,其能容纳的字符串长度增加了5个字节变成了44.

为什么 Redis 要用 SDS 实现字符串

C  字符串组SDS
获取字符串长度的复杂度为 O(N)获取字符串长度的复杂度为 O(1)
API 是不安全的,可能会造成缓冲区溢出API 是安全的,不会早晨个缓冲区溢出
修改字符串长度N次必然需要执行N次内存重分配修改字符串长度N次最多需要执行N次内存重分配
只能保存文本数据可以保存文本或者二进制数据
可以使用所有库中的函数可以使用一部分库中的函数

BitMaps

Bitmaps 是在字符串类型上面定义的位操作。一个字节由 8 个二进制位组成。

主要应用场景包括用户访问统计和在线用户统计。

应用场景

1、热点数据缓存;

2、数据共享:如分布式Session缓存;

3、分布式锁(不建议):使用setnex命令,只有不存在才能添加成功

4、全局ID:使用int类型的incrby的原子性

5、计数器、限流等。

Hash的数据结构

相当于Java中的HashMap,可以将相关的值聚集存储在一起,节省内存空间。存储 hash 数据类型时,常称为内层的哈希。内层的哈希底层可以使用两种数据结构实现:1、ziplist:OBJ_ENCODING_ZIPLIST(压缩列表)2、hashtable:OBJ_ENCODING_HT(哈希表)

ziplist 压缩列表

/* ziplist.c 源码头部注释 */The ziplist is a specially encoded dually linked list that is designed to be very memory efficient. It stores both strings andinteger values, where integers are encoded as actual integers instead of a series of characters. It allows push and popoperations on either side of the list in O(1) time. However, because every operation requires a reallocation of the memoryused by the ziplist, the actual complexity is related to the amount of memory used by the ziplist.

ziplist 是一个经过特殊编码的双向链表,它不存储指向上一个链表节点和指向下一个链表节点的指针,而是存储上一个节点长度和当前节点长度,通过牺牲部分读写性能,来换取高效的内存空间利用率,是一种时间换空间的思想。只用在字段个数少,字段值小的场景里面。

ziplist 的内部结构采用如下结构存储

9823d190752316e5b04d7ae56a3a2a22.png

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

 prevrawlensize字段表示前一个元素的字节长度,占1个或者5个字节;当前一个元素的长度小于254字节时,prevrawlen字段用一个字节表示;当前一个元素的长度大于等于254字节时,prevrawlen字段用5个字节来表示;而这时候prevrawlen的第一个字节是固定的标志0xFE,后面4个字节才真正表示前一个元素的长度;假设已知当前元素的首地址为p,那么(p-prevrawlen)就是前一个元素的首地址,从而实现压缩列表从尾到头的遍历;

encoding字段表示当前元素的编码,即content字段存储的数据类型(整数或者字节数组),数据内容存储在content字段,根据encoding字段第一个字节的前2个比特,可以判断content字段存储的是整数,或者字节数组(以及字节数组最大长度);当content存储的是字节数组时,后续字节标记字节数组的实际长度;当content存储的是整数时,根据第3、4比特才能判断整数的具体类型;而当encoding字段标识当前元素存储的是0~12的立即数时,数据直接存储在encoding字段的最后4个比特。

#define ZIP_STR_06B (0 << 6) //长度小于等于 63 字节#define ZIP_STR_14B (1 << 6) //长度小于等于 16383 字节#define ZIP_STR_32B (2 << 6) //长度小于等于 4294967295 字节#define ZIP_INT_16B (0xc0 | 0<<4)#define ZIP_INT_32B (0xc0 | 1<<4)#define ZIP_INT_64B (0xc0 | 2<<4)#define ZIP_INT_24B (0xc0 | 3<<4)#define ZIP_INT_8B 0xfe

什么时候使用 ziplist 存储

当 hash 对象同时满足以下两个条件的时候,使用 ziplist 编码:1)所有的键值对的键和值的字符串长度都小于等于 64byte(一个英文字母一个字节);2)哈希对象保存的键值对数量小于 512 个。

hash-max-ziplist-value 64 // ziplist 中最大能存放的值长度hash-max-ziplist-entries 512 // ziplist 中最多能存放的 entry 节点数量
/* 源码位置:t_hash.c ,当字段个数超过阈值,使用 HT 作为编码 */if (hashTypeLength(o) > server.hash_max_ziplist_entries)hashTypeConvert(o, OBJ_ENCODING_HT);/*源码位置:t_hash.c,当字段值长度过大,转为 HT */for (i = start; i <= end; i++) {    if (sdsEncodedObject(argv[i]) &&    sdslen(argv[i]->ptr) > server.hash_max_ziplist_value){    hashTypeConvert(o, OBJ_ENCODING_HT);    break;}}

hashtable

在 Redis 中,hashtable 被称为字典(dictionary),它是一个数组+链表的结构。dictEntry 放到了 dictht(hashtable 里面)

/* This is our hash table structure. Every dictionary has two of this as we* implement incremental rehashing, for the old to the new table. */typedef struct dictht {dictEntry **table; /* 哈希表数组 */unsigned long size; /* 哈希表大小 */unsigned long sizemask; /* 掩码大小,用于计算索引值。总是等于 size-1 */unsigned long used; /* 已有节点数 */} dictht;
typedef struct dict {dictType *type; /* 字典类型 */void *privdata; /* 私有数据 */dictht ht[2]; /* 一个字典有两个哈希表 */long rehashidx; /* rehash 索引 */unsigned long iterators; /* 当前正在使用的迭代器数量 */} dict;

a3acee97d9e25d0c4815f72106af9500.png

哈希表 dictht 是用链地址法来解决碰撞问题的。在这种情况下,哈希表的性能取决于它的大小(size 属性)和它所保存的节点的数量(used 属性)之间的比率,如果节点数量比哈希表的大小要大很多的话(这个比例用 ratio 表示,5 表示平均一个 ht 存储 5 个 entry),那么哈希表就会退化成多个链表,哈希表本身的性能优势就不再存在。在这种情况下需要扩容。Redis 里面的这种操作叫做 rehash。

应用场景

1、存储对象类型的数据,如缓存数据表的数据

List的数据结构

存储有序的字符串(从左到右),元素可以重复。可以充当队列和栈的角色。3.2 版本之后,统一用 quicklist 来存储。quicklist 存储了一个双向链表,每个节点都是一个 ziplist。quicklist(快速列表)是 ziplist 和 linkedlist 的结合体。

typedef struct quicklist {quicklistNode *head; /* 指向双向列表的表头 */quicklistNode *tail; /* 指向双向列表的表尾 */unsigned long count; /* 所有的 ziplist 中一共存了多少个元素 */unsigned long len; /* 双向链表的长度,node 的数量 */int fill : 16; /* fill factor for individual nodes */unsigned int compress : 16; /* 压缩深度,0:不压缩;*/} quicklist;

quicklistNode 中的*zl 指向一个 ziplist,一个 ziplist 可以存放多个元素。

typedef struct quicklistNode {struct quicklistNode *prev; /* 前一个节点 */struct quicklistNode *next; /* 后一个节点 */unsigned char *zl; /* 指向实际的 ziplist */unsigned int sz; /* 当前 ziplist 占用多少字节 */unsigned int count : 16; /* 当前 ziplist 中存储了多少个元素,占 16bit(下同),最大 65536 个 */unsigned int encoding : 2; /* 是否采用了 LZF 压缩算法压缩节点,1:RAW 2:LZF */unsigned int container : 2; /* 2:ziplist,未来可能支持其他结构存储 */unsigned int recompress : 1; /* 当前 ziplist 是不是已经被解压出来作临时使用 */unsigned int attempted_compress : 1; /* 测试用 */unsigned int extra : 10; /* 预留给未来使用 */} quicklistNode

c88b2aa1266c7bab7f8a9e9084b8d109.png

应用场景

1、用户 消息时间线  timeline2、消息队列:List 提供了两个阻塞的弹出操作:BLPOP/BRPOP,可设置超时时间。

Set的数据结构

String 类型的无序集合,最大存储数量 2^32-1,Redis 用 intset 或 hashtable 存储 set。如果元素都是整数类型,就用 inset 存储。如果不是整数类型,就用 hashtable(数组+链表的存储结构)。使用hashtable存储的时候value为null。如果元素个数超过 512 个,也会用 hashtable 存储。

typedef struct intset {      uint32_t encoding;      uint32_t length;      int8_t contents[];    } intset;

encoding记录来编码方式

#define INTSET_ENC_INT16 (sizeof(int16_t))#define INTSET_ENC_INT32 (sizeof(int32_t))#define INTSET_ENC_INT64 (sizeof(int64_t))

contents数组是整数集合的底层实现:整数集合中的每个元素就是centents数组的每个元素,其中的每个元素都是按数值从小到大排列的,且元素不重复。虽然在结构体中contents被声明为int8_t类型数组,但是contents不会保存任何int8_t类型的元素,保存的正真的类型取决于encodeing。(contents[]这种结构体最后一个成员为[]数组的声明方式,不需要初始化,数组 名就是所在的偏移地址)

应用场景

1、抽奖:srandmember 随机抽取元素 正数,返回随机取一个去重集合,负数返回一个可重复的结构集

2、点赞、签到、打卡

3、标签

4、用户 关注、推荐模型

Zset(sorted Set) 有序集合

同时满足以下条件时使用 ziplist 编码:1、 元素数量小于 128 个2、所有 member 的长度都小于 64 字节在 ziplist 的内部,按照 score 排序递增来存储。插入的时候要移动之后的数据。超过阈值之后,使用 skiplist+dict 存储。

skiplist 跳表

skiplist本质上也是一种查找结构,用于解决算法中的查找问题(Searching),即根据给定的key,快速查到它所在的位置(或者对应的value)。这种数据结构是由William Pugh发明的,最早出现于他在1990年发表的论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》。对细节感兴趣的朋友可以下载论文原文。

skiplist不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每个节点随机出一个层数(level)。比如,一个节点随机出的层数是3,那么就把它链入到第1层到第3层这三层链表中。为了表达清楚,下图展示了如何通过一步步的插入操作从而形成一个skiplist的过程:

0c6f69d7597cc186a3d1df0521538c28.png

从上面skiplist的创建和插入过程可以看出,每一个节点的层数(level)是随机出来的,而且新插入一个节点不会影响其它节点的层数。因此,插入操作只需要修改插入节点前后的指针,而不需要对很多节点都进行调整。这就降低了插入操作的复杂度。这是skiplist的一个很重要的特性,它在插入性能上明显优于平衡树的方案。

刚刚创建的这个skiplist总共包含4层链表,现在假设我们在它里面依然查找23,下图给出了查找路径:

e0560fdfe1ad8ebee9eba8510af3b1d5.png

执行插入操作时计算随机数的过程,是一个很关键的过程,它对skiplist的统计特性有着很重要的影响。这并不是一个普通的服从均匀分布的随机数,它的计算过程如下:

  • 首先,每个节点肯定都有第1层指针(每个节点都在第1层链表里)。

  • 如果一个节点有第i层(i>=1)指针(即节点已经在第1层到第i层链表中),那么它有第(i+1)层指针的概率为p。

  • 节点最大的层数不允许超过一个最大值,记为MaxLevel。

randomLevel()    level := 1    // random()返回一个[0...1)的随机数    while random() < p and level < MaxLevel do        level := level + 1    return level

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

  • skiplist和各种平衡树(如AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做单个key的查找,不适宜做范围查找。

  • 在做范围查找的时候,平衡树比skiplist操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。

  • 平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。

  • 从内存占用上来说,skiplist比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。

  • 查找单个key,skiplist和平衡树的时间复杂度都为O(log n),大体相当;而哈希表在保持较低的哈希值冲突概率的前提下,查找时间复杂度接近O(1),性能更高一些。所以我们平常使用的各种Map或dictionary结构,大都是基于哈希表实现的。

  • 从算法实现难度上来比较,skiplist比平衡树要简单得多。

Redis中skipList 数据结构

#define ZSKIPLIST_MAXLEVEL 32#define ZSKIPLIST_P 0.25typedef struct zskiplistNode {    robj *obj;    double score;    struct zskiplistNode *backward;    struct zskiplistLevel {        struct zskiplistNode *forward;        unsigned int span;    } level[];} zskiplistNode; typedef struct zskiplist {    struct zskiplistNode *header, *tail;    unsigned long length;    int level;} zskiplist;
  • 开头定义了两个常量,ZSKIPLIST_MAXLEVEL和ZSKIPLIST_P,分别对应我们前面讲到的skiplist的两个参数:一个是MaxLevel,一个是p。

  • obj字段存放的是节点数据,它的类型是一个string robj。本来一个string robj可能存放的不是sds,而是long型,但zadd命令在将数据插入到skiplist里面之前先进行了解码,所以这里的obj字段里存储的一定是一个sds。

  • score字段是数据对应的分数

  • level[]存放指向各层链表后一个节点的指针(后向指针)。每层对应1个后向指针,用forward字段表示。另外,每个后向指针还对应了一个span值,它表示当前的指针跨越了多少个节点。span用于计算元素排名(rank),这是Redis对于skiplist所做的一个扩展。需要注意的是,level[]是一个柔性数组(flexible array member),因此它占用的内存不在zskiplistNode结构里面,而需要插入节点的时候单独为它分配。也正因为如此,skiplist的每个节点所包含的指针数目才是不固定的。

  • zskiplist定义了真正的skiplist结构,它包含:

    • 头指针header和尾指针tail。

    • 链表长度length,即链表包含的节点总数。注意,新创建的skiplist包含一个空的头指针,这个头指针不包含在length计数中。

    • level表示skiplist的总层数,即所有节点层数的最大值。

549ce8c524d6ef9213727dab521c24cb.png

Redis为什么用skiplist而不用平衡树

在前面我们对于skiplist和平衡树、哈希表的比较中,已经不难看出Redis里使用skiplist而不用平衡树的原因。我们再看看,对于这个问题,Redis的作者 @antirez 是怎么说的:

There are a few reasons:1) They are not very memory intensive. It’s up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.2) A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.3) They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.

应用场景

1、排行榜

Hyperloglogs

Redis HyperLogLog 是用来做基数统计的算法,HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定 的、并且是很小的。

在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基数。这和计算基数时,元素越多耗费内存就越多的集合形成鲜明对比。

Redis 提供了 PFADDPFCOUNTPFMERGE 三个命令来供用户使用 HyperLogLog。

PFADD 用于向 HyperLogLog 添加元素。

PFCOUNT 命令会给出 HyperLogLog 包含的近似基数。在计算出基数后,PFCOUNT 会将值存储在 HyperLogLog 中进行缓存,直到下次 PFADD 执行成功前,就都不需要再次进行基数的计算。

PFMERGE 将多个 HyperLogLog 合并为一个 HyperLogLog , 合并后的 HyperLogLog 的基数接近于所有输入 HyperLogLog 的并集基数。

HyperLogLog 对象的定义。

struct hllhdr {    char magic[4];      /* 魔法值 "HYLL" */    uint8_t encoding;   /* 密集结构或者稀疏结构 HLL_DENSE or HLL_SPARSE. */    uint8_t notused[3]; /* 保留位, 全为0. */    uint8_t card[8];    /* 基数大小的缓存 */    uint8_t registers[]; /* 数据字节数组 */};

HyperLogLog 最典型的使用场景就是统计网站的每日UV。实例如下:

@Test    public void testUv(){        String uv1 = "uv96";        String uv2 = "uv97";        IntStream.rangeClosed(1,100)                .forEach(i -> {                    System.out.println(i);                    redisTemplate.opsForHyperLogLog()                            .add(uv1,"user"+i);                    redisTemplate.opsForHyperLogLog()                            .add(uv2,"user"+i/2);                });        long uv1Count = redisTemplate.opsForHyperLogLog().size(uv1);        System.out.println(uv1Count);        long uv2Count = redisTemplate.opsForHyperLogLog().size(uv2);        System.out.println(uv2Count);        String uv1uv2 = "uv67";        Long uv1uv2Count = redisTemplate.opsForHyperLogLog().union(uv1uv2,uv1,uv2);        System.out.println(uv1uv2Count);        Long realCount = redisTemplate.opsForHyperLogLog().size(uv1uv2);        System.out.println(realCount);    }

Geo

GEO功能在Redis3.2版本提供,支持存储地理位置信息用来实现诸如附近位置、摇一摇这类依赖于地理位置信息的功能。geo的数据类型为zset.

geoadd将给定的空间元素(纬度、经度、名字)添加到指定的键里面。

geoadd key longitude latitude member [longitude latitude member...]

geopos从键里面返回所有给定位置元素的位置(经度和纬度)

geopos key member [member...]

geodist如果两个位置之间的其中一个不存在,那么命令返回空值。

geodist key member1 member2 [unit]

georadius给定的经纬度为中心,返回键包含的位置元素当中,与中心的距离不超过给定最大距离的所有位置元素。

georadius key longitude latitude radius m|km|ft|mi [withcoord][withdist][withhash][asc|desc][count count]

Streams

Stream是Redis 5.0版本引入的一个新的数据类型,它以更抽象的方式模拟日志数据结构,但日志仍然是完整的:就像一个日志文件,通常实现为以只附加模式打开的文件,Redis流主要是一个仅附加数据结构。至少从概念上来讲,因为Redis流是一种在内存表示的抽象数据类型,他们实现了更加强大的操作,以此来克服日志文件本身的限制。

streams数据结构本身非常简单,但是streams依然是Redis到目前为止最复杂的类型,其原因是实现的一些额外的功能:一系列的阻塞操作允许消费者等待生产者加入到streams的新数据。另外还有一个称为Consumer Groups的概念,这个概念最先由kafka提出,Redis有一个类似实现,和kafka的Consumer Groups的目的是一样的:允许一组客户端协调消费相同的信息流!

typedef struct stream {    rax *rax;               /* The radix tree holding the stream. */    uint64_t length;        /* Number of elements inside this stream. */    streamID last_id;       /* Zero if there are yet no items. */    rax *cgroups;           /* Consumer groups dictionary: name -> streamCG */} stream;

至于redis对radix tree (基数树)的实现,参考源码:https://github.com/antirez/redis/blob/5.0.0/src/rax.c 和 https://github.com/antirez/redis/blob/5.0.0/src/rax.h

Stream支持多播的可持久化的消息队列,用于实现发布订阅功能。如果你了解MQ,那么可以把streams当做MQ。如果你还了解kafka,那么甚至可以把streams当做kafka。

一张图来大概了解stream的使用

e9d84f60863760672bef36142e9f67a7.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值