Redis-数据结构

数据结构

Redis底层数据结构

Redis是一种高性能的非关系型数据库(NoSQL),提供了多种数据类型以满足不同的应用需求。常见的数据类型包括StringListHashSetZset。每种数据类型在Redis内部都有特定的底层数据结构来支持其功能和性能特性。

数据类型与底层结构

Redis底层数据结构图

数据类型 底层结构 存储的值 读写能力
String 简单动态字符串(SDS) 字符串、整数或浮点数 支持原子性的读写操作,适用于计数器、缓存等场景。
List 双向链表或压缩列表(ziplist) 字符串元素的列表 支持高效的插入和删除操作,适用于消息队列、任务队列等。
Hash 哈希表或压缩列表(ziplist) 字段-值对 支持快速的查找、插入和删除操作,适用于存储对象属性。
Set 哈希表或整数集合(intset) 无序、唯一的字符串集合 支持高效的集合运算(交集、并集、差集),适用于标签系统、好友关系等。
Zset 跳表或压缩列表(ziplist) 有序、唯一的字符串集合,每个元素关联一个分数 支持按分数排序的查找、插入和删除操作,适用于排行榜、范围查询等。

而在后续的版本更新中,又增加了新的四种数据结构支持:BitMap(2.2)HyperLogLog(2.8)GEO(3.2)Stream(5.0)

redis数据结构常用的场景分别如下:

  • String:不仅存储字符串数据,还维护了字符串的长度和可用空间。适用于存储对象、简单计数器、分布式锁、session等。例如,可以使用INCR命令实现一个简单的计数器,用于统计网站的访问量。
  • List:使用双向链表来存储数据。适用于消息队列、任务队列等。例如,可以使用LPUSHRPOP命令实现一个简单的消息队列,用于处理异步任务。
  • Hash:使用哈希表来存储数据。适用于存储对象属性,如购物车。例如,可以使用HSETHGET命令存储用户的购物车信息。
  • Set:高效地进行集合运算(交集、并集、差集)。适用于标签系统、好友关系等。例如,可以使用SINTER命令计算两个用户之间的共同好友。
  • Zset:使用跳表来存储数据,这样可以高效地进行按分数排序。适用于排行榜、范围查询等。例如,可以使用ZADDZRANGE命令实现一个热度榜,用于展示热门文章。

在后续的版本更新中,Redis增加了四种新的数据结构:

  • BitMap(2.2):用于存放二值计算,如用户签到等。
  • HyperLogLog(2.8):用于海量基数统计,比如统计网站的独立访客数。
  • GEO(3.2):常用于存放地理位置信息,比如高德地图、滴滴打车。
  • Stream(5.0):常用于做详细队列,与List不同的是,Stream会自动生成全局唯一ID,支持消费组形式消费数据。

Zset实现

基础了解

Zset(有序集合)是Redis中一种非常重要的数据类型,它能够存储有序的字符串集合,并且每个元素都关联一个分数(score),通过分数可以对元素进行排序。Zset的底层实现主要依赖于两种数据结构:跳表(Skip List)压缩列表(ziplist)在Redis 7.0及之后的版本中,压缩列表已被弃用,取而代之的是listpack

在Redis7.0版本之前,Zset数据结构为:

  • 若有序集合的元素小于128,且元素的数据大小小于64字节时,Redis会采用压缩列表作为底层数据结构。
  • 若有序集合不满足以上条件,则采用跳表作为数据结构。

如何使用Zset

以某文章热度为例,以下介绍在Redis中如何使用Zset。

  1. 使用ZADD添加文章

ZADD命令用于向有序集合中添加一个或多个元素。每个元素都关联一个分数(score),用于排序。

1
ZADD article_hotness 0 "article_id_1" 0 "article_id_2" 0 "article_id_3"

在这个例子中,我们向名为article_hotness的有序集合中添加了三篇文章,初始热度为0。

  1. 使用ZINCRBY增加文章热度

ZINCRBY命令用于增加有序集合中某个元素的分数(热度)。

1
2
3
ZINCRBY article_hotness 10 "article_id_1"
ZINCRBY article_hotness 20 "article_id_2"
ZINCRBY article_hotness 30 "article_id_3"

在这个例子中,我们分别增加了三篇文章的热度,article_id_1增加了10,article_id_2增加了20,article_id_3增加了30。

  1. 使用ZRANGEWITHSCORES获取热度前三的文章

ZRANGE命令用于获取有序集合中指定范围内的元素。由于Zset默认是按升序排列的,因此我们需要获取热度最高的三篇文章时,可以使用反转范围来获取。WITHSCORES选项用于同时返回元素的分数。

1
ZREVRANGE article_hotness 0 2 WITHSCORES

在这个例子中,我们使用ZREVRANGE命令来获取热度前三的文章及其热度值。

  1. 使用ZRANGEBYSCORE找出热度大于500的文章

ZRANGEBYSCORE命令用于获取有序集合中分数在指定范围内的元素。

1
ZRANGEBYSCORE article_hotness 500 +inf WITHSCORES

在这个例子中,我们获取了热度大于500的所有文章及其热度值。

跳表实现

链表在查询数据时,需要一个个元素遍历,时间复杂度是O(N)的,查询效率贼低,所以就有了跳表。跳表是在链表的基础上改进的,实现了一种“多层”的有序链表结构,以便于快速定位数据位置。

跳表数据结构

以上图中头节点有L0~L2三个头指针,分别指向不同层级的节点,每个层级的节点都通过指针连接起来。

  • L0层有5个节点:1、2、3、4、5
  • L1层有3个节点:2、3、4
  • L2层有1个节点:3

跳表的核心思想是通过增加多级索引来加速查找过程。跳表的每一层都是一个有序链表,高层级的节点数量较少,低层级的节点数量较多。通过这种方式,跳表可以在O(log n)的时间复杂度内完成查找操作。

跳表是如何工作的呢?举个例子,假设我们在链表中需要查找33这个数据节点,一个个元素遍历到该节点需要查找6次(1->5->11->20->27->33),加了一层索引后,优化到需要4次(1->11->27-x->50转往下一层节点找27->33),以此类推。查找流程示意图如下。

跳表查找元素模拟

这个查找的过程在链表中跳跃,最后定位到元素,当数据量巨大时,查询的时间复杂度就来到了O(logn)。

那么跳表的节点如何实现多层级的?跳表的节点结构包含以下几个关键部分:

  • 元素值(ele):存储节点的值,通常是一个字符串(sds类型)。
  • 分数(score):存储节点的分数,用于排序,通常是一个双精度浮点数(double类型)。
  • 后向指针(backward):指向前一个节点,方便从跳表的尾节点开始访问,倒序查找时方便。
  • 层级数组(level[]):包含多个层级的信息,每个层级包含两个信息:
    • 前向节点指针(forward):指向当前层级的下一个节点。
    • 跨度(span):表示当前节点到下一个节点之间的跨度,用于计算节点的排名。

以下是跳表节点的C语言数据结构示例:

1
2
3
4
5
6
7
8
9
typedef struct zskiplistNode {
sds ele; // 元素值
double score; // 分数
struct zskiplistNode *backward; // 后向指针
struct zskiplistLevel {
struct zskiplistNode *forward; // 前向节点指针
unsigned int span; // 跨度
} level[]; // 层级数组
} zskiplistNode;

跟我的刚开始想象不同的是,在跳表中,跨度(Span)并不是固定的步长,而是表示当前节点到下一个节点之间的“距离”。跨度可以是1,也可以是2,不必与上一个节点同层级的跨度一致。跨度的主要作用是帮助计算节点的排名(rank),而不是用于遍历操作。遍历操作只需要通过链表的前向指针(forward)来实现。在这里继续引用小林哥的模拟图:

zset多层级示意图

Redis跳表在创建节点时,随机生成每个节点的层数,并没有严格维持相邻两层的节点数量比例为2:1的情况。具体的做法是,跳表在创建节点时,会生成一个范围是[0, 1]的随机数,并根据这个随机数来决定节点的层数。如果随机数小于0.25(选取0.25基于概率和性能考虑),则增加一层,然后生成下一个随机数,直到随机数大于0.25结束。最后,层数越高,概率越低,最高不会超过64层。

Redis 为啥使用跳表而不是B+树?

Redis选择跳表(Skip List)而不是B+树(B+ Tree)作为有序集合(Zset)的底层数据结构,主要是基于以下几个方面的考虑:内存占用、对范围查找的支持、实现难易程度。

  1. 内存占用更少
  • B+树:每个节点通常包含两个指针(至少),一个指向左子节点,一个指向右子节点。此外,B+树的叶子节点还需要存储数据,这会占用更多的内存。
  • 跳表:跳表的节点包含一个前向指针和一个跨度(span),平均每个节点包含约1.33个指针(概率期望值,p = 0.25)。由于跳表的层数是随机生成的,高层级的节点数量较少,因此总体内存占用较少。
  1. 范围查询更简单
  • B+树:B+树的范围查询需要从根节点开始遍历,找到范围的起始节点,然后沿着叶子节点链表遍历到范围的结束节点。这个过程相对复杂,尤其是在范围较大时。
  • 跳表:跳表的范围查询非常简单。只需要从最高层级开始,逐级向下查找,直到找到范围的起始节点,然后沿着链表遍历到范围的结束节点。由于跳表的层级结构,范围查询的效率非常高。
  1. 算法实现难度更简单
  • B+树:B+树的实现相对复杂,需要维护平衡性,插入和删除操作需要进行复杂的旋转和分裂操作,以保持树的平衡。
  • 跳表:跳表的实现相对简单,插入和删除操作只需要更新指针,不需要进行复杂的平衡操作。跳表的层数是随机生成的,不需要严格维持平衡性,因此实现难度较低。

压缩列表实现

压缩列表(ziplist)是Redis为优化内存使用而设计的一种紧凑的顺序存储结构,类似于数组。它通过一系列精心设计的字段来高效地存储和访问数据,特别适用于存储少量数据。

主要字段

压缩列表包含以下几个关键字段:

  • zlbytes:记录当前压缩列表总共占用的字节数。这个字段帮助快速计算列表的总大小,无需遍历整个列表。
  • zltail:记录最后一个节点相对于头节点的偏移量。通过这个字段,可以快速定位到最后一个节点,而不需要从头遍历整个列表。
  • zllen:记录当前压缩列表中节点的数量。这个字段帮助快速获取列表的长度。
  • zlend:表示压缩列表的结束位置,通常值为0xff(即十进制的255)。这个字段用于标识列表的结束,类似于C语言中的空字符(’\0’)。

查找元素

在压缩列表中,查找第一个和最后一个元素非常高效,时间复杂度为O(1)。具体来说:

  • 查找第一个元素:直接从列表头开始,时间复杂度为O(1)。
  • 查找最后一个元素:通过zltail字段可以直接计算出最后一个节点的位置,时间复杂度也为O(1)。

然而,查找其他元素则需要遍历列表,时间复杂度为O(n),其中n是列表中节点的数量。因此,压缩列表并不适合存储大量数据,更适合用于存储少量、频繁访问的数据。

节点结构

压缩列表中的每个节点包含以下三个部分:

  • prevlen:记录前一个节点的长度。这个字段使得从后向前遍历列表成为可能,因为可以通过prevlen快速定位到前一个节点。
  • encoding:记录当前节点实际数据的类型和长度。Redis支持两种主要的数据类型:字符串和整数。encoding字段根据数据类型和大小动态调整,以节省内存。
  • data:记录当前节点的实际数据。根据encoding字段的不同,data部分可以是字符串或整数。

插入数据

当向压缩列表插入数据时,Redis会根据插入数据的类型和大小动态调整prevlenencoding字段的空间占用。例如:

  • 如果插入的是一个较小的整数,encoding字段可能会使用较少的字节来表示这个整数。
  • 如果插入的是一个较长的字符串,encoding字段可能会使用更多的字节来表示字符串的长度。

这种根据数据类型和大小进行动态空间分配的设计思想,正是Redis为了节省内存而采用的核心策略。

连锁更新问题

压缩列表的缺点是会发生“连锁更新”问题,因为连锁更新一旦发生,就会导致压缩列表多次重新分配分配,从而影响直接压缩列表的访问性能。

为什么会发生连锁更新问题?因为当插入或删除节点时,如果前一个节点的长度发生变化,可能会导致后续节点的prevlen字段需要更新,从而引发连锁更新。

  • 在压缩列表中,prevlen字段的长度是动态的,根据前一个节点的长度来决定。例如,如果前一个节点的长度小于254字节,prevlen字段占用1字节;如果大于等于254字节,prevlen字段占用5字节。

Listpack实现

尽管quicklist通过控制quicklistnode中元素的大小和数量来缓解压缩列表(ziplist)的性能问题,但它并未完全解决压缩列表中存在的“连锁更新”问题。为了进一步优化,Redis在5.0版本中引入了一个新的数据结构——listpack

Listpack的特点

listpack最大的特点是每个节点不再包含前一个节点的长度信息。这一设计从根本上消除了“连锁更新”问题,从而提高了数据结构的稳定性和性能。

Listpack的头部信息

listpack的头部包含两个关键数据:

  • listpack总字节数:记录整个listpack结构占用的总字节数。这个字段帮助快速计算listpack的大小,无需遍历整个结构。
  • listpack元素数量:记录listpack中包含的元素数量。这个字段帮助快速获取listpack的长度。

listpack结构示意

Listpack节点的结构

listpack中的每个节点包含以下三个部分:

  • encoding:定义该元素的编码类型。encoding字段根据数据类型和大小动态调整,以节省内存。例如,如果数据是一个小整数,encoding字段可能会使用较少的字节来表示这个整数。
  • data:实际存放的数据。根据encoding字段的不同,data部分可以是字符串或整数。
  • lenencodingdata的总长度。这个字段记录了当前节点的总字节数,帮助快速计算节点的长度。

假设我们有一个listpack,其中存储了三个节点:

  1. 第一个节点存储了一个整数123
  2. 第二个节点存储了一个字符串"hello"
  3. 第三个节点存储了一个整数456

在这种情况下,listpack的头部会记录整个listpack的总字节数和元素数量。每个节点的encoding字段会根据数据类型(整数或字符串)和数据大小动态调整,而len字段会记录当前节点的总字节数。

例如:

  • 第一个节点的encoding可能是0x01(表示一个小整数),data123len可能是2字节。
  • 第二个节点的encoding可能是0x02(表示一个字符串),data"hello"len可能是7字节。
  • 第三个节点的encoding可能是0x01(表示一个小整数),data456len可能是2字节。

通过这种设计,listpack在存储数据时更加紧凑和高效,同时避免了压缩列表中“连锁更新”的问题(因为已经不会影响其他节点长度字段的变化),从而提高了整体性能和稳定性。

Hash表扩容

在Redis中,Hash表的扩容(rehash)是一个关键操作,用于处理Hash表在数据量增加时可能出现的性能问题。然而,传统的rehash过程可能会导致性能瓶颈,特别是在Hash表中数据量非常大的情况下。为了解决这个问题,Redis采用了渐进式rehash机制。

传统rehash的问题

在传统的rehash过程中,需要使用两个Hash表:

  1. Hash表1:当前正在使用的Hash表。
  2. Hash表2:新建的Hash表,空间大小通常是Hash表1的两倍。

传统rehash的步骤如下:

  1. 分配空间:为Hash表2分配空间。
  2. 数据迁移:将Hash表1中的所有数据重新分配到Hash表2中。
  3. 释放空间:数据迁移完成后,释放Hash表1的空间,并将Hash表2设置为当前使用的Hash表。

这种一次性完成数据迁移的方式存在以下问题:

  • 性能影响:如果Hash表1中的数据量非常大,数据迁移过程需要大量复制操作,可能导致Redis阻塞,影响性能。
  • 阻塞风险:在数据迁移期间,Redis无法处理其他请求,可能导致服务中断。
Redis中Hash扩容(图片来源小林coding)

渐进式rehash

为了避免上述问题,Redis采用了渐进式rehash机制。渐进式rehash的核心思想是将数据迁移过程分散到多次操作中,而不是一次性完成。

渐进式rehash的步骤

  1. 分配空间:为Hash表2分配空间。
  2. 逐步迁移:在rehash期间,每次对Hash表进行增删改查操作时,Redis除了执行这些操作,还会顺序地将索引位置上的所有key-value迁移到Hash表2中。
  3. 完成迁移:随着处理客户端发起的Hash表操作越来越多,最终某个时间点会将Hash表1中的所有key-value迁移到Hash表2中,完成rehash操作。

渐进式rehash的优势

  • 避免阻塞:通过将数据迁移分散到多次操作中,避免了在rehash过程中Redis的阻塞,提高了系统的可用性。
  • 平滑过渡:渐进式rehash使得Hash表的扩容过程更加平滑,减少了性能波动。

渐进式rehash期间的Hash表操作

在渐进式rehash进行期间,Redis会同时维护两个Hash表(Hash表1和Hash表2)。因此,Hash表的删除、查找、更新等操作会在两个Hash表中进行:

  • 查找操作:先在Hash表1中查找,如果没找到,再在Hash表2中查找。
  • 新增操作:新增的key-value会被保存到Hash表2中,而Hash表1不再进行任何添加操作。
  • 删除和更新操作:同样会在两个Hash表中进行查找和操作。

随着渐进式rehash的进行,Hash表1的key-value数量会逐渐减少,最终变为空表。当所有数据都迁移到Hash表2后,Hash表1会被释放,Hash表2成为当前使用的Hash表。

假设我们有一个Hash表1,其中包含100万个key-value对。当Hash表1需要扩容时,Redis会执行以下步骤:

  1. 分配空间:为Hash表2分配空间,大小为Hash表1的两倍。
  2. 逐步迁移:在每次对Hash表进行操作时,Redis会逐步将Hash表1中的数据迁移到Hash表2中。例如,每次操作时迁移1000个key-value对。
  3. 完成迁移:随着操作的进行,最终所有数据都会迁移到Hash表2中。

通过这种方式,Redis避免了在rehash过程中可能出现的性能瓶颈和阻塞问题,确保了系统的稳定性和性能。

String存储

在Redis中,String类型的数据是通过SDS(Simple Dynamic String)数据结构来存储的。SDS是Redis自己实现的一种字符串表示方式,相较于C语言中的字符数组(char[]),SDS提供了更多的功能和优势。

SDS的结构

SDS的结构中包含以下几个成员变量:

  • len:记录了字符串的实际长度。这个字段使得获取字符串长度的时间复杂度为O(1),因为可以直接从len属性读取,而不需要像C语言中的strlen函数那样遍历整个字符数组。
  • alloc:分配给字符串的空间数组长度。这个字段记录了当前SDS结构中为字符数组分配的总空间大小,用于管理内存分配和释放。
  • flags:用来表示不同类型的SDS。SDS有多种类型,flags字段用于区分这些类型,以便在不同情况下进行不同的处理。
  • **buf[]**:字符数组,用来保存实际的字符串数据。这个字段存储了字符串的内容。
SDS数据结构示意图

SDS的优势

相较于C语言的字符数组,SDS具备以下优势:

  1. O(1)的获取长度时间复杂度

    • 在C语言中,获取字符串长度需要调用strlen函数,该函数需要遍历整个字符数组,时间复杂度为O(n)。
    • 在SDS中,字符串的长度直接存储在len字段中,获取长度的时间复杂度为O(1),极大地提高了性能。
  2. 二进制安全

    • C语言的字符串以空字符(’\0’)作为字符串的结束标志,这使得C语言字符串不能存储包含空字符的二进制数据。
    • SDS使用len字段来表示字符串的长度,因此不需要以空字符作为结束标志,可以安全地存储任意二进制数据。
  3. 避免缓冲区溢出

    • 在C语言中,如果字符串操作不当,可能会导致缓冲区溢出,即写入的数据超出了分配的内存空间,导致程序崩溃或安全漏洞。
    • SDS通过alloc字段记录分配的内存空间大小,并在字符串操作时检查是否超出分配的空间,从而避免了缓冲区溢出的问题。

示例

假设我们有一个字符串"hello",在C语言中,它通常表示为:

1
char str[] = "hello";

在SDS中,这个字符串的表示方式如下:

1
2
3
4
5
6
struct sdshdr {
unsigned int len; // 字符串长度,值为5
unsigned int alloc; // 分配的空间大小,假设为10
unsigned char flags; // SDS类型标志,假设为0
char buf[]; // 字符数组,存储"hello"
};

在这个例子中:

  • len字段的值为5,表示字符串"hello"的长度。
  • alloc字段的值为10,表示为字符数组分配了10个字节的内存空间。
  • flags字段的值为0,表示这是一个基本的SDS类型。
  • buf[]字段存储了字符串"hello"的实际内容。

通过这种方式,SDS不仅提供了高效的性能,还解决了C语言字符串的一些常见问题,如缓冲区溢出和二进制不安全等。

线程模型

Redis为什么快?

Redis基准测试

Redis以其极高的性能而闻名,官方基准测试结果显示,单线程的Redis吞吐量可以达到每秒10万次操作(100,000 ops/s)。Redis之所以能够实现如此高的性能,主要有以下几个原因:

  1. 内存操作

Redis的大部分操作都是在内存中完成的。内存的访问速度远远快于磁盘I/O,因此数据操作的性能瓶颈通常不在于CPU,而在于数据的I/O操作。Redis基于内存操作,将性能瓶颈从磁盘I/O转移到了内存和网络传输上。此外,Redis还采用了高性能的数据结构,如哈希表、跳表等,进一步提升了操作效率。

  1. 单线程模型

Redis采用单线程模型来处理网络I/O和请求数据。单线程模型有以下几个优势:

  • 避免线程竞争:多线程程序中,线程间的竞争和同步开销较大。单线程模型避免了线程间的竞争问题,减少了线程切换的开销。
  • 简化设计:单线程模型简化了程序设计,避免了复杂的线程同步和锁机制,降低了出错的概率。
  1. I/O多路复用机制

Redis采用I/O多路复用机制来处理大量的客户端socket请求。I/O多路复用机制允许一个线程同时处理多个I/O流,具体来说:

  • 内核监听:在Redis单线程的情况下,I/O多路复用机制允许内核同时监听多个socket上的连接请求和数据请求。
  • 请求处理:一旦有请求到达,内核会将请求交给Redis线程处理,从而实现了一个Redis线程处理多个I/O流的效果。
  1. 高效的数据结构

Redis内部使用了多种高效的数据结构,如哈希表、跳表、压缩列表等。这些数据结构在内存中操作时,能够提供高效的插入、删除和查找操作,进一步提升了Redis的性能。

Redis对多线程的使用

Redis的单线程模型指的是“接收客户端请求 -> 解析客户端请求 -> 进行数据操作 -> 返回数据给客户端”这个过程由一个主线程完成。

虽然Redis在处理客户端请求时采用单线程模型,但这并不意味着Redis程序本身是单线程的。实际上,Redis在启动时会启动多个后台线程(BIO,Background I/O)来处理一些耗时的任务,从而避免这些任务占用主线程的资源,影响Redis的性能。

  • 在Redis2.6,后台会启动线程分别完成关闭文件和AOF刷盘任务;
  • 在Redis4.0版本后,新增了一个线程用于异步释放内存操作,也就是lazyfree。例如,执行unlink key / flushdb async / flushall async等命令,异步释放内存,这样的好处是不占用主线程。

将这些“关闭文件”、“AOF算盘”、“释放空间”任务交由创建的独立线程完成,能够极大地提升Redis的处理效率,因为这些操作都是极为耗时的,若由主线程完成可能会造成阻塞,同时也没法处理其他socket请求了,严重影响了Redis的性能。

Redis BIO(图片来源小林coding)

虽然Redis的主要工作(网络I/O和执行命令)一直是单线程模型,但在Redis 6.0版本也引入了多个线程来处理网络I/O请求。这是因为随着网络硬件性能的提升,Redis的性能瓶颈有时候会出现在处理网络I/O上。

多线程网络I/O的优势

  • 提升网络I/O并行度:通过引入多线程处理网络I/O请求,Redis能够更高效地处理大量的并发连接和数据传输,从而提升整体的性能。
  • 不影响命令执行:尽管引入了多线程处理网络I/O,但对于执行命令,Redis仍然采用单线程模型。这种设计确保了命令执行的顺序性和一致性,避免了多线程带来的复杂性和潜在的竞争问题。

Redis如何实现IO多路复用

Redis采用单线程模型来执行命令,这意味着所有的任务都按照顺序执行。然而,由于输入输出(I/O)操作是阻塞的,如果一个文件的I/O操作阻塞,整个进程将无法为其他客户端提供服务。为了解决这个问题,Redis采用了I/O多路复用技术。

I/O多路复用的概念

I/O多路复用(I/O Multiplexing)是一种允许单个线程同时监视多个文件描述符(如socket)的技术。通过这种技术,单个线程可以检查多个socket的I/O就绪状态,从而在单个线程中处理多个I/O流。

Redis中的I/O多路复用

在Redis中,I/O多路复用主要用于处理多个客户端连接的网络I/O操作。Redis使用了一种称为epoll的机制来实现I/O多路复用。epoll是Linux系统中的一种高效的I/O多路复用技术,能够显著提高Redis在高并发环境下的性能。其模型参考一张网络来源图:

Redis I/O多路复用
  1. 监听多个socket:Redis主线程会监听多个客户端连接的socket。每个socket代表一个客户端连接。
  2. 事件通知:当某个socket上有数据到达(如客户端发送请求),epoll机制会通知Redis主线程。
  3. 处理事件:Redis主线程接收到事件通知后,会处理该socket上的请求,执行相应的命令,并将结果返回给客户端。epoll机制使用非阻塞I/O操作。当某个文件描述符上的I/O操作无法立即完成时,I/O多路复用机制会立即返回,并继续监听其他文件描述符上的事件。
  4. 继续监听:处理完一个事件后,Redis主线程继续监听其他socket,等待下一个事件的到来。

假设我们有一个Redis服务器,它需要处理多个客户端的请求。在传统的阻塞I/O模型中,每个客户端连接都需要一个独立的线程来处理,这会导致线程数量过多,资源消耗大。而在Redis的I/O多路复用模型中,所有客户端连接的socket都由一个主线程监听和处理。

例如,当客户端A发送一个请求时,epoll机制会通知Redis主线程处理该请求;同时,客户端B的请求也会被epoll监听到,并交给Redis主线程处理。通过这种方式,Redis能够高效地处理大量的并发请求,而不会因为线程切换和竞争导致性能下降。

Redis通过采用I/O多路复用技术,实现了在单个线程中高效处理多个客户端连接的请求。这种设计使得Redis能够在高并发环境下提供极高的性能,同时确保系统的稳定性和响应速度。通过epoll机制,Redis能够监听多个socket,并在事件发生时及时处理,避免了传统阻塞I/O模型中的性能瓶颈。

Redis网络模型

Redis在6.0版本之前,采用的是单Reactor单线程模型。这种模型虽然简单,但在高并发环境下存在一些性能瓶颈。为了进一步提升性能,Redis在6.0版本引入了多线程处理网络I/O,但命令的执行仍然保持单线程模式

单Reactor单线程模型

在Redis 6.0之前,Redis采用的是单Reactor单线程模型。缺点:

  • 无法充分利用多核CPU:单线程模型无法充分利用多核CPU的计算能力,限制了Redis在高并发环境下的性能。
  • 业务处理延时:在进行业务处理时,无法执行其他连接的事件。如果业务处理时间较长,会造成较大的延时,影响系统的响应速度。
单Reactor

Redis 6.0的多线程网络I/O

为了解决单Reactor单线程模型的性能瓶颈,Redis在6.0版本引入了多线程处理网络I/O。具体来说:

  • 多线程处理网络I/O:Redis 6.0将网络I/O操作从主线程中分离出来,使用多个线程来处理网络I/O请求,从而提高网络I/O的并行度和处理效率。
  • 单线程执行命令:尽管网络I/O采用了多线程处理,但对于命令的执行,Redis仍然保持单线程模式。这种设计确保了命令执行的顺序性和一致性,避免了多线程带来的复杂性和潜在的竞争问题。

优点

  • 提升网络I/O并行度:通过多线程处理网络I/O,Redis能够更高效地处理大量的并发连接和数据传输,从而提升整体的性能。
  • 不影响命令执行:尽管引入了多线程处理网络I/O,但对于命令的执行,Redis仍然采用单线程模型。这种设计确保了命令执行的顺序性和一致性,避免了多线程带来的复杂性和潜在的竞争问题。
多线程Reactor

假设我们有一个Redis服务器,它需要处理大量的客户端请求。在Redis 6.0之前,所有的网络I/O和命令执行都在一个主线程中进行。如果某个客户端的请求处理时间较长,会导致主线程阻塞,无法处理其他客户端的请求。

在Redis 6.0中,网络I/O操作被分离出来,由多个线程处理。例如,当客户端A发送一个请求时,网络I/O线程会处理该请求的读取和写入操作;同时,客户端B的请求也会被其他网络I/O线程处理。这样,即使某个客户端的请求处理时间较长,也不会影响其他客户端的请求处理。

Redis在6.0版本之前采用单Reactor单线程模型,虽然简单,但在高并发环境下存在性能瓶颈。为了进一步提升性能,Redis在6.0版本引入了多线程处理网络I/O,但命令的执行仍然保持单线程模式。这种设计使得Redis能够在高并发环境下提供极高的性能,同时确保系统的稳定性和响应速度。