前言
本文章为网络编程TCP篇的学习笔记,文章中的图片,文字部分引用小林coding,阿秀的学习笔记,知识星球如有侵权,请联系删除。
Redis简介
Redis 是一种基于内存的数据库,对数据的读写操作都是在内存中完成,因此读写速度非常快,常用于缓存,消息队列、分布式锁等场景。
Redis 提供了多种数据类型来支持不同的业务场景,比如 String(字符串)、Hash(哈希)、 List (列表)、Set(集合)、Zset(有序集合)、Bitmaps(位图)、HyperLogLog(基数统计)、GEO(地理信息)、Stream(流),并且对数据类型的操作都是原子性的,因为执行命令由单线程负责的,不存在并发竞争的问题。
除此之外,Redis 还支持事务 、持久化、Lua 脚本、多种集群方案(主从复制模式、哨兵模式、切片机群模式)、发布/订阅模式,内存淘汰机制、过期删除机制等等。
常常会使用Redis作为MySQL的缓存,因为Redis具备高性能和高并发的特性。1、Redis 具备高性能:假如用户第一次访问 MySQL 中的某些数据。这个过程会比较慢,因为是从硬盘上读取的。将该用户访问的数据缓存在 Redis 中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了,操作 Redis 缓存就是直接操作内存,所以速度相当快,如果MySQL中数据改变了,只要同步改变Redis中的数据就行,要注意双写一致性问题。2、Redis 具备高并发:单台设备的 Redis 的 QPS(Query Per Second,每秒钟处理完请求的次数) 是 MySQL 的 10 倍,Redis 单机的 QPS 能轻松破 10w,而 MySQL 单机的 QPS 很难破 1w。所以,直接访问 Redis 能够承受的请求是远远大于直接访问 MySQL 的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。
Redis中常见的数据结构有五种,分别是String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)主要存储得到值和结构的读写如下图所示:
Redis后续的版本又支持四种数据类型包括BitMap,HyperLogLog,GEO,Stream。
5种常见的数据结构的底层实现,string类底层数据结构主要是SDS(简单动态字符串),不使用C的原生字符串是因为SDS 不仅可以保存文本数据,还可以保存二进制数据;SDS 获取字符串长度的时间复杂度是 O(1);Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出。
List类型的底层数据结构是由双向链表或压缩列表实现列表元素小于512,每个元素的值小于64字节会用压缩列表存储数据结构,否则使用双向列表,但在Redis3.2版本之后,List数据类型底层数据结构只由快表实现了。
Hash类型内部实现,底层数据是由压缩列表或哈希表实现的,如果哈希类型元素小于512,所有数值小于64字节使用压缩列表,否则使用哈希表,但在Redis7.0中压缩列表已经由listpack(紧凑列表)数据结构实现。
Set类型的底层是由哈希表或是整数集合实现的,如果元素中整数的元素少于512会使用整数集合,否则使用哈希表。
Zset底层使用压缩列表和跳表来实现,如果有序集合元素个数小于128并且每个元素值小于64字节,会使用压缩列表,否则使用跳表,在Redis7.0中压缩列表已经废弃,使用listpack来实现。
一般说的Redis是单线程的是因为他的工作流程是「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的,但是Redis还会在后台启动线程BIO,在2.6版本之前会启动两个线程分别处理关闭文件和AOF刷盘任务(因为这些任务很耗时,如果都放在主线程处理Redis很容易阻塞,),在4.0之后还会新增一个后台线程用来异步释放Redis内存,这样就不会导致Redis主线程卡顿,因为如果使用del命令会在主线程处理。后台线程相当于一个消费者,生产者把耗时的任务丢到任务队列中去,消费者(BIO)不停轮询队列,拿出任务去执行。
而主线程的工作流程是在初始化中创建socket和epoll对象,绑定端口,注册连接事件,接着进入事件循环函数,先处理发送队列函数,如果有任务就先把发送缓存区的数据发送,否则等待epoll_wait发现任务;如果等到的是连接事件就是用连接事件处理函数:调用 accpet 获取已连接的 socket -> 调用 epoll_ctl 将已连接的 socket 加入到 epoll -> 注册「读事件」处理函数;如果是读事件就会调用读事件处理函数,调用 read 获取客户端发送的数据 -> 解析命令 -> 处理命令 -> 将客户端对象添加到发送队列 -> 将执行结果写到发送缓存区等待发送;如果是写事件,就调用写事件处理函数,通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会继续注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。
Redis采用单线程还能有很快的速度,是因为redis中大部分操作都在内存中完成,避免了多线程之间的竞争,采用了I/O多路复用机制处理大量客户端socket,而如果使用多核会增加系统复杂度,同时可能存在线程切换,甚至加锁解锁、死锁造成的性能损耗。后来Redis又引入了多线程是因为随着网络硬件的性能提升,Redis的性能瓶颈有时会出现在网络I/O上,所以使用Redis处理网络I/O,对于命令的执行仍然使用单线程处理。
Redis持久化:因为Redis是通过把数据存在内存中,所以为了重启之后数据不丢失,需要用持久化机制把数据存储到磁盘主要有三种持久化的方式分别为AOF日志:每执行一条写操作命令,就把该命令以追加的方式写入一个文件夹,然后 Redis 重启时,会读取该文件记录的命令,然后逐一执行命令的方式来进行数据恢复。RDB快照:将某一时刻的内存数据(实际数据,AOF文件记录的是命令操作日志,所以RDB会更快),以二进制的方式写入磁盘;混合持久化方式:Redis4.0新增的方法,集成了AOF和RBD的优点。(RBD方法频率控制不好会让丢失的数据变得比较多,AOF的缺点是数据恢复的效率太低)使用混合持久化AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。
项目介绍
以Carl哥的基于跳表的KV存储引擎来详细说下Redis中底层数据结构跳表的实际运用。项目中的关键是定义了SkipList和Node结构。项目主要的技术栈就是基于跳表结构的存储引擎的开发,支持数据的增删查改展示,数据落盘,加载数据等功能的实现。
跳表是在一个原始链表中添加了多级索引,通过每一级索引来进行二分搜索的数据结构,其架构如下:
在上述跳表中,假如查询key=10的记录,则可以从第二级索引开始快速定位:
1、遍历第二级索引,从1开始,发现7<10<13,7就是该层要找的索引,通过它跳到下一级索引
2、遍历第一级索引,从7开始,发现9<10<13,9就是该层要找的索引,通过它跳到下一级索引
3、遍历原始链表,从9开始,发现10=10,10就是该层要找的最终索引
相比于直接遍历原始链表,多级索引的存在使跳表查询效率更快,总结:跳表的优点: 可以实现高效的插入、删除和查询 ,时间复杂度为O(logn).
跳表的缺点:需要存储多级索引,增加了额外的存储空间
跳表的用途:经典的以空间换时间的数据结构,常用于非关系数据库的存储引擎中
Node结构是SkipList结构的实现基础:
template<typename K, typename V>
class Node {
// ~ Node 节点定义
public:
// 构造函数
Node() {}
Node(K k, V v, int);
// 析构函数
~Node();
// 键值对操作相关的成员函数
K get_key() const; // 取 Key
V get_value() const; // 取 value
void set_value(V); // 设定 value
// 前向指针数组,内部存前向指针,指向下一个 Node
Node<K, V> **forward;
// 节点层数
int node_level;
private:
// 数据成员
K key;
V value;
};
定义了对Key和Value的存取设定操作,和前向指针操作指向下一个Node,并且记录节点层数。这里是用类模板实现了要存储的节点数据,
之后定义了SkipList:
template <typename K, typename V>
class SkipList {
public:
// 构造和析构函数,创建 Node 节点函数
SkipList(int);
~SkipList();
Node<K, V>* create_node(K, V, int);
// 增删改查操作函数
int insert_element(K, V); // 增
int delete_element(K); // 删
int update_element(K, V, bool); // 改
bool search_element(K); // 查
void display_list(); // 打印跳表
void clear(); // 清空跳表
int size(); // 返回跳表节点数(不包含头节点)
// 数据落盘和数据加载
void dump_file();
void load_file();
// 获得随机层高函数
int get_random_level();
private:
// 数据加载相关函数, 用来区分 key 和 value
void get_key_value_from_string(const std::string& str, std::string* key, std::string* value);
bool is_valid_string(const std::string& str);
private:
int _max_level; // 跳表层数上限
int _skip_list_level; // 当前跳表的最高层
int _element_count; // 跳表中节点数
Node<K, V> *_header; // 跳表中头节点指针
// file operator
std::ofstream _file_writer;
std::ifstream _file_reader;
};
这个SkipList类中完成了创建节点函数,增删查改函数,清空跳表,数据落盘和加载数据及相关的函数,打印跳表函数,返回跳表节点数函数,获取随机层高度函数。
插入元素:如果只把多个节点(很多很多)插入到最后一层中,然后不对上层的索引进行更新的话,那么再查找插入的节点过程中,在最后一层查找元素的过程就会退化成单链表查找的情况。 解决方法:在插入的最后一层每,两个节点提取一个节点给上一层。
下面为insert_element函数
template<typename K, typename V>
int SkipList<K, V>::insert_element(const K key, const V value) {
mtx.lock(); // 写操作,加锁
Node<K, V> *current = this->_header; // 从头节点遍历
// update 是一个指针数组,数组内存放指针,指向 node 节点,其索引代表层
Node<K, V> *update[_max_level + 1]; // update 的大小 >= forward
memset(update, 0, sizeof(Node<K, V>*)*(_max_level + 1)); // 初始化
// 从最高层开始遍历
for(int i = _skip_list_level; i >= 0; i--) {
// 只要当前节点非空,且 key 小于目标, 就会向后遍历
while(current->forward[i] != NULL && current->forward[i]->get_key() < key) {
current = current->forward[i]; // 节点向后移动
}
update[i] = current; // update[i] 记录当前层最后符合要求的节点
}
// 遍历到 level 0 说明到达最底层了,forward[0]指向的就是跳表下一个邻近节点
current = current->forward[0];
// 注意此时 current->get_key() >= key !!!
// 1. 插入元素已经存在
if (current != NULL && current->get_key() == key) {
std::cout << "key: " << key << ", exists" << std::endl;
mtx.unlock();
return -1; // 插入元素已经存在,返回 -1,插入失败
}
// 2. 如果当前 current 不存在,或者 current->get_key > key
if (current == NULL || current->get_key() != key ) {
// 随机生成层的高度,也即 forward[] 大小
int random_level = get_random_level();
// 如果新添加的节点层高大于当前跳表层高,则需要更新 update 数组
// 将原本[_skip_list_level random_level]范围内的NULL改为_header
if (random_level > _skip_list_level) {
for (int i = _skip_list_level + 1; i < random_level + 1; i++) {
update[i] = _header;
}
_skip_list_level = random_level; // 最后更新跳表层高
}
// 创建节点,并进行插入操作
Node<K, V>* inserted_node = create_node(key, value, random_level);
// 该操作等价于:
// new_node->next = pre_node->next;
// pre_node->next = new_node; 只不过是逐层进行
//下面是插入的关键代码
for (int i = 0; i <= random_level; i++) {
inserted_node->forward[i] = update[i]->forward[i];
update[i]->forward[i] = inserted_node;
}
std::cout << "Successfully inserted key: " << key << ", value: " << value << std::endl;
_element_count ++; // 更新节点数
}
mtx.unlock();
return 0; // 返回 0,插入成功
}
其中的update指针数组存放的是指针,指向node,他的索引代表层。在插入的时候先从最高层开始遍历,只要当前节点非空,并且key小于目标就会向后遍历,使用update[i]记录当前层最后符合要求的节点,当遍历到forward[0](level 0)说明到达了最底层,当前位置的元素用current表示,最后判断如果要插入的元素在当前位置已经存在就返回-1,如果不存在或者当前位置的值大于当前要插入的值,就随机生成一个层的高度(层高的选择是根据幂次定律,越大的数出现的概率越小),更新update数组,再更新跳表的最高层,最后创建节点,将数据插入。
删除元素:跟插入节点一样的操作,先要记录删除节点每一层的前继节点,然后每一层做一个链表删除节点的操作
下面是delete_element函数:
template<typename K, typename V>
int SkipList<K, V>::delete_element(K key) {
// 笔者修改了其返回值,如果返回 0 删除成功,返回 -1 删除失败
// 操作同 insert_element
mtx.lock();
Node<K, V> *current = this->_header;
Node<K, V> *update[_max_level + 1];
memset(update, 0, sizeof(Node<K, V>*)*(_max_level + 1));
for (int i = _skip_list_level; i >= 0; i--) {
while (current->forward[i] !=NULL && current->forward[i]->get_key() < key) {
current = current->forward[i];
}
update[i] = current;
}
current = current->forward[0];
// 1. 非空,且 key 为目标值
if (current != NULL && current->get_key() == key) {
// 从最底层开始删除 update->forward 指向的节点,即目标节点
for (int i = 0; i <= _skip_list_level; i++) {
// 如果 update[i] 已经不指向 current 说明 i 的上层也不会指向 current
// 也说明了被删除节点层高 i - 1。直接退出循环即可
if (update[i]->forward[i] != current)
break;
// 删除操作,等价于 node->next = node->next->next
update[i]->forward[i] = current->forward[i];
}
// 因为可能删除的元素它的层数恰好是当前跳跃表的最大层数
// 所以此时需要重新确定 _skip_list_level,通过头节点判断
while (_skip_list_level > 0 && _header->forward[_skip_list_level] == 0) {
_skip_list_level --;
}
std::cout << "Successfully deleted key : "<< key << std::endl;
_element_count --;
mtx.unlock();
return 0; // 返回值 0 说明成功删除
}
// 2. 笔者添加了没有该键时的情况,打印输出提示
else {
std::cout << key << " is not exist, please check your input !\n";
mtx.unlock();
return -1; // 返回值 -1 说明没有该键值
}
}
删除操作和插入操作相似,先从最高层开始遍历,只要当前节点非空,并且key小于目标就会向后遍历,使用update[i]记录当前层最后符合要求的节点,当遍历到forward[0](level 0)说明到达了最底层。如果最后存储的current非空,并且key为目标值就从最底层开始删除update->forward指向的节点(目标节点)
下面是update_element函数:
// 笔者添加了修改值的操作
// 1. 如果当前键存在,更新值
// 2. 如果当前键不存在,通过 flag 指示是否创建该键 (默认false)
// 2.1 flag = true :创建 key value
// 2.2 flag = false : 返回键不存在
// 返回值 1 表示更新成功, 返回值 0 表示创建成功, 返回值 -1 表示更新失败且创建失败
template<typename K, typename V>
int SkipList<K, V>::update_element(const K key, const V value, bool flag = false) {
// 同 insert,delete 操作
mtx1.lock(); // 插入操作,加锁, 使用 mtx1
Node<K, V> *current = this->_header;
Node<K, V> *update[_max_level + 1];
memset(update, 0, sizeof(Node<K, V>*)*(_max_level + 1));
for(int i = _skip_list_level; i >= 0; i--) {
while(current->forward[i] != NULL && current->forward[i]->get_key() < key) {
current = current->forward[i];
}
update[i] = current;
}
current = current->forward[0];
// 1. 插入元素已经存在
if (current != NULL && current->get_key() == key) {
std::cout << "key: " << key << ", exists" << std::endl;
std::cout << "old value : " << current->get_value() << " --> "; // ~ 打印 old value
current->set_value(value); // 重新设置 value, 并打印输出。
std::cout << "new value : " << current->get_value() << std::endl;
mtx1.unlock();
return 1; // 插入元素已经存在,只是修改操作,返回 1 说明更新成功
}
// 2. 如果插入的元素不存在
// 2.1 flag = true,允许更新创建操作,则使用 insert_element 添加
if (flag) {
SkipList<K, V>::insert_element(key, value);
mtx1.unlock();
return 0; // 说明 key 不存在,但是创建了它
}
// 2.1 flag = false, 不允许更新创建操作, 打印提示信息
else {
std::cout << key << " is not exist, please check your input !\n";
mtx1.unlock();
return -1; // 表示 key 不存在,并且不被允许创建
}
}
先和增删操作一样,先遍历跳表找到不小于更改值的位置current,然后判断这个数据是否存在,如果存在就修改,如果不存在就调用insert_element函数增加元素。
查找元素:从当前最大层数开始找,如果查找的键比cur的下一个节点的键值大,cur就往后移动 找到大于等于key的第一个节点,如果那个节点等于key,就说明找到了,否则就没有该key。
下面是search_element函数:
template<typename K, typename V>
bool SkipList<K, V>::search_element(K key) {
std::cout << "search_element..." << std::endl;
Node<K, V> *current = _header;
// 从最高层开始遍历,找到最底层中最后一个满足小于key的节点
for (int i = _skip_list_level; i >= 0; i--) {
while (current->forward[i] && current->forward[i]->get_key() < key) {
current = current->forward[i];
}
}
current = current->forward[0]; // 该操作后 current->get_key >= key 或者 null
// 找到
if (current != NULL && current->get_key() == key) {
std::cout << "Found key: " << key << ", value: " << current->get_value() << std::endl;
return true;
}
// 没找到
std::cout << "Not Found Key: " << key << std::endl;
return false;
}
像上面三个函数一样遍历跳表,如果最后找到最后一个小于key的节点用current存储,如果当前的元素符合要求,就打印出来,不符合要求就是没找到。
下面是数据落盘和数据加载函数:
// 数据落盘,把数据写入文件中
template<typename K, typename V>
void SkipList<K, V>::dump_file() {
std::cout << "dump_file..." << std::endl;
_file_writer.open(STORE_FILE); // 打开文件,写操作
Node<K, V> *node = this->_header->forward[0];
// 只写入键值对,放弃层信息
while (node != NULL) {
// 文件写入(key value 以 : 为分隔符),及信息打印
_file_writer << node->get_key() << ":" << node->get_value() << "\n";
std::cout << node->get_key() << ":" << node->get_value() << ";\n";
node = node->forward[0];
}
_file_writer.flush();
_file_writer.close();
return ;
}
// 数据加载,从文件中读取数据
template<typename K, typename V>
void SkipList<K, V>::load_file() {
_file_reader.open(STORE_FILE); // 打开文件,读操作
std::cout << "load_file..." << std::endl;
std::string line;
// key 与 value 是一个指向 string 对象的指针
std::string* key = new std::string();
std::string* value = new std::string();
while (getline(_file_reader, line)) { // 一行一行写入
get_key_value_from_string(line, key, value); // 辅助函数
if (key->empty() || value->empty()) {
continue;
}
// 重新载入过程使用 insert_element()
// 所以层之间的关系(各节点的层高)可能发生变化, 所以与之前的SkipList不同
insert_element(*key, *value);
std::cout << "key:" << *key << "value:" << *value << std::endl;
}
_file_reader.close();
}