聊一聊redis十种数据类型及底层原理
Posted 大妖史莱姆
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了聊一聊redis十种数据类型及底层原理相关的知识,希望对你有一定的参考价值。
概述
Redis 是一个开源的高性能键值数据库,它支持多种数据类型,可以满足不同的业务需求。本文将介绍 Redis 的10种数据类型,分别是
- string(字符串)
- hash(哈希)
- list(列表)
- set(集合)
- zset(有序集合)
- stream(流)
- geospatial(地理)
- bitmap(位图)
- bitfield(位域)
- hyperloglog(基数统计)
String
概述
string 是 Redis 最基本的数据类型,它可以存储任意类型的数据,比如文本、数字、图片或者序列化的对象。一个 string 类型的键最大可以存储 512 MB 的数据。
string 类型的底层实现是 SDS(simple dynamic string),它是一个动态字符串结构,由长度、空闲空间和字节数组三部分组成。SDS有3种编码类型:
- embstr:占用64Bytes的空间,存储44Bytes的数据
- raw:存储大于44Bytes的数据
- int:存储整数类型
embstr和raw存储字符串数据,int存储整型数据
应用场景
string 类型的应用场景非常广泛,比如:
- 缓存数据,提高访问速度和降低数据库压力。
- 计数器,利用 incr 和 decr 命令实现原子性的加减操作。
- 分布式锁,利用 setnx 命令实现互斥访问。
- 限流,利用 expire 命令实现时间窗口内的访问控制。
底层原理
embstr结构
embstr 结构存储小于等于44个字节的字符串,embstr 每次开辟64个byte的空间
- 前19个byte用于存储embstr 结构
- 中间的44个byte存储数据
- 最后为
\\0
符号
raw结构
embstr和raw的转换
在存储字符串的时候,redis会根据数据的长度判断使用哪种结构
- 如果长度小于等于44个字节,就会选择embstr 结构
- 如果长度大于44个byte,就会选择raw结构
127.0.0.1:6379> object encoding str
"embstr"
# str赋值44个字节的字符串
127.0.0.1:6379> set str 1234567890123456789012345678901234567890abcd
OK
127.0.0.1:6379> object encoding str
"embstr"
# str2赋值45个字节的字符串
127.0.0.1:6379> set str2 1234567890123456789012345678901234567890abcde
OK
127.0.0.1:6379> object encoding str2
"raw"
127.0.0.1:6379> set num 123
OK
127.0.0.1:6379> object encoding num
"int"
Hash
概述
hash 是一个键值对集合,它可以存储多个字段和值,类似于编程语言中的 map 对象。一个 hash 类型的键最多可以存储 2^32 - 1 个字段。
Hash类型的底层实现有三种:
ziplist
:压缩列表,当hash达到一定的阈值时,会自动转换为hashtable
结构listpack
:紧凑列表,在Redis7.0之后,listpack
正式取代ziplist
。同样的,当hash达到一定的阈值时,会自动转换为hashtable
结构hashtable
:哈希表,类似map
应用场景
hash 类型的应用场景主要是存储对象,比如:
- 用户信息,利用 hset 和 hget 命令实现对象属性的增删改查。
- 购物车,利用 hincrby 命令实现商品数量的增减。
- 配置信息,利用 hmset 和 hmget 命令实现批量设置和获取配置项。
底层原理
Redis在存储hash结构的数据,为了达到内存和性能的平衡,也针对少量存储和大量存储分别设计了两种结构,分别为:
- ziplist(redis7.0之前使用)和listpack(redis7.0之后使用)
- hashTable
从ziplist/listpack编码转换为hashTable编码是通过判断元素数量或单个元素Key或Value的长度决定的:
hash-max-ziplist-entries
:表示当hash中的元素数量小于或等于该值时,使用ziplist编码,否则使用hashtable编码。ziplist是一种压缩列表,它可以节省内存空间,但是访问速度较慢。hashtable是一种哈希表,它可以提高访问速度,但是占用内存空间较多。默认值为512。hash-max-ziplist-value
:表示当 hash中的每个元素的key
和value
的长度都小于或等于该值时,使用 ziplist编码,否则使用 hashtable编码。默认值为 64。
ziplist与listpack
ziplist/listpack都是hash结构用来存储少量数据的结构。从Redis7.0后,hash默认使用ziplist结构。因为 ziplist有一个致命的缺陷,就是连锁更新,当一个节点的长度发生变化时,可能会导致后续所有节点的长度字段都要重新编码,这会造成极差的性能
ziplist结构
ziplist是一种紧凑的链表结构,它将所有的字段和值顺序地存储在一块连续的内存中。
Redis中ziplist源码
typedef struct
/* 当使用字符串时,slen表示为字符串长度 */
unsigned char *sval;
unsigned int slen;
/* 当使用整形时,sval为NULL,lval为ziplistEntry的value */
long long lval;
ziplistEntry;
listpack结构
zipList的连锁更新问题
ziplist的每个entry都包含previous_entry_length来记录上一个节点的大小,长度是1个或5个byte:
- 如果前一节点的长度小于254个byte,则采用1个byte来保存这个长度值
- 如果前一节点的长度大于等于254个byte,则采用5个byte来保存这个长度值,第一个byte为0xfe,后四个byte才是真实长度数据
假设,现有有N个连续、长度为250~253个byte的entry,因此entry的previous_entry_length属性占用1个btye
当第一节长度大于等于254个bytes,导致第二节previous_entry_length变为5个bytes,第二节的长度由250变为254。而第二节长度的增加必然会影响第三节的previous_entry_length。ziplist这种特殊套娃的情况下产生的连续多次空间扩展操作成为连锁更新。新增、删除都可能导致连锁更新的产生。
listpack是如何解决的
- 由于ziplist需要倒着遍历,所以需要用previous_entry_length记录前一个entry的长度。而listpack可以通过total_bytes和end计算出来。所以previous_entry_length不需要了。
- listpack 的设计彻底消灭了 ziplist 存在的级联更新行为,元素与元素之间完全独立,不会因为一个元素的长度变长就导致后续的元素内容会受到影响。
- 与ziplist做对比的话,牺牲了内存使用率,避免了连锁更新的情况。从代码复杂度上看,listpack相对ziplist简单很多,再把增删改统一做处理,从listpack的代码实现上看,极简且高效。
hashTable
hashTable是一种散列表结构,它将字段和值分别存储在两个数组中,并通过哈希函数计算字段在数组中的索引
Redis中hashTable源码
struct dict
dictType *type;
dictEntry **ht_table[2];
unsigned long ht_used[2];
long rehashidx; /* 当进行rehash时,rehashidx为-1 */
int16_t pauserehash; /* 如果rehash暂停,pauserehash则大于0,(小于0表示代码错误)*/
signed char ht_size_exp[2]; /* 哈希桶的个数(size = 1<<exp) */
;
typedef struct dict
dictEntry **table;
dictType *type;
unsigned long size;
unsigned long sizemask;
unsigned long used;
void *privdata;
dict;
typedef struct dictEntry
void *key;
void *val;
struct dictEntry *next;
dictEntry;
ziplist和hashTable的转换
127.0.0.1:6379> hset h1 id 123456789012345678901234567890123456789012345678901234567890abcd
(integer) 1
127.0.0.1:6379> object encoding h1
"ziplist"
127.0.0.1:6379> hset h2 id 123456789012345678901234567890123456789012345678901234567890abcde
(integer) 1
127.0.0.1:6379> object encoding h2
"hashtable"
ziplist的废弃
显然是ziplist在field个数太大、key太长、value太长三者有其一的时候会有以下问题:
- ziplist每次插入都有开辟空间,连续的
- 查询的时候,需要从头开始计算,查询速度变慢
hashTable变得越来越长怎么办
扩容,hashTabel是怎么扩容的?使用的是渐进式扩容rehash
。rehash
会重新计算哈希值,且将哈希桶的容量扩大。
rehash 步骤
扩展哈希和收缩哈希都是通过执行rehash
来完成,这其中就涉及到了空间的分配和释放,主要经过以下五步:
- 为字典dict的ht[1]哈希表分配空间,其大小取决于当前哈希表已保存节点数(即:
ht[0].used
):- 如果是扩展操作则ht[1]的大小为2的n次方中第一个大于等于
ht[0].used * 2
属性的值(比如used=3,此时ht[0].used * 2=6
,故2的3次方为8就是第一个大于used * 2的值(2 的 2 次方 6))。 - 如果是收缩操作则ht[1]大小为 2 的 n 次方中第一个大于等于
ht[0].used
的值
- 如果是扩展操作则ht[1]的大小为2的n次方中第一个大于等于
- 将字典中的属性
rehashidx
的值设置为0,表示正在执行rehash
操作 - 将ht[0]中所有的键值对依次重新计算哈希值,并放到ht[1]数组对应位置,每完成一个键值对的
rehash
之后rehashidx的值需要自增1 - 当ht[0]中所有的键值对都迁移到ht[1]之后,释放ht[0],并将ht[1]修改为ht[0],然后再创建一个新的ht[1]数组,为下一次rehash做准备
- 将字典中的属性
rehashidx
设置为-1,表示此次rehash
操作结束,等待下一次rehash
渐进式 rehash
Redis中的这种重新哈希的操作因为不是一次性全部rehash
,而是分多次来慢慢的将ht[0]中的键值对rehash
到ht[1],故而这种操作也称之为渐进式rehash
。渐进式rehash
可以避免集中式rehash
带来的庞大计算量,是一种分而治之的思想。
在渐进式rehash
过程中,因为还可能会有新的键值对存进来,此时Redis的做法是新添加的键值对统一放入ht[1]中,这样就确保了ht[0]键值对的数量只会减少。
当正在执行rehash
操作时,如果服务器收到来自客户端的命令请求操作,则会先查询ht[0],查找不到结果再到ht[1]中查询
List
概述
list 是一个有序的字符串列表,它按照插入顺序排序,并且支持在两端插入或删除元素。一个 list 类型的键最多可以存储 2^32 - 1 个元素。
redis3.2
以后,list 类型的底层实现只有一种结构,就是quicklist。版本不同时,底层实现是不同的,下面会讲解。
应用场景
list 类型的应用场景主要是实现队列和栈,比如:
- 消息队列,利用 lpush 和 rpop 命令实现生产者消费者模式。
- 最新消息,利用 lpush 和 ltrim 命令实现固定长度的时间线。
- 历史记录,利用 lpush 和 lrange 命令实现浏览记录或者搜索记录。
底层原理
在讲解list结构之前,需要先说明一下list结构编码的更替,如下
- 在
Redis3.2
之前,list使用的是linkedlist
和ziplist
- 在
Redis3.2~Redis7.0
之间,list使用的是quickList
,是linkedlist
和ziplist
的结合 - 在
Redis7.0
之后,list使用的也是quickList
,只不过将ziplist
转为listpack
,它是listpack、linkedlist结合版
linkedlist与ziplist
在Redis3.2
之前,linkedlist
和ziplist
两种编码可以选择切换,它们之间的转换关系如图
同样地,ziplist转为linkedlist的条件可在redis.conf配置
list-max-ziplist-entries 512
list-max-ziplist-value 64
quickList(ziplist、linkedlist结合版)
quicklist
存储了一个双向列表,每个列表的节点是一个ziplist
,所以实际上quicklist
并不是一个新的数据结构,它就是linkedlist
和ziplist
的结合,然后被命名为快速列表。
ziplist内部entry个数可在redis.conf配置
list-max-ziplist-size -2
# -5: 每个ziplist最多为 64 kb <-- 影响正常负载,不推荐
# -4: 每个ziplist最多为 32 Kb <-- 不推荐
# -3: 每个ziplist最多为 16 Kb <-- 最好不要使用
# -2: 每个ziplist最多为 8 Kb <-- 好
# -1: 每个ziplist最多为 4 Kb <-- 好
# 正数为ziplist内部entry个数
ziplist通过特定的LZF压缩算法来将节点进行压缩存储,从而更进一步的节省空间,而很多场景都是两端元素访问率最高,我们可以通过配置list-compress-depth
来排除首尾两端不压缩的entry个数。
list-compress-depth 0
# - 0:不压缩(默认值)
# - 1:首尾第 1 个元素不压缩
# - 2:首位前 2 个元素不压缩
# - 3:首尾前 3 个元素不压缩
# - 以此类推
quickList(listpack、linkedlist结合版)
和Hash结构一样,因为ziplist
有连锁更新问题,redis7.0
将ziplist
替换为listpack
,下面是新quickList的结构图
Redis中listpack源码
typedef struct quicklist
quicklistNode *head;
quicklistNode *tail;
unsigned long count; /* 所有列表包中所有条目的总数,占用16 bits,最大65536 */
unsigned long len; /* quicklistNode 的数量 */
signed int fill : QL_FILL_BITS; /* 单个节点的填充因子 */
unsigned int compress : QL_COMP_BITS; /* 不压缩的端节点深度;0=off */
unsigned int bookmark_count: QL_BM_BITS;
quicklistBookmark bookmarks[];
quicklist;
typedef struct quicklistNode
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *entry;
size_t sz; /* 当前entry占用字节 */
unsigned int count : 16; /* listpack元素个数,最大65535 */
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
unsigned int container : 2; /* PLAIN==1 or PACKED==2 */
unsigned int recompress : 1; /* 当前listpack是否需要再次压缩 */
unsigned int attempted_compress : 1; /* 测试用 */
unsigned int extra : 10; /* 备用 */
quicklistNode;
listpack内部entry个数可在redis.conf配置
List-Max-listpack-size -2
# -5: 每个listpack最多为 64 kb <-- 影响正常负载,不推荐
# -4: 每个listpack最多为 32 Kb <-- 不推荐
# -3: 每个listpack最多为 16 Kb <-- 最好不要使用
# -2: 每个listpack最多为 8 Kb <-- 好
# -1: 每个listpack最多为 4 Kb <-- 好
# 正数为listpack内部entry个数
Set
概述
set
是一个无序的字符串集合,它不允许重复的元素。一个 set
类型的键最多可以存储 2^32 - 1 个元素。
set
类型的底层实现有两种:
intset
,整数集合hashtable
(哈希表)。哈希表和 hash 类型的哈希表相同,它将元素存储在一个数组中,并通过哈希函数计算元素在数组中的索引
Redis 会根据 set 中元素的数量和类型来选择合适的编码方式,当 set 达到一定的阈值时,会自动转换编码方式。
typedef struct intset
uint32_t encoding;
uint32_t length;
int8_t contents[];
intset;
应用场景
set 类型的应用场景主要是利用集合的特性,比如:
- 去重,利用 sadd 和 scard 命令实现元素的添加和计数。
- 交集,并集,差集,利用 sinter,sunion 和 sdiff 命令实现集合间的运算。
- 随机抽取,利用 srandmember 命令实现随机抽奖或者抽样。
底层原理
在讲解set结构之前,需要先说明一下set结构编码的更替,如下
- 在
Redis7.2
之前,set使用的是intset
和hashtable
- 在
Redis7.2
之后,set使用的是intset
、listpack
、hashtable
intset
intset
是一种紧凑的数组结构,它只保存int
类型的数据,它将所有的元素按照从小到大的顺序存储在一块连续的内存中。intset
会根据传入的数据大小,encoding
分为int16_t
、int32_t
、int64_t
127.0.0.1:6379> sadd set 123
(integer) 1
127.0.0.1:6379> object encoding set
"intset"
127.0.0.1:6379> sadd set abcd
(integer) 1
127.0.0.1:6379> object encoding set
"hashtable"
intset 和 hashtable 的转换
在Redis7.2
之前,当一个集合满足以下两个条件时,Redis 会选择使用intset
编码:
- 集合对象保存的所有元素都是整数值
- 集合对象保存的元素数量小于等于
512
个(默认)
intset最大元素数量可在redis.conf配置
set-max-intset-entries 512
为什么加入了listpack
在redis7.2
之前,sds
类型的数据会直接放入到编码结构式为hashtable
的set
中。其中,sds
其实就是redis
中的string
类型。
而在redis7.2
之后,sds类型的数据,首先会使用listpack
结构当 set
达到一定的阈值时,才会自动转换为hashtable
。
添加listpack
结构是为了提高内存利用率和操作效率,因为 hashtable 的空间开销和碰撞概率都比较高。
hashtable 的空间开销高
hashtable 的空间开销高是因为它需要预先分配一个固定大小的数组来存储键值对,而这个数组的大小通常要大于实际存储的元素个数,以保证较低的装载因子。装载因子是指 hashtable 中已经存储的元素个数和数组大小的比值,它反映了 hashtable 的空间利用率
- 如果装载因子过高,那么 hashtable 的性能会下降,因为碰撞的概率会增加
- 如果装载因子过低,那么 hashtable 的空间利用率会下降,因为数组中会有很多空闲的位置
因此,hashtable 需要在装载因子和空间利用率之间做一个平衡,通常装载因子的推荐值是 0.75
hashtable 的碰撞概率高
hashtable
的碰撞概率高是因为它使用了一个散列函数来将任意长度的键映射到一个有限范围内的整数,作为数组的索引
散列函数的设计很重要,它应该尽可能地保证不同的键能够均匀地分布在数组中,避免出现某些位置过于拥挤,而其他位置过于稀疏的情况。然而,由于散列函数的输出范围是有限的,而键的取值范围是无限的,所以不可能完全避免两个不同的键被散列到同一个位置上,这就产生了碰撞。碰撞会影响 hashtable 的性能,因为它需要额外的处理方式来解决冲突,比如开放寻址法或者链地址法
举例说明,假设有一个大小为8的hashtable
,使用取模运算作为散列函数,即h(k) = k mod 8。现在有四个键:5,13,21,29,它们都被散列到索引1
处
这就是一个碰撞的例子,因为四个键都映射到了同一个索引。这种情况可能是由于以下原因造成的:
- 散列函数的选择不合适,没有充分利用hashtable的空间。
- 键的分布不均匀,有些区间的键出现的频率更高。
- hashtable的大小太小,不能容纳所有的键。
为了解决碰撞,redis
采用了链地址法。就是在每个索引处维护一个链表,存储所有散列到该索引的键。但是,如果链表过长,查找效率会降低。因此,一般建议保持hashtable的负载因子(即键的数量除以hashtable的大小)在一定范围内,比如0.5到0.75之间。如果负载因子过高或过低,可以通过扩容或缩容来调整hashtable的大小
intset 、listpack和hashtable的转换
intset 、listpack和hashtable这三者的转换时根据要添加的数据、当前set
的编码和阈值决定的。
-
如果要添加的数据是整型,且当前
set
的编码为intset
,如果超过阈值由intset
直接转为hashtable
阈值条件为:
set-max-intset-entries
,intset
最大元素个数,默认512 -
如果要添加的数据是字符串,分为三种情况
- 当前
set
的编码为intset
:如果没有超过阈值,转换为listpack
;否则,直接转换为hashtable
- 当前
set
的编码为listpack
:如果超过阈值,就转换为hashtable
- 当前
set
的编码为hashtable
:直接插入,编码不会进行转换
阈值条件为:
set-max-listpack-entries
:最大元素个数,默认128
set_max_listpack_value
:最大元素大小,默认64
以上两个条件需要同时满足才能进行编码转换 - 当前
ZSet
概述
Redis
中的 zset
是一种有序集合类型,它可以存储不重复的字符串元素,并且给每个元素赋予一个排序权重值(score
)。Redis
通过权重值来为集合中的元素进行从小到大的排序。zset
的成员是唯一的,但权重值可以重复。一个 zset
类型的键最多可以存储 2^32 - 1 个元素。
Redis中zset源码
typedef struct zskiplistNode
sds ele;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel
struct zskiplistNode *forward;
unsigned long span;
level[];
zskiplistNode;
typedef struct zskiplist
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
zskiplist;
typedef struct zset
dict *dict;
zskiplist *zsl;
zset;
应用场景
zset 类型的应用场景主要是利用分数和排序的特性,比如:
- 排行榜,利用 zadd 和 zrange 命令实现分数的更新和排名的查询
- 延时队列,利用 zadd 和 zpopmin 命令实现任务的添加和执行,并且可以定期地获取已经到期的任务
- 访问统计,可以使用 zset 来存储网站或者文章的访问次数,并且可以按照访问量进行排序和筛选。
底层原理
Redis
在存储zset
结构的数据,为了达到内存和性能的平衡,针对少量存储和大量存储分别设计了两种结构,分别为:
ziplist
(redis7.0
之前使用)和listpack(redis7.0
之后使用)skiplist
当 zset
中的元素个数和元素值的长度比较小的时候,Redis
使用ziplist/listpack
来节省内存空间。当 zset
中的元素个数和元素值的长度达到一定阈值时,Redis
会自动将ziplist/listpack
转换为skiplist
,以提高操作效率
具体来说,当 zset
同时满足以下两个条件时,会使用 listpack
作为底层结构:
- 元素个数小于
zset_max_listpack_entries
,默认值为 128 - 元素值的长度小于
zset_max_listpack_value
,默认值为 64
当 zset
中不满足以上两个条件时,会使用 skiplist
作为底层结构。
skiplist
跳跃表是一种随机化的数据结构,实质就是一种可以进行二分查找的有序链表。跳跃表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。跳跃表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能
跳跃表相比于其他平衡树结构,有以下几个优点和缺点:
优点:
- 实现简单,易于理解和调试
- 插入和删除操作只需要修改局部节点的指针,不需要像平衡树那样进行全局调整
- 可以利用空间换时间,通过增加索引层来提高查找效率
- 支持快速的范围查询,可以方便地返回指定区间内的所有元素
缺点:
- 空间复杂度较高,需要额外存储多级索引
- 随机性太强,性能不稳定,最坏情况下可能退化成链表
- 不支持快速的倒序遍历,需要额外的指针来实现
redis的skiplist
skiplist
有一个层数上的问题,当层数过多,会影响查询效率。而Redis
使用了一个随机函数来决定每个节点的层数,这个随机函数的期望值是 1/(1-p)
,其中 p
是一个概率常数,Redis
中默认为 0.25
。这样可以保证跳跃表的平均高度为 log (1/p) n
,其中 n
是节点数。Redis 还限制了跳跃表的最大层数为 32 ,这样可以避免过高的索引层造成空间浪费
Stream
概述
stream 是一个类似于日志的数据结构,它可以记录一系列的键值对,每个键值对都有一个唯一的 ID。一个 stream 类型的键最多可以存储 2^64 - 1 个键值对。
stream 类型的底层实现是 rax(基数树),它是一种压缩的前缀树结构,它将所有的键值对按照 ID 的字典序存储在一个树形结构中。rax 可以快速地定位、插入、删除任意位置的键值对
应用场景
stream 类型的应用场景主要是实现事件驱动的架构,比如:
- 消息队列,利用 xadd 和 xread 命令实现生产者消费者模式。
- 操作日志,利用 xadd 和 xrange 命令实现操作记录和回放。
- 数据同步,利用 xadd 和 xreadgroup 命令实现多个消费者组之间的数据同步。
底层原理
Rax Tree
rax tree是一种基于基数树(radix tree)的变体,也叫做压缩前缀树(compressed prefix tree),它被应用于redis stream中,用来存储streamID,其数据结构为
typedef struct raxNode
uint32_t iskey:1; /* Does this node contain a key? */
uint32_t isnull:1; /* Associated value is NULL (don\'t store it). */
uint32_t iscompr:1; /* 前缀是否压缩 */
uint32_t size:29; /* Number of children, or compressed string len. */
unsigned char data[];
raxNode;
iskey
:是否包含keyisnull
:是否存储value值iscompr
:前缀是否压缩。决定了size
存储的是什么和data
的数据结构size
:iscompr=0
:节点为非压缩节点,size
是孩子节点的数量iscompr=1
:节点为压缩节点,size
是已压缩的字符串长度
data
:iscompr=0
:节点为非压缩节点,数据格式为[header strlen=0][abc][a-ptr][b-ptr][c-ptr](value-ptr?)
。其有size个字符,iscompr=1
:节点为压缩节点,数据格式为[header strlen=3][xyz][z-ptr](value-ptr?)
。
为了便于理解,设定一些场景举例说明
场景一:只插入foot
数据结构为:
其中,z-ptr
指向的叶子节点的iskey=1
,标识foot
这个key。下图为使用树状图的形式来展现其数据结构
场景二:插入foot后,插入footer
数据结构为:
其插入过程为:
- 与foot节点中每个字符进行比较,获得最大公共前缀
foot
- 将er作为foot的子节点,其
iskey=1
,标识foot
这个key - 将er的子节点的
iskey=1
,标识footer
这个key
下图为使用树状图的形式来展现其数据结构
场景三:插入foot后,插入fo
数据结构为:
其插入过程为:
- 与foot节点中每个字符进行比较,获得最大公共前缀
fo
- 将foot拆成fo和ot
- 将ot作为fo的子节点,其
iskey=1
,标识fo
这个key - 设置ot的子节点的
iskey=1
,标识foot
这个key
下图为使用树状图的形式来展现其数据结构
场景四:插入foot后,插入foobar
数据结构为:
其插入过程为:
- 与foot节点中每个字符进行比较,获得最大公共前缀
foo
- 将foot拆成foo和t
- 将footbar拆成foo、b、ar
- 将t、b作为foo的子节点
- 设置ot的子节点的
iskey=1
,标识foot
这个key - 将ar作为b的子节点
- 设置ar的子节点的
iskey=1
,标识footbar
这个key
下图为使用树状图的形式来展现其数据结构
Stream
stream的底层使用了rax tree
和listpack
两种结构,rax tree
用来存储streamID,而listpack
用来存储对应的值,结构图如下:
Hyperloglog
概述
HyperLogLog 是一种概率数据结构,用于在恒定的内存大小下估计集合的基数(不同元素的个数)。它不是一个独立的数据类型,而是一种特殊的 string 类型,它可以使用极小的空间来统计一个集合中不同元素的数量,也就是基数。一个 hyperloglog 类型的键最多可以存储 12 KB 的数据
hyperloglog 类型的底层实现是 SDS(simple dynamic string),它和 string 类型相同,只是在操作时会使用一种概率算法来计算基数。hyperloglog 的误差率为 0.81%,也就是说如果真实基数为 1000,那么 hyperloglog 计算出来的基数可能在 981 到 1019 之间
应用场景
hyperloglog 类型的应用场景主要是利用空间换时间和精度,比如:
- 统计网站的独立访客数(UV)
- 统计在线游戏的活跃用户数(DAU)
- 统计电商平台的商品浏览量
- 统计社交网络的用户关注数
- 统计日志分析中的不同事件数
假如需要统计某商品的用户关注数,可以通过以下方式:
> PFADD goodA "1"
1
> PFADD goodA "2"
1
> PFADD goodA "3"
1
> PFCOUNT goodA
3
GEO
概述
geospatial 是一种用于存储和查询地理空间位置的数据类型,它基于 sorted set 数据结构实现,利用 geohash 算法将经纬度编码为二进制字符串,并作为 sorted set 的 score 值。Redis geospatial 提供了一系列的命令来添加、删除、搜索和计算地理空间位置,例如:
-
GEOADD key longitude latitude member [longitude latitude member …]
:将一个或多个地理空间位置(经度、纬度、名称)添加到指定的 key 中 -
GEOPOS key member [member …]
:返回一个或多个地理空间位置的经纬度 -
GEODIST key member1 member2 [unit]
:返回两个地理空间位置之间的距离,可以指定单位(m, km, mi, ft) -
GEORADIUS key longitude latitude radius unit [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]
:返回指定圆心和半径内的地理空间位置,可以指定返回坐标、距离、哈希值、数量、排序方式等,也可以将结果存储到另一个 key 中 -
GEORADIUSBYMEMBER key member radius unit [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]
: 返回以指定成员为圆心的指定半径内的地理空间位置,其他参数同GEORADIUS
应用场景
geospatial 的应用是地理位置搜索、分析和展示,例如地图应用、导航应用、位置服务应用等。例如,可以使用 geospatial 来实现以下功能:
- 统计某个区域内的商家或用户数量
- 查询某个位置附近的餐馆或酒店
- 计算两个位置之间的距离或行驶时间
- 显示某个位置周围的景点或活动
Bitmap
概述
bitmap
不是一个独立的数据类型,而是一种特殊的 string 类型,它可以将一个 string 类型的值看作是一个由二进制位组成的数组,并提供了一系列操作二进制位的命令。一个 bitmap 类型的键最多可以存储 2^32 - 1 个二进制位。
bitmap 类型的底层实现是 SDS(simple dynamic string),它和 string 类型相同,只是在操作时会将每个字节拆分成 8 个二进制位。
应用场景
bitmap 类型的应用场景主要是利用二进制位的特性,比如:
- 统计用户活跃度,利用 setbit 和 bitcount 命令实现每天或每月用户登录次数的统计。
- 实现布隆过滤器,利用 setbit 和 getbit 命令实现快速判断一个元素是否存在于一个集合中。
- 实现位图索引,利用 bitop 和 bitpos 命令实现对多个条件进行位运算和定位
假如需要统计每个用户的当天登录次数统计。
首先,需要规定bitmap的格式,假设为userid:年份:第几天 秒数 是否登录
将
userid
为100的用户,记录他在2024年第100天中第1秒,是否登录
SETBIT 1000:2024:100 1 1
0
将
userid
为100的用户,记录他在2024年第100天中第10240 秒,是否登录
SETBIT 1000:2024:100 10240 1
0
将
userid
为100的用户,记录他在2024年第100天中第86400 秒,是否登录
SETBIT 1000:2024:100 86400 1
0
统计
userid
为100的用户,在2024年第100天的登录次数
BITCOUNT 1000:2024:100
3
Bitfield
概述
bitfield
结构是基于字符串类型的一种扩展,可以让你对一个字符串中的任意位进行设置,增加和获取操作,就像一个位数组一样
可以操作任意位长度的整数,从无符号的1位整数到有符号的63位整数。这些值是使用二进制编码的Redis
字符串来存储的
bitfield
结构支持原子的读,写和增加操作,使它们成为管理计数器和类似数值的好选择
使用场景
Bitfield
的使用场景与bitmap
类似,主要是一些需要用不同位长度的整数来表示状态或属性的场合,例如:
-
用一个32位的无符号整数来表示用户的金币数量,用一个32位的无符号整数来表示用户杀死的怪物数量,可以方便地对这些数值进行设置,增加和获取
-
用一个16位的有符号整数来表示用户的等级,用一个16位的有符号整数来表示用户的经验值,可以方便地对这些数值进行设置,增加和获取
-
用一个8位的无符号整数来表示用户的性别,用一个8位的无符号整数来表示用户的年龄,可以方便地对这些数值进行设置,增加和获取
bitfield
和bitmap
都是基于string
类型的位操作,但是有一些区别:
bitmap
只能操作1位的无符号整数,而bitfield
可以操作任意位长度的有符号或无符号整数bitmap
只能设置或获取指定偏移量上的位,而bitfield
可以对指定偏移量上的位进行增加或减少操作bitmap
可以对多个字符串进行位运算,而bitfield
只能对单个字符串进行位操作bitmap
的偏移量是从0开始的,而bitfield
的偏移量是从最高有效位开始的
例如,使用bitfield
存储用户的个人信息,
- 用一个8位的无符号整数来表示用户的性别,0表示男,1表示女
- 用一个8位的无符号整数来表示用户的年龄,范围是0-255
- 用一个16位的无符号整数来表示用户的身高,单位是厘米,范围是0-65535
- 用一个16位的无符号整数来表示用户的体重,单位是克,范围是0-65535
假设有一个用户,性别是女,年龄是25,身高是165厘米,体重是50千克,可以用以下命令来存储和获取这些信息:
> BITFIELD user:1:info SET u8 #0 1 SET u8 #1 25 SET u16 #2 165 SET u16 #3 50000
0
0
0
0
然后,获取这个用户的信息,性别、年龄、身高、体重
> BITFIELD user:1:info GET u8 #0 GET u8 #1 GET u16 #2 GET u16 #3
1
25
165
50000
Redis基本数据结构及底层实现原理
前言
面试必问之Redis,大部分人都知道Redis的几种数据类型,也知道怎么用。但具体底层是怎么实现的呢,面试过程中面试官问:Redis底层是怎么实现的,你能答上来吗?
1.Redis支持的数据类型
一、Redis支持的数据类型
Redis 主要有以下几种数据类型:
- String 字符串对象
- Hash 哈希Map对象
- List 列表对象
- Set 集合对象
- ZSet 有序集合
还有三种特殊数据类型:
- geospatial: Redis 在 3.2 推出 Geo 类型,该功能可以推算出地理位置信息,两地之间的距离。
- hyperloglog:基数:数学上集合的元素个数,是不能重复的。这个数据结构常用于统计网站的 UV。
- bitmap: bitmap 就是通过最小的单位 bit 来进行0或者1的设置,表示某个元素对应的值或者状态。一个 bit 的值,或者是0,或者是1;也就是说一个 bit 能存储的最多信息是2。bitmap 常用于统计用户信息比如活跃粉丝和不活跃粉丝、登录和未登录、是否打卡等。
补充说明:
基数是一种算法。举个例子 一本英文著作由数百万个单词组成,你的内存却不足以存储它们,那么我们先分析下业务。英文单词本身是有限的,在这本书的几百万个单词中有许许多多重复单词,扣去重复的单词,这本书中也就 千到 万多个单词而己,那么内存就足够存储它 了。比如数字集合{ l,2,5 1,5,9 }的基数集合为{ 1,2,5 }那么 基数(不重复元素)就是基数的作用是评估大约需要准备多少个存储单元去存储数据,基数并不是存储元素,存储元素消耗内存空间比较大,而是给某个有重复元素的数据集合( 般是很大的数据集合〉评估需要的空间单元数。
几种特殊类型的使用场景会在文末详细地补充介绍,请耐心看完。
2.redisObject对象
Redis存储的所有值对象在内部都定义为redisObject结构体,内部结构如下图所示:
Redis存储的包括string,hash,list,set,zset在内的所有数据类型,都使用redisObject来封装的。
下面针对每个字段做详细说明:
1.type字段:表示当前对象使用的数据类型,Redis主要支持5种数据类型:string,hash,list,set,zset。可以使用type key命令查看对象所属类型,type命令返回的是值对象类型,键都是string类型。
2.encoding字段:表示Redis内部编码类型,encoding在Redis内部使用,代表当前对象内部采用哪种数据结构实现。理解Redis内部编码方式对于优化内存非常重要,同一个对象采用不同的编码实现内存占用存在明显差异,具体细节见之后编码优化部分。
3.lru字段:记录对象最后一次被访问的时间,当配置了 maxmemory和maxmemory-policy=volatile-lru | allkeys-lru 时,用于辅助LRU算法删除键数据。可以使用object idletime key命令在不更新lru字段情况下查看当前键的空闲时间。开发提示:可以使用scan + object idletime 命令批量查询哪些键长时间未被访问,找出长时间不访问的键进行清理降低内存占用。
4.refcount字段:记录当前对象被引用的次数,用于通过引用次数回收内存,当refcount=0时,可以安全回收当前对象空间。使用object refcount key获取当前对象引用。当对象为整数且范围在[0-9999]时,Redis可以使用共享对象的方式来节省内存。具体细节见之后共享对象池部分。
5.ptr字段:与对象的数据内容相关,如果是整数直接存储数据,否则表示指向数据的指针。Redis在3.0之后对值对象是字符串且长度<=39字节的数据,内部编码为embstr类型,字符串sds和redisObject一起分配,从而只要一次内存操作。开发提示:高并发写入场景中,在条件允许的情况下建议字符串长度控制在39字节以内,减少创建redisObject内存分配次数从而提高性能。
可以简单的理解成下图:
每一种类型都有自己特有的数据结构,下面我们要探讨的就是每种数据类型的具体的底层结构。
相关视频推荐
学习地址:C/C++Linux服务器开发/后台架构师【零声教育】
需要C/C++ Linux服务器架构师学习资料加qun812855908获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享
3.String
- string 是 redis 最基本的类型,你可以理解成与 Memcached 一模一样的类型,一个 key 对应一个 value。
- value其实不仅是String,也可以是数字
- string 类型是二进制安全的,可以包含任何数据,比如jpg图片或者序列化的对象
- string 类型的值最大能存储 512MB
- 常用命令:get、set、incr、decr、mget等
应用场景
常规key-value缓存应用。常规计数: 微博数, 粉丝数。
String 底层实现
字符串是我们日常工作中用得最多的对象类型,它对应的编码可以是int、raw和embstr。通过 object encoding key 命令来查看具体的编码格式:
如果一个字符串对象保存的是不超过long类型的整数值,此时编码类型即为int,其底层数据结构直接就是long类型。例如执行set number 10086,就会创建int编码的字符串对象作为number键的值。
如果字符串对象保存的是一个长度大于39字节的字符串,此时编码类型即为raw,其底层数据结构是简单动态字符串(SDS)。
如果长度小于等于39个字节,编码类型则为embstr,底层数据结构就是embstr编码SDS。下面,我们详细理解下什么是简单动态字符串。
SDS
SDS是"simple dynamic string"的缩写。redis中所有场景中出现的字符串,由SDS来实现。
free:还剩多少空间 len:字符串长度 buf:存放的字符数组。
在源码的 src目录下,找到了 sds.h 这样一个文件,规定了 SDS 的结构:
struct sdsshr<T>
T len;//数组长度
T alloc;//数组容量
unsigned flags;//sdshdr类型
char buf[];//数组内容
可以看出,SDS 的结构有点类似于 Java 中的 ArrayList。buf[]表示真正存储的字符串内容,alloc 表示所分配的数组的长度,len 表示字符串的实际长度,并且由于 len 这个属性的存在,Redis 可以在 O(1)的时间复杂度内获取数组长度。
空间预分配
为减少修改字符串带来的内存重分配次数,sds采用了“一次管够”的策略:
- 若修改之后sds长度小于1MB,则多分配现有len长度的空间
- 若修改之后sds长度大于等于1MB,则扩充除了满足修改之后的长度外,额外多1MB空间
由于Redis的字符串是动态字符串,可以修改,内部结构类似于Java的ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配。如上图所示,内部为当前字符串实际分配的空间capacity,一般高于实际字符串长度len。
假设我们要存储的结构是:
"name": "xiaowang",
"age": "35"
如果此时将此用户信息的name改为“xiaoli”,再存到redis中,redis是不需要重新分配空间的,使用已分配空间即可。
惰性空间释放
为避免缩短字符串时候的内存重分配操作,sds在数据减少时,并不立刻释放空间。
SDS与C字符串的区别
C语言使用长度为N+1的字符数组来表示长度为N的字符串,并且字符串的最后一个元素是空字符\\0。Redis采用SDS相对于C字符串有如下几个优势:
- 常数复杂度获取字符串长度
- 杜绝缓冲区溢出
- 减少修改字符串时带来的内存重分配次数
- 二进制安全
raw和embstr编码的SDS区别
长度大于39字节的字符串,编码类型为raw,底层数据结构是简单动态字符串(SDS)。
比如当我们执行set story "Long, long, long ago there lived a king ..."(长度大于39)之后,Redis就会创建一个raw编码的String对象。数据结构如下:
长度小于等于39个字节的字符串,编码类型为embstr,底层数据结构则是embstr编码SDS。
embstr编码是专门用来保存短字符串的,它和raw编码最大的不同在于:raw编码会调用两次内存分配分别创建redisObject结构和sdshdr结构,而embstr编码则是只调用一次内存分配,在一块连续的空间上同时包含redisObject结构和sdshdr`结构。
编码转换
int编码和embstr编码的字符串对象在条件满足的情况下会自动转换为raw编码的字符串对象。
- 对于int编码来说,当我们修改这个字符串为不再是整数值的时候,此时字符串对象的编码就会从int变为raw;
- 对于embstr编码来说,只要我们修改了字符串的值,此时字符串对象的编码就会从embstr变为raw。embstr编码的字符串对象可以认为是只读的,因为Redis为其编写任何修改程序。当我们要修改embstr编码字符串时,都是先将转换为raw编码,然后再进行修改。
Redis字符串结构特点
- O(1) 时间复杂度获取:字符串长度,已用长度,未用长度。
- 可用于保存字节数组,支持安全的二进制数据存储。
- 内部实现空间预分配机制,降低内存再分配次数。
- 惰性删除机制,字符串缩减后的空间不释放,作为预分配空间保留。
4.List
列表对象的编码可以是linkedlist或者ziplist,对应的底层数据结构是链表和压缩列表。
默认情况下,当列表对象保存的所有字符串元素的长度都小于64字节,且元素个数小于512个时,列表对象采用的是ziplist编码,否则使用linkedlist编码。可以通过配置文件修改该上限值。
链表
提供了高效的节点重排能力以及顺序性的节点访问方式。在Redis中,每个链表节点使用listNode结构表示:
typedef struct listNode
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点值
void *value;
listNode
多个listNode通过prev和next指针组成双端链表,如下图所示:
为了操作起来比较方便,Redis使用了list结构持有链表。list结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len,而dup、free和match成员则是实现多态链表所需类型的特定函数。
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链表实现的特征总结如下:
- 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(n)。
- 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。
- 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。
- 带链表长度计数器:程序使用list结构的len属性来对list持有的节点进行计数,程序获取链表中节点数量的复杂度为O(1)。
- 多态:链表节点使用void*指针来保存节点值,可以保存各种不同类型的值。
压缩列表
压缩列表。redis的列表键和哈希键的底层实现之一。此数据结构是为了节约内存而开发的。和各种语言的数组类似,它是由连续的内存块组成的,这样一来,由于内存是连续的,就减少了很多内存碎片和指针的内存占用,进而节约了内存。
entry的结构是这样的:
压缩列表记录了各组成部分的类型、长度以及用途:
5.Hash
哈希对象的编码可以是ziplist或者hashtable。
哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节并且保存的键值对数量小于 512 个,使用ziplist 编码;否则使用hashtable;
hash-ziplist
ziplist底层使用的是压缩列表实现,前面已经详细介绍了压缩列表的实现原理。每当有新的键值对要加入哈希对象时,先把保存了键的节点推入压缩列表表尾,然后再将保存了值的节点推入压缩列表表尾。比如,我们执行如下三条HSET命令:
HSET profile name "tom"
HSET profile age 25
HSET profile career "Programmer"
如果此时使用ziplist编码,那么该Hash对象在内存中的结构如下:
hash-hashtable
hashtable 编码的哈希对象使用字典dictht作为底层实现。字典是一种保存键值对的数据结构。
typedef struct dictht
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size-1
unsigned long sizemask;
// 该哈希表已有节点数量
unsigned long used;
dictht
table属性是一个数组,数组中的每个元素都是一个指向dictEntry结构的指针,每个dictEntry结构保存着一个键值对。size属性记录了哈希表的大小,即table数组的大小。used属性记录了哈希表目前已有节点数量。sizemask总是等于size-1,这个值主要用于数组索引。比如下图展示了一个大小为4的空哈希表。
哈希表节点
哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对:
typedef struct dictEntry
// 键
void *key;
// 值
union
void *val;
unit64_t u64;
nit64_t s64;
v;
// 指向下一个哈希表节点,形成链表
struct dictEntry *next;
dictEntry;
key属性保存着键值对中的键,而v属性则保存了键值对中的值。值可以是一个指针,一个uint64_t整数或者是int64_t整数。next属性指向了另一个dictEntry节点,在数组桶位相同的情况下,将多个dictEntry节点串联成一个链表,以此来解决键冲突问题。(链地址法)
字典
Redis字典由dict结构表示:
typedef struct dict
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
//rehash索引
// 当rehash不在进行时,值为-1
int rehashidx;
ht是大小为2,且每个元素都指向dictht哈希表。一般情况下,字典只会使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。rehashidx记录了rehash的进度,如果目前没有进行rehash,值为-1。
rehash
为了使hash表的负载因子(ht[0]).used/ht[0]).size)维持在一个合理范围,当哈希表保存的元素过多或者过少时,程序需要对hash表进行相应的扩展和收缩。rehash(重新散列)操作就是用来完成hash表的扩展和收缩的。rehash的步骤如下:
- 为ht[1]哈希表分配空间,如果是扩展操作,那么ht[1]的大小为第一个大于ht[0].used*2的2^n。比如ht[0].used=5,那么此时ht[1]的大小就为16。(大于10的第一个2^n的值是16)如果是收缩操作,那么ht[1]的大小为第一个大于ht[0].used的2^n。比如ht[0].used=5,那么此时ht[1]的大小就为8。(大于5的第一个2^n的值是8)
- 将保存在ht[0]中的所有键值对rehash到ht[1]中。
- 迁移完成之后,释放掉ht[0],并将现在的ht[1]设置为ht[0],在ht[1]新创建一个空白哈希表,为下一次rehash做准备。
哈希表的扩展和收缩时机:
当服务器没有执行BGSAVE或者BGREWRITEAOF命令时,负载因子大于等于1触发哈希表的扩展操作。
当服务器在执行BGSAVE或者BGREWRITEAOF命令,负载因子大于等于5触发哈希表的扩展操作。
当哈希表负载因子小于0.1,触发哈希表的收缩操作。
渐进式rehash
前面讲过,扩展或者收缩需要将ht[0]里面的元素全部rehash到ht[1]中,如果ht[0]元素很多,显然一次性rehash成本会很大,从影响到Redis性能。为了解决上述问题,Redis使用了渐进式rehash技术,具体来说就是分多次,渐进式地将ht[0]里面的元素慢慢地rehash到ht[1]中。下面是渐进式rehash的详细步骤:
- 为ht[1]分配空间。
- 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash正式开始。
- 在rehash进行期间,每次对字典执行添加、删除、查找或者更新时,除了会执行相应的操作之外,还会顺带将ht[0]在rehashidx索引位上的所有键值对rehash到ht[1]中,rehash完成之后,rehashidx值加1。
- 随着字典操作的不断进行,最终会在某个时刻迁移完成,此时将rehashidx值置为-1,表示rehash结束。
渐进式rehash一次迁移一个桶上所有的数据,设计上采用分而治之的思想,将原本集中式的操作分散到每个添加、删除、查找和更新操作上,从而避免集中式rehash带来的庞大计算。
因为在渐进式rehash时,字典会同时使用ht[0]和ht[1]两张表,所以此时对字典的删除、查找和更新操作都可能会在两个哈希表进行。比如,如果要查找某个键时,先在ht[0]中查找,如果没找到,则继续到ht[1]中查找。
hash对象中的hashtable
HSET profile name "tom"
HSET profile age 25
HSET profile career "Programmer"
还是上述三条命令,保存数据到Redis的哈希对象中,如果采用hashtable编码保存的话,那么该Hash对象在内存中的结构如下:
6.Set
集合对象的编码可以是intset或者hashtable。
当集合对象保存的元素都是整数,并且个数不超过512个时,使用intset编码,否则使用hashtable编码。
set-intset
整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,它可以保存类型为int16_t、int32_t或者int64_t的整数值,并且保证集合中的数据不会重复。Redis使用intset结构表示一个整数集合。
typedef struct intset
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
intset;
contents数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项,各个项在数组中按值大小从小到大有序排列,并且数组中不包含重复项。虽然contents属性声明为int8_t类型的数组,但实际上,contents数组不保存任何int8_t类型的值,数组中真正保存的值类型取决于encoding。如果encoding属性值为INTSET_ENC_INT16,那么contents数组就是int16_t类型的数组,以此类推。
当新插入元素的类型比整数集合现有类型元素的类型大时,整数集合必须先升级,然后才能将新元素添加进来。这个过程分以下三步进行。
- 根据新元素类型,扩展整数集合底层数组空间大小。
- 将底层数组现有所有元素都转换为与新元素相同的类型,并且维持底层数组的有序性。
- 将新元素添加到底层数组里面。
还有一点需要注意的是,整数集合不支持降级,一旦对数组进行了升级,编码就会一直保持升级后的状态。
举个栗子,当我们执行SADD numbers 1 3 5向集合对象插入数据时,该集合对象在内存的结构如下:
set-hashtable
hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象对应一个集合元素,字典的值都是NULL。当我们执行SADD fruits "apple" "banana" "cherry"向集合对象插入数据时,该集合对象在内存的结构如下:
7.Zset
有序集合的编码可以是ziplist或者skiplist。
当有序集合保存的元素个数小于128个,且所有元素成员长度都小于64字节时,使用ziplist编码,否则,使用skiplist编码。
zset-ziplist
ziplist编码的有序集合使用压缩列表作为底层实现,每个集合元素使用两个紧挨着一起的两个压缩列表节点表示,第一个节点保存元素的成员(member),第二个节点保存元素的分值(score)。
压缩列表内的集合元素按照分值从小到大排列。如果我们执行ZADD price 8.5 apple 5.0 banana 6.0 cherry命令,向有序集合插入元素,该有序集合在内存中的结构如下:
zset-skiplist
skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表。
typedef struct zset
zskiplist *zs1;
dict *dict;
继续介绍之前,我们先了解一下什么是跳跃表。
跳跃表
跳跃表(skiplist)是一种有序的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
Redis的跳跃表由zskiplistNode和zskiplist两个结构定义,zskiplistNode结构表示跳跃表节点,zskiplist保存跳跃表节点相关信息,比如节点的数量,以及指向表头和表尾节点的指针等。
跳跃表节点 zskiplistNode
跳跃表节点zskiplistNode结构定义如下:
typedef struct zskiplistNode
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
level[];
zskiplistNode;
下图是一个层高为5,包含4个跳跃表节点(1个表头节点和3个数据节点)组成的跳跃表:
- 每次创建一个新的跳跃表节点的时候,会根据幂次定律(越大的数出现的概率越低)随机生成一个1-32之间的值作为当前节点的"层高"。每层元素都包含2个数据,前进指针和跨度。 前进指针:每层都有一个指向表尾方向的前进指针,用于从表头向表尾方向访问节点。 跨度:层的跨度用于记录两个节点之间的距离。
- 后退指针(BW) 节点的后退指针用于从表尾向表头方向访问节点,每个节点只有一个后退指针,所以每次只能后退一个节点。
- 分值和成员 节点的分值(score)是一个double类型的浮点数,跳跃表中所有节点都按分值从小到大排列。节点的成员(obj)是一个指针,指向一个字符串对象。在跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点的分值确实可以相同。
需要注意的是,表头节点不存储真实数据,并且层高固定为32,从表头节点第一个不为NULL最高层开始,就能实现快速查找。
跳跃表 zskiplist
实际上,仅靠多个跳跃表节点就可以组成一个跳跃表,但是Redis使用了zskiplist结构来持有这些节点,这样就能够更方便地对整个跳跃表进行操作。比如快速访问表头和表尾节点,获得跳跃表节点数量等等。zskiplist结构定义如下:
typedef struct zskiplist
// 表头节点和表尾节点
struct skiplistNode *header, *tail;
// 节点数量
unsigned long length;
// 最大层数
int level;
zskiplist;
下图是一个完整的跳跃表结构示例:
有序集合对象的skiplist实现
前面讲过,skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表。
typedef struct zset
zskiplist *zs1;
dict *dict;
zset结构中的zs1跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素。通过跳跃表,可以对有序集合进行基于score的快速范围查找。zset结构中的dict字典为有序集合创建了从成员到分值的映射,字典的键保存了成员,字典的值保存了分值。通过字典,可以用O(1)复杂度查找给定成员的分值。
假如还是执行ZADD price 8.5 apple 5.0 banana 6.0 cherry命令向zset保存数据,如果采用skiplist编码方式的话,该有序集合在内存中的结构如下:
8.总结
Redis底层数据结构主要包括简单动态字符串(SDS)、链表、字典、跳跃表、整数集合和压缩列表六种类型,并且基于这些基础数据结构实现了字符串对象、列表对象、哈希对象、集合对象以及有序集合对象五种常见的对象类型。每一种对象类型都至少采用了2种数据编码,不同的编码使用的底层数据结构也不同。
以上是关于聊一聊redis十种数据类型及底层原理的主要内容,如果未能解决你的问题,请参考以下文章
面试干货10——聊一聊Redis的应用吧!(实现分布式锁缓存抽奖热搜点赞商品筛选..)