文章目录
前言
根据Redis官方数据可知,Redis的QPS(每秒请求数)可以达到每秒10万。
横轴为连接数,纵轴为QPS。
一、Redis为什么那么快?
Redis之所以那么快,有以下五点原因:
- 基于内存实现
- 使用I/O多路复用模型
- 采用单线程模型
- 高效的数据结构
- 合理的数据编码
下面将通过对以上五点原因的分析来说明 “Redis为什么那么快?”。
二、原因之一:基于内存实现
内存由CPU控制,也就是CPU内部集成的内存控制器,享受与CPU通信的最优带宽。Redis 将数据存储在内存中,读写操作不会受到磁盘的 IO 速度限制,所以Redis的读写速度会非常的快。
三、原因之二:使用I/O多路复用模型
传统阻塞I/O(BIO)
传统阻塞 IO ,在执行accept 、recv 等网络操作时,如遇到异常情况会一直处于阻塞状态。
同步非阻塞IO(NIO)
channel(管道)、buffer(缓冲区)和selector(多路复用器)为NIO的核心组件,channel就像是一个火车,用来运输buffer,而buffer中保存了所有的数据,每个channel携带着buffer注册到selector上,selector会对各个管道进行轮询,若有IO事件发生,内核就开启一个对应事件的线程去处理。
相比之下,NIO的效率明显高于BIO,而NIO是I/O多路复用模型的基础。
IO多路复用模型
多路指的是多个 socket 连接,复用指的是复用一个线程。多路复用主要有三种技术:select,poll,epoll。epoll 是最新的也是目前最好的多路复用技术。
Redis 单线程情况下,内核会一直监听 socket 上的连接请求或者数据请求,一旦有请求到达就交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。
select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的事件处理器。所以 Redis 一直在处理事件,提升了 Redis 的响应性能。
Redis 线程不会阻塞在某一个特定的客户端请求处理上。正因为此,Redis可以同时和多个客户端连接并处理请求,从而提升了并发性。
四、原因之三:采用单线程模型
Redis 的单线程指的是 Redis 的网络 IO 以及键值对指令读写是由一个线程来执行的。 对于 Redis 的持久化、集群数据同步、异步删除等都是其他线程执行。
多线程的弊端
- 上下文切换消耗资源
何为上下文切换?
一个线程被剥夺CPU的使用权而被暂停运行叫做“切出”。
一个线程被选中占用CPU开始或继续运行叫做“切入”。
在“切入”、“切出”的过程中,CPU需要保存和恢复相应的进度信息,这个进度信息叫做“上下文”。
一个工作的线程被另一个线程暂停,而这个线程占用CPU开始执行任务的过程叫做“上下文切换”。
多线程中,上下文切换相对频繁,也就意味着CPU需要不停地“切入”、“切出”以及不停地保存和恢复相应的进度信息,完成这一系列的操作是非常消耗资源的。 - 额外的性能开销
当多线程并行修改共享数据的时候,为了保证数据正确,需要加锁机制,这就会带来额外的性能开销。 - 增加代码复杂度和调试难度
单线程的优势
- 不会因为线程创建导致的性能消耗
- 避免上下文切换引起的 CPU 消耗,没有多线程切换的开销
- 避免了线程之间的竞争问题,比如添加锁、释放锁、死锁等,不需要考虑各种锁问题
- 代码更清晰,处理逻辑简单
单线程是否没有充分利用 CPU 资源呢?
因为 Redis 是基于内存的操作,CPU 不是 Redis 的瓶颈,Redis 的瓶颈最有可能是机器内存的大小或者网络带宽。
五、原因之四:高效的数据结构
为了追求速度,不同数据类型使用不同的数据结构速度才得以提升。每种数据类型都有一种或者多种数据结构来支撑,Redis数据类型和底层数据结构的关系如下图所示。
HashTable 哈希表
全局哈希表
哈希表本质上是一个数组,每一个元素叫做哈希桶,每个桶中的entry保存着键值指针(*key 和 *value)。
Redis其实就是一个全局哈希表,哈希表的时间复杂度是 O(1),通过key的哈希值和哈希函数,就可以定位对应哈希桶的位置,从而确定桶中entry,找到对应数据。
哈希冲突
当写入 Redis 的数据越来越多,哈希冲突不可避免,会出现不同的key计算出一样的哈希值的情况。
链式哈希
Redis采用链式哈希的方式解决冲突,即同一个桶里面的元素使用链表保存。
随着数据量的增加,哈希冲突越来越多,链表也随之越来越长,进而导致查找性能变差。因此,Redis使用了两个全局哈希表,通过rehash操作,增加现有的哈希桶数量,分散单桶元素数量,从而在减少哈希冲突的同时缩短链表长度,提高Redis的查询效率。
rehash的执行过程
- 给哈希表 2 分配更大的空间
- 将 hash 表 1 的数据重新映射拷贝到 hash 表 2 中
- 释放 hash 表 1 的空间
rehash触发条件
扩容时 rehash:当以下条件中的任意一个被满足时, 程序会自动开始对哈希表执行扩容 rehash 操作
- 服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 1;
- 服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 5。
收缩时rehash:当哈希表的负载因子小于 0.1 时,程序自动开始对哈希表执行收缩操作
渐进式rehash
因为 Redis 是单线程,当数据量过大时,会造成严重阻塞,所以采用的是渐进式 rehash。所谓渐进式rehash,就是每处理一个请求时,从哈希表 1 中依次将索引位置上的所有 entry 拷贝到哈希表 2 中,将rehash 分散到多次请求过程中,避免耗时阻塞。
SDS 简单动态字符串
SDS是String的底层实现结构。
Redis 中定义动态字符串的结构
struct sdshdr {
int len; // buf中已使用空间的长度
int free; // buf中剩余可用空间的长度
char buf[]; // 存储的实际内容
};
SDS的特性
- 低时间复杂度
SDS的len保存了已使用空间的长度,获取字符串长度的时间复杂度为O(1) - 空间预分配
SDS被修改后,会被分配所需要的必须空间以及额外的未使用空间。
分配规则:如果SDS被修改后,len的长度小于1M,那么SDS将被分配和len相同长度的未使用空间。
举个例子,如果 len=10,重新分配后,buf的实际长度会变为10(已使用空间)+10(额外空间)+1(空字符)=21。如果SDS被修改后,len长度大于1M,那么SDS将分配1M的未使用空间。
拼接实例:
原字符串
调用拼接函数拼接字符串"JIYI",Redis根据以上规则分配13byte的free。
继续拼接字符串"LOVE"
- 惰性空间释放
在执行完一个字符串缩短的操作后,Redis并不会马上回收free的空间,是为了预防后续拼接的操作,这样可以减少重新分配空间带来的消耗,但是再次操作之后还是没用到free的空间,Redis就会收回free的空间,防止内存的浪费。 - 二进制安全
以空字符也就是’\0’举例,C语言是根据’\0’去判断一个字符串的长度,但是有些数据中会有’\0’穿插在中间,比如图片,音频,视频,压缩文件的二进制数据。以下图为例,C语言只能识别第一个’\0’前面的部分字符"TINGQIU"。
ZipList 压缩列表
ZipList是List 、hash、sorted Set三种数据类型底层实现之一。
ZipList的特性
- ZipList是由一系列特殊编码的连续内存块组成的顺序型的数据结构,不容易产生内存碎片,内存利用率高。
- 适用情景:一个列表只有少量数据,并且每个列表项是小整数或短字符串
ZipList的结构。 - 查找第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,时间复杂度是O(1)。而查找其他元素时,只能逐个查找,此时的时间复杂度是O(N)。
ZipList在表头有三个字段:zlbytes、zltail和zllen,分别表示列表占用字节数、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。
ZipList的结构
struct ziplist<T> {
int32 zlbytes; // 整个压缩列表占用字节数
int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
int16 zllength; // 元素个数
T[] entries; // 元素内容列表,挨个挨个紧凑存储
int8 zlend; // 标志压缩列表的结束,值恒为0xFF
}
ZipList的缺点
插入和删除操作需要频繁的申请和释放内存,同时会发生内存拷贝,数据量大时内存拷贝开销较大。
LinkedList 双向链表
LinkedList是List的底层实现结构之一。
LinkedList的特性
- 双端
带有prev和next指针,定义前后节点的时间复杂度为O(1)。
- 无环
表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL 为终点。
- 表头指针和表尾指针
通过 list 结构的 head 指针和 tail 指针,获取链表的表头节点和表尾节点的时间复杂度为O(1)。 - 链表长度计数器
使用list结构的len属性来对list持有的链表节点进行计数,获取链表中节点数量的时间复杂度为O(1)。 - 多态
链表节点使用void*指针来保存节点值,并且可以通过 list 结构的dup、free、match 三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。
typedef struct list {
listNode *head; // 头结点
listNode *tail; // 尾结点
void *(*dup)(void *ptr); // 节点复制函数
void (*free)(void *ptr); // 节点释放函数
int (*match)(void *ptr, void *key); // 节点对比函数
unsigned long len; // 链表所包含的节点数量
} list;
LinkedList的缺点
除保存数据外还需要保存prev、next两个指针,内存利用率低,LinkedList的各个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片。
QuickList
在Redis 3.2版本中,新增了QuickList数据结构,用于替代ZipList和LinkedList。
官网对QuickList的解释为"A doubly linked list of ziplists",意为由ZipList组成的LinkedList,结合了ZipList和LinkedList的优点。
QuickList结构
typedef struct quicklist {
quicklistNode *head; /* 头结点 */
quicklistNode *tail; /* 尾结点 */
unsigned long count; /* 在所有的ziplist中的entry总数 */
unsigned long len; /* quicklist节点总数 */
int fill : QL_FILL_BITS; /* 16位,每个节点的最大容量 */
unsigned int compress : QL_COMP_BITS; /* 16位,quicklist的压缩深度,0表示所有节点都不压缩,否则就表示从两端开始有多少个节点不压缩 */
unsigned int bookmark_count: QL_BM_BITS; /*4位,bookmarks数组的大小,bookmarks是一个可选字段,用来quicklist重新分配内存空间时使用,不使用时不占用空间*/
quicklistBookmark bookmarks[];
} quicklist;
QuickList节点结构
typedef struct quicklistNode {
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *zl; /* quicklist节点对应的ziplist */
unsigned int sz; /* ziplist的字节数 */
unsigned int count : 16; /* ziplist的item数*/
unsigned int encoding : 2; /* 数据类型,RAW==1 or LZF==2 */
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */
unsigned int recompress : 1; /* 这个节点以前压缩过吗? */
unsigned int attempted_compress : 1; /* node can't compress; too small */
unsigned int extra : 10; /* 未使用到的10位 */
} quicklistNode;
SkipList 跳表
SkipList是Sorted Set的底层实现结构之一。
SkipList的特性
- SkipList是一种有序数据结构,它通过在每个节点中存储着多个指向其他节点的指针,从而达到快速访问节点的目的。
- SkipList支持平均O(logN)、最坏O(N)时间复杂度的节点查找,还可以通过顺序性操作来批量处理节点。
- SkipList在LinkedList的基础上,增加了多层级索引,通过索引位置的几个跳转,实现数据的快速定位。
IntSet 整数数组
IntSet是Set的底层实现结构之一。
Intset的特性
- 适用情景:一个集合只包含整数值元素,并且这个集合的元素数量不多。
- Inset的每个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。
typedef struct intset{
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
}intset;
六、原因之五:合理的数据编码
Redis使用对象(RedisObject)来表示键值对,在创建Redis键值对时,将创建两个对象,一个对象是用做键值对的键对象,另一个是键值对的值对象。
例如:创建键值对“set tingqiu jiyi"时,键值对的键对象是一个包含了字符串“tingqiu“的对象,键值对的值对象是包含字符串"jiyi"的对象。
typedef struct redisObject{
//类型
unsigned type:4;
//编码
unsigned encoding:4;
//指向底层数据结构的指针
void *ptr;
//...
}robj;
由上文可知,不同的数据类型,其底层支持可能有多种数据结构,在不同的时候选择不同的底层数据结构,就涉及到编码转化的问题。
String
底层结构为SDS,若是数字,采用int类型的编码;若是字符,采用raw编码。
List
List 对象的编码包括ZipList和LinkedList(Redis 3.2后改由QuickList实现),若字符串长度 < 64 字节且元素个数 < 512 (默认)则使用ZipList编码,否则使用LinkedList 编码。
上述选择ZipList编码的条件为Redis默认值,具体数值可在redis.conf文件中修改。
list-max-ziplist-entries 512
list-max-ziplist-value 64
Hash
Hash对象的编码包括ZipList(Redis 6.0后被ListPack取代)和HashTable,与List类似,若键/值字符串长度 < 64 字节且元素个数 < 512 (默认)则使用 ZipList编码,否则使用HashTable编码。
Soeted Set
Soeted Set对象的编码包括ZipList(Redis 6.0后被listpack取代)和SkipList,与List类似,若字符串长度 < 64 字节且元素个数 < 512 (默认)则使用ZipList编码,否则使用HashTable编码。
Set
Set对象的编码包括IntSet和HashTable,若元素为整数且元素个数小于一定范围使用IntSet编码,否则使用 hashtable 编码。