Redis是key-value数据结构,k-v 在java中会想到map,redis中是叫dict。
redis还是数据库,它是使用数组和链表来存储海量的数据的。
hash(key) --> 自然数 % array.size --> 这样就得到了数组的下标了,然后把key-value保存在数组的这个位置
如果产生了hash冲突,是使用链表来解决的,使用的头插法
Redis中默认有16个数据库,底层是redisDb对象,代码如下
typedef struct redisDb {
dict *dict; // 上面提到的 key-value 就是存储在dict中的
dict *expires; // 存储各个key的过期时间
dict *blocking_keys; // 存储的是阻塞队列相关的内容
dict *ready_keys; // 维护key和client客户端连接之间的对应关系
dict *watched_keys; // 关于事务处理是存放在watched_keys
int id; // 这个就是数据库的id 0~15
long long avg_ttl;
unsigned long expires_cursor;
list *defrag_later;
} redisDb;
重点是要讲解dict
,我们可以理解为这就是一个hashtable
typedef struct dict {
dictType *type; // 指定hash算法,并且产生hash冲突后去进行equals比较 是否进行覆盖或者头插法插入元素
void *privdata;
dictht ht[2]; // 这就是一个hashtable结构,ht[0]是老数组 ht[1]是新数组,指向下面的dictht对象
long rehashidx;
unsigned long iterators;
} dict;
dictht
的代码如下所示。这就是一个hashtable的数据结构,每个dict
字典都有两个dictht
,目的就是实现一个渐进式的rehash,其实就是数组的扩容,把老数组的内容拷贝到扩容后新数组中去。Redis的扩容是newSize=oldSize*2,扩容完成后并不是一次性把所有的key-value移动到新数组中去,而是一次移动一部分数据,然后去处理用户请求,过一会了又移动一部分,扩容是在master线程中执行的,扩容触发的条件是size : used = 1。当把ht[0]中的数据都移动到ht[1]之后,会把ht[0]指向ht[1],ht[1]=null
在移动数据过程中如果客户端进行了更新操作,Redis会操作两个dictht,先去老数组ht[0]中找,如果没有找到就直接去新数组ht[1]中操作,如果老数组找到了就是在老数组中去操作,同时会把这个hash桶中的数据全都移动到新数组中去。
typedef struct dictht {
dictEntry **table;
unsigned long size; // hash桶个数、数组的长度
unsigned long sizemask; // size-1 计算key在数组中的下标时 hash(key)%2^n == hash(key) & (2^n-1),位运行要快,sizemask存在的意义
unsigned long used; // 已经存在了多少个元素,不是使用hash桶的个数
} dictht;
我们现在知道了dictht
的作用与结构,那么也就知道Key-value其实存储在这其中的,具体就是存储在dictEntry
指针中的,如下所示
dictEntry
代码如下所示,其实就是存储了key、value、next三个元素
typedef struct dictEntry {
void *key; // 这个指针就是指向的一个SDS的对象
union {
void *val; // void *表示一个空指针,可以指向任意的数据类型,实际上指向下面的redisObject对象
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next; // 产生hash冲突后,链表就是靠next实现的
} dictEntry;
val这个指针指向的对象类型可能是String、hash、list、set、zset…,当然Redis中也不是简单的直接用这些类型对象,而是用了redisObject
对象来封装
typedef struct redisObject {
// type当key不存在时根据客户端执行的命令确定value是什么类型,比如set命令->string。
// 当key存在时会约束客户端命令的操作,比如name是一个string类型,但是我却执行lpush name [value...]那么这里直接就抛错了
unsigned type:4;
unsigned encoding:4; // 某个key对应的value,这个value在redis内存中到底是什么类型的编码
unsigned lru:LRU_BITS; // 设置了内存淘汰策略时才会用到
int refcount; // 类似于java的垃圾标记算法——引用计数法,用这个来管理内存回收
void *ptr; // 指向value内存中真实存储的位置
} robj;
RedisDB主体数据结构如下图所示
Redis中所有的key都是String类型的,底层是使用的SDS类型,没有使用c语言的字符数组去实现字符串。因为c语言是以\0
作为字符串结束标识的,redis需要支持各种语言,当数据以stream流的形式传输到Redis-server后可能某个字符串中就包含这个\0
字符。
SDS simple dynamic string
关键特点是:
二进制安全的数据结构
它有一个属性指定了当前字符串的长度,然后根据这个长度去读取字符串,而不是根据\0
作为结束标识
提供了内存预分配机制,避免频繁的内存分配
如果我们使用append等命令修改一个字符串时,会去判断当前剩余空间是否足够,如果不足够这则按照(length + addlen) * 2
去重新分配内存 创建spring对象,当达到了1024*1024后 也就是1M后就会按照每次增加1M去扩容
兼容c语言的函数库
会自动的在字符串的结尾添加\0
去兼容c语言的函数库
SDS
free:6
len:10
char buf[] = "hushang123"
在redis3.2以前,len它是整形占4个字节 32位,也就是说它len的值最大为2^32 -1 ,但是我们一般一个字符串不会很长一般都是100个字符以内。在redis3.2之后的版本中SDS就有了几种,如果字符串的长度在2^5 -1之内就使用sdshdr5类型,如果字符串的长度在2^8 -1之内就使用sdshdr8类型…
会根据不同的业务数据长度创建不同的SDS业务类型
如上图所示,在redis3.2之后的版本中,SDS还多了一些其他的属性
我们接下来再来看value部分的底层结构,redis中的value它能支持的常见数据类型有String、hash、list、set、zset…
下面我新插入了三个值,首先查看类型都是string,但是查看value的encoding编码却有三种情况
127.0.0.1:6379> set name hushang
OK
127.0.0.1:6379> set age 23
OK
127.0.0.1:6379> set script aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
OK
# 类型都是string
127.0.0.1:6379> type name
string
127.0.0.1:6379> type age
string
127.0.0.1:6379> type script
string
127.0.0.1:6379> object encoding name
"embstr"
127.0.0.1:6379> object encoding age
"int"
127.0.0.1:6379> object encoding script
"raw"
raw
其实就是原始类型SDS,上面讲解Key的结构时已经做了介绍,这里就不在重新写一遍了,
int
这种就是整形,它的特点是内存长度是固定了,最多占8字节 64bit,不会有动态扩容机制。如下所示redisObject
对象中的ptr是最终指向内存中的value值,也占8字节,那么能不能直接把int值存放在ptr中嘞,这样也就省了又开辟一块内存空间,也省略一次内存寻址。其实redis底层也就是这么来做的。redis会首先判断当前value的长度是否超过数值能表示的最大长度,然后调一个函数判断是否能把当前value变为一个数值型,如果都满足则*ptr直接存储这个值
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS;
int refcount;
void *ptr; // 指向value内存中真实存储的位置
} robj;
embstr
cpu去内存读取数据不是要多少数据就读多少数据,有一个缓存行cache line的概念,每次读取数据最少读取一个cache line,一般linux系统一个cache line的大小是64byte字节。
保存value的对象是如上所示的readsObject
对象,它占4bit + 4bit + 24bit + 4byte + 8byte = 16byte
,但是缓存行一次读64字节,那么也就意味着后面48字节根本用不上。那么这一块就可以优化,我们可以在通过readsObject
对象里面的*ptr
再去读取一次数据,把后面的48字节利用上。
*ptr
指向的是一个SDS对象,从上一节介绍SDS我们可以知道SDS分为sdshdr5、sdshdr8、sdshdr16…,48个字节是在32~64之间,所以这里会使用sdshdr8
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[];
};
而一个sdshdr8
中len占8bit、alloc占8bit、flags占1byte、之前提到了sds为了满足c语言的函数库会在buf[]后面加一个\0
,所以buf[]至少也占1byte,现在readsObject
+ sdshdr8
= 16byte + 4byte = 20字节,目前还剩余44字节未使用。我们保存的value是存在buf[]中的,那么我们的value字符串如果 <= 44字节那么就是能一次cpu缓存行读取就能把具体值给读取出来,不需要进行多次内存寻址。
所以value<= 44字节 就可以是embstr
127.0.0.1:6379> set name aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
OK
127.0.0.1:6379> strlen name
(integer) 44
127.0.0.1:6379> object encoding name
"embstr"
# 超过44字节的value就变成了raw类型了
127.0.0.1:6379> set name aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab
OK
127.0.0.1:6379> strlen name
(integer) 45
127.0.0.1:6379> object encoding name
"raw"
redis中list结构的一个特点是它存储的数据类型是不固定的,可以是string,也可以是数值…,如果是string可能需要好几个字节,如果是数值可能一个字节就能存储了,并且还可以从两端存两端取。
# 往list的两端存元素,
127.0.0.1:6379> lpush hs_list aa bb cc
(integer) 3
127.0.0.1:6379> rpush hs_list dd ee 123
(integer) 6
127.0.0.1:6379> lpop hs_list
"cc"
Redis它并没有直接使用链表结构来实现list,因为如果采用链表那么每个元素都需要pre和next两个指针,而在64位操作系统中,两个指针就占了16byte,反而数据value却占一点点内存,这就出现了胖指针。并且使用链表结构,各个元素内存是不连续的,会出现很多内存碎片化
Redis采用了quicklist双端链表和ziplist作为list的底层实现
我们首先来看一下ziplist底层的编码结构
zlbytes
,标识当前ziplist中存了多少数据,内存容量
zltail
,标记尾结点的内存地址
zllen
,当前ziplist中有多少个元素
entry
,list中的具体元素
zlend
,占1字节,恒等于255,表示ziplist结尾部分。
每个Entry
包含三部分,因为list需要满足两端遍历,所以每个entry对象包含了前一个元素的信息与自己本身的信息
preawlen
:包含前一个元素的信息
首先会判断preawlen这个字节代表的数据是否小于254,这个254就是一个标识,因为255已经被ziplist结尾标识给占用了。目的是从后往前遍历时大概知道前面一个元素占用多大内存空间,如果是小于254则占用一个字节标识,如果大于254则用5字节
len
:标识当前元素长度,它有很多的含义,具体如上图所示
data
:当前元素的具体内容值
List并没有把所有的元素都存储在上图绿色方框位置,还使用了双端链表quicklist,具体结构如下图所示
list共分为了多个ziplist,每个ziplist充当双端链表quicklist中的一个元素节点。当要插入或删除元素时只需要修改一个ziplist即可,当一个ziplist存储的元素过多时,它还会进行分裂,创建一个新的ziplist出来
可以通过redis.conf设置每个ziplist的最大容量,quicklist的数据压缩范围,提升数据存取效率
# 单个ziplist节点最大能存储 8kb ,超过则进行分裂,将数据存储在新的ziplist节点中
list-max-ziplist-size -2
# 头节点和尾结点这两个ziplist可能会频繁访问,而中间节点的ziplist基本很少访问,那么中间的节点是否需要进行压缩,通过下面的配置
# 0 代表所有节点,都不进行压缩,1, 代表从头节点往后走一个,尾节点往前走一个不用压缩,其他的全部压缩,2,3,4 ... 以此类推
list-compress-depth 1
hash的底层数据结构其实就是一个dict字典,其实从上面介绍RedisDB的时候已经结束了关于dict
相关的很多知识。
Hash当数据量比较少或单元元素比较小时,底层用ziplist存储
我们知道dict
,也就是一个hashtable是无序的,接下来再看下面的案例
# 刚开始存放的元素,获取出来竟然是有序的,按照我们放入的顺序输出
127.0.0.1:6379> hset user:1 name hushang age 23 k1 v1 k2 v2 k3 v3
(integer) 5
127.0.0.1:6379> hgetall user:1
1) "name"
2) "hushang"
3) "age"
4) "23"
5) "k1"
6) "v1"
7) "k2"
8) "v2"
9) "k3"
10) "v3"
# 接下来在存一个比较大的值,再查询发现就是乱序输出了
127.0.0.1:6379> hset user:1 k4 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
(integer) 1
127.0.0.1:6379> hgetall user:1
1) "name"
2) "hushang"
3) "k3"
4) "v3"
5) "k4"
6) "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
7) "k1"
8) "v1"
9) "k2"
10) "v2"
11) "age"
12) "23"
127.0.0.1:6379> object encoding user:1
"hashtable"
这是因为Hash当数据量比较少或单元元素比较小时,底层用ziplist
存储,否则才用字典(dict
)来存储
Hash数据大小和元素数量阈值可以通过如下redis.conf参数设置
hash-max-ziplist-entries 512 # ziplist 元素个数超过 512 ,将改为hashtable编码
hash-max-ziplist-value 64 # 单个元素大小超过 64 byte时,将改为hashtable编码
面试题:String和hash数据结构
string:key-value
hash:key-dict
- 拿User对象那举例,如果使用String,user对象的每个属性都是一个key-value,那么就会存在比较多的key,之前讲DB的时候就说了Redis底层是使用的dict 类似于hashtable的数结构,如果存在这么多的key,也就很容易触发rehash进行数组扩容。而使用hash结构的话就只会创建一个key。
- 关于过期时间,Hash这种只能给最外层的key设置过期时间,不能给field设置过期时间
set为无序的,自动去重的集合,Set数据结构底层实现为一个value为null的字典dict,只用了dict中的key;
set当所有数据可以用整形表示时,set集合将被编码为intset数据结构,当下面两个条件任意一个无法满足时Set集合将用hashtable存储数据:
元素个数大于set-max-intset-entries
。
redis.conf配置文件中默认值是512,表示intset 能存储的最大元素个数,超过则用hashtable编码
元素无法用整形表示
# 先全部存储数值型,可以发现最后打印输出的内容竟然是排好序的。
# 目的是使用intset会内存消耗更友好,排序的目的是能更快找元素,比如新增时判断这个值是否已经存在了
127.0.0.1:6379> sadd hs-set 3 5 1 6 8 2 2 2 2
(integer) 6
127.0.0.1:6379> smembers hs-set
1) "1"
2) "2"
3) "3"
4) "5"
5) "6"
6) "8"
127.0.0.1:6379> type hs-set
set
127.0.0.1:6379> object encoding hs-set
"intset"
# 此时我在加一个不是数值型的元素,就会使用hashtable了
127.0.0.1:6379> sadd hs-set a
(integer) 1
127.0.0.1:6379> smembers hs-set
1) "2"
2) "8"
3) "3"
4) "a"
5) "5"
6) "6"
7) "1"
127.0.0.1:6379> type hs-set
set
127.0.0.1:6379> object encoding hs-set
"hashtable"
dict的底层代码在RedisDB部分时已经详细分析过了,接下来我们分析一下intset
的代码
typedef struct intset {
uint32_t encoding; // 编码类型
uint32_t length; // 元素个数
int8_t contents[]; // 具体元素存储
} intset;
// intset有三种数据类型,分别占用不同的bit位数
// define INTSET_ENC_INT16 (sizeof(int16_t))
// define INTSET_ENC_INT32 (sizeof(int32_t))
// define INTSET_ENC_INT64 (sizeof(int64_t))
下图就是一个数组的结构,根据不同大小的数值选择不同的intset类型,这样也就知道了每个元素占多数bit了,那么也就能从数组中拿到元素了。
Zset为有序、自动去重的集合数据类型、zset底层数据结构为 字典dict + 跳表skiplist。当数据量较少时使用ziplist编码结构去存储
zset-max-ziplist-entries 128 # 元素个数超过128 ,将用skiplist编码
zset-max-ziplist-value 64 # 单个元素大小超过 64 byte, 将用 skiplist编码
# 插入三个元素,分值分别是100 120 200
127.0.0.1:6379> zadd hs-zset 100 a 120 b 200 c
(integer) 3
# 查询某个范围的值 -1表示查询所有
127.0.0.1:6379> zrange hs-zset 0 -1
1) "a"
2) "b"
3) "c"
127.0.0.1:6379> zrange hs-zset 0 1
1) "a"
2) "b"
Zset它是有序的数据结构,而底层如果要使用一块连续的内存空间维护有序的数据,如果新增数据那么就会造成频繁的数据移动,所以Redis底层是采用的链表的结构来维护的多个数据,但是使用链表随机查找又比较慢,Redis再又加上了跳表的数据结构来提升查询效率。同时Zset底层除了跳表之外还有一个字典dict,它的作用是能够以O(1)的时间复杂度快速找到某个元素与对应的分值。
接下来我们详细看一下跳表的实现,
// 创建zset 数据结构: 字典 + 跳表
robj *createZsetObject(void) {
zset *zs = zmalloc(sizeof(*zs));
robj *o;
// dict用来查询数据到分数的对应关系, 如 zscore 就可以直接根据 元素拿到分值
zs->dict = dictCreate(&zsetDictType,NULL);
// skiplist用来根据分数查询数据(可能是范围查找)
zs->zsl = zslCreate();
// 设置对象类型
o = createObject(OBJ_ZSET,zs);
// 设置编码类型
o->encoding = OBJ_ENCODING_SKIPLIST;
return o;
}
// zset数据结构,是字典dict和跳表skiplist来实现的
// 跳表能够实现范围查找或根据分数查询数据 字典能够快速查询到某个数据、分数
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
// 跳表
typedef struct zskiplist {
struct zskiplistNode *header, *tail; // 指向头节点和尾结点,表头不存储元素,拥有最高的层级
unsigned long length; // 元素个数
int level; // 层数
} zskiplist;
// 跳表节点
typedef struct zskiplistNode {
sds ele; // 具体元素值
double score; // 分数
struct zskiplistNode *backward; // 前一个节点
struct zskiplistLevel {
struct zskiplistNode *forward; // 同一层级中的下一个节点指针
unsigned long span;
} level[];
} zskiplistNode;
跳表skiplist的结构图
其实就是一种类似于下图所示的结构
因为我们可以使用zrange
命令按照分值升序输出,也可以使用zrevrange
按照分值降序输出,所以跳表skiplistNode才有前一个节点执行与下一个节点指针
skiplist
中的level记录的是最高的层高数,因为索引遍历时是从最高的那一层开始往下找的,初始层高是1。
当新增一个元素时,最底层的数据层肯定是会插入的,那么问题是这个值需要不要在各个索引层中也新增嘞,这也是通过一个随机函数决定的,越高的层数新增的概率越低
底层实现是靠geo算法+Zset数据结构来实现的。
地图是二维的,但Zset排序的数据是一维的,他们是怎么联系在一起使用的嘞?
经度范围是东经180到西经180,纬度范围是南纬90到北纬90,我们设定西经为负,南纬为负
所以地球上的经度范围就是[-180, 180],纬度范围就是[-90,90]。
如果以本初子午线、赤道为界,地球可以分成4个部分。如果纬度范围[-90°, 0°)用二进制0代表,(0°, 90°]用二进制1代表,经度范围[-180°, 0°)用二进制0代表,(0°, 180°]用二进制1代表,那么地球可以分成如下(左图 )4个部分
我们在每一个小格子中还可以进一步细分,每个小格子又可以分为四个小格子,但是高2位不变,低2位又可以分为00 01 10 11
四种,再继续拆分最终就会得到一串二进制的经纬度。上面的图片右边部分用线连接的就是Z阶曲线
通过GeoHash算法,可以将经纬度的二维坐标变成一个可排序、可比较的的字符串编码。 在编码中的每个字符代表一个区域,并且前面的字符是后面字符的父区域。其算法的过程如下:
根据GeoHash 来计算纬度的二进制编码地球纬度区间是[-90,90], 如某纬度是39.92324,可以通过下面算法来进行维度编码:
纬度产生的编码为1011 1000 1100 0111 1001,经度产生的编码为1101 0010 1100 0100 0100。偶数位放经度,奇数位放纬度,把2串编码组合生成新串:11100 11101 00100 01111 00000 01101 01011 00001。
现在得到了一串二进制,然后通过geohash算法对二进制进行编码生成一段字符串,具体生成的方法是通过base32进行编码
使用0-9、b-z(去掉a, i, l, o)这32个字母进行base32编码,将二进制中每5位作为一个数字(2^5 - 1 =31),首先将11100 11101 00100 01111 00000 01101 01011 00001转成十进制 28,29,4,15,0,13,11,1,十进制对应的编码就是wx4g0ec1。同理,将编码转换成经纬度的解码算法与之相反
它与Zset的关联,因为这一串编码之后的字符串是有序的,那么我们也就可以使用Zset来存储。
GEO_STEP_MAX
的值是26,表示Redis中会底层会拆分26次,经纬度都需要拆分,所以最后需要26*2=52bit来存储一整个二进制串。然后在根据52bit位的值生成Zset需要的score,再存储在Zset中
求基数,结果是一个近似值
HyperLogLog基于概率论中伯努利试验并结合了极大似然估算方法,并做了分桶优化。具体实现原理参考下一篇文章——Redis HyperLogLog底层实现
亿级用户日活统计,一天内用户登录了就记一次
bitmap底层就是基于String来实现的。
bitmap我们可以使用日期作为key,而value底层就是一串二进制的数组 比如0 1 1 1 0 0 0 0 1 0 1 1 1 0 0...
,如果用户登录了我们就可以根据用户id定位到具体的某一个bit位上,然后将值改为1。一个bit为表示一个用户,这样就会非常节省空间
如果数据量比较大可以使用bitmap,如果数据量不大就没必要使用bitmap。bitmap还可以进行位运算,& |