Redis

Redis数据结构

数据类型 简单描述 使用场景
String string(字符串)是Redis最简单也是使用最广泛的数据结构,它的内部是一个字符数组。String(字符串)是动态字符串,允许修改;它在结构上的实现类似于Java中的ArrayList(默认构造一个大小为10的初始数组),这是冗余分配内存的思想,这种思想可以减少扩容带来的性能消耗。当string(字符串)的大小达到扩容阈值时,将会对string(字符串)进行扩容,string(字符串)的扩容主要有三种情况:1.长度小于1MB,扩容后为原先的两倍; length = length * 2 2.长度大于1MB,扩容后增加1MB; length = length + 1MB 3. 字符串的长度最大值为 512MB 缓存、计数器、分布式锁等。
List Redis的列表相当于Java语言中的LinkedList,它是一个双向链表数据结构,支持前后顺序遍历。链表结构插入和删除操作快,时间复杂度O(1),查询慢,时间复杂度O(n)。Redis的list(列表)不是一个简单的LinkedList,而是quicklist ——“快速列表”,quicklist是多个ziplist(压缩列表)组成的双向列表; 链表、异步队列、微博关注人时间轴列表……
Hash hash(字典)的实现与Java中的HashMap(JDK1.7)的结构是一致的,它的数据结构也是数组+链表组成的二维结构,Redis中的hash(字典)存储的value只能是字符串值,此外扩容与Java中的HashMap也不同。Java中的HashMap在扩容的时候是一次性完成的,而Redis为了追求高性能,因而采取了渐进式rehash策略。渐进式rehash指的是并非一次性完成,它是多次完成的,因此需要保留旧的hash结构,所以Redis中的hash(字典)会存在新旧两个hash结构,在rehash结束后也就是旧hash的值全部搬迁到新hash之后,新的hash在功能上才会完全替代以前的hash。 用户信息、Hash 表……
Set Redis的set(集合)相当于Java语言里的HashSet,它内部的键值对是无序的、唯一的。它的内部实现了一个所有value为null的特殊字典。 去重功能、赞、踩、共同好友……
Bitmaps Bitmaps 称为位图,Bitmaps底层就是字符串(key-value)byte数组。我们可以使用普通的get/set直接获取和设值位图的内容,也可以通过Redis提供的位图操作getbit/setbit等将byte数组看成“位数组”来处理。Bitmaps 的“位数组”每个单元格只能存储0和1,数组的下标在Bitmaps中称为偏移量。Bitmaps设置时key不存在会自动生成一个新的字符串,如果设置的偏移量超出了现有内容的范围,就会自动将位数组进行零扩充 员工打卡……
ZSET 有序键值对集合,和哈希表不同,底层用跳表实现,可以实现键值对的有序存储 成绩榜单
HyperLogLog HyperLogLog是用来做基数统计的算法,它提供不精确的去重计数方案(这个不精确并不是非常不精确),标准误差是0.81%,对于UV这种统计来说这样的误差范围是被允许的。HyperLogLog的优点在于,输入元素的数量或者体积非常大时,基数计算的存储空间是固定的。在Redis中,每个HyperLogLog键只需要花费12KB内存,就可以计算接近2^64个不同的基数。但是HyperLogLog只能统计基数的大小(也就是数据集的大小,集合的个数),他不能存储元素的本身,不能向set集合那样存储元素本身,也就是说无法返回元素。 基数统计比如UV等

redis

9fa26a74965efbf0f56b707a03bb9b7f

字符串

int:存储数字

raw:长度大于39字节,基于SDS

embstr:长度小于39字节,基于SDS

SDS结构

SDS为字符串添加了信息header

  1. 无符号变量len:记录字符串的长度
  2. 无符号变量free:记录空闲内存的大小
  3. char型数组buf:存储字符

buf尾部会自动追加一个空字符,遵循了c语言原生字符串的规范,并且SDS的指针也不是指向起始位置,而是指向buf,使得SDS可以直接使用一部分库函数。

SDS取消了字节对齐,使得指针移动一位便可以读取到header里的信息。如果没有取消,这个移动的位数是未知的,就无法兼容C语言的库函数了,指针操作也要麻烦很多

如果一个字符串非常短,但是记录信息的头部却占用了更多的空间,这未免有一些浪费,所以SDS会分为五种类型

  1. 短字符串:小于32,用一个char类型的flag变量来记录长度,低三位存储类型,高三位存储长度
  2. 短字符串:用一字节的char来记录长度,一字节的flag来记录类型
  3. 长字符串:用2字节的short来记录长度,1字节的flag来记录类型
  4. 长字符串:用4字节的int来记录长度
  5. 超长字符串:用8字节的long来记录字符串

SDS的最大长度:在3.X版本中,因为数据结构中的len属性是由int来修饰的,所以buf的最大长度就是214783647,即512MB

但是在6.x版本后,长度就更多样了

SDS相比原生string的优势:

  1. O(1)时间复杂度获取字符串的长度:因为C语言原生基本数据类型不记录自身长度,获取一个字符串的长度必须遍历,直到遇到空字符为止,时间复杂度O(n),而使用SDS则直接在header里获取len属性即可,时间复杂度为O(1)
  2. 二进制安全:在C语言中,用空字符表示字符串的结束,若字符串本身就包含空字符,那么遇到便会截断,即非二进制安全。与其相对的便是二进制安全,SDS使用len属性来判断字符串是否结束,不会受到空字符影响。
  3. 杜绝缓冲区溢出:在C语言中,在对字符串进行拼接操作时,若没有给字符串分配足够的内存,那么就可能产生缓冲区溢出,把其他数据覆盖掉。而SDS的自动扩容机制杜绝了溢出,SDS在拼接时会判断是否有足够的空间留给新字符串,若空闲空间大于待拼接字符串的长度,则无需扩容;若拼接后的长度小于1M,则直接扩容至新长度的两倍;若拼接后的长度大于1M,则扩容至新长度+1M;扩容后检查类型,若发生变化,则需要为SDS重新分配内存(header的大小也改变了)
  4. 优化的内存分配策略:冗余分配,从而减少修改字符串时带来的内存重分配次数;惰性空间释放机制:当缩短字符串时,不会立刻回收空余的空间,而是仅仅更新len属性,空余空间供将来使用,减少内存分配频率。

List列表

Redis 的列表相当于 Java 语言里面的 LinkedList,可以实现队列和栈

ziplist

节点的数据小于64字节,数据个数小于512个,一般的数组都要求每一格的元素大小相同,但是若要存储不同大小的字符串,就需要以最大长度来作为元素大小,会造成一定程度的浪费。而压缩列表就是将元素紧凑,但是会在每个元素的头部追加一个len属性,这样就能很容易计算出下一个元素的内存地址。

双向链表

linkedlist很相似,可以处理数据量较大的情况,每个节点包含value和前驱,后继节点的指针

quicklist

在redis3.2版本之前,使用ziplist和linkedlist作为列表的底层实现,3.2之后就使用quicklist

quicklist其实现也是依赖于ziplist和linkedlist来实现的,它是两个结构的结合。

它将ziplist来进行分段存储,也就是分成一个个的quicklistNode节点来进行存储。每个quicklistNode指向一个ziplist,然后quicklistNode之间是通过双向指针来进行连接的。

redis1

传统链表的缺点:

  1. 每个节点都有自己的前后指针,指针会占用内存,当节点内数据较少时,附加空间成本就太高了
  2. 每个节点单独的进行内存分配,当节点过多,造成的内存碎片太多了。影响内存管理的效率。

因此,定义了 quicklist, 将 linkedlist 和 ziplist 结合起来,形成一个,将多个 ziplist 通过前后指针互相连接起来的结构,可以在一定程度上缓解上面说到的两个问题。为了进一步节约内存,Reids 还可以对 ziplist 进行压缩存储,应用 LZF 算法压缩,即quicklistLZF结构

Hash

节点的数据小于64字节,数据个数小于512个时由ziplist实现

其他情况由哈希表实现,和JDK1.7里的hashmap类似,都是无序键值对集合,底层是数组+链表

hash也有两种实现方式,当数据量小的时候,使用压缩列表,当数据量大的时候,使用散列表。

但是hash只能存储字符串,并且redis为了保证高性能,采用渐进式的rehash方法,即在不断输入的任务以及hash操作中一步步将旧结构里的内容迁移到新结构中

哈希表的扩容

负载因子:factor = used/size

size:数组的长度used:表示有多少个键值对实体

扩容规则

  1. 没有执行bgsavebgrewriteaof指令的情况下,即未 进行子进程RDB快照和AOF重写时,负载因子大于等于1时进行扩容。
  2. 正在执行bgsavebgrewriteaof指令的情况下,哈希表的负载因大于等于5时进行扩容;

扩容与JAVA哈希表相同,都是扩容为2次幂大小

缩容规则

负载因子小于0.1时,会进行缩容操作,同样保持2次幂大小

渐进式rehash

Java中HashMap的rehash过程就是一次性将当前所有节点进行rehash然后复制到新哈希表相应的位置上,之后释放掉原有的hash表,持有新表的引用,这个过程是一个时间复杂度为O(n)的操作。
对于单线程的Redis而言,一次性承受O(n)时间复杂度的rehash操作所带来的阻塞的感知是很明显的,因而其使用的是一种称为渐进式rehash的方式

其过程如下:

  1. 假设当前数据在表1中,那么首先为表2分配足够的空间,空间大小即按照扩容和缩容规则设定的大小
  2. 在表中维护一个变量,rehashidx=0表示rehash正式开始
  3. rehash进行期间,外界调用Redis执行增删改查操作时,程序除了执行指定的操作以外,还会顺带将旧表中的元素进行转移,每次仅处理少量的转移任务(100个元素)
  4. 当一次rehash工作完成之后,程序将rehashidx属性的值+1
  5. 随着rehash的不断执行,最终在某个时间点上,所有元素迁移完成,这时程序将rehashidx属性的值设为-1表示rehash操作已完成

这个过程会存在两个明显问题

  1. 每次对字典执行增删改查时才会触发rehash过程,万一不进行操作了,是不是就不会进行数据迁移了呢?
  2. 在维护两个表的时候,此时如何正常对外提供服务?

第一个问题,Redis有一个定时器,会定时去判断rehash是否完成,如果没有完成,即使没有命令执行,也会主动进行rehash

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 服务器定时任务
void databaseCron() {
...
if (server.activerehashing) {
for (j = 0; j < dbs_per_call; j++) {
// rehash方法
int work_done = incrementallyRehash(rehash_db);
if (work_done) {
/* If the function did some work, stop here, we'll do
* more at the next cron loop. */
break;
} else {
/* If this db didn't need rehash, we'll try the next one. */
rehash_db++;
rehash_db %= server.dbnum;
}
}
}
}

第二个问题,对于添加操作,会将新的数据直接添加到新表上面。

对于删除、更改、查询操作,会直接在旧表上进行,当在旧表上查询不到时,会接着去新表上查找,如果再找不到,则表明不存在该键值对。

渐进式rehash存在的问题:需要长时间维护两个hash表,对内存会造成较大的压力,特别是当内存紧张时

SET

无序集合,存储一组不重复的数据,类似于HashSet,元素无序且唯一

同样两种实现方法:有序数组inset(只用于处理整数)和散列表。前者是处理较少的数据,后者是处理大量数据。

ZSET

有序键值对集合

节点的数据小于64字节,数据个数小于128个时由ziplist实现

其他情况由跳表来实现

ZSET是一个有序集合,它一方面通过set来保证内部value值的唯一性,另一方面通过value的score(权重)来进行排序。

这个排序的功能是通过Skip List来实现的

应用场景:

  1. 存储粉丝列表,value是粉丝的ID,score是关注时间戳,这样可以对粉丝关注进行排序
  2. 存储学生成绩,value使学生的ID,score是学生的成绩,这样可以对学生的成绩排名

跳表skipList

跳表是可以实现二分查找的有序链表,常用于redis的有序集合数据结构

拥有与红黑树相近的查找,删除,插入效率,并且范围查找效率更优越

原理在于为有序链表建立多级索引,从而实现跳跃查找,

最底层包含所有元素,第一级索引包含1/2的元素,以此类推。

每个索引包含了一个指针数组,指向了该索引可以到达的所有节点,数组下标即当前指针所在的层数。

3def9bbb70168ee0d6645fc450f2e030

以生成随机数的方式,来为每一个插入的元素设定索引级数,从而无需重建整个索引,降低了时间复杂度

索引晋升计算模式:random()是个随机数,产生越高的节点层数,概率越低

1
2
3
4
5
6
7
8
9
public int randomLevel(){
int level = 1;
// random()返回一个[0...1)的随机数
while (random() < p && level < MaxLevel){
level += 1;
}
return level;
}
//在redis中p为0.25,MaxLevel为64

跳表自身拥有的优势:

  1. 数据天然有序
  2. 插入查询过程类似于二分查找,所以时间复杂度为O(logn)
  3. 与红黑树相比:实现简单,无需变色左旋右旋等操作

Redis对象模型

Redis其实并没有直接使用这些基本数据类型,了加快读写速度,在此基础上又包装了一层称之为RedisObject

redis中每一个value都可以理解为是一个RedisObject

1
2
3
4
5
6
7
typedef struct redisObject{
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS;
int refcount;
void *ptr;
}robj;

结构内有五个属性

type

type主要存储当前value对象的数据类型:字符串,列表,哈希,集合,有序集合,就是上文写的基本数据类型。

对于 Redis 数据库保存的键值对来说,键一定是一个字符串对象,而值则可以是上述五种的其中一种

encoding

简单说encoding存储当前值对象底层编码的实现方式。不同type对象对应不同的编码

比如对于type为ZSET的对象,encoding可以为INTSET(整数集合),也可以为SKIPLIST(跳表)

简而言之就是说明底层数据类型的具体实现

1116398-20190711211545977-842176016

lru

在使用LRU和LFU时会有差别,但是大小固定为24bit,用于内存淘汰策略

refcount

用于共享计数,类似于JVM GC的引用计数垃圾回收算法,当refcount为0时,表示没有其它对象引用,可以进行释放此对象

**ptr **

ptr指针是指向对象的底层实现数据结构

1116398-20190711211630034-1363544681

持久化

RDB快照

快照,记录某一个瞬间的内存数据,记录的是实际数据

save

执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程

bgsave

Redis会单独创建fork一个子进程来进行持久化,这样可以避免主线程的阻塞

fork 的作用是复制一个与当前进程一样的进程。新进程的所有数据(变量、环境变量、程序计数器等)数值都和原进程一致,但是是一个全新的进程,并作为原进程的子进程

执行 bgsave 过程中,Redis 依然可以继续处理操作命令的,也就是数据是能被修改的,关键的技术就在于写时复制技术(Copy-On-Write, COW)。

在主线程没有对数据进行写入时,子进程和父进程通过指针共享一个物理页面,也就是可以共享读。对数据进行写入修改时,OS会为页面创建副本,子进程将副本数据写入RDB,这个过程不影响主进程对数据的修改。子进程完成对新RDB文件的写入时,便会拿其替换原来的RDB文件。

RDB是对整个内存的数据进行快照,所以只有一个文件,这种方法适合大规模的数据恢复,而且很方便。

但是RDB也有一些缺点:最后一次快照无法及时写入内存,可能会发生丢失,RDB属于全量快照,这是一个比较重量级的操作,当数据集较大时,可能会影响整个服务器的性能。

对过期键的处理

RDB 文件生成阶段:从内存状态持久化成 RDB(文件)的时候,会对 key 进行过期检查,过期的键「不会」被保存到新的 RDB 文件中,因此 Redis 中的过期键不会对生成新 RDB 文件产生任何影响。

RDB 加载阶段:RDB 加载阶段时,要看服务器是主服务器还是从服务器,分别对应以下两种情况:

  • 如果 Redis 是「主服务器」运行模式的话,在载入 RDB 文件时,程序会对文件中保存的键进行检查,过期键「不会」被载入到数据库中。所以过期键不会对载入 RDB 文件的主服务器造成影响;
  • 如果 Redis 是「从服务器」运行模式的话,在载入 RDB 文件时,不论键是否过期都会被载入到数据库中。但由于主从服务器在进行数据同步时,从服务器的数据会被清空。所以一般来说,过期键对载入 RDB 文件的从服务器也不会造成影响。

AOF(Append Only File)

以日志的形式来记录写操作,只记录写指令,恢复时只需从前往后执行一遍即可完成数据恢复的工作。

Reids 是先执行写操作命令后,才将该命令记录到 AOF 日志里的

这么做的好处:

  1. 避免额外的检查开销:让操作系统先执行命令,只有命令执行成功才能被记录,这种方式排除了错误的指令
  2. 不会阻塞当前写操作命令的执行:因为当写操作命令执行成功后,才会将命令记录到 AOF 日志

这么做的风险:

  1. 数据可能会丢失: 执行写操作命令和记录日志是两个过程,那当 Redis 在还没来得及将命令写入到硬盘时,服务器发生宕机了,这个数据就会有丢失的风险。
  2. 可能阻塞其他操作: 由于写操作命令执行成功后才记录到 AOF 日志,不会阻塞当前命令的执行, 但是会阻塞后续的操作。

Redis 提供了 3 种将日志写回硬盘的策略

98987d9417b2bab43087f45fc959d32a

AOF 重写机制

当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。

AOF 重写机制是在重写时,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到「新的 AOF 文件」,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。在使用重写机制后,就会读取 name 最新的 value(键值对)重写工作完成后,就会将新的 AOF 文件覆盖现有的 AOF 文件,这就相当于压缩了 AOF 文件,使得 AOF 文件体积变小了。

Redis 的重写 AOF 过程是由子进程 bgrewriteaof来完成的

子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程;同样使用写时复制,当主线程只进行读操作时,子进程使用与主进程相同的内存空间,当发生写操作时,就会复制一份副本给子进程。双方写操作互不影响。

重写过程中,主进程依然可以正常处理命令,那么如何保证主进程操作的KV和AOF的一致性?

为了解决数据不一致问题,Redis 设置了一个 AOF 重写缓冲区,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。

在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」

也就是说,在 bgrewriteaof 子进程执行 AOF 重写期间,主进程需要执行以下三个工作:

  • 执行客户端发来的命令;
  • 将执行后的写命令追加到 「AOF 缓冲区」;
  • 将执行后的写命令追加到 「AOF 重写缓冲区」;

当子进程完成 AOF 重写工作(更新KV)后,会向主进程发送一条信号,主进程收到该信号后,会调用一个信号处理函数,该函数主要做以下工作:

  • 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致
  • 新的 AOF 的文件进行改名,覆盖现有的 AOF 文件。、

简而言之就是设立一个缓冲区来暂存AOF进程暂不能扫描到的新内容,等AOF完成后再追加

对过期键的处理

  • AOF 文件写入阶段:当 Redis 以 AOF 模式持久化时,如果数据库某个过期键还没被删除那么 AOF 文件会保留此过期键,当此过期键被删除后,Redis 会向 AOF 文件追加一条 DEL 命令来显式地删除该键值。
  • AOF 重写阶段:执行 AOF 重写时,会对 Redis 中的键值对进行检查,已过期的键不会被保存到重写后的 AOF 文件中,因此不会对 AOF 重写造成任何影响。

Redis的事务

redis是不支持回滚的:由程序员自行纠正编程错误,无回滚的方式保证了内部的简单快速

以MULTI开始一个事务,将多个命令入队,入队后不会立即执行,而是放置在等待执行的队列里,由EXEC触发事务

所有命令都会被序列化,顺序执行,执行过程中不会被其他客户端的命令打断

在提交之前所有命令都不会被执行

不保证原子性:有一条命令失败,其他的命令仍然会进行,没有回滚

Redis过期删除和内存淘汰

每当我们对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个过期字典

该数据结构保存了数据库中所有 key 的过期时间

当我们查询一个 key 时,Redis 首先检查过期字典

  1. 如果不存在该key,则正常读取键值
  2. 如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判定该key已过期

惰性删除+定期删除

惰性删除策略,不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。

惰性删除的缺点:如果一个 key 已经过期,而这个 key 又仍然保留在数据库中,那么只要这个过期 key 一直没有被访问,它所占用的内存就不会释放,造成了一定的内存空间浪费。

定期删除策略,每隔一段时间随机从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。

定期删除的缺点:难以确定删除操作执行的时长和频率。如果执行的太频繁,就会对 CPU 不友好;如果执行的太少,那又和惰性删除一样了,过期 key 占用的内存不会及时得到释放。

Redis 选择「惰性删除+定期删除」这两种策略配和使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。

内存淘汰

不进行数据淘汰的策略

noeviction(Redis3.0之后默认的内存淘汰策略):当超过最大设置内存时,不淘汰任何数据,而是不再提供服务,直接返回错误

进行数据淘汰的策略

在设置了过期时间的数据中进行淘汰:

  • volatile-random:随机淘汰设置了过期时间的任意键值;
  • volatile-ttl:优先淘汰最早过期的键值。
  • volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;
  • volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;

在所有数据范围内进行淘汰:

  • allkeys-random:随机淘汰任意键值;
  • allkeys-lru:淘汰整个键值中最久未使用的键值;
  • allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。

LRU和LFU的区别

Redis 实现的是一种近似 LRU 算法,为了更好的节约内存,实现方式是在 Redis 的对象结构体中记录此数据的最后一次访问时间。当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取多个key,然后淘汰最久没有使用的那个

在 LRU 算法中,Redis 对象头的 24 bits的字段是用来记录 key 的访问时间戳,因此Redis可以根据对象头中的字段记录的值,来比较最后一次 key 的访问时间长,从而淘汰最久未被使用的 key。

Redis的LRU实现方式诠释了redis对于高性能的执着:不用为所有的数据维护链表,节省了空间占用;不用在每次数据访问时都移动更改node的前驱指针和后继指针,提升了缓存性能。

但是LRU无法解决缓存污染问题,比如一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。

Redis 4.0之后引入了 LFU 算法来解决这个问题。

LFU即最近最不常用的,LFU 算法是根据数据访问次数来淘汰数据的,它的核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。所以,LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。这样就解决了偶尔被访问一次之后,数据留存在缓存中很长一段时间的问题。

在 LFU 算法中,Redis对象头的24 bits的字段被分成两段来存储,高 16bit 存储 ldt(Last Decrement Time),用来记录 key 的访问时间戳;低 8bit 存储 logc(Logistic Counter),用来记录 key 的访问频次。

QQ截图20221206155431

Redis网络模型

Redis为何选择单线程,其性能瓶颈究竟在哪里

Redis 官方的对于此的回答是:

对于一个 DB 来说,CPU 通常不会是瓶颈,因为大多数请求不是 CPU 密集型的,而是 I/O 密集型

具体到 Redis 的话,如果不考虑 RDB/AOF 等持久化,Redis 是完全的纯内存操作,执行速度是非常快的,因此这部分操作通常不会是性能瓶颈,Redis 真正的性能瓶颈在于网络 I/O,也就是客户端和服务端之间的网络传输延迟,因此 Redis 选择了单线程的 I/O 多路复用来实现它的核心网络模型。

  1. 避免过多的上下文切换开销:上下文的切换涉及程序计数器、堆栈指针和程序状态字等一系列的寄存器置换、程序堆栈重置,如果是进程内的多线程切换还OK,因为单一进程内多线程共享进程地址空间,线程上下文切换代价比较小。如果是跨进程调度,则需要切换掉整个进程地址空间,开销就会很大,而单线程则可以规避进程内频繁的线程切换开销,因为程序始终运行在进程中单个线程内,没有多线程切换的场景。
  2. 避免同步机制的开销:如果 Redis 选择多线程模型,又因为 Redis 是一个数据库,那么势必涉及到底层数据同步的问题,则必然会引入同步锁,而Redis 不仅仅提供了简单的 key-value 数据结构,还有 list、set 和 hash 等等数据结构,不同数据结构的加锁粒度又不尽相同,如果引入多线程,同步锁的竞争和锁释放会增加程序复杂度,还会降低性能。
  3. 简单可维护:Redis 选择单线程可以说是多方博弈之后的一种权衡:在保证足够的性能表现之下,使用单线程保持代码的简单和可维护性。

Redis 最初选择的是单线程网络模型,因为CPU通常不会成为性能瓶颈。瓶颈往往是内存网络,因此单线程足够了。然而随着互联网的飞速发展,互联网业务系统所要处理的线上流量越来越大,Redis 的单线程模式会导致系统消耗很多 CPU 时间在网络 I/O 上从而降低吞吐量,所以Redis 的网络 I/O 瓶颈就越来越明显。要提升 Redis 的性能有两个方向:优化网络 I/O 模块,提高机器内存读写的速度。前者依赖IO模型的设计优化,后者则依赖于硬件的发展。

Redis到底是不是单线程的?

其实Redis 早在4.0就已经引入了多线程。

Redis4.0

引入多线程处理异步任务

Redis 的 DEL 命令是用来删除掉一个或多个 key 储存的值,它是一个阻塞的命令,删除的 key 里的值最多几十上百个对象时,可以很快完成,阻塞不明显。但是如果你要删的是一个超大的键值对,里面有几百万个对象,那么这条命令可能会阻塞至少好几秒,又因为事件循环是单线程的,所以会阻塞后面的其他事件,导致吞吐量下降。于是,在 Redis4.0 之后增加了一些的非阻塞命令如 UNLINK

UNLINK 命令其实就是 DEL 的异步版本,它不会同步删除数据,而是把 key 从 keyspace 中暂时移除掉,然后将任务添加到一个异步队列,由后台线程去删除,不过如果用 UNLINK 去删除一个很小的 key,用异步的方式去做反而开销更大。所以它会先计算一个开销,只有当这个值大于 64 才会使用异步的方式去删除 key,对于基本的数据类型如 List、Set、Hash 这些,开销就是其中存储的对象数量。

Redis6.0

正式在网络模型中实现 I/O 多线程

见下文详解

为什么redis这么快

  1. 完全基于内存,所以IO效率高
  2. 纯C语言实现
  3. 单线程模型(引入IO多线程)
  4. 合理高效的数据结构
  5. IO多路复用处理客户端socket连接

单线程事件循环模型(v1.0到v6.0)

redisIO

从 Redis 的 v1.0 到 v6.0 版本之前,Redis 的核心网络模型一直是一个典型的单 Reactor 模型:

利用 epoll/select/kqueue等多路复用技术,在单线程的事件循环中不断去处理事件(客户端请求),最后回写响应数据到客户端

client:客户端对象,Redis 是典型的 CS 架构(Client <—> Server),客户端通过 socket 与服务端建立网络通道然后发送请求命令,服务端执行请求的命令并回复。Redis 使用结构体 client 存储客户端的所有相关信息以及进行数据的收发。因为是NIO,所以面向缓冲区,client对象内包括但不限于读入缓冲区 -- querybuf写出缓冲区 -- buf写出数据链表 -- reply

aeApiPoll:I/O 多路复用 API,是基于 epoll_wait/select/kevent 等系统调用的封装,监听等待读写事件触发,然后处理,它是事件循环(Event Loop)中的核心函数,是事件驱动得以运行的基础。

acceptTcpHandler:连接应答处理器,底层使用系统调用 accept 接受来自客户端的新连接,并为新连接注册绑定命令读取处理器,以备后续处理新的客户端 TCP 连接;

readQueryFromClient:命令读取处理器,解析并执行客户端的请求命令。

beforeSleep:事件循环中进入 aeApiPoll 等待事件到来之前会执行的函数,其中包含一些日常的任务,比如把 client->buf 或者 client->reply (后面会解释为什么这里需要两个缓冲区)中的响应写回到客户端,持久化 AOF 缓冲区的数据到磁盘等,相对应的还有一个 afterSleep 函数,在 aeApiPoll 之后执行。

sendReplyToClient:命令回复处理器,当一次事件循环之后写出缓冲区中还有数据残留,则这个处理器会被注册绑定到相应的连接上,等连接触发写就绪事件时,它会将写出缓冲区剩余的数据回写到客户端。

Redis 内部实现了一个高性能的事件库 — AE,基于 epoll/select/kqueue/evport 四种事件驱动技术,实现 Linux/MacOS/FreeBSD/Solaris 多平台的高性能事件循环模型。Redis 的核心网络模型正式构筑在 AE 之上,包括 I/O 多路复用、各类处理器的注册绑定,都是基于此才得以运行。

以下便是单线程事件循环模型的全过程:

  1. Redis 服务器启动,开启主线程事件循环(Event Loop),注册 acceptTcpHandler 连接应答处理器到用户配置的监听端口对应的文件描述符,等待新连接到来;
  2. 客户端和服务端建立网络连接;
  3. acceptTcpHandler 被调用,主线程将 readQueryFromClient 命令读取处理器绑定到新连接对应的文件描述符上,并初始化一个 client 对象绑定这个客户端连接;
  4. 客户端发送请求命令,触发读就绪事件,主线程调用 readQueryFromClient将从 socket 读取到的命令存入 client->querybuf 读入缓冲区;
  5. 接着调用 processInputBuffer,在其中使用 processInlineBuffer 或者 processMultibulkBuffer 解析命令,最后调用 processCommand 执行命令;
  6. 根据请求命令的类型(SET, GET, DEL, EXEC 等),分配相应的命令执行器去执行,最后调用 addReply 函数族将响应数据写入到对应 client 的写出缓冲区:client->buf 是首选的写出缓冲区,固定大小 16KB,一般来说可以缓冲足够多的响应数据,但是如果客户端在时间窗口内需要响应的数据非常大,那么则会自动切换到 client->reply 链表上去,使用链表理论上能够保存无限大的数据(受限于机器的物理内存),最后把 client 添加进一个 LIFO 队列 clients_pending_write
  7. 在事件循环(Event Loop)中,主线程执行 beforeSleep –> handleClientsWithPendingWrites,遍历 clients_pending_write 队列,调用 writeToClientclient 的写出缓冲区里的数据回写到客户端,如果写出缓冲区还有数据遗留,则注册 sendReplyToClient 命令回复处理器到该连接的写就绪事件,等待客户端可写时在事件循环中再继续回写残余的响应数据。

总结概括:

主线程承包了连接的处理,命令的解析以及数据的收发。使用命令读取处理器监听文件描述符,当读事件发生时,主线程便会到已经读取到数据的缓冲区进行处理,并写入数据到写缓冲区,写完成后便会将client对象添加到就绪队列中。使用事件循环将队列中的数据写回客户端。

redisIO1

其实就是很典型的单reactor模型

Redis 的核心网络模型在 6.0 版本之前,一直是单 Reactor 模式,虽然在 4.0 版本中引入了多线程,但是那个更像是针对特定场景(删除超大 key 值等)而打的补丁,并不能被视作核心网络模型的多线程。

Redis 多线程网络模型(v6.0之后)

前文说到,要提升 Redis 的性能有两个方向:优化网络 I/O 模块,提高机器内存读写的速度。后者由于硬件的限制,暂时无解。

网络 I/O 的优化又可以分为两个方向:零拷贝技术或者 DPDK 技术,利用多核优势。

零拷贝具有局限性,无法完全适配 Redis 这一类复杂的网络 I/O 场景,因此,利用多核优势成为了优化网络 I/O 性价比最高的方案。

RedisIO2

  1. Redis 服务器启动,开启主线程事件循环(Event Loop),注册 acceptTcpHandler 连接应答处理器到用户配置的监听端口对应的文件描述符,等待新连接到来;
  2. 客户端和服务端建立网络连接;
  3. acceptTcpHandler 被调用,主线程将 readQueryFromClient 命令读取处理器绑定到新连接对应的文件描述符上,并初始化一个 client 绑定这个客户端连接;
  4. 客户端发送请求命令,触发读就绪事件,服务端主线程不会通过 socket 去读取客户端的请求命令,而是先将 client 放入一个 LIFO 队列 clients_pending_read
  5. 在事件循环(Event Loop)中,主线程执行 beforeSleep –>handleClientsWithPendingReadsUsingThreads,利用 Round-Robin 轮询策略,把 clients_pending_read队列中的连接均匀地分配给 I/O 线程各自的本地 FIFO 任务队列 io_threads_list[id] 和主线程自己,I/O 线程通过 socket 读取客户端的请求命令,存入 client->querybuf 并解析第一个命令,但不执行命令,主线程忙轮询,等待所有 I/O 线程完成读取任务;
  6. 主线程和所有 I/O 线程都完成了读取任务,主线程结束忙轮询,遍历 clients_pending_read 队列,执行所有客户端连接的请求命令,先调用 processCommandAndResetClient 执行第一条已经解析好的命令,然后调用 processInputBuffer 解析并执行客户端连接的所有命令,在其中使用 processInlineBuffer 或者 processMultibulkBuffer 根据 Redis 协议解析命令,最后调用 processCommand 执行命令;
  7. 根据请求命令的类型(SET, GET, DEL, EXEC 等),分配相应的命令执行器去执行,最后调用 addReply 函数族将响应数据写入到对应 client 的写出缓冲区:client->buf client->reply ,和单线程模型一样,最后把 client 添加进一个 LIFO 队列 clients_pending_write
  8. 在事件循环(Event Loop)中,主线程执行 beforeSleep –> handleClientsWithPendingWritesUsingThreads,利用 Round-Robin 轮询策略,把 clients_pending_write 队列中的连接均匀地分配给 I/O 线程各自的本地 FIFO 任务队列 io_threads_list[id] 和主线程自己,I/O 线程通过调用 writeToClientclient 的写出缓冲区里的数据回写到客户端,主线程忙轮询,等待所有 I/O 线程完成写出任务;
  9. 主线程和所有 I/O 线程都完成了写出任务, 主线程结束忙轮询,遍历 clients_pending_write 队列,如果 client 的写出缓冲区还有数据遗留,则注册 sendReplyToClient 到该连接的写就绪事件,等待客户端可写时在事件循环中再继续回写残余的响应数据。

总结:大部分逻辑和之前的单线程模型是一致的,变动的地方仅仅是把读取客户端请求命令和回写响应数据的逻辑异步化,主线程使用轮询将client对象分配给不同的IO线程,每个IO线程自带一个队列,由IO线程完成请求的读取和数据的写回。需要注意的是:I/O 线程仅仅是读取和解析客户端命令而不会真正去执行命令,客户端命令的执行最终还是要在主线程上完成。

既然是多线程,那么有同步锁吗

Redis 的多线程模式下,是没有对数据进行锁保护的,事实上 Redis 的多线程模型是全程无锁(Lock-free)的。

这是通过原子操作+交错访问来实现的,主线程和 I/O 线程之间共享的变量有三个:io_threads_pending 计数器、io_threads_op I/O 标识符和 io_threads_list 线程本地任务队列。

io_threads_pending 是原子变量,不需要加锁保护,io_threads_opio_threads_list 这两个变量则是通过控制主线程和 I/O 线程交错访问来规避共享数据竞争问题:I/O 线程启动之后会通过忙轮询和锁休眠等待主线程的信号,在这之前它不会去访问自己的本地任务队列 io_threads_list[id]

而主线程会在分配完所有任务到各个 I/O 线程的本地队列之后才去唤醒 I/O 线程开始工作

并且主线程之后在 I/O 线程运行期间只会访问自己的本地任务队列 io_threads_list[0] 而不会再去访问 I/O 线程的本地队列,这也就保证了主线程永远会在 I/O 线程之前访问 io_threads_list 并且之后不再访问,保证了交错访问。

io_threads_op 同理,主线程会在唤醒 I/O 线程之前先设置好 io_threads_op 的值,并且在 I/O 线程运行期间不会再去访问这个变量。

Redis的多线程网络模型和标准的多reactor模式有什么区别?

在标准的 Multi-Reactors/Master-Workers 模式下,Workers 会完成 网络读 -> 数据解析 -> 命令执行 -> 网络写 整套流程,而Master 只负责分派任务。

而在 Redis 的多线程方案中,I/O 线程任务仅仅是通过 socket 读取客户端请求命令并解析,没有真正去执行命令,所有客户端命令最后还需要回到主线程去执行,因此对多核的利用率并不算高,而且每次主线程都必须在分配完任务之后忙轮询等待所有 I/O 线程完成任务之后才能继续执行其他逻辑。

RedisIO3

为什么Redis要使用这种不寻常的线程模型?

即便是多线程,但是也仅仅是针对于网络IO,命令的执行还是只有一个主线程。Redis 之所以如此设计它的多线程网络模型,主要的原因是为了保持兼容性,因为以前 Redis 是单线程的,所有的客户端命令都是在单线程的事件循环里执行的,也因此 Redis 里所有的数据结构都是非线程安全的,现在引入多线程,如果按照标准的 Multi-Reactors/Master-Workers 模式来实现,则所有内置的数据结构都必须重构成线程安全的,这个工作量无疑是巨大且麻烦的。所以Redis 目前的多线程方案更像是一个折中的选择:既保持了原系统的兼容性,又能利用多核提升 I/O 性能。

总结:Redis为其多线程网络模型做了哪些优化?

  • 使用 I/O 线程实现网络 I/O 多线程化,I/O 线程只负责网络 I/O 和命令解析,不执行客户端命令
  • 利用原子操作+交错访问实现无锁的多线程模型
  • 隔离主进程和其他子进程,让多线程网络模型能发挥最大的性能。Redis 通过设置 CPU 亲和性,可以将主进程和子进程绑定到不同的核隔离开来,使之互不干扰

redis和memcached的区别

memcached全部存储于内存中,断电后会挂掉;redis具有持久化机制

Redis具有复杂的数据类型

Redis自己构建了VM机制,一般的系统调用系统函数,会浪费时间去移动和请求

redis的value值最大可以达到1gb,memcached只有1mb


Redis
http://example.com/post/Redis.html
作者
SamuelZhou
发布于
2022年9月24日
许可协议