0%

redis 为什么那么快

Redis 为什么那么快

Redis 的速度为什么那么快?这个问题有点过于宽泛了,必须要确定 redis 的比较对象才方便进行比较。对比传统的硬盘数据库来说,redis 的读写操作大部分都只发生在内存中,不需要昂贵的硬盘 IO;对比较为新型的 NoSQL 数据库而言,这些数据库如 MongoDB 虽然也大量利用了内存缓存,但相比之下,redis 单索引、不保证数据安全性的特点使得 redis 的读写 flow 更加短,因此能够取得更好的效果;相比于同类型的缓存服务,如 memcached,redis 的速度其实并没有太大的优势,这是 redis 的单线程事务模型带来的缺点。

高效的数据结构

redis 是一个基于内存的数据库,因而可以使用更加高效灵活的数据结构——哈希表。由于哈希表的查询时间复杂度为 O(1),在千万级别的数据量下,单次查询性能仍然能够保持在 us 级别。

高效的网络 IO 模型

最新版本的 redis 中,网络 IO 线程与事件处理线程是分离的,是一个非标准的 reactor 模式。这种非标准的设计主要是考虑了复杂度问题,如果使用多线程进行事务处理需要付出高昂的工程代价,并且只能够获得大概 8%左右的性能提升(参考 memcached 与 redis 的性能测试对比)。

在引入多线程 IO 后,仍然只有主线程是负责 epoll 的逻辑的,流程可以简述如下:

1、主线程负责接收建立连接请求,获取 socket 放入全局等待读处理队列
2、主线程处理完读事件之后,通过 RR(Round Robin) 将这些连接分配给这些 IO 线程
3、主线程阻塞等待 IO 线程读取 socket 完毕
4、主线程通过单线程的方式执行请求命令,请求数据读取并解析完成,但并不执行
5、主线程阻塞等待 IO 线程将数据回写 socket 完毕
6、解除绑定,清空等待队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 主线程
while(true){
if 有可读 socket{
平均分配 socket 给 IO 线程组
}
等待所有 IO 线程组读取完成

按照次序执行所有的 IO 请求

等待所有 IO 线程组写入完成
}

// IO 线程
while(true){

等待主线程分配 IO 读事件

通知主线程读取完成(原子操作)

等待主线程分配 IO 读事件

通知主线程写入完成(原子操作)
}

redis 这里的 IO 事件是按组进行的,主线程会阻塞等待一组中所有的 IO 事件读或者写完成。这里主要是为了防止事件乱序执行,否则可以等待单个 IO 完成后就执行任务。由于 IO 过程非常快,所以这里的性能损耗基本可以忽略不计。

成熟的事件调度机制

redis 的事件主要可以分为 IO 事件、定时事件、后台线程事件这三种,主循环只负责处理 IO 事件和定时事件,这两种事件的处理速度较快因而可以放在一起处理。

在 redis 的主循环处理流程伪代码可以表示如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
while(true){
最大等待时间 = 最近定时任务 - 当前时间

if 有IO事件可以处理 {
if IO为轻量级任务
处理 IO 事件
else
放入后台线程处理
} else {
休眠最大等待时间
}

if 有定时任务可以处理
处理定时任务
}

由于我接触到 redis 比较早,在初学时比较困惑,为什么这种循环处理的逻辑能够保证客户端请求的高效处理,并且如何保证定时任务及时处理的。

首先说明如何保证定时任务的处理。在主循环处理流程中,IO 事件即客户端请求事件是优先处理的,并不保证定时事件一定能够及时处理,但是这种延迟是非常细微的。以 std::unordered_map 为例,在 1000W 级别的数据中,平均的单条读取时间是 1us 级别的,因此单个 IO 事件即使是计算了读取和写入 socket 所耗费的时间,通常也是不足 1ms 的,只会是 10 us 级别。在这种操作速度的数量级下,即使是同一时间到达万级请求,也不会对定时任务造成秒级别的延迟,这是完全可以接受的。另外,受限于物理机器的网卡速度、协议栈复制速度等因素,一次主循环中并不会到达过多的请求。因此,完全不必担心定时任务会因为短时间内出现大量 IO 请求而延迟较大。

既然用户 IO 请求不会过多影响定时任务的触发时机,那么定时任务显然也不会影响到 IO 事件的处理。redis 中的定时任务基本上可以分为以下几种:

  • 过期键的删除操作
  • 更新服务器状态
  • 清理过期和失效的客户端
  • 尝试开始持久化操作
  • 与其他服务器同步消息

redis 中这些定时任务的执行时间通常更短,因为任务所查询的数据量级是远远小于 redis 数据库的数据量级的,并且 redis 内部具有计时机制来主动防止定时任务消耗过多时间。这些定时任务会每隔 100ms 执行一次,每次的总执行时间在 ms 级别,不会对客户端请求造成很大的延迟。

归根结底,redis 采用的这种事件循环调度机制,是考虑到单个事件的执行时间非常短,这主要还是由于所有的操作都只与内存进行交互,现代计算机的内存速度是非常快的。

后台线程

redis 中比较重量级的事件会考虑放入到后台线程中来完成,防止主循环的单次循环时间过长,使客户端请求延迟增加。会放入到后台线程中的事件主要有:

  • 大键的惰性删除
  • AOF 持久化刷盘
  • 关闭 file descriptor

这些事件在执行过程中,可能产生的耗时是毫秒级别的,与其他的事件的时间相差过大,直接在主线程中执行可能会造成循环时间过长。

redis 中是通过队列的方式来实现主线程与后台线程之间的任务分配的,以上三个事件都有各自的队列,这是为了防止某一个类型的后台任务过多,从而阻塞了其他种类任务的执行。每次后台线程选择任务执行时会采用一定的策略公平公正地对三种任务执行,防止某一种事件过长时间无法执行。

合适的高可用架构

redis 官方提供了哨兵模式的高可用架构,这是一个比较松散的高可用方案。相比于 etcd、MongoDB 中的高可用实现,redis 的哨兵模式会舍弃一部分的数据安全性来换取更高的性能。

哨兵模式其本质上是一个能够自动切换节点的主从复制结构。同其他数据库的主从复制结构类似,redis 中的主从复制也是异步复制的过程。异步复制的好处是性能较高,不会过多影响主节点的吞吐量,这正是 redis 所需要的,但缺点就是会带来数据延迟,当主节点下线可能会造成一部分数据的丢失。但由于 redis 所存储的数据本身不需要过高的安全性,所以 redis 完全可以舍弃一部分安全性来换取更高的吞吐量。并且这种因主节点下线造成的数据丢失数量是非常少的。

如果引入 raft、paxos 等选举算法,虽然能够保证更高的可用性,但是会造成非常严重的性能劣化。想象一下,引入这些选举算法,所有的客户端请求相当于是一个 2pc,需要引入主节点-从节点的通信延迟、从节点提交的延迟,这里性能可能会折半甚至更低。这就是为什么 redis 并没有引入选举算法——一切为了性能。

哨兵模式的高可用体现在,故障自动恢复的容灾而不是数据一致性的保障。