Redis详解(四)------ redis的底层数据结构
Redis详解(五)------ redis的五大数据类型实现原理
Redis中每次创建一个键值对是,至少会创建两个对象,键对象和值对象,
Redis中每一个对象都是由redisObject来表示的:
typedef struct redisObject{
//类型
unsigned type:4;
//编码
unsigned encoding:4;
//指向底层数据结构的指针
void *ptr;
//引用计数
int refcount;
//记录最后一次被程序访问的时间
unsigned lru:22;
}robj
type属性就是我们所讲的五大数据类型(键一般就是字符串,值可以是字符串,列表,集合等等)
encoding指的是每种数据结构存储的不同数据(例如字符串可以存储字符类型,也可以存储数值类型).也用来数据类型的不同实现方式(list的压缩列表实现和双端链表实现).
ptr指向的是底层数据结构的物理存储地址.
字符串,能保存任何类型的数据,包括二进制数据,最大512M(单个的key-value)
所有的key都是string类型,另外其他数据结构的构成元素也是字符串
格式: set key value
编码可以是int编码(long类型的整数值),embstr编码(长度大于44字节的字符串),raw编码(大于44字节的字符串)
raw和embstr编码使用sdshdr保存数据,其内部维护一个字符数组,并存储已用容量和未使用容量.(这与C语言中的字符串实现不同,C语言中的字符串的数组是不可变的,但是共同点是都以’\0’结尾,目的是为了使用c的部分str库函数)
struct sdshdr{
//记录buf数组中已使用字节的数量
//等于 SDS 保存字符串的长度
int len;
//记录 buf 数组中未使用字节的数量
int free;
//字节数组,用于保存字符串
char buf[];
}
使用sds而不是c格式的字符串的好处:方便获取字符串长度,杜绝溢出(会先检查空闲空间大小),减少内存重新分配(重用),二进制安全(二进制表示的数据中可能会出现’\0’,sds虽然以’\0’结尾但是并不以’\0’为结束符,而是根据长度判断是否结束)
raw分配空间时,redisObject和sdshdr不在一起,使用指针连接.embstr分配空间则是连续的.
int编码保存的值超过long大小范围后,会转化为raw.对于embstr编码的数据在修改时一定会转化为raw编码.
list:列表,简单的字符串列表,按照插入顺序排序
可以头添加和尾添加,底层使用链表实现
格式: lpush name value1 value2…
编码可以是ziplist(压缩链表,将数据按照一定规则编码在一块连续的内存区域)和linkedlist(双端链表)
压缩列表的每个节点构成如下:
①、previous_entry_ength:记录压缩列表前一个字节的长度。previous_entry_ength的长度可能是1个字节或者是5个字节,如果上一个节点的长度小于254,则该节点只需要一个字节就可以表示前一个节点的长度了,如果前一个节点的长度大于等于254,则previous length的第一个字节为254,后面用四个字节表示当前节点前一个节点的长度。利用此原理即当前节点位置减去上一个节点的长度即得到上一个节点的起始位置,压缩列表可以从尾部向头部遍历。这么做很有效地减少了内存的浪费。
②、encoding:节点的encoding保存的是节点的content的内容类型(前两位)以及长度(后面的所有位)encoding区域长度为1字节、2字节或者5字节长。
③、content:content区域用于保存节点的内容,节点内容类型和长度由encoding决定。内部数据如果是数值类型,那么转换为2进制存储,如果是字符串类型,那么将每个字符的ACSII码找出,然后用两位16进制数存储(一共16位的空间).
typedef struct listNode{
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
}listNode;
typedef struct list{
//表头节点
listNode *head;
//表尾节点
listNode *tail;
//链表所包含的节点数量
unsigned long len;
//节点值复制函数
void (*free) (void *ptr);
//节点值释放函数
void (*free) (void *ptr);
//节点值对比函数
int (*match) (void *ptr,void *key);
}list;
就是双向链表么.
哈希类型,是一个string类型的field和value的映射表(参考Map,name指的是数据类型的名称,下同)
格式: hmset name key1 value1 key2 value2…
ziplist(相邻节点存储key和value)和hashtable(下面讲解)
类比HashMap
typedef struct dictht{
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值 总是等于 size-1
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
}dictht;
typedef struct dictEntry{
//键
void *key;
//值
union{
void *val;
uint64_tu64;
int64_ts64;
}v;
//指向下一个哈希表节点,形成链表,链地址法解决哈希冲突
struct dictEntry *next;
}dictEntry;
// 使用字典设置的哈希函数,计算键 key 的哈希值
int hash = dict->type->hashFunction(key);
// 有三种hash函数,分别对整型提供一种算法,字符串提供两种算法
// 使用哈希表的sizemask属性和第一步得到的哈希值,计算索引值
int index = hash & dict->ht[x].sizemask;
二倍扩容/收缩.对每一个元素重新哈希后放入新的内存空间,然后将原内存空间释放.
负载因子 = 哈希表大小/数组长度
执行磁盘访问时(BGSAVE和BGREWRITEAOF),负载因子大于5才会扩容,否则大于1就会扩容.
扩容并不是一次性完成的,数据量过大的情况下阻塞会非常明显.
所以依次扩容行为分为多次进行,在这期间产生了两个hash,当对数据的操作在其中一张表中没有找到时,就会查找另一张表.
保存元素小于512,每个元素长度小于64字节时,使用ziplist,否则使用hashtable
set:集合,无序,成员唯一
格式: sadd name value1 value2…
有intset和hashtable两种.
intset只能存储整数类型.
hashtable底层使用hash实现,可以理解为Java中的HashSet.
当集合中所有元素都是整数,并且总量不超过512时,使用intset,其他所有情况使用hashtable.
有序集和,每一个value都对应一个score(double类型)用以排序
格式: zadd name score1 value1 score2 value2…
zset的成员是唯一的,但分数(score)却可以重复
可以是ziplist(之前提到过,使用两个相邻的节点存储元素和分值,内部存储时就已经按分值排序了)和skiplist(跳跃表,下面讲解)
typedef struct zset{
//跳跃表
zskiplist *zsl;
//字典
dict *dice; //字典的键存放元素的分值,字典的值存放元素本身
} zset;
typedef struct zskiplist {
struct zskiplistNode *header, *tail; // 链式存储,这里是有序链表
unsigned long length;
int level;
} zskiplist;
typedef struct zskiplistNode {
robj *obj; // 存储元素,这里和最外层的字典共享指针,保证数据的不重复
double score; // 存储分值
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned int span;
} level[];
} zskiplistNode;
当满足元素数量小于128并且所有元素长度小于64字节时,使用ziplist,否则使用skiplist.
list:列表,简单的字符串列表,按照插入顺序排序
格式: lpush name value1 value2…
set:集合,无序,成员唯一
格式: sadd name value1 value2…
通过哈希表实现
zset:有序集和,每一个value都对应一个score(double类型)用以排序
格式: zadd name score1 value1 score2 value2…
zset的成员是唯一的,但分数(score)却可以重复
持久化就是将Redis中用内存存储的数据写入磁盘,下次启动Redis服务可以恢复到内存中
RDB特点:
AOF特点:
Redis调用fork会产生一个子进程,主进程将数据写入一个临时的RDB文件,写入结束后替换掉旧的文件
有两个命令可以生成RDB文件:SAVE(会阻塞主线程)和BGSAVE(在子线程中完成).实际创建RDB的工作由rdbSave完成,这两个命令内部的调用细节不同.BGSAVE内部会创建子进程,子进程处理,父进程中会轮询的等待子进程信号
BGSAVE命令在子线程中生成RDB文件的过程中,主线程如果再次调用了SAVE和BGSAVE命令,会被拒绝.
BGSAVE和BGREWRITEAOF命令不能同时执行,会相互延迟执行.(这里实际上不会出现什么问题,但是处于性能上的考虑,禁止同时执行)
文件的载入工作在服务器启动的时候自动执行(检测到RDB文件就会进行载入),并没有专门用于载入RDB文件的命令.
如果服务器开启了AOF持久化功能,那么会优先使用AOF文件还原数据库状态.
RDB文件载入时,服务处于阻塞状态
Redis的默认设置:
save 900 1 //900秒内进行了一次同步
save 300 10 //300秒内进行了10次同步
save 60 10000 //60秒内进行了10000次同步
当满足以上条件时,会执行BGSAVE命令.
服务器会根据配置文件中的该配置设置saveParams属性数组:
struct saveParams{
// 秒数
time_t seconds;
// 修改数
int changes;
}
Redis还会维持一个dirty计数器(上一次SAVE后产生的脏数据数),和一个lastsave属性(距离上一次SAVE的时间).Redis会周期性(100毫秒)的执行serverCron,来检查是否达到上面的条件,如果满足就调用BGSAVE
Redis调用flushAppendOnlyFile函数执行WRITE(将缓存写入内存中的AOF文件中)和SAVE(将AOF文件从内存持久化到磁盘)两个工作.
支持三种工作方式:
AOF文件采用RESP通讯协议保存命令.
只要根据AOF文件中的协议,重新执行一遍AOF文件中的所有命令就可以还原Redis的数据了.
步骤:
使用伪客户端的原因是恢复数据不需要网络,效果完全一样.
BGREWRITEAOF命令
Redis会在AOF文件中进行命令的重写,相当于合并命令到另一个文件,这个过程在子线程中进行,主线程可以继续处理命令请求.
重写期间的命令会写入重写缓冲区,在重写完成之后追加在新AOF文件末尾.
这个过程完成之后使用新的AOF文件代替原来的旧文件.
RESP是Redis客户端和服务端的一种通讯协议,请求格式都相同,使用数组搭配多行字符串.而返回有很多种
每一行消息是以\r\n结尾的,也就是分行
*3 // 这里星号指数组,后面数字代表数组长度,也就是命令的分段数,后面会紧跟3个多行字符串
$3 // 美元符号指多行字符串,后面数字代表字符串长度
SET // 这是多行字符串的内容
$3
KEY
$5
VALUE
1234四种格式的消息或者5复合前面4中基础格式的消息.
多个client连接一个Redis服务端
容量有限,处理能力有限
根据一个主服务器复制出多个从服务器,从服务器负责查询,主服务器进行数据的添加删除和修改.每当主服务器上的数据有变动时,会同步到从服务器上.
降低了master的读压力,但是没有缓解写压力.
在主从复制的基础上添加了哨兵机制,主服务器下线时进行故障转移(将另一台从服务器切换为主服务器来预防单点故障).
优点是自动故障迁移,保证稳定性,缺点还是没有缓解主服务器的写压力
使用代理进行服务的分发(通过hash).减缓各服务器的压力.
Twemproxy是Twitter开源的一个Redis和memcache轻量级代理服务器.
通过代理对象将写请求分发到多个主服务器上,将读请求分发到多个从服务器上.各个服务器之间进行同步.
优点在于增加了各种算法,合理的分配服务,还支持故障节点的自动删除.缺点是增加了新的proxy,需要维护.
Redis集群由对台Redis服务器组成,这种直连方式对服务器部分主从.每个节点要处理部分写请求和读请求.通过同步进行统一.
优点是可大量扩展,高可用(部分节点不可用时,整个集群还是工作的),自动故障处理.
缺点是资源隔离性较差,数据通过异步复制,不保证强一致性.
setnx和expire中间出现故障的解决办法:
放弃使用expire命令.将当前时间戳作为value存入此锁中,通过当前时间戳和Redis中的时间戳进行对比,如果超过一定差值,认为锁已经时效,防止锁无限期的锁下去.如果两个线程同时发现锁超时,可能会同时获取到锁.这个问题通过getset()解决,通过getset原子操作保证只能有
while(jedis.setnx(lock, now+超时时间)==0){
if(now>jedis.get(lock) && now>jedis.getset(lock, now+超时时间)){
// 这里先判断锁是否过期
// 然后如果锁过期了,尝试竞争锁,只有一个线程能成功正确的返回之前的过期时间
// 这时多个线程中的其他线程都会返回新的超时时间
// 这个超时时间被更改并不重要,主要就是用于防止永久锁,问题不大
break;
}else{
Thread.sleep(300);
}
}
// 执行业务代码;
jedis.del(lock);
合并命令
// redis6.2后可将上述两步合并起来
set key value seconds milliseconds nx|xx
// seconds:秒
// milliseconds:毫秒
// nx:只有键不存在时,才对键进行设置操作
// xx:只有键存在时,才对键进行设置操作
// set操作成功完成时,返回ok,否则返回nil