数据库之Redis
Redis概述
Redis(Remote Dictionary Server),即远程字典服务。是一个开源的、使用C语言编写、支持网络、可基于内存亦可持久化的日志型、key-value数据库,并提供多种语言的API。
Redis会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件,并且在此基础上实现了master-slave(主从)同步。
Redis是单线程(因为全部数据在内存中,使用单线程可以取消线程切换时的上下文切换资源消耗)的,基于内存操作,CPU不是redis的性能瓶颈,他的瓶颈在于机器的内存和网络带宽。
Redis键的类型只能为字符串,值支持五种数据类型:字符串、列表、集合、散列表、有序集合。
Redis中键值和值都是用SDS(Simple Dynamic String)
存储。string同C语言一样,末尾保存一个’\0’,但是不计算在len中。
struct sdshdr{
int len; // 记录buf数组中已使用字节的数量,也就是SDS中字符串的长度
int free; // 记录未使用字节的数量
char buf[]; // 字节数组
};
Redis特性
- 多样的数据类型
- 持久化
- 集群
- 事务
基本指令
select num # 默认有16各数据库,num为0-15
keys * # 查看数据库所有的key
flushdb # 清空当前库
flushall # 清空全部的数据库
重要指令
KEYS * # 查看所有的key
SET _key _value # 设置key
GET _key # 获取key的value
EXISTS _key # 判断key是否存在
MOVE _key _num # 将指定key移动到num号数据库
EXPIRE _key _sec # 设置key过期时间,单位是秒
TTL _key # 查看key的剩余时间
TYPE _key # 查看key的类型
Redis连接方式
unix scoket(本机通信)
- 基于网络协议栈的,是网络中不同主机之间的通讯,需要明确IP和端口。
tcp(网络通信)
- 同一台主机内不同应用不同进程间的通讯,不需要基于网络协议,不需要打包拆包、计算校验和、维护序号和应
答等,只是将应用层数据从一个进程拷贝到另一个进程,主要是基于文件系统的,它可以用于同一台主机上两个
没有亲缘关系的进程,并且是全双工的,提供可靠消息传递(消息不丢失、不重复、不错乱)的IPC机制,效率
会远高于tcp短连接。与Internet domain socket类似,需要知道是基于哪一个文件(相同的文件路径)来通
信的。
unix domain socket有2种工作模式一种是SOCK_STREAM,类似于TCP,可靠的字节流。另一种是SOCK_DGRAM,
类似于UDP,不可靠的字节流。
如果您想尽快回答并且您的负载低于redis-server峰值性能,那么避免流水线操作可能是最佳选择.但是,如果您希望能够处理更高的吞吐量,那么您可以处理请求的管道.响应可能需要更长时间,但您可以在某些硬件上处理更多请求.
Redis和Memcached区别
Redis优点
- 支持多种数据结构,如 string(字符串)、 list(双向链表)、dict(hash表)、set(集合)、zset(排序set)
- 支持持久化操作,可以进行aof及rdb数据持久化到磁盘,从而进行数据备份或数据恢复等操作,较好的防止数据丢失的手段。
- 支持通过Replication进行数据复制,通过master-slave机制,可以实时进行数据的同步复制,支持多级复制和增量复制,master-slave机制是Redis进行HA的重要手段。
- 单线程请求,所有命令串行执行,并发情况下不需要考虑数据一致性问题。
- 支持pub/sub消息订阅机制,可以用来进行消息订阅与通知。
Redis缺点
- Redis只能使用单线程,性能受限于CPU性能,故单实例CPU最高才可能达到5-6wQPS每秒(取决于数据结构,数据大小以及服务器硬件性能,日常环境中QPS高峰大约在1-2w左右)。
- 支持简单的事务需求,但业界使用场景很少,并不成熟,既是优点也是缺点。
- Redis在string类型上会消耗较多内存,可以使用dict(hash表)压缩存储以降低内存耗用。
Memcached优点
- Memcached可以利用多核优势,单实例吞吐量极高,可以达到几十万QPS(取决于key、value的字节大小以及服务器硬件性能,日常环境中QPS高峰大约在4-6w左右)。适用于最大程度扛量。
- 支持直接配置为session handle。
Memcache缺点
- 只支持简单的key/value数据结构,不像Redis可以支持丰富的数据类型。
- 无法进行持久化,数据不能备份,只能用于缓存使用,且重启后数据全部丢失。
- 无法进行数据同步,不能将MC中的数据迁移到其他MC实例中。
- Memcached内存分配采用Slab Allocation机制管理内存,value大小分布差异较大时会造成内存利用率降低,并引发低利用率时依然出现踢出等问题。需要用户注重value设计。
区别
两者都是非关系型内存键值数据库,主要有以下不同:
-
网络IO模型:Redis使用单线程的IO复用模型,自己封装了一个简单的AeEvent事件处理框架,主要实现了epoll,kqueue和select,对于单存只有IO操作来说,单线程可以将速度优势发挥到最大;Memcached是多线程,非阻塞IO复用的网络模型,分为监听主线程和worker子线程,主线程监听网络连接,接受请求后,将连接描述字pipe传递给worker线程,进行读写IO,网络层使用libevent封装的事件库,多线程模型可以发挥多核作用,但是引入了cache coherency和锁的问题。
-
**数据类型:**Redis支持五种不同的类型,可以更灵活的解决问题;Memcached仅支持字符串类型
-
**数据持久化:**Redis支持两种持久化策略(RDB快照和AOF日志);Memcached不支持持久化
-
**分布式:**Redis Cluster实现了对分布式的支持;Memcached不支持分布式,只能通过在客户端使用一致性哈希来实现分布式存储,这种方式在存储和查询时都需要先在客户端计算一次数据所在的节点。
-
内存管理机制:
- Redis中,并不是所有的数据都一直在内存中,可以将一些很久没用的value交换到磁盘;Memcached的数据一直在内存中。
- Memcached将内存分割为特定长度的块来存储数据,以解决内存碎片的问题,但是这种方式会导致内存使用率不高。
redis事件驱动详解
事件驱动数据结构
-
事件循环结构体aeEventLoop:事件循环结构体维护 I/O 事件表,定时事件表和触发事件表。
- 文件事件结构体:aefileevent
- 时间事件结构体:aetimeevent
- 触发事件结构体:aefiredevent
-
redis 的主函数中调用 initServer() 函数从而初始化事件循环中心(EventLoop),它的主要工作是在 aeCreateEventLoop() 中完成的。
事件驱动原理
-
事件注册:文件 I/O 事件注册主要操作在 aeCreateFileEvent() 中完成。aeCreateFileEvent() 会根据文件描述符的数值大小在事件循环结构体的 I/O 事件表中取一个数据空间,利用系统提供的 I/O 多路复用技术监听感兴趣的 I/O 事件,并设置回调函数。
- 对于不同版本的 I/O 多路复用,比如 epoll,select,kqueue 等,redis 有各自的版本,但接口统一,譬如 aeApiAddEvent(),会有多个版本的实现。
-
准备监听的工作:redis 提供了 TCP 和 UNIX 域套接字两种工作方式。以 TCP 工作方式为例,listenPort() 创建绑定了套接字并启动了监听
-
为监听的套接字注册事件:initServer() 为所有的监听套接字注册了读事件(读事件表示有新的连接到来),响应函数为 acceptTcpHandler() 或者 acceptUnixHandler()。
- acceptCommonHandler() 主要工作就是:
- 建立并保存服务端与客户端的连接信息,这些信息保存在一个 struct redisClient 结构体中;
- 为与客户端连接的套接字注册读事件,相应的回调函数为 readQueryFromClient(),readQueryFromClient() 作用是从套接字读取数据,执行相应操作并回复客户端。
- acceptCommonHandler() 主要工作就是:
-
进入事件循环:发生在 aeProcessEvents() 中:
- 根据定时事件表计算需要等待的最短时间;
- 调用 redis api aeApiPoll() 进入监听轮询,如果没有事件发生就会进入睡眠状态,其实就是 I/O 多路复用 select() epoll() 等的调用;
- 有事件发生会被唤醒,处理已触发的 I/O 事件和定时事件。
-
aeApiPoll() 调用了 select() 进入了监听轮询。aeApiPoll() 的 tvp 参数是最小等待时间,它会被预先计算出来,它主要完成:
- 拷贝读写的 fdset。select() 的调用会破坏传入的 fdset,实际上有两份 fdset,一份作为备份,另一份用作调用。每次调用 select() 之前都从备份中直接拷贝一份;
- 调用 select();
- 被唤醒后,检查 fdset 中的每一个文件描述符,并将可读或者可写的描述符记录到触发表当中。
接下来的操作便是执行相应的回调函数,先处理 I/O 事件,再处理定时事件。
redis如何提供服务
详细过程
-
初始化:redis 在启动做了一些初始化逻辑,比如配置文件读取(initserverconfig),数据中心初始化,网络通信模块初始化等(initserver),待所有初始化任务完毕后,便开始进入事件循环等待请求(aemain)。
-
redis 注册了回调函数 acceptTcpHandler(),当有新的连接到来时,这个函数会被回调
-
获取客户端的数据:readQueryFromClient() 则是获取来自客户端的数据,接下来它会调用 processInputBuffer() 解析命令和执行命令,对于命令的执行,调用的是函数 processCommand()。
- redis 首先根据客户端给出的命令字在命令表中查找对应的 c->cmd, 找到命令结构提之后直接调用相应的回调函数指针
Redis数据结构
SDS
简单动态字符串(simple dynamic string),redis中使用sds作为默认字符串。
struct sdshdr{
// 1 bytes
uint8_t len; // 字符串长度
// 1 bytes
uint8_t free; // buf数组中未使用的字节数量
// 1 bytes
unsigned char flags;
char buf[]; // 字符串数组,最后有一个隐式的'\0'
};
与C字符串区别
- 常数时间复杂度获取字符串长度
- 不会发生缓冲区溢出,由于sds知道自己的字符串长度和剩余空间,所以当不足时可以自动扩展空间
- 减少修改字符串时带来的内存重分配次数。
- sds通过未使用空间解除了字符串长度和底层数组长度之间的关联;SDS通过未使用空间实现了空间预分配和惰性空间释放两种优化策略
- 空间预分配:用于优化字符串增长操作,当sds需要扩展空间的时候,程序不仅会对sds分配修改所需要的空间,同时还会为sds分配额外的未使用空间。
- 如果扩展之后空间小于1M,程序会分配和len属性大小相同的未使用空间,即len=free
- 如果大于1M,程序会额外分配1M的未使用空间,即free=1M
- 惰性空间释放:用于优化sds的缩短操作,当sds需要缩短空间的时候,程序不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将其存起来。
- 二进制安全,C字符串只能保存文本数据,sds可以保存文本或者二进制数据,可以识别出’\0’
- 兼容部分C字符串函数
链表list
双端,无环,带有表头指针和表尾指针,带链表长度计数器。
多态:链表节点使用void*指针来保存节点值,并且通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。
typedef struct listNode{
struct listNode *pre;
struct listNode *next;
void* value;
};
typedef sturct list{
listNode *head;
listNode *tail;
unsigned long len;
void *(*dup) (void *ptr); // 节点值复制函数
void (*free) (void *ptr); // 节点值释放函数
int (*match) (void *ptr, void *key); // 节点值对比函数
}list;
字典
dictht
是一个散列表结构,使用拉链法解决哈希冲突。
// 一个哈希表结构,每个字典有两个这样的结构
typedef struct dictht{
dictEntry **table; // 哈希表数组
unsigned long size; // 哈希表大小
unsigned long sizemask; // 哈希表大小掩码,用于计算索引值,总是等于size-1
unsigned long used; // 该哈希表已有节点的数量
}dictht;
// 哈希表节点结构
typedef struct dictEntry{
void *key; // 键
union{ // 值
void *val;
uint64_t u64;
int64_t s64;
double d;
}v;
struct dictEntry *next; // 指向下一个哈希表节点,形成链表,用来解决键冲突
}dictEntry;
Redis
的字典dict
包含两个哈希表dictht
,这是为了方便进行rehash
操作,在扩容时,将其中一个dictht
上的键值对rehash
到另一个dictht
上面,完成之后释放空间并交换两个dictht
的角色。
typedef struct dict{
dictType *type; // 类型特定函数
void *privdata; // 私有数据
dictht ht[2]; // 哈希表,一般只使用ht[0],ht[1]是用来rehash的
long rehashidx; // 记录rehash目前的进度,如果没有进行rehash他的值为-1
unsigned long iterators;// 当前运行的迭代器的数量
}dict;
字典中的哈希算法
hash = dict->type->hashFunction(key);
index = hash & index->ht[0].sizemask;
字典解决键冲突(哈希冲突)
Redis的哈希表使用链地址法解决哈希冲突,每个哈希表节点上都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单项链表连接起来。
程序总是将新节点添加到链表的表头为止(复杂度为O(1)),排在其他已有节点的前面。
字典的rehash
当哈希表中键值对主键增多或者减少,为了让哈希表的负载因子维持在一个合理的范围内,当哈希表键值对数量太多或者太少,程序需要对哈希表的大小进行相应的扩展或者收缩,也就是rehash操作。
rehash步骤:
- 为字典的ht[1]哈希表分配空间,空间大小取决于要执行的操作以及ht[0]当前包含的键值对数量。
- 扩展操作:ht[1]的大小为第一个大于等于
ht[0].used*2的2^n
(会考虑redis是否正在bgsave,但是如果过多也会强制扩容) - 收缩操作:ht[1]的大小为第一个大于等于
ht[0].used的2^n
(缩容条件:小于10%,不会考虑是否正在besave)
- 扩展操作:ht[1]的大小为第一个大于等于
- 将保存在ht[0]中的所有键值对rehash到ht[1]上:rehash指重新计算键的哈希值和索引值,然后放置在ht[1]的指定位置上
- 当ht[0]中的键值对迁移完成之后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]上创建一个空白哈希表。
rehash操作不是一次性完成的,而是采用渐进方式,这是为了避免一次性执行过多的rehash操作给服务器带来过大的负担。
渐进式rehash步骤:
- 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
- 在字典中维持一个索引计数器变量
rehashidx
,并将其设置为0,表示渐进式rehash开始 - 在rehash期间,每次对字典进行添加、删除、查找或者更新操作时,会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1]上,之后将该值加1
- 完成全部键值对rehash之后,程序将rehashidx属性值设置为-1,表示操作完成。
采用渐进式rehash会导致字典中的数据分散在两个dictht上,因此对字典的查找操作也需要到对应的dictht去执行。
添加操作之后保存到ht[1]中,可以保证ht[0]中的键值对只减不增。
跳跃列表
是有序集合的底层实现之一。是一种有序数据结构,通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
跳跃表支持平均O(logN),最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。
Redis中只有一个地方用到了跳跃表:有序集合。
跳跃表是基于多指针有序链表实现的,可以看成多个有序链表。
Redis跳跃表由redis.h/zskiplistNode和redis.h/zskiplist
两个结构定义,其中zskiplistNode
结构用于表述跳跃表节点,zskiplist
结构用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头结点和表尾节点的指针等等。
跳跃表节点
typedef struct zskiplistNode{
// 层
struct zskiplistLevel{
// 前进指针
struct zskipListNode *forward;
// 跨度,用于计算一个元素的排名
unsigned int span;
}level[];
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
}zskiplistNode;
- **层:**跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快。
- 层数为每次创建一个新跳跃表节点时按照幂次定律(越大的数出现的概率越小)随机生成一个介于1和32之间的值。
- 前进指针:每个层都有一个指向表尾方向的前进指针(level[i].forward),用于从表头向表尾方向访问节点。
- 跨度:层的跨度用于记录两个节点之间的距离,两个节点之间的跨度越大,相距就越远;指向NULL的所有前进指针的跨度都为0,因为他们没有连向任何节点。
- 后退指针:用于从表尾向表头访问节点,一次只能后退至前一个节点。
- 分值和成员:分值是一个double浮点数,跳跃表中的所有节点按照分值从小到大排序;对象是一个指针,指向一个字符串对象,保存着一个SDS值。
跳跃表结构
typedef struct zskiplist{
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
}zskiplist;
- 头尾指针:通过这两个指针程序定位表头节点和表尾节点的复杂度为O(1)
- length:节点个数
- level:层数最大的节点的层数量,头节点层高不算在内。
在查找中,从上层指针开始查找,找到对应的区间之后再到下一层取查找。
与红黑树等平衡树相比,跳跃表具有以下优点:
- 插入速度非常快速,因为不需要进行旋转等操作维护树的平衡性
- 更容易实现
- 支持无锁操作
查找过程
从当前的最高层开始,后继比待查关键字大,下移;比待查关键字小,右移。
整数集合(IntSet)
是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。
数据结构:intset.h/intset
typedef struct intset{
uint32_t encoding; // 元素的编码方式
uint32_t length;
int8_t contents[]; // 保存元素的数据
}intset;
压缩列表(ziplist)
是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么是长度比较短的字符串,那么redis就会使用压缩列表来做列表键的底层实现。
struct ziplist<T>{
int32 zlbytes; // 整个压缩列表字节数
int32 zitail_offset;// 最后一个元素的偏移量
int16 zllength; // 元素个数
T[] entries; // 元素内容
int8 zlend; // 结束标志,恒为0xFF
};
struct entry{
int<var> prevlen; // 上一个entry的字节长度,如果长度小于254就用一个字节表示;如果超出就用5个字节表示
int<var> encoding; // 元素类型编码,通过前缀位识别具体存储的数据形式
optional byte[] content; // 元素内容
};
为了支持双向遍历,加入了最后一个元素的偏移量,同时在entry结构体中加入了上一个entry的字节长度。
增加元素时,因为ziplist都是紧凑存储,没有冗余空间意味着插入一个新元素都要调用realloc扩展内存。
紧凑链表(listpack)
对ziplist结构的改进,在存储空间上更加节省,结构上也比ziplist更精简。
struct listpack<T>{
int32 total_bytes; // 占用的总字节
int16 size; // 元素个数
T[] entries; // 紧凑排列的元素列表
int8 end; // 0xFF
};
struct lpentry{
int<var> encoding;
optional byte[] content;
int<var> length;
};
因为在lpentry结构体的内部,length放置在了尾部,并且存储的是当前元素的长度,所以可以省去altail_offset
来标记最后一个元素的位置,这个位置可以通过total_bytes字段和最后一个元素的长度字段计算出来。
快速列表(quicklist)
由于链表list的附加空间相对太高,prev和next指针就要占用16字节,另外每个节点的内存都是单独分配,会加剧内存的碎片化,影响内存管理效率。
quicklist是ziplist和linkedlist的结合体,他将linkedlist按段切分,每一段使用ziplist来紧凑存储,多个ziplist之间使用双向指针串接起来。
struct ziplist{
};
struct ziplist_compressed{
int32 size;
byte[] compressed_data;
};
struct quicklistNode{
quicklistNode* prev;
quicklistNode* next;
ziplist* zl; // 指向压缩链表
int32 size; // ziplist的字节总数
int16 count; // ziplist中的元素数量
int2 encoding; // 存储形式2bit,是按照原生字节数组还是LZF压缩存储
...
};
struct quicklist{
quicklistNode *head;
quicklistNode *tail;
long count; // 元素总数
int nodes; // ziplist节点的个数
int compressDepth; // LZF算法压缩深度
...
};
quicklist内部默认单个ziplist长度位8K字节,超出这个字节数,就会新起一个ziplist。ziplist长度由参数list-max-ziplist-size
决定。
quicklist默认压缩深度为0,也就是不压缩。为了支持快速的push/pop操作,quicklist的首尾两个ziplist不压缩,深度就是1。
rax树
一种有序字典树(基数树Radix Tree),按照key的字典序配列,支持快速定位、插入和删除操作。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KFXQRfY6-1602224447718)(C:/Users/free/Desktop/面试/interviewmd/assets/post/others/rax.png)]
Redis 数据类型对象
Redis并没有直接使用上述数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象。
Redis使用对象来表示数据库中的键和值,每次创建一个键值对时,至少会创建两个对象,一个对象为键对象,一个为值对象。
Redis每个对象都由一个redisObject
结构表示,该结构中和保存数据有关的三个属性分别为type,encoding,ptr
typedef struct redisObject{
unsigned type:4; // 4 bits
unsigned encoding:4; // 4 bits
unsigned lru:LRU_BITS; // 24 bits
int refcount; // 4 bytes
void *ptr; // 指向底层实现数据结构的指针,占用8 bytes
}robj;
- 类型type:
REDIS_STRING, REDIS_LIST, REDIS_HASH, REDIS_SET, REDIS_ZSET
- 编码encoding:记录了对象所使用的编码,也就是说这个对象使用了什么数据结构作为底层实现的。
- 每种类型的对象都至少使用了两种不同的编码。
- 使用命令
OBJECT ENCODING _key
可以获取键值的底层数据结构类型
- ptr指针指向对象的底层实现数据结构。
字符串对象
编码可以是int, raw, embstr
。
- 如果字符串对象保存的是一个整数值,并且这个整数值可以用long类型表示,那么字符串对象会将整数值保存在字符串对象数据结构的ptr属性里面,并将字符串对象的编码设置为
int
。否则将其按照字符串格式存储。 - 如果保存的是一个字符串值,并且其长度大于44字节,那么使用SDS保存,并设置为
raw
- 字符串值超度小于等于44字节,使用
embstr
方式保存。 - 可以用long double类型表示的浮点数在redis中也是作为字符串值来保存的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TFAI1g3E-1602224447721)(C:/Users/free/Desktop/面试/interviewmd/assets/post/others/embstr+raw.png)]
embstr
存储方式是将RedisObject
对象头和SDS
对象连续存在一起,使用malloc
方法一次性分配。
raw
存储方式不存在一起,分两次分配空间,使用指针连接。
-
为什么是44字节作为分界点?
-
Redis内存分配器
jemalloc/tcmalloc
等分配内存大小的单位都是2,4,8,16,32,64等,为了能容纳一个完整的embstr
对象,jemalloc
最少会分配32个字节的空间,如果字符再长一点,就是64字节的空间。如果更长就会按照raw方式分两次分配空间存储 -
当分配为64字节时,对象头占用16字节,SDS的len和free占用3字节,还有最后一个字符’\0’,所以最大为64-16-3-1=44字节。
列表对象
编码使用quicklist
。
哈希对象
编码可以是ziplist
或者hashtable
。
ziplist
编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾。
hashtable
编码的哈希对象使用字典作为底层实现,哈希对象的每个键值对都是用一个字典键值来保存。
- 字典的每个键都是一个字符串对象,对象中保存了键值对的键
- 字典的每个值都是一个字符串对象,对象中保存了键值对的值
当哈希对象同时满足以下两个条件时,哈希对象使用ziplist,否则使用hashtable编码
- 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节
- 哈希对象保存的键值对数量小于512个
集合对象
编码可以是intset
或者hashtable
。
intset
编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。
hashtable
编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,字典的值全被设为NULL。
当集合对象同时满足以下两个条件时,使用intset
编码:
- 集合对象保存的所有元素都是整数值
- 元素数量不超过512个。
有序集合对象
有序集合对象的编码可以是ziplist
或者skiplist
。
ziplist
编码的压缩列表对象使用压缩列表来作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存。第一个节点保存元素的成员(member),第二个节点保存元素的分值(score)。
- 压缩列表里的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,分值大的放置在靠近表尾的方向。
skiplist
编码的有序集合对象使用zset
结构作为底层实现,一个zset
结构同时包含一个字典和一个跳跃表。
typedef struct zset{
dict *dict;
zskiplist *zsl;
}zset;
zsl
跳跃表按照分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的object属性保存了元素的成员,跳跃表节点的score属性保存了元素的分值。通过跳跃表,可以对有序集合进行范围型操作。dict
字典为有序集合创建一个成员到分值的映射,字典的每个键值都保存了一个集合元素:字典的键保存了元素的成员,字典的值保存元素的分值。通过字典,可以以O(1)复杂度查找给定成员的分值。- 注意:有序集合的每个元素成员都是一个字符串对象,每个元素的分值都是一个double类型的浮点数。
zset
结构同时使用zsl
和dict
来保存有序集合元素,但是这两种数据结构都会通过指针来共享相同元素的成员和分值。
- 当有序集合对象同时满足以下两个条件的时候,对象使用
ziplist
编码:- 有序集合保存的元素数量小于128个
- 所有元素成员的长度都小于64字节
Redis 数据类型操作
数据类型 | 可以存储的值 | 操作 |
---|---|---|
STRING | 字符串、整数或者浮点数 | 对整个字符串或者字符串的其中一部分执行操作 对整数和浮点数执行自增或者自减操作 |
LIST | 列表 | 从两端压入或者弹出元素 对单个或者多个元素进行修剪, 只保留一个范围内的元素 |
SET | 无序集合 | 添加、获取、移除单个元素 检查一个元素是否存在于集合中 计算交集、并集、差集 从集合里面随机获取元素 |
HASH | 包含键值对的无序散列表 | 添加、获取、移除单个键值对 获取所有键值对 检查某个键是否存在 |
ZSET | 有序集合 | 添加、获取、删除元素 根据分值范围或者成员来获取元素 计算一个键的排名 |
String:字符串
APPEND _key _string # 向key的value后追加字符串,如果不存在相当于set key
STRLEN _key # 获取key的长度
INCR _key # key的value自加1
DECR _key # key的value自减1
INCR _key _num # key的value自加num
DECR _key _num # key的value自减num
GETRANGE_key start end # 截取[start,end]范围的字符串,-1表示最后一个
SETRANGE_key offset string # 替换从offset开始的之后的字符串
SETEX _key _time _string # 设置带有过期时间的key
SETNX _key _string # 不存在时设置
MSET _key _value _key _value # 批量设置key
MGET _key _key # 批量获取key
MSETNX _key _value # 批量设置不存在的,原子性操作,要么全部成功,要么一起失败
List:列表(可以重复)
RPUSH _list _item # 从list右边插入value,如果没有list就创建
LPUSH _list _item # 从左边插入
RPOP _list # 从右边删除一个
LPOP _list # 从左边删除一个
LRANGE _list start end # 获取list中范围value
LINDEX _list _index # 获取指定位置的value
SET:集合(不可重复,无序)
集合是通过哈希表实现的。集合中最大的成员数为2^32-1(即每个集合可存储40多亿个成员)
SADD _set _item # 集合中插入一个value,如果没有就创建
SMEMBERS_set # 获取集合所有value
SISMEMBER _set _item # 查看item是否在集合中
SREM _set _item # 移除元素
Hash:哈希表
HSET _hash _key _value # 在_hash表中设置_key和_value
HGETALL _hash # 获取_hash表中所有键值对
HDEL _hash _key # 删除_hash表中指定键
HGET _hash _key # 获取指定键
ZSET:有序集合
每个元素都会关联一个double类型的分数,redis时通过分数为集合中的成员从小到大进行排序的。
有序集合成员是唯一的,但是分数是可以重复的。
ZADD _zset _score _mem # 向zset有序集合中添加成员
ZRANGE _zset start end WITHSCORES # 获取范围内成员,WITHSCORES参数表示同时获取分数
ZRANGEBYSCORES _zset start end WITHSCORES # 根据分数范围获取
ZREM _zset _mem # 移除成员
Redis数据库设计
Redis服务器将所有的数据库保存在服务器状态redis.h/redisServer
的db数组中,db数组的每个项都是一个redis.h/redisDb
,每个redisDb结构代表一个数据库。
Redis中数据库的数量会根据dbnum的值进行创建,默认情况下dbnum值为16.
typedef struct redisDb {
dict *dict;
dict *expires;
int id;
} redisDb;
数据库键空间
Redis是一个键值对(key-value pair)数据库服务器,服务器中的每个数据库都有一个redis.h/redisDb
结构表示,其中redisDb结构中的dict字典保存了数据库中的所有键值对,这个字典称为键空间(key space)
- 键空间的键也就是数据库的键,每个键都是一个字符串对象
- 键空间的值也就是数据库的值,每个值可以是字符串对象、列表对象、哈希表对象、集合对象和有序集合对象中的任意一种redis对象。
因为数据库的键空间是一个字典,所有所有针对数据库的操作(添加,删除,更新,取值),实际上都是通过对键空间字典进行操作来实现的。
数据库过期删除
redisDb结构的expires字典保存了数据库中所有键的过期时间,这个字典称为过期字典。
- 过期字典的键是一个指针,这个指针指向键空间中的某个键对象(也就是某个数据库键)
- 过期字典的值是一个long long类型的整数,这个整数保存了键所指向的数据库键的过期时间–一个毫秒精度的UNIX时间戳。
过期删除策略
- 定时删除(不使用):设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间来临时,立即执行对键的删除操作。
- 优点:对于内存是最友好的,通过使用定时器,定时删除策略可以保证过期键尽快删除,并释放内存空间
- 缺点:对于CPU不友好,在过期键比较多的情况下,删除过期键会占用相当一部分的CPU时间。
- 其次,创建一个定时器需要用到Redis的时间事件,而时间事件的实现方式无序链表,查找一个事件的时间复杂度为O(N),会导致不能高效的处理大量时间事件。
- 惰性删除:放任键过期不管,但是每次从键空间来获取键时,先检查该键是否过期,如果过期就删除,否则就返回
- 使用
redis.c/expireIfNeeded
函数实现,如果输入键未设置过期时间或者设置过期时间但未过期,不做动作;否则删除键。 - 优点:对CPU友好,删除的目标仅限于当前处理的键,不会在删除其他无关的过期键上花费任何CPU时间
- 缺点:对内存不友好,可能会存在大量不使用的键,可以将这种情况看成一种内存泄漏
- 使用
- 定期删除:每隔一段时间,程序对数据库进行检查,删除过期键
- 使用
redis.c/activeExpireCycle
函数实现,分多次遍历每个数据库,从数据库中的expires字典中随机检查一部分键的过期时间,并删除其中的过期键。其中current_db
记录了最后被检查的数据库,下次定期删除的时候从该数据库开始遍历。 - 通过限制删除操作执行的时长和频率来减少操作对CPU时间的影响
- 通过定其删除过期键有效的减少了因为过期键带来的内存浪费
- 但是确定删除操作执行的时长和频率是一个难点。
- 使用
过期删除对持久化和复制的影响
- RDB持久化
- 写入RDB文件不会产生影响,因为写入该文件时会首先检查是否过期
- 载入RDB文件,服务器为主服务器模式时过期键会被忽略,从服务器模式时无论键是否过期都会被载入到数据库中。
- AOF持久化
- 写入AOF文件,当过期键被惰性删除或者定期删除之后,程序会向AOF文件后追加一条DEL命令,显式记录该键已被删除。
- 重写AOF文件,已过期的键不会被保存到重写后的AOF文件
- 复制
- 当服务器运行在复制模式下时,从服务器的过期键删除动作由主服务器控制;
- 主服务器在删除一个过期键之后,会显式的向所有服务器发送一个DEL命令,告知从服务器删除这个过期键
- 从服务器在执行完客户端发送的读命令后,即使碰到过期键也不会删除,而是继续像处理未过期的键一样来处理过期键
- 从服务器只有接到主服务器发来的DEL命令之后才会删除过期键。
- 当服务器运行在复制模式下时,从服务器的过期键删除动作由主服务器控制;
数据库通知
该功能可以让客户端通过订阅给定的频道或者模式,来获知数据库中键的变化,以及数据库中命令的执行情况。
notify-keyspace-events
选项决定了服务器所发送通知的类型:
AKE
:发送所有类型的键空间通知和键空间事件通知AK
:发送所有类型的键空间通知AE
:发送所有类型的键事件通知K$
:只发送和字符串键有关的键空间通知El
:只发送和列表键有关的键事件通知
发送通知
该功能由void notifyKeyspaceEvent(int type, char *event, robj *key, int dbid)
函数实现
Redis使用场景
计数器
可以对string进行自增自减运算,从而实现计数器的功能。
Redis这种内存型数据库的读写性能非常高,很适合存储频繁读写的计数量。
缓存
将热点数据放在内存中,设置内存的最大使用量以及淘汰缓存策略来保证缓存的命中率。
查找表
查找表和缓存类似,也是利用了redis快速的查找特性。但是查找表的内容不能失效,而缓存的内容可以失效,因为缓存不作为可靠的数据来源。
例如DNS记录就很适合使用redis进行存储。
消息队列
List是一个双向链表,可以通过LPUSH和RPOP写入和读取信息。
不过最好使用rebbitmq等消息中间件。
会话缓存
可以使用redis来统一存储多台应用服务器的会话信息。
当应用服务器不再存储用户的会话信息,也就不再具有状态,一个用户可以请求任意一个应用服务器,从而更容易实现高可用性以及可伸缩性。
分布式锁实现
在分布式场景下,无法使用单机环境的锁来对多个节点上的进程进行同步。
可以使用redis自带的SETNX命令实现分布式锁,除此之外,还可以使用官方提供的redlock分布式锁来实现。
其他
Set可以实现交集、并集等操作,从而实现共同好友等功能。
ZSet可以实现有序性操作,从而实现排行榜等功能。
数据淘汰策略-内存满
可以设置内存最大使用量,当内存使用量超出时,会实行数据淘汰策略。
Redis中有6中淘汰策略:
策略 | 描述 |
---|---|
volatile-lru | 从已设置过期时间的数据集中挑选最近最少使用的数据淘汰 |
volatile-ttl | 从已设置过期时间的数据集中挑选将要过期的数据淘汰 |
volatile-random | 从已设置过期时间的数据集中任意选择数据淘汰 |
allkeys-lru | 从所有数据集中挑选最近最少使用的数据淘汰 |
allkeys-random | 从所有数据集中任意选择数据进行淘汰 |
noeviction | 禁止驱逐数据 |
作为内存数据库,处于对性能和内存消耗的考虑,redis的淘汰算法实际实现上并非针对所有key,而是抽样一部分并且选出被淘汰的key。
使用redis缓存数据时,为了提高缓存命中率,需要保证缓存数据都是热点数据,可以将内存最大使用量设置为热点数据占用的内存量,然后使用allkeys-lru
淘汰策略,将最近最少使用的数据淘汰。
Redis持久化
Redis是内存型数据库,为了保证数据在断电后不会丢失,需要将内存中的数据持久化到硬盘上。
通常将服务器中的非空数据库以及他们的键值对统称为数据库状态。
RDB持久化
将某个时间点的所有数据(RDB文件)都存放在硬盘上。
可以将快照复制到其他服务器从而创建具有相同数据的服务器副本。
如果系统发生故障,将会丢失最后一次创建快照之后的数据。
如果数据量很大,保存快照的时间很长。
RDB使用命令
- SAVE命令:阻塞Redis服务器进程,直到RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不处理任何命令请求。
- BGSAVE命令:派生一个子进程,由子进程负责创建RDB文件。
BGSAVE
和SAVE、BGSAVE、BGREWRITEAOF
命令冲突。
RDB文件结构
一个完整的RDB文件包含五部分:REDIS, db_version, databases, EOF, check_sum
REDIS
:RDB文件标识,存储的是REDIS五个字符db_version
:长度4个字节,一个字符串表示的整数,记录了RDB文件的版本号databases
:包含零个或者任意多个数据库,以及各个数据库中的键值对数据- 如果服务器的数据库状态为空,那么这个部分也为空,长度为0字节
- 如果数据库状态为非空,那么这个部分也为非空
EOF
:长度为1个字节,标志RDB文件正文内容结束check_sum
:8字节长度的无符号整数
AOF持久化
将写命令添加到AOF文件的末尾。(Append Only File),是通过保存Redis服务器所执行的写命令来记录数据库状态的。
使用AOF持久化需要设置同步选项,从而确保写命令同步到磁盘文件上的时机。这时因为对文件进行写入并不会马上将内容同步到磁盘上,而是先存储到缓冲区,然后由操作系统决定什么时候同步到磁盘。
选项 | 同步频率 |
---|---|
always | 每个写命令都同步 |
everysec | 每秒同步一次 |
no | 让操作系统来决定何时同步 |
- always 选项会严重减低服务器的性能;
- everysec 选项比较合适,可以保证系统崩溃时只会丢失一秒左右的数据,并且 Redis 每秒执行一次同步对服务器性能几乎没有任何影响;
- no 选项并不能给服务器性能带来多大的提升,而且也会增加系统崩溃时数据丢失的数量。
随着服务器写请求的增多,AOF 文件会越来越大。Redis 提供了一种将 AOF 重写的特性,能够去除 AOF 文件中的冗余写命令。
AOF持久化的实现
AOF持久化的实现可以分为**命令追加(append),文件写入(写入内存缓冲区),文件同步(sync,写入真实文件)**三个步骤。
- 命令追加:服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的
aof_buf
缓存区的末尾。 - 文件写入:服务器在处理文件事件时可能会执行写命令,使得一些内容被追加到
aof_buf
缓冲区中,所以在服务器每次结束一个事件循环之前,都会调用flushAppendOnlyFile
函数,考虑是否将缓冲区内容写入和保存到AOF文件里面。flushAppendOnlyFile
函数的行为可以通过配置appendfsync
选项的值来决定,- 当为
always
时将缓冲区的内容写入并同步到AOF文件中,最安全的,但是效率最慢; everysec
将缓冲区内容写入到AOF文件中,如果上次同步AOF时间为一秒前,就对AOF文件进行同步,如果出现故障只会丢失1s内的数据,效率适当;no
将aof_buf
缓冲区内容写入到AOF文件,但是并不对其进行同步,何时同步由操作系统决定,安全性最低,效率和everysec差不多。
- 当为
AOF文件的载入与数据还原
- 创建一个没有网络连接的伪客户端
- 从AOF文件中分析并读取写命令,并使用伪客户端执行该命令
AOF重写
可以使原本包含很多冗余命令的AOF文件减小很多,所以文件大小也会小很多,最后数据还原的结果是相同的。
- 原理:首先从数据库中读取键现在的值,然后用一条命令区记录键值对,代替之前记录这个键值对的多条命令。
RDB和AOF对比
- 如果redis设置了AOF持久化那么就会优先使用AOF来还原数据库状态,没有设置AOF才会采用RDB载入RDB文件
- Redis加载RDB恢复数据远远快于AOF的方式
- RDB是一个紧凑压缩的-`±二进制文件,适用于备份,每一段时间拷贝到远程文件系统中,用于灾难恢复
- RDB无法做到秒级的持久化,因为bgsave每次运行都要执行fork操作创建子进程,属于重量级的操作,执行成本很高。AOF可以设置成每一秒记录到缓冲区,在从缓冲区型硬盘同步。
- AOF需要定期对AOF文件进行重写,达到压缩的目的。
Redis事件
Redis服务器是一个事件驱动程序。
文件事件
服务器通过套接字和客户端(或者其他服务器)进行通信,文件事件就是对套接字操作的抽象。
Redis通过Reactor模式开发了自己的网络事件处理器,被称为文件事件处理器。
- 使用I/O多路复用程序同时监听多个套接字,并将到达的事件传送给文件事件分派器,分派器会根据套接字产生的事件类型调用响应的事件处理器。
- 当被监听的套接字准备好执行连接应答(accept),读取(read),写入(write),关闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-e2Wti5oS-1602224447728)(C:/Users/free/Desktop/面试/interviewmd/assets/post/others/fileevent.png)]
- 尽管多个文件事件可能会并发的出现,但是IO多路复用程序总是将所有产生事件的套接字都放在一个队列里面,然后通过这个队列,以有序、同步、每次一个套接字的方式向文件事件分派器传送套接字。
文件事件的I/O多路复用
Redis的IO多路复用程序的所有功能都是通过包装常用的select,epoll,evport和kqueue
这些IO多路复用函数库来实现的。
Redis在IO多路复用程序的实现源码中用#include
宏定义了相应的规则,程序会在编译的时候自动选择系统中性能最高的IO多路复用函数库来作为redis的IO多路复用的底层实现。
文件事件的类型
IO多路复用程序可以监听多个套接字的ae.h/AE_READABLE, ae.h/AR_WRITABLE
事件,分别对应:
- 当套接字可读时(客户端产生write操作,或者执行close操作),或者有新的可应答(acceptable)套接字出现时(客户端产生connect操作),套接字产生
AE_READABLE
事件。 - 当套接字可写时(客户端执行read操作),套接字产生
AE_WRITABLE
事件。
当一个套接字同时可读可写时,服务器优先处理读,再处理写。
文件事件API
ae.c/aeCreateFileEvent
函数接受一个套接字描述符,一个事件类型,一个事件处理器作为参数。
文件事件的处理器
- 连接应答处理器:
networking.c/acceptTcpHandler
,用于对服务器监听套接字的客户端进行应答,具体实现为sys/socket.h/accept
函数的封装。- Redis服务器进行初始化的时候,服务器会将这个处理器和
AE_READABLE
事件关联起来,当客户端使用sys/scoket.h/connect
函数连接服务器监听套接字的时候,套接字就会产生AE_READABLE
事件,引发连接应答处理器执行
- Redis服务器进行初始化的时候,服务器会将这个处理器和
- 命令请求处理器:
networking,c/readQueryFromClient
,负责从套接字中读入客户端发送的命令请求内容,具体实现为unistd.h/read
函数的包装。- 当有客户端连接到服务器之后,服务器会将这个处理器和
AE_READABLE
事件关联起来,当客户端向服务器发送命令请求时,会产生AE_READABLE
事件,引发命令请求处理器执行。 - 在客户端连接服务端的整个过程中,服务器会一直为客户端套接字的
AE_READABLE
关联命令请求处理器
- 当有客户端连接到服务器之后,服务器会将这个处理器和
- 命令回复处理器:
networking.c/sendReplyToClient
,负责将服务器执行命令后得到的命令恢复通过套接字返回给客户端,具体实现为unistd.h/write
函数的包装- 当服务器有命令回复需要传送给客户端的时候,服务器会将客户端套接字的
AE_WRITABLE
事件和命令回复处理器关联起来,当客户端准备好接收命令回复时,会产生该事件,引发命令回复处理器执行。 - 当发送完毕后,服务器自动解除相应的关联。
- 当服务器有命令回复需要传送给客户端的时候,服务器会将客户端套接字的
时间事件
服务器有一些操作需要在给定的时间点执行,时间事件是对这类定时操作的抽象。
时间事件又分为:
- 定时事件:是让一段程序在指定的时间之内执行一次,执行完成之后从链表中删除该事件
- 周期性事件:是让一段程序没隔指定事件就执行一次,执行完成之后更新时间事件的when属性
时间事件主要由以下三个属性组成:
id
:服务器为事件事件创建的全局唯一ID(标识号),ID按照从小打到的顺序递增,新事件的ID号总是大于旧事件的。when
:毫秒精度的UNIX时间戳,记录了时间事件的到达时间。timeproc
:时间事件处理器,一个函数。
一个时间事件是定时事件还是周期性事件取决于时间事件处理器的返回值。
- 返回
ae.h/AE_NOMORE
,该事件为定时事件:该事件到达一次就会被删除,之后不会到达。 - 返回非NOMORE的整数值,那么这个事件为定期事件:当一个时间事件到达之后,服务器会根据事件处理器返回的值,对时间事件的when属性进行更新,让这个事件再次到达。
时间事件实现
Redis将所有时间事件都放在一个无序链表(无序链表不是不按ID排序,而是说该链表不按when属性的大小排序)中。
通过遍历这个链表查找已经到达的时间事件,并调用相应的事件处理器。
因为链表没有按照when属性进行排序,所以需要遍历链表中的所有时间事件,这样才能确保服务器中所有已到达的时间事件都会被处理。
时间事件API
ae.c/aeCreateTimeEvent
函数接受一个milliseconds和一个时间事件处理器proc作为参数,将一个新的时间时间添加到服务器。
ae.c/aeDeleteTimeEvent
函数接受一个时间事件ID作为参数,然后从服务器中删除该ID对应的时间事件。
ae.c/aeSearchNearestTimer
函数返回到达事件距离当前事件最近的那个时间事件
ae.c/processTimeEvents
函数是时间事件的执行器,这个函数遍历所有已经到达的时间事件,并调用这些事件的处理器。
事件的调度与执行
服务器需要不断监听文件事件的套接字才能得到待处理的文件事件,但是不能一直监听,否则时间事件无法在规定的时间内执行,因此监听时间应该根据距离现在最近的时间事件来决定。
事件的调度和执行由ae.c/aeProcessEvents
函数负责:
def aeProcessEvents():
# 获取到达时间离当前时间最接近的时间事件
time_event = aeSearchNearestTimer()
# 计算最接近的时间事件距离到达还有多少毫秒
remaind_ms = time_event.when - unix_ts_now()
# 如果事件已到达,那么 remaind_ms 的值可能为负数,将它设为 0
if remaind_ms < 0:
remaind_ms = 0
# 根据 remaind_ms 的值,创建 timeval
timeval = create_timeval_with_ms(remaind_ms)
# 阻塞并等待文件事件产生,最大阻塞时间由传入的 timeval 决定
aeApiPoll(timeval)
# 处理所有已产生的文件事件,事实上这个部分是直接写在这个函数里面的
procesFileEvents()
# 处理所有已到达的时间事件
processTimeEvents()
将aeProcessEvents
函数置于一个循环之中,加上初始化和清理函数,就构成了redis的服务器的主函数。
def main():
# 初始化服务器
init_server()
# 一直处理事件,直到服务器关闭
while server_is_not_shutdown():
aeProcessEvents()
# 服务器关闭,执行清理操作
clean_server()
事件的调度和执行规则
aeApiPoll
函数的最大阻塞时间由到达时间最接近当前时间的时间事件决定,这种方法既可以避免服务器对时间事件进行频繁的轮询,也可以确保aeApiPoll
函数不会阻塞过长时间。- 时间事件到达时间之前,一直处理到达的文件事件。
- 对文件事件和时间事件的处理都是同步、有序、原子的执行的,服务器不会中途中断事件处理,也不会对事件进行抢占。因此两种事件都应当尽量减少程序的阻塞时间。
- 因为时间事件在文件事件之后处理,并且事件之间不会出现抢占,所以时间事件的处理时间通常会比设定时间晚一些。
Redis客户端
Redis服务器是一个典型的一对多服务器程序:一个服务器可以与多个客户端建立网络连接,每个客户端可以向服务器发送命令请求,而服务器则接收并处理客户端发送的命令请求,并向客户端返回命令回复。
通多使用由IO多路复用技术实现的文件事件处理器,redis服务器使用单线程单进程的方式来处理命令请求,并于多个客户端进行网络通信。
对于每个域服务器进行连接的客户端,服务器都为这些客户端建立了相应的server.h/client
结构。主要内容有:
struct client{
int fd; // 客户端描述字
robj *name; // 客户端名字
sds querybuf; // 客户端请求缓存
int argc;
robj **argv; // 当前命令
struct redisCommand *cmd;
redisDb *db; // 指向当前选中的数据库的指针
int flags; // 客户端的标志值
list *reply; // 返回给客户端的对象
char buf[PROTP_REPLY_CHUNK_BYTES]; // 固定长度输出缓冲区
int bufpos; // 记录输出缓冲区输出目前已经使用的字节数量
list *reply; // 可变大小输出缓冲区
...
}
客户端属性
- 通用属性,很少与特定功能有关,无论客户端执行的是什么工作都需要这些属性
- 和特定功能相关的属性,比如操作数据库需要用到的db属性和dictid属性
套接字描述符
- 伪客户端的fd属性值为-1:伪客户端处理的命令请求来源于AOF文件或者LUA脚本,而不是网络,所以不需要套接字连接。目前两个地方用到伪客户端:一个用于载入AOF文件并还原数据库状态,一个用于执行Lua脚本包含的redis命令。
- 普通客户端的fd属性值为大于-1的整数
名字
一般情况下客户端是没有名字的,但是为了让客户端的身份清晰,可以使用CLIENT setname
命令为客户端设置名字
标志值
记录了客户端的角色(role),以及客户端所处的状态。
- 在复制操作时,主服务器会成为从服务器的客户端,从服务器也是主服务器的客户端,主服务器标志值为
REDIS_MASTER
,从服务器标志值为REDIS_SLAVE
REDIS_LUA_CLIENT
表示仅为处理Lua脚本命令的伪客户端REDIS_UNIX_SOCKET
表示服务器使用UNIX套接字来连接客户端
输入缓冲区
客户端状态的输入缓冲区用于保存客户端发送的命令请求。
输入缓冲区的大小会根据输入内容动态的缩小或者扩大,但是不能超过1G,否则服务器会关闭这个客户端。
命令参数
服务器将客户端发送的命令请求保存到输入缓冲区之后,会对命令请求的内容进行分析,并将得到的命令参数以及命令参数的个数分别保存到客户端状态的argv属性和argc属性。
argv
是一个数组,数组中的每个项都是一个字符串对象,其中argv[0]是要执行的命令,之后的其他项是传给命令的参数argc
负责记录argv数组的长度
命令的实现函数
struct redisCommand *cmd
:服务器从协议内容分析得到argv和argc属性后,服务器将根据项argv[0]的值,在命令表中查找命令所对应的命令实现函数。
输出缓冲区
执行命令得到的命令回复会被保存在客户端状态的输出缓冲区里,每个客户端都有两个输出缓冲区可用,一个的大小是固定的,一个大小是可变的。
- 固定大小的缓冲区用于保存长度较小的回复,默认值为16*1024(16KB)
- 可变大小的缓冲区用于保存那些长度比较长的回复,使用reply链表和一个或者多个字符串对象组成
身份验证
客户端状态中的authenticated
属性记录客户端是否通过了身份验证,如果为0表示未通过身份验证,为1表示通过
- 当属性值为0时,除了
AUTH
命令之外,客户端的其他命令都会被服务器拒绝执行 - 该属性仅在服务器启用了身份验证功能时使用
时间
time_t ctime; // 客户端创建的时间
time_t lastinteraction; // 最后一次互动的时间
time_t obuf_soft_limit_reached_time; // 客户端的空转时间,也即距离最后一次互动过去了多久
客户端的创建与关闭
普通客户端
连接:通过网络连接和服务器进行连接的客户端,在客户端使用connect
函数连接到服务器时,服务器会调用连接事件的处理器,并为客户端创建相应的客户端状态,并将这个客户端状态添加到服务器状态结构clients链表的结尾。
关闭:
- 客户端进程退出或者被杀死,客户端与服务器的网络连接被关闭,此时客户端关闭
- 客户端向服务器发送了带有不符合协议格式的命令请求,也会被关闭
- 客户端成为
CLIENT KILL
命令的目标,会被关闭 - 服务器设置了
timeout
选项,那么客户端的空转时间超过这个值,会被关闭 - 客户端发送的命令请求超过了输入缓冲区大小,会被关闭
- 发送给客户端的命令回复的大小超过输出缓冲区大小,会被关闭
- 理论上有了可变输出缓冲区,该缓冲区大小可以保存任意长的回复,但是为了避免客户端回复过大,服务器会检查客户端的输出缓冲区的大小,使用两种方式限制客户端输出缓冲区的大小
- 硬性限制:如果输出缓冲区大小超过了硬性限制所设置的大小,那么服务器会立即关闭客户端
- 软性限制:如果输出缓冲区大小超过了软性限制所设置的大小,但是还没超过硬性限制大小,那么服务器使用
obuf_soft_limit_reached_time
属性记录客户端到达软性限制的起始时间,如果该属性持续超过服务器设定时长,客户端会被关闭;否则该值清零
- 理论上有了可变输出缓冲区,该缓冲区大小可以保存任意长的回复,但是为了避免客户端回复过大,服务器会检查客户端的输出缓冲区的大小,使用两种方式限制客户端输出缓冲区的大小
Lua伪客户端
服务器在初始化时会创建负责执行lua脚本中包含的redis命令的伪客户端,并将这个伪客户端关联在服务器状态结构的lua_client
属性中,伪客户端在服务器运行的整个生命周期都是存在的。
**AOF文件的伪客户端:**服务器在载入AOF文件时,会创建用于执行AOF文件包含的reids命令的伪客户端,并在载入完成之后,关闭这个伪客户端。
Redis服务器
Redis服务器负责与多个客户端建立网络连接,处理客户端发送的命令请求,在数据库中保存客户端执行命令产生的数据,并通过资源管理来维持服务器自身的运转。
命令请求的执行过程
例:SET KEY VALUE --->OK
- 客户端向服务器发送命令请求
SET KEY VALUE
- 服务器接收并处理客户端发送的命令请求,在数据库中进行设置操作,并产生命令回复OK
- 服务器将命令回复OK发送给客户端
- 客户端接收到回复,并显示出来
发送命令请求
- 用户键入命令请求
- 客户端转换为协议格式
- 通过连接到服务器套接字将协议格式命令请求发送给服务器
- 例如
SET KEY VALUE
转换为协议格式*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n
读取命令请求
当连接套接字因为客户端的写入而变得可读时,服务器调用命令请求处理器来执行以下操作:
- 读取套接字中协议格式的命令请求,并将其保存到客户端状态的输入缓冲区里
- 对输入缓冲区中的命令请求进行分析,提取出命令请求中包含的命令参数,以及命令参数的个数,然后分别将参数和参数个数保存在argv和argc属性中
- 调用命令执行器,执行客户端指定的命令
命令执行器
struct redisCommand{
char *name; // 命令名称,比如"SET"
// typedef void redisCommandProc(client *c)
redisCommandProc *proc; // 函数指针,指向命令的实现函数,比如setCommand
int arity; // 命令参数的个数,用于检查命令请求是否正确。如果该值为-N,标识参数数量大于等于N
char *sflags; // 字符串形式的标识值,这个值记录了命令的属性
uint64_t flags; // 对标识分析得到的二进制标识
long long microseconds, calls; // 执行命令耗费的总时长,总共执行了多少次这个命令
};
// sflags
w // 写入命令,可能会修改数据库, 比如SET, RPUSH, DEL等
r // 只读命令,不会修改, 比如GET, STRLEN, EXISTS等
m // 该命令会占用大量内存,执行前先检查内存情况, 比如SET, APPEND,RPUSH等
a // 管理命令 比如SAVE,BGSAVE等
例如:SET命令的名字为"set",实现函数为setCommand
,参数个数为-3;标识为"wm
"
程序对输入的argv[0]
,在命令表中进行查找,命令表返回argv[0]
对应的redisCommand
结构,客户端状态的cmd指针会指向这个结构。
执行预备操作:
- 检查客户端状态的cmd指针是否指向NULL,如果是则向客户端返回一个错误
- 根据客户端cmd属性指向的redisCommand结构的arity属性,检查命令请求所给定的参数个数是否正确,如果不正确,返回错误
- 检查客户端是否通过身份验证
- 如果服务器打开了maxmemory功能,那么在执行命令之前,先检查服务器的内存占用情况,并在有需要时进行回收,如果内存回收失败,返回错误
- 如果服务器上次BGSAVE命令出错,并且服务器打开了
stop-writes-on-bgsave-error
,而且服务器将要执行的是一个写命令,那么服务器拒绝执行这个命令,并返回错误 - 如果客户端正在用SUBSCRIBE命令订阅频道,或者正在用PSUBSCRIBE命令,那么服务器只会执行客户端发来的订阅相关的命令,其他命令会被拒绝。
- …
调用命令的实现函数
client->cmd->proc(client)
,被调用的命令实现函数会执行指定的操作,并产生相应的命令回复,这些回复会被保存在客户端状态的输出缓冲区里,之后实现函数还会为客户端的套接字关联命令回复处理器,负责后续回复操作。
执行后续操作
实现函数执行完后,还会执行一些后续操作:
- 如果服务器开启了慢查询日志功能,那么慢查询日志模块会检查是否需要为刚执行完的命令请求添加一条新的慢查询日志
- 根据执行命令耗费的时长,更新被执行命令的redisCommand结构的milliseconds属性,并将calls属性值加1
- 如果服务器开启了AOF持久化,将刚执行的命令请求写入到AOF缓冲区里
- 如果有其他从服务器正在复制当前服务器,那么服务器会将刚执行的命令传播给其他服务器
将命令回复给客户端
关联命令回复处理器后,当客户端套接字变为可写状态时,服务器会执行命令回复处理器,将保存在客户端输出缓冲区中的命令回复发送给客户端。
当发送完成后,回复处理器会清空客户端状态的输出缓冲区,为处理下一个命令请求做好准备。
serverCron函数
该函数默认每个100ms执行一次,负责管理服务器的资源,并保持服务器自身的良好运转。
更新服务器时间缓存
服务器状态中的unixtime
和mstime
属性被用作当前时间的缓存。由于每100ms更新一次,所以该时间精度不高
- 只会在打印日志,更新服务器的LRU时钟,决定是否执行持久化任务,计算服务器上线时间这些对精度要求不高的功能上
- 对于为键设置过期时间,添加慢查询日志这种需要高精度的功能来说还是需要每次执行系统调用
更新LRU时钟
服务器状态中的lruclock
属性保存了服务器的LRU时钟,也是服务器时间缓存的一种。
当服务器要计算一个数据库键的空转时间,程序会使用服务器的lruclock
属性记录的时间减去对象的lru
属性记录的时间,得出的结果就是这个对象的空转时间。
更新服务器每秒执行命令次数
trackInstantaneousMetric
函数以每100ms一次的频率执行,这个函数的功能是以抽样计算的形式,估算并记录服务器在最近一秒钟处理的命令请求数量。估算值
更新服务器内存峰值记录
服务器状态中的stat_peak_memory
属性记录了服务器的内存峰值大小。更新最大值
处理SIGTERM信号
启动服务器时,Redis会为服务器进行的SIGTERM信号关联处理器sigtermHandler
函数,这个信号处理器负责在服务器接收到SIGTERM信号时,打开服务器状态的shutdown_asap
标识。
每次根据该标识决定是否关闭服务器。关闭之前会进行RDB持久化操作。
管理客户端资源
每次执行都会调用clientsCron
函数,会对一定数量的客户端进行以下两个检查
- 如果客户端与服务器之间连接已经超时,那么程序释放该客户端
- 如果客户端在上一次的请求之后,输入缓冲区的大小超过了一定的长度,那么程序 会释放客户端当前的输入缓冲区,并重新创建一个默认大小的输入缓冲区,从而防止客户端的输入缓冲区耗费过多的内存。
管理数据库资源
内次执行都会调用databsesCron
函数,对服务器中一部分数据库进行操作,删除其中的过期键,并在有需要时,对字典进行收缩操作。
执行被延迟的BGREWRITEAOF
在服务器执行BGSAVE命令的期间,如果客户端向服务器发来BGREWRITEAOF命令,那么服务器会将BGREWRITEAOF命令的执行时间延迟到BGSAVE命令执行完毕之后。
服务器状态的aof_rewrite_scheduled
标识记录了服务器是否延迟了该命令。
检查持久化操作的运行状态
服务器状态使用rdb_child_pid
和aof_child_pid
属性记录执行BGSAVE命令和BGREWRITEAOF命令的子进程pid,这两个属性可以用于检查这两个命令是否正在执行。
rdb_child_pid
:如果服务器没有在执行BGSAVE,该值为-1aof_child_pid
:如果服务器没有在执行BGREWRITEAOF,该值为-1
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZLTnlgFJ-1602224447734)(C:/Users/free/Desktop/面试/interviewmd/assets/post/others/chijiuhua.png)]
将AOF缓冲区内容写入到AOF文件
如果开启了AOF持久化,并且AOF缓冲区内还有待写入的数据,那么此时将AOF缓冲区中的内容写入到AOF文件里面。
关闭异步客户端
服务器会关闭那些输出缓冲区大小超出限制的客户端
增加cronloops计数器的值
cronloops
属性的唯一作用就是在复制模块中实现“每执行serverCron函数N次就执行一次指定代码”的功能
初始化服务器
- 初始化服务器状态结构,即初始化一些默认属性比如运行ID、默认运行频率、默认配置文件路径、运行架构、默认端口号、创建命令表等。
- 载入配置选项,如果用户为启动指定了配置文件,就使用配置文件中指定的值初始化服务器
- 初始化服务器数据结构,包括
clients
链表,db
数组,server.pubsub_channels
字典,server.lua
,server.slowlog
慢查询日志属性。 - 还为服务器设置进程信号处理器,创建共享对象(包含redis服务器经常用到的一些值,比如包含“OK”回复的字符串),打开服务器的监听端口,并为监听套接字关联连接应答事件处理器,等待服务器正式运行时接受客户端的连接。
- 并为serverCron函数创建时间事件,等待服务器正式运行时执行serverCron函数。
- 如果AOF持久化功能已经打开,那么打开现有的AOF文件,如果AOF文件不存在,那么创建并打开一个AOF文件
- 初始化服务器的后台IO模块,为将来的IO操作做好准备。
还原数据库状态
完成了服务器状态server的初始化之后,服务器需要载入rdb文件或者aof文件,并根据文件内容还原数据库的状态。
- 如果开启了该功能,那么服务器使用AOF文件来还原数据库状态
- 否则使用RDB文件
执行事件循环
初始化的最后一步,服务器打印准备好进行连接之后开始执行服务器的事件循环(loop)
Redis复制
Redis中,可以通过执行SLAVEOF
命令或者设置slaveof
选项,让一个服务器去复制(replicate)另一个服务器,被复制的称为主服务器(master),进行复制的被称为从服务器(slave)。
Redis的复制功能分为两个操作:同步(sync)和命令传播(command propagate):
- 同步操作用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态
- 命令传播用于在主服务器的数据库状态被修改,导致主从服务器的数据库状态出现不一致,让主从服务器的数据库重新回到一直状态。
SYNC
- 从服务器向主服务器发送SYNC命令
- 收到SYNC命令的主服务器执行BGSAVE命令,在后台生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令。
- 当主服务器的BGSAVE命令执行完毕,主服务器会将生成的RDB文件发送给从服务器,从服务器载入这个文件,将自己的数据库更新至主服务器执行BGSAVE时的数据库状态
- 主服务器将记录在缓冲区里面的所有写命令发送给从服务器,从服务器执行这些命令将自身状态更新到主服务器当前状态。
SYNC是一个非常耗费资源的操作:
- 生成RDB文件会耗费大量的CPU、内存和磁盘IO资源
- 传送RDB文件给从服务器会耗费主从服务器上的大量网络资源,并对主服务器响应命令请求的时间产生影响
- 从服务器载入RDB文件会因为阻塞而无法处理命令请求。
命令传播
主服务器将自己执行的写命令,发送给从服务器执行,这样可以保持一致状态。
PSYNC
为了解决旧版复制功能在处理断线重复情况时的低效问题,Redis使用PSYNC命令代替SYNC命令来执行复制时的同步操作。
PSYNC命令具有完整重同步(full resynchronization)和部分重同步(partial resynchronization)两种模式:
- 完整重同步用于处理初次复制情况:完整重同步的执行步骤和SYNC命令的执行步骤基本一样,都是通过让主服务器创建并发送RDB文件,以及向服务器发送保存在缓冲区里面的写命令来进行同步。
- 部分重同步则用于处理短线后重复制情况:当服务器在断线后重新连接主服务器时,如果条件允许,主服务器可以将主服务器连接断开期间执行的写命令发送给从服务器,从服务器只要接收并执行这些写命令,就可以将数据库更新到主服务器当前所处的状态。
部分重同步的实现
有三个部分构成:
- 主服务器的复制偏移量(replication offset)和从服务器的复制偏移量
- 主服务器的复制积压缓冲区(replication backlog)
- 服务器运行ID(run ID)
复制偏移量
执行复制的双方——主服务器和从服务器会分别维护一个复制偏移量:
- 主服务器每次向从服务器传播N个字节的数据时,就将自己的复制偏移量的值加上N
- 从服务器每次收到主服务器传播来的N个字节的数据时,九江自己的复制偏移量的值加N
通过对比复制偏移量,可以很容易的知道主从服务器是否处于一致状态。
复制积压缓冲区
复制积压缓冲区是由主服务器维护的一个**固定长度(fixed-size)先进先出(FIFO)**队列,默认大小为1MB。
当主服务器进行命令传播时,它不仅会将写命令发送给所有从服务器,还会将写命令入队到复制积压缓冲区里。
当从服务器重新连接到主服务器时,从服务区会通过PSYNC命令将自己的复制偏移量offset发送给主服务器,主服务器会根据这个偏移量来决定服务器进行何种同步操作:
- 如果offset还在缓冲区里面,执行部分重同步,否则完全重同步
复制积压缓冲区的大小可以用second*write_size_per_second来估算。
服务器运行ID
每个Redis服务器,不论是主服务器还是从服务器,都会有自己的运行ID
运行ID在服务器启动时自动生成,由40个随机的16进制字符组成。
从服务器对主服务器进行初次复制时,主服务器会将自己的运行ID发送给从服务器,从从服务器会将这个运行ID保存起来。
当从服务器断线并重新连接这个主服务器,从服务器将向当前连接的主服务器发送之前保存的运行ID;如果一致尝试执行部分重同步,如果不一致执行完整重同步操作。
PSYNC命令实现
如果从服务器从来没有复制过任何主服务器,或者之前执行过SLAVEOF no one命令,那么从服务器在开始一次新的复制时发送PSYNC ? -1
命令,主动请求主服务器进行完整重同步。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fKXw3cIa-1602224447736)(C:/Users/free/Desktop/面试/interviewmd/assets/post/others/psync.png)]
复制的实现
通过向从服务器发送SLAVEOF
命令,可以让一个从服务器去复制一个主服务器:
SLAVEOF <master_ip> <master_port>
复制功能实现的主要步骤:
- 设置主服务器的地址和端口
- 从服务器收到客户端的命令之后将IP和端口保存到服务器状态的
masterhost
和masterport
属性里。 - SLAVEOF是一个异步命令,设置完成服务器状态属性之后,从服务器返回OK,实际的复制工作之后完成
- 从服务器收到客户端的命令之后将IP和端口保存到服务器状态的
- 建立套接字连接:从服务器根据命令所设置的IP地址和端口创建连向主服务器的套接字连接。
- 从服务器创建的套接字能成功连接(connect)到主服务器,那么从服务器将为这个套接字关联到一个专门用于处理复制工作的文件事件处理器,负责后续的复制工作。
- 主服务器在接受(accept)从服务器的套接字连接之后,为该套接字创建相应的客户端状态,并将从服务器看作是一个连接到主服务器的客户端看待(即从服务器是主服务器的客户端)
- 发送PING命令:连接成功之后从服务器向主服务器发送一个PING命令
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zZl2gP27-1602224447739)(C:/Users/free/Desktop/面试/interviewmd/assets/post/others/copyping.png)]
- 身份验证
- 如果从服务器设置了
masterauth
选项,就进行身份验证;反之不进行身份验证 - 在需要进行身份验证的情况下,从服务器向主服务器发送一条AUTH命令,命令的参数为从服务器
masterauth
选项的值
- 如果从服务器设置了
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hG66MWTc-1602224447744)(C:/Users/free/Desktop/面试/interviewmd/assets/post/others/copyauth.png)]
- 发送端口信息
- 身份验证之后,从服务器执行
REPLCONF listening-port <port-number>
,向主服务器发送从服务器的监听端口号。 - 主服务器收到这个端口,将其设置在客户端状态中的
slave_listening_port
属性中 - 目前该属性唯一的作用是在主服务器执行
INFO replication
命令时输出端口号
- 身份验证之后,从服务器执行
- 同步:从服务器向主服务器发送PSYNC命令,执行同步操作,并将自己的数据库更新至主服务器数据库当前所处的状态
- 注意:在同步操作执行之前,只有从服务器是主服务器的客户端,但是在执行之后,主服务器也会变成从服务器的客户端
- 命令传播:主服务器只要一直系那个自己执行的写命令发送给从服务器,从服务器一直接收并执行写命令就可以实现同步了
- 心跳检测:在命令传播阶段,从服务器默认每秒一次的频率向主服务器发送命令
REPLCONF ACK <replication_offset>
,参数为从服务器当前的偏移量,主要用来实现- 检测主从服务器的网络连接状态
- 辅助实现min-slaves选项
- 检测命令丢失
- 心跳检测:在命令传播阶段,从服务器默认每秒一次的频率向主服务器发送命令
Redis哨兵(Sentinel)
Sentinel(哨兵)是Redis的高可用性解决方案:有一个或者多个Sentinel实例组成的Sentinel系统可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,自动将下线主服务器属下的某个从服务器升级为主服务器,然后由新的主服务器代替下线的主服务器继续处理命令请求;并发送命令将余下的从服务器转转化为当前主服务器的从服务器。
当下线的主服务器重新上线之后,自动将其设置为当前主服务器的从服务器。
Sentinel本质就是一个运行在特殊模式下的Redis服务器。
启动并初始化Sentinel
redis-sentinel <conf file path>
初始化服务器
- Sentinel本质上就是一个运行在特殊模式下的Redis服务器,所以首先初始化一个普通的Redis服务器
- 但Sentinel的服务器与普通服务器并不相同
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8np36AJO-1602224447746)(C:/Users/free/Desktop/面试/interviewmd/assets/post/others/sentinelfunc.png)]
使用sentinel专用代码
- 将一部分普通Redis服务器使用的代码替换成Sentinel专用代码。
- 例如Sentinel使用的端口号为26379,普通服务器使用的是6379
- Sentinel使用的命令表为
sentinel.c/sentinelcmds
,普通服务器使用的是server.c/redisCommandTable
初始化Sentinel状态
服务器初始化一个sentinel.c/sentinelState
结构(称为Sentinel状态),这个结构保存了服务器所有和Sentinel功能有关的状态。
struct sentinelState{
dict *masters; // 字典的键是主服务器的名字,值是一个指向sentinelRedisInstance结构的指针
...
}sentinel;
初始化Sentinel状态的masters属性
Sentinel状态中的masters字典记录了所有被Sentinel监视的主服务器的相关信息,其中:
- 字典的键是被监视主服务器的名字
- 字典的值是被监视主服务器对应的
sentinel.c/sentinelRedisInstance
结构,每个该结构代表一个被Sentinel监视的Redis服务器实例,这个实例可以是主服务器、从服务器,或者是另一个Sentinel
masters字典的初始化是根据Sentinel配置文件来进行的。
创建连向主服务器的网络连接
初始化的最后一步是创建连向主服务器的网络连接,Sentinel将称为主服务器的客户端,可以向主服务器发送命令,并从命令回复中获取相关的信息。
对于每个被Sentinel监视的主服务器来说,Sentinel会创建两个连向主服务器的异步网络连接:
- 一个是命令连接,这个连接专门用于向主服务器发送命令,并接收命令回复
- 一个是订阅连接,专门用来订阅主服务器的
__sentinel__:hello
频道
获取主服务器信息
Sentinel默认以每10秒一次的频率,通过命令连接向被监视的主服务器发送INFO命令,并通过分析INFO命令的回复来获取主服务器的当前信息。信息中一部分是主服务器自身的信息;另一部分是主服务器属下所有的从服务器的信息。
# Server
...
run_id:xxxxxxx(40位)
...
# Replication
role:master
...
slave0:ip=xxx,port=xxx,state=online,offset=xx,lag=0
slave1:ip=xxx,port=xxx,state=online,offset=xx,lag=0
...
# Other Sections
...
获取从服务器信息
当Sentinel发现主服务器有新的从服务器出现时,Sentinel除了会对这个新的从服务器进行相应的实例结构之外,Sentinel还会创建连接到这个从服务器的命令连接和订阅连接。
# Server
...
run_id:xxxxx
...
# Replication
role:slave
master_host:xxx
master_port:xxx
master_link_status:up // 主从服务器连接状态
slave_repl_offset:xxx // 从服务器复制偏移量
slave_priority:100 // 优先级
# Other Sections
...
向主从服务器发送信息
默认情况下,Sentinel会以每2秒一次的频率通过命令连接向所有被监视的服务器发送以下格式的命令:
PUBLISH __sentinel__:hello "<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_epoch>"
其中s开头的信息为Sentinel本身的信息,m开头的信息为主服务器的信息。
(开始)接收来自主从服务器的频道信息
当Sentinel与一个主服务器或者从服务器建立起订阅连接之后,Sentinel就会通过订阅连接,向服务器发送以下命令:
SUBSCRIBE __sentinel__:hello
Sentinel对__sentinel__:hello
频道的订阅会一直持续到Sentinel与服务器连接断开为止。也就是说,对于每个和sentinel连接的服务器,Sentinel既可以通过命令连接向服务器的__sentinel__:hello
频道发送信息,也可以通过订阅连接从服务器的__sentinel__:hello
频道接收信息。
对于同监视同一个服务器的多个Sentinel来说,一个Sentinel发送的信息会被其他Sentinel接收到,这些信息会被用于更新其他Sentinel对发送信息Sentinel的互相认知。
更新sentinels字典
Sentinel为主服务器创建的实例结构中的sentinels字典保存了除Sentinel本身之外,其他监视该主服务器的Sentinel的资料。
- 键为其他Sentinel的名字,一般为IP:PORT
- 值为对应的Sentinel的实例结构
当一个Sentinel接收到其他Sentinel发来的信息时,目标Sentinel会从信息中分析并提取出一些参数:
- 与Sentinel有关的参数如源Sentinel的IP地址,端口号,运行ID和配置纪元
- 与主服务器有关的参数如源Sentinel正在监听的主服务器的名字,IP地址,端口号和配置纪元
根据提取出的主服务器参数,目标Sentinel会在自己的状态的masters字典中查找相应的主服务器实例结构,然后根据提取出的Sentinel参数检查主服务器实例结构中的sentinels字典,然后创建新实例结构或者更新实例。
检查主观下线状态
每个Sentinel节点会每隔1秒对主节点、从节点、其他Sentinel节点发送ping命令做心跳检测,当这些节点超过down-after-milliseconds没有进行有效回复,Sentinel节点就会对该节点做失败判定,这个行为叫做主观下线
检查客观下线状态
当Sentinel将一个主服务器判断为主观下线后,为了确认这个主服务器是否真的下线,他会同样监视这个主服务器的其他Sentinel进行询问,当Sentinel从其他Sentinel那里接收到足够数量(启动时设置的参数)的已下线判断之后,Sentinel就会判定为客观下线,并进行故障转移操作。
选举领头Sentinel(Raft)
领头Sentinel用于对主服务器进行故障转移。
-
Raft协议描述的节点共有三种状态:Leader, Follower, Candidate。
-
在系统运行正常的时候只有Leader和Follower两种状态的节点。一个Leader节点,其他的节点都是Follower。
-
Candidate是系统运行不稳定时期的中间状态,当一个Follower对Leader的的心跳出现异常,就会转变成Candidate,Candidate会去竞选新的Leader,它会向其他节点发送竞选投票,如果大多数节点都投票给它,它就会替代原来的Leader,变成新的Leader,原来的Leader会降级成Follower。
Raft选举流程
- 增加自己的epoch
- 启动一个新的定时器(随机超时时间,随机选取配置时间的1到2倍之间。所以先成为candidate的更有机会成为leader)
- 给自己投一票
- 向所有节点发送RequestVote,并等待其他节点的回复。在这期间如果收到其他节点发送的AppendEntries就说明有leader产生了,自己转换为Follower,选举结束。
- 如果在计时器超时前,节点收到多数节点(N/2+1)的同意投票,就转换为Leader,同时向所有的其他节点发送AppendEntries,告知自己成为了leader。
- 如果所有candidate都没有拿到多数票,那么等计时器超时后重新成为candidate,重复选举流程。
Sentinel选举流程
当需要故障转移的时候会在sentinel集群中选举出一个leader执行故障转移操作。Sentinel使用了Raft协议实现Sentinel间选举leader的算法。Sentinel集群运行过程中故障转移完成,所有Sentinel又会恢复平等。Leader仅仅是故障转移时出现的角色。
- 某个Sentinel认定master客观下线后,会检查自己有没有投过票,如果已经投过,在2倍的故障转移超时时间内不会成为leader。
- 如果未投过票,就成为Candidate
- 以下进入Raft流程
- 更新故障转移状态开始
- 当前epoch+1,更新自己的超时时间未当前时间加1s内的随机毫秒数
- 向其他节点发送
is-master-down-by-addr
命令请求选票,并带上自己的epoch - 给自己投一票,在sentinel中,给自己投票的方式是把自己master结构体中的leader和leader_epoch改成投给的sentinel和它的epoch
- 其他sentinel收到Candidate发送的
is-master-down-by-addr
命令,如果Sentinel当前epoch和Candidate传给他的epoch一样,说明它已经给别的candidate投过票,投过票的Sentinel在当前epoch内只能成为follower。 - Candidate会不断统计自己的票数,如果发现票数超过一半并且超过配置的quorum就成为Leader
- 如果在一个选举时间内,candidate没有获得一半且超过quorum的票数,则选举失败;等待超过2倍故障转移的超时时间后,重新开始选举流程。
- 与Raft不同,Leader不会把自己称为Leader的消息发给其他Sentinel。其他Sentinel等待leader选出master后,检测到新的master正常工作后,就会去掉客观下线的标志,从而不需要进入故障转移流程。
故障转移
选出新的主服务器
- 在已下线主服务器属下的所有从服务器里,选择一个从服务器并将其设置为主服务器
- 将所有从服务器保存在一个列表里,删除处于下线或者短线的,删除最近5秒内没有回复过领头Sentinel的INFO命令的,删除
- 让已下线主服务器属下的所有从服务器改为复制新的主服务器
- 将已下线主服务器设置为新的主服务器的从服务器,当旧主服务器重新上线后,会自动改变角色
选择完成后,领头Sentinel向被选中的从服务器发送SLAVEOF no one
命令,之后以一秒一次的频率向被升级的从服务器发送INFO命令,观察命令回复的角色信息。
修改从服务器的复制目标
向其他从服务器发送SLAVEOF <ip> <port>
命令。
将旧主服务器变为从服务器
在旧主服务器实例中的角色属性将其转换为从服务器,在它重新上线后,向他发送SLAVEOF
命令。
Redis集群
Redis集群是Redis提供的分布式数据库方案,集群通过分片(sharding)来进行数据共享,并提供复制和故障转移功能。
节点
一个Redis集群有多个**节点(node)**组成。使用CLUSTER MEET
命令连接各个节点:
CLUSTER MEET <ip> <port>
向一个节点发送以上命令,可以让该节点同ip和port所指定的节点进行握手,握手成功之后,该节点就会将指定节点添加到该节点所在的集群中。
启动节点
一个节点就是一个运行在集群模式下的Redis服务器,Redis服务器在启动时会根据cluster-enabled
配置选项是否为yes来决定是否开启服务器的集群模式。
节点会继续使用所有在单机模式下使用的服务器组件,比如文件事件处理器,时间事件处理器,RDB和AOF持久化等。
其中serverCron函数会调用集群模式特有的clusterCron函数,该函数会执行在集群模式下需要执行的常规操作。比如向集群中的其他节点发送Gossip消息,检查节点是否断线等。
节点还使用cluster.h/clusterNode,clusterLink,clusterState
结构保存集群模式状态。
集群数据结构
每个节点都会使用一个clusterNode结构来保存自己的状态,比如节点创建时间,节点名字,节点IP地址和端口等,并为集群中的所有其他节点(包括主节点和从节点)都创建一个相应的clusterNode结构。
clusterNode结构中的link属性是一个clusterLink结构,该结构保存了连接节点所需要的有关信息,比如套接字描述符,输入输出缓冲区等。
clusterState结构记录了当前节点的视角下,集群目前所处的状态,比如集群是在线还是下线,集群包含了多少节点,集群目前的配置纪元等
CLUSTER MEET命令实现
通过向节点A发送CLUSTER MEET <ip> <port>
命令,客户端可以让接收命令的节点A将另一个节点B添加到集群里
收到命令的节点A将与节点B进行握手,来确认彼此存在:
- 节点A为B创建一个
clusterNode
结构,并添加到自己的clusterState.node
字典里 - 节点A根据命令给出的ip和port,向节点B发送一条MEET消息
- 节点B收到节点A发送的MEET消息,节点B会为节点A创建一个
clusterNode
结构,并添加到自己的集群节点字典中 - 之后节点B向A返回一条PONG消息
- A收到B发送的PONG消息,向B发送一条PING消息
- B收到A发送的PING消息,B知道A成功收到自己的PONG消息,握手完成
之后,节点A通过Gossip协议将节点B的信息传播给集群中的其他节点,让其他节点与节点B握手,经过一段时间,节点B会被集群中的所有节点认识。
槽指派
Redis集群通过分片的方式来保存数据库中的键值时:集群的整个数据库被分为16384(2^14)个槽(slot),数据库中的每个键都属于这16384个槽中的其中一个,集群中的每个节点可以处理0或者16384个槽。(因为传输节点数据时用到了位图存储,一次大约2K,心跳包适中)
当数据库中的16384个槽都有节点在处理时,集群处于上线状态(OK);相反如果有任何一个或者多个没有节点处理,就处于下线状态(fail)。
通过CLUSTER ADDSLOTS
命令,可以将一个或者多个槽指派给节点负责。
clusterNode
结构中的slots
和numslot
属性记录了节点负责处理哪些槽。其中slots
数组记录的二进制位信息,大小为16384/8。
节点除了会将自己负责的槽记录在属性中之外,海会将自己的slots
数组通过消息发送给集群中的其他节点,以此来告诉其他节点自己目前负责处理哪些槽。其他节点根据这些信息更新保存的其他节点结构中的slots
属性。
clusterState
结构中的slots
数组记录了每个槽的指派信息,大小为16384。
执行CLUSTER ADDSLOTS
命令中,需要先检查所有输入槽是否都处于未指派状态,然后再对他们进行指派操作。
集群中执行命令
在对数据库的16384个节点进行指派之后,集群就处于上线状态,当客户端向节点发送与数据库键有关的命令时,接收命令的节点计算要处理的键属于哪个槽CRC16(key)&16383
,并检查该槽是否指派给了自己:
- 如果该槽指派给了当前节点,那么该节点直接执行该命令
- 如果没有指派给当前节点,那么节点向客户端返回一个
MOVED
错误,指引客户端转向正确的节点,并向正确的节点再次发送命令。
节点和单机服务器在数据库方面的一个区别是:节点只能使用0号数据库,单机没有这个限制。
除了将键值对保存在数据库里之外,节点还会用clusterState
结构中的slots_to_keys
rax树结构保存槽和键之间的关系。
重新分片
Redis集群的重新分片操作可以将任意数量已经指派给某个节点的槽改为指派给另一个节点,并且相关槽所属的键值对也会转到另一个节点。
重新分片操作是通过Redis的集群管理软件redis-trib
负责执行的,Redis提供了进行重新分片所需的所有指令,而redis-trib
则通过向源节点和目标节点发送命令来进行重新分片操作。
主要步骤为:
redis-trib
对目标节点发送CLUSTER SETSLOT <slot> IMPORTING <source_id>
命令,让目标节点准备好导入属于槽slot的键值对。redis-trib
对源节点发送CLUSTER SETSLOT <slot> MIGRATING <target_id>
命令,让源节点准备好迁移(migrating)slot键值对到目标节点。redis-trib
对目标节点发送CLUSTER GETKEYSINSLOT <slot> <count>
命令,获得最多count个属于槽slot的键值对的键名。- 对于上个步骤获得的每个键名,
redis-trib
都向源节点发送MIGRATE <target_ip> <target_port> <key_name> 0 <timeout>
,将被选中的键原子的从源节点迁移到目标节点。 - 重复执行上述步骤,直到所有键值对迁移完成。
redis-trib
向集群中任意一个节点发送CLUSTER SETSLOT <slot> NODE <target_id>
命令,将槽id指派给目标节点,最终整个集群都会知道。
ASK错误
在重新分片过程中,可能存在部分键值对保存在源节点中,另一部分在目标节点。此时仓客户端发送一个与数据库键有关的命令,并且命令要处理的数据库键恰好属于正在被迁移的槽(保存在clusterNOde *migrating_slots_to[16384]
中)时:
- 源节点首先在自己数据库中查找指定的键,如果找到就返回给客户端
- 如果没有找到,那么这个键有可能被迁移到了目标节点中,源节点向客户端返回一个ASK错误,指引客户端转向目标节点查找。
ASK错误和MOVED错误的区别
两者都会导致客户端转向
- MOVED错误标识槽的负责权已经从一个节点转移到另一个节点,永久性重定向
- ASK错误是迁移槽过程中使用的一种临时措施,客户端收到该错误之后,会在下一次命令请求中将其发送到该错误所指定的节点上,之后还是发到当前节点上
复制和故障转移
Redis集群中由多个节点组成,主节点负责进行处理槽,每个主节点还可以有多个从节点,用于复制主节点,达到高可用的目的。
当发生主节点掉线后,集群自动从该主节点的从节点中选择一个作为主节点处理相应的槽。
- 设置从节点
- 向一个节点发送
CLUSTER REPLICATE <node_id>
将其变为指定节点的从节点,并对其进行复制 - 接收到该命令的节点首先在自己的
clusterState.nodes
字典中找到node_id
所对应节点的clusterNode
结构,并将自己的clusterState.myself.slaveof
指针指向这个结构,以此来记录这个节点正在复制的主节点。 - 修改
clusterState.myself.flags
中的属性,关闭REIDS_NODE_MASTER
,打开REDIS_NODE_SLAVE
,表示该节点已经变为从节点 - 最后,节点调用复制代码,根据第二步保存的主节点信息对齐进行复制,相当于向从节点发送命令
SLAVEOF <master_ip> <master_port>
- 当一个节点成为从节点并开始复制某个主节点后,这一信息会发送给集群内所有节点,集群中的所有节点更新该主节点结构体(
nodes值
)中的slaves
和numslaves
属性。
- 向一个节点发送
- 故障检测
- 集群中的每个节点都会定期向集群中的其他节点发送PING消息,以此来检测对象是否在线,如果接收PING消息的节点在规定时间内没有返回PONG消息,将其标记为疑似下线。
- 集群中的各个节点还会通过互相发送消息的方式来交换集群中各个节点的状态信息,比如
ONLINE, PFAIL, FAIL
- 所有收到某主节点疑似下线信息的节点将该信息保存到某节点结构体中的
fail_reports
链表中。 - 如果一个集群里面半数负责处理槽的主节点都将某个主节点x报告为疑似下线,那么这个主节点x就会被标记为下线,同时将该信息广播给集群内其他节点,其他收到该信息的节点立即将主节点x标记为下线。
- 故障转移
- 当一个从节点发现自己正在复制的主节点进入了下线状态,从节点开始对下线主节点进行故障转移:
- 选中一个从节点执行
SLAVEOF no one
,成为新的主节点 - 新的主节点会撤销所有对已下线主节点的槽指派,并将槽全部分配给自己
- 新的主节点向集群广播一条PONG消息,让其他节点知道该节点已转变为主节点
- 该新主节点开始处理命令请求。
- 选中一个从节点执行
- 当一个从节点发现自己正在复制的主节点进入了下线状态,从节点开始对下线主节点进行故障转移:
消息
集群中的各个节点通过发送和接收消息(message)来进行通信,消息由消息头和消息体组成,消息主要包括以下5种:
MEET
消息:连接节点,使节点加入到集群中PING
消息:每1秒钟随机从已知节点列表中选出5个,对这5个节点最长没有发送过PING消息的节点发送PING消息,检测被选中阶段是否在线;除此之外,如果节点A最后一次收到节点B发送的PONG消息的时间,距离当前时间已经超过了节点A的cluster-node-timeout
选项设置时长的一半,那么节点A也会向节点B发送PING消息。PONG
消息:收到PING消息后返回PONG消息;另外,一个节点也可以通过广播自己的PONG消息来让集群中的其他节点立即刷新关于该节点的认知FAIL
消息:当一个主节点A判断另一个节点B进入FAIL状态,节点A向集群广播一条关于B的FAIL消息PUBLISH
消息:当节点收到一个PUBLISH命令,节点会执行这个命令,并向集群广播一条PUBLISH消息,所有收到这条消息的节点都执行相同的PUBLISH命令。
Redis高频问题
Redis常见性能问题和解决方案
1.Master写内存快照,save命令调度rdbSave函数,会阻塞主线程的工作,当快照比较大时对性能影响是非常大的,会间断性暂停服务,所以Master最好不要写内存快照。
2.Master AOF持久化,如果不重写AOF文件,这个持久化方式对性能的影响是最小的,但是AOF文件会不断增大,AOF文件过大会影响Master重启的恢复速度。Master最好不要做任何持久化工作,包括内存快照和AOF日志文件,特别是不要启用内存快照做持久化,如果数据比较关键,某个Slave开启AOF备份数据,策略为每秒同步一次。
3.Master调用BGREWRITEAOF重写AOF文件,AOF在重写的时候会占大量的CPU和内存资源,导致服务load过高,出现短暂服务暂停现象。
4.Redis主从复制的性能问题,为了主从复制的速度和连接的稳定性,Slave和Master最好在同一个局域网内。
海量redis数据
假如Redis里面有1亿个key,其中有10w个key是以某个固定的已知的前缀开头的,如果将它们全部找出来?
使用keys指令可以扫出指定模式的key列表。
对方接着追问:如果这个redis正在给线上的业务提供服务,那使用keys指令会有什么问题?
这个时候你要回答redis关键的一个特性:redis的单线程的。keys指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用scan指令,scan指令可以无阻塞的提取出指定模式的key列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用keys指令长。
使用Redis做异步队列
一般使用list结构作为队列,rpush生产消息,lpop消费消息。当lpop没有消息的时候,要适当sleep一会再重试。
**如果对方追问可不可以不用sleep呢?**list还有个指令叫blpop,在没有消息的时候,它会阻塞住直到消息到来。
**如果对方追问能不能生产一次消费多次呢?**使用pub/sub主题订阅者模式,可以实现1:N的消息队列。
**如果对方追问pub/sub有什么缺点?**在消费者下线的情况下,生产的消息会丢失,得使用专业的消息队列如rabbitmq等。
**如果对方追问redis如何实现延时队列?**我估计现在你很想把面试官一棒打死如果你手上有一根棒球棍的话,怎么问的这么详细。但是你很克制,然后神态自若的回答道:使用sortedset,拿时间戳作为score,消息内容作为key调用zadd来生产消息,消费者用zrangebyscore指令获取N秒之前的数据轮询进行处理。
到这里,面试官暗地里已经对你竖起了大拇指。但是他不知道的是此刻你却竖起了中指,在椅子背后。
如果有大量的key需要设置同一时间过期,一般需要注意什么?
如果大量的key过期时间设置的过于集中,到过期的那个时间点,redis可能会出现短暂的卡顿现象。一般需要在时间上加一个随机值,使得过期时间分散一些。
Redis如何做持久化的(bgsave原理)
bgsave做镜像全量持久化,aof做增量持久化。因为bgsave会耗费较长时间,不够实时,在停机的时候会导致大量丢失数据,所以需要aof来配合使用。在redis实例重启时,会使用bgsave持久化文件重新构建内存,再使用aof重放近期的操作指令来实现完整恢复重启之前的状态。
对方追问那如果突然机器掉电会怎样?取决于aof日志sync属性的配置,如果不要求性能,在每条写指令时都sync一下磁盘,就不会丢失数据。但是在高性能的要求下每次都sync是不现实的,一般都使用定时sync,比如1s1次,这个时候最多就会丢失1s的数据。
对方追问bgsave的原理是什么?你给出两个词汇就可以了,fork和cow。fork是指redis通过创建子进程来进行bgsave操作,cow指的是copy on write,子进程创建后,父子进程共享数据段,父进程继续提供读写服务,写脏的页面数据会逐渐和子进程分离开来。
数据库之常见面试题
主键索引和唯一索引的区别
- 主键是一种约束,而唯一索引是一种索引,两者在本质上不同的
- 主键创建后一定包含一个唯一性索引,唯一性索引不一定就是主键
- 主键列不允许空值,唯一性索引允许空值
- 主键列在创建时,已经默认为非空值+唯一索引了
- 一个表最多只能创建一个主键,但是可以创建多个唯一性索引
- 主键更适合那些不容易改变的唯一标识,比如自动递增列,身份证号等
SQL中各种JOIN的区别
INNER JOIN
内连接是最常见的一种连接,也被称为普通连接,之链接匹配的行。它又分为等值连接(=)和不等值连接(between…and…)。
OUTER JOIN
FULL OUTER JOIN
包含左,右两个表的全部行,不管另一边的表中是否存在与他们匹配的行
LEFT OUTER JOIN
包含左边表的全部行(不管右边是否存在与他们匹配的行),以及右边表中全部匹配的行
RIGHT OUTER JOIN
包含右边表的全部行,以及左边表中全部匹配的行
CROSS JOIN
笛卡尔乘积(所有可能的行对),交叉连接用于对两个源表进行纯关系代数的乘运算,它不使用连接条件来限制结果集合,而是将分别来自两个数据源中的行以所有可能的方式进行组合。
NATURAL JOIN
自然连接是一种特殊的等值连接,它要求两个关系中进行比较的分量必须是相同的属性组,并且在结果中把重复的属性列去掉;而等值连接不会去掉重复的属性列。
NATURAL LEFT JOIN
左自然连接,保留2个表的列(删除重复列),以左表为准,不存在匹配的右表列,值置为NULL
SELF JOIN
某个表和其自身连接,连接方式可以为内连接,外连接,交叉连接
数据库连接池
基本思想:为数据库连接建立一个“缓冲池”,预先在池中放入一定数量的数据库连接管道,需要时从池子中取出管道进行使用,操作完毕后,再讲管道放入池子中,从而避免了频繁的向数据库中申请和释放资源带来的性能损耗。
优点
- **资源重用:**由于数据库连接得到重用,避免了频繁创建、释放连接引起的大量性能开销。在减少系统消耗的基础上,增加了系统环境的平稳性(减少内存碎片以及数据库临时进程、线程的数量)
- **更快的系统响应速度:**数据库连接池在初始化过程中,往往已经创建了若干数据库连接置于池内备用。此时连接池的初始化操作均已完成。对于业务请求处理而言,直接利用现有可用连接,避免了数据库连接初始化和释放过程的时间开销,从而缩减了系统整体响应时间。
- 新的资源分配手段:对于多应用共享同一数据库的系统而言,可在应用层通过数据库连接的配置,实现数据库连接技术。
- 统一的连接管理,避免数据库连接泄露:在较为完备的数据库连接池实现中,可根据预先的连接占用超时设定,强制收回被占用的连接,从而避免了常规数据库连接操作中可能出现的资源泄露
MySQL的表空间方式
MySQL没有真正意义上的表空间管理。MySQL的InnoDB包含两种表空间文件模式,默认的共享表空间和每个表分离的独立表空间。
在my.cnf
中添加innodb_file_per_table=1
可以从共享表空间切换到独立表空间。
共享表空间方式
默认的表空间方式。相对而言所有的数据都在一个(或几个)文件中,比较利于管理,而且在操作的时候只需要open这一个(或几个)文件即可,相对来说代价很低。
缓存问题
正常的使用缓存的流程为,数据查询先进行缓存查询,如果key不存在或者key已经过期,再对数据库进行查询,并且把查询到的对象放进缓存,如果数据库查询对象为空,则不放进缓存。
缓存穿透
缓存穿透指查询一个数据库一定不存在的数据。比如发起id为’-1’的数据或者id特别大不存在的数据,这时的用户可能是攻击者,攻击会导致数据库压力过大。
解决方案
- 接口层增加校验,比如用户鉴权校验,id做基础校验,id<=0的直接拦截
- 缓存取不到的数据,将key-value对写成key-null并放入缓存中,缓存有效时间设置短一点,比如30s,这样可以防止攻击用户反复用同一个id暴力攻击。
缓存击穿
缓存击穿是指缓存中没有但是数据库中有的数据(一般指缓存时间到期),这时由于并发用户很多,同时读缓存没读到数据,又同时去数据库读取数据,引起数据库压力瞬间增大,造成过大压力。
解决方案
- 设置热点数据永不过期
- 加互斥锁,这样在第一个进行查询的时候,其他查询无法到达数据库,当第一个查询结束之后,缓存中已经有了该数据。
缓存雪崩
缓存雪崩是指缓存中数据有大批量到过期时间,而查询数据量巨大,引起数据库压力多大甚至宕机。和缓存击穿不同的是,缓存击穿指并发查一条数据,缓存雪崩是指不同数据同时都过期了,进而都去查询数据库。
解决方案
- 将缓存数据的过期时间设置随机,防止同一时间内大量数据同时过期。
- 如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中,防止因为一台缓存服务器宕机导致的缓存雪崩
- 设置热点数据永不过期
SQL注入
通过SQL语句,实现无账号登陆,甚至篡改数据库。主要原因是程序员可能在开发用户和数据库交互的系统时没有对用户输入的字符串进行过滤,转义,限制或者处理不严谨,导致用户可以通过精心构造的字符串去非法获取数据库中的数据。
解决方案
- 检查变量数据类型和格式:只要有固定格式的变量,在SQL语句执行前,应该严格按照格式去检查,确保变量是我们预想的格式,这在很大程度上可以避免SQL注入
- 过滤特殊符号:对于无法确定固定格式的变量,进行特殊符号过滤或者转义处理
- 绑定变量,使用预编译语句:将一些常用的语句中的值用占位符替代,可以视为将sql语句模板化或者参数化。优势在于:一次编译,多次运行,省去了解析优化等过程;此外预编译可以防止sql注入。
独立表空间方式
相对而言对立表空间每个表都有独立的多个数据文件,而且做到了索引和数据的分离。多个小文件之间很方便的完成跨数据库甚至跨硬件的数据拷贝和迁移。相对来说灵活性很好。
一条sql执行很慢的原因
- 当MySQL执行的任务比较多的时候,例如IO操作、脏页较多等,那么可能导致MySQL的这些后台线程执行缓慢
- 当缓冲池、重做日志缓冲不够用时,或者存储空间变小时,也会是导致MySQL执行慢的原因
- MySQL对数据的访问实惠加锁的,因此当你的SQL语句设计到一张表时,如果这条数据被别的事务加锁了,那你就就必须等到锁的释放,因此这也可能是一种因素
- 没有加索引或者mysql没有使用你的索引
- 下面我们想要查询id为999的学生信息,但是由于=运算符的左边是999了,而不是id了,因此索引不会被使用到
- select * from student where id - 1 = 1000;