Redis有哪些数据类型
Redis主要有5种数据类型,包括String,List,Hash,Set,Zset,满足大部分的使用要求
数据类型 | 可以存储的值 | 操作 | 应用场景 |
---|---|---|---|
STRING | 字符串、整数或者浮点数 | ||
最大容量512M | 对整个字符串或者字符串的其中一部分执行操作对整数和浮点数执行自增或者自减操作 | 做简单的键值对缓存,计数器,分布式锁 | |
LIST | 列表 | 从两端压入或者弹出元素对单个或者多个元素进行修剪,只保留一个范围内的元素 | 存储一些列表型的数据结构,类似粉丝列表、文章的评论列表之类的数据 |
HASH | 包含键值对的无序散列表 | 添加、获取、移除单个键值对获取所有键值对 检查某个键是否存在 | 结构化的数据,比如一个对象 |
SET | 无序集合 | 添加、获取、移除单个元素检查一个元素是否存在于集合中 计算交集、并集、差集从集合里面随机获取元素 | 交集、并集、差集的操作,比如交集,可以把两个人的粉丝列表整一个交集 |
ZSET | 有序集合 | 添加、获取、删除元素根据分值范围或者成员来获取元素 计算一个键的排名 | 去重但可以排序,如获取排名前几名的用户 |
String
常用命令:get/set/del/incr/decr/incrby/decrby
实战场景1:单值缓存、对象缓存(分布式session)
方案:
业务逻辑上:先从redis读取,有值就从redis读取,没有则从mysql读取,并写一份到redis中作为缓存,注意要设置过期时间。
键值设计上:
直接将用户一条mysql记录做序列化(通常序列化为json)作为值,userInfo:userid 作为key,键名如:userInfo:123,value存储对应用户信息的json串。如 key为:“user: id:name:1”, value为"{“name”:“leijia”,“age”:18}"。
实战场景2:计数器(限定某个ip特定时间内的访问次数,分布式唯一ID,限流,阅读数等)
方案:
用key记录IP,value记录访问次数,同时key的过期时间设置为60秒,如果key过期了则重新设置,否则进行判断,当一分钟内访问超过100次,则禁止访问。
Hash
以购物车为例子,用户id设置为key,那么购物车里所有的商品就是用户key对应的值了,每个商品有id和购买数量,对应hash的结构就是商品id为field,商品数量为value。如图所示:
如果将商品id和商品数量序列化成json字符串,那么也可以用上面讲的string类型存储。下面对比一下这两种数据结构:
对比项 | string(json) | hash |
---|---|---|
效率 | 很高 | 高 |
容量 | 低 | 低 |
灵活性 | 低 | 高 |
序列化 | 简单 | 复杂 |
总结一下:
当对象的某个属性需要频繁修改时,不适合用string+json,因为它不够灵活,每次修改都需要重新将整个对象序列化并赋值;如果使用hash类型,则可以针对某个属性单独修改,没有序列化,也不需要修改整个对象。比如,商品的价格、销量、关注数、评价数等可能经常发生变化的属性,就适合存储在hash类型里。
List
- **实现队列(FIFO)**Lpush(左边进) + Rpop(右边出)
- 消息队列(由于出现了stream可以用它实现)
- **实现栈(FILO)**Lpush(左边进) + Lpop(左边出)
- 实现阻塞队列Lpush(左边进) + BRpop(相比于Rpop会阻塞)
- 很经典的一个例子:公众号、微博消息推送
我关注了公众号A
- 公众号A发了篇文章: 公众号A的id 文章id
- 我要查看公众号A最新的消息(一页四个消息):公众号A的id 0 4
Set
集合的特点是无序性和确定性(不重复)。
- 集合操作
求交集: sinter set1 set2 set3 结果为 {c}
求并集: sunion set1 set2 set3 结果为 {a,b,c,d,e}
求差集: sdiff set1 set2 set3 结果为 {a}
-
很经典的一个例子:微博的关注模型
- boom关注了a,b,c: sadd boom a b c
- Tom关注了b,c,d: sadd tom b c d
- b关注了tom: sadd b tom
- boom和tom的共同关注的人: sinter boom tom 得到c
- boom关注的人也关注了tom: sismember tom b
- boom可能认识的人: sdiff tom b
实战场景:收藏夹
例如QQ音乐中如果你喜欢一首歌,点个『喜欢』就会将歌曲放到个人收藏夹中,每一个用户做一个收藏的集合,每个收藏的集合存放用户收藏过的歌曲id。
key为用户id,value为歌曲id的集合。
Sorted Set
有序集合的特点是有序,无重复值。与set不同的是sorted set每个元素都会关联一个score属性,redis正是通过score来为集合中的成员进行从小到大的排序。
ZSet常用操作
- 往有序集合key中加入带分值元素: ZADD key score member [[score member]…]
- 从有序集合key中删除元素: ZREM key member [member…]
- 返回有序集合key中元素member的分值: ZSCORE key member
- 为有序集合key中元素member的分值加上increment: ZINCRBY key increment member
- 返回有序集合key中元素个数: ZCARD key
- 正序获取有序集合key从start下标到stop下标的元素: ZRANGE key start stop [WITHSCORES]
- 倒序获取有序集合key从start下标到stop下标的元素: ZREVRANGE key start stop [WITHSCORES]
- ZSet集合操作
并集计算: ZUNIONSTORE destkey numkeys key [key …]
交集计算: ZINTERSTORE destkey numkeys key [key…]
很经典的一个例子:微博热搜排行榜
- 点击新闻: ZINCRBY hotNews_20210728 基金大跌
- 展示当日排行前十: ZREVRANGE hotNews_20210728 0 9 WITHSCORES
- 七日搜索榜单计算: ZUNIONSTORE hotNews_20210722_20210728 7 hotNews_20210722 hotNews_20210723… hotNews_20210728
- 展示七日排行前十: ZREVRANGE hotNews_20210722_20210728 0 9 WITHSCORES
HyperLogLog
通常用于基数统计。使用少量固定大小的内存,来统计集合中唯一元素的数量。统计结果不是精确值,而是一个带有0.81%标准差(standard error)的近似值。所以,HyperLogLog适用于一些对于统计结果精确度要求不是特别高的场景,例如网站的UV统计。
Geo
redis 3.2 版本的新特性。可以将用户给定的地理位置信息储存起来, 并对这些信息进行操作:获取2个位置的距离、根据给定地理位置坐标获取指定范围内的地理位置集合。
链接
Stream
Redis5的新特性。主要用于消息队列,类似于 kafka,可以认为是 pub/sub 的改进版。提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失。
链接
- Consumer Group :消费组,使用 XGROUP CREATE 命令创建,一个消费组有多个消费者(Consumer), 这些消费者之间是竞争关系。
- last_delivered_id :游标,每个消费组会有个游标 last_delivered_id,任意一个消费者读取了消息都会使游标 last_delivered_id 往前移动。
- pending_ids :消费者(Consumer)的状态变量,作用是维护消费者的未确认的 id。 pending_ids 记录了当前已经被客户端读取的消息,但是还没有 ack (Acknowledge character:确认字符)。如果客户端没有ack,这个变量里面的消息ID会越来越多,一旦某个消息被ack,它就开始减少。这个pending_ids变量在Redis官方被称之为PEL,也就是Pending Entries List,这是一个很核心的数据结构,它用来确保客户端至少消费了消息一次,而不会在网络传输的中途丢失了没处理。
- 消息ID: 消息ID的形式是timestampInMillis-sequence,例如1527846880572-5,它表示当前的消息在毫米时间戳1527846880572时产生,并且是该毫秒内产生的第5条消息。消息ID可以由服务器自动生成,也可以由客户端自己指定,但是形式必须是整数-整数,而且必须是后面加入的消息的ID要大于前面的消息ID。
- 消息内容: 消息内容就是键值对,形如hash结构的键值对,这没什么特别之处。
消息ID的设计是否考虑了时间回拨的问题?
XADD生成的1553439850328-0,就是Redis生成的消息ID,由两部分组成:时间戳-序号。时间戳是毫秒级单位,是生成消息的Redis服务器时间,它是个64位整型(int64)。序号是在这个毫秒时间点内的消息序号,它也是个64位整型。
为了保证消息是有序的,因此Redis生成的ID是单调递增有序的。由于ID中包含时间戳部分,为了避免服务器时间错误而带来的问题(例如服务器时间延后了),Redis的每个Stream类型数据都维护一个latest_generated_id属性,用于记录最后一个消息的ID。若发现当前时间戳退后(小于latest_generated_id所记录的),则采用时间戳不变而序号递增的方案来作为新消息ID(这也是序号为什么使用int64的原因,保证有足够多的的序号),从而保证ID的单调递增性质。
bitmap
- 位图索引
Oracle等有使用,比如一张信息表,筛选其中是男生的个数,通过某个位即可判断是否是男生。
位图索引适合只有几个固定值的列,如性别、婚姻状况、行政区等等,而身份证号这种类型不适合用位图索引。适合静态数据,频繁更新时容易阻塞(比如更新男生,锁住了所有男生列)
public class BitMap {
//保存数据的
private byte[] bits;
//能够存储多少数据
private int capacity;
public BitMap(int capacity) {
this.capacity = capacity;
//1bit能存储8个数据,那么capacity数据需要多少个bit呢,capacity/8+1,右移3位相当于除以8
bits = new byte[(capacity >> 3) + 1];
}
public void add(int num) {
// num/8得到byte[]的index
int arrayIndex = num >> 3;
// num%8得到在byte[index]的位置
int position = num & 0x07;
//将1左移position后,那个位置自然就是1,然后和以前的数据做|,这样,那个位置就替换成1了。
bits[arrayIndex] |= (1 << position);
}
public boolean contain(int num) {
// num/8得到byte[]的index
int arrayIndex = num >> 3;
// num%8得到在byte[index]的位置
int position = num & 0x07;
//将1左移position后,那个位置自然就是1,然后和以前的数据做&,判断是否为0即可
return (bits[arrayIndex] & (1 << position)) == 0;
}
public void clear(int num) {
// num/8得到byte[]的index
int arrayIndex = num >> 3;
// num%8得到在byte[index]的位置
int position = num & 0x07;
//将1左移position后,那个位置自然就是1,然后对取反,再与当前值做&,即可清除当前的位置了.
bits[arrayIndex] &= ~(1 << position);
}
public static void main(String[] args) {
BitMap bitmap = new BitMap(100);
bitmap.add(7);
System.out.println("插入7成功");
boolean isexsit = bitmap.contain(7);
System.out.println("7是否存在:" + isexsit);
bitmap.clear(7);
isexsit = bitmap.contain(7);
System.out.println("7是否存在:" + isexsit);
}
}
- 布隆过滤器
将数据哈希映射在Bitmap上,然后通过位运算能够快速地判定数据是否已经存在。
- 邮件过滤,使用布隆过滤器来做邮件黑名单过滤
- 对爬虫网址进行过滤,爬过的不再爬
- 解决新闻推荐过的不再推荐
- HBase\RocksDB\LevelDB等数据库内置布隆过滤器,用于判断数据是否存在,可以减少数据库的IO请求
- 缓存穿透
基于bitmap,但是如果有字符串,我们需要将它转化为数字,直接使用hashcode可能会相同,一个hash函数也是,所以采用多个hash函数减少冲突,存在一定误判率。
https://www.dandelioncloud.cn/article/details/1493491247112331265
- 位运算代替SQL
一个网站如何统计出所有连续在线一周的用户
当然,在关系型数据库下,我们只需要一条SQL就能轻松搞定这个问题。但如果这个网站有20亿用户呢?你要怎么来设计这条SQL以保证我们查询的性能呢?
无疑Bitmap为我们提供了更好的解决思路。 首先,我们保证用户ID为一串纯数字,以便我们能够把20亿的用户ID存放在Bitmap中。通过上面的学习我们已经了解到Bitmap的本质就是按位存储,这样我们只需要把7天的Bitmap数据进行一个&位运算,最终得出的新的Bitmap就是连续一周在线的用户ID的集合了。
同理,如果我们要找出一周内上过线的所有用户,也只是一个|位运算就能搞定的事情。
底层实现
源码
SDS(简单动态字符串)
String底层实现为SDS
为了兼容C的字符串,所以在最后也加入’\0’,这样可以直接使用C的某些函数。查找复杂度为O(1)。
为什么不使用C的字符串?
因为C要预分配内存,所以可能导致字符串拼接时缓冲区溢出。而SDS在拼接前会先检查空间是否足够。另外字符串删除或添加元素会使内存重分配比较耗时,SDS直接修改free和len即可。
当SDS要修改时,程序不仅分配必要空间,还会额外分配空间。
c语言字符必须符合某种编码,并且字符串不能保存空字符,否则会认为是结束符,因此只能保存文本数据。而SDS都是以二进制安全的方式处理字符,所以可以保存任意格式的二进制,如音乐,视频,压缩文件等。
SDS除了用来保存数据库的字符串值外,还被用作缓冲区(buffer):AOF缓冲区,客户端状态中的输入缓冲区。
链表
双向链表,可以存不同类型的值。
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;
除了链表键之外,发布与订阅、慢查询、监视器等功能也用到了链表。
字典
又称映射(map),保存键值对。
使用哈希表作为底层实现。
typedef struct dictht{
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
//总是等于size-1
unsigned long sizemark;
//该哈希表已有结点数量
unsigned long used;
}dictht;
table是一个数组,每个元素都是一个dictEntry
哈希表结点
typedef struct dictEntry{
//键
void *key;
//值
union{
void *val;
uint64_tu64;
int64_ts64;
}v;
//指向下个哈希表结点,形成链表
struct dictEntry *next;
}dictEntry
字典结构
typedef struct dict{
//类型特定函数
dictType *type;
//私有数据
void *privdata;
//哈希表
dictht ht[2];
//rehash索引
//当rehash不在进行时,值为-1
int trehashidx;
}dict;
扩容需要满足两个条件:
- 容量达到100%且扩容标志位为true(当没有AOF和RDB子进程时为true)
- 容量达到500%(5是可以修改的,默认为5,这时候冲突非常严重需要扩容)
rehash过程
值得注意的是,rehash的过程是渐进式的
1.为 ht[1] 分配空间, 让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
2.在字典中维持一个索引计数器变量 rehashidx,并将它的值设置为0,表示 rehash 工作正式开始。
3.在 rehash 进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,
还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] ,
当 rehash 工作完成之后, 程序将rehashidx 属性的值增一,表示rehash下个桶内的元素。
4.随着字典操作的不断执行, 最终在某个时间点上, ht[0] 的所有键值对都会被 rehash 至 ht[1],
这时程序将 rehashidx 属性的值设为 -1 , 表示 rehash 操作已完成。
执行删除操作后,Redis会检查字典是否需要缩容,当Hash表长度大于4且负载因子小于0.1时,会执行缩容操作,以节省内存。缩容实际上也是通过dictExpand函数完成的,只是函数的第二个参数size是缩容后的大小。
跳跃表
有序结构,平均O(logN),实现比平衡树简单。
实现
typedef struct zskiplist{
struct zskiplistNode *header,*tail;
unsigned long length;
int level;
}zskiplist;
header:指向表头结点
tail:指向表尾结点
level:记录目前跳跃表内层数最大的那个结点的层数(不算表头结点)
length:跳跃表的长度
跳跃表结点
typedef struct zskiplistNode{
//层
struct zskiplistLevel{
//前进指针
struct zskiplistNode *forward;
//跨度
unsigned int span;
}level[];
//分值
double score;
//成员对象,是个指针,指向一个sds
robj *obj;
}zskiplistNode;
每次创建一个新结点时,生成一个随机int值,转化为二进制,当最高位和最低位同时是0(1/4概率)时需要判断level。然后从第二位开始看有多少个1相连
如果level>maxLevel就增加一层
按分值从小到大排列。
结点的成员对象是一个指针,指向基于SDS的字符串对象,较小的排在前面。
整数集合
保存整数值,可以保存类型为int16_t,int32_t,int64_t的整数值,并且保证不会出现重复。
typedef struct intset{
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
}intset;
contents真正的类型取决于encoding的值。
如果新元素类型比现有元素类型要大,整数集合要先升级:
- 根据新元素类型,扩展底层数组的空间大小,为新元素分配空间
- 将现有元素转化为新元素的类型,并重新放置位置。
- 添加新元素
只能升级不能降级。
只有在必要时候才会升级,有很好的灵活性。
压缩列表
previous_entry_length记录前一节点的长度,如果小于254字节就用1字节记录,大于等于254就用0xFE(十进制254)+4字节的长度记录。
所以如果知道了当前节点起始地址就能推出前一节点的地址。
encoding记录了节点的content属性所保存的类型及长度。
content记录节点的值,可以是一个字节数组或者整数。
连锁更新问题:
如果插入大于254字节的新节点后,previous_entry_length本来是1字节,现在改成5字节,恰好改完后这个节点也大于254,然后后一个接着改。。。
不过这种概率比较低,几个节点的连锁更新不会造成太大影响。
quicklist
如果链表很长,ziplist中每次插入或删除节点时都需要进行大量的内存拷贝,这个性能是无法接受的。
quicklist的设计思想很简单,将一个长ziplist拆分为多个短ziplist,避免插入或删除元素时导致大量的内存拷贝。
ziplist存储数据的形式更类似于数组,而quicklist是真正意义上的链表结构,它由quicklistNode节点链接而成,在quicklistNode中使用ziplist存储数据。
listpack
listpack 也叫紧凑列表,它的特点就是用一块连续的内存空间来紧凑地保存数据,同时为了节省内存空间,listpack 列表项使用了多种编码方式,来表示不同长度的数据,这些数据包括整数和字符串。用来代替ziplist
- 使用大端序
- 逆序读取,从右往左逐字节读取
- 每个字节只是用7bit表示数字,最高位表示是否还有数据,1表示还需要读取下一字节,0表示结束了
对比ziplist
- 结构组成不同:ziplist内存结构分了四个功能块:ziplist总长度,元素个数,最后元素的偏移和结尾标志;而listpack只有三个功能块:listpack总长度,元素个数和结尾标志,少了最后元素的偏移;
- 数据长度不同:两者的包含元素个数使用的字节长度不一样ziplist是4个字节的uint32_t类型数据,而listpack则是两个字节的unint16_t类型数据,单从这个数据的长度来看,listpack能存储的数据个数是比ziplist少的。因为uint16_t能容纳的数据比uint32_t要少。
- 两者元素结构不同:ziplist元素的三个组成部分分别是:前置元素(entry)的长度数据,本条目的编码方案(包含数据长度)和具体的数据内容;而listpack元素的三个组成部分则是:本条目的编码方案(包含数据长度)、具体的数据内容和本条目前面两个条目数据长度编码后需要的字节数。就是说ziplist条目保存了上一个条目的长度信息,而listpack则保存了自己的长度信息。这两者有很明显的区别,而且这个区别,将影响两者操作的完全不同。
大小端对齐
比如压缩列表的encoding属性使用多个字节存储节点元素长度,这种多字节数据存储在计算机内存中或者进行网络传输时的字节顺序称为字节序,字节序有两种类型:大端字节序和小端字节序。
- 大端字节序:低字节数据保存在内存高地址位置,高字节数据保存在内存低地址位置。
- 小端字节序:低字节数据保存在内存低地址位置,高字节数据保存在内存高地址位置。
数值0X44332211的大端字节序和小端字节序存储方式如图所示。
CPU处理指令通常是按照内存地址增长方向执行的。使用小端字节序,CPU可以先读取并处理低位字节,执行计算的借位、进位操作时效率更高。大端字节序则更符合人们的读写习惯。
ziplist采取的是小端字节序。
对象
对象底层
redis的每个对象都由一个redisObject表示
/*
* Redis 对象
*/
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码方式
unsigned encoding:4;
// LRU - 24位, 记录最末一次访问时间(相对于lru_clock); 或者 LFU(最少使用的数据:8位频率,16位访问时间)
unsigned lru:LRU_BITS; // LRU_BITS: 24
// 引用计数
int refcount;
// 指向底层数据结构实例
void *ptr;
} robj;
这里的类型、编码方式、LRU后面使用数字表示所占位数,unsigned是4字节,这里没有每个都分配一个unsigned,而是采用位域的方式,即将一个unsigned的32位拆成三个域,比如前两个都是4位,第三个是24位,共用一个unsigned,节省空间。
键值对是以两个对象存储的,紧挨着。
redis一般会把一些常见的值放到一个共享对象中,这样可使程序避免了重复分配的麻烦,也节约了一些CPU时间。
redis预分配的值对象如下:
- 各种命令的返回值,比如成功时返回的OK,错误时返回的ERROR,命令入队事务时返回的QUEUE,等等
- 包括0 在内,小于REDIS_SHARED_INTEGERS的所有整数(REDIS_SHARED_INTEGERS的默认值是10000)
注意:共享对象只能被字典和双向链表这类能带有指针的数据结构使用。像整数集合和压缩列表这些只能保存字符串、整数等内存数据结构
String
使用SDS实现
有三种方式:
- embstr:小于 44 字节,嵌入式存储,redisObject 和 SDS 一起分配内存,只分配 1 次内存
- rawstr:大于 44 字节,redisObject 和 SDS 分开存储,需分配 2 次内存
- long:整数存储(小于 10000,使用共享对象池存储,但有个前提:Redis 没有设置淘汰策略,详见 object.c 的 tryObjectEncoding 函数)
list
底层使用压缩链表和linkedlist。
后面改为快速列表
hash
使用压缩链表和hashtable。
同一键值对的俩结点挨着,键在前,值在后。采用尾插法。
Hash 对象的扩容流程
hash 对象在扩容时使用了一种叫“渐进式 rehash”的方式,步骤如下:
1)计算新表 size、掩码,为新表 ht[1] 分配空间,让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
2)将 rehash 索引计数器变量 rehashidx 的值设置为0,表示 rehash 正式开始。
3)在 rehash 进行期间,每次对字典执行添加、删除、査找、更新操作时,程序除了执行指定的操作以外,还会触发额外的 rehash 操作,在源码中的 _dictRehashStep 方法。
_dictRehashStep:从名字也可以看出来,大意是 rehash 一步,也就是 rehash 一个索引位置。
该方法会从 ht[0] 表的 rehashidx 索引位置上开始向后查找,找到第一个不为空的索引位置,将该索引位置的所有节点 rehash 到 ht[1],当本次 rehash 工作完成之后,将 ht[0] 索引位置为 rehashidx 的节点清空,同时将 rehashidx 属性的值加一。
4)将 rehash 分摊到每个操作上确实是非常妙的方式,但是万一此时服务器比较空闲,一直没有什么操作,难道 redis 要一直持有两个哈希表吗?
答案当然不是的。我们知道,redis 除了文件事件外,还有时间事件,redis 会定期(默认100ms)触发时间事件,这些时间事件用于执行一些后台操作,其中就包含 rehash 操作:当 redis 发现有字典正在进行 rehash 操作时,会花费1毫秒的时间,一起帮忙进行 rehash,如果操作超过1ms就结束本次任务。注意,定时rehash只会迁移全局hash表的数据。(全局hash表存的就是redisobject)
5)随着操作的不断执行,最终在某个时间点上,ht[0] 的所有键值对都会被 rehash 至 ht[1],此时 rehash 流程完成,会执行最后的清理工作:释放 ht[0] 的空间、将 ht[0] 指向 ht[1]、重置 ht[1]、重置 rehashidx 的值为 -1。
渐进式 rehash 的优点
渐进式 rehash 的好处在于它采取分而治之的方式,将 rehash 键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式 rehash 而带来的庞大计算量。
在进行渐进式 rehash 的过程中,字典会同时使用 ht[0] 和 ht[1] 两个哈希表, 所以在渐进式 rehash 进行期间,字典的删除、査找、更新等操作会在两个哈希表上进行。例如,要在字典里面査找一个键的话,程序会先在 ht[0] 里面进行査找,如果没找到的话,就会继续到 ht[1] 里面进行査找,诸如此类。
另外,在渐进式 rehash 执行期间,新增的键值对会被直接保存到 ht[1], ht[0] 不再进行任何添加操作,这样就保证了 ht[0] 包含的键值对数量会只减不增,并随着 rehash 操作的执行而最终变成空表。
rehash 流程在数据量大的时候会有什么问题吗(Hash 对象的扩容流程在数据量大的时候会有什么问题吗)
1)扩容期开始时,会先给 ht[1] 申请空间,所以在整个扩容期间,会同时存在 ht[0] 和 ht[1],会占用额外的空间。
2)扩容期间同时存在 ht[0] 和 ht[1],查找、删除、更新等操作有概率需要操作两张表,耗时会增加。
3)redis 在内存使用接近 maxmemory 并且有设置驱逐策略的情况下,出现 rehash 会使得内存占用超过 maxmemory,触发驱逐淘汰操作,导致 master/slave 均有有大量的 key 被驱逐淘汰,从而出现 master/slave 主从不一致。
set
使用intset或者hashtable
sorted set
使用压缩链表和跳表。
跳表情况下zset作为底层结点,包含一个字典和跳跃表。主要是为了提升性能。
单独使用字典:在执行范围型操作,比如 zrank、zrange,字典需要进行排序,至少需要 O(NlogN) 的时间复杂度及额外 O(N) 的内存空间。
单独使用跳跃表:根据成员查找分值操作的复杂度从 O(1) 上升为 O(logN)。
为什么不用红黑树?因为跳表性能差不多,且更容易实现。
跳表插入时,生成一个int随机数,与上100000000…1,即最高位和最低位同时是0时成立(概率1/4)。然后判断这个随机数中间有几个1,有几个1索引级别就加几(从1开始)。如果大于最大的,就让最大的加1,防止过高。
关于 Redis 的 ZSet 为什么用 skiplist 而不用平衡二叉树实现的问题,原因是:
- skiplist 更省内存:25% 概率的随机层数,可通过公式计算出 skiplist 平均每个节点的指针数是 1.33 个,平衡二叉树每个节点指针是 2 个(左右子树)
- skiplist 遍历更友好:skiplist 找到大于目标元素后,向后遍历链表即可,平衡树需要通过中序遍历方式来完成,实现也略复杂
- skiplist 更易实现和维护:扩展 skiplist 只需要改少量代码即可完成,平衡树维护起来较复杂
采用计数器机制实现内存回收
参考:《Redis设计与实现》
《极客时间Redis专栏》